llm-api-gateway-cli 1.0.2 → 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.
package/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  | `cli-openai.js` | 模式一:OpenAI 兼容 | `/v1/chat/completions` | `openai` SDK |
8
8
  | `cli-anthropic.js` | 模式二:Anthropic 兼容 | `/v1/messages` | `@anthropic-ai/sdk` |
9
9
  | `cli-claude-code.js` | 模式三:Claude Code | `/v1/messages`(透传 claude CLI) | 本机已装 `claude` |
10
- | `server.js` | 本地 Web UI 入口:聊天(`/`)+ 任务(`/task`),共用一个端口 | `/v1/chat/completions`(浏览器 → 本机服务 → 网关)+ 任务侧的工具调用 | 无(零新增依赖) |
10
+ | `server.js` | 本地 Web UI 入口:聊天 / 任务 / 手册在**同一个页面**里用顶部 Tab 切换(`/`),三个面板也能各自单独打开 | `/v1/chat/completions`(浏览器 → 本机服务 → 网关)+ 任务侧的工具调用 | 无(零新增依赖) |
11
11
  | `task-server.js` | 同一个服务的别名入口,直接落到 `/task` | 同左 | 无(零新增依赖) |
12
12
  | `cli-agent.js` | 模式六:原生 Agent CLI(推荐) | `/v1/chat/completions` + 工具调用 | 无(零新增依赖) |
13
13
 
@@ -188,12 +188,21 @@ node cli-claude-code.js --config # 打印 CC Switch / CC GUI 可粘
188
188
 
189
189
  `server.js` 用 `node:http` 起一个**只监听本机**的小服务,浏览器打开即是一个聊天界面:多轮对话、流式打字机输出、Markdown 渲染、模型切换、系统提示词、token 用量统计。请求链路是「浏览器 → 本机 3100 → 网关 9000」,`sk-` 密钥只留在服务端,不下发到页面。
190
190
 
191
- **聊天和任务现在是一个服务、一个端口**,两个页面之间是同源相对路径跳转:
191
+ **聊天、任务、操作手册现在是一个服务、一个端口、一个页面**:打开 `http://127.0.0.1:3100/` 就是顶部 Tab 外壳,
192
+ 在**聊天 / 任务 / 手册**之间切换(不再新开标签页,切走的面板状态不丢,地址栏同步 `#chat` / `#task` / `#manual`);
193
+ 三个面板也都能单独打开(外部系统 iframe 嵌入、`?bg=` 底色照旧):
192
194
 
193
- | 页面 | 地址 |
194
- | --- | --- |
195
- | 聊天(模式四) | http://127.0.0.1:3100/ |
196
- | 任务(模式五) | http://127.0.0.1:3100/task |
195
+ | 页面 | 地址 | 说明 |
196
+ | --- | --- | --- |
197
+ | Tab 外壳(推荐入口) | http://127.0.0.1:3100/ | 聊天 / 任务 / 手册 同页切换;面板**第一次切过去才加载**(懒加载),后台在跑的任务在 Tab 上有脉冲点 |
198
+ | 聊天(模式四) | http://127.0.0.1:3100/chat | 纯对话,不碰文件系统(老链接 `/index.html` 也还是它) |
199
+ | 任务(模式五) | http://127.0.0.1:3100/task | 选一个目录,模型真读写 |
200
+ | 操作手册 | http://127.0.0.1:3100/manual | 同一份手册,见下文「操作手册页」 |
201
+
202
+ 外壳顶栏就是唯一一条顶栏(品牌 / Tab / 当前面板的操作按钮 / 主题 / ⟳ 重载当前面板):面板自带的顶栏在嵌入时收起,
203
+ 那些按钮(☰ 侧栏开关、工作目录、设置、新对话)仍是面板里的真按钮,外壳只负责「代点」。
204
+ 快捷键 `Alt+1/2/3` 切面板。三个面板各自完整、互不干扰(同源 iframe),所以两边都能同时用;
205
+ 主题切换在同源文档间即时同步(`theme.js` 监听 `storage`)。
197
206
 
198
207
  `npm run web` 和 `npm run task`(`server.js` / `task-server.js`)起的是**同一个服务**,两个命令等价;保留两个名字只是为了不打断已有习惯和脚本。旧的 3101 不再使用。
199
208
 
@@ -489,8 +498,10 @@ node task-server.js --mode auto # 默认审批模式:manual / auto
489
498
 
490
499
  | 接口 | 作用 |
