llm-api-gateway-cli 1.0.3 → 1.0.5

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.
@@ -7,7 +7,9 @@
7
7
  <link rel="stylesheet" href="/styles.css" />
8
8
  <link rel="stylesheet" href="/manual.css" />
9
9
  <script>
10
- // 防闪白:必须在首次绘制前把主题定下来(逻辑与 /theme.js 一致,三个页面都一样)
10
+ // 防闪白:必须在首次绘制前把主题定下来(逻辑与 /theme.js 一致,四个页面都一样)。
11
+ // 同时定下「是不是被 Tab 外壳嵌着」:带 ?embed=1 时 <html> 挂 data-embed,
12
+ // styles.css 据此收起本页自己的顶栏(顶栏归外壳)。
11
13
  (() => {
12
14
  try {
13
15
  let t = localStorage.getItem('lgw.theme');
@@ -19,6 +21,13 @@
19
21
  } catch (e) {
20
22
  document.documentElement.setAttribute('data-theme', 'dark');
21
23
  }
24
+ try {
25
+ if (new URLSearchParams(location.search).get('embed') === '1') {
26
+ document.documentElement.setAttribute('data-embed', '1');
27
+ }
28
+ } catch (e) {
29
+ /* 地址解析不了就当作没嵌入,页面照常独立使用 */
30
+ }
22
31
  })();
23
32
  </script>
24
33
  <script src="/tint.js"></script>
@@ -57,6 +66,7 @@
57
66
  <nav class="manual-toc" aria-label="目录">
58
67
  <a href="#s-install">安装与启动</a>
59
68
  <a href="#s-quick">三分钟上手</a>
69
+ <a href="#s-shell">同一页的三个 Tab</a>
60
70
  <a href="#s-modes">三种模式</a>
61
71
  <a href="#s-workdir">工作目录与边界</a>
62
72
  <a href="#s-howto">指令怎么用</a>
@@ -83,7 +93,8 @@
83
93
  ② <b>选做什么、在哪跑</b>:<b>裸敲</b> <code>llm-api-gateway-cli</code>(或 <code>gateway-agent</code>)会先问
84
94
  「① 对话 / ② 任务」,再问「① 命令行窗口 / ② 网页 UI」,选完直接进入对应形态 —— 不替你默认。
85
95
  ③ 只有选了「网页 UI」才会起服务;<b>起服务后要自己用浏览器</b>打开
86
- <code>http://127.0.0.1:3100/</code>(聊天)或 <code>/task</code>(任务),
96
+ <code>http://127.0.0.1:3100/</code> —— 聊天 / 任务 / 手册在<b>同一个页面</b>里用顶部 Tab 切换
97
+ (也能单独打开 <code>/chat</code> 与 <code>/task</code>),
87
98
  而<b>这一页手册本身就是 3100 上的 <code>/manual</code></b>,也要服务起着才看得到。
88
99
  网关地址与密钥在任务页「设置 → 其他配置」里也能改;命令行是 <code>gateway-agent config set baseUrl|key …</code>。
89
100
  想跳过启动器:<code>-p "问题"</code>(单轮)· <code>-i</code>(直接进对话)· <code>gateway-task</code>(直接起网页)。
@@ -287,8 +298,32 @@ gateway-agent config set mode auto # 新任务的默认审批模式</co
287
298
  </ol>
288
299
  </section>
289
300
 
