@vincentbanana/banana-gui 0.1.1 → 0.2.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/README.md +30 -5
- package/desktop/main.cjs +113 -0
- package/package.json +41 -3
- package/ui/app.js +568 -28
- package/ui/index.html +19 -1
- package/ui/style.css +257 -3
package/README.md
CHANGED
|
@@ -22,13 +22,38 @@ banana-gui --provider deepseek --model deepseek-chat
|
|
|
22
22
|
|
|
23
23
|
## 特性
|
|
24
24
|
|
|
25
|
-
-
|
|
26
|
-
-
|
|
27
|
-
-
|
|
25
|
+
- **和 CLI 同一套内核**:agent 循环、工具、权限、斜杠命令全部来自 `@vincentbanana/bananacli`,两边行为完全一致
|
|
26
|
+
- 左侧会话列表:新建 / 切换 / 重命名 / 导出(Markdown、JSON)/ 删除
|
|
27
|
+
- 输入 `/` 调出命令面板(`/help` 看全部),输入 `@` 补全项目文件
|
|
28
|
+
- 流式回复、工具调用实时状态,点工具行可展开完整输出
|
|
29
|
+
- 危险操作弹窗确认,写文件前给 diff
|
|
28
30
|
- 一键撤销上一轮文件改动
|
|
31
|
+
- 右侧项目文件面板:浏览目录、预览文件、一键插入引用
|
|
29
32
|
- 粘贴或拖拽图片,随消息一起发送
|
|
30
|
-
-
|
|
31
|
-
-
|
|
33
|
+
- 模型、权限、API Key、工作区、系统提示词在设置里改,改完立刻生效
|
|
34
|
+
- 权限设置写清了每种模式的含义和风险,新手也不会懵
|
|
35
|
+
- 三态主题:跟随系统 / 浅色 / 深色,纯黑白无渐变
|
|
36
|
+
- 没配 API Key 也能打开界面,照着引导填完即可用
|
|
37
|
+
|
|
38
|
+
## 桌面版
|
|
39
|
+
|
|
40
|
+
仓库里有 Electron 主进程,可以打包成独立应用:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
npm install # 装 electron 与 electron-builder
|
|
44
|
+
npm run desktop # 本地直接跑桌面版
|
|
45
|
+
npm run dist:win # 出 Windows 安装包(也支持 dist:mac / dist:linux)
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
npm 安装的 `banana-gui` 仍是浏览器版,零原生依赖,任何装了 Node 20+ 的系统都能跑。
|
|
49
|
+
|
|
50
|
+
## 配置
|
|
51
|
+
|
|
52
|
+
首次启动会提示配置向导。GUI 与 CLI 共用 `~/.banana/config.json`:
|
|
53
|
+
|
|
54
|
+
- 也可以直接在界面的设置面板里填 API Key、切换模型和权限
|
|
55
|
+
- 环境变量优先级更高,例如 `DEEPSEEK_API_KEY`
|
|
56
|
+
- API Key 只写在你自己的机器上,不会进代码仓库
|
|
32
57
|
|
|
33
58
|
## 安全
|
|
34
59
|
|
package/desktop/main.cjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Banana 桌面版主进程:拉起本地 GUI 服务,再用窗口加载它。
|
|
2
|
+
// 界面资源仍来自本包 ui/,agent 内核来自 @vincentbanana/bananacli。
|
|
3
|
+
const { app, BrowserWindow, Menu, shell, dialog } = require('electron');
|
|
4
|
+
const { spawn } = require('node:child_process');
|
|
5
|
+
const { createRequire } = require('node:module');
|
|
6
|
+
|
|
7
|
+
const require_ = createRequire(__filename);
|
|
8
|
+
let child = null;
|
|
9
|
+
let win = null;
|
|
10
|
+
|
|
11
|
+
function startServer() {
|
|
12
|
+
const entry = require_.resolve('@vincentbanana/bananacli');
|
|
13
|
+
// 端口交给 CLI 随机分配,避免多开时互相抢占
|
|
14
|
+
child = spawn(process.execPath, [entry, '--ui', '--no-open'], {
|
|
15
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
16
|
+
env: process.env,
|
|
17
|
+
});
|
|
18
|
+
return new Promise((resolve, reject) => {
|
|
19
|
+
let buffer = '';
|
|
20
|
+
const onData = (chunk) => {
|
|
21
|
+
buffer += chunk.toString('utf8');
|
|
22
|
+
const match = buffer.match(/http:\/\/127\.0\.0\.1:\d+\/t\/[a-f0-9]+\//);
|
|
23
|
+
if (match) {
|
|
24
|
+
child.stdout.off('data', onData);
|
|
25
|
+
resolve(match[0]);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
child.stdout.on('data', onData);
|
|
29
|
+
child.stderr.on('data', (chunk) => process.stderr.write(`[banana] ${chunk}`));
|
|
30
|
+
child.on('error', reject);
|
|
31
|
+
child.on('exit', (code) => {
|
|
32
|
+
if (!win) return;
|
|
33
|
+
dialog.showErrorBox('Banana 已退出', `本地服务意外结束(退出码 ${code})。`);
|
|
34
|
+
app.quit();
|
|
35
|
+
});
|
|
36
|
+
setTimeout(() => reject(new Error('启动超时:本地服务没有在 30 秒内就绪')), 30_000);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function createWindow() {
|
|
41
|
+
const url = await startServer();
|
|
42
|
+
win = new BrowserWindow({
|
|
43
|
+
width: 1180,
|
|
44
|
+
height: 820,
|
|
45
|
+
minWidth: 720,
|
|
46
|
+
minHeight: 520,
|
|
47
|
+
backgroundColor: '#ffffff',
|
|
48
|
+
title: 'Banana',
|
|
49
|
+
autoHideMenuBar: true,
|
|
50
|
+
webPreferences: { contextIsolation: true, nodeIntegration: false, sandbox: true },
|
|
51
|
+
});
|
|
52
|
+
await win.loadURL(url);
|
|
53
|
+
win.on('closed', () => {
|
|
54
|
+
win = null;
|
|
55
|
+
});
|
|
56
|
+
// 外部链接交给系统浏览器,窗口只负责本地界面
|
|
57
|
+
win.webContents.setWindowOpenHandler(({ url: target }) => {
|
|
58
|
+
shell.openExternal(target);
|
|
59
|
+
return { action: 'deny' };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const menuTemplate = [
|
|
64
|
+
...(process.platform === 'darwin' ? [{ role: 'appMenu' }] : []),
|
|
65
|
+
{
|
|
66
|
+
label: '文件',
|
|
67
|
+
submenu: [
|
|
68
|
+
{
|
|
69
|
+
label: '新建对话',
|
|
70
|
+
accelerator: 'CmdOrCtrl+N',
|
|
71
|
+
click: () => win?.webContents.executeJavaScript("document.getElementById('newChat')?.click()"),
|
|
72
|
+
},
|
|
73
|
+
{ type: 'separator' },
|
|
74
|
+
process.platform === 'darwin' ? { role: 'close' } : { role: 'quit', label: '退出' },
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
{ role: 'editMenu', label: '编辑' },
|
|
78
|
+
{
|
|
79
|
+
label: '视图',
|
|
80
|
+
submenu: [
|
|
81
|
+
{
|
|
82
|
+
label: '项目文件',
|
|
83
|
+
accelerator: 'CmdOrCtrl+Shift+F',
|
|
84
|
+
click: () => win?.webContents.executeJavaScript("document.getElementById('filesBtn')?.click()"),
|
|
85
|
+
},
|
|
86
|
+
{ type: 'separator' },
|
|
87
|
+
{ role: 'reload', label: '重新加载' },
|
|
88
|
+
{ role: 'toggleDevTools', label: '开发者工具' },
|
|
89
|
+
{ role: 'togglefullscreen', label: '全屏' },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
{ role: 'windowMenu', label: '窗口' },
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
app.whenReady().then(() => {
|
|
96
|
+
Menu.setApplicationMenu(Menu.buildFromTemplate(menuTemplate));
|
|
97
|
+
createWindow().catch((error) => {
|
|
98
|
+
dialog.showErrorBox('Banana 启动失败', String(error?.message ?? error));
|
|
99
|
+
app.quit();
|
|
100
|
+
});
|
|
101
|
+
app.on('activate', () => {
|
|
102
|
+
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
app.on('window-all-closed', () => {
|
|
107
|
+
if (process.platform !== 'darwin') app.quit();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
app.on('before-quit', () => {
|
|
111
|
+
child?.kill();
|
|
112
|
+
child = null;
|
|
113
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentbanana/banana-gui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Banana 的图形界面:本地服务 + 浏览器。黑白主题,自绘图标,无第三方 UI 依赖",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -12,14 +12,52 @@
|
|
|
12
12
|
"files": [
|
|
13
13
|
"bin",
|
|
14
14
|
"ui",
|
|
15
|
+
"desktop",
|
|
15
16
|
"README.md"
|
|
16
17
|
],
|
|
17
18
|
"scripts": {
|
|
18
19
|
"sync": "node ../scripts/sync-gui.mjs",
|
|
19
|
-
"test": "node test/gui-package.test.mjs"
|
|
20
|
+
"test": "node test/gui-package.test.mjs",
|
|
21
|
+
"desktop": "electron desktop/main.cjs",
|
|
22
|
+
"pack": "electron-builder --dir",
|
|
23
|
+
"dist": "electron-builder",
|
|
24
|
+
"dist:win": "electron-builder --win",
|
|
25
|
+
"dist:mac": "electron-builder --mac",
|
|
26
|
+
"dist:linux": "electron-builder --linux"
|
|
27
|
+
},
|
|
28
|
+
"build": {
|
|
29
|
+
"appId": "dev.banana.gui",
|
|
30
|
+
"productName": "Banana",
|
|
31
|
+
"files": [
|
|
32
|
+
"desktop/**/*",
|
|
33
|
+
"ui/**/*",
|
|
34
|
+
"bin/**/*",
|
|
35
|
+
"package.json"
|
|
36
|
+
],
|
|
37
|
+
"mac": {
|
|
38
|
+
"category": "public.app-category.developer-tools",
|
|
39
|
+
"target": [
|
|
40
|
+
"dmg"
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
"win": {
|
|
44
|
+
"target": [
|
|
45
|
+
"nsis"
|
|
46
|
+
]
|
|
47
|
+
},
|
|
48
|
+
"linux": {
|
|
49
|
+
"target": [
|
|
50
|
+
"AppImage"
|
|
51
|
+
],
|
|
52
|
+
"category": "Development"
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"electron": "^33.0.0",
|
|
57
|
+
"electron-builder": "^25.0.0"
|
|
20
58
|
},
|
|
21
59
|
"dependencies": {
|
|
22
|
-
"@vincentbanana/bananacli": "^0.
|
|
60
|
+
"@vincentbanana/bananacli": "^0.3.0"
|
|
23
61
|
},
|
|
24
62
|
"keywords": [
|
|
25
63
|
"ai",
|
package/ui/app.js
CHANGED
|
@@ -24,6 +24,13 @@ const ICONS = {
|
|
|
24
24
|
folder: '<path d="M4 6.5h5.5L12 9h8v10.5H4z"/>',
|
|
25
25
|
message: '<path d="M4 5.5h16v11H9.5L4 20z"/>',
|
|
26
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"/>',
|
|
27
34
|
};
|
|
28
35
|
|
|
29
36
|
const TOOL_ICON = {
|
|
@@ -131,21 +138,117 @@ function renderSidebar() {
|
|
|
131
138
|
const node = document.createElement('div');
|
|
132
139
|
node.className = `session-item${active}`;
|
|
133
140
|
node.innerHTML =
|
|
134
|
-
`<span class="s-
|
|
141
|
+
`<span class="s-actions">` +
|
|
142
|
+
`<span class="s-act" data-menu="${item.id}" title="更多">${svg('dots')}</span>` +
|
|
143
|
+
`<span class="s-act" data-del="${item.id}" title="删除">${svg('trash')}</span>` +
|
|
144
|
+
`</span>` +
|
|
135
145
|
`<span class="s-title">${escapeHtml(item.title || '新对话')}</span>` +
|
|
136
146
|
`<span class="s-meta">${item.turns} 轮 · ${relativeTime(item.updatedAt)}</span>`;
|
|
147
|
+
|
|
137
148
|
node.addEventListener('click', (event) => {
|
|
138
|
-
if (event.target.closest('[data-del]')) return;
|
|
139
|
-
post('session', { action: 'switch', ref: item.id });
|
|
149
|
+
if (event.target.closest('[data-del], [data-menu]')) return;
|
|
150
|
+
post('session', { action: 'switch', ref: item.id }).then(() => refresh(true));
|
|
140
151
|
});
|
|
152
|
+
|
|
141
153
|
node.querySelector('[data-del]').addEventListener('click', (event) => {
|
|
142
154
|
event.stopPropagation();
|
|
143
|
-
post('session', { action: 'delete', ref: item.id });
|
|
155
|
+
post('session', { action: 'delete', ref: item.id }).then(() => refresh(true));
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
node.querySelector('[data-menu]').addEventListener('click', (event) => {
|
|
159
|
+
event.stopPropagation();
|
|
160
|
+
openSessionMenu(item, node);
|
|
144
161
|
});
|
|
145
162
|
list.appendChild(node);
|
|
146
163
|
}
|
|
147
164
|
}
|
|
148
165
|
|
|
166
|
+
/** 会话操作菜单:切换 / 重命名 / 导出 Markdown / 导出 JSON */
|
|
167
|
+
function openSessionMenu(item, anchor) {
|
|
168
|
+
document.querySelector('.popover')?.remove();
|
|
169
|
+
const rect = anchor.getBoundingClientRect();
|
|
170
|
+
const pop = document.createElement('div');
|
|
171
|
+
pop.className = 'popover';
|
|
172
|
+
pop.innerHTML = `
|
|
173
|
+
<button data-act="switch">切换到这一轮</button>
|
|
174
|
+
<button data-act="rename">重命名</button>
|
|
175
|
+
<button data-act="md">导出 Markdown</button>
|
|
176
|
+
<button data-act="json">导出 JSON</button>`;
|
|
177
|
+
document.body.appendChild(pop);
|
|
178
|
+
// 先量再定位:贴着按钮右边缘,并且始终留在视口内
|
|
179
|
+
pop.style.visibility = 'hidden';
|
|
180
|
+
pop.style.left = '0px';
|
|
181
|
+
pop.style.top = '0px';
|
|
182
|
+
const width = pop.offsetWidth;
|
|
183
|
+
const height = pop.offsetHeight;
|
|
184
|
+
const left = Math.max(8, Math.min(rect.right - width, window.innerWidth - width - 8));
|
|
185
|
+
const top = rect.bottom + height > window.innerHeight - 8 ? Math.max(8, rect.top - height - 4) : rect.bottom + 4;
|
|
186
|
+
pop.style.left = `${left}px`;
|
|
187
|
+
pop.style.top = `${top}px`;
|
|
188
|
+
pop.style.visibility = '';
|
|
189
|
+
|
|
190
|
+
const close = () => {
|
|
191
|
+
pop.remove();
|
|
192
|
+
document.removeEventListener('click', close, true);
|
|
193
|
+
};
|
|
194
|
+
document.addEventListener('click', close, true);
|
|
195
|
+
|
|
196
|
+
pop.addEventListener('click', async (event) => {
|
|
197
|
+
const action = event.target.closest('[data-act]')?.dataset.act;
|
|
198
|
+
if (!action) return;
|
|
199
|
+
event.stopPropagation();
|
|
200
|
+
close();
|
|
201
|
+
if (action === 'switch') {
|
|
202
|
+
await post('session', { action: 'switch', ref: item.id });
|
|
203
|
+
await refresh(true);
|
|
204
|
+
} else if (action === 'rename') {
|
|
205
|
+
const title = prompt('新的标题', item.title || '');
|
|
206
|
+
if (title && title.trim()) {
|
|
207
|
+
await post('session', { action: 'switch', ref: item.id });
|
|
208
|
+
await post('session', { action: 'rename', title: title.trim() });
|
|
209
|
+
await refresh(false);
|
|
210
|
+
toast('已重命名');
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
await post('session', { action: 'switch', ref: item.id });
|
|
214
|
+
await refresh(true);
|
|
215
|
+
downloadSession(action === 'json' ? 'json' : 'md', item.id);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function sessionToMarkdown(messages, title) {
|
|
221
|
+
const lines = [`# ${title || '对话'}`, '', `导出时间:${new Date().toLocaleString()}`, ''];
|
|
222
|
+
for (const message of messages) {
|
|
223
|
+
if (message.role === 'tool') continue;
|
|
224
|
+
lines.push(message.role === 'user' ? '## 你' : '## Banana', '');
|
|
225
|
+
lines.push(message.content || '(空)');
|
|
226
|
+
if (message.toolCalls?.length) {
|
|
227
|
+
lines.push('', '工具调用:');
|
|
228
|
+
for (const call of message.toolCalls) {
|
|
229
|
+
lines.push(`- \`${call.name}\` ${JSON.stringify(call.input ?? {}).slice(0, 200)}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
lines.push('');
|
|
233
|
+
}
|
|
234
|
+
return lines.join('\n');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function downloadSession(format, id) {
|
|
238
|
+
const session = state.info.session;
|
|
239
|
+
const blob =
|
|
240
|
+
format === 'json'
|
|
241
|
+
? JSON.stringify(session, null, 2)
|
|
242
|
+
: sessionToMarkdown(session.messages, session.title);
|
|
243
|
+
const blobUrl = URL.createObjectURL(new Blob([blob], { type: format === 'json' ? 'application/json' : 'text/markdown' }));
|
|
244
|
+
const link = document.createElement('a');
|
|
245
|
+
link.href = blobUrl;
|
|
246
|
+
link.download = `${(session.title || id).replace(/[\\/:*?"<>|]/g, '_').slice(0, 40)}.${format}`;
|
|
247
|
+
link.click();
|
|
248
|
+
setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
|
249
|
+
toast(`已导出 ${format === 'json' ? 'JSON' : 'Markdown'}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
149
252
|
function toolGroup(calls, results) {
|
|
150
253
|
const wrap = document.createElement('div');
|
|
151
254
|
wrap.className = 'tool-group';
|
|
@@ -191,14 +294,36 @@ function renderMessage(message) {
|
|
|
191
294
|
function renderEmpty() {
|
|
192
295
|
const wrap = document.createElement('div');
|
|
193
296
|
wrap.className = 'empty';
|
|
297
|
+
// 第一次用、或者还没配 key 时,给一条能照着做的引导
|
|
298
|
+
const provider = state.info?.provider;
|
|
299
|
+
const current = state.info?.providers?.find((item) => item.current);
|
|
300
|
+
const hasKey = Boolean(current?.keyReady);
|
|
301
|
+
const banner = hasKey
|
|
302
|
+
? ''
|
|
303
|
+
: `<div class="empty-warn">
|
|
304
|
+
<strong>还差一步:填上 API Key</strong>
|
|
305
|
+
<span>没有 key 就没法对话。点下面的按钮,填进去立刻就能用(key 只存在你自己的电脑上)。</span>
|
|
306
|
+
<button class="btn primary" id="emptySetup">现在去填</button>
|
|
307
|
+
</div>`;
|
|
194
308
|
wrap.innerHTML =
|
|
195
|
-
'<h1>要做什么</h1
|
|
309
|
+
'<h1>要做什么</h1>' +
|
|
310
|
+
'<p>读写文件、跑命令、查资料,都可以。默认每次都要你确认。</p>' +
|
|
311
|
+
banner +
|
|
196
312
|
'<div class="suggest">' +
|
|
197
|
-
['这个仓库是做什么的', '跑一下测试并修复失败', '给我一个重构方案,先别改代码', '把 README 翻译成英文']
|
|
198
|
-
|
|
313
|
+
['这个仓库是做什么的', '跑一下测试并修复失败', '给我一个重构方案,先别改代码', '把 README 翻译成英文']
|
|
314
|
+
.map((text) => `<button>${escapeHtml(text)}</button>`)
|
|
315
|
+
.join('') +
|
|
316
|
+
'</div>' +
|
|
317
|
+
`<div class="empty-tips">
|
|
318
|
+
<span><b>/</b> 调命令(/help 看全部)</span>
|
|
319
|
+
<span><b>@</b> 引用项目里的文件</span>
|
|
320
|
+
<span><b>${escapeHtml(provider?.name ?? '')}</b> 当前模型</span>
|
|
321
|
+
</div>`;
|
|
199
322
|
wrap.querySelectorAll('.suggest button').forEach((btn) => {
|
|
200
323
|
btn.addEventListener('click', () => { $('input').value = btn.textContent; $('input').focus(); grow(); });
|
|
201
324
|
});
|
|
325
|
+
const setup = wrap.querySelector('#emptySetup');
|
|
326
|
+
if (setup) setup.addEventListener('click', openSettings);
|
|
202
327
|
return wrap;
|
|
203
328
|
}
|
|
204
329
|
|
|
@@ -206,6 +331,11 @@ function renderThread() {
|
|
|
206
331
|
THREAD.innerHTML = '';
|
|
207
332
|
const messages = state.info?.session?.messages ?? [];
|
|
208
333
|
if (messages.length === 0) THREAD.appendChild(renderEmpty());
|
|
334
|
+
else {
|
|
335
|
+
const divider = document.createElement('div');
|
|
336
|
+
divider.className = 'thread-divider';
|
|
337
|
+
THREAD.appendChild(divider);
|
|
338
|
+
}
|
|
209
339
|
for (const message of messages) {
|
|
210
340
|
const node = renderMessage(message);
|
|
211
341
|
if (node) THREAD.appendChild(node);
|
|
@@ -334,6 +464,13 @@ async function send() {
|
|
|
334
464
|
if (!text || state.busy) return;
|
|
335
465
|
input.value = '';
|
|
336
466
|
grow();
|
|
467
|
+
closePalette();
|
|
468
|
+
// 斜杠开头的是命令,不走对话
|
|
469
|
+
if (text.startsWith('/')) {
|
|
470
|
+
await runCommand(text);
|
|
471
|
+
grow();
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
337
474
|
|
|
338
475
|
if (state.info.session.messages.length === 0 && state.info.session.title === '新对话') {
|
|
339
476
|
state.info.session.title = text.slice(0, 40);
|
|
@@ -371,6 +508,7 @@ async function send() {
|
|
|
371
508
|
|
|
372
509
|
let buffer = '';
|
|
373
510
|
let pending = 0;
|
|
511
|
+
let streamEnded = false;
|
|
374
512
|
const paint = () => {
|
|
375
513
|
pending = 0;
|
|
376
514
|
body.textContent = buffer;
|
|
@@ -430,13 +568,27 @@ async function send() {
|
|
|
430
568
|
if (row) {
|
|
431
569
|
row.querySelector('.t-state').innerHTML = event.ok ? svg('check') : svg('x');
|
|
432
570
|
row.classList.toggle('is-error', !event.ok);
|
|
571
|
+
if (event.detail) {
|
|
572
|
+
row.classList.add('is-clickable');
|
|
573
|
+
row.title = '点击展开输出';
|
|
574
|
+
row.addEventListener('click', () => toggleToolOutput(row, event.detail, !event.ok));
|
|
575
|
+
}
|
|
433
576
|
}
|
|
434
577
|
} else if (event.type === 'approval') {
|
|
435
578
|
const allowed = await askApproval(event);
|
|
436
579
|
await post('approve', { id: event.id, allowed });
|
|
580
|
+
} else if (event.type === 'error') {
|
|
581
|
+
// 服务端提前收尾(没配 key、请求出错等):给出能照做的下一步
|
|
582
|
+
body.classList.remove('caret');
|
|
583
|
+
body.innerHTML = markdown(`**${escapeHtml(event.message ?? '出错了')}**`);
|
|
584
|
+
if (event.hint) metaLine('提示', event.hint);
|
|
585
|
+
if (event.code === 'no_api_key') openSettings();
|
|
586
|
+
streamEnded = true;
|
|
437
587
|
} else if (event.type === 'done') {
|
|
438
588
|
body.classList.remove('caret');
|
|
439
|
-
|
|
589
|
+
streamEnded = true;
|
|
590
|
+
if (event.error) body.innerHTML = markdown(`**请求失败** ${escapeHtml(event.error)}`);
|
|
591
|
+
else if (!buffer.trim()) body.innerHTML = markdown('这一轮没有返回内容。');
|
|
440
592
|
else body.innerHTML = markdown(buffer);
|
|
441
593
|
metaLine('步数', event.steps);
|
|
442
594
|
metaLine('用时', (event.ms / 1000).toFixed(1) + 's');
|
|
@@ -455,9 +607,17 @@ async function send() {
|
|
|
455
607
|
}
|
|
456
608
|
}
|
|
457
609
|
}
|
|
610
|
+
// 流结束了却没收到 done/error:多半是被中断,别让界面一直转圈
|
|
611
|
+
if (!streamEnded) {
|
|
612
|
+
body.classList.remove('caret');
|
|
613
|
+
if (body.textContent === '' && toolWrap.childElementCount === 0) {
|
|
614
|
+
body.innerHTML = markdown('这一轮没有返回内容。可以重试,或点「停止」后换个说法。');
|
|
615
|
+
}
|
|
616
|
+
await refresh();
|
|
617
|
+
}
|
|
458
618
|
} catch (error) {
|
|
459
619
|
body.classList.remove('caret');
|
|
460
|
-
body.innerHTML = markdown(`**中断** ${error.message}`);
|
|
620
|
+
body.innerHTML = markdown(`**中断** ${escapeHtml(error.message)}`);
|
|
461
621
|
await refresh();
|
|
462
622
|
} finally {
|
|
463
623
|
setBusy(false);
|
|
@@ -465,6 +625,201 @@ async function send() {
|
|
|
465
625
|
}
|
|
466
626
|
}
|
|
467
627
|
|
|
628
|
+
// ---------------- 主题 ----------------
|
|
629
|
+
// 三态:跟随系统 / 浅色 / 深色,选择记在本地
|
|
630
|
+
const THEMES = ['auto', 'light', 'dark'];
|
|
631
|
+
const THEME_LABEL = { auto: '跟随系统', light: '浅色', dark: '深色' };
|
|
632
|
+
const THEME_ICON = { auto: 'contrast', light: 'sun', dark: 'moon' };
|
|
633
|
+
|
|
634
|
+
function applyTheme(theme) {
|
|
635
|
+
if (theme === 'auto') delete document.documentElement.dataset.theme;
|
|
636
|
+
else document.documentElement.dataset.theme = theme;
|
|
637
|
+
try {
|
|
638
|
+
localStorage.setItem('banana-theme', theme);
|
|
639
|
+
} catch {}
|
|
640
|
+
const button = $('themeBtn');
|
|
641
|
+
if (button) {
|
|
642
|
+
button.innerHTML = svg(THEME_ICON[theme]);
|
|
643
|
+
button.title = `主题:${THEME_LABEL[theme]}(点击切换)`;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function loadTheme() {
|
|
648
|
+
let saved = 'auto';
|
|
649
|
+
try {
|
|
650
|
+
saved = localStorage.getItem('banana-theme') ?? 'auto';
|
|
651
|
+
} catch {}
|
|
652
|
+
if (!THEMES.includes(saved)) saved = 'auto';
|
|
653
|
+
applyTheme(saved);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function cycleTheme() {
|
|
657
|
+
const current = (() => {
|
|
658
|
+
try {
|
|
659
|
+
return localStorage.getItem('banana-theme') ?? 'auto';
|
|
660
|
+
} catch {
|
|
661
|
+
return 'auto';
|
|
662
|
+
}
|
|
663
|
+
})();
|
|
664
|
+
const next = THEMES[(THEMES.indexOf(current) + 1) % THEMES.length];
|
|
665
|
+
applyTheme(next);
|
|
666
|
+
toast(`主题:${THEME_LABEL[next]}`);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// ---------------- 补全面板:/ 命令与 @ 文件共用一套 ----------------
|
|
670
|
+
let commandList = [];
|
|
671
|
+
|
|
672
|
+
function renderCommandPalette(query) {
|
|
673
|
+
const box = $('commandPalette');
|
|
674
|
+
const term = query.replace(/^\//, '').toLowerCase();
|
|
675
|
+
const matches = commandList
|
|
676
|
+
.filter((item) => !term || item.name.startsWith(term) || item.aliases.some((alias) => alias.startsWith(term)))
|
|
677
|
+
.slice(0, 8);
|
|
678
|
+
state.commandMatches = matches.map((item) => ({ kind: 'command', item, insert: item.usage.startsWith('/') ? item.usage + ' ' : '/' + item.name + ' ' }));
|
|
679
|
+
state.commandIndex = 0;
|
|
680
|
+
paintPalette();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function paintPalette() {
|
|
684
|
+
const box = $('commandPalette');
|
|
685
|
+
box.innerHTML = '';
|
|
686
|
+
const rows = state.commandMatches ?? [];
|
|
687
|
+
if (!rows.length) {
|
|
688
|
+
box.innerHTML = '<div class="cmd-empty">没有匹配项</div>';
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
rows.forEach((entry, index) => {
|
|
692
|
+
const row = document.createElement('button');
|
|
693
|
+
row.className = `cmd-item${index === state.commandIndex ? ' is-on' : ''}`;
|
|
694
|
+
row.type = 'button';
|
|
695
|
+
if (entry.kind === 'command') {
|
|
696
|
+
row.innerHTML =
|
|
697
|
+
`<span class="cmd-name">/${escapeHtml(entry.item.name)}</span>` +
|
|
698
|
+
`<span class="cmd-summary">${escapeHtml(entry.item.summary)}</span>` +
|
|
699
|
+
`<span class="cmd-usage">${escapeHtml(entry.item.usage)}</span>`;
|
|
700
|
+
} else {
|
|
701
|
+
row.innerHTML =
|
|
702
|
+
`<span class="cmd-name">${escapeHtml(entry.item.split('/').pop())}</span>` +
|
|
703
|
+
`<span class="cmd-summary">${escapeHtml(entry.item)}</span>` +
|
|
704
|
+
'<span class="cmd-usage">文件</span>';
|
|
705
|
+
}
|
|
706
|
+
row.addEventListener('mousedown', (event) => {
|
|
707
|
+
event.preventDefault();
|
|
708
|
+
applyCompletion(entry);
|
|
709
|
+
});
|
|
710
|
+
box.appendChild(row);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/** 用候选替换输入框里对应的片段。 */
|
|
715
|
+
function applyCompletion(entry) {
|
|
716
|
+
const input = $('input');
|
|
717
|
+
const value = input.value;
|
|
718
|
+
const replace = state.paletteReplace ?? '';
|
|
719
|
+
if (replace && value.endsWith(replace)) {
|
|
720
|
+
input.value = value.slice(0, value.length - replace.length) + entry.insert;
|
|
721
|
+
} else {
|
|
722
|
+
input.value = `${value.trimEnd()} ${entry.insert}`;
|
|
723
|
+
}
|
|
724
|
+
closePalette();
|
|
725
|
+
input.focus();
|
|
726
|
+
grow();
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** 命令面板:本地过滤,无需请求。 */
|
|
730
|
+
function openPalette(query) {
|
|
731
|
+
const box = $('commandPalette');
|
|
732
|
+
box.hidden = false;
|
|
733
|
+
box.dataset.kind = 'command';
|
|
734
|
+
state.paletteReplace = '';
|
|
735
|
+
renderCommandPalette(query);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function closePalette() {
|
|
739
|
+
$('commandPalette').hidden = true;
|
|
740
|
+
state.commandMatches = [];
|
|
741
|
+
state.paletteReplace = '';
|
|
742
|
+
state.pendingFiles = [];
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function movePalette(step) {
|
|
746
|
+
const box = $('commandPalette');
|
|
747
|
+
if (box.hidden) return;
|
|
748
|
+
const rows = [...box.querySelectorAll('.cmd-item')];
|
|
749
|
+
if (!rows.length) return;
|
|
750
|
+
state.commandIndex = (state.commandIndex + step + rows.length) % rows.length;
|
|
751
|
+
rows.forEach((row, index) => row.classList.toggle('is-on', index === state.commandIndex));
|
|
752
|
+
rows[state.commandIndex].scrollIntoView({ block: 'nearest' });
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/** @ 补全:走服务端,和 CLI 用同一份 completeFileReference。 */
|
|
756
|
+
let completeTimer = null;
|
|
757
|
+
async function requestFileComplete(value) {
|
|
758
|
+
clearTimeout(completeTimer);
|
|
759
|
+
completeTimer = setTimeout(async () => {
|
|
760
|
+
try {
|
|
761
|
+
const data = await get(`complete?line=${encodeURIComponent(value)}`);
|
|
762
|
+
if (data.kind !== 'file' || !data.candidates.length) {
|
|
763
|
+
if (!$('commandPalette').hidden && $('commandPalette').dataset.kind === 'file') closePalette();
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
// 补全请求返回时用户可能已经继续输入了,丢弃过期结果
|
|
767
|
+
if ($('input').value !== value) return;
|
|
768
|
+
state.pendingFiles = data.candidates.map((path) => ({ kind: 'file', item: path, insert: path + ' ' }));
|
|
769
|
+
$('commandPalette').hidden = false;
|
|
770
|
+
$('commandPalette').dataset.kind = 'file';
|
|
771
|
+
state.paletteReplace = data.replace;
|
|
772
|
+
state.commandMatches = state.pendingFiles;
|
|
773
|
+
state.commandIndex = 0;
|
|
774
|
+
paintPalette();
|
|
775
|
+
} catch {
|
|
776
|
+
/* 补全失败不影响输入 */
|
|
777
|
+
}
|
|
778
|
+
}, 90);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/** 执行命令:走服务端,和终端里同一份逻辑、同一份命令表。 */
|
|
782
|
+
async function runCommand(text) {
|
|
783
|
+
closePalette();
|
|
784
|
+
const userMsg = document.createElement('article');
|
|
785
|
+
userMsg.className = 'msg user cmd';
|
|
786
|
+
userMsg.innerHTML = '<div class="msg-role">你</div>';
|
|
787
|
+
const userBody = document.createElement('div');
|
|
788
|
+
userBody.className = 'msg-body';
|
|
789
|
+
userBody.textContent = text;
|
|
790
|
+
userMsg.appendChild(userBody);
|
|
791
|
+
THREAD.appendChild(userMsg);
|
|
792
|
+
|
|
793
|
+
const botMsg = document.createElement('article');
|
|
794
|
+
botMsg.className = 'msg assistant';
|
|
795
|
+
const role = document.createElement('div');
|
|
796
|
+
role.className = 'msg-role';
|
|
797
|
+
role.textContent = '命令输出';
|
|
798
|
+
const out = document.createElement('div');
|
|
799
|
+
out.className = 'msg-body cmd-out';
|
|
800
|
+
botMsg.append(role, out);
|
|
801
|
+
THREAD.appendChild(botMsg);
|
|
802
|
+
out.textContent = '执行中…';
|
|
803
|
+
scrollToEnd();
|
|
804
|
+
|
|
805
|
+
try {
|
|
806
|
+
const result = await post('command', { input: text });
|
|
807
|
+
out.textContent = (result.output || '').replace(/\u001b\[\d+J/g, '') || (result.ok ? '(没有输出)' : '');
|
|
808
|
+
out.classList.toggle('is-error', !result.ok);
|
|
809
|
+
if (result.error) out.textContent += (out.textContent ? '\n' : '') + result.error;
|
|
810
|
+
if (result.info) state.info = result.info;
|
|
811
|
+
if (result.sessions) state.sessions = result.sessions;
|
|
812
|
+
// \u001b[clear 之类的终端指令:清空当前视图
|
|
813
|
+
if ((result.output || '').includes('\u001b[clear]')) renderThread();
|
|
814
|
+
renderHeader();
|
|
815
|
+
renderSidebar();
|
|
816
|
+
} catch (error) {
|
|
817
|
+
out.textContent = error.message;
|
|
818
|
+
out.classList.add('is-error');
|
|
819
|
+
}
|
|
820
|
+
scrollToEnd();
|
|
821
|
+
}
|
|
822
|
+
|
|
468
823
|
// ---------------- 附件 ----------------
|
|
469
824
|
function renderAttachments() {
|
|
470
825
|
const box = $('attachments');
|
|
@@ -536,7 +891,7 @@ function renderSettings() {
|
|
|
536
891
|
const keyPlaceholder = active?.keySaved
|
|
537
892
|
? '已保存(留空表示不修改)'
|
|
538
893
|
: active?.keyEnv
|
|
539
|
-
? `默认读取环境变量 ${active.keyEnv}
|
|
894
|
+
? `默认读取环境变量 ${active.keyEnv}(当前${active.keyReady ? '已设置' : '未设置'})`
|
|
540
895
|
: '粘贴 API key';
|
|
541
896
|
keyField.innerHTML =
|
|
542
897
|
'<label>API Key</label>' +
|
|
@@ -562,38 +917,88 @@ function renderSettings() {
|
|
|
562
917
|
const permField = document.createElement('div');
|
|
563
918
|
permField.className = 'field';
|
|
564
919
|
permField.innerHTML = '<label>权限</label>';
|
|
565
|
-
|
|
920
|
+
// 权限是小白最容易懵的地方,所以每种模式都写清楚"改了会发生什么"
|
|
921
|
+
const PERM_INFO = {
|
|
922
|
+
shell: {
|
|
923
|
+
name: '执行命令',
|
|
924
|
+
hint: 'Banana 可以在你的电脑上运行命令(如 npm test、git status)。',
|
|
925
|
+
modes: {
|
|
926
|
+
ask: '每次执行前弹窗问你 —— 最安全,推荐新手用这个',
|
|
927
|
+
allow: '直接执行,不打扰你 —— 方便但风险自负',
|
|
928
|
+
deny: '完全禁止 —— Banana 只能读文件,不能动你的系统',
|
|
929
|
+
},
|
|
930
|
+
},
|
|
931
|
+
write: {
|
|
932
|
+
name: '写入文件',
|
|
933
|
+
hint: '修改或新建文件的能力。每次改动都可以用左下角「撤销」回滚。',
|
|
934
|
+
modes: {
|
|
935
|
+
ask: '每次改文件前给你看改动内容,你确认才写',
|
|
936
|
+
allow: '直接改,不再逐个确认',
|
|
937
|
+
deny: '禁止任何写入 —— 只读模式',
|
|
938
|
+
},
|
|
939
|
+
},
|
|
940
|
+
network: {
|
|
941
|
+
name: '联网',
|
|
942
|
+
hint: '允许 Banana 访问外部网络(搜索、抓网页)。',
|
|
943
|
+
modes: {
|
|
944
|
+
ask: '每次联网都问你',
|
|
945
|
+
allow: '允许联网(默认)',
|
|
946
|
+
deny: '完全断网,只能用本地信息',
|
|
947
|
+
},
|
|
948
|
+
},
|
|
949
|
+
};
|
|
566
950
|
for (const key of ['shell', 'write', 'network']) {
|
|
951
|
+
const spec = PERM_INFO[key];
|
|
567
952
|
const row = document.createElement('div');
|
|
568
953
|
row.className = 'perm-row';
|
|
569
954
|
row.dataset.key = key;
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
'</div>';
|
|
580
|
-
row.querySelector('.seg').addEventListener('click', async (event) => {
|
|
955
|
+
const seg = document.createElement('div');
|
|
956
|
+
seg.className = 'seg';
|
|
957
|
+
seg.innerHTML = ['ask', 'allow', 'deny']
|
|
958
|
+
.map((mode) => {
|
|
959
|
+
const label = mode === 'ask' ? '每次问' : mode === 'allow' ? '直接执行' : '禁止';
|
|
960
|
+
return `<button data-mode="${mode}" class="${info.permissions[key] === mode ? 'is-on' : ''}">${label}</button>`;
|
|
961
|
+
})
|
|
962
|
+
.join('');
|
|
963
|
+
seg.addEventListener('click', async (event) => {
|
|
581
964
|
const button = event.target.closest('[data-mode]');
|
|
582
965
|
if (!button) return;
|
|
583
|
-
const
|
|
584
|
-
const key = row.dataset.key;
|
|
966
|
+
const mode = button.dataset.mode;
|
|
585
967
|
// 立即反馈,失败再回滚并提示,避免看起来"点不动"
|
|
586
|
-
|
|
968
|
+
seg.querySelectorAll('[data-mode]').forEach((item) => item.classList.toggle('is-on', item === button));
|
|
969
|
+
const detail = row.querySelector('.perm-detail');
|
|
970
|
+
if (detail) detail.textContent = spec.modes[mode];
|
|
587
971
|
try {
|
|
588
|
-
await post('action', { action: 'permission', name: key, value:
|
|
972
|
+
await post('action', { action: 'permission', name: key, value: mode });
|
|
589
973
|
await refresh(false);
|
|
974
|
+
toast(`「${spec.name}」已设为:${mode === 'ask' ? '每次问' : mode === 'allow' ? '直接执行' : '禁止'}`);
|
|
590
975
|
} catch (error) {
|
|
591
|
-
|
|
976
|
+
seg.querySelectorAll('[data-mode]').forEach((item) => item.classList.toggle('is-on', item.dataset.mode !== mode));
|
|
977
|
+
if (detail) detail.textContent = spec.modes[info.permissions[key]];
|
|
592
978
|
toast('保存失败:' + error.message);
|
|
593
979
|
}
|
|
594
980
|
});
|
|
981
|
+
|
|
982
|
+
const block = document.createElement('div');
|
|
983
|
+
block.className = 'perm-block';
|
|
984
|
+
const head = document.createElement('div');
|
|
985
|
+
head.className = 'perm-head';
|
|
986
|
+
head.innerHTML = `<span class="perm-name">${escapeHtml(spec.name)}</span>`;
|
|
987
|
+
head.appendChild(seg);
|
|
988
|
+
const desc = document.createElement('div');
|
|
989
|
+
desc.className = 'hint';
|
|
990
|
+
desc.textContent = spec.hint;
|
|
991
|
+
const detail = document.createElement('div');
|
|
992
|
+
detail.className = 'perm-detail';
|
|
993
|
+
detail.textContent = spec.modes[info.permissions[key]];
|
|
994
|
+
block.append(head, desc, detail);
|
|
995
|
+
row.appendChild(block);
|
|
595
996
|
permField.appendChild(row);
|
|
596
997
|
}
|
|
998
|
+
const note = document.createElement('div');
|
|
999
|
+
note.className = 'hint perm-note';
|
|
1000
|
+
note.textContent = '改动立刻生效,并保存在配置里(CLI 和 GUI 共用同一份设置)。不确定就保持「每次问」。';
|
|
1001
|
+
permField.appendChild(note);
|
|
597
1002
|
body.appendChild(permField);
|
|
598
1003
|
|
|
599
1004
|
const toolsField = document.createElement('div');
|
|
@@ -622,6 +1027,91 @@ function renderSettings() {
|
|
|
622
1027
|
body.appendChild(sysField);
|
|
623
1028
|
}
|
|
624
1029
|
|
|
1030
|
+
// ---------------- 文件面板 ----------------
|
|
1031
|
+
const FILE_ICON = { dir: 'folder', file: 'file' };
|
|
1032
|
+
|
|
1033
|
+
async function loadTree(path = '.', container = $('filesTree')) {
|
|
1034
|
+
container.innerHTML = '<div class="hint" style="padding:8px 10px">读取中…</div>';
|
|
1035
|
+
try {
|
|
1036
|
+
const { entries } = await get(`files?path=${encodeURIComponent(path)}`);
|
|
1037
|
+
container.innerHTML = '';
|
|
1038
|
+
if (!entries.length) {
|
|
1039
|
+
container.innerHTML = '<div class="hint" style="padding:8px 10px">(空目录)</div>';
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
for (const entry of entries) {
|
|
1043
|
+
const row = document.createElement('button');
|
|
1044
|
+
row.className = 'file-row';
|
|
1045
|
+
row.innerHTML =
|
|
1046
|
+
`<span class="tool-caret">${entry.type === 'dir' ? svg('chevron') : ''}</span>` +
|
|
1047
|
+
`<span>${svg(FILE_ICON[entry.type])}</span>` +
|
|
1048
|
+
`<span class="f-name">${escapeHtml(entry.name)}</span>`;
|
|
1049
|
+
row.addEventListener('click', async () => {
|
|
1050
|
+
if (entry.type === 'dir') {
|
|
1051
|
+
const holder = row.nextElementSibling;
|
|
1052
|
+
if (holder?.classList.contains('file-children')) {
|
|
1053
|
+
holder.remove();
|
|
1054
|
+
row.classList.remove('is-open');
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
row.classList.add('is-open');
|
|
1058
|
+
const box = document.createElement('div');
|
|
1059
|
+
box.className = 'file-children';
|
|
1060
|
+
row.after(box);
|
|
1061
|
+
await loadTree(entry.path, box);
|
|
1062
|
+
} else {
|
|
1063
|
+
await openFile(entry.path);
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
container.appendChild(row);
|
|
1067
|
+
}
|
|
1068
|
+
} catch (error) {
|
|
1069
|
+
container.innerHTML = `<div class="hint" style="padding:8px 10px">${escapeHtml(error.message)}</div>`;
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async function openFile(path) {
|
|
1074
|
+
const box = $('filesPreview');
|
|
1075
|
+
const body = $('previewBody');
|
|
1076
|
+
box.hidden = false;
|
|
1077
|
+
$('previewName').textContent = path;
|
|
1078
|
+
body.textContent = '读取中…';
|
|
1079
|
+
try {
|
|
1080
|
+
const data = await get(`file?path=${encodeURIComponent(path)}`);
|
|
1081
|
+
$('insertPath').dataset.path = path;
|
|
1082
|
+
if (data.binary) body.textContent = `二进制文件(${data.bytes} 字节),无法预览。`;
|
|
1083
|
+
else if (data.tooLarge) body.textContent = `文件较大(${data.bytes} 字节),未加载。`;
|
|
1084
|
+
else body.textContent = data.content ?? '';
|
|
1085
|
+
document.querySelectorAll('.file-row.is-active').forEach((node) => node.classList.remove('is-active'));
|
|
1086
|
+
} catch (error) {
|
|
1087
|
+
body.textContent = error.message;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
async function toggleFiles() {
|
|
1092
|
+
const panel = $('filesPanel');
|
|
1093
|
+
panel.hidden = !panel.hidden;
|
|
1094
|
+
if (!panel.hidden && !$('filesTree').childElementCount) {
|
|
1095
|
+
$('filesTitle').textContent = `项目文件 · ${state.info?.workspace ?? ''}`;
|
|
1096
|
+
await loadTree('.');
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ---------------- 工具输出详情 ----------------
|
|
1101
|
+
function toggleToolOutput(row, detail, isError) {
|
|
1102
|
+
const existing = row.nextElementSibling;
|
|
1103
|
+
if (existing?.classList.contains('tool-output')) {
|
|
1104
|
+
existing.remove();
|
|
1105
|
+
row.classList.remove('is-open');
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
const pre = document.createElement('div');
|
|
1109
|
+
pre.className = `tool-output${isError ? ' is-error' : ''}`;
|
|
1110
|
+
pre.textContent = detail || '(无输出)';
|
|
1111
|
+
row.after(pre);
|
|
1112
|
+
row.classList.add('is-open');
|
|
1113
|
+
}
|
|
1114
|
+
|
|
625
1115
|
// ---------------- 启动 ----------------
|
|
626
1116
|
function openSettings() {
|
|
627
1117
|
renderSettings();
|
|
@@ -640,7 +1130,10 @@ function bind() {
|
|
|
640
1130
|
$('abortBtn').innerHTML = svg('stop');
|
|
641
1131
|
$('toggleSidebar').innerHTML = svg('list');
|
|
642
1132
|
$('closeSettings').innerHTML = svg('x');
|
|
1133
|
+
$('filesBtn').innerHTML = svg('panel');
|
|
1134
|
+
$('closeFiles').innerHTML = svg('x');
|
|
643
1135
|
hydrate();
|
|
1136
|
+
loadTheme();
|
|
644
1137
|
|
|
645
1138
|
$('newChat').addEventListener('click', () => post('session', { action: 'new' }).then(() => refresh(true)));
|
|
646
1139
|
$('abortBtn').addEventListener('click', async () => { controller?.abort(); await post('abort'); });
|
|
@@ -654,6 +1147,18 @@ function bind() {
|
|
|
654
1147
|
if (event.target === $('settingsSheet')) closeSettings();
|
|
655
1148
|
});
|
|
656
1149
|
$('toggleSidebar').addEventListener('click', () => $('sidebar').classList.toggle('open'));
|
|
1150
|
+
$('filesBtn').addEventListener('click', toggleFiles);
|
|
1151
|
+
$('themeBtn').addEventListener('click', cycleTheme);
|
|
1152
|
+
$('closeFiles').addEventListener('click', () => { $('filesPanel').hidden = true; });
|
|
1153
|
+
$('insertPath').addEventListener('click', () => {
|
|
1154
|
+
const path = $('insertPath').dataset.path;
|
|
1155
|
+
if (!path) return;
|
|
1156
|
+
const box = $('input');
|
|
1157
|
+
box.value = box.value.trim() ? `${box.value.trim()} @${path} ` : `@${path} `;
|
|
1158
|
+
box.focus();
|
|
1159
|
+
box.setSelectionRange(box.value.length, box.value.length);
|
|
1160
|
+
toast('已插入文件引用');
|
|
1161
|
+
});
|
|
657
1162
|
$('approvalNo').addEventListener('click', () => closeApproval(false));
|
|
658
1163
|
$('approvalYes').addEventListener('click', () => closeApproval(true));
|
|
659
1164
|
|
|
@@ -663,8 +1168,36 @@ function bind() {
|
|
|
663
1168
|
});
|
|
664
1169
|
|
|
665
1170
|
$('composer').addEventListener('submit', (event) => { event.preventDefault(); send(); });
|
|
666
|
-
$('input').addEventListener('input',
|
|
1171
|
+
$('input').addEventListener('input', () => {
|
|
1172
|
+
grow();
|
|
1173
|
+
const value = $('input').value;
|
|
1174
|
+
// / 开头 → 命令面板;结尾是 @路径 → 文件补全(服务端用 CLI 同一份逻辑)
|
|
1175
|
+
if (/^\/[a-zA-Z?]*$/.test(value) && commandList.length) openPalette(value);
|
|
1176
|
+
else if (/(^|\s)@[^\s]*$/.test(value)) requestFileComplete(value);
|
|
1177
|
+
else closePalette();
|
|
1178
|
+
});
|
|
667
1179
|
$('input').addEventListener('keydown', (event) => {
|
|
1180
|
+
if (!$('commandPalette').hidden) {
|
|
1181
|
+
if (event.key === 'ArrowDown') { event.preventDefault(); movePalette(1); return; }
|
|
1182
|
+
if (event.key === 'ArrowUp') { event.preventDefault(); movePalette(-1); return; }
|
|
1183
|
+
if (event.key === 'Tab') {
|
|
1184
|
+
event.preventDefault();
|
|
1185
|
+
const picked = state.commandMatches?.[state.commandIndex ?? 0];
|
|
1186
|
+
if (picked) applyCompletion(picked);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
// 文件补全面板里回车直接发送更顺手;命令面板里回车优先补全
|
|
1190
|
+
if (event.key === 'Enter' && !event.shiftKey && $('commandPalette').dataset.kind === 'command' && state.commandMatches?.length) {
|
|
1191
|
+
event.preventDefault();
|
|
1192
|
+
const picked = state.commandMatches[state.commandIndex ?? 0];
|
|
1193
|
+
if (picked) {
|
|
1194
|
+
applyCompletion(picked);
|
|
1195
|
+
renderCommandPalette(picked.item.name ?? '');
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (event.key === 'Escape') { event.preventDefault(); closePalette(); return; }
|
|
1200
|
+
}
|
|
668
1201
|
if (event.key === 'Enter' && !event.shiftKey && !event.isComposing) { event.preventDefault(); send(); }
|
|
669
1202
|
});
|
|
670
1203
|
|
|
@@ -672,6 +1205,7 @@ function bind() {
|
|
|
672
1205
|
if (event.key === 'Escape') {
|
|
673
1206
|
if (!$('approvalModal').hidden) closeApproval(false);
|
|
674
1207
|
else if (!$('settingsSheet').hidden) closeSettings();
|
|
1208
|
+
else if (!$('filesPanel').hidden) $('filesPanel').hidden = true;
|
|
675
1209
|
else if ($('sidebar').classList.contains('open')) $('sidebar').classList.remove('open');
|
|
676
1210
|
}
|
|
677
1211
|
});
|
|
@@ -688,6 +1222,12 @@ function bind() {
|
|
|
688
1222
|
|
|
689
1223
|
async function boot() {
|
|
690
1224
|
bind();
|
|
1225
|
+
// 命令表来自服务端(与 CLI 同一份),拉不到也不影响对话
|
|
1226
|
+
try {
|
|
1227
|
+
commandList = (await get('commands')).commands ?? [];
|
|
1228
|
+
} catch {
|
|
1229
|
+
commandList = [];
|
|
1230
|
+
}
|
|
691
1231
|
await refresh(true);
|
|
692
1232
|
grow();
|
|
693
1233
|
$('input').focus();
|
package/ui/index.html
CHANGED
|
@@ -42,6 +42,8 @@
|
|
|
42
42
|
<button class="chip-btn" id="modelBtn" title="模型与权限">
|
|
43
43
|
<span data-icon="chip"></span><span id="modelLabel">—</span>
|
|
44
44
|
</button>
|
|
45
|
+
<button class="icon-btn" id="filesBtn" title="项目文件" aria-label="项目文件"></button>
|
|
46
|
+
<button class="icon-btn" id="themeBtn" title="切换主题" aria-label="切换主题"></button>
|
|
45
47
|
<button class="icon-btn" id="abortBtn" title="停止" aria-label="停止" hidden></button>
|
|
46
48
|
</div>
|
|
47
49
|
</header>
|
|
@@ -51,7 +53,8 @@
|
|
|
51
53
|
<div class="composer-wrap">
|
|
52
54
|
<div class="attachments" id="attachments" hidden></div>
|
|
53
55
|
<form class="composer" id="composer">
|
|
54
|
-
<
|
|
56
|
+
<div class="command-palette" id="commandPalette" hidden></div>
|
|
57
|
+
<textarea id="input" rows="1" placeholder="说点什么;输入 / 调命令,@ 引文件,Enter 发送,Shift+Enter 换行"></textarea>
|
|
55
58
|
<button class="send-btn" id="sendBtn" type="submit" title="发送" aria-label="发送"></button>
|
|
56
59
|
</form>
|
|
57
60
|
<div class="composer-hint" id="composerHint"></div>
|
|
@@ -69,6 +72,21 @@
|
|
|
69
72
|
</div>
|
|
70
73
|
</div>
|
|
71
74
|
|
|
75
|
+
<aside class="files-panel" id="filesPanel" hidden>
|
|
76
|
+
<header class="files-head">
|
|
77
|
+
<span id="filesTitle">项目文件</span>
|
|
78
|
+
<button class="icon-btn" id="closeFiles" aria-label="关闭文件面板"></button>
|
|
79
|
+
</header>
|
|
80
|
+
<div class="files-tree" id="filesTree"></div>
|
|
81
|
+
<div class="files-preview" id="filesPreview" hidden>
|
|
82
|
+
<div class="files-preview-head">
|
|
83
|
+
<span id="previewName"></span>
|
|
84
|
+
<button class="btn tiny" id="insertPath">插入到输入框</button>
|
|
85
|
+
</div>
|
|
86
|
+
<pre id="previewBody"></pre>
|
|
87
|
+
</div>
|
|
88
|
+
</aside>
|
|
89
|
+
|
|
72
90
|
<div class="modal" id="approvalModal" hidden>
|
|
73
91
|
<div class="modal-card">
|
|
74
92
|
<div class="modal-head">
|
package/ui/style.css
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
--sans: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/* 深色主题:默认跟随系统,也可以手动锁 light / dark */
|
|
22
23
|
@media (prefers-color-scheme: dark) {
|
|
23
|
-
:root {
|
|
24
|
+
:root:not([data-theme='light']) {
|
|
24
25
|
--bg: #0e0e10;
|
|
25
26
|
--panel: #131316;
|
|
26
27
|
--panel-2: #1a1a1e;
|
|
@@ -35,6 +36,20 @@
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
|
|
39
|
+
:root[data-theme='dark'] {
|
|
40
|
+
--bg: #0e0e10;
|
|
41
|
+
--panel: #131316;
|
|
42
|
+
--panel-2: #1a1a1e;
|
|
43
|
+
--line: #26262b;
|
|
44
|
+
--line-strong: #33333a;
|
|
45
|
+
--text: #f2f2f0;
|
|
46
|
+
--text-2: #a1a1a8;
|
|
47
|
+
--text-3: #74747c;
|
|
48
|
+
--inverse: #f2f2f0;
|
|
49
|
+
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
|
50
|
+
--shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.55);
|
|
51
|
+
}
|
|
52
|
+
|
|
38
53
|
* { box-sizing: border-box; }
|
|
39
54
|
|
|
40
55
|
html, body {
|
|
@@ -486,6 +501,10 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
|
|
|
486
501
|
.sheet[hidden] { display: none; }
|
|
487
502
|
.sheet.is-closing { animation: fadeOut 160ms var(--ease-in) both; }
|
|
488
503
|
@keyframes fadeOut { to { opacity: 0; } }
|
|
504
|
+
@keyframes pop {
|
|
505
|
+
from { opacity: 0; transform: translateY(4px) scale(0.99); }
|
|
506
|
+
to { opacity: 1; transform: none; }
|
|
507
|
+
}
|
|
489
508
|
.sheet-panel {
|
|
490
509
|
width: min(420px, 92vw);
|
|
491
510
|
height: 100%;
|
|
@@ -534,11 +553,246 @@ pre.code code { border: 0; background: none; padding: 0; font-size: 12.6px; line
|
|
|
534
553
|
.seg button:active { transform: scale(0.98); }
|
|
535
554
|
.seg button.is-on { background: var(--inverse); color: var(--bg); }
|
|
536
555
|
|
|
537
|
-
.perm-row { display:
|
|
538
|
-
.perm-
|
|
556
|
+
.perm-row { display: block; }
|
|
557
|
+
.perm-block { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; }
|
|
558
|
+
.perm-head { display: flex; align-items: center; gap: 10px; margin-bottom: 6px; }
|
|
559
|
+
.perm-name { font-size: 13px; color: var(--text); font-weight: 500; }
|
|
560
|
+
.perm-head .seg { flex: none; width: 190px; }
|
|
561
|
+
.perm-detail {
|
|
562
|
+
margin-top: 6px;
|
|
563
|
+
padding-top: 6px;
|
|
564
|
+
border-top: 1px dashed var(--line);
|
|
565
|
+
font-size: 12px;
|
|
566
|
+
color: var(--text-2);
|
|
567
|
+
line-height: 1.5;
|
|
568
|
+
}
|
|
569
|
+
.perm-note { margin-top: 4px; }
|
|
570
|
+
|
|
571
|
+
/* ---------- 空状态引导 ---------- */
|
|
572
|
+
.empty-warn {
|
|
573
|
+
display: flex;
|
|
574
|
+
flex-direction: column;
|
|
575
|
+
align-items: flex-start;
|
|
576
|
+
gap: 8px;
|
|
577
|
+
max-width: 520px;
|
|
578
|
+
margin: 4px auto 22px;
|
|
579
|
+
padding: 16px 18px;
|
|
580
|
+
border: 1px solid var(--line-strong);
|
|
581
|
+
border-radius: var(--radius);
|
|
582
|
+
background: var(--panel);
|
|
583
|
+
text-align: left;
|
|
584
|
+
}
|
|
585
|
+
.empty-warn strong { font-size: 14.5px; font-weight: 600; }
|
|
586
|
+
.empty-warn span { font-size: 13px; color: var(--text-2); line-height: 1.6; }
|
|
587
|
+
.empty-warn .btn { margin-top: 4px; }
|
|
588
|
+
.empty-tips {
|
|
589
|
+
display: flex;
|
|
590
|
+
flex-wrap: wrap;
|
|
591
|
+
justify-content: center;
|
|
592
|
+
gap: 6px 18px;
|
|
593
|
+
margin-top: 22px;
|
|
594
|
+
font-size: 12px;
|
|
595
|
+
color: var(--text-3);
|
|
596
|
+
}
|
|
597
|
+
.empty-tips b { font-family: var(--mono); font-weight: 600; color: var(--text-2); }
|
|
598
|
+
.thread-divider {
|
|
599
|
+
max-width: 780px;
|
|
600
|
+
margin: 6px auto 18px;
|
|
601
|
+
padding: 0 24px;
|
|
602
|
+
display: flex;
|
|
603
|
+
align-items: center;
|
|
604
|
+
gap: 12px;
|
|
605
|
+
color: var(--text-3);
|
|
606
|
+
font-size: 11.5px;
|
|
607
|
+
}
|
|
608
|
+
.thread-divider::before,
|
|
609
|
+
.thread-divider::after {
|
|
610
|
+
content: '';
|
|
611
|
+
flex: 1;
|
|
612
|
+
height: 1px;
|
|
613
|
+
background: var(--line);
|
|
614
|
+
}
|
|
615
|
+
.thread-divider:empty::before,
|
|
616
|
+
.thread-divider:empty::after { display: none; }
|
|
617
|
+
|
|
618
|
+
/* ---------- 命令面板 ---------- */
|
|
619
|
+
.command-palette {
|
|
620
|
+
position: absolute;
|
|
621
|
+
left: 12px;
|
|
622
|
+
right: 12px;
|
|
623
|
+
bottom: calc(100% + 8px);
|
|
624
|
+
z-index: 40;
|
|
625
|
+
padding: 5px;
|
|
626
|
+
border: 1px solid var(--line);
|
|
627
|
+
border-radius: 11px;
|
|
628
|
+
background: var(--bg);
|
|
629
|
+
box-shadow: var(--shadow-lg);
|
|
630
|
+
animation: pop 170ms var(--ease) both;
|
|
631
|
+
max-height: 320px;
|
|
632
|
+
overflow: auto;
|
|
633
|
+
}
|
|
634
|
+
.command-palette[hidden] { display: none; }
|
|
635
|
+
.cmd-item {
|
|
636
|
+
display: grid;
|
|
637
|
+
grid-template-columns: minmax(70px, 108px) 1fr auto;
|
|
638
|
+
align-items: baseline;
|
|
639
|
+
gap: 10px;
|
|
640
|
+
width: 100%;
|
|
641
|
+
padding: 7px 9px;
|
|
642
|
+
border: 0;
|
|
643
|
+
border-radius: 7px;
|
|
644
|
+
background: transparent;
|
|
645
|
+
text-align: left;
|
|
646
|
+
cursor: pointer;
|
|
647
|
+
transition: background 120ms var(--ease);
|
|
648
|
+
}
|
|
649
|
+
.cmd-item:hover, .cmd-item.is-on { background: var(--panel-2); }
|
|
650
|
+
.cmd-name { font-family: var(--mono); font-size: 12.5px; color: var(--text); }
|
|
651
|
+
.cmd-summary { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
652
|
+
.cmd-usage { font-family: var(--mono); font-size: 11.5px; color: var(--text-3); }
|
|
653
|
+
.cmd-empty { padding: 10px; font-size: 12.5px; color: var(--text-3); }
|
|
654
|
+
|
|
655
|
+
/* 命令输出用等宽字体,和对话正文区分开 */
|
|
656
|
+
.msg.cmd .msg-body { font-family: var(--mono); font-size: 12.6px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text-2); }
|
|
657
|
+
.cmd-out.is-error { color: var(--text); }
|
|
658
|
+
.msg.cmd .msg-role { color: var(--text-3); }
|
|
539
659
|
|
|
540
660
|
.hint { font-size: 12px; color: var(--text-3); line-height: 1.5; }
|
|
541
661
|
|
|
662
|
+
/* ---------- 工具详情 ---------- */
|
|
663
|
+
.tool-row { cursor: default; }
|
|
664
|
+
.tool-row.is-open .t-detail { color: var(--text-2); }
|
|
665
|
+
.tool-caret { transition: transform 200ms var(--ease); }
|
|
666
|
+
.tool-row.is-open .tool-caret { transform: rotate(90deg); }
|
|
667
|
+
.tool-output {
|
|
668
|
+
margin: 2px 0 8px;
|
|
669
|
+
padding: 10px 12px;
|
|
670
|
+
max-height: 320px;
|
|
671
|
+
overflow: auto;
|
|
672
|
+
border: 1px solid var(--line);
|
|
673
|
+
border-radius: 9px;
|
|
674
|
+
background: var(--panel);
|
|
675
|
+
font-family: var(--mono);
|
|
676
|
+
font-size: 12.2px;
|
|
677
|
+
line-height: 1.55;
|
|
678
|
+
white-space: pre-wrap;
|
|
679
|
+
overflow-wrap: anywhere;
|
|
680
|
+
animation: fade 200ms var(--ease) both;
|
|
681
|
+
}
|
|
682
|
+
.tool-output.is-error { color: var(--text); border-color: var(--line-strong); }
|
|
683
|
+
|
|
684
|
+
/* ---------- 文件面板 ---------- */
|
|
685
|
+
.files-panel {
|
|
686
|
+
position: fixed;
|
|
687
|
+
top: 0;
|
|
688
|
+
right: 0;
|
|
689
|
+
bottom: 0;
|
|
690
|
+
width: min(420px, 92vw);
|
|
691
|
+
background: var(--bg);
|
|
692
|
+
border-left: 1px solid var(--line);
|
|
693
|
+
box-shadow: var(--shadow-lg);
|
|
694
|
+
display: flex;
|
|
695
|
+
flex-direction: column;
|
|
696
|
+
z-index: 30;
|
|
697
|
+
animation: slide 260ms var(--ease) both;
|
|
698
|
+
}
|
|
699
|
+
.files-panel[hidden] { display: none; }
|
|
700
|
+
.files-head {
|
|
701
|
+
display: flex;
|
|
702
|
+
align-items: center;
|
|
703
|
+
justify-content: space-between;
|
|
704
|
+
height: 54px;
|
|
705
|
+
padding: 0 10px 0 16px;
|
|
706
|
+
border-bottom: 1px solid var(--line);
|
|
707
|
+
font-size: 13px;
|
|
708
|
+
color: var(--text-2);
|
|
709
|
+
}
|
|
710
|
+
.files-tree { flex: 1; min-height: 0; overflow: auto; padding: 8px; }
|
|
711
|
+
.file-row {
|
|
712
|
+
display: flex;
|
|
713
|
+
align-items: center;
|
|
714
|
+
gap: 6px;
|
|
715
|
+
width: 100%;
|
|
716
|
+
padding: 5px 8px;
|
|
717
|
+
border: 0;
|
|
718
|
+
border-radius: 7px;
|
|
719
|
+
background: transparent;
|
|
720
|
+
color: var(--text-2);
|
|
721
|
+
font-size: 13px;
|
|
722
|
+
text-align: left;
|
|
723
|
+
cursor: pointer;
|
|
724
|
+
transition: background 140ms var(--ease), color 140ms var(--ease);
|
|
725
|
+
}
|
|
726
|
+
.file-row:hover { background: var(--panel-2); color: var(--text); }
|
|
727
|
+
.file-row.is-active { background: var(--panel-2); color: var(--text); }
|
|
728
|
+
.tool-caret { flex: none; display: inline-grid; place-items: center; width: 13px; height: 13px; color: var(--text-3); }
|
|
729
|
+
.file-row.is-open .tool-caret { transform: rotate(90deg); }
|
|
730
|
+
.file-children { margin-left: 13px; padding-left: 7px; border-left: 1px solid var(--line); }
|
|
731
|
+
.tool-row.is-clickable { cursor: pointer; }
|
|
732
|
+
.tool-row.is-clickable:hover .t-detail { color: var(--text); }
|
|
733
|
+
.tool-row.is-clickable .t-state { opacity: 1; }
|
|
734
|
+
.file-row svg { width: 13px; height: 13px; flex: none; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
|
|
735
|
+
.file-row .f-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
736
|
+
.files-preview { border-top: 1px solid var(--line); max-height: 46%; display: flex; flex-direction: column; }
|
|
737
|
+
.files-preview-head {
|
|
738
|
+
display: flex;
|
|
739
|
+
align-items: center;
|
|
740
|
+
justify-content: space-between;
|
|
741
|
+
gap: 8px;
|
|
742
|
+
padding: 8px 10px 8px 14px;
|
|
743
|
+
font-size: 12px;
|
|
744
|
+
color: var(--text-3);
|
|
745
|
+
font-family: var(--mono);
|
|
746
|
+
}
|
|
747
|
+
.files-preview pre {
|
|
748
|
+
margin: 0;
|
|
749
|
+
padding: 0 14px 14px;
|
|
750
|
+
overflow: auto;
|
|
751
|
+
font-family: var(--mono);
|
|
752
|
+
font-size: 12.2px;
|
|
753
|
+
line-height: 1.55;
|
|
754
|
+
color: var(--text-2);
|
|
755
|
+
white-space: pre-wrap;
|
|
756
|
+
overflow-wrap: anywhere;
|
|
757
|
+
}
|
|
758
|
+
.btn.tiny { height: 24px; padding: 0 9px; font-size: 12px; border-radius: 7px; }
|
|
759
|
+
|
|
760
|
+
/* ---------- popover ---------- */
|
|
761
|
+
.popover {
|
|
762
|
+
position: fixed;
|
|
763
|
+
z-index: 60;
|
|
764
|
+
min-width: 172px;
|
|
765
|
+
padding: 5px;
|
|
766
|
+
border: 1px solid var(--line);
|
|
767
|
+
border-radius: 10px;
|
|
768
|
+
background: var(--bg);
|
|
769
|
+
box-shadow: var(--shadow-lg);
|
|
770
|
+
animation: pop 160ms var(--ease) both;
|
|
771
|
+
}
|
|
772
|
+
.popover button {
|
|
773
|
+
display: block;
|
|
774
|
+
width: 100%;
|
|
775
|
+
text-align: left;
|
|
776
|
+
padding: 7px 10px;
|
|
777
|
+
border: 0;
|
|
778
|
+
border-radius: 7px;
|
|
779
|
+
background: transparent;
|
|
780
|
+
color: var(--text-2);
|
|
781
|
+
font-size: 13px;
|
|
782
|
+
cursor: pointer;
|
|
783
|
+
transition: background 130ms var(--ease), color 130ms var(--ease);
|
|
784
|
+
}
|
|
785
|
+
.popover button:hover { background: var(--panel-2); color: var(--text); }
|
|
786
|
+
|
|
787
|
+
/* ---------- 侧边栏操作 ---------- */
|
|
788
|
+
.session-item { position: relative; padding-right: 52px; }
|
|
789
|
+
.s-actions { position: absolute; right: 6px; top: 6px; display: flex; gap: 2px; opacity: 0; transition: opacity 140ms var(--ease); }
|
|
790
|
+
.session-item:hover .s-actions, .session-item.is-active .s-actions { opacity: 1; }
|
|
791
|
+
.s-act { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 5px; color: var(--text-3); transition: background 130ms var(--ease), color 130ms var(--ease); }
|
|
792
|
+
.s-act:hover { background: var(--panel-2); color: var(--text); }
|
|
793
|
+
.s-act svg { width: 13px; height: 13px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
|
|
794
|
+
.session-item.is-active .s-title { padding-right: 0; }
|
|
795
|
+
|
|
542
796
|
@media (prefers-reduced-motion: reduce) {
|
|
543
797
|
*, *::before, *::after {
|
|
544
798
|
animation-duration: 1ms !important;
|