491
500
  | --- | --- |
492
- | `GET /` | 任务页面 |
493
- | `GET /manual` | 操作手册页(安装与启动 + 任务页操作流程 + 三端斜杠指令对照) |
501
+ | `GET /` | Tab 外壳:聊天 / 任务 / 手册在同一个页面里用顶部 Tab 切换(`/shell.html` 同页) |
502
+ | `GET /chat` | 聊天面板(单独打开;老链接 `/index.html` 也是它) |
503
+ | `GET /task` | 任务面板(单独打开,选目录、模型真读写) |
504
+ | `GET /manual` | 操作手册面板(安装与启动 + 任务流程 + 三端斜杠指令对照) |
494
505
  | `GET /api/commands` | 三端(命令行 / 本机聊天页 / 任务页)指令的机器可读清单,手册页据此生成,不手抄 |
495
506
  | `GET /api/cost?prompt=&completion=&model=` | 费用粗估(与 CLI `/cost` 同一份单价表 `lib/pricing.js`) |
496
507
  | `GET /api/config` | 网关地址、模型、工具清单、上限、密钥是否就绪(只回脱敏值 + 来源 + 密钥文件路径) |
@@ -605,14 +616,22 @@ node task-server.js --mode auto # 默认审批模式:manual / auto
605
616
 
606
617
  > **安全边界**:任务记录里含工具读过的文件正文,属于本机数据,所以存储接口和目录浏览一样**只在服务绑定本机时开放**,绑定 `0.0.0.0` 需显式 `--allow-remote-fs`。另外,如果你把工作目录正好选成了存储目录所在的位置(比如回退到 `.tasks/` 时选了本项目),模型的文件工具就能读到任务历史 —— 服务端检测到这种情况会在状态栏提示。
607
618
 
608
- > 与聊天的区别:聊天没有工作目录、不碰文件系统,是纯对话;任务是「选目录 → 干活」。两者是同一个服务上的两个页面(/ /task),可以同时开着,右上角互相切换。
619
+ > 与聊天的区别:聊天没有工作目录、不碰文件系统,是纯对话;任务是「选目录 → 干活」。两者是 `http://127.0.0.1:3100/` 这个 Tab 外壳里的两个面板(也能用 `/chat`、`/task` 单独打开),可以同时用,顶部 Tab 互相切换。
620
+
621
+ 三个面板之间不用记端口,也不用新开页面:
622
+
623
+ - 顶栏 Tab **聊天 / 任务 / 手册** 直接切(`Alt+1/2/3`);
624
+ - **导航只归外壳**:聊天面板里不再自带通往任务页的入口(原先那个「+ 新任务 ↗」已删除),要开任务就点外壳的「任务」Tab;
625
+ - 任务页顶栏仍保留「聊天 ↗」入口,方便 `/task` 单开(外部 iframe 嵌入)时用。
609
626
 
610
- 两个页面互相留了入口,不用记端口:
627
+ ### 任务多了 / 历史长了:懒加载
611
628
 
612
- - 聊天页侧边栏「**+ 新任务**」在「+ 新对话」**上面**,点它新开标签页进入任务模式;
613
- - 任务页顶栏「**聊天 ↗**」反向回到聊天模式。
629
+ - **侧边栏**默认只渲染前 30 条任务,滚到底自动续载,也可以点「加载更多(还有 N 条)」;被截断的目录分组会标注「本组还有 N 条」;
630
+ - 侧边栏搜索框按**任务标题 / 工作目录**过滤(纯客户端,不发请求),`Esc` 清空;
631
+ - **消息历史**默认只渲染最近 30 条,往上翻点顶部「载入更早的消息」**前插**(视口不跳、斜杠指令输出不会被清掉);
632
+ - 磁盘上的任务与消息始终是完整的:懒加载只少建 DOM,不改落盘、发送给模型的内容与批准流程。
614
633
 
615
- 两边都用当前主机名推导对方地址(所以通过局域网 IP 访问时不会跳回 `127.0.0.1`);端口若改过,可用 `?task=http://host:port/` 或 `?chat=http://host:port/` 覆盖。
634
+ 任务页顶栏那个「聊天 ↗」用同源相对路径,通过局域网 IP 访问时不会跳回 `127.0.0.1`;端口若改过,可用 `?chat=http://host:port/` 覆盖它。
616
635
 
617
636
  ## 模式六 · 原生 Agent CLI(推荐,不依赖 Claude Code)
