llm-api-gateway-cli 1.0.5 → 1.0.6

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.
@@ -0,0 +1,135 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>LLM API Gateway · MCP 工具</title>
7
+ <link rel="stylesheet" href="/styles.css" />
8
+ <link rel="stylesheet" href="/mcp.css" />
9
+ <script>
10
+ // 防闪白:必须在首次绘制前把主题定下来(逻辑与 /theme.js 一致,各页面都一样)。
11
+ (() => {
12
+ try {
13
+ let t = localStorage.getItem('lgw.theme');
14
+ if (t !== 'dark' && t !== 'light') {
15
+ t = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
16
+ }
17
+ document.documentElement.setAttribute('data-theme', t);
18
+ document.documentElement.style.colorScheme = t;
19
+ } catch (e) {
20
+ document.documentElement.setAttribute('data-theme', 'dark');
21
+ }
22
+ // 被 Tab 外壳嵌着时挂上 data-embed:styles.css 据此收起本页自己的顶栏(顶栏归外壳)
23
+ try {
24
+ if (new URLSearchParams(location.search).get('embed') === '1') {
25
+ document.documentElement.setAttribute('data-embed', '1');
26
+ }
27
+ } catch (e) {
28
+ /* 地址解析不了就当作没嵌入,页面照常独立使用 */
29
+ }
30
+ })();
31
+ </script>
32
+ <!-- 外部集成:地址上带 ?bg=<底色> 时按底色推导整套配色。必须是阻塞脚本(排在 theme.js 之前) -->
33
+ <script src="/tint.js"></script>
34
+ <script src="/theme.js" defer></script>
35
+ </head>
36
+ <body>
37
+ <div class="app">
38
+ <aside class="sidebar" id="sidebar">
39
+ <div class="sidebar-head">
40
+ <a class="btn ghost block" href="/task" title="回到任务页(模型真读写你选的目录)">← 回到任务页</a>
41
+ <div class="mcp-field" style="margin:0">
42
+ <input id="mcp-search" type="search" spellcheck="false" autocomplete="off"
43
+ placeholder="搜索工具 / 服务器" aria-label="搜索 MCP 工具" />
44
+ </div>
45
+ </div>
46
+ <div class="session-list" id="mcp-tools"></div>
47
+ <div class="sidebar-foot">
48
+ <span id="mcp-tool-count">工具 —</span>
49
+ </div>
50
+ <div class="store-line" id="mcp-gateway" title="">—</div>
51
+ </aside>
52
+
53
+ <main class="main">
54
+ <header class="topbar">
55
+ <div class="brand">
56
+ <span class="dot" id="mcp-dot"></span>
57
+ <h1>MCP 工具</h1>
58
+ </div>
59
+ <div class="topbar-actions">
60
+ <button class="btn ghost" id="mcp-reload" type="button" title="重新读一次绑定与工具清单">刷新</button>
61
+ <a class="btn ghost" href="/manual"
62
+ title="操作手册:三端指令对照与 MCP 子命令说明">手册</a>
63
+ <div class="theme-switch" role="group" aria-label="主题">
64
+ <button class="theme-btn" type="button" data-theme-option="system" title="跟随系统" aria-label="跟随系统">◐</button>
65
+ <button class="theme-btn" type="button" data-theme-option="light" title="白天" aria-label="白天">☀</button>
66
+ <button class="theme-btn" type="button" data-theme-option="dark" title="黑夜" aria-label="黑夜">☾</button>
67
+ </div>
68
+ </div>
69
+ </header>
70
+
71
+ <p class="mcp-banner" id="mcp-banner" hidden></p>
72
+
73
+ <section class="messages" id="mcp-body">
74
+ <div class="mcp-doc">
75
+ <div class="mcp-card">
76
+ <h2>这把密钥的 MCP(范围就是你这把 sk- 密钥,不是全局)</h2>
77
+ <div class="mcp-row">
78
+ <span>模式</span>
79
+ <select class="mcp-select" id="mcp-mode" aria-label="MCP 模式">
80
+ <option value="off">off · 本密钥不用 MCP</option>
81
+ <option value="inject">inject · 只把工具定义注入上游</option>
82
+ <option value="loop">loop · 网关代执行</option>
83
+ </select>
84
+ <button class="btn ghost tiny" id="mcp-mode-save" type="button">保存模式(不动绑定)</button>
85
+ <span id="mcp-enabled"></span>
86
+ </div>
87
+ <div class="mcp-row" id="mcp-selfserver">自助登记:—</div>
88
+ <p class="mcp-note">
89
+ 密钥只存在服务端:这一页(浏览器)从不接触 <code>sk-</code>,所有动作都由本机服务代发给网关。
90
+ </p>
91
+ </div>
92
+
93
+ <div class="mcp-card">
94
+ <h2>已绑定 / 可自助加</h2>
95
+ <div id="mcp-bind-list"></div>
96
+ <div class="mcp-actions">
97
+ <button class="btn primary" id="mcp-bind-save" type="button" disabled>保存绑定</button>
98
+ <span class="mcp-note" id="mcp-bind-note">勾选后保存;只能在自己可选的范围内改。</span>
99
+ </div>
100
+ </div>
101
+
102
+ <div class="mcp-card">
103
+ <h2>我自建的(BYO-A:http/https 公网地址)</h2>
104
+ <div id="mcp-mine-list"></div>
105
+ <div class="mcp-actions" style="margin-top:10px">
106
+ <label class="mcp-field" style="flex:1;margin:0">
107
+ <span>名字(全局唯一,用于绑定与识别)</span>
108
+ <input id="mcp-add-name" spellcheck="false" autocomplete="off" placeholder="例如 my-wiki" />
109
+ </label>
110
+ <label class="mcp-field" style="flex:2;margin:0">
111
+ <span>URL(http/https,公网可达)</span>
112
+ <input id="mcp-add-url" spellcheck="false" autocomplete="off" placeholder="https://mcp.example.com/mcp" />
113
+ </label>
114
+ </div>
115
+ <label class="mcp-field" style="margin-top:8px">
116
+ <span>自定义请求头(可选,一行一个 <code>名字: 值</code>;值只发给网关,不回显)</span>
117
+ <textarea id="mcp-add-headers" rows="2" spellcheck="false"
118
+ placeholder="Authorization: Bearer xxx"></textarea>
119
+ </label>
120
+ <div class="mcp-actions">
121
+ <button class="btn primary" id="mcp-add-save" type="button">登记并探测</button>
122
+ <span class="mcp-note">
123
+ 能不能自助登记由网关决定;没开放时这里会告诉你怎么开(<code>.env</code> 的
124
+ <code>MCP_ALLOW_SELF_SERVERS=true</code> + 重启网关)。
125
+ </span>
126
+ </div>
127
+ </div>
128
+ </div>
129
+ </section>
130
+ </main>
131
+ </div>
132
+
133
+ <script src="/mcp.js"></script>
134
+ </body>
135
+ </html>
package/public/mcp.js ADDED
@@ -0,0 +1,327 @@
1
+ /**
2
+ * 网页 MCP 面板(/mcp)—— 用户视角
3
+ *
4
+ * 它对应终端里的 `/mcp`(bind / mode / add / rm),差别只有一个:
5
+ * 用网页的人不该被迫去终端敲命令,而**页面又绝不能碰 sk- 密钥**。
6
+ * 所以这一页只打本机的 `/api/mcp*`,由 hub 用服务端保存的密钥代发给网关
7
+ * (与 `/api/chat` 完全同一套姿势)。
8
+ *
9
+ * 纪律(与 manual.js / sessions.js 一致):
10
+ * · 一律 `textContent` —— 服务器名、探测错误、网关 detail 都是**数据**,不是 HTML;
11
+ * · 不本地判断"能不能自助登记":那是网关的事(`self_server.allowed`),页面只显示;
12
+ * · 改完就重读(服务端已经重拉过清单,这里再拉一次绑定,保证页面上看到的是新的)。
13
+ */
14
+ (function () {
15
+ 'use strict';
16
+
17
+ var MODES = ['off', 'inject', 'loop'];
18
+ var SECRET_PARAMS = ['key', 'apikey', 'api_key', 'ak', 'sk', 'token', 'access_token', 'auth',
19
+ 'authorization', 'password', 'passwd', 'pwd', 'secret', 'client_secret', 'sign', 'signature', 'sig'];
20
+
21
+ var dom = {
22
+ tools: document.getElementById('mcp-tools'),
23
+ toolCount: document.getElementById('mcp-tool-count'),
24
+ gateway: document.getElementById('mcp-gateway'),
25
+ dot: document.getElementById('mcp-dot'),
26
+ banner: document.getElementById('mcp-banner'),
27
+ mode: document.getElementById('mcp-mode'),
28
+ modeSave: document.getElementById('mcp-mode-save'),
29
+ enabled: document.getElementById('mcp-enabled'),
30
+ selfServer: document.getElementById('mcp-selfserver'),
31
+ bindList: document.getElementById('mcp-bind-list'),
32
+ bindSave: document.getElementById('mcp-bind-save'),
33
+ bindNote: document.getElementById('mcp-bind-note'),
34
+ mineList: document.getElementById('mcp-mine-list'),
35
+ addName: document.getElementById('mcp-add-name'),
36
+ addUrl: document.getElementById('mcp-add-url'),
37
+ addHeaders: document.getElementById('mcp-add-headers'),
38
+ addSave: document.getElementById('mcp-add-save'),
39
+ search: document.getElementById('mcp-search'),
40
+ reload: document.getElementById('mcp-reload'),
41
+ };
42
+
43
+ var state = { binding: null, tools: [], keyword: '', busy: false };
44
+
45
+ function el(tag, cls, content) {
46
+ var node = document.createElement(tag);
47
+ if (cls) node.className = cls;
48
+ if (content != null) node.textContent = String(content);
49
+ return node;
50
+ }
51
+
52
+ function banner(msg, kind) {
53
+ if (!dom.banner) return;
54
+ dom.banner.textContent = msg || '';
55
+ dom.banner.hidden = !msg;
56
+ dom.banner.className = 'mcp-banner' + (kind ? ' ' + kind : '');
57
+ }
58
+
59
+ /** 只用于**显示**的掩码(真正的掩码在服务端 lib/mcpadmin.js;这里是给页面自己回显用的) */
60
+ function maskUrl(url) {
61
+ var raw = String(url == null ? '' : url);
62
+ if (!raw) return '';
63
+ try {
64
+ var u = new URL(raw);
65
+ var keys = [];
66
+ u.searchParams.forEach(function (_v, k) { keys.push(k); });
67
+ if (!keys.length) return u.origin + u.pathname;
68
+ return u.origin + u.pathname + '?' + keys.map(function (k) {
69
+ return k + '=' + (SECRET_PARAMS.indexOf(k.toLowerCase()) >= 0 ? '***' : u.searchParams.get(k));
70
+ }).join('&');
71
+ } catch (e) {
72
+ return raw.replace(/([?&])([A-Za-z0-9_.\-]+)=([^&\s]*)/g, function (m, sep, name, value) {
73
+ return SECRET_PARAMS.indexOf(String(name).toLowerCase()) >= 0 ? sep + name + '=***' : m;
74
+ });
75
+ }
76
+ }
77
+
78
+ function api(method, path, body) {
79
+ return fetch(path, {
80
+ method: method,
81
+ headers: body ? { 'content-type': 'application/json' } : undefined,
82
+ body: body ? JSON.stringify(body) : undefined,
83
+ }).then(function (resp) {
84
+ return resp.json().catch(function () { return {}; }).then(function (data) {
85
+ if (!resp.ok) {
86
+ var err = new Error((data && data.error) || ('HTTP ' + resp.status));
87
+ err.status = resp.status;
88
+ err.hint = (data && data.hint) || '';
89
+ throw err;
90
+ }
91
+ return data;
92
+ });
93
+ });
94
+ }
95
+
96
+ function fail(e) {
97
+ banner(e.message + (e.hint ? ' · ' + e.hint : ''), 'danger');
98
+ }
99
+
100
+ /* ---------- 渲染 ---------- */
101
+
102
+ function serverMeta(s) {
103
+ var bits = [];
104
+ if (Number.isFinite(Number(s && s.tool_count))) bits.push(Number(s.tool_count) + ' 个工具');
105
+ if (s && s.enabled === false) bits.push('已停用');
106
+ if (s && s.owner === 'self') bits.push('自建');
107
+ else if (s && s.self_service) bits.push('可自助');
108
+ return bits.join(' · ');
109
+ }
110
+
111
+ function renderTools() {
112
+ var kw = state.keyword.toLowerCase();
113
+ var rows = state.tools.filter(function (t) {
114
+ if (!kw) return true;
115
+ return (t.name + ' ' + t.server + ' ' + t.tool + ' ' + t.description).toLowerCase().indexOf(kw) >= 0;
116
+ });
117
+ dom.tools.textContent = '';
118
+ if (!rows.length) {
119
+ dom.tools.appendChild(el('p', 'store-line', state.tools.length ? '没有匹配的工具' : '这把密钥现在没有可用工具'));
120
+ return;
121
+ }
122
+ rows.forEach(function (t) {
123
+ var box = el('span', 'mcp-tool');
124
+ box.appendChild(el('b', null, t.name));
125
+ var meta = [t.server, t.tool].filter(Boolean).join('/');
126
+ if (meta) box.appendChild(el('span', null, ' [' + meta + ']'));
127
+ if (t.description) box.appendChild(el('span', null, ' ' + t.description));
128
+ dom.tools.appendChild(box);
129
+ });
130
+ }
131
+
132
+ function renderBinding() {
133
+ var b = state.binding;
134
+ dom.bindList.textContent = '';
135
+ dom.mineList.textContent = '';
136
+
137
+ if (!b) {
138
+ dom.bindList.appendChild(el('p', 'store-line', '读不到绑定信息(见上面的红色提示)'));
139
+ dom.mineList.appendChild(el('p', 'store-line', '—'));
140
+ dom.mode.value = 'off';
141
+ dom.selfServer.textContent = '自助登记:—';
142
+ dom.bindSave.disabled = true;
143
+ return;
144
+ }
145
+
146
+ dom.mode.value = MODES.indexOf(b.mode) >= 0 ? b.mode : 'off';
147
+ dom.enabled.textContent = b.enabled === false ? '(总开关:关)' : '(总开关:开)';
148
+ var s = b.selfServer || {};
149
+ dom.selfServer.textContent = s.allowed
150
+ ? '自助登记:开放(' + (s.used || 0) + '/' + (s.max || 0) + ' 台)'
151
+ : '自助登记:未开放(部署方在网关 .env 设 MCP_ALLOW_SELF_SERVERS=true 并重启网关后才可用)';
152
+ dom.selfServer.className = 'mcp-row' + (s.allowed ? '' : ' mcp-note');
153
+
154
+ // 可勾选的集合:已绑定 ∪ 可自助加(后者勾上就是"加")
155
+ var byId = {};
156
+ (b.bound || []).forEach(function (x) { byId[Number(x.id)] = { s: x, bound: true }; });
157
+ (b.addable || []).forEach(function (x) {
158
+ var id = Number(x.id);
159
+ if (!byId[id]) byId[id] = { s: x, bound: false };
160
+ });
161
+ var ids = Object.keys(byId).map(Number).sort(function (a, c) { return a - c; });
162
+ if (!ids.length) {
163
+ dom.bindList.appendChild(el('p', 'store-line', '这把密钥一台都没绑;管理员也没把它标成可自助。'));
164
+ }
165
+ ids.forEach(function (id) {
166
+ var it = byId[id];
167
+ var row = el('label', 'mcp-item');
168
+ var cb = document.createElement('input');
169
+ cb.type = 'checkbox';
170
+ cb.checked = it.bound;
171
+ cb.setAttribute('data-id', String(id));
172
+ cb.addEventListener('change', function () { dom.bindSave.disabled = false; });
173
+ row.appendChild(cb);
174
+ row.appendChild(el('span', 'name', '#' + id + ' ' + (it.s.name || '')));
175
+ row.appendChild(el('span', 'meta', serverMeta(it.s)));
176
+ if (it.bound) row.appendChild(el('span', 'mcp-tag', '已绑定'));
177
+ else row.appendChild(el('span', 'mcp-tag off', '可加'));
178
+ dom.bindList.appendChild(row);
179
+ });
180
+ var selectable = (b.selectableIds || []).length;
181
+ dom.bindNote.textContent = selectable
182
+ ? '网关允许你在这 ' + selectable + ' 台里选(勾选后保存)。'
183
+ : '网关没有给你可选项。';
184
+
185
+ if (!(b.mine || []).length) {
186
+ dom.mineList.appendChild(el('p', 'store-line', '你还没有登记过服务器 —— 用下面的表单加一台。'));
187
+ }
188
+ (b.mine || []).forEach(function (m) {
189
+ var row = el('div', 'mcp-item');
190
+ row.appendChild(el('span', 'name', '#' + m.id + ' ' + (m.name || '')));
191
+ row.appendChild(el('span', 'meta', serverMeta(m)));
192
+ row.appendChild(el('span', 'spacer'));
193
+ var del = el('button', 'btn ghost tiny', '删除');
194
+ del.type = 'button';
195
+ del.addEventListener('click', function () {
196
+ if (!window.confirm('删掉 #' + m.id + ' ' + (m.name || '') + '?删完还要重新探测才能恢复。')) return;
197
+ run(function () {
198
+ return api('DELETE', '/api/mcp/servers/' + encodeURIComponent(m.id)).then(function (r) {
199
+ banner('已删除(现有 ' + r.toolCount + ' 个工具)', 'ok');
200
+ });
201
+ });
202
+ });
203
+ row.appendChild(del);
204
+ dom.mineList.appendChild(row);
205
+ });
206
+ dom.bindSave.disabled = true;
207
+ }
208
+
209
+ /* ---------- 动作 ---------- */
210
+
211
+ function run(fn) {
212
+ if (state.busy) return Promise.resolve();
213
+ state.busy = true;
214
+ return Promise.resolve()
215
+ .then(fn)
216
+ .then(load)
217
+ .catch(fail)
218
+ .then(function () { state.busy = false; });
219
+ }
220
+
221
+ function checkedIds() {
222
+ var out = [];
223
+ var boxes = dom.bindList.querySelectorAll('input[type=checkbox]');
224
+ for (var i = 0; i < boxes.length; i++) {
225
+ if (boxes[i].checked) out.push(Number(boxes[i].getAttribute('data-id')));
226
+ }
227
+ return out;
228
+ }
229
+
230
+ function parseHeaders(text) {
231
+ var out = {};
232
+ String(text || '').split('\n').forEach(function (line) {
233
+ var s = line.trim();
234
+ if (!s) return;
235
+ var idx = s.indexOf(':');
236
+ if (idx <= 0) throw Object.assign(new Error('自定义头要写成 "名字: 值",这一行不认识:' + s), { hint: '' });
237
+ out[s.slice(0, idx).trim()] = s.slice(idx + 1).trim();
238
+ });
239
+ return out;
240
+ }
241
+
242
+ function load() {
243
+ return api('GET', '/api/mcp').then(function (data) {
244
+ state.binding = data.binding;
245
+ state.tools = Array.isArray(data.tools) ? data.tools : [];
246
+ dom.dot.className = 'dot ok';
247
+ dom.toolCount.textContent = '工具 ' + state.tools.length;
248
+ dom.gateway.textContent = '密钥与网关地址都由服务端代发(本页不接触密钥)';
249
+ renderBinding();
250
+ renderTools();
251
+ if (data.bindingError) {
252
+ banner('绑定信息读不到:' + data.bindingError.error + (data.bindingError.hint ? ' · ' + data.bindingError.hint : ''), 'danger');
253
+ } else if (data.toolsError) {
254
+ banner('工具清单读不到:' + data.toolsError, 'danger');
255
+ } else {
256
+ banner('');
257
+ }
258
+ }).catch(function (e) {
259
+ dom.dot.className = 'dot err';
260
+ dom.toolCount.textContent = '工具 —';
261
+ dom.gateway.textContent = e.status === 403 ? '接口已禁用(服务未绑定在本机地址)' : '读不到';
262
+ fail(e);
263
+ state.binding = null;
264
+ state.tools = [];
265
+ renderBinding();
266
+ renderTools();
267
+ });
268
+ }
269
+
270
+ dom.bindSave.addEventListener('click', function () {
271
+ var ids = checkedIds();
272
+ run(function () {
273
+ return api('PATCH', '/api/mcp/binding', { serverIds: ids }).then(function (r) {
274
+ banner('已改绑:' + (ids.length ? ids.map(function (i) { return '#' + i; }).join('、') : '(空 = 不绑任何服务器)')
275
+ + ' 现有 ' + r.toolCount + ' 个工具', 'ok');
276
+ });
277
+ });
278
+ });
279
+
280
+ dom.modeSave.addEventListener('click', function () {
281
+ var mode = dom.mode.value;
282
+ run(function () {
283
+ return api('PATCH', '/api/mcp/binding', { mode: mode }).then(function (r) {
284
+ banner('已把模式改成 ' + mode + '(绑定没动) 现有 ' + r.toolCount + ' 个工具', 'ok');
285
+ });
286
+ });
287
+ });
288
+
289
+ dom.addSave.addEventListener('click', function () {
290
+ var name = String(dom.addName.value || '').trim();
291
+ var url = String(dom.addUrl.value || '').trim();
292
+ if (!name || !url) {
293
+ banner('名字和 URL 都要填(URL 必须是 http/https 公网可达地址)', 'danger');
294
+ return;
295
+ }
296
+ var headers;
297
+ try {
298
+ headers = parseHeaders(dom.addHeaders.value);
299
+ } catch (e) {
300
+ banner(e.message, 'danger');
301
+ return;
302
+ }
303
+ run(function () {
304
+ banner('登记中:' + name + ' ← ' + maskUrl(url), '');
305
+ return api('POST', '/api/mcp/servers', { name: name, url: url, headers: headers }).then(function (r) {
306
+ dom.addName.value = '';
307
+ dom.addUrl.value = '';
308
+ dom.addHeaders.value = '';
309
+ var msg = '已登记 #' + (r.server && r.server.id) + ' ' + ((r.server && r.server.name) || name)
310
+ + '(工具 ' + ((r.server && r.server.tool_count) || 0) + ' 个),并已绑到这把密钥 现有 ' + r.toolCount + ' 个工具';
311
+ if (r.probeError) {
312
+ banner(msg + ' · 探测失败(已置为停用):' + r.probeError, 'danger');
313
+ } else {
314
+ banner(msg, 'ok');
315
+ }
316
+ });
317
+ });
318
+ });
319
+
320
+ dom.reload.addEventListener('click', function () { run(function () { return Promise.resolve(); }); });
321
+ dom.search.addEventListener('input', function () {
322
+ state.keyword = String(dom.search.value || '').trim();
323
+ renderTools();
324
+ });
325
+
326
+ load();
327
+ })();
@@ -0,0 +1,198 @@
1
+ /**
2
+ * 命令行会话(/sessions)只读页
3
+ *
4
+ * 只用 styles.css 里的变量取色,不定义新变量、不写裸颜色 —— 与另外三个页面同一套主题契约
5
+ * (tests/theme.test.mjs 的「选择器里没有裸的十六进制颜色 / 裸的 rgba」对本文件同样成立)。
6
+ * 布局骨架复用 styles.css 的 .app / .sidebar / .main / .topbar / .messages / .empty。
7
+ */
8
+
9
+ .cs-filter input {
10
+ width: 100%;
11
+ box-sizing: border-box;
12
+ padding: 6px 8px;
13
+ border-radius: var(--radius-sm);
14
+ border: 1px solid var(--border);
15
+ background: var(--bg-raised);
16
+ color: var(--text);
17
+ font-size: 12px;
18
+ }
19
+
20
+ .cs-list-item {
21
+ display: block;
22
+ width: 100%;
23
+ text-align: left;
24
+ border: 1px solid transparent;
25
+ background: none;
26
+ color: var(--text);
27
+ border-radius: var(--radius-sm);
28
+ padding: 8px;
29
+ margin-bottom: 4px;
30
+ cursor: pointer;
31
+ transition: background var(--t), border-color var(--t);
32
+ }
33
+
34
+ .cs-list-item:hover { background: var(--bg-raised); }
35
+
36
+ .cs-list-item.active {
37
+ background: var(--accent-soft);
38
+ border-color: var(--border-focus);
39
+ }
40
+
41
+ .cs-item-title {
42
+ display: block;
43
+ font-size: 12px;
44
+ font-family: var(--mono);
45
+ overflow: hidden;
46
+ text-overflow: ellipsis;
47
+ white-space: nowrap;
48
+ direction: rtl; /* 路径太长时优先露出尾部(目录名更有信息量) */
49
+ text-align: left;
50
+ }
51
+
52
+ .cs-item-meta {
53
+ display: block;
54
+ margin-top: 4px;
55
+ font-size: 11px;
56
+ color: var(--text-dim);
57
+ }
58
+
59
+ .cs-item-tag {
60
+ display: inline-block;
61
+ margin-left: 6px;
62
+ padding: 0 5px;
63
+ border-radius: 999px;
64
+ font-size: 10px;
65
+ background: var(--warn-soft-strong);
66
+ color: var(--warn);
67
+ }
68
+
69
+ .cs-banner {
70
+ margin: 0;
71
+ padding: 10px 16px;
72
+ border-bottom: 1px solid var(--border);
73
+ background: var(--bg-soft);
74
+ color: var(--text-dim);
75
+ font-size: 12.5px;
76
+ line-height: 1.7;
77
+ }
78
+
79
+ .cs-banner.danger {
80
+ background: var(--danger-soft);
81
+ border-bottom-color: var(--danger-border);
82
+ color: var(--danger);
83
+ }
84
+
85
+ .cs-banner b { color: var(--text); }
86
+
87
+ .cs-cmd {
88
+ display: inline-flex;
89
+ align-items: center;
90
+ gap: 6px;
91
+ margin-left: 4px;
92
+ }
93
+
94
+ .cs-cmd code {
95
+ font-family: var(--mono);
96
+ font-size: 12px;
97
+ padding: 1px 6px;
98
+ border-radius: var(--radius-sm);
99
+ background: var(--bg-code);
100
+ border: 1px solid var(--border);
101
+ color: var(--text);
102
+ }
103
+
104
+ .cs-doc {
105
+ padding: 4px 0 40px;
106
+ }
107
+
108
+ .cs-head {
109
+ border: 1px solid var(--border);
110
+ border-radius: var(--radius);
111
+ background: var(--bg-soft);
112
+ padding: 12px 14px;
113
+ margin-bottom: 14px;
114
+ }
115
+
116
+ .cs-head h2 {
117
+ margin: 0 0 6px;
118
+ font-size: 14px;
119
+ font-family: var(--mono);
120
+ word-break: break-all;
121
+ }
122
+
123
+ .cs-head-row {
124
+ font-size: 12px;
125
+ color: var(--text-dim);
126
+ line-height: 1.9;
127
+ }
128
+
129
+ .cs-head-row code {
130
+ font-family: var(--mono);
131
+ color: var(--text);
132
+ }
133
+
134
+ .cs-msg {
135
+ border: 1px solid var(--border);
136
+ border-radius: var(--radius);
137
+ margin-bottom: 10px;
138
+ overflow: hidden;
139
+ background: var(--bg-soft);
140
+ }
141
+
142
+ .cs-msg-head {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 8px;
146
+ padding: 6px 10px;
147
+ background: var(--bg-code);
148
+ border-bottom: 1px solid var(--border);
149
+ font-size: 12px;
150
+ color: var(--text-dim);
151
+ }
152
+
153
+ .cs-role {
154
+ font-weight: 600;
155
+ color: var(--text);
156
+ }
157
+
158
+ .cs-content {
159
+ padding: 10px;
160
+ font-size: 13px;
161
+ line-height: 1.7;
162
+ white-space: pre-wrap;
163
+ word-break: break-word;
164
+ max-height: 420px;
165
+ overflow: auto;
166
+ color: var(--text);
167
+ }
168
+
169
+ .cs-content.clamped { max-height: 180px; }
170
+
171
+ .cs-tool {
172
+ border-top: 1px dashed var(--border-dashed);
173
+ padding: 8px 10px;
174
+ font-family: var(--mono);
175
+ font-size: 12px;
176
+ color: var(--text-dim);
177
+ white-space: pre-wrap;
178
+ word-break: break-word;
179
+ max-height: 260px;
180
+ overflow: auto;
181
+ }
182
+
183
+ .cs-tool b { color: var(--accent); }
184
+
185
+ .cs-toggle {
186
+ margin-left: auto;
187
+ border: 1px solid var(--border);
188
+ background: none;
189
+ color: var(--text-dim);
190
+ border-radius: var(--radius-sm);
191
+ font-size: 11px;
192
+ padding: 1px 8px;
193
+ cursor: pointer;
194
+ }
195
+
196
+ .cs-toggle:hover { color: var(--text); border-color: var(--border-strong); }
197
+
198
+ .cs-sys { color: var(--text-faint); }