@vincentbanana/bananacli 0.3.0 → 0.4.0

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/ui/app.js ADDED
@@ -0,0 +1,1253 @@
1
+ // Banana GUI:零依赖前端。只用自绘 SVG 图标,无 emoji,无第三方 UI。
2
+ // 注意结尾斜杠:API 是前缀,所有请求都直接拼名字(如 API + 'action')
3
+ const API = location.pathname.replace(/\/+$/, '') + '/api/';
4
+
5
+ // ---------------- 自绘图标 ----------------
6
+ const ICONS = {
7
+ plus: '<path d="M12 5v14M5 12h14"/>',
8
+ send: '<path d="M12 19V5M6 11l6-6 6 6"/>',
9
+ stop: '<rect x="7" y="7" width="10" height="10" rx="1.5"/>',
10
+ chip: '<rect x="7" y="7" width="10" height="10" rx="2"/><path d="M10 4v3M14 4v3M10 17v3M14 17v3M4 10h3M4 14h3M17 10h3M17 14h3"/>',
11
+ sliders: '<path d="M4 8h8M16 8h4M4 16h4M12 16h8"/><circle cx="14" cy="8" r="2"/><circle cx="10" cy="16" r="2"/>',
12
+ undo: '<path d="M9 8H4m0 0 3.5-3.5M4 8h9a6 6 0 0 1 0 12h-4"/>',
13
+ trash: '<path d="M5 7h14M10 7V5h4v2M7 7l1 13h8l1-13"/>',
14
+ x: '<path d="M6 6l12 12M18 6 6 18"/>',
15
+ shield: '<path d="M12 3.5l7 2.5V12c0 4.2-2.9 7.4-7 8.5-4.1-1.1-7-4.3-7-8.5V6z"/>',
16
+ file: '<path d="M9 3.5h5l4.5 4.5v12.5H9z"/><path d="M14 3.5V8h4.5"/>',
17
+ terminal: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 10l2.5 2L7 14M12.5 14.5H16"/>',
18
+ search: '<circle cx="11" cy="11" r="6"/><path d="M15.5 15.5 20 20"/>',
19
+ globe: '<circle cx="12" cy="12" r="8"/><path d="M4 12h16M12 4c2.4 2.4 2.4 13.6 0 16-2.4-2.4-2.4-13.6 0-16z"/>',
20
+ list: '<path d="M4 6h16M4 12h16M4 18h10"/>',
21
+ pencil: '<path d="M4 20l1-4.5L16.5 4l3.5 3.5L8.5 19z"/>',
22
+ image: '<rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/><path d="M5 17l4.5-4.5L13 16l3-3 4 4"/>',
23
+ check: '<path d="M5 12.5l4.5 4.5L19 7"/>',
24
+ folder: '<path d="M4 6.5h5.5L12 9h8v10.5H4z"/>',
25
+ message: '<path d="M4 5.5h16v11H9.5L4 20z"/>',
26
+ listCheck: '<path d="M4 7h10M4 12h10M4 17h6M17 15.5l1.5 1.5L22 13"/>',
27
+ dots: '<circle cx="6" cy="12" r="1.4"/><circle cx="12" cy="12" r="1.4"/><circle cx="18" cy="12" r="1.4"/>',
28
+ panel: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M14 5v14"/>',
29
+ chevron: '<path d="M9 6l6 6-6 6"/>',
30
+ download: '<path d="M12 4v10M8 11l4 4 4-4M5 19h14"/>',
31
+ sun: '<circle cx="12" cy="12" r="4"/><path d="M12 3v2M12 19v2M3 12h2M19 12h2M5.6 5.6l1.4 1.4M17 17l1.4 1.4M18.4 5.6L17 7M7 17l-1.4 1.4"/>',
32
+ moon: '<path d="M20 14.5A8 8 0 019.5 4a8 8 0 1010.5 10.5z"/>',
33
+ contrast: '<circle cx="12" cy="12" r="8"/><path d="M12 4v16"/><path d="M12 4a8 8 0 010 16" fill="currentColor" stroke="none"/>',
34
+ };
35
+
36
+ const TOOL_ICON = {
37
+ read_file: 'file', write_file: 'pencil', edit_file: 'pencil', list_dir: 'folder',
38
+ glob: 'search', grep: 'search', shell: 'terminal', web_search: 'globe',
39
+ web_fetch: 'globe', todo_write: 'listCheck', todo_read: 'listCheck',
40
+ };
41
+
42
+ function svg(name) {
43
+ return `<svg viewBox="0 0 24 24" aria-hidden="true">${ICONS[name] ?? ICONS.file}</svg>`;
44
+ }
45
+
46
+ const $ = (id) => document.getElementById(id);
47
+
48
+ // ---------------- Markdown(够用即可) ----------------
49
+ const escapeHtml = (text) =>
50
+ text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
51
+
52
+ function inline(text) {
53
+ return escapeHtml(text)
54
+ .replace(/`([^`\n]+)`/g, '<code>$1</code>')
55
+ .replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>')
56
+ .replace(/(^|[\s(])\*([^*\n]+)\*/g, '$1<em>$2</em>')
57
+ .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
58
+ }
59
+
60
+ function markdown(source) {
61
+ const lines = source.replace(/\r\n/g, '\n').split('\n');
62
+ const out = [];
63
+ let i = 0;
64
+ while (i < lines.length) {
65
+ const line = lines[i];
66
+ if (/^```/.test(line)) {
67
+ const lang = line.slice(3).trim();
68
+ const body = [];
69
+ i += 1;
70
+ while (i < lines.length && !/^```/.test(lines[i])) { body.push(lines[i]); i += 1; }
71
+ i += 1;
72
+ out.push(`<pre class="code"><code data-lang="${escapeHtml(lang)}">${escapeHtml(body.join('\n'))}</code></pre>`);
73
+ continue;
74
+ }
75
+ if (/^\s*$/.test(line)) { i += 1; continue; }
76
+ if (/^###\s/.test(line)) { out.push(`<h3>${inline(line.replace(/^###\s/, ''))}</h3>`); i += 1; continue; }
77
+ if (/^##\s/.test(line)) { out.push(`<h2>${inline(line.replace(/^##\s/, ''))}</h2>`); i += 1; continue; }
78
+ if (/^#\s/.test(line)) { out.push(`<h1>${inline(line.replace(/^#\s/, ''))}</h1>`); i += 1; continue; }
79
+ if (/^\s*[-*]\s/.test(line)) {
80
+ const items = [];
81
+ while (i < lines.length && /^\s*[-*]\s/.test(lines[i])) { items.push(lines[i].replace(/^\s*[-*]\s/, '')); i += 1; }
82
+ out.push(`<ul>${items.map((item) => `<li>${inline(item)}</li>`).join('')}</ul>`);
83
+ continue;
84
+ }
85
+ if (/^\s*\d+\.\s/.test(line)) {
86
+ const items = [];
87
+ while (i < lines.length && /^\s*\d+\.\s/.test(lines[i])) { items.push(lines[i].replace(/^\s*\d+\.\s/, '')); i += 1; }
88
+ out.push(`<ol>${items.map((item) => `<li>${inline(item)}</li>`).join('')}</ol>`);
89
+ continue;
90
+ }
91
+ if (/^>\s?/.test(line)) {
92
+ const items = [];
93
+ while (i < lines.length && /^>\s?/.test(lines[i])) { items.push(lines[i].replace(/^>\s?/, '')); i += 1; }
94
+ out.push(`<blockquote>${items.map((item) => inline(item)).join('<br/>')}</blockquote>`);
95
+ continue;
96
+ }
97
+ const para = [line];
98
+ i += 1;
99
+ while (i < lines.length && !/^(`|#{1,3}\s|>\s?|\s*[-*]\s|\s*\d+\.\s)/.test(lines[i]) && !/^\s*$/.test(lines[i])) {
100
+ para.push(lines[i]);
101
+ i += 1;
102
+ }
103
+ out.push(`<p>${inline(para.join(' '))}</p>`);
104
+ }
105
+ return out.join('');
106
+ }
107
+
108
+ // ---------------- 状态 ----------------
109
+ const state = { info: null, sessions: [], attachments: [], busy: false, pendingApproval: null };
110
+
111
+ const THREAD = $('thread');
112
+
113
+ // ---------------- 渲染 ----------------
114
+ function hydrate(root = document) {
115
+ root.querySelectorAll('[data-icon]').forEach((node) => {
116
+ if (node.dataset.iconDone) return;
117
+ node.innerHTML = svg(node.dataset.icon);
118
+ node.dataset.iconDone = '1';
119
+ });
120
+ }
121
+
122
+ function relativeTime(iso) {
123
+ if (!iso) return '';
124
+ const diff = Date.now() - new Date(iso).getTime();
125
+ const minutes = Math.round(diff / 60000);
126
+ if (minutes < 1) return '刚刚';
127
+ if (minutes < 60) return `${minutes} 分钟前`;
128
+ const hours = Math.round(minutes / 60);
129
+ if (hours < 24) return `${hours} 小时前`;
130
+ return `${Math.round(hours / 24)} 天前`;
131
+ }
132
+
133
+ /** 窄屏下侧边栏是覆盖层,选完会话必须收起来,否则挡住对话。 */
134
+ function closeSidebarIfOverlay() {
135
+ if (window.matchMedia('(max-width: 900px)').matches) $('sidebar').classList.remove('open');
136
+ }
137
+
138
+ function renderSidebar() {
139
+ const list = $('sessionList');
140
+ list.innerHTML = '';
141
+ for (const item of state.sessions) {
142
+ const active = item.id === state.info?.session?.id ? ' is-active' : '';
143
+ const node = document.createElement('div');
144
+ node.className = `session-item${active}`;
145
+ node.innerHTML =
146
+ `<span class="s-actions">` +
147
+ `<span class="s-act" data-menu="${item.id}" title="更多">${svg('dots')}</span>` +
148
+ `<span class="s-act" data-del="${item.id}" title="删除">${svg('trash')}</span>` +
149
+ `</span>` +
150
+ `<span class="s-title">${escapeHtml(item.title || '新对话')}</span>` +
151
+ `<span class="s-meta">${item.turns} 轮 · ${relativeTime(item.updatedAt)}</span>`;
152
+
153
+ node.addEventListener('click', (event) => {
154
+ if (event.target.closest('[data-del], [data-menu]')) return;
155
+ post('session', { action: 'switch', ref: item.id }).then(() => {
156
+ refresh(true);
157
+ closeSidebarIfOverlay();
158
+ });
159
+ });
160
+
161
+ node.querySelector('[data-del]').addEventListener('click', (event) => {
162
+ event.stopPropagation();
163
+ post('session', { action: 'delete', ref: item.id }).then(() => refresh(true));
164
+ });
165
+
166
+ node.querySelector('[data-menu]').addEventListener('click', (event) => {
167
+ event.stopPropagation();
168
+ openSessionMenu(item, node);
169
+ });
170
+ list.appendChild(node);
171
+ }
172
+ }
173
+
174
+ /** 会话操作菜单:切换 / 重命名 / 导出 Markdown / 导出 JSON */
175
+ function openSessionMenu(item, anchor) {
176
+ document.querySelector('.popover')?.remove();
177
+ const rect = anchor.getBoundingClientRect();
178
+ const pop = document.createElement('div');
179
+ pop.className = 'popover';
180
+ pop.innerHTML = `
181
+ <button data-act="switch">切换到这一轮</button>
182
+ <button data-act="rename">重命名</button>
183
+ <button data-act="md">导出 Markdown</button>
184
+ <button data-act="json">导出 JSON</button>`;
185
+ document.body.appendChild(pop);
186
+ // 先量再定位:贴着按钮右边缘,并且始终留在视口内
187
+ pop.style.visibility = 'hidden';
188
+ pop.style.left = '0px';
189
+ pop.style.top = '0px';
190
+ const width = pop.offsetWidth;
191
+ const height = pop.offsetHeight;
192
+ const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8));
193
+ const top = rect.bottom + height > window.innerHeight - 8 ? Math.max(8, rect.top - height - 4) : rect.bottom + 4;
194
+ pop.style.left = `${left}px`;
195
+ pop.style.top = `${top}px`;
196
+ pop.style.visibility = '';
197
+
198
+ const close = () => {
199
+ pop.remove();
200
+ document.removeEventListener('click', close, true);
201
+ };
202
+ document.addEventListener('click', close, true);
203
+
204
+ pop.addEventListener('click', async (event) => {
205
+ const action = event.target.closest('[data-act]')?.dataset.act;
206
+ if (!action) return;
207
+ event.stopPropagation();
208
+ close();
209
+ if (action === 'switch') {
210
+ await post('session', { action: 'switch', ref: item.id });
211
+ await refresh(true);
212
+ closeSidebarIfOverlay();
213
+ } else if (action === 'rename') {
214
+ const title = prompt('新的标题', item.title || '');
215
+ if (title && title.trim()) {
216
+ await post('session', { action: 'switch', ref: item.id });
217
+ await post('session', { action: 'rename', title: title.trim() });
218
+ await refresh(false);
219
+ toast('已重命名');
220
+ }
221
+ } else {
222
+ await post('session', { action: 'switch', ref: item.id });
223
+ await refresh(true);
224
+ downloadSession(action === 'json' ? 'json' : 'md', item.id);
225
+ }
226
+ });
227
+ }
228
+
229
+ function sessionToMarkdown(messages, title) {
230
+ const lines = [`# ${title || '对话'}`, '', `导出时间:${new Date().toLocaleString()}`, ''];
231
+ for (const message of messages) {
232
+ if (message.role === 'tool') continue;
233
+ lines.push(message.role === 'user' ? '## 你' : '## Banana', '');
234
+ lines.push(message.content || '(空)');
235
+ if (message.toolCalls?.length) {
236
+ lines.push('', '工具调用:');
237
+ for (const call of message.toolCalls) {
238
+ lines.push(`- \`${call.name}\` ${JSON.stringify(call.input ?? {}).slice(0, 200)}`);
239
+ }
240
+ }
241
+ lines.push('');
242
+ }
243
+ return lines.join('\n');
244
+ }
245
+
246
+ function downloadSession(format, id) {
247
+ const session = state.info.session;
248
+ const blob =
249
+ format === 'json'
250
+ ? JSON.stringify(session, null, 2)
251
+ : sessionToMarkdown(session.messages, session.title);
252
+ const blobUrl = URL.createObjectURL(new Blob([blob], { type: format === 'json' ? 'application/json' : 'text/markdown' }));
253
+ const link = document.createElement('a');
254
+ link.href = blobUrl;
255
+ link.download = `${(session.title || id).replace(/[\\/:*?"<>|]/g, '_').slice(0, 40)}.${format}`;
256
+ link.click();
257
+ setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
258
+ toast(`已导出 ${format === 'json' ? 'JSON' : 'Markdown'}`);
259
+ }
260
+
261
+ function toolGroup(calls, results) {
262
+ const wrap = document.createElement('div');
263
+ wrap.className = 'tool-group';
264
+ for (const call of calls) {
265
+ const result = results.get(call.id);
266
+ const row = document.createElement('div');
267
+ row.className = `tool-row${result && result.isError ? ' is-error' : ''}`;
268
+ const detail = JSON.stringify(call.input ?? {}).slice(0, 90);
269
+ row.innerHTML =
270
+ `<span class="t-ico">${svg(TOOL_ICON[call.name] ?? 'file')}</span>` +
271
+ `<span class="t-name">${escapeHtml(call.name)}</span>` +
272
+ `<span class="t-detail">${escapeHtml(detail)}</span>` +
273
+ `<span class="t-state">${result ? (result.isError ? '失败' : escapeHtml(result.content?.slice(0, 40) ?? '完成')) : '已调用'}</span>`;
274
+ wrap.appendChild(row);
275
+ }
276
+ return wrap;
277
+ }
278
+
279
+ function renderMessage(message) {
280
+ if (message.role === 'tool') return null;
281
+ const node = document.createElement('article');
282
+ node.className = `msg ${message.role}`;
283
+ const role = document.createElement('div');
284
+ role.className = 'msg-role';
285
+ role.textContent = message.role === 'user' ? '你' : 'Banana';
286
+ const body = document.createElement('div');
287
+ body.className = 'msg-body';
288
+ if (message.role === 'user') {
289
+ body.textContent = message.content;
290
+ } else {
291
+ body.innerHTML = markdown(message.content || '');
292
+ if (message.toolCalls?.length) {
293
+ const results = new Map(
294
+ (state.info?.session?.messages ?? []).filter((m) => m.role === 'tool').map((m) => [m.toolCallId, m]),
295
+ );
296
+ body.insertBefore(toolGroup(message.toolCalls, results), body.firstChild);
297
+ }
298
+ }
299
+ node.append(role, body);
300
+ return node;
301
+ }
302
+
303
+ function renderEmpty() {
304
+ const wrap = document.createElement('div');
305
+ wrap.className = 'empty';
306
+ // 第一次用、或者还没配 key 时,给一条能照着做的引导
307
+ const provider = state.info?.provider;
308
+ const current = state.info?.providers?.find((item) => item.current);
309
+ const hasKey = Boolean(current?.keyReady);
310
+ const banner = hasKey
311
+ ? ''
312
+ : `<div class="empty-warn">
313
+ <strong>还差一步:填上 API Key</strong>
314
+ <span>没有 key 就没法对话。点下面的按钮,填进去立刻就能用(key 只存在你自己的电脑上)。</span>
315
+ <button class="btn primary" id="emptySetup">现在去填</button>
316
+ </div>`;
317
+ wrap.innerHTML =
318
+ '<h1>要做什么</h1>' +
319
+ '<p>读写文件、跑命令、查资料,都可以。默认每次都要你确认。</p>' +
320
+ banner +
321
+ '<div class="suggest">' +
322
+ ['这个仓库是做什么的', '跑一下测试并修复失败', '给我一个重构方案,先别改代码', '把 README 翻译成英文']
323
+ .map((text) => `<button>${escapeHtml(text)}</button>`)
324
+ .join('') +
325
+ '</div>' +
326
+ `<div class="empty-tips">
327
+ <span><b>/</b> 调命令(/help 看全部)</span>
328
+ <span><b>@</b> 引用项目里的文件</span>
329
+ <span><b>${escapeHtml(provider?.name ?? '')}</b> 当前模型</span>
330
+ </div>`;
331
+ wrap.querySelectorAll('.suggest button').forEach((btn) => {
332
+ btn.addEventListener('click', () => { $('input').value = btn.textContent; $('input').focus(); grow(); });
333
+ });
334
+ const setup = wrap.querySelector('#emptySetup');
335
+ if (setup) setup.addEventListener('click', openSettings);
336
+ return wrap;
337
+ }
338
+
339
+ function renderThread() {
340
+ THREAD.innerHTML = '';
341
+ const messages = state.info?.session?.messages ?? [];
342
+ if (messages.length === 0) THREAD.appendChild(renderEmpty());
343
+ else {
344
+ const divider = document.createElement('div');
345
+ divider.className = 'thread-divider';
346
+ THREAD.appendChild(divider);
347
+ }
348
+ for (const message of messages) {
349
+ const node = renderMessage(message);
350
+ if (node) THREAD.appendChild(node);
351
+ }
352
+ renderTurnMeta();
353
+ scrollToEnd();
354
+ }
355
+
356
+ function renderTurnMeta() {
357
+ const meta = $('threadMeta');
358
+ const usage = state.info?.session?.usage;
359
+ if (!usage || (!usage.inputTokens && !usage.outputTokens)) { meta.textContent = ''; return; }
360
+ const parts = [`↑${usage.inputTokens} ↓${usage.outputTokens}`];
361
+ if (usage.costUsd) parts.push(usage.costUsd.toFixed(4) + ' USD');
362
+ meta.textContent = parts.join(' · ');
363
+ }
364
+
365
+ function scrollToEnd(smooth = true) {
366
+ requestAnimationFrame(() => {
367
+ THREAD.scrollTo({ top: THREAD.scrollHeight, behavior: smooth ? 'smooth' : 'auto' });
368
+ });
369
+ }
370
+
371
+ function renderHeader() {
372
+ const info = state.info;
373
+ if (!info) return;
374
+ $('threadTitle').textContent = info.session.title || '新对话';
375
+ $('modelLabel').textContent = `${info.provider.name} / ${info.provider.model}`;
376
+ $('composerHint').textContent =
377
+ `工作区 ${info.workspace} · ${info.tools.length} 个工具 · shell=${info.permissions.shell} write=${info.permissions.write} network=${info.permissions.network}`;
378
+ renderTurnMeta();
379
+ }
380
+
381
+ let toastTimer = null;
382
+ function toast(text) {
383
+ const node = $('toast');
384
+ node.textContent = text;
385
+ node.hidden = false;
386
+ requestAnimationFrame(() => node.classList.add('show'));
387
+ clearTimeout(toastTimer);
388
+ toastTimer = setTimeout(() => {
389
+ node.classList.remove('show');
390
+ setTimeout(() => { node.hidden = true; }, 200);
391
+ }, 2200);
392
+ }
393
+
394
+ // ---------------- 请求 ----------------
395
+ async function get(path) {
396
+ const res = await fetch(API + path);
397
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
398
+ return res.json();
399
+ }
400
+
401
+ async function post(path, body) {
402
+ const res = await fetch(API + path, {
403
+ method: 'POST',
404
+ headers: { 'content-type': 'application/json' },
405
+ body: JSON.stringify(body ?? {}),
406
+ });
407
+ if (!res.ok) {
408
+ let message = res.statusText;
409
+ try {
410
+ message = (await res.json()).error ?? message;
411
+ } catch {}
412
+ throw new Error(message);
413
+ }
414
+ return res.json();
415
+ }
416
+
417
+ async function refresh(path) {
418
+ const info = await get('info');
419
+ state.info = info;
420
+ const { sessions } = await get('sessions');
421
+ state.sessions = sessions;
422
+ renderHeader();
423
+ renderSidebar();
424
+ if (path) renderThread();
425
+ }
426
+
427
+ /** 设置面板里的改动要立刻反映在面板上,所以改完重画面板内容。 */
428
+ async function refreshSettings() {
429
+ await refresh(false);
430
+ if (!$('settingsSheet').hidden) renderSettings();
431
+ }
432
+
433
+ // ---------------- 聊天流 ----------------
434
+ let controller = null;
435
+
436
+ function grow() {
437
+ const input = $('input');
438
+ input.style.height = 'auto';
439
+ input.style.height = Math.min(input.scrollHeight, 220) + 'px';
440
+ $('sendBtn').disabled = state.busy || input.value.trim() === '';
441
+ }
442
+
443
+ function setBusy(busy) {
444
+ state.busy = busy;
445
+ $('abortBtn').hidden = !busy;
446
+ $('sendBtn').disabled = busy;
447
+ document.body.style.cursor = busy ? 'progress' : '';
448
+ }
449
+
450
+ function askApproval(payload) {
451
+ return new Promise((resolve) => {
452
+ state.pendingApproval = resolve;
453
+ $('approvalKind').textContent = payload.kind === 'shell' ? '确认执行命令' : payload.kind === 'write' ? '确认写入文件' : '确认网络请求';
454
+ $('approvalSummary').textContent = payload.summary;
455
+ const preview = $('approvalPreview');
456
+ preview.textContent = payload.preview ?? '';
457
+ preview.hidden = !payload.preview;
458
+ $('approvalModal').hidden = false;
459
+ $('approvalYes').focus();
460
+ });
461
+ }
462
+
463
+ function closeApproval(allowed) {
464
+ $('approvalModal').hidden = true;
465
+ const resolve = state.pendingApproval;
466
+ state.pendingApproval = null;
467
+ if (resolve) resolve(allowed);
468
+ }
469
+
470
+ async function send() {
471
+ const input = $('input');
472
+ const text = input.value.trim();
473
+ if (!text || state.busy) return;
474
+ input.value = '';
475
+ grow();
476
+ closePalette();
477
+ // 斜杠开头的是命令,不走对话
478
+ if (text.startsWith('/')) {
479
+ await runCommand(text);
480
+ grow();
481
+ return;
482
+ }
483
+
484
+ if (state.info.session.messages.length === 0 && state.info.session.title === '新对话') {
485
+ state.info.session.title = text.slice(0, 40);
486
+ renderHeader();
487
+ }
488
+ renderEmpty();
489
+
490
+ const userMsg = document.createElement('article');
491
+ userMsg.className = 'msg user';
492
+ userMsg.innerHTML = '<div class="msg-role">你</div>';
493
+ const userBody = document.createElement('div');
494
+ userBody.className = 'msg-body';
495
+ userBody.textContent = text;
496
+ userMsg.appendChild(userBody);
497
+ THREAD.appendChild(userMsg);
498
+
499
+ const botMsg = document.createElement('article');
500
+ botMsg.className = 'msg assistant';
501
+ botMsg.innerHTML = '<div class="msg-role">Banana</div>';
502
+ const toolWrap = document.createElement('div');
503
+ toolWrap.className = 'tool-group';
504
+ const reasoning = document.createElement('div');
505
+ reasoning.className = 'reasoning';
506
+ reasoning.hidden = true;
507
+ const body = document.createElement('div');
508
+ body.className = 'msg-body caret';
509
+ const meta = document.createElement('div');
510
+ meta.className = 'turn-meta';
511
+ botMsg.append(toolWrap, reasoning, body, meta);
512
+ THREAD.appendChild(botMsg);
513
+
514
+ const images = state.attachments.map((item) => item.path);
515
+ state.attachments = [];
516
+ renderAttachments();
517
+
518
+ let buffer = '';
519
+ let pending = 0;
520
+ let streamEnded = false;
521
+ const paint = () => {
522
+ pending = 0;
523
+ body.textContent = buffer;
524
+ scrollToEnd(false);
525
+ };
526
+ const schedule = () => {
527
+ if (!pending) pending = requestAnimationFrame(paint);
528
+ };
529
+
530
+ setBusy(true);
531
+ controller = new AbortController();
532
+ const metaLine = (label, value) => {
533
+ const span = document.createElement('span');
534
+ span.textContent = `${label} ${value}`;
535
+ meta.appendChild(span);
536
+ };
537
+
538
+ try {
539
+ const res = await fetch(API + 'chat', {
540
+ method: 'POST',
541
+ headers: { 'content-type': 'application/json' },
542
+ body: JSON.stringify({ message: text, images }),
543
+ signal: controller.signal,
544
+ });
545
+ if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
546
+
547
+ const reader = res.body.getReader();
548
+ const decoder = new TextDecoder();
549
+ let bufferRaw = '';
550
+ while (true) {
551
+ const { done, value } = await reader.read();
552
+ if (done) break;
553
+ bufferRaw += decoder.decode(value, { stream: true });
554
+ let cut = bufferRaw.indexOf('\n\n');
555
+ while (cut !== -1) {
556
+ const chunk = bufferRaw.slice(0, cut);
557
+ bufferRaw = bufferRaw.slice(cut + 2);
558
+ cut = bufferRaw.indexOf('\n\n');
559
+ for (const line of chunk.split('\n')) {
560
+ if (!line.startsWith('data: ')) continue;
561
+ const event = JSON.parse(line.slice(6));
562
+ if (event.type === 'text') { buffer += event.chunk; schedule(); }
563
+ else if (event.type === 'reasoning') { reasoning.hidden = false; reasoning.textContent += event.chunk; }
564
+ else if (event.type === 'tool_start') {
565
+ const row = document.createElement('div');
566
+ row.className = 'tool-row';
567
+ row.innerHTML =
568
+ `<span class="t-ico">${svg(TOOL_ICON[event.name] ?? 'file')}</span>` +
569
+ `<span class="t-name">${escapeHtml(event.name)}</span>` +
570
+ `<span class="t-detail">${escapeHtml(event.detail)}</span>` +
571
+ '<span class="t-state"><span class="spin"></span></span>';
572
+ toolWrap.appendChild(row);
573
+ scrollToEnd(false);
574
+ } else if (event.type === 'tool_end') {
575
+ const rows = toolWrap.querySelectorAll('.tool-row');
576
+ const row = rows[rows.length - 1];
577
+ if (row) {
578
+ row.querySelector('.t-state').innerHTML = event.ok ? svg('check') : svg('x');
579
+ row.classList.toggle('is-error', !event.ok);
580
+ if (event.detail) {
581
+ row.classList.add('is-clickable');
582
+ row.title = '点击展开输出';
583
+ row.addEventListener('click', () => toggleToolOutput(row, event.detail, !event.ok));
584
+ }
585
+ }
586
+ } else if (event.type === 'approval') {
587
+ const allowed = await askApproval(event);
588
+ await post('approve', { id: event.id, allowed });
589
+ } else if (event.type === 'error') {
590
+ // 服务端提前收尾(没配 key、请求出错等):给出能照做的下一步
591
+ body.classList.remove('caret');
592
+ body.innerHTML = markdown(`**${escapeHtml(event.message ?? '出错了')}**`);
593
+ if (event.hint) metaLine('提示', event.hint);
594
+ if (event.code === 'no_api_key') openSettings();
595
+ streamEnded = true;
596
+ } else if (event.type === 'done') {
597
+ body.classList.remove('caret');
598
+ streamEnded = true;
599
+ if (event.error) body.innerHTML = markdown(`**请求失败** ${escapeHtml(event.error)}`);
600
+ else if (!buffer.trim()) body.innerHTML = markdown('这一轮没有返回内容。');
601
+ else body.innerHTML = markdown(buffer);
602
+ metaLine('步数', event.steps);
603
+ metaLine('用时', (event.ms / 1000).toFixed(1) + 's');
604
+ if (event.cost && event.cost !== '≈$0') metaLine('花费', event.cost);
605
+ state.info = event.info;
606
+ state.info.session.messages = [...state.info.session.messages];
607
+ state.info.session.messages[state.info.session.messages.length - 1] = {
608
+ role: 'assistant',
609
+ content: buffer,
610
+ };
611
+ const { sessions } = await get('sessions');
612
+ state.sessions = sessions;
613
+ renderSidebar();
614
+ renderHeader();
615
+ }
616
+ }
617
+ }
618
+ }
619
+ // 流结束了却没收到 done/error:多半是被中断,别让界面一直转圈
620
+ if (!streamEnded) {
621
+ body.classList.remove('caret');
622
+ if (body.textContent === '' && toolWrap.childElementCount === 0) {
623
+ body.innerHTML = markdown('这一轮没有返回内容。可以重试,或点「停止」后换个说法。');
624
+ }
625
+ await refresh();
626
+ }
627
+ } catch (error) {
628
+ body.classList.remove('caret');
629
+ body.innerHTML = markdown(`**中断** ${escapeHtml(error.message)}`);
630
+ await refresh();
631
+ } finally {
632
+ setBusy(false);
633
+ scrollToEnd();
634
+ }
635
+ }
636
+
637
+ // ---------------- 主题 ----------------
638
+ // 三态:跟随系统 / 浅色 / 深色,选择记在本地
639
+ const THEMES = ['auto', 'light', 'dark'];
640
+ const THEME_LABEL = { auto: '跟随系统', light: '浅色', dark: '深色' };
641
+ const THEME_ICON = { auto: 'contrast', light: 'sun', dark: 'moon' };
642
+
643
+ function applyTheme(theme) {
644
+ if (theme === 'auto') delete document.documentElement.dataset.theme;
645
+ else document.documentElement.dataset.theme = theme;
646
+ try {
647
+ localStorage.setItem('banana-theme', theme);
648
+ } catch {}
649
+ const button = $('themeBtn');
650
+ if (button) {
651
+ button.innerHTML = svg(THEME_ICON[theme]);
652
+ button.title = `主题:${THEME_LABEL[theme]}(点击切换)`;
653
+ }
654
+ }
655
+
656
+ function loadTheme() {
657
+ let saved = 'auto';
658
+ try {
659
+ saved = localStorage.getItem('banana-theme') ?? 'auto';
660
+ } catch {}
661
+ if (!THEMES.includes(saved)) saved = 'auto';
662
+ applyTheme(saved);
663
+ }
664
+
665
+ function cycleTheme() {
666
+ const current = (() => {
667
+ try {
668
+ return localStorage.getItem('banana-theme') ?? 'auto';
669
+ } catch {
670
+ return 'auto';
671
+ }
672
+ })();
673
+ const next = THEMES[(THEMES.indexOf(current) + 1) % THEMES.length];
674
+ applyTheme(next);
675
+ toast(`主题:${THEME_LABEL[next]}`);
676
+ }
677
+
678
+ // ---------------- 补全面板:/ 命令与 @ 文件共用一套 ----------------
679
+ let commandList = [];
680
+
681
+ function renderCommandPalette(query) {
682
+ const box = $('commandPalette');
683
+ const term = query.replace(/^\//, '').toLowerCase();
684
+ const matches = commandList
685
+ .filter((item) => !term || item.name.startsWith(term) || item.aliases.some((alias) => alias.startsWith(term)))
686
+ .slice(0, 8);
687
+ state.commandMatches = matches.map((item) => ({ kind: 'command', item, insert: item.usage.startsWith('/') ? item.usage + ' ' : '/' + item.name + ' ' }));
688
+ state.commandIndex = 0;
689
+ paintPalette();
690
+ }
691
+
692
+ function paintPalette() {
693
+ const box = $('commandPalette');
694
+ box.innerHTML = '';
695
+ const rows = state.commandMatches ?? [];
696
+ if (!rows.length) {
697
+ box.innerHTML = '<div class="cmd-empty">没有匹配项</div>';
698
+ return;
699
+ }
700
+ rows.forEach((entry, index) => {
701
+ const row = document.createElement('button');
702
+ row.className = `cmd-item${index === state.commandIndex ? ' is-on' : ''}`;
703
+ row.type = 'button';
704
+ if (entry.kind === 'command') {
705
+ row.innerHTML =
706
+ `<span class="cmd-name">/${escapeHtml(entry.item.name)}</span>` +
707
+ `<span class="cmd-summary">${escapeHtml(entry.item.summary)}</span>` +
708
+ `<span class="cmd-usage">${escapeHtml(entry.item.usage)}</span>`;
709
+ } else {
710
+ row.innerHTML =
711
+ `<span class="cmd-name">${escapeHtml(entry.item.split('/').pop())}</span>` +
712
+ `<span class="cmd-summary">${escapeHtml(entry.item)}</span>` +
713
+ '<span class="cmd-usage">文件</span>';
714
+ }
715
+ row.addEventListener('mousedown', (event) => {
716
+ event.preventDefault();
717
+ applyCompletion(entry);
718
+ });
719
+ box.appendChild(row);
720
+ });
721
+ }
722
+
723
+ /** 用候选替换输入框里对应的片段。 */
724
+ function applyCompletion(entry) {
725
+ const input = $('input');
726
+ const value = input.value;
727
+ const replace = state.paletteReplace ?? '';
728
+ if (replace && value.endsWith(replace)) {
729
+ input.value = value.slice(0, value.length - replace.length) + entry.insert;
730
+ } else {
731
+ input.value = `${value.trimEnd()} ${entry.insert}`;
732
+ }
733
+ closePalette();
734
+ input.focus();
735
+ grow();
736
+ }
737
+
738
+ /** 命令面板:本地过滤,无需请求。 */
739
+ function openPalette(query) {
740
+ const box = $('commandPalette');
741
+ box.hidden = false;
742
+ box.dataset.kind = 'command';
743
+ state.paletteReplace = '';
744
+ renderCommandPalette(query);
745
+ }
746
+
747
+ function closePalette() {
748
+ $('commandPalette').hidden = true;
749
+ state.commandMatches = [];
750
+ state.paletteReplace = '';
751
+ state.pendingFiles = [];
752
+ }
753
+
754
+ function movePalette(step) {
755
+ const box = $('commandPalette');
756
+ if (box.hidden) return;
757
+ const rows = [...box.querySelectorAll('.cmd-item')];
758
+ if (!rows.length) return;
759
+ state.commandIndex = (state.commandIndex + step + rows.length) % rows.length;
760
+ rows.forEach((row, index) => row.classList.toggle('is-on', index === state.commandIndex));
761
+ rows[state.commandIndex].scrollIntoView({ block: 'nearest' });
762
+ }
763
+
764
+ /** @ 补全:走服务端,和 CLI 用同一份 completeFileReference。 */
765
+ let completeTimer = null;
766
+ async function requestFileComplete(value) {
767
+ clearTimeout(completeTimer);
768
+ completeTimer = setTimeout(async () => {
769
+ try {
770
+ const data = await get(`complete?line=${encodeURIComponent(value)}`);
771
+ if (data.kind !== 'file' || !data.candidates.length) {
772
+ if (!$('commandPalette').hidden && $('commandPalette').dataset.kind === 'file') closePalette();
773
+ return;
774
+ }
775
+ // 补全请求返回时用户可能已经继续输入了,丢弃过期结果
776
+ if ($('input').value !== value) return;
777
+ state.pendingFiles = data.candidates.map((path) => ({ kind: 'file', item: path, insert: path + ' ' }));
778
+ $('commandPalette').hidden = false;
779
+ $('commandPalette').dataset.kind = 'file';
780
+ state.paletteReplace = data.replace;
781
+ state.commandMatches = state.pendingFiles;
782
+ state.commandIndex = 0;
783
+ paintPalette();
784
+ } catch {
785
+ /* 补全失败不影响输入 */
786
+ }
787
+ }, 90);
788
+ }
789
+
790
+ /** 执行命令:走服务端,和终端里同一份逻辑、同一份命令表。 */
791
+ async function runCommand(text) {
792
+ closePalette();
793
+ const userMsg = document.createElement('article');
794
+ userMsg.className = 'msg user cmd';
795
+ userMsg.innerHTML = '<div class="msg-role">你</div>';
796
+ const userBody = document.createElement('div');
797
+ userBody.className = 'msg-body';
798
+ userBody.textContent = text;
799
+ userMsg.appendChild(userBody);
800
+ THREAD.appendChild(userMsg);
801
+
802
+ const botMsg = document.createElement('article');
803
+ botMsg.className = 'msg assistant';
804
+ const role = document.createElement('div');
805
+ role.className = 'msg-role';
806
+ role.textContent = '命令输出';
807
+ const out = document.createElement('div');
808
+ out.className = 'msg-body cmd-out';
809
+ botMsg.append(role, out);
810
+ THREAD.appendChild(botMsg);
811
+ out.textContent = '执行中…';
812
+ scrollToEnd();
813
+
814
+ try {
815
+ const result = await post('command', { input: text });
816
+ out.textContent = (result.output || '').replace(/\u001b\[\d+J/g, '') || (result.ok ? '(没有输出)' : '');
817
+ out.classList.toggle('is-error', !result.ok);
818
+ if (result.error) out.textContent += (out.textContent ? '\n' : '') + result.error;
819
+ if (result.info) state.info = result.info;
820
+ if (result.sessions) state.sessions = result.sessions;
821
+ // \u001b[clear 之类的终端指令:清空当前视图
822
+ if ((result.output || '').includes('\u001b[clear]')) renderThread();
823
+ renderHeader();
824
+ renderSidebar();
825
+ } catch (error) {
826
+ out.textContent = error.message;
827
+ out.classList.add('is-error');
828
+ }
829
+ scrollToEnd();
830
+ }
831
+
832
+ // ---------------- 附件 ----------------
833
+ function renderAttachments() {
834
+ const box = $('attachments');
835
+ box.hidden = state.attachments.length === 0;
836
+ box.innerHTML = '';
837
+ state.attachments.forEach((item, index) => {
838
+ const chip = document.createElement('span');
839
+ chip.className = 'att';
840
+ chip.innerHTML = `${svg('image')}<span>${escapeHtml(item.name)}</span><button aria-label="移除">${svg('x')}</button>`;
841
+ chip.querySelector('button').addEventListener('click', () => {
842
+ state.attachments.splice(index, 1);
843
+ renderAttachments();
844
+ });
845
+ box.appendChild(chip);
846
+ });
847
+ }
848
+
849
+ async function attach(file) {
850
+ if (!/^image\//.test(file.type)) { toast('只支持图片'); return; }
851
+ if (file.size > 4 * 1024 * 1024) { toast('图片超过 4MB'); return; }
852
+ const base64 = await new Promise((resolve) => {
853
+ const reader = new FileReader();
854
+ reader.onload = () => resolve(String(reader.result).split(',')[1]);
855
+ reader.readAsDataURL(file);
856
+ });
857
+ const { path } = await post('attach?name=' + encodeURIComponent(file.name), { data: base64 });
858
+ state.attachments.push({ name: file.name, path });
859
+ renderAttachments();
860
+ toast('已附加 ' + file.name);
861
+ }
862
+
863
+ // ---------------- 设置面板 ----------------
864
+ function renderSettings() {
865
+ const info = state.info;
866
+ const body = $('settingsBody');
867
+ body.innerHTML = '';
868
+
869
+ const providerField = document.createElement('div');
870
+ providerField.className = 'field';
871
+ providerField.innerHTML =
872
+ '<label>Provider</label><select>' +
873
+ info.providers.map((item) => `<option value="${item.name}" ${item.current ? 'selected' : ''}>${item.name} — ${item.model}</option>`).join('') +
874
+ '</select>';
875
+ providerField.querySelector('select').addEventListener('change', async (event) => {
876
+ await post('action', { action: 'provider', name: event.target.value });
877
+ await refreshSettings();
878
+ });
879
+ body.appendChild(providerField);
880
+
881
+ const modelField = document.createElement('div');
882
+ modelField.className = 'field';
883
+ modelField.innerHTML = `<label>模型</label><div class="row"><input value="${escapeHtml(info.provider.model)}" /><button class="btn">保存</button></div>`;
884
+ modelField.querySelector('button').addEventListener('click', async () => {
885
+ const value = modelField.querySelector('input').value.trim();
886
+ if (!value) return;
887
+ await post('action', { action: 'model', value });
888
+ await refreshSettings();
889
+ toast('模型已更新');
890
+ });
891
+ modelField.querySelector('input').addEventListener('keydown', (event) => {
892
+ if (event.key === 'Enter') modelField.querySelector('button').click();
893
+ });
894
+ body.appendChild(modelField);
895
+
896
+ // API key:留空表示不修改;提示可以用环境变量代替
897
+ const active = info.providers.find((item) => item.current);
898
+ const keyField = document.createElement('div');
899
+ keyField.className = 'field';
900
+ const keyPlaceholder = active?.keySaved
901
+ ? '已保存(留空表示不修改)'
902
+ : active?.keyEnv
903
+ ? `默认读取环境变量 ${active.keyEnv}(当前${active.keyReady ? '已设置' : '未设置'})`
904
+ : '粘贴 API key';
905
+ keyField.innerHTML =
906
+ '<label>API Key</label>' +
907
+ `<div class="row"><input type="password" placeholder="${keyPlaceholder}" /><button class="btn">保存</button></div>` +
908
+ `<div class="hint">${active?.keyEnv ? `默认读取环境变量 ${active.keyEnv};` : ''}更安全的做法是只设环境变量,配置文件里只留变量名。</div>`;
909
+ keyField.querySelector('button').addEventListener('click', async () => {
910
+ const input = keyField.querySelector('input');
911
+ const value = input.value.trim();
912
+ if (!value) {
913
+ toast('留空表示不修改');
914
+ return;
915
+ }
916
+ await post('action', { action: 'apiKey', value });
917
+ input.value = '';
918
+ await refreshSettings();
919
+ toast('API Key 已保存');
920
+ });
921
+ keyField.querySelector('input').addEventListener('keydown', (event) => {
922
+ if (event.key === 'Enter') keyField.querySelector('button').click();
923
+ });
924
+ body.appendChild(keyField);
925
+
926
+ const permField = document.createElement('div');
927
+ permField.className = 'field';
928
+ permField.innerHTML = '<label>权限</label>';
929
+ // 权限是小白最容易懵的地方,所以每种模式都写清楚"改了会发生什么"
930
+ const PERM_INFO = {
931
+ shell: {
932
+ name: '执行命令',
933
+ hint: 'Banana 可以在你的电脑上运行命令(如 npm test、git status)。',
934
+ modes: {
935
+ ask: '每次执行前弹窗问你 —— 最安全,推荐新手用这个',
936
+ allow: '直接执行,不打扰你 —— 方便但风险自负',
937
+ deny: '完全禁止 —— Banana 只能读文件,不能动你的系统',
938
+ },
939
+ },
940
+ write: {
941
+ name: '写入文件',
942
+ hint: '修改或新建文件的能力。每次改动都可以用左下角「撤销」回滚。',
943
+ modes: {
944
+ ask: '每次改文件前给你看改动内容,你确认才写',
945
+ allow: '直接改,不再逐个确认',
946
+ deny: '禁止任何写入 —— 只读模式',
947
+ },
948
+ },
949
+ network: {
950
+ name: '联网',
951
+ hint: '允许 Banana 访问外部网络(搜索、抓网页)。',
952
+ modes: {
953
+ ask: '每次联网都问你',
954
+ allow: '允许联网(默认)',
955
+ deny: '完全断网,只能用本地信息',
956
+ },
957
+ },
958
+ };
959
+ for (const key of ['shell', 'write', 'network']) {
960
+ const spec = PERM_INFO[key];
961
+ const row = document.createElement('div');
962
+ row.className = 'perm-row';
963
+ row.dataset.key = key;
964
+ const seg = document.createElement('div');
965
+ seg.className = 'seg';
966
+ seg.innerHTML = ['ask', 'allow', 'deny']
967
+ .map((mode) => {
968
+ const label = mode === 'ask' ? '每次问' : mode === 'allow' ? '直接执行' : '禁止';
969
+ return `<button data-mode="${mode}" class="${info.permissions[key] === mode ? 'is-on' : ''}">${label}</button>`;
970
+ })
971
+ .join('');
972
+ seg.addEventListener('click', async (event) => {
973
+ const button = event.target.closest('[data-mode]');
974
+ if (!button) return;
975
+ const mode = button.dataset.mode;
976
+ // 立即反馈,失败再回滚并提示,避免看起来"点不动"
977
+ seg.querySelectorAll('[data-mode]').forEach((item) => item.classList.toggle('is-on', item === button));
978
+ const detail = row.querySelector('.perm-detail');
979
+ if (detail) detail.textContent = spec.modes[mode];
980
+ try {
981
+ await post('action', { action: 'permission', name: key, value: mode });
982
+ await refresh(false);
983
+ toast(`「${spec.name}」已设为:${mode === 'ask' ? '每次问' : mode === 'allow' ? '直接执行' : '禁止'}`);
984
+ } catch (error) {
985
+ seg.querySelectorAll('[data-mode]').forEach((item) => item.classList.toggle('is-on', item.dataset.mode !== mode));
986
+ if (detail) detail.textContent = spec.modes[info.permissions[key]];
987
+ toast('保存失败:' + error.message);
988
+ }
989
+ });
990
+
991
+ const block = document.createElement('div');
992
+ block.className = 'perm-block';
993
+ const head = document.createElement('div');
994
+ head.className = 'perm-head';
995
+ head.innerHTML = `<span class="perm-name">${escapeHtml(spec.name)}</span>`;
996
+ head.appendChild(seg);
997
+ const desc = document.createElement('div');
998
+ desc.className = 'hint';
999
+ desc.textContent = spec.hint;
1000
+ const detail = document.createElement('div');
1001
+ detail.className = 'perm-detail';
1002
+ detail.textContent = spec.modes[info.permissions[key]];
1003
+ block.append(head, desc, detail);
1004
+ row.appendChild(block);
1005
+ permField.appendChild(row);
1006
+ }
1007
+ const note = document.createElement('div');
1008
+ note.className = 'hint perm-note';
1009
+ note.textContent = '改动立刻生效,并保存在配置里(CLI 和 GUI 共用同一份设置)。不确定就保持「每次问」。';
1010
+ permField.appendChild(note);
1011
+ body.appendChild(permField);
1012
+
1013
+ const toolsField = document.createElement('div');
1014
+ toolsField.className = 'field';
1015
+ toolsField.innerHTML =
1016
+ '<label>工具</label><div class="hint">' +
1017
+ info.tools.map((tool) => `${escapeHtml(tool.name)} <span style="opacity:.6">${tool.permission}</span>`).join('、') +
1018
+ '</div>';
1019
+ body.appendChild(toolsField);
1020
+
1021
+ const sysField = document.createElement('div');
1022
+ sysField.className = 'field';
1023
+ sysField.innerHTML = '<label>系统提示词</label>';
1024
+ const textarea = document.createElement('textarea');
1025
+ textarea.rows = 6;
1026
+ textarea.style.cssText = 'border:1px solid var(--line);border-radius:9px;padding:8px 10px;font-size:13px;resize:vertical';
1027
+ textarea.value = state.systemPrompt ?? '';
1028
+ sysField.append(textarea);
1029
+ const saveRow = document.createElement('div');
1030
+ saveRow.innerHTML = '<button class="btn">保存提示词</button>';
1031
+ saveRow.querySelector('button').addEventListener('click', async () => {
1032
+ await post('action', { action: 'system', value: textarea.value });
1033
+ toast('已保存');
1034
+ });
1035
+ sysField.append(saveRow);
1036
+ body.appendChild(sysField);
1037
+ }
1038
+
1039
+ // ---------------- 文件面板 ----------------
1040
+ const FILE_ICON = { dir: 'folder', file: 'file' };
1041
+
1042
+ async function loadTree(path = '.', container = $('filesTree')) {
1043
+ container.innerHTML = '<div class="hint" style="padding:8px 10px">读取中…</div>';
1044
+ try {
1045
+ const { entries } = await get(`files?path=${encodeURIComponent(path)}`);
1046
+ container.innerHTML = '';
1047
+ if (!entries.length) {
1048
+ container.innerHTML = '<div class="hint" style="padding:8px 10px">(空目录)</div>';
1049
+ return;
1050
+ }
1051
+ for (const entry of entries) {
1052
+ const row = document.createElement('button');
1053
+ row.className = 'file-row';
1054
+ row.innerHTML =
1055
+ `<span class="tool-caret">${entry.type === 'dir' ? svg('chevron') : ''}</span>` +
1056
+ `<span>${svg(FILE_ICON[entry.type])}</span>` +
1057
+ `<span class="f-name">${escapeHtml(entry.name)}</span>`;
1058
+ row.addEventListener('click', async () => {
1059
+ if (entry.type === 'dir') {
1060
+ const holder = row.nextElementSibling;
1061
+ if (holder?.classList.contains('file-children')) {
1062
+ holder.remove();
1063
+ row.classList.remove('is-open');
1064
+ return;
1065
+ }
1066
+ row.classList.add('is-open');
1067
+ const box = document.createElement('div');
1068
+ box.className = 'file-children';
1069
+ row.after(box);
1070
+ await loadTree(entry.path, box);
1071
+ } else {
1072
+ await openFile(entry.path);
1073
+ }
1074
+ });
1075
+ container.appendChild(row);
1076
+ }
1077
+ } catch (error) {
1078
+ container.innerHTML = `<div class="hint" style="padding:8px 10px">${escapeHtml(error.message)}</div>`;
1079
+ }
1080
+ }
1081
+
1082
+ async function openFile(path) {
1083
+ const box = $('filesPreview');
1084
+ const body = $('previewBody');
1085
+ box.hidden = false;
1086
+ $('previewName').textContent = path;
1087
+ body.textContent = '读取中…';
1088
+ try {
1089
+ const data = await get(`file?path=${encodeURIComponent(path)}`);
1090
+ $('insertPath').dataset.path = path;
1091
+ if (data.binary) body.textContent = `二进制文件(${data.bytes} 字节),无法预览。`;
1092
+ else if (data.tooLarge) body.textContent = `文件较大(${data.bytes} 字节),未加载。`;
1093
+ else body.textContent = data.content ?? '';
1094
+ document.querySelectorAll('.file-row.is-active').forEach((node) => node.classList.remove('is-active'));
1095
+ } catch (error) {
1096
+ body.textContent = error.message;
1097
+ }
1098
+ }
1099
+
1100
+ async function toggleFiles() {
1101
+ const panel = $('filesPanel');
1102
+ panel.hidden = !panel.hidden;
1103
+ if (!panel.hidden && !$('filesTree').childElementCount) {
1104
+ $('filesTitle').textContent = `项目文件 · ${state.info?.workspace ?? ''}`;
1105
+ await loadTree('.');
1106
+ }
1107
+ }
1108
+
1109
+ // ---------------- 工具输出详情 ----------------
1110
+ function toggleToolOutput(row, detail, isError) {
1111
+ const existing = row.nextElementSibling;
1112
+ if (existing?.classList.contains('tool-output')) {
1113
+ existing.remove();
1114
+ row.classList.remove('is-open');
1115
+ return;
1116
+ }
1117
+ const pre = document.createElement('div');
1118
+ pre.className = `tool-output${isError ? ' is-error' : ''}`;
1119
+ pre.textContent = detail || '(无输出)';
1120
+ row.after(pre);
1121
+ row.classList.add('is-open');
1122
+ }
1123
+
1124
+ // ---------------- 启动 ----------------
1125
+ function openSettings() {
1126
+ renderSettings();
1127
+ $('settingsSheet').hidden = false;
1128
+ }
1129
+
1130
+ function closeSettings() {
1131
+ $('settingsSheet').hidden = true;
1132
+ // 收掉动画后再清空内容,避免关闭瞬间出现内容跳动
1133
+ setTimeout(() => { if ($('settingsSheet').hidden) $('settingsBody').innerHTML = ''; }, 240);
1134
+ }
1135
+
1136
+ function bind() {
1137
+ $('newChat').innerHTML = svg('plus');
1138
+ $('sendBtn').innerHTML = svg('send');
1139
+ $('abortBtn').innerHTML = svg('stop');
1140
+ $('toggleSidebar').innerHTML = svg('list');
1141
+ $('closeSettings').innerHTML = svg('x');
1142
+ $('filesBtn').innerHTML = svg('panel');
1143
+ $('closeFiles').innerHTML = svg('x');
1144
+ hydrate();
1145
+ loadTheme();
1146
+
1147
+ $('newChat').addEventListener('click', () => post('session', { action: 'new' }).then(() => refresh(true)));
1148
+ $('abortBtn').addEventListener('click', async () => { controller?.abort(); await post('abort'); });
1149
+ $('modelBtn').addEventListener('click', openSettings);
1150
+ $('openSettings').addEventListener('click', openSettings);
1151
+ $('closeSettings').addEventListener('click', closeSettings);
1152
+ $('settingsSheet').addEventListener('click', (event) => {
1153
+ if (event.target === $('settingsSheet')) closeSettings();
1154
+ });
1155
+ $('settingsSheet').addEventListener('mousedown', (event) => {
1156
+ if (event.target === $('settingsSheet')) closeSettings();
1157
+ });
1158
+ $('toggleSidebar').addEventListener('click', () => $('sidebar').classList.toggle('open'));
1159
+ $('filesBtn').addEventListener('click', toggleFiles);
1160
+ $('themeBtn').addEventListener('click', cycleTheme);
1161
+ $('closeFiles').addEventListener('click', () => { $('filesPanel').hidden = true; });
1162
+ $('insertPath').addEventListener('click', () => {
1163
+ const path = $('insertPath').dataset.path;
1164
+ if (!path) return;
1165
+ const box = $('input');
1166
+ box.value = box.value.trim() ? `${box.value.trim()} @${path} ` : `@${path} `;
1167
+ box.focus();
1168
+ box.setSelectionRange(box.value.length, box.value.length);
1169
+ toast('已插入文件引用');
1170
+ });
1171
+ $('approvalNo').addEventListener('click', () => closeApproval(false));
1172
+ $('approvalYes').addEventListener('click', () => closeApproval(true));
1173
+
1174
+ $('undoBtn').addEventListener('click', async () => {
1175
+ const result = await post('undo');
1176
+ toast(result.had ? `已回滚:恢复 ${result.restored} 个文件` : '没有可回滚的改动');
1177
+ });
1178
+
1179
+ $('composer').addEventListener('submit', (event) => { event.preventDefault(); send(); });
1180
+ $('input').addEventListener('input', () => {
1181
+ grow();
1182
+ const value = $('input').value;
1183
+ // / 开头 → 命令面板;结尾是 @路径 → 文件补全(服务端用 CLI 同一份逻辑)
1184
+ if (/^\/[a-zA-Z?]*$/.test(value) && commandList.length) openPalette(value);
1185
+ else if (/(^|\s)@[^\s]*$/.test(value)) requestFileComplete(value);
1186
+ else closePalette();
1187
+ });
1188
+ $('input').addEventListener('keydown', (event) => {
1189
+ if (!$('commandPalette').hidden) {
1190
+ if (event.key === 'ArrowDown') { event.preventDefault(); movePalette(1); return; }
1191
+ if (event.key === 'ArrowUp') { event.preventDefault(); movePalette(-1); return; }
1192
+ if (event.key === 'Tab') {
1193
+ event.preventDefault();
1194
+ const picked = state.commandMatches?.[state.commandIndex ?? 0];
1195
+ if (picked) applyCompletion(picked);
1196
+ return;
1197
+ }
1198
+ // 文件补全面板里回车直接发送更顺手;命令面板里回车优先补全
1199
+ if (event.key === 'Enter' && !event.shiftKey && $('commandPalette').dataset.kind === 'command' && state.commandMatches?.length) {
1200
+ event.preventDefault();
1201
+ const picked = state.commandMatches[state.commandIndex ?? 0];
1202
+ if (picked) {
1203
+ applyCompletion(picked);
1204
+ renderCommandPalette(picked.item.name ?? '');
1205
+ return;
1206
+ }
1207
+ }
1208
+ if (event.key === 'Escape') { event.preventDefault(); closePalette(); return; }
1209
+ }
1210
+ if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); send(); }
1211
+ });
1212
+
1213
+ document.addEventListener('keydown', (event) => {
1214
+ if (event.key === 'Escape') {
1215
+ if (!$('approvalModal').hidden) closeApproval(false);
1216
+ else if (!$('settingsSheet').hidden) closeSettings();
1217
+ else if (!$('filesPanel').hidden) $('filesPanel').hidden = true;
1218
+ else if ($('sidebar').classList.contains('open')) $('sidebar').classList.remove('open');
1219
+ }
1220
+ });
1221
+
1222
+ // 窄屏下点空白处收起侧边栏(点侧边栏内部或开合按钮不算)
1223
+ document.addEventListener('click', (event) => {
1224
+ if (!window.matchMedia('(max-width: 900px)').matches) return;
1225
+ if (!$('sidebar').classList.contains('open')) return;
1226
+ if (event.target.closest('#sidebar, #toggleSidebar')) return;
1227
+ $('sidebar').classList.remove('open');
1228
+ });
1229
+
1230
+ $('input').addEventListener('paste', (event) => {
1231
+ for (const item of event.clipboardData?.files ?? []) attach(item);
1232
+ });
1233
+ document.addEventListener('dragover', (event) => event.preventDefault());
1234
+ document.addEventListener('drop', (event) => {
1235
+ event.preventDefault();
1236
+ for (const item of event.dataTransfer?.files ?? []) attach(item);
1237
+ });
1238
+ }
1239
+
1240
+ async function boot() {
1241
+ bind();
1242
+ // 命令表来自服务端(与 CLI 同一份),拉不到也不影响对话
1243
+ try {
1244
+ commandList = (await get('commands')).commands ?? [];
1245
+ } catch {
1246
+ commandList = [];
1247
+ }
1248
+ await refresh(true);
1249
+ grow();
1250
+ $('input').focus();
1251
+ }
1252
+
1253
+ boot().catch((error) => toast('加载失败:' + error.message));