618
637
 
@@ -706,7 +725,7 @@ node cli-agent.js -i --resume <会话id> # 切到指定
706
725
 
707
726
  模式五(Web)与模式六(CLI)共用同一套实现,避免两处各写一遍后行为漂移:
708
727
 
709
- - `lib/hub.js`:**唯一的 Web 服务实现** —— 静态文件(`/` → 聊天页、`/task` → 任务页、`/manual` → 操作手册页)、两页共用的 `/api/config` 与 `/api/models`、手册页数据 `/api/commands` 与 `/api/cost`、聊天的 `/api/chat`、任务的全部接口都在这里;`server.js` 与 `task-server.js` 只是它的两个薄入口。
728
+ - `lib/hub.js`:**唯一的 Web 服务实现** —— 静态文件(`/` → Tab 外壳、`/chat` → 聊天面板、`/task` → 任务面板、`/manual` → 操作手册面板)、两页共用的 `/api/config` 与 `/api/models`、手册页数据 `/api/commands` 与 `/api/cost`、聊天的 `/api/chat`、任务的全部接口都在这里;`server.js` 与 `task-server.js` 只是它的两个薄入口。
710
729
  - `lib/tools.js`:八个工具(`list_dir` / `read_file` / `search_files` / `glob` / `grep` / `apply_patch` / `write_file` / `bash`)+ 沙箱(`realpath` 符号链接逃逸检查)+ 写入预览 `writePreview`;`bash` 由 `setBashEnabled` 控制开关,未开启时 `availableTools()` 直接把它摘掉。
711
730
  - `lib/agent.js`:模型 → 工具 → 回填 → 再问的循环、SSE 解析、按 `index` 拼接 `tool_calls`、挂起/批准/续跑、带退避的 `fetchWithRetry`;
712
731
  - `lib/runner.js`:把上面两者包成「建会话 → 跑一轮 → 遇写入挂起 → 等批准 → 断点续跑」的传输无关流程,Web 与 CLI 都基于它(`createSession` / `restoreSession` / `pushUser` / `runTurn` / `resumePendingTurn` / `buildTaskSystemPrompt`);
@@ -755,7 +774,8 @@ npm run test:all # 先离线再联网
755
774
  | `tests/taskstore.test.mjs` | 磁盘存储(`lib/taskstore.js`):路径探测与降级、id 防目录穿越、增删改查、15 天/20 条清理、原子写入、索引损坏重建、任务文件损坏容错、脏数据清洗、重开进程后数据仍在;以及**没改东西的保存不算更新**(时间戳不动、文件不重写,真变化才更新,任务文件丢了会补写) |
756
775
  | `tests/task-store.test.mjs` | 任务页客户端逻辑:调接口的路径与方法对不对、草稿态不建任务、`ensureTask` 幂等、自动命名与重命名保护、从浏览器旧数据迁移、接口报错时有可读提示 |
757
776
  | `tests/page-runtime.test.mjs` | 把 `task.js` 放进最小 DOM 垫片 + 假服务端里真跑一遍:`init()` 不抛异常、侧边栏渲染、**按工作目录分组**(同目录多任务、分组 `+` 新建、折叠/展开、切过去自动展开)、**模型下拉的选项来自 `/api/models`**(配置里的模型不在列表里时保留并选中、拉不到列表时只剩默认+当前值并说明原因)、存储路径展示、点「新任务」/切换/删除的完整请求路径、迁移浏览器旧数据、服务端报错时页面不崩;用「卡住的流」验证**切走不中断**(后台跑完并落盘到它自己的历史)、**切回走缓存不重新拉**、看别的任务时输入不被禁用;**点标题只换视图**(切走与切回都不产生 PUT、时间戳原封不动,内容真变了才落盘、同内容连存两次只发一次请求) |