301
+ <section id="s-shell">
302
+ <h2>三、同一页的三个 Tab(聊天 / 任务 / 手册)</h2>
303
+ <p>
304
+ 起服务后打开 <code>http://127.0.0.1:3100/</code>,<b>只有一个浏览器标签页</b>:顶部一排 Tab 在
305
+ <b>聊天</b> / <b>任务</b> / <b>手册</b> 之间切换,不再靠新开页面互跳。
306
+ 当前 Tab 会写进地址栏(<code>/#task</code>、<code>/#manual</code>),所以刷新、收藏、分享链接都能回到同一个面板。
307
+ </p>
308
+ <ul>
309
+ <li><b>状态不丢</b>:切走的面板只是藏起来,正在跑的任务、输入框里的草稿、会话列表都还在原处。</li>
310
+ <li><b>导航只归外壳</b>:面板里不再自带跨面板入口(聊天面板原先那个「+ 新任务 ↗」已删除),切面板一律点顶部的 Tab 或按 <code>Alt+1/2/3</code>。只有任务面板在单独打开时保留了「聊天 ↗」反向入口。</li>
311
+ <li><b>操作按钮跟着当前面板走</b>:外壳顶栏那排按钮(☰ 侧栏开关 / 工作目录 / 设置 / 侧栏收起时的「新对话」)是「代点」面板里的真按钮,所以在哪个 Tab 上就是哪个面板的。<b>不重复占位</b>:面板里已经有的入口(如聊天面板侧边栏的「+ 新对话」)不会在外壳上再放一个。</li>
312
+ <li><b>快捷键</b>:<code>Alt+1</code> 聊天、<code>Alt+2</code> 任务、<code>Alt+3</code> 手册;点 ⟳ 重新加载当前面板。</li>
313
+ <li><b>懒加载</b>:面板是<b>第一次切过去时</b>才加载的;后台在跑的任务会在「任务」Tab 上显示一个脉冲点,切到别的 Tab 也看得见。</li>
314
+ <li><b>三个面板仍能单独打开</b>:<code>/chat</code>、<code>/task</code>、<code>/manual</code> 各自都是完整页面(外部系统 iframe 嵌入、<code>?bg=</code> 底色照旧可用)。</li>
315
+ </ul>
316
+ <h3>任务多了 / 历史长了:懒加载</h3>
317
+ <ul>
318
+ <li><b>任务侧边栏</b>:默认只渲染前 30 条,滚到底自动续载,也可以点「加载更多(还有 N 条)」;每个目录分组被截断时会标注「本组还有 N 条」。</li>
319
+ <li><b>搜索</b>:侧边栏「+ 新任务」下面的搜索框按<b>任务标题与工作目录</b>过滤,任务多的时候比翻列表快;<code>Esc</code> 清空。</li>
320
+ <li><b>消息历史</b>:一条任务默认只渲染最近 30 条消息,往上翻时点顶部「载入更早的消息」前插(视口不跳,也不会把斜杠指令的输出清掉)。</li>
321
+ <li>这两处的懒加载<b>只影响画面</b>:任务与消息始终完整地在磁盘上,落盘、发给模型、批准流程都不受渲染窗口影响。</li>
322
+ </ul>
323
+ </section>
324
+
290
325
  <section id="s-modes">
291
- <h2>三、三种模式(跟任务走,随时可切)</h2>
326
+ <h2>四、三种模式(跟任务走,随时可切)</h2>
292
327
  <div class="manual-table-wrap">
293
328
  <table class="manual-table">
294
329
  <thead>
@@ -320,7 +355,7 @@ gateway-agent config set mode auto # 新任务的默认审批模式</co
320
355
  </section>
321
356
 
322
357
  <section id="s-workdir">
323
- <h2>四、工作目录、任务记录与边界</h2>
358
+ <h2>五、工作目录、任务记录与边界</h2>
324
359
  <ul>
325
360
  <li>模型的一切文件操作都被限制在你选定<b>工作目录</b>之内;换目录用顶栏的目录芯片,或敲 <code>/cwd &lt;路径&gt;</code>。</li>
326
361
  <li><code>/files [子路径]</code> 列目录、<code>/init</code> 在工作目录里生成 <code>AGENTS.md</code> 项目约定骨架、<code>/instructions</code> 看当前注入给模型的项目记忆(<code>AGENTS.md</code> / <code>CLAUDE.md</code>,超上限截断)。</li>
@@ -332,7 +367,7 @@ gateway-agent config set mode auto # 新任务的默认审批模式</co
332
367
  </section>
333
368
 
334
369
  <section id="s-howto">
335
- <h2>五、斜杠指令怎么用</h2>
370
+ <h2>六、斜杠指令怎么用</h2>
336
371
  <ul>
337
372
  <li>在任务页输入框敲一个 <code>/</code>,就会弹出<b>全部</b>候选:<code>↑↓</code> 选择 · <code>Tab</code> 补全 · <code>Enter</code> 执行 · <code>Esc</code> 关闭。接着打字母会按前缀收窄(<code>/pl</code> → <code>/plan</code>)。</li>
338
373
  <li>指令<b>只在页面本地执行</b>,不发给模型、也不进任务对白(所以它不会污染模型的上下文)。</li>
@@ -345,7 +380,7 @@ gateway-agent config set mode auto # 新任务的默认审批模式</co
345
380
  </section>
346
381
 
347
382
  <section id="s-commands">
348
- <h2>六、指令一览(自动生成)</h2>
383
+ <h2>七、指令一览(自动生成)</h2>
349
384
  <p class="manual-note">
350
385
  三端对照在前,各端清单在后。<code>命令行 REPL</code> 是终端里的 <code>gateway-agent</code>;
351
386
  <code>任务页</code> 是本服务的 <code>/task</code>;<code>本机聊天页</code> 是本服务的 <code>/</code>。
@@ -354,7 +389,7 @@ gateway-agent config set mode auto # 新任务的默认审批模式</co
354
389
  </section>
355
390
 
356
391
  <section id="s-faq">
357
- <h2>七、常见问题</h2>
392
+ <h2>八、常见问题</h2>
358
393
  <dl class="manual-faq">
359
394
  <dt><code>/files</code> 说"未放行"?</dt>
360
395
  <dd>目录浏览、项目记忆、任务记录这类接口会回传本机路径与文件正文,服务只在本机监听(或显式加了 <code>--allow-remote-fs</code>)时才放行。用 <code>--host 0.0.0.0</code> 从别的机器访问时,它们是关着的 —— 这是有意的安全边界。</dd>
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 面板 ↔ 外壳 的桥(聊天面板与任务面板引入;手册面板只需要主题同步,不引)
5
+ *
6
+ * 背景:`/` 是 Tab 外壳(public/shell.html),三个面板是它懒加载的同源 iframe。
7
+ * 面板单独打开(`/chat`、`/task?bg=…`、被别的系统 iframe 嵌入)时本文件**什么都不做** ——
8
+ * 所有上行消息都要求 `?embed=1` 且真的在某个父窗口里,单独打开时 `lgwBridge.active === false`。
9
+ *
10
+ * 上行(面板 → 外壳):ready / dot / workdir / sidebar / busy / goto(协议全文见 shell.js 头注释)
11
+ * 下行(外壳 → 面板):
12
+ * · `{type:'click', id}` —— 外壳代点面板里的 `#id`(设置、侧栏开关、工作目录…)。
13
+ * 面板顶栏在嵌入时被 CSS 收起,但元素还在 DOM 里,`.click()` 照样触发原 handler。
14
+ *
15
+ * 用它的三条纪律:
16
+ * 1. 一律通过 `window.lgwBridge` 特性检测调用(`window.lgwBridge && window.lgwBridge.dot('ok')`),
17
+ * 不在页面里写 iframe/embed 判断 —— 单独打开的那条路径必须一行都不受影响。
18
+ * 2. 上行消息只发同源 origin,且外壳只认自己登记的 iframe 窗口(两侧都校验)。
19
+ * 3. **导航只归外壳**:面板自己不提供跨面板入口(聊天页原先那个「+ 新任务 ↗」入口在 20260922 已删掉,
20
+ * 切面板一律走外壳顶部的 Tab),所以这里没有「把链接改成切 Tab」那套拦截逻辑。
21
+ */
22
+ (function () {
23
+ 'use strict';
24
+
25
+ const params = new URLSearchParams(window.location.search || '');
26
+ const hasEmbed = params.get('embed') === '1';
27
+ const inFrame = Boolean(window.parent) && window.parent !== window;
28
+ const active = hasEmbed && inFrame;
29
+ const ORIGIN = window.location.origin;
30
+
31
+ /** 当前面板的 Tab id:外壳据此把消息对上号(认不出来就什么都不发) */
32
+ function tabId() {
33
+ const p = (window.location.pathname || '/').replace(/\/+$/, '') || '/';
34
+ if (p === '/task' || p === '/task.html') return 'task';
35
+ if (p === '/' || p === '/chat' || p === '/chat.html' || p === '/index.html') return 'chat';
36
+ return '';
37
+ }
38
+
39
+ const tab = tabId();
40
+ const canPost = active && Boolean(tab);
41
+
42
+ function post(msg) {
43
+ if (!canPost) return false;
44
+ try {
45
+ window.parent.postMessage({ source: 'lgw-pane', tab, ...msg }, ORIGIN);
46
+ return true;
47
+ } catch {
48
+ return false; // 父窗口正好没了:不影响面板自身的功能
49
+ }
50
+ }
51
+
52
+ /**
53
+ * 面板 → 外壳的五个通知。都不返回值、也不算「业务成功与否」——
54
+ * 外壳不在(单独打开)时它们是空操作,页面照常。
55
+ */
56
+ const api = {
57
+ active: canPost,
58
+ tab,
59
+ post,
60
+ ready: () => post({ type: 'ready' }),
61
+ dot: (kind) => post({ type: 'dot', kind: kind || '' }),
62
+ workdir: (text, title) => post({ type: 'workdir', text: text || '', title: title || '' }),
63
+ sidebar: (open) => post({ type: 'sidebar', open: Boolean(open) }),
64
+ busy: (busy) => post({ type: 'busy', busy: Boolean(busy) }),
65
+ /** 请求外壳切到另一个面板(`Alt+1/2/3` 用) */
66
+ goto: (target) => post({ type: 'goto', tab: target }),
67
+ };
68
+
69
+ window.lgwBridge = api;
70
+
71
+ if (!canPost) return; // 单独打开:到此为止
72
+
73
+ window.addEventListener('message', (e) => {
74
+ // 只收同源的、发给本面板的外壳消息
75
+ if (e.origin !== ORIGIN) return;
76
+ const msg = e.data;
77
+ if (!msg || msg.source !== 'lgw-shell' || msg.tab !== tab) return;
78
+ if (msg.type === 'click') {
79
+ const el = document.getElementById(msg.id);
80
+ if (el) el.click();
81
+ }
82
+ });
83
+
84
+ /**
85
+ * Alt+1/2/3 切面板的快捷键在面板里也要生效:焦点在输入框里时 keydown 只发给面板自己的文档,
86
+ * 外壳那个监听根本收不到 —— 这里补一条,转成 goto 消息(面板单独打开时上面已经 return 了)。
87
+ */
88
+ const TAB_KEYS = { 1: 'chat', 2: 'task', 3: 'manual' };
89
+ document.addEventListener('keydown', (e) => {
90
+ if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
91
+ const target = TAB_KEYS[e.key];
92
+ if (!target) return;
93
+ e.preventDefault();
94
+ api.goto(target);
95
+ });
96
+
97
+ // 所有面板脚本(含 init)都跑完才算就绪:外壳收到 ready 后会补发排队中的代点指令
98
+ if (document.readyState === 'complete') api.ready();
99
+ else window.addEventListener('load', () => api.ready());
100
+ })();
@@ -0,0 +1,242 @@
1
+ /* Tab 外壳(/):聊天 / 任务 / 手册 在同一个页面里切换
2
+ *
3
+ * 只做「外壳」这一层的排版:配色、按钮、主题全部复用 styles.css 的变量与类
4
+ * (--bg / --border / --text / .btn / .dot / .theme-switch …),
5
+ * 所以白天 / 黑夜 / 外部底色(?bg=)三种情况下都与三个面板协调。
6
+ * 新加的变量(--accent-2 / --accent-2-soft)同时写在 styles.css 的深浅两套里,tests/theme.test.mjs 盯着。
7
+ */
8
+
9
+ .shell-body {
10
+ display: flex;
11
+ flex-direction: column;
12
+ height: 100%;
13
+ overflow: hidden; /* 滚动条交给面板内部,外壳自己不滚 */
14
+ }
15
+
16
+ /* ---------- 顶栏(唯一一条) ---------- */
17
+
18
+ .shell-bar {
19
+ flex: none;
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 12px;
23
+ padding: 8px 14px;
24
+ background: var(--bg-soft);
25
+ border-bottom: 1px solid var(--border);
26
+ box-shadow: var(--shadow-xs);
27
+ position: relative;
28
+ z-index: 3;
29
+ }
30
+
31
+ /* 顶栏底部一条极细的强调线:让「这一条是外壳、下面是面板」一眼可分 */
32
+ .shell-bar::after {
33
+ content: "";
34
+ position: absolute;
35
+ left: 0;
36
+ right: 0;
37
+ bottom: -1px;
38
+ height: 1px;
39
+ background: linear-gradient(90deg, transparent, var(--accent-2), transparent);
40
+ opacity: .45;
41
+ pointer-events: none;
42
+ }
43
+
44
+ .shell-brand { flex: none; }
45
+ .shell-brand h1 { font-size: 14.5px; }
46
+
47
+ /* ---------- Tab 组:药丸 + 滑动高亮块 ---------- */
48
+
49
+ .tabs {
50
+ position: relative;
51
+ display: flex;
52
+ align-items: center;
53
+ gap: 2px;
54
+ padding: 3px;
55
+ border: 1px solid var(--border);
56
+ border-radius: 999px;
57
+ background: var(--bg-code);
58
+ flex: none;
59
+ }
60
+
61
+ .tab-ink {
62
+ position: absolute;
63
+ top: 3px;
64
+ bottom: 3px;
65
+ left: 0;
66
+ width: 0;
67
+ border-radius: 999px;
68
+ background: var(--bg-raised);
69
+ border: 1px solid var(--border-strong);
70
+ box-shadow: var(--shadow-xs);
71
+ transition: transform var(--t), width var(--t);
72
+ pointer-events: none;
73
+ }
74
+
75
+ .tab {
76
+ position: relative;
77
+ z-index: 1;
78
+ display: inline-flex;
79
+ align-items: center;
80
+ gap: 6px;
81
+ border: 1px solid transparent;
82
+ background: none;
83
+ color: var(--text-dim);
84
+ font: inherit;
85
+ font-size: 13px;
86
+ padding: 5px 14px;
87
+ border-radius: 999px;
88
+ cursor: pointer;
89
+ transition: color var(--t), background-color var(--t);
90
+ white-space: nowrap;
91
+ }
92
+
93
+ .tab:hover { color: var(--text); background: var(--accent-2-soft); }
94
+ .tab:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--accent-soft); }
95
+ .tab[aria-selected="true"] { color: var(--text); font-weight: 600; }
96
+
97
+ .tab-icon { font-size: 12.5px; line-height: 1; }
98
+
99
+ /* 后台有任务在跑时的脉冲点:切到别的 Tab 也知道任务没停 */
100
+ .tab-dot {
101
+ display: none;
102
+ width: 6px;
103
+ height: 6px;
104
+ border-radius: 50%;
105
+ background: var(--warn);
106
+ box-shadow: 0 0 0 3px var(--warn-ring);
107
+ }
108
+
109
+ .tab.busy .tab-dot {
110
+ display: block;
111
+ animation: tab-pulse 1.4s ease-in-out infinite;
112
+ }
113
+
114
+ @keyframes tab-pulse {
115
+ 0%, 100% { opacity: .35; transform: scale(.85); }
116
+ 50% { opacity: 1; transform: scale(1); }
117
+ }
118
+
119
+ /* ---------- 当前面板的操作按钮(外壳代点面板里的按钮) ---------- */
120
+
121
+ .shell-actions {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 8px;
125
+ margin-left: auto;
126
+ min-width: 0;
127
+ overflow: hidden; /* 顶栏不允许被长内容顶出屏幕;工作目录那一条自己会省略号截断 */
128
+ }
129
+
130
+ .shell-actions:empty { display: none; }
131
+
132
+ .shell-actions .btn.active {
133
+ color: var(--accent);
134
+ border-color: var(--border-strong);
135
+ background: var(--accent-soft);
136
+ }
137
+
138
+ /* 工作目录:内容来自任务面板(桥消息 workdir),点击转发给面板里的 #workdir-chip */
139
+ .shell-chip {
140
+ display: inline-flex;
141
+ align-items: center;
142
+ gap: 8px;
143
+ max-width: 320px;
144
+ border: 1px solid var(--border);
145
+ background: var(--bg-code);
146
+ color: var(--text-dim);
147
+ border-radius: 999px;
148
+ padding: 4px 12px;
149
+ font: inherit;
150
+ font-size: 12px;
151
+ cursor: pointer;
152
+ transition: border-color var(--t), color var(--t);
153
+ }
154
+
155
+ .shell-chip:hover { border-color: var(--border-strong); color: var(--text); }
156
+ .shell-chip .wd-label { color: var(--text-faint); font-size: 11px; flex: none; }
157
+ .shell-chip code {
158
+ font-family: var(--mono);
159
+ font-size: 11.5px;
160
+ white-space: nowrap;
161
+ overflow: hidden;
162
+ text-overflow: ellipsis;
163
+ /* flex 项的 min-width 默认是 auto:不写这行,长路径会把顶栏顶出屏幕(省略号也不生效) */
164
+ min-width: 0;
165
+ }
166
+
167
+ /* ---------- 面板区 ---------- */
168
+
169
+ .panes {
170
+ position: relative;
171
+ flex: 1;
172
+ min-height: 0;
173
+ background: var(--bg);
174
+ }
175
+
176
+ .pane {
177
+ position: absolute;
178
+ inset: 0;
179
+ /* 切回来时淡入一下(display:none → 显示会让动画重新开始,正是想要的效果) */
180
+ animation: pane-in 180ms cubic-bezier(.4, 0, .2, 1);
181
+ }
182
+
183
+ .pane[hidden] { display: none; }
184
+
185
+ .pane-frame {
186
+ width: 100%;
187
+ height: 100%;
188
+ border: 0;
189
+ display: block;
190
+ background: var(--bg);
191
+ }
192
+
193
+ @keyframes pane-in {
194
+ from { opacity: 0; transform: translateY(4px); }
195
+ to { opacity: 1; transform: none; }
196
+ }
197
+
198
+ /* 首次激活前的骨架:懒加载时给一句「正在加载」而不是白屏。
199
+ z-index 是必须的 —— iframe 在后面创建,不抬起来会被它盖住(等于没骨架)。
200
+ [hidden] 这条也是必须的:UA 的 `[hidden]{display:none}` 会被上面那条 `display:flex` 压掉,
201
+ 不加这一条骨架在 iframe 加载完之后仍然盖在上面(表现就是「外壳出来了、面板一直空着」)。 */
202
+ .pane-skeleton {
203
+ position: absolute;
204
+ inset: 0;
205
+ z-index: 1;
206
+ display: flex;
207
+ flex-direction: column;
208
+ align-items: center;
209
+ justify-content: center;
210
+ gap: 12px;
211
+ color: var(--text-faint);
212
+ font-size: 13px;
213
+ background: var(--bg);
214
+ }
215
+
216
+ .pane-skeleton[hidden] { display: none; }
217
+
218
+ .pane-skeleton .spinner {
219
+ width: 22px;
220
+ height: 22px;
221
+ border-radius: 50%;
222
+ border: 2px solid var(--border);
223
+ border-top-color: var(--accent);
224
+ animation: spin .8s linear infinite;
225
+ }
226
+
227
+ @keyframes spin { to { transform: rotate(360deg); } }
228
+
229
+ /* ---------- 窄屏 ---------- */
230
+
231
+ @media (max-width: 860px) {
232
+ .shell-bar { gap: 8px; padding: 8px 10px; }
233
+ .shell-chip { max-width: 180px; }
234
+ }
235
+
236
+ @media (max-width: 700px) {
237
+ .tab-text { display: none; }
238
+ .tab { padding: 5px 12px; }
239
+ .shell-brand h1, .shell-brand .tag { display: none; }
240
+ .shell-chip { max-width: 120px; }
241
+ .shell-actions .btn .btn-text { display: none; }
242
+ }
@@ -0,0 +1,73 @@
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</title>
7
+ <link rel="stylesheet" href="/styles.css" />
8
+ <link rel="stylesheet" href="/shell.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
+ })();
23
+ </script>
24
+ <!-- 外部集成:地址上带 ?bg=<底色> 时按底色推导整套配色,并透传给三个面板(见 shell.js 的 paneSrc)。
25
+ 必须是阻塞脚本(排在 theme.js 之前),否则会先按默认配色画一帧再变色 -->
26
+ <script src="/tint.js"></script>
27
+ <script src="/theme.js" defer></script>
28
+ </head>
29
+ <body class="shell-body">
30
+ <!-- 唯一顶栏:品牌 + Tab + 当前面板操作 + 主题。
31
+ 三个面板自己的顶栏在嵌入时收起(html[data-embed] .topbar),避免两条横栏 -->
32
+ <header class="shell-bar" id="shell-bar">
33
+ <div class="brand shell-brand">
34
+ <span class="dot" id="shell-dot"></span>
35
+ <h1>LLM API Gateway</h1>
36
+ <span class="tag" id="shell-tag">Web UI</span>
37
+ </div>
38
+
39
+ <nav class="tabs" id="shell-tabs" role="tablist" aria-label="页面">
40
+ <button class="tab" type="button" role="tab" data-tab="chat" id="tab-chat"
41
+ aria-selected="true" title="纯对话:不碰文件系统(Alt+1)">
42
+ <span class="tab-icon" aria-hidden="true">💬</span><span class="tab-text">聊天</span>
43
+ </button>
44
+ <button class="tab" type="button" role="tab" data-tab="task" id="tab-task"
45
+ aria-selected="false" title="任务模式:选一个目录,模型真读写(Alt+2)">
46
+ <span class="tab-icon" aria-hidden="true">🗂</span><span class="tab-text">任务</span>
47
+ <span class="tab-dot" aria-hidden="true"></span>
48
+ </button>
49
+ <button class="tab" type="button" role="tab" data-tab="manual" id="tab-manual"
50
+ aria-selected="false" title="操作手册:流程 + 三端指令对照(Alt+3)">
51
+ <span class="tab-icon" aria-hidden="true">📖</span><span class="tab-text">手册</span>
52
+ </button>
53
+ <span class="tab-ink" id="tab-ink" aria-hidden="true"></span>
54
+ </nav>
55
+
56
+ <div class="shell-actions" id="shell-actions"></div>
57
+
58
+ <div class="theme-switch" role="group" aria-label="主题">
59
+ <button class="theme-btn" type="button" data-theme-option="system" title="跟随系统" aria-label="跟随系统">◐</button>
60
+ <button class="theme-btn" type="button" data-theme-option="light" title="白天" aria-label="白天">☀</button>
61
+ <button class="theme-btn" type="button" data-theme-option="dark" title="黑夜" aria-label="黑夜">☾</button>
62
+ </div>
63
+
64
+ <button class="btn ghost icon-only" id="btn-reload-pane" type="button"
65
+ title="重新加载当前面板(面板里的状态会重置)">⟳</button>
66
+ </header>
67
+
68
+ <!-- 面板区:iframe 首次激活时才创建(懒加载),切走的保留在 DOM 里 —— 状态不丢 -->
69
+ <main class="panes" id="panes"></main>
70
+
71
+ <script src="/shell.js"></script>
72
+ </body>
73
+ </html>