llm-api-gateway-cli 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,412 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Tab 外壳(/):聊天 / 任务 / 手册 在同一个页面里切换,不再新开标签页
5
+ *
6
+ * 三条设计决定:
7
+ * 1. **面板 = 同源 iframe,首次激活才创建**(懒加载):三个页面各自的 dom 映射、全局键盘事件、
8
+ * SSE 与轮询一行都不用改 —— 合并进同一个 DOM 就得给上百处重名 id 改名,还得让两套
9
+ * `Enter 发送` 互相避让,风险远大于收益。切走的面板留在 DOM 里(只 hidden),状态不丢。
10
+ * 2. **顶栏只有一条**:外壳持有品牌 / Tab / 当前面板操作 / 主题;面板带 `?embed=1` 打开时
11
+ * 用 CSS 收起自己的顶栏(`html[data-embed] .topbar`)。面板里那些按钮(设置、侧栏开关、
12
+ * 工作目录)仍留在原页面 DOM 里,外壳只负责「代点」—— 面板单独打开时行为与本轮之前完全一致。
13
+ * 3. **面板之间没有直接耦合**:全部走 postMessage(只认同源 + 已登记的 iframe 窗口)。
14
+ * 面板里 `window.lgwBridge` 不存在时(单独打开、被别的系统 iframe 嵌入)一切照旧。
15
+ *
16
+ * 消息协议(两侧都写在这两个文件头上,改一处必须改另一处):
17
+ *
18
+ * 面板 → 外壳 { source:'lgw-pane', tab, type:'ready' } 面板脚本就绪
19
+ * { source:'lgw-pane', tab, type:'dot', kind } 状态点('' / ok / warn / err)
20
+ * { source:'lgw-pane', tab, type:'workdir', text, title } 工作目录文案(任务面板)
21
+ * { source:'lgw-pane', tab, type:'sidebar', open } 侧边栏开合(驱动 ☰ 的按下态)
22
+ * { source:'lgw-pane', tab, type:'busy', busy } 面板正在跑(Tab 上的脉冲点)
23
+ * { source:'lgw-pane', tab, type:'goto', tab } 请求切到另一个 Tab(Alt+1/2/3)
24
+ *
25
+ * 外壳 → 面板 { source:'lgw-shell', tab, type:'click', id } 代点面板里的 #id
26
+ *
27
+ * 主题不走消息:外壳与面板同源共享 localStorage,theme.js 用 storage 事件自己跟。
28
+ * 底色(?bg=)由 paneSrc 透传给面板地址,外部集成嵌进来的颜色不会掉在 iframe 里。
29
+ */
30
+ (function (root, factory) {
31
+ root.Shell = factory();
32
+ })(typeof globalThis !== 'undefined' ? globalThis : this, function () {
33
+ 'use strict';
34
+
35
+ /**
36
+ * 面板清单。actions 是「外壳代点」的按钮:id 必须是对应面板里真实存在的元素 id,
37
+ * 点击时外壳只发一条 click 消息,真正的 handler 还在面板里。
38
+ */
39
+ const TABS = [
40
+ {
41
+ id: 'chat',
42
+ label: '聊天',
43
+ src: '/chat',
44
+ hint: '纯对话:不碰文件系统',
45
+ actions: [
46
+ { id: 'btn-toggle-sidebar', label: '☰', title: '会话列表', icon: true },
47
+ { id: 'btn-settings', label: '设置' },
48
+ // 「新对话」:聊天面板侧边栏里已经有一个「+ 新对话」,侧边栏开着时这里不再重复占位;
49
+ // 只在侧边栏收起(点 ☰ 收起,或窄屏下侧栏是抽屉)时补一个 —— 否则那会儿就没有新建入口了。
50
+ { id: 'btn-clear', label: '新对话', primary: true, whenSidebarHidden: true },
51
+ ],
52
+ },
53
+ {
54
+ id: 'task',
55
+ label: '任务',
56
+ src: '/task',
57
+ hint: '选一个目录,模型真读写',
58
+ actions: [
59
+ { id: 'btn-toggle-sidebar', label: '☰', title: '任务列表', icon: true },
60
+ // 内容由任务面板通过 workdir 消息填,点击转发给面板里的 #workdir-chip(目录选择器)
61
+ { id: 'workdir-chip', chip: true, label: '工作目录', value: '未选择' },
62
+ { id: 'btn-settings', label: '设置' },
63
+ ],
64
+ },
65
+ {
66
+ id: 'manual',
67
+ label: '手册',
68
+ src: '/manual',
69
+ hint: '操作手册与三端指令对照',
70
+ actions: [],
71
+ },
72
+ ];
73
+
74
+ /** 面板地址上带的嵌入标记:面板据此收起自己的顶栏 */
75
+ const EMBED_PARAM = 'embed';
76
+ /** 地址里认这三个底色参数(与 tint.js 的 PARAMS 一致),要透传给面板 */
77
+ const TINT_PARAMS = ['bg', 'bgcolor', 'bg-color'];
78
+
79
+ function tabById(id) {
80
+ return TABS.find((t) => t.id === id) || null;
81
+ }
82
+
83
+ /** `#task` / `#manual` / 空串 → 合法 Tab id(认不出就回第一个) */
84
+ function tabFromHash(hash) {
85
+ const id = String(hash || '').replace(/^#/, '').trim().toLowerCase();
86
+ return tabById(id) ? id : TABS[0].id;
87
+ }
88
+
89
+ /** 面板地址:带 embed=1,并把外壳地址上的底色参数原样带上 */
90
+ function paneSrc(tab, search) {
91
+ const sp = new URLSearchParams(String(search || '').replace(/^\?/, ''));
92
+ const out = new URLSearchParams();
93
+ out.set(EMBED_PARAM, '1');
94
+ for (const name of TINT_PARAMS) {
95
+ const v = sp.get(name);
96
+ if (v) out.set(name, v);
97
+ }
98
+ return `${tab.src}?${out.toString()}`;
99
+ }
100
+
101
+ return { TABS, EMBED_PARAM, TINT_PARAMS, tabById, tabFromHash, paneSrc };
102
+ });
103
+
104
+ /* ---------- 浏览器里的接线(node 里 import 它只为拿上面的纯函数) ---------- */
105
+
106
+ if (typeof document !== 'undefined') {
107
+ (function () {
108
+ 'use strict';
109
+
110
+ const S = globalThis.Shell;
111
+
112
+ const dom = {
113
+ tabs: document.getElementById('shell-tabs'),
114
+ tabInk: document.getElementById('tab-ink'),
115
+ actions: document.getElementById('shell-actions'),
116
+ panes: document.getElementById('panes'),
117
+ dot: document.getElementById('shell-dot'),
118
+ tag: document.getElementById('shell-tag'),
119
+ reload: document.getElementById('btn-reload-pane'),
120
+ };
121
+
122
+ const state = {
123
+ active: null,
124
+ /** tabId → { wrap, frame, skeleton, loaded } */
125
+ panes: new Map(),
126
+ /** 面板还没就绪时先排队的下行消息:[tabId, payload] */
127
+ pending: [],
128
+ /** 任务面板最近一次报上来的工作目录文案(重画操作区时保留) */
129
+ workdir: '',
130
+ workdirTitle: '',
131
+ /** 各面板侧边栏开合(重画操作区时保留) */
132
+ sidebar: {},
133
+ /** 各面板是否在跑(Tab 脉冲点) */
134
+ busy: {},
135
+ };
136
+
137
+ /* ---------- 面板的创建与懒加载 ---------- */
138
+
139
+ /**
140
+ * 骨架(「正在加载…」那层)的显隐。
141
+ *
142
+ * 两处一起改,是因为**只写 hidden 不够**:`[hidden]` 的 UA 规则是 `display:none`,
143
+ * 会被 `.pane-skeleton { display: flex }` 这条作者样式压掉 —— 骨架是绝对定位且 z-index 高于 iframe,
144
+ * 压不掉就会永远盖在面板上(症状:外壳正常、Tab 能切,但面板区一直空着)。
145
+ * 这是本项目里真出过一次的 bug,两条路都留着:CSS 里有 `.pane-skeleton[hidden]`,这里再显式关一次。
146
+ */
147
+ function showSkeleton(pane, on) {
148
+ pane.skeleton.hidden = !on;
149
+ pane.skeleton.style.display = on ? '' : 'none';
150
+ }
151
+
152
+ function ensurePane(tab) {
153
+ const existed = state.panes.get(tab.id);
154
+ if (existed) return existed;
155
+
156
+ const wrap = document.createElement('div');
157
+ wrap.className = 'pane';
158
+ wrap.dataset.tab = tab.id;
159
+ wrap.hidden = true;
160
+
161
+ const skeleton = document.createElement('div');
162
+ skeleton.className = 'pane-skeleton';
163
+ const spinner = document.createElement('div');
164
+ spinner.className = 'spinner';
165
+ const text = document.createElement('div');
166
+ text.textContent = `正在加载${tab.label}…`;
167
+ skeleton.append(spinner, text);
168
+
169
+ const frame = document.createElement('iframe');
170
+ frame.className = 'pane-frame';
171
+ frame.title = tab.label;
172
+ frame.setAttribute('loading', 'eager');
173
+
174
+ const pane = { wrap, frame, skeleton, loaded: false };
175
+ // 懒加载就发生在这里:只有第一次激活某个 Tab 时才会给面板发请求
176
+ frame.src = S.paneSrc(tab, window.location.search);
177
+ // 兜底:面板迟迟没到(服务挂了、面板脚本报错、请求被拦)时,把骨架文案换成能自救的提示。
178
+ // 没有这一条,用户对着一个永远转圈的「正在加载」没有任何线索 —— 这正是本项目踩过的坑。
179
+ const slowTimer = setTimeout(() => {
180
+ if (pane.loaded) return;
181
+ text.textContent = `${tab.label}面板没加载出来:点顶栏 ⟳ 重试,或直接打开 ${tab.src}`;
182
+ }, 8000);
183
+ frame.addEventListener('load', () => {
184
+ clearTimeout(slowTimer);
185
+ pane.loaded = true;
186
+ showSkeleton(pane, false);
187
+ flush(tab.id);
188
+ moveInk();
189
+ });
190
+
191
+ wrap.append(skeleton, frame);
192
+ dom.panes.appendChild(wrap);
193
+ state.panes.set(tab.id, pane);
194
+ return pane;
195
+ }
196
+
197
+ function paneOfSource(source) {
198
+ // source 为空也必须挡住:postMessage 的 source 可能来自 Service Worker / MessagePort(那是 null),
199
+ // 且面板窗口被销毁后也不再是「已登记的面板」
200
+ if (!source) return null;
201
+ for (const [id, p] of state.panes) {
202
+ if (p.frame.contentWindow === source) return id;
203
+ }
204
+ return null;
205
+ }
206
+
207
+ /* ---------- 外壳 → 面板 ---------- */
208
+
209
+ function send(tabId, msg) {
210
+ const payload = { source: 'lgw-shell', tab: tabId, ...msg };
211
+ const pane = state.panes.get(tabId);
212
+ if (!pane || !pane.loaded) {
213
+ // 面板还没建/还没加载完:先排队,load 之后统一补发(例如刚切过去就点了「设置」)
214
+ state.pending.push([tabId, payload]);
215
+ return false;
216
+ }
217
+ try {
218
+ pane.frame.contentWindow.postMessage(payload, window.location.origin);
219
+ return true;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+
225
+ function flush(tabId) {
226
+ const rest = [];
227
+ for (const [tid, payload] of state.pending) {
228
+ const pane = state.panes.get(tid);
229
+ if (tid === tabId && pane && pane.loaded) {
230
+ try {
231
+ pane.frame.contentWindow.postMessage(payload, window.location.origin);
232
+ } catch {
233
+ /* 面板正好被重建了,丢这一条不影响使用 */
234
+ }
235
+ } else {
236
+ rest.push([tid, payload]);
237
+ }
238
+ }
239
+ state.pending = rest;
240
+ }
241
+
242
+ /* ---------- 顶栏渲染 ---------- */
243
+
244
+ function moveInk() {
245
+ const btn = dom.tabs && dom.tabs.querySelector('.tab[aria-selected="true"]');
246
+ if (!btn || !dom.tabInk || typeof btn.offsetWidth !== 'number') return;
247
+ dom.tabInk.style.width = `${btn.offsetWidth}px`;
248
+ dom.tabInk.style.transform = `translateX(${btn.offsetLeft}px)`;
249
+ }
250
+
251
+ function renderActions(tab) {
252
+ dom.actions.textContent = '';
253
+ for (const a of tab.actions) {
254
+ // 「只在侧栏收起时才出现」的按钮:面板还没报过侧栏状态(undefined)时也不显示 ——
255
+ // 默认(桌面端侧栏是开着的)就不该出现,免得开局闪一下又消失。
256
+ if (a.whenSidebarHidden && state.sidebar[tab.id] !== false) continue;
257
+ let el;
258
+ if (a.chip) {
259
+ el = document.createElement('button');
260
+ el.type = 'button';
261
+ el.className = 'shell-chip';
262
+ el.dataset.action = a.id;
263
+ const label = document.createElement('span');
264
+ label.className = 'wd-label';
265
+ label.textContent = a.label;
266
+ const code = document.createElement('code');
267
+ code.textContent = state.workdir || a.value || '未选择';
268
+ el.title = state.workdirTitle || '点击选择工作目录';
269
+ el.append(label, code);
270
+ } else if (a.icon) {
271
+ el = document.createElement('button');
272
+ el.type = 'button';
273
+ el.className = 'btn ghost icon-only';
274
+ el.dataset.action = a.id;
275
+ el.textContent = a.label;
276
+ el.title = a.title || a.label;
277
+ } else {
278
+ el = document.createElement('button');
279
+ el.type = 'button';
280
+ el.className = a.primary ? 'btn primary' : 'btn ghost';
281
+ el.dataset.action = a.id;
282
+ el.title = a.title || a.label;
283
+ const text = document.createElement('span');
284
+ text.className = 'btn-text';
285
+ text.textContent = a.label;
286
+ el.appendChild(text);
287
+ }
288
+ if (a.id === 'btn-toggle-sidebar' && state.sidebar[tab.id]) el.classList.add('active');
289
+ el.addEventListener('click', () => send(tab.id, { type: 'click', id: a.id }));
290
+ dom.actions.appendChild(el);
291
+ }
292
+ }
293
+
294
+ /** 工作目录文案变了:只改那一个节点,不重画整个操作区(免得按钮闪) */
295
+ function renderWorkdir() {
296
+ const code = dom.actions && dom.actions.querySelector('.shell-chip code');
297
+ if (code) code.textContent = state.workdir || '未选择';
298
+ const chip = dom.actions && dom.actions.querySelector('.shell-chip');
299
+ if (chip) chip.title = state.workdirTitle || '点击选择工作目录';
300
+ }
301
+
302
+ /* ---------- 切 Tab ---------- */
303
+
304
+ function openTab(id) {
305
+ const tab = S.tabById(id) || S.TABS[0];
306
+ state.active = tab.id;
307
+
308
+ // 懒加载:第一次激活这个 Tab 时才建 iframe(也只有这时才发请求)
309
+ const pane = ensurePane(tab);
310
+ pane.wrap.hidden = false;
311
+ for (const [tid, p] of state.panes) if (tid !== tab.id) p.wrap.hidden = true;
312
+
313
+ for (const btn of dom.tabs.querySelectorAll('.tab')) {
314
+ btn.setAttribute('aria-selected', String(btn.dataset.tab === tab.id));
315
+ }
316
+ moveInk();
317
+ renderActions(tab);
318
+ if (dom.tag) dom.tag.textContent = tab.label;
319
+ document.title = `${tab.label} · LLM API Gateway`;
320
+
321
+ if (window.location.hash !== `#${tab.id}`) window.location.hash = tab.id;
322
+ return tab.id;
323
+ }
324
+
325
+ /* ---------- 面板 → 外壳 ---------- */
326
+
327
+ function onPaneMessage(tabId, msg) {
328
+ switch (msg.type) {
329
+ case 'ready':
330
+ flush(tabId);
331
+ break;
332
+ case 'dot':
333
+ dom.dot.className = `dot${msg.kind ? ' ' + msg.kind : ''}`;
334
+ break;
335
+ case 'workdir':
336
+ state.workdir = String(msg.text || '');
337
+ state.workdirTitle = String(msg.title || '');
338
+ renderWorkdir();
339
+ break;
340
+ case 'sidebar':
341
+ state.sidebar[tabId] = Boolean(msg.open);
342
+ if (state.active === tabId) renderActions(S.tabById(tabId));
343
+ break;
344
+ case 'busy': {
345
+ state.busy[tabId] = Boolean(msg.busy);
346
+ const btn = dom.tabs.querySelector(`.tab[data-tab="${tabId}"]`);
347
+ if (btn) btn.classList.toggle('busy', state.busy[tabId]);
348
+ break;
349
+ }
350
+ case 'goto':
351
+ openTab(msg.tab);
352
+ break;
353
+ default:
354
+ break;
355
+ }
356
+ }
357
+
358
+ window.addEventListener('message', (e) => {
359
+ // 只认同源,且必须是本页登记的某个面板窗口 —— 别的页面往这里发消息一律不理
360
+ if (e.origin !== window.location.origin) return;
361
+ const msg = e.data;
362
+ if (!msg || msg.source !== 'lgw-pane') return;
363
+ const tabId = paneOfSource(e.source);
364
+ if (!tabId) return;
365
+ onPaneMessage(tabId, msg);
366
+ });
367
+
368
+ /* ---------- 交互接线 ---------- */
369
+
370
+ for (const btn of dom.tabs.querySelectorAll('.tab')) {
371
+ btn.addEventListener('click', () => openTab(btn.dataset.tab));
372
+ }
373
+
374
+ window.addEventListener('hashchange', () => openTab(S.tabFromHash(window.location.hash)));
375
+ window.addEventListener('resize', moveInk);
376
+
377
+ // Alt+1 / Alt+2 / Alt+3 直接切面板(与浏览器自身的快捷键不冲突)
378
+ document.addEventListener('keydown', (e) => {
379
+ if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
380
+ const idx = ['1', '2', '3'].indexOf(e.key);
381
+ if (idx < 0 || !S.TABS[idx]) return;
382
+ e.preventDefault();
383
+ openTab(S.TABS[idx].id);
384
+ });
385
+
386
+ dom.reload.addEventListener('click', () => {
387
+ const tab = S.tabById(state.active) || S.TABS[0];
388
+ const pane = ensurePane(tab);
389
+ showSkeleton(pane, true);
390
+ pane.loaded = false;
391
+ try {
392
+ pane.frame.contentWindow.location.reload();
393
+ } catch {
394
+ pane.frame.src = S.paneSrc(tab, window.location.search);
395
+ }
396
+ });
397
+
398
+ openTab(S.tabFromHash(window.location.hash));
399
+ moveInk();
400
+
401
+ // 供调试与测试用:外壳自己的最小 API
402
+ globalThis.lgwShell = {
403
+ TABS: S.TABS,
404
+ openTab,
405
+ send,
406
+ tabFromHash: S.tabFromHash,
407
+ paneSrc: S.paneSrc,
408
+ panes: () => [...state.panes.keys()],
409
+ active: () => state.active,
410
+ };
411
+ })();
412
+ }
package/public/styles.css CHANGED
@@ -24,6 +24,9 @@
24
24
  --accent-hover: #3f7cf0;
25
25
  --accent-soft: #1d2c44;
26
26
  --on-accent: #fff;
27
+ /* 第二个强调色:Tab 外壳的滑动高亮、顶栏细线、渐变主按钮用(20260922 起) */
28
+ --accent-2: #8b5cf6;
29
+ --accent-2-soft: rgba(139, 92, 246, .16);
27
30
 
28
31
  --danger: #ff6b6b;
29
32
  --danger-soft: rgba(255, 107, 107, .07);
@@ -79,6 +82,8 @@ html[data-theme="light"] {
79
82
  --accent-hover: #1d4ed8;
80
83
  --accent-soft: #e8f0ff;
81
84
  --on-accent: #fff;
85
+ --accent-2: #7c3aed;
86
+ --accent-2-soft: rgba(124, 58, 237, .12);
82
87
 
83
88
  --danger: #d92d20;
84
89
  --danger-soft: rgba(217, 45, 32, .06);
@@ -149,21 +154,7 @@ body {
149
154
 
150
155
  .btn.block { width: 100%; }
151
156
 
152
- /* 「新任务」跳到任务模式(另一个端口),用 明示会开新页面 */
153
- .task-entry {
154
- display: flex;
155
- align-items: center;
156
- justify-content: center;
157
- gap: 6px;
158
- text-decoration: none;
159
- box-sizing: border-box;
160
- }
161
-
162
- .task-entry .ext-mark { font-size: 11px; opacity: .55; }
163
-
164
- .task-entry:hover .ext-mark { opacity: 1; }
165
-
166
- /* 任务页顶栏里回聊天页的反向入口 */
157
+ /* 任务页顶栏里回聊天页的反向入口(/task 单开时用;Tab 外壳里顶栏是收起的) */
167
158
  .chat-entry { display: inline-flex; align-items: center; gap: 5px; text-decoration: none; box-sizing: border-box; }
168
159
  .chat-entry .ext-mark { font-size: 11px; opacity: .55; }
169
160
  .chat-entry:hover .ext-mark { opacity: 1; }
@@ -321,6 +312,19 @@ body {
321
312
 
322
313
  .topbar-actions { display: flex; gap: 8px; }
323
314
 
315
+ /* ---------- 被 Tab 外壳嵌进来时(?embed=1) ----------
316
+ * 顶栏只有一条,而且是外壳的(public/shell.html 的 .shell-bar):面板自己这条收起来,
317
+ * 免得上下两条横栏。元素仍留在 DOM 里 —— 外壳「代点」设置 / 侧栏开关 / 工作目录,
318
+ * 靠的就是对隐藏元素调 .click()(隐藏不影响事件派发)。
319
+ * 标记由各页面 head 里的内联脚本在首帧前挂上(见 index.html / task.html / manual.html)。 */
320
+ html[data-embed] .topbar { display: none; }
321
+
322
+ /* 面板自带的跨页入口在外壳里是多余的(Tab 栏就能切),↗ 这个「会开新标签页」的提示更没意义。
323
+ 导航一律归外壳:面板里不再有跨面板入口(聊天页那个「+ 新任务」已在 20260922 删掉),
324
+ 所以这里不需要再针对某个入口做隐藏,只剩下面这条给消息区留的呼吸位。 */
325
+ html[data-embed] .messages { padding-top: 14px; }
326
+ html[data-embed] .settings { border-left-color: var(--border); }
327
+
324
328
  /* ---------- 消息区 ---------- */
325
329
 
326
330
  .messages {
@@ -953,3 +957,65 @@ html[data-tint] .theme-switch { display: none; }
953
957
  .theme-btn { width: 24px; }
954
958
  }
955
959
 
960
+ /* ---------- 观感提升(20260922) ----------
961
+ * 全部只改「观感」,不动任何类名与结构:三个面板 + Tab 外壳共用这一份。
962
+ * 颜色一律走变量(theme 测试盯着裸色值),深浅两套与外部底色(?bg=)下都得成立。 */
963
+
964
+ /* 顶栏:比面板底色亮一档 + 极轻的渐变,让「工具条」与「内容区」分层 */
965
+ .topbar {
966
+ background: linear-gradient(180deg, var(--bg-soft), var(--bg-soft));
967
+ box-shadow: var(--shadow-xs);
968
+ padding: 10px 18px;
969
+ }
970
+
971
+ .brand h1 { letter-spacing: .3px; }
972
+
973
+ /* 主按钮:双色渐变 + 抬升阴影,比单色块更像「可点的东西」 */
974
+ .btn.primary {
975
+ background: linear-gradient(135deg, var(--accent), var(--accent-2));
976
+ border-color: transparent;
977
+ box-shadow: 0 2px 10px var(--accent-soft);
978
+ }
979
+
980
+ .btn.primary:hover:not(:disabled) {
981
+ background: linear-gradient(135deg, var(--accent-hover), var(--accent-2));
982
+ border-color: transparent;
983
+ }
984
+
985
+ /* 侧边栏:条目再加一点呼吸感,选中项左侧强调条更清楚 */
986
+ .sidebar-head { padding: 12px; }
987
+ .session-item { border-radius: 10px; padding: 2px 4px 2px 9px; }
988
+ .session-item:hover { background: var(--bg-soft); box-shadow: var(--shadow-xs); }
989
+ .session-item.active { background: var(--accent-soft); box-shadow: inset 2px 0 0 var(--accent); }
990
+ .session-list { padding: 10px 8px; }
991
+ .sidebar-foot { padding: 9px 12px; }
992
+
993
+ /* 消息区:气泡圆角与间距放宽一档,长回答更好读 */
994
+ .messages { padding: 24px 0 10px; }
995
+ .msg { margin-bottom: 20px; gap: 13px; }
996
+ .msg.user .bubble { border-radius: 14px; padding: 11px 15px; box-shadow: var(--shadow-xs); }
997
+ .bubble code { border-radius: 6px; }
998
+
999
+ /* 输入区:聚焦时给一圈柔和外环(比只换描边颜色更明确) */
1000
+ .composer-box { border-radius: 16px; box-shadow: var(--shadow-xs); }
1001
+ .composer-box:focus-within { border-color: var(--border-focus); box-shadow: 0 0 0 4px var(--accent-soft); }
1002
+
1003
+ /* 快捷提示与空态 */
1004
+ .empty h2 { font-size: 21px; letter-spacing: .2px; }
1005
+ .empty p { line-height: 1.8; }
1006
+ .chip { box-shadow: var(--shadow-xs); }
1007
+
1008
+ /* 滚动条更细一点,深色下不再抢视线 */
1009
+ ::-webkit-scrollbar { width: 10px; height: 10px; }
1010
+ ::-webkit-scrollbar-thumb { border: 2px solid transparent; background-clip: padding-box; }
1011
+
1012
+ /* 系统开了「减少动态效果」时把过渡与动画关掉(含 Tab 外壳的滑动高亮) */
1013
+ @media (prefers-reduced-motion: reduce) {
1014
+ *, *::before, *::after {
1015
+ animation-duration: .001ms !important;
1016
+ animation-iteration-count: 1 !important;
1017
+ transition-duration: .001ms !important;
1018
+ scroll-behavior: auto !important;
1019
+ }
1020
+ }
1021
+
package/public/task.css CHANGED
@@ -737,3 +737,73 @@
737
737
  .slash-msg.error .slash-out { border-color: var(--danger); color: var(--danger); }
738
738
  .slash-msg.warn .slash-out { border-color: var(--warn, var(--danger)); }
739
739
 
740
+ /* ---------- 任务多了 / 历史长了:懒加载的两条入口(20260922) ----------
741
+ * 侧边栏分批渲染与消息区「载入更早」都要有个明确的落点,
742
+ * 否则用户看到的就是「我的任务不见了」——这比多一个按钮难解释得多。
743
+ */
744
+
745
+ /* 侧边栏搜索:常驻在「+ 新任务」下面,输入即过滤(纯客户端) */
746
+ .task-filter {
747
+ display: flex;
748
+ align-items: center;
749
+ gap: 6px;
750
+ }
751
+
752
+ #task-search {
753
+ flex: 1;
754
+ min-width: 0;
755
+ background: var(--bg);
756
+ border: 1px solid var(--border);
757
+ border-radius: 8px;
758
+ color: var(--text);
759
+ font: inherit;
760
+ font-size: 12.5px;
761
+ padding: 5px 10px;
762
+ outline: none;
763
+ transition: border-color var(--t), box-shadow var(--t);
764
+ }
765
+
766
+ #task-search::placeholder { color: var(--text-faint); }
767
+ #task-search:focus { border-color: var(--border-focus); box-shadow: 0 0 0 3px var(--accent-soft); }
768
+ #task-search::-webkit-search-cancel-button { display: none; } /* 用自己的「✕」清空按钮 */
769
+
770
+ /* 「加载更多」:贴在列表末尾,不抢任务行的视觉重量 */
771
+ .task-more {
772
+ display: block;
773
+ width: 100%;
774
+ margin: 4px 0 2px;
775
+ padding: 7px 10px;
776
+ background: var(--bg-soft);
777
+ border: 1px dashed var(--border);
778
+ border-radius: 8px;
779
+ color: var(--text-dim);
780
+ font: inherit;
781
+ font-size: 12px;
782
+ cursor: pointer;
783
+ transition: border-color var(--t), color var(--t), background-color var(--t);
784
+ }
785
+
786
+ .task-more:hover { color: var(--text); border-color: var(--border-strong); background: var(--bg-raised); }
787
+
788
+ /* 某个目录下的任务被截断时的一行提示 */
789
+ .task-group-more {
790
+ padding: 2px 8px 4px;
791
+ font-size: 11px;
792
+ color: var(--text-faint);
793
+ }
794
+
795
+ /* 消息区顶部「载入更早」:居中的细条,不占气泡的视觉层级 */
796
+ .msg-more-bar {
797
+ display: flex;
798
+ justify-content: center;
799
+ margin: 0 auto 14px;
800
+ padding: 0 20px;
801
+ }
802
+
803
+ .msg-more-btn {
804
+ border-style: dashed;
805
+ color: var(--text-dim);
806
+ }
807
+
808
+ .msg-more-btn:hover { color: var(--text); }
809
+
package/public/task.html CHANGED
@@ -7,7 +7,10 @@
7
7
  <link rel="stylesheet" href="/styles.css" />
8
8
  <link rel="stylesheet" href="/task.css" />
9
9
  <script>
10
- // 防闪白:必须在首次绘制前把主题定下来,所以内联在这里(逻辑与 /theme.js 一致)
10
+ // 防闪白:必须在首次绘制前把主题定下来,所以内联在这里(逻辑与 /theme.js 一致,四个页面都一样)。
11
+ // 顺手把「是不是被 Tab 外壳嵌着」也定下来:带 ?embed=1 时 <html> 上挂 data-embed,
12
+ // styles.css 据此收起本页自己的顶栏(顶栏归外壳,免得出现两条横栏)。
13
+ // 放在首帧之前是为了不闪一下再收起来。
11
14
  (() => {
12
15
  try {
13
16
  let t = localStorage.getItem('lgw.theme');
@@ -19,6 +22,13 @@
19
22
  } catch (e) {
20
23
  document.documentElement.setAttribute('data-theme', 'dark');
21
24
  }
25
+ try {
26
+ if (new URLSearchParams(location.search).get('embed') === '1') {
27
+ document.documentElement.setAttribute('data-embed', '1');
28
+ }
29
+ } catch (e) {
30
+ /* 地址解析不了就当作没嵌入,页面照常独立使用 */
31
+ }
22
32
  })();
23
33
  </script>
24
34
  <!-- 外部集成:地址上带 ?bg=<底色> 时按底色推导整套配色。必须是阻塞脚本(排在 theme.js 之前),
@@ -31,6 +41,12 @@
31
41
  <aside class="sidebar" id="sidebar">
32
42
  <div class="sidebar-head">
33
43
  <button class="btn primary block" id="btn-new-task" type="button">+ 新任务</button>
44
+ <!-- 任务多了要能找:按标题 / 工作目录过滤(列表本身也是分批渲染的,见 task.js 的 LIST_PAGE) -->
45
+ <div class="task-filter">
46
+ <input id="task-search" type="search" spellcheck="false" autocomplete="off"
47
+ placeholder="搜索任务标题 / 目录" aria-label="搜索任务" />
48
+ <button class="btn ghost tiny" id="btn-task-search-clear" type="button" hidden title="清空搜索">✕</button>
49
+ </div>
34
50
  </div>
35
51
  <div class="session-list" id="task-list"></div>
36
52
  <div class="sidebar-foot">
@@ -215,6 +231,8 @@
215
231
  <script src="/render.js"></script>
216
232
  <script src="/models.js"></script>
217
233
  <script src="/task-slash.js"></script>
234
+ <!-- 面板 ↔ Tab 外壳(/)的桥:单独打开本页时它什么都不做,见 public/shell-bridge.js 的说明 -->
235
+ <script src="/shell-bridge.js"></script>
218
236
  <script src="/task.js"></script>
219
237
  </body>
220
238
  </html>