758
- | `tests/theme.test.mjs` | 主题与单端口契约:两套 CSS 里没有裸颜色、浅色覆盖了深色的**每一个**颜色变量、`task.css` 用到的变量都有定义、两个页面都在 `<head>` 里内联防闪白脚本且 key 与 `theme.js` 一致、`theme.js` 在 DOM 垫片里真跑(跟随系统 / 切换 / 记住 / 非法值容错 / 点按钮生效 / 系统变化在跟随模式下生效、明确选过后不再被覆盖)、`server.js` 与 `task-server.js` 是同一服务的薄入口、两页路由与两套接口都在同一个 handler 里、存储路径只在放行时才进配置 |
777
+ | `tests/theme.test.mjs` | 主题与单端口契约:两套 CSS 里没有裸颜色、浅色覆盖了深色的**每一个**颜色变量、`task.css` `shell.css` 用到的变量都有定义、四个页面都在 `<head>` 里内联防闪白脚本且 key 与 `theme.js` 一致、`theme.js` 在 DOM 垫片里真跑(跟随系统 / 切换 / 记住 / 非法值容错 / 点按钮生效 / 系统变化在跟随模式下生效、明确选过后不再被覆盖、**监听 `storage` 让同源的面板与多标签页跟着变**)、`server.js` 与 `task-server.js` 是同一服务的薄入口、`/` 是 Tab 外壳而三个面板各有独立路由、两套接口都在同一个 handler 里、存储路径只在放行时才进配置 |
778
+ | `tests/shell.test.mjs` | Tab 外壳:外壳页结构(三个 Tab、唯一顶栏、面板区为空容器 = iframe 懒加载)、`shell.js` 的懒加载与桥协议(用到才建 iframe、切走只 hidden、只认同源 origin、只认已登记的面板窗口、代点 `{type:click}` 与 `{type:cmd}`、没就绪先排队、`#tab` 同步、`Alt+1/2/3`)、**用 DOM 垫片真跑一遍**(懒加载顺序、点外壳按钮发出正确的代点消息、面板上行状态驱动外壳顶栏、跨源/未登记窗口的消息被忽略、load 后补发排队指令)、`shell-bridge.js` 只在 `?embed=1` 且真在 iframe 里才生效(单独打开时一行都不动)、三个面板的 `data-embed` 适配、任务侧边栏与消息历史懒加载的源码契约,以及真起服务后 `/`、`/chat`、`/task`、`/manual`、`/shell.html`、`/shell.css`、`/shell-bridge.js` 各自可达 |
759
779
  | `tests/tint.test.mjs` | 外部集成配色(`?bg=`):两页都在首次绘制前阻塞接上 `tint.js`、颜色解析(hex / rgb / hsl / 具名色,以及 `;`、`url()`、`var()` 这类注入写法一律拒绝)、**传默认深/浅底推导结果贴住默认那一套配色**、任意底色下的层次关系(深底加亮、浅底更亮、描边始终可见)、垫片里真跑(参数优先 / 同标签页记忆 / `?bg=none` 清除 / 认不出就忽略 / `sessionStorage` 不可用也能用 / 页内跳转带参数)、`theme.js` 遇到底色让位 |
760
780
  | `tests/chat.test.mjs` | 聊天端到端:静态资源、目录穿越防护、真实流式对话(含思维链)、错误处理、配置不下发真实密钥与本机路径 |
761
781
  | `tests/live.test.mjs` | 任务端到端:真实模型调用工具读写真实文件、批准后落盘、拒绝后不落盘、过期运行态返回 410 |
@@ -847,7 +867,7 @@ history.maxChars 60000 内置默认
847
867
 
848
868
  ## Docker 容器化部署
849
869
 
850
- 把整个应用打成一个镜像,用 Docker Desktop 起一个容器就能用:聊天页 `/` 与任务页 `/task` 都在里面,Agent 的「工作目录」映射到宿主机的某个目录。**本节命令全部是 Windows PowerShell 写法。**
870
+ 把整个应用打成一个镜像,用 Docker Desktop 起一个容器就能用:Tab 外壳 `/`(聊天 / 任务 / 手册同页切换,也能用 `/chat`、`/task` 单独打开)就在里面,Agent 的「工作目录」映射到宿主机的某个目录。**本节命令全部是 Windows PowerShell 写法。**
851
871
 
852
872
  | 新增文件 | 作用 |
853
873
  | --- | --- |
package/lib/config.js CHANGED
@@ -8,8 +8,9 @@
8
8
  * 所有 CLI 都走 resolveConfig,保证问「密钥从哪来」只有一种答案。
9
9
  */
10
10
 
11
- import { resolveBaseUrl, strArg } from './common.js';
11
+ import { DEFAULT_BASE_URL, strArg } from './common.js';
12
12
  import { resolveSecretKey } from './secrets.js';
13
+ import { resolveSettings, settingsFilePath } from './settings.js';
13
14
 
14
15
  /**
15
16
  * 缺密钥时的统一提示(各 CLI 文案一致,避免用户按提示改了还是不通)。
@@ -44,12 +45,21 @@ export function numArg(v) {
44
45
  * @returns {{key:string, baseUrl:string, model:string, workDir:string,
45
46
  * temperature:number|undefined, maxTokens:number|undefined, system:string|undefined}}
46
47
  */
47
- export function resolveConfig(args = {}, { defaultModel = '', defaultMaxTokens, env = process.env } = {}) {
48
+ export function resolveConfig(args = {}, { defaultModel = '', defaultMaxTokens, env = process.env, file = settingsFilePath(env) } = {}) {
48
49
  const maxTokens = numArg(args['max-tokens']) ?? defaultMaxTokens;
49
50
  // 密钥走 lib/secrets.js:`--key` > 环境变量 / `.env` > credentials.json。
50
51
  // 命令行必须认那个文件,否则「在界面里配一次就能用」对 CLI 入口是句空话
51
52
  // (界面与 config set key 写的都是它)。来源字段一并带出来,横幅才能说清「谁给的」。
52
53
  const secret = resolveSecretKey({ args, env });
54
+ // 网关地址与模型走 lib/settings.js 的**四层**优先级:--flag > 环境变量/.env > config.json > 内置默认。
55
+ //
56
+ // 这一段曾经用的是 lib/common.js 的 resolveBaseUrl —— 它只认 flag 与环境变量,**不读 config.json**。
57
+ // 后果是 `gateway-agent setup` 与 `config set baseUrl` 写进配置文件的地址,命令行根本不认:
58
+ // 网页/设置面板显示 192.168.x.x,命令行却仍旧连内置默认的 127.0.0.1:9000(模型同理)。
59
+ // 网页侧一直走 resolveSettings,所以「页面是对的、命令行是错的」——两条链必须合成一条。
60
+ const settings = resolveSettings({ file, env, args });
61
+ // 模型:配置文件里**真写了**才用它,否则保留各入口自己的 defaultModel(cli-anthropic 等默认值不同)
62
+ const modelFromFile = settings.sources.model === 'default' ? '' : strArg(settings.values.model);
53
63
  return {
54
64
  key: secret.key,
55
65
  keySource: secret.source,
@@ -57,9 +67,12 @@ export function resolveConfig(args = {}, { defaultModel = '', defaultMaxTokens,
57
67
  // 整个来源对象一并带出:横幅要 `secretSourceText(cfg.secret)` 才能说清
58
68
  // 「环境变量 GATEWAY_KEY(来自 .env:…)」这类细节
59
69
  secret,
60
- baseUrl: resolveBaseUrl(args, env),
61
- // 顺序:--model > GATEWAY_MODEL > 该入口的默认模型
62
- model: strArg(args.model) || strArg(env.GATEWAY_MODEL) || defaultModel,
70
+ // resolveBaseUrl / Web 侧同一口径:去掉尾部斜杠
71
+ baseUrl: String(settings.values.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, ''),
72
+ // 顺序:--model > GATEWAY_MODEL > config.json > 该入口的默认模型
73
+ model: strArg(args.model) || strArg(env.GATEWAY_MODEL) || modelFromFile || defaultModel,
74
+ // 整份解析结果:调用方要 `settingSourceText(settings, 'baseUrl')` 这类「谁给的」信息
75
+ settings,
63
76
  workDir: strArg(args.cwd) || strArg(args['work-dir']) || '',
64
77
  temperature: numArg(args.temperature),
65
78
  maxTokens: Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined,
package/lib/hub.js CHANGED
@@ -22,7 +22,6 @@ import {
22
22
  parseArgs,
23
23
  strArg,
24
24
  resolveKey,
25
- resolveBaseUrl,
26
25
  resolvePort,
27
26
  sendJson,
28
27
  readBody,
@@ -127,10 +126,13 @@ const STORE_SOURCE_TEXT = {
127
126
 
128
127
  const HELP = `用法:node server.js [选项]
129
128
 
130
- 本地 Web UI:聊天(模式四)与任务(模式五)跑在同一个端口上。
129
+ 本地 Web UI:聊天(模式四)、任务(模式五)、操作手册跑在同一个端口、同一个页面里,
130
+ 顶部 Tab 切换(不再新开标签页);三个面板也能各自单独打开。
131
131
 
132
- http://127.0.0.1:${DEFAULT_PORT}/ 聊天
133
- http://127.0.0.1:${DEFAULT_PORT}/task 任务(选目录,模型真读写)
132
+ http://127.0.0.1:${DEFAULT_PORT}/ 聊天 / 任务 / 手册(Tab 外壳)
133
+ http://127.0.0.1:${DEFAULT_PORT}/chat 只打开聊天面板
134
+ http://127.0.0.1:${DEFAULT_PORT}/task 只打开任务面板(选目录,模型真读写)
135
+ http://127.0.0.1:${DEFAULT_PORT}/manual 只打开操作手册
134
136
 
135
137
  选项:
136
138
  --port <端口> 监听端口(默认 ${DEFAULT_PORT},也可用 WEB_PORT / TASK_WEB_PORT)
@@ -190,9 +192,17 @@ const MIME = {
190
192
  '.png': 'image/png',
191
193
  };
192
194
 
193
- /** 路径 → public/ 下的文件。每个页面各有自己的入口 */
195
+ /**
196
+ * 路径 → public/ 下的文件。每个页面各有自己的入口。
197
+ *
198
+ * `/` 从 20260922 起是 **Tab 外壳**(shell.html):聊天 / 任务 / 手册在一个页面里切换,
199
+ * 不再新开标签页。三个面板本身仍是完整页面,`/chat`、`/task`、`/manual` 单独打开时的
200
+ * 行为与以前一模一样(外部系统 iframe 嵌入、`?bg=` 底色都照旧)。
201
+ */
194
202
  function pageFile(urlPath) {
195
- if (urlPath === '/' || urlPath === '/index.html') return 'index.html';
203
+ if (urlPath === '/' || urlPath === '/shell' || urlPath === '/shell.html') return 'shell.html';
204
+ if (urlPath === '/chat' || urlPath === '/chat/' || urlPath === '/chat.html') return 'index.html';
205
+ if (urlPath === '/index.html') return 'index.html'; // 老链接:直接给聊天页
196
206
  if (urlPath === '/task' || urlPath === '/task/' || urlPath === '/task.html') return 'task.html';
197
207
  if (urlPath === '/manual' || urlPath === '/manual/' || urlPath === '/manual.html') return 'manual.html';
198
208
  return null;
@@ -1345,8 +1355,13 @@ export function runHub(argv, { entry = 'web' } = {}) {
1345
1355
  const settingsFile = settingsFilePath();
1346
1356
  const settings = resolveSettings({ file: settingsFile, env: process.env, args });
1347
1357
  for (const w of settings.warnings) console.warn(`[警告] ${w}`);
1348
- const baseUrl = resolveBaseUrl(args);
1349
- const model = strArg(args.model) || DEFAULT_MODEL;
1358
+ // 网关地址与模型:事实来源是 lib/settings.js 的四层优先级(flag > 环境变量/.env > config.json > 内置默认)。
1359
+ // 这里曾经用 resolveBaseUrl(只认 flag/env),于是**横幅把 config.json 里的地址显示成内置默认**,
1360
+ // 而 applySettings 之后的运行态用的却是配置文件里的值 —— 横幅与事实打架,用户以为配置没生效。
1361
+ // 现在两边同源:横幅打印的就是运行态那个值。
1362
+ const baseUrl = String(settings.values.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
1363
+ const modelFromFile = settings.sources.model === 'default' ? '' : strArg(settings.values.model);
1364
+ const model = strArg(args.model) || strArg(process.env.GATEWAY_MODEL) || modelFromFile || DEFAULT_MODEL;
1350
1365
  // 老的两个服务各用各的环境变量名,这里都认
1351
1366
  const port =
1352
1367
  resolvePort(args, process.env, 'WEB_PORT', null) ??
@@ -1456,8 +1471,8 @@ export function runHub(argv, { entry = 'web' } = {}) {
1456
1471
  server.listen(port, host, () => {
1457
1472
  const s = store.stats();
1458
1473
  console.log('LLM API Gateway · 本地 Web UI');
1459
- console.log(` 聊天 http://${host}:${port}/`);
1460
- console.log(` 任务 http://${host}:${port}/task`);
1474
+ console.log(` 页面 http://${host}:${port}/(聊天 / 任务 / 手册,顶部 Tab 切换)`);
1475
+ console.log(` 面板 /chat 聊天 · /task 任务 · /manual 手册(都能单独打开)`);
1461
1476
  // 网关 / 模型也要带来源:新机器上「有值」不等于「配过」——默认值、当前目录的 .env、
1462
1477
  // config.json 三者长得很像,不说清来源就只能靠猜(与 CLI 横幅同一套文案)
1463
1478
  console.log(` 网关 ${baseUrl}(${settingSourceText(settings, 'baseUrl')})`);
@@ -1486,7 +1501,7 @@ export function runHub(argv, { entry = 'web' } = {}) {
1486
1501
  console.log(` 轮次 单个任务最多 ${cfg.maxSteps} 轮模型调用(--max-steps 可调);撞到上限会先收尾给结论,再给「继续执行」`);
1487
1502
  console.log(' 写入按当前模式处理:手动逐个批准 / 自动直接执行 / 计划只读先出方案。');
1488
1503
  if (entry === 'task') {
1489
- console.log(' 提示 任务模式与聊天已合并到同一端口,/task 是任务页、/ 是聊天页。');
1504
+ console.log(' 提示 / 是 Tab 外壳(聊天 / 任务 / 手册同页切换),/task 直接落到任务面板。');
1490
1505
  }
1491
1506
  });
1492
1507
 
package/lib/launcher.js CHANGED
@@ -25,7 +25,7 @@ export const TOPICS = [
25
25
 
26
26
  export const SURFACES = [
27
27
  { value: 'cli', label: '命令行窗口', desc: '就在这个终端里继续' },
28
- { value: 'web', label: '网页 UI', desc: `起本机网页 http://127.0.0.1:${WEB_PORT}(聊天页 / 任务页)` },
28
+ { value: 'web', label: '网页 UI', desc: `起本机网页 http://127.0.0.1:${WEB_PORT}(聊天 / 任务 / 手册,顶部 Tab 切换)` },
29
29
  ];
30
30
 
31
31
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "llm-api-gateway-cli",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "CLI 测试工具:验证 LLM API Gateway(http://127.0.0.1:9000)的 OpenAI / Anthropic / 原生 Agent / Claude Code 多种接入方式",
5
5
  "type": "module",
6
6
  "bin": {
package/public/app.js CHANGED
@@ -44,7 +44,6 @@ const dom = {
44
44
  send: document.getElementById('btn-send'),
45
45
  stop: document.getElementById('btn-stop'),
46
46
  clear: document.getElementById('btn-clear'),
47
- btnOpenTask: document.getElementById('btn-open-task'),
48
47
  status: document.getElementById('status'),
49
48
  dot: document.getElementById('status-dot'),
50
49
  meta: document.getElementById('gateway-meta'),
@@ -93,6 +92,8 @@ function setStatus(text, kind = '') {
93
92
 
94
93
  function setDot(kind) {
95
94
  dom.dot.className = `dot${kind ? ' ' + kind : ''}`;
95
+ // 被 Tab 外壳嵌着时,状态点也在外壳顶栏上(本页顶栏是收起的),顺手报一份过去
96
+ if (window.lgwBridge) window.lgwBridge.dot(kind || '');
96
97
  }
97
98
 
98
99
  /**
@@ -485,9 +486,21 @@ function startDraft() {
485
486
  dom.input.focus();
486
487
  }
487
488
 
489
+ /** 侧边栏当前是不是开着:桌面看 .collapsed,窄屏看 .open(口径只写这一处) */
490
+ function sidebarOpen() {
491
+ const mobile = window.matchMedia('(max-width: 820px)').matches;
492
+ return mobile ? dom.sidebar.classList.contains('open') : !dom.sidebar.classList.contains('collapsed');
493
+ }
494
+
495
+ /** 被 Tab 外壳嵌着(/)时,外壳顶栏上的 ☰ 要有按下态;单独打开本页时这行是空操作 */
496
+ function notifySidebar() {
497
+ if (window.lgwBridge) window.lgwBridge.sidebar(sidebarOpen());
498
+ }
499
+
488
500
  function toggleSidebar() {
489
501
  if (window.matchMedia('(max-width: 820px)').matches) dom.sidebar.classList.toggle('open');
490
502
  else dom.sidebar.classList.toggle('collapsed');
503
+ notifySidebar();
491
504
  }
492
505
 
493
506
  /* ---------- 渲染 ---------- */
@@ -588,6 +601,8 @@ function setBusy(busy) {
588
601
  state.streaming = busy;
589
602
  dom.send.disabled = busy;
590
603
  dom.stop.hidden = !busy;
604
+ // 切到别的 Tab 也能看出「聊天这边还在跑」(外壳 Tab 上的脉冲点)
605
+ if (window.lgwBridge) window.lgwBridge.busy(busy);
591
606
  }
592
607
 
593
608
  /* ---------- 发送消息 ---------- */
@@ -981,16 +996,6 @@ async function loadModels() {
981
996
 
982
997
  /* ---------- 事件绑定 ---------- */
983
998
 
984
- /** 任务页和聊天页现在是同一个服务,同源相对路径就能跳过去。
985
- * 需要指向别处时用 ?task=http://host:port/task 覆盖。 */
986
- const TASK_PATH = '/task';
987
-
988
- function wireTaskEntry() {
989
- if (!dom.btnOpenTask) return;
990
- const override = new URLSearchParams(location.search).get('task');
991
- dom.btnOpenTask.href = override || TASK_PATH;
992
- }
993
-
994
999
  function autoGrow() {
995
1000
  dom.input.style.height = 'auto';
996
1001
  dom.input.style.height = `${Math.min(dom.input.scrollHeight, 220)}px`;
@@ -1002,7 +1007,6 @@ function init() {
1002
1007
  applySettingsToUI();
1003
1008
  renderSessions();
1004
1009
  renderAll();
1005
- wireTaskEntry();
1006
1010
  loadConfig().then(loadModels);
1007
1011
 
1008
1012
  if (state.prunedCount) setStatus(`已自动清理 ${state.prunedCount} 个过期会话(保留 ${TTL_DAYS} 天 / 最多 ${MAX_SESSIONS} 个)`, '');
@@ -1101,6 +1105,8 @@ function init() {
1101
1105
 
1102
1106
  autoGrow();
1103
1107
  dom.input.focus();
1108
+ // 初值也报一份:外壳顶栏上的 ☰ 不能等用户点一次才有正确的按下态
1109
+ notifySidebar();
1104
1110
  }
1105
1111
 
1106
1112
  init();
package/public/index.html CHANGED
@@ -6,7 +6,10 @@
6
6
  <title>LLM API Gateway · 聊天</title>
7
7
  <link rel="stylesheet" href="/styles.css" />
8
8
  <script>
9
- // 防闪白:必须在首次绘制前把主题定下来,所以内联在这里(逻辑与 /theme.js 一致)
9
+ // 防闪白:必须在首次绘制前把主题定下来,所以内联在这里(逻辑与 /theme.js 一致,四个页面都一样)。
10
+ // 顺手把「是不是被 Tab 外壳嵌着」也定下来:带 ?embed=1 时 <html> 上挂 data-embed,
11
+ // styles.css 据此收起本页自己的顶栏(顶栏归外壳,免得出现两条横栏)。
12
+ // 放在首帧之前是为了不闪一下再收起来。
10
13
  (() => {
11
14
  try {
12
15
  let t = localStorage.getItem('lgw.theme');
@@ -18,6 +21,13 @@
18
21
  } catch (e) {
19
22
  document.documentElement.setAttribute('data-theme', 'dark');
20
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
+ }
21
31
  })();
22
32
  </script>
23
33
  <!-- 外部集成:地址上带 ?bg=<底色> 时按底色推导整套配色。必须是阻塞脚本(排在 theme.js 之前),
@@ -29,11 +39,8 @@
29
39
  <div class="app">
30
40
  <aside class="sidebar" id="sidebar">
31
41
  <div class="sidebar-head">
32
- <a class="btn block task-entry" id="btn-open-task" href="/task" target="_blank" rel="noopener"
33
- title="进入任务模式:选一个本地目录,让模型真读写其中的文件">
34
- <span>+ 新任务</span>
35
- <span class="ext-mark" aria-hidden="true">↗</span>
36
- </a>
42
+ <!-- 这里原先有一个「+ 新任务 ↗」跳到任务页。20260922 起导航归 Tab 外壳(/ 顶部那排 Tab),
43
+ 聊天页不再自己留一条通往任务页的入口 —— 免得和外壳的「任务」Tab 重复。 -->
37
44
  <button class="btn primary block" id="btn-new-session" type="button">+ 新对话</button>
38
45
  </div>
39
46
  <div class="session-list" id="session-list"></div>
@@ -170,6 +177,8 @@
170
177
 
171
178
  <script src="/render.js"></script>
172
179
  <script src="/models.js"></script>
180
+ <!-- 面板 ↔ Tab 外壳(/)的桥:单独打开本页时它什么都不做,见 public/shell-bridge.js 的说明 -->
181
+ <script src="/shell-bridge.js"></script>
173
182
  <script src="/app.js"></script>
174
183
  </body>
175
184
  </html>
@@ -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
+ })();