llm-api-gateway-cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.env.example +10 -0
  2. package/README.md +1127 -0
  3. package/cli-agent.js +666 -0
  4. package/cli-anthropic.js +236 -0
  5. package/cli-claude-code.js +317 -0
  6. package/cli-openai.js +212 -0
  7. package/completions/_llm-api-gateway-cli +65 -0
  8. package/completions/llm-api-gateway-cli.bash +64 -0
  9. package/completions/llm-api-gateway-cli.fish +43 -0
  10. package/images/chat.png +0 -0
  11. package/images/settings.png +0 -0
  12. package/images/task.png +0 -0
  13. package/lib/agent.js +607 -0
  14. package/lib/commands.js +468 -0
  15. package/lib/common.js +196 -0
  16. package/lib/config.js +70 -0
  17. package/lib/configcmd.js +230 -0
  18. package/lib/hub.js +1494 -0
  19. package/lib/jsonstore.js +49 -0
  20. package/lib/mcp.js +375 -0
  21. package/lib/memory.js +109 -0
  22. package/lib/plandoc.js +178 -0
  23. package/lib/pricing.js +52 -0
  24. package/lib/runner.js +234 -0
  25. package/lib/runstore.js +96 -0
  26. package/lib/secrets.js +198 -0
  27. package/lib/sessionstore.js +269 -0
  28. package/lib/settings.js +517 -0
  29. package/lib/tasksession.js +594 -0
  30. package/lib/taskstore.js +740 -0
  31. package/lib/tools.js +927 -0
  32. package/package.json +55 -0
  33. package/public/app.js +1055 -0
  34. package/public/index.html +167 -0
  35. package/public/manual.css +215 -0
  36. package/public/manual.html +381 -0
  37. package/public/manual.js +186 -0
  38. package/public/models.js +121 -0
  39. package/public/render.js +250 -0
  40. package/public/styles.css +955 -0
  41. package/public/task-slash.js +493 -0
  42. package/public/task.css +739 -0
  43. package/public/task.html +220 -0
  44. package/public/task.js +3127 -0
  45. package/public/theme.js +91 -0
  46. package/public/tint.js +261 -0
  47. package/scripts/install.ps1 +537 -0
  48. package/scripts/install.sh +510 -0
  49. package/server.js +14 -0
  50. package/task-server.js +15 -0
package/README.md ADDED
@@ -0,0 +1,1127 @@
1
+ # LLM API Gateway CLI 测试工具
2
+
3
+ 验证 `llm-api-gateway`(`http://127.0.0.1:9000`)除了 Web 对话页 / SDK 之外,**CLI 方式同样可用**。多个脚本覆盖多种接入方言,其中**原生 Agent(`cli-agent.js`)是终端里干活的推荐方式** —— 自带工具循环,无需安装 Claude Code。
4
+
5
+ | 脚本 | 模式 | 走网关的端点 | 依赖 |
6
+ | --- | --- | --- | --- |
7
+ | `cli-openai.js` | 模式一:OpenAI 兼容 | `/v1/chat/completions` | `openai` SDK |
8
+ | `cli-anthropic.js` | 模式二:Anthropic 兼容 | `/v1/messages` | `@anthropic-ai/sdk` |
9
+ | `cli-claude-code.js` | 模式三:Claude Code | `/v1/messages`(透传 claude CLI) | 本机已装 `claude` |
10
+ | `server.js` | 本地 Web UI 入口:聊天(`/`)+ 任务(`/task`),共用一个端口 | `/v1/chat/completions`(浏览器 → 本机服务 → 网关)+ 任务侧的工具调用 | 无(零新增依赖) |
11
+ | `task-server.js` | 同一个服务的别名入口,直接落到 `/task` | 同左 | 无(零新增依赖) |
12
+ | `cli-agent.js` | 模式六:原生 Agent CLI(推荐) | `/v1/chat/completions` + 工具调用 | 无(零新增依赖) |
13
+
14
+ > 迭代计划与历史记录都在 [`docs/`](docs/) 下,文件名带日期前缀(如 `20260910-优化计划.md`、`20260911-后续迭代计划.md`),目录排序即时间顺序。
15
+ >
16
+ > 想用容器跑(Windows + Docker Desktop + PowerShell):直接看 [Docker 容器化部署](#docker-容器化部署),一条 `.\docker.ps1 up` 起服务。
17
+
18
+ ## 准备
19
+
20
+ ```bash
21
+ cd llm-api-gateway-cli
22
+ npm install # 安装 openai 与 @anthropic-ai/sdk
23
+
24
+ # 密钥四选一提供(优先级从高到低):
25
+ # ① 命令行 --key sk-xxx
26
+ # ② 环境变量 SK / GATEWAY_KEY
27
+ # ③ 复制 .env.example 为 .env 后填入 GATEWAY_KEY=sk-xxx
28
+ # ④ 起服务后在页面「设置」里填,或 gateway-agent config set key sk-xxx
29
+ # (写 ~/.llm-api-gateway-cli/credentials.json,仅本用户可读;**不进 config.json**)
30
+ ```
31
+
32
+ > 密钥在网关后台「密钥管理」创建后一次性回显 `sk-` 明文,请立即保存;列表只会显示脱敏前缀。
33
+ > 四个 CLI 的密钥与地址解析走同一份实现(`lib/config.js`),优先级一律是 `--flag` > 环境变量 > `.env`(> 密钥文件)。
34
+ > 密钥文件是**唯一**允许存密钥的地方,它由 `lib/secrets.js` 独占实现:界面与命令行写的是同一个文件,`config get key` 只回掩码。
35
+
36
+ ## 一键安装(curl / npm,不用先克隆仓库)
37
+
38
+ 装完就能在**任意目录**直接用 `gateway-agent`,密钥不需要手写 `.env`:起服务后在页面「设置」里填一次(或 `gateway-agent config set key sk-xxx`),存进 `~/.llm-api-gateway-cli/credentials.json`(**不进 `config.json`**);也仍然可以用环境变量 / `.env` / `--key`,那三层优先级更高。
39
+
40
+ > **⚠️ 装完还差一步:配密钥**(不用手写 `.env`)。`gateway-web` / `gateway-task` **没有密钥也能起**(页面照常打得开,启动横幅写「密钥 未配置」),在页面「设置 → 网关密钥」里填一次即可,保存后立即生效、不用重启;命令行也可 `gateway-agent config set key sk-xxx`。只有一次性命令行 agent(`gateway-agent -p "…"`)没有界面可交互,缺密钥会**直接报错退出**。想用环境变量 / `.env` 也行(优先级更高):`.env` 的加载顺序是**①安装目录 → ②当前工作目录**,npm 全局安装的包目录里只有 `.env.example`,所以要放在**你运行命令的那个目录**(服务在**启动那一刻**读)。网关不在默认地址(`http://127.0.0.1:9000`)或想固定模型,用 `gateway-agent config set baseUrl|model …`(也能在设置面板里改)。完整步骤见手册页第一节「安装与启动 → 首次配置」。
41
+
42
+ > 不想翻文档也行:起服务后打开 **[操作手册页 `/manual`](http://127.0.0.1:3100/manual)**,第一节就是「安装与启动」—— 三条路线、参数表、换镜像、**首次配置(界面 / 命令行 / 环境变量三种给密钥方式、密钥存哪、网关地址与模型)**、启动自检与卸载,命令都是可复制的。
43
+
44
+ ### macOS / Linux / WSL
45
+
46
+ ```bash
47
+ # 固定版本(推荐:tag 不会漂,装到的就是你看的那个版本)
48
+ curl -fsSL https://raw.githubusercontent.com/boonya-hrgk/llm-api-gateway-cli/v1.0.0/scripts/install.sh | bash
49
+
50
+ # 想传参数就加 -s --(管道里直接跟参数会被 bash 当成它自己的参数)
51
+ curl -fsSL https://raw.githubusercontent.com/boonya-hrgk/llm-api-gateway-cli/v1.0.0/scripts/install.sh \
52
+ | bash -s -- --version v1.0.0 --prefix "$HOME/.llm-api-gateway"
53
+
54
+ # 只想「永远拿最新的脚本」也可以走 main,但脚本与 Release 的契约可能先后不一致,
55
+ # 出问题请改用上面的 tag 形式
56
+ curl -fsSL https://raw.githubusercontent.com/boonya-hrgk/llm-api-gateway-cli/main/scripts/install.sh | bash
57
+ ```
58
+
59
+ 不传 `--version` 时脚本会自己去查最新 Release(先问 GitHub API,不通就用镜像的 `/releases/latest` 重定向解析)。
60
+
61
+ ### Windows(PowerShell 5.1 / 7 通用)
62
+
63
+ ```powershell
64
+ irm https://raw.githubusercontent.com/boonya-hrgk/llm-api-gateway-cli/v1.0.0/scripts/install.ps1 | iex
65
+
66
+ # 管道里要传参就用 scriptblock 形式
67
+ & ([scriptblock]::Create((irm .../install.ps1))) -Version v1.0.0 -DryRun
68
+ ```
69
+
70
+ ### 或者走 npm
71
+
72
+ ```bash
73
+ npm install -g llm-api-gateway-cli
74
+ ```
75
+
76
+ > 两条路线用的是**同一份产物**(`npm pack` 出来的 `.tgz`,见 `.github/workflows/release.yml`),所以不会出现「curl 装到 A 版、npm 装到 B 版」。curl 那条只是省掉了「先有 npm 全局目录」这层麻烦。
77
+ > npm 路线需要维护者把对应版本 `npm publish` 上去(CI 只负责发 Release 资产)。还没发布时,直接从 Release 页下载 `.tgz` 装同一份东西:`npm install -g ./llm-api-gateway-cli-1.0.0.tgz`。
78
+
79
+ 安装脚本的几个共性设计:
80
+
81
+ - **装到用户私有目录**(默认 `~/.llm-api-gateway`,Windows 是 `$HOME\.llm-api-gateway`),不碰系统目录、不要管理员/sudo —— 卸载就是删一个目录;
82
+ - **帮你把 `bin` 接进 PATH**(Linux 写 `.bashrc`、macOS 写 `.bash_profile`、zsh 写 `.zshrc`、fish 用 `fish_add_path`),已经加过就不重复加;想自己管就加 `--no-modify-path` / `-NoModifyPath`;
83
+ - **幂等**:已是目标版本直接跳过(`--force` / `-Force` 可强制重装);
84
+ - **`--dry-run` / `-DryRun`**:只打印将要做什么,不下载、不落盘、不改 PATH —— 想先看看它会动你什么,就用这个;
85
+ - **失败不留半成品**:临时文件必删;404 与网络失败分开报(版本号写错和网络不通的处理办法完全不同);
86
+ - **不代装 Node**:缺 Node 或版本 < 18 会明确告诉你该装什么,而不是偷偷改你的系统运行时。
87
+
88
+ 想换国内镜像(raw / api.github.com 不稳时):
89
+
90
+ ```bash
91
+ LLM_GATEWAY_INSTALL_BASE=https://your-mirror.example.com bash install.sh
92
+ ```
93
+
94
+ ```powershell
95
+ $env:LLM_GATEWAY_INSTALL_BASE = 'https://your-mirror.example.com'; irm .../install.ps1 | iex
96
+ ```
97
+
98
+ > 镜像只要保留 GitHub 的 `/releases/latest` → `/releases/tag/<tag>` 重定向,脚本在 `api.github.com` 不通时也能解析出最新版本。
99
+ > 也可以用 `LLM_GATEWAY_INSTALL_VERSION` / `LLM_GATEWAY_INSTALL_DIR`(以及 `PREFIX`)走环境变量,不必传参。
100
+
101
+ ## 发布(维护者)
102
+
103
+ **完整步骤见 [`docs/20260921-发布步骤.md`](docs/20260921-发布步骤.md)** —— 从改版本号、跑测试、打 tag,到校验 Release、`npm publish`、装一遍验证、出问题怎么回滚,命令可直接复制。
104
+
105
+ 一句话版:改 `package.json` 的 `version` → `npm test` 全绿 → `git tag -a v<版本>` 并推 tag → CI(`.github/workflows/release.yml`)自动校验版本一致性、打包、建 Release 并上传 `llm-api-gateway-cli-<版本>.tgz` → **再手工 `npm publish`**。
106
+
107
+ > 两条安装路线共用 CI 打出来的**同一份** `.tgz`:curl / PowerShell 脚本从 Release 资产拿,npm 路线从 registry 拿。CI **不做** `npm publish`,所以只打 tag 只能让脚本装得到、`npm i -g` 还装不到。
108
+
109
+ ## 从源码安装为全局命令
110
+
111
+ 把主工具装成全局命令 `llm-api-gateway-cli`,之后**在任意目录**直接干活,配置仍只读 `llm-api-gateway-cli/.env` 这一份:
112
+
113
+ ```bash
114
+ cd llm-api-gateway-cli
115
+ npm install
116
+ npm link # 软链到源目录;改 .env 立即生效,无需重装
117
+ ```
118
+
119
+ 装好后,`cd` 到你的目标项目目录即可:
120
+
121
+ ```bash
122
+ llm-api-gateway-cli # 模式六:原生 Agent 交互,当前目录即工作目录
123
+ llm-api-gateway-cli -p "任务描述" # 模式六:非交互一句话任务(写入前会确认)
124
+ gateway-agent -C ./some/dir -p "任务" # 与上面同物异名,可显式指定工作目录
125
+ gateway-claude-code # 模式三:进入 Claude Code 交互(需本机已装 claude)
126
+ gateway-openai "你好" # 模式一(OpenAI 兼容)
127
+ gateway-anthropic "你好" # 模式二(Anthropic 兼容)
128
+ gateway-web # 模式四:本地聊天 Web UI(http://127.0.0.1:3100)
129
+ gateway-task # 模式五:直接落到任务页(http://127.0.0.1:3100/task,与上面同一个服务)
130
+ ```
131
+
132
+ > 卸载:在 `llm-api-gateway-cli` 目录执行 `npm unlink`。
133
+ > 用 `npm link` 而非 `npm install -g .`:后者把文件**拷贝**进全局目录,而 `.env` 被 `.gitignore` 忽略、不会随之拷走,会导致读不到密钥(现在也可以用 `gateway-agent config set key sk-xxx` 绕开这一点:它写的是数据根里的 `credentials.json`,与安装目录无关)。
134
+
135
+ ## 模式一 · OpenAI 兼容
136
+
137
+ ```bash
138
+ node cli-openai.js "你好,用一句话介绍你自己"
139
+ node cli-openai.js -p "你好" --model qwen3:8b --system "你是中文助手"
140
+ echo "你好" | node cli-openai.js # 从标准输入读提示词
141
+ node cli-openai.js --list-models # 列出上游可用模型
142
+ node cli-openai.js -i # 多轮交互
143
+ ```
144
+
145
+ ## 模式二 · Anthropic 兼容
146
+
147
+ ```bash
148
+ node cli-anthropic.js "你好,用一句话介绍你自己"
149
+ node cli-anthropic.js -p "你好" --model qwen3:8b --max-tokens 512
150
+ node cli-anthropic.js -i # 多轮交互
151
+ ```
152
+
153
+ ## 模式三 · Claude Code
154
+
155
+ ```bash
156
+ node cli-claude-code.js # 进入 Claude Code 交互界面
157
+ node cli-claude-code.js -- -p "用一句话介绍你自己" # `--` 之后透传给 claude
158
+ node cli-claude-code.js --smoke # 只做连通性自检后退出
159
+ node cli-claude-code.js --print-env # 打印要注入的环境变量
160
+ node cli-claude-code.js --config # 打印 CC Switch / CC GUI 可粘贴的 JSON
161
+ ```
162
+
163
+ 脚本向 `claude` 注入的环境变量(与网关 `USEAGE.md` 中 CC Switch 配置一致):
164
+
165
+ | 变量 | 值 |
166
+ | --- | --- |
167
+ | `ANTHROPIC_BASE_URL` | `http://127.0.0.1:9000` |
168
+ | `ANTHROPIC_AUTH_TOKEN` | 网关发放的 `sk-` 密钥 |
169
+ | `ANTHROPIC_MODEL` | 主模型(默认 `qwen3:8b`,可 `--model` 覆盖) |
170
+ | `ANTHROPIC_DEFAULT_OPUS/SONNET_MODEL` | 默认同主模型 |
171
+ | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | `qwen3:8b-nothink` |
172
+ | `ANTHROPIC_SMALL_FAST_MODEL` | 后台快速模型(默认同 haiku) |
173
+ | `ANTHROPIC_CUSTOM_HEADERS` | `baggage: session.id=<本次启动的随机 id>`(任务树标记,见下) |
174
+ | `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | `1`(关闭遥测/模型探测) |
175
+
176
+ **关于 `ANTHROPIC_CUSTOM_HEADERS`(任务树标记)**:真正的 HTTP 请求是 `claude` 自己发的,本脚本碰不到它的头,只能借这个自定义头环境变量把 W3C 标准头 `baggage: session.id=<id>` 注进去,网关的任务树才会把这次启动的若干次调用聚成一趟会话(否则只能靠指纹去猜)。三条约定:
177
+
178
+ - **一次启动 = 一趟会话**:id 每次随机,所以两次启动是两个任务,不会互相污染。想跨启动续同一趟,就自己设好这个变量(脚本检测到已有 `baggage` 会原样尊重,不再重复注入;有其它自定义头则在其后追加一行,不覆盖)。
179
+ - **`--print-env` 会把它打出来**,方便你核对当前这次启动用的是哪个 id;启动时也会在 stderr 打一行 `[gateway] 会话 <id>`。
180
+ - **`--config` 输出的静态 JSON 里刻意不带它**:那是要写进配置文件的固定值,所有会话共用一个 id 反而会把任务树并成一棵。用配置文件接入的话,这一项没有标记(网关会退回按对话历史推断的口径)。
181
+
182
+ > 也可直接复制 `--config` 输出的 JSON 到 CC Switch / CC GUI 使用(相当于手动配置,不依赖本脚本)。
183
+ > 若你使用的是较老版本 Claude Code,且只认 `ANTHROPIC_API_KEY` 头,可自行在脚本里把 `ANTHROPIC_AUTH_TOKEN` 换成 `ANTHROPIC_API_KEY`(网关两种头都认)。
184
+
185
+ ## 本地 Web UI(模式四 聊天 + 模式五 任务,同一个端口)
186
+
187
+ `server.js` 用 `node:http` 起一个**只监听本机**的小服务,浏览器打开即是一个聊天界面:多轮对话、流式打字机输出、Markdown 渲染、模型切换、系统提示词、token 用量统计。请求链路是「浏览器 → 本机 3100 → 网关 9000」,`sk-` 密钥只留在服务端,不下发到页面。
188
+
189
+ **聊天和任务现在是一个服务、一个端口**,两个页面之间是同源相对路径跳转:
190
+
191
+ | 页面 | 地址 |
192
+ | --- | --- |
193
+ | 聊天(模式四) | http://127.0.0.1:3100/ |
194
+ | 任务(模式五) | http://127.0.0.1:3100/task |
195
+
196
+ `npm run web` 和 `npm run task`(`server.js` / `task-server.js`)起的是**同一个服务**,两个命令等价;保留两个名字只是为了不打断已有习惯和脚本。旧的 3101 不再使用。
197
+
198
+ ```bash
199
+ npm run web # 默认 http://127.0.0.1:3100,两个页面都在
200
+ node server.js --port 3100 --model deepseek-v4-pro --verbose
201
+ node server.js --host 0.0.0.0 # 想让同局域网设备访问时才这么开
202
+ ```
203
+
204
+ 启动后终端会打印地址、网关、模型、脱敏密钥。页面右侧「设置」可改模型(**是从网关 `/v1/models` 拉下来的下拉,只能选、不能填**)、系统提示词、温度、最大 tokens、是否流式、是否显示思考过程、上下文上限。两个页面的「模型」共用同一套规则(`public/models.js`):第一项是「(用服务端默认:<模型>)」,其余选项来自接口;点「刷新模型」重新拉。**当前配置里的模型万一不在上游列表里**(模型下线了、或当初是手输的),它仍会作为「(当前设置,不在上游列表里)」出现在下拉里并被选中 —— 不声不响把你的配置换成别的模型,才是更糟的行为;列表彻底拉不到时就只剩「默认 + 当前值」,并在提示里说明原因与重试办法。
205
+
206
+ ### 关闭服务
207
+
208
+ **同一个终端里起的,直接 `Ctrl+C`。** 这是最干净的方式:服务会走正常退出流程,端口立刻释放。下面几节是给「终端已经关掉 / 进程变成孤儿 / 忘了在哪个终端起的」这种情况用的。
209
+
210
+ 服务默认只监听 `127.0.0.1:3100`,所以找它就是在找**谁占着 3100**。
211
+
212
+ #### Linux / macOS
213
+
214
+ ```bash
215
+ # 1) 正常关闭:在起服务的那个终端按 Ctrl+C
216
+ # 或按名字向进程发信号
217
+ pkill -f 'node server.js' # 宽松匹配,干掉所有同名进程
218
+ pkill -f 'node task-server.js' # task-server.js 是同一个服务的另一个入口
219
+
220
+ # 2) 不知道是谁占着端口 —— 反查 PID 再杀
221
+ lsof -i :3100 # macOS / 装了 lsof 的 Linux
222
+ ss -lptn 'sport = :3100' # Linux(netstat 的现代替代)
223
+ fuser -k 3100/tcp # 直接杀掉占用该端口的进程
224
+
225
+ # 3) 已经拿到 PID(例如上面输出里的 12345)
226
+ kill 12345 # 先好好说
227
+ kill -9 12345 # 不退再强杀
228
+
229
+ # 4) 确认真的没了:连不上就是关了
230
+ curl -s -o /dev/null -w '%{http_code}\n' --max-time 3 http://127.0.0.1:3100/api/config
231
+ # 000 = 已关闭;200 = 还活着
232
+ ```
233
+
234
+ #### Windows
235
+
236
+ ```powershell
237
+ # 1) 正常关闭:在起服务的那个终端按 Ctrl+C
238
+
239
+ # 2) 找是谁占着 3100(最后一列就是 PID)
240
+ netstat -ano | findstr :3100
241
+ # TCP 127.0.0.1:3100 0.0.0.0:0 LISTENING 34800
242
+ # ^^^^^ 这个
243
+
244
+ # 3) 拿到 PID 后杀掉
245
+ Stop-Process -Id 34800 -Force
246
+
247
+ # 4) 一步到位:查端口 + 杀,不手抄 PID
248
+ netstat -ano | Select-String ':3100\s+.*LISTENING' |
249
+ ForEach-Object { ($_.Line.Trim() -split '\s+')[-1] } |
250
+ Select-Object -Unique |
251
+ ForEach-Object { Stop-Process -Id $_ -Force }
252
+
253
+ # 5) 确认真的没了
254
+ curl.exe -s -o NUL -w '%{http_code}' --max-time 3 http://127.0.0.1:3100/api/config
255
+ # 000 = 已关闭;200 = 还活着
256
+ ```
257
+
258
+ > **在 Windows 上就用 `curl` + `netstat -ano` 这两样判断,别用 `Get-NetTCPConnection` / `Get-CimInstance`。**
259
+ > 实测(Windows + Node 22):服务好好跑着、`curl` 返回 200、`netstat` 也能看到 PID 时,
260
+ > `Get-NetTCPConnection -LocalPort 3100 -State Listen` 和 `Get-CimInstance Win32_Process`
261
+ > **都返回空**。拿它们判断「服务在不在」会得到「端口空闲」的假结论,白排查半天。
262
+ > `curl` 探端口 + `netstat -ano` 找 PID,这两条实测最稳。
263
+
264
+ #### 关于端口
265
+
266
+ | 端口 | 是什么 | 关掉会怎样 |
267
+ | --- | --- | --- |
268
+ | `3100` | 本项目的 Web 服务(聊天 + 任务) | 页面打不开,**不影响**网关和已落盘的任务记录 |
269
+ | `9000` | 你的 LLM API Gateway | 所有模式都不能用了,不只是 Web |
270
+ | `3080` | DSH Web(如果装了) | 是另一个程序,跟本项目无关 |
271
+
272
+ 关掉 Web 服务**不会**丢任务记录:任务和挂起态都在磁盘上(见「任务记录存在磁盘上」),重新 `npm run web` 就还在。
273
+
274
+ ### 主题:白天 / 黑夜
275
+
276
+ 两页右上角都有一个三挡开关:
277
+
278
+ | 挡位 | 行为 |
279
+ | --- | --- |
280
+ | **跟随系统**(默认) | 看系统的深浅色偏好,系统变它也变 |
281
+ | **白天** ☀ | 固定浅色 |
282
+ | **黑夜** ☾ | 固定深色 |
283
+
284
+ 选过之后记在浏览器里,下次打开还是那个;没选过就一直跟随系统。颜色全部走 CSS 变量(`styles.css` 里 `:root` 是深色、`html[data-theme="light"]` 覆盖成浅色),所以**两套主题不可能漏色** —— 有测试盯着这一点(见 `tests/theme.test.mjs`)。
285
+
286
+ 页面 `<head>` 里内联了一小段定主题的脚本,在首次绘制前就把 `data-theme` 挂上,所以刷新时不会先闪一下白底再变黑。用 `prefers-reduced-motion` 的用户会关掉所有过渡。
287
+
288
+ ### 外部集成:把宿主的底色带进来(`?bg=<颜色>`)
289
+
290
+ **被别的系统嵌进去用**(iframe、内嵌面板、自家后台的侧边栏)时,页面自带的白底/黑底往往和宿主对不上,看着像「贴上去的一块」。所以在地址上带一个底色就行:
291
+
292
+ ```text
293
+ http://127.0.0.1:3100/task?bg=%23f0f4ff 任务页:浅底
294
+ http://127.0.0.1:3100/task?bg=%23111a2b 任务页:深底
295
+ http://127.0.0.1:3100/?bg=rgb(20,%2024,%2032) 聊天页同样支持
296
+ http://127.0.0.1:3100/task?bg=none 清掉,回到页面自己的配色
297
+ ```
298
+
299
+ 嵌入示例(宿主给什么色,页面就跟着走):
300
+
301
+ ```html
302
+ <iframe src="http://127.0.0.1:3100/task?bg=%23111a2b" style="width:100%;height:720px;border:0"></iframe>
303
+ ```
304
+
305
+ | 参数 | 说明 |
306
+ | --- | --- |
307
+ | `bg` | 底色(主名字);`bgcolor` / `bg-color` 也认 |
308
+ | 值 | `#rgb` / `#rrggbb` / `rgba()` / `hsl()` / `white`、`navy` 这类常见具名色;`#` 记得编码成 `%23` |
309
+ | `bg=none` | 显式清掉(`off` / `default` 同义) |
310
+
311
+ **只传一个底色,其余层次由它推导**(`public/tint.js`):面板、悬浮层、代码块、描边按「离底色的距离」算出来,**文字和强调色则按底色的明暗自动走深色或浅色主题** —— 所以给浅底不会出现白字、给深底不会出现黑字。传给默认的那两个底色(`#0f1115` / `#f5f6f8`)推导出来就是默认配色本身,有测试盯着这一点(`tests/tint.test.mjs`)。
312
+
313
+ 几条行为约定:
314
+
315
+ - **不闪**:推导在你的浏览器画出第一帧之前就完成了(它排在定主题的内联脚本之后、`theme.js` 之前,是阻塞脚本);
316
+ - **主题开关会消失**:颜色既然由宿主决定,再让你在这儿切就没意义了,切出来的浅色主题压在你给的深底上还会瞎眼;
317
+ - **同一个标签页里会粘住**:页内跳到聊天/任务页时会把底色带上(写进链接),所以两页不会一个有色一个没色;换个标签页或宿主不带参数就回到默认;
318
+ - **认不出的值一律忽略**:`?bg=` 后面不是个像样的颜色(包括想往里塞 `;`、`url()` 的写法)就当没传,不会被写进 CSS 变量。
319
+
320
+ > 不开这个参数时一切照旧:默认配色 + 三挡主题开关,行为与以前完全一样。
321
+
322
+ 配置功能:
323
+ ![img.png](images/settings.png)
324
+
325
+ 多对话功能:
326
+ ![img.png](images/chat.png)
327
+
328
+ | 接口 | 作用 |
329
+ | --- | --- |
330
+ | `GET /` | 聊天页面 |
331
+ | `GET /api/config` | 网关地址、默认模型、密钥是否就绪(只回脱敏值) |
332
+ | `GET /api/models` | 代理网关 `/v1/models`,返回上游可用模型名 + `default`(配置里没写 model 时服务端会用哪个);设置面板的「模型」下拉就吃这一份,**只能选、不能填** |
333
+ | `POST /api/chat` | 代理 `/v1/chat/completions`,把上游 SSE 归一化成 `reasoning` / `delta` / `usage` / `notice` / `error` / `done` 事件 |
334
+
335
+ ### 思考模型(deepseek-v4-*)的思维链
336
+
337
+ 网关对思考模型把思维链放在 `delta.reasoning_content`,正文要等思考结束才出现在 `delta.content`,且**思考会吃掉大部分 token 预算**(实测一次 531 tokens 的回复里 499 是思考)。所以:
338
+
339
+ - 服务端把思维链作为独立的 `reasoning` 事件转发,前端渲染成「思考过程 · N 字」可折叠块,思考时自动展开、正文一开始就自动收起(用增量更新 DOM,不会每帧重置你的手动折叠);
340
+ - `max_tokens` 设得太小时,可能只返回思维链、没有正文。这种情况页面会明确提示「只返回了思维链、未产出正文,通常是 max_tokens 太小」,而不是留一个空气泡;
341
+ - 不想看思维链就在设置里关掉「显示思考过程」。
342
+
343
+ ### 多会话与本地存储(滑动 15 天 / 最多 20 个)
344
+
345
+ 会话和设置都存在**浏览器 localStorage**,不上传任何服务器;密钥始终只在服务端进程里(界面里填的那次也是 POST 给本机服务、写 `credentials.json`,不进浏览器存储)。
346
+
347
+ | localStorage key | 内容 |
348
+ | --- | --- |
349
+ | `lgw.sessions.v2` | 会话索引:`{ version, activeId, draft, sessions: [{ id, title, createdAt, updatedAt, renamed }] }` |
350
+ | `lgw.session.<id>` | 单条会话的消息数组 `[{ role, content, reasoning }]` |
351
+ | `lgw.settings.v1` | 偏好设置(模型、系统提示词、温度等) |
352
+
353
+ 索引与消息分开存,发一条消息只重写当前会话那一个 key,清理时按 id 精确删除,不会因为一次写入把全部历史重写一遍。
354
+
355
+ 自动管理规则:
356
+
357
+ 1. **自动创建** —— 发第一条消息时才落盘成会话;只点「新对话」不写盘,避免留下一堆空会话;
358
+ 2. **自动命名** —— 取首条用户消息前 24 字作标题;手动改过名的会话标记 `renamed`,之后不再被覆盖;
359
+ 3. **滑动过期** —— 从**最后一次活动**算起,超过 15 天的会话在下次打开页面或发消息时连消息数据一并删除;
360
+ 4. **数量配额** —— 会话总数超过 20 个时淘汰最旧的,同样连消息数据一起删;
361
+ 5. **状态可查** —— 设置面板显示当前会话数、最早过期时间;侧边栏底部有「清理」按钮可立即执行一次;执行过清理会用状态栏告知清掉了几个。
362
+
363
+ 旧版的单会话数据(`lgw.messages.v1`)在首次打开时会自动迁移成一个正常会话,原 key 随即删除。
364
+
365
+ > **上下文裁剪**:默认最多发送最近 32000 字符的历史(设置里的「上下文上限」,填 `0` 关闭)。超出时只从最旧的开始丢弃,至少保留最后一条;裁剪发生时状态栏会显示「上下文已裁剪 N 条较早消息」。这样长对话不会把请求体撑到上游拒绝。
366
+
367
+ > 与网关自带 Web 页面的区别:网关那套是**管理后台**(登录、密钥管理、授权),本目录这套是**面向开发的轻量调试客户端**,用于验证「浏览器直连网关」这条链路,和几个 CLI 脚本是一组对照实现。
368
+ > 服务默认绑定 `127.0.0.1`;`--host 0.0.0.0` 会把页面暴露给局域网,此时谁都能用你 .env 里的密钥额度,请自行判断。
369
+
370
+ ## 模式五 · 任务模式(模型真读写你选的目录)
371
+
372
+ 模式四是「聊天」,模型只能说话。模式五给模型配上**文件工具**,让它真的去读你的代码、改你的文件 —— 和 Claude Code 的工作方式类似,但完全跑在本地。
373
+
374
+ ![img.png](images/task.png)
375
+
376
+ **任务模式下工作目录是必需的**:模型的一切文件操作都被限制在你选定的那个目录里,不能读也不能写目录之外的任何东西。
377
+
378
+ ```bash
379
+ npm run task # 同上,直接打开 http://127.0.0.1:3100/task
380
+ node task-server.js --model deepseek-v4-pro --verbose
381
+ node task-server.js --allow-remote-fs # 配合 --host 0.0.0.0 时,才允许非本机访问目录浏览
382
+ node task-server.js --allow-bash # 开启命令执行:模型能自己跑测试/验收(默认关闭)
383
+ node task-server.js --store D:\my-tasks # 任务记录换个地方存
384
+ node task-server.js --mode auto # 默认审批模式:manual / auto / plan
385
+ ```
386
+
387
+ 打开页面后会先弹出**目录选择器**:可点左侧预设根目录(当前项目 / 用户主目录 / 各盘符)、双击进入子目录,也可以直接把绝对路径粘进地址栏回车。选定后顶部会一直显示当前工作目录,点它可随时更换。
388
+
389
+ > 第一次用先看 **[操作手册页 `/manual`](http://127.0.0.1:3100/manual)**(任务页顶栏有入口,也可以敲 `/manual`):**怎么装(一键脚本 / npm / 源码三条路线,Node ≥ 18)、装完怎么起服务**,操作全流程、三种模式、斜杠指令逐条用法,以及「同一条指令在哪一端能用」的三端对照表。手册页里的指令清单由 `GET /api/commands` 从 `lib/commands.js` 与 `public/task-slash.js` 两张表直接生成,不会比代码少一条;安装信息则由 `tests/manual.test.mjs` 与两份安装脚本交叉钉住。
390
+
391
+ ### 模型能用哪些工具
392
+
393
+ | 工具 | 作用 | 计划模式下可用 |
394
+ | --- | --- | --- |
395
+ | `list_dir` | 列出目录下的文件与子目录(带大小) | 是 |
396
+ | `read_file` | 读取文本文件全文(上限 200KB,拒绝二进制) | 是 |
397
+ | `search_files` | 按关键字搜索文件内容,返回 `文件:行号: 内容` | 是 |
398
+ | `glob` | 按文件名模式查找(`*` / `**` / `?` / `{a,b}`),如 `**/*.test.mjs` | 是 |
399
+ | `grep` | 按**正则**搜索文件内容,返回 `文件:行号: 内容`(比关键字搜索更精确) | 是 |
400
+ | `apply_patch` | 对**已存在**文件做「查找 → 替换」增量编辑:一处匹配不上就整体不落盘 | **否**(只在手动/自动模式下提供) |
401
+ | `write_file` | 写入/整体覆盖一个文件(上限 512KB,自动建父目录) | **否**(只在手动/自动模式下提供) |
402
+ | `bash` | 在工作目录内执行一条 shell 命令(**默认关闭**,任务页加 `--allow-bash` 才开启;有超时与输出截断) | **否** |
403
+
404
+ > **为什么值得开 `--allow-bash`**:不开的时候模型只能读写文件,改完代码**没法跑测试**,
405
+ > 于是它只能回你一句「已静态复核通过」—— 你从现象上分不清这是模型偷懒,还是它手上压根没有执行工具。
406
+ > 开了以后它能自己 `python -m pytest` / `npm test`,跑完再报结论。
407
+ >
408
+ > 代价要清楚:**自动模式下命令不再逐个确认**(和写入工具同一条路;手动模式仍会挂起等你点批准),
409
+ > 所以别在别人的仓库或生产机上随手开。
410
+ >
411
+ > 还有一条平台差异:Windows 下命令经 **`cmd.exe`** 执行,`tail` / `head` / `grep` / `sed` 这类
412
+ > Unix 管道命令**不存在**(会报「不是内部或外部命令」),要截断请用程序自带参数(如 `pytest -q`、`--maxfail=1`)。
413
+
414
+ 写操作怎么处理由页面上的**审批模式**决定,三种模式随时可切、跟着任务走(切回某个任务时还是当初用的那个模式):
415
+
416
+ | 模式 | 行为 | 适合 |
417
+ | --- | --- | --- |
418
+ | **手动**(默认) | 每次写入都要你点「批准写入」才落盘 | 改别人代码、不熟的仓库 |
419
+ | **自动** | 写入直接执行,不再打断你 | 你自己的仓库、批量重构、放手让它干 |
420
+ | **计划** | **只给模型只读工具**,先出方案、不动文件;**方案自动写入 `<工作目录>/docs/YYYYMMDD-<概要>.md`**,你确认后点「按计划执行」才动手 | 先看清它打算怎么改,再决定要不要放行 |
421
+
422
+ - **手动**:批准前会展示将要写入的内容、目标路径、是新建还是覆盖、行数与字节数 —— 看清了再点。点「拒绝」则这次写入被取消,并把「用户拒绝」作为工具结果回填给模型,让它另想办法,而不是让整个任务崩掉。
423
+ - **自动**:写入不再逐个确认,但**照样逐条显示改了什么**(步骤行尾标「自动模式:已直接写入」),顶部模式切换器也会一直用告警色标着「自动」,免得开完忘了。沙箱边界不变 —— 仍然只能动你选的工作目录之内。
424
+ - **计划**:写入工具(`write_file` / `apply_patch` / `bash`)**根本不提供给模型**,所以它不是「要求模型别写」,而是模型手上就没有这支笔;万一它硬要调(模型幻觉),服务端还会兜一道拒绝并回填原因。跑完会在回答下方给出「按计划执行 / 手动执行」两个按钮,并把计划路径写在按钮旁边。
425
+
426
+ > **计划会自动落盘**:计划模式跑完,那份计划被**原样写成工作目录里的 `docs/YYYYMMDD-<概要>.md`**(`docs/` 不存在时自动创建,概要取自计划第一行 `# 标题`)。同一天同一主题再计划一次是**追加「更新」小节**而不是覆盖;同名同内容则完全不写盘。计划路径随会话一起落盘,所以**刷新页面、甚至重启服务**之后,切到自动 / 手动模式仍然知道要按哪份文档执行:模型会先读它、按其中 `- [ ]` 步骤推进、勾掉完成的项,并把结果回填进文末「执行结果」。
427
+
428
+ > **挂起不会白等**:手动模式下等你批准的那个状态(挂起态)**保留 60 分钟,并且落盘**。刷新页面、关掉标签页、甚至重启服务,回来卡片还在,点一下就能接着跑完 —— 不用从头重跑整个任务。
429
+
430
+ 页面上的每一步工具调用都会显示成一条可展开的记录(调用名、参数、结果、输出内容)。
431
+
432
+ ### 侧边栏按工作目录分组:一个目录下可以挂多条任务
433
+
434
+ 任务一多,按时间平铺就难找了。侧边栏改成**按工作目录分组**,一个目录就是一组,组下挂该目录的所有任务:
435
+
436
+ ```text
437
+ ▾ 📁 alpha 3 +
438
+ 目录A的第一件事 3 分钟前
439
+ 目录A的第二件事 1 小时前
440
+ 目录A的第三件事 昨天
441
+ ▸ 📁 beta 1 +
442
+ ```
443
+
444
+ - **同一个工作目录可以有多条任务**:磁盘上本来就是按任务 id 存的,目录只是分组维度,并不存在「一个目录只能有一条」的限制 —— 分组只是把这条关系显式地画出来;
445
+ - 分组行右侧的 **`+`** 直接在**该目录下**再开一条任务(顶部那个「+ 新任务」则是沿用当前目录);
446
+ - 点分组行**折叠 / 展开**,折叠状态存在浏览器里,刷新后还在;切到某条任务、或在该组下新建任务时,折叠着的组会**自动展开**,不会「点了没反应」;
447
+ - 分组标题是目录名,**悬停看完整路径**;不同目录同名时(`a\src` 与 `b\src`)自动显示最后两段加以区分;
448
+ - 分组之间、组内任务之间都按**最近更新**排序;草稿(点了 `+` 但还没发出第一条指令)会在目标分组里占一行,让你看得见它将落在哪个目录。
449
+
450
+ ### 一个任务可以派生子任务(任务树)
451
+
452
+ 「一件事拆成几件」很常见:先让模型看清现状,再针对其中一块单独开工。这种延续关系现在会留在树上,而不是平铺成一堆看不出关系的时间线。
453
+
454
+ - **怎么派生**:鼠标移到某条任务上,行尾会出现一个 `+`,点它就在**这条任务之下**新建子任务(工作目录继承父任务);
455
+ - **长什么样**:子任务缩进一级、左侧一条细竖线,第二行以 `↳` 开头,一眼看出它是从哪条派生出来的;
456
+ - **收起 / 展开**:有子任务的父任务行首有一个箭头,点一下把整棵子树收起来(折叠状态记在浏览器里,刷新后还在);切到折叠着的父任务下的子任务时,父任务会**自动展开**;
457
+ - **顺序**:每层内部仍按最近更新排序 —— 没有父子关系时就是原来的平铺,观感不变;
458
+ - **删父任务不连带删子任务**:子任务会升到上一层(确认框里会先说清楚),不会跟着消失;
459
+ - **边界**:父任务必须与子任务在**同一个工作目录**(跨目录的派生会被服务端退回顶层),所以树永远不会横跨两个目录分组。
460
+
461
+ > 派生关系存在任务索引里(`parentId`),指向不存在或跨目录的父任务一律当顶层处理 —— 父任务被删掉时,子任务只是升一层,而不是变成看不见的孤儿。
462
+
463
+ ### 进度是可见的
464
+
465
+ 只要一个结论:**进行中** 还是 **已完成**。侧边栏每条任务一个小圆点,正在跑的那个会呼吸,那条任务左侧同时亮一条竖线;已完成的点常亮。文字只在悬停提示里,列表本身不堆状态词。
466
+
467
+ 状态栏也只有三种说法:`进行中` / `就绪` / `已停止`。
468
+
469
+ **轮次上限默认 100,撞到也不堵活**:上限数的是**模型轮次**(一轮里可以并行多个工具调用),默认 100 轮,可用 `--max-steps` / `TASK_MAX_STEPS` 调整。会话、任务、挂起态都已经落盘,长任务撞线后点一下「继续执行」就能带着全部历史接着跑,所以轮次上限只是**防跑飞的兜底**,不必卡得紧(8 轮 / 40 轮那种紧上限会让真实任务干到一半被掐断)。真的撞到上限时:先自动走一轮**不带工具**的收尾把结论给你(`hitLimit` / `wrappedUp` 标在结束事件里),页面再给一个**「继续执行」按钮**,点一下带着全部历史接着干,不用重新描述一遍。这个按钮和提示会一起落盘,刷新页面也还在。
470
+
471
+ **空回复一定会有解释**:模型某一轮既没有正文、也没有要求调用工具时,页面会单独给出一条提示(并带上 `finish_reason` 便于排查)—— 最常见的原因是思考模型把 `max_tokens` 耗在了思维链上。这类提示是**独立横幅**,不会因为已经有正文而被丢掉。
472
+
473
+ **连接中途断掉**(服务重启 / 网络中断)也会明说「未收到结束信号,结果可能不完整」,不会假装「就绪」。
474
+
475
+ **切换任务不打断、也不刷新**:在侧边栏点另一条任务**不会停掉正在跑的那条** —— 它会继续在后台跑完,跑完照常落盘(存进它自己的历史,不会串到当前正在看的那条)。每条任务在内存里各留一份状态(`cache`),所以切回去是**立刻恢复**,包括跑了一半的正文、进行中的呼吸点、以及「继续执行」按钮;只有**刷新浏览器**才会真正重新从服务端读一次,这正是预期行为。同时跑的判定是按任务算的(`runs`):看 B 的时候,A 在后台跑不会禁用 B 的输入框;侧边栏上正在跑的任务(包括没在看的)都会呼吸。停止按钮只停**当前正在看的**那条。删除任务会一并停掉它并清掉缓存。
476
+
477
+ **点标题不会刷新「N 分钟前」**:侧边栏那个时间说的是「最后一次**真正改动**」,不是「最后一次被看过」。所以点开一条任务、切走、切回来、把工作目录选成同一个目录 —— 这些都不会让时间跳到「刚刚」,也不会白写一次盘:
478
+
479
+ - **服务端说了算**:`PUT /api/tasks/<id>` 会先和盘上的记录逐项比(标题 / 工作目录 / 模式 / 改名标记 / 回答轮数 / 消息正文),**一模一样就原样返回**,`updatedAt` 不动、任务文件和索引都不重写。盘上的任务文件丢了或坏了则照常补写,不会因为「内容看着一样」而漏掉自愈。
480
+ - **客户端根本不发这一趟**:页面在切任务 / 切模式 / 点目录时都会顺手保存一次,如果内容和上次保存的**逐字相同**,这个 PUT 就整个省掉(大任务的历史可能几十 MB,点一下标题就上传一遍太亏了)。刚读回来的任务会先记一份指纹,所以「点一条、再点回来」不会有任何写入。
481
+ - **真改了才更新**:发一条新指令、改名、换目录、换模式、审批通过后接着跑,都会正常落盘并刷新时间。
482
+
483
+ > 顺带一个好处:另一个标签页刚存过的新内容,不会因为你在本标签页**什么也没改**地切来切去而被旧内容覆盖回去。
484
+
485
+
486
+ ### 接口
487
+
488
+ | 接口 | 作用 |
489
+ | --- | --- |
490
+ | `GET /` | 任务页面 |
491
+ | `GET /manual` | 操作手册页(安装与启动 + 任务页操作流程 + 三端斜杠指令对照) |
492
+ | `GET /api/commands` | 三端(命令行 / 本机聊天页 / 任务页)指令的机器可读清单,手册页据此生成,不手抄 |
493
+ | `GET /api/cost?prompt=&completion=&model=` | 费用粗估(与 CLI `/cost` 同一份单价表 `lib/pricing.js`) |
494
+ | `GET /api/config` | 网关地址、模型、工具清单、上限、密钥是否就绪(只回脱敏值 + 来源 + 密钥文件路径) |
495
+ | `GET /api/fs/roots` | 预设根目录(当前项目、用户主目录、各盘符) |
496
+ | `GET /api/fs/list?path=` | 浏览目录,返回子目录与文件(供选择器用) |
497
+ | `GET /api/models` | 代理网关 `/v1/models` |
498
+ | `POST /api/task` | 执行任务:body 为 `{ taskId, prompt, workDir?, mode?, model? }`(**有状态**,不再回传对白),SSE 事件:`started` / `reasoning` / `delta` / `tool_call` / `tool_result` / `approval_required` / `usage` / `notice` / `error` / `done`;同一任务并发发起返回 409 |
499
+ | `POST /api/task/approve` | 批准或拒绝一次挂起的写入,然后接着往下跑 |
500
+ | `POST /api/task/cancel` | 取消一个挂起的任务 |
501
+ | `GET /api/task/pending` | 当前待批准的挂起态(页面刷新后据此恢复批准卡片) |
502
+ | `GET /api/settings` | 全部配置项:值、**来源**(flag/env/file/default)、默认值、说明、校验失败时的 warning;第一行是密钥(`type:secret`,值不回显,只给掩码与来源);每次都会重读磁盘 |
503
+ | `PUT /api/settings` | 写入若干项(原子写、即时生效);`key` 走单独一条路写 `credentials.json`(立即生效、不重启),其余键写 `config.json`;未知键/越界值/不成形的密钥 400 |
504
+ | `DELETE /api/settings` | 删掉若干项,回到内置默认 |
505
+ | `GET /api/store` | 存储位置、占用空间、任务数与保留策略,以及**会话占用**(`sessions.count` / `bytes`) |
506
+ | `GET /api/tasks` | 任务列表(顺手清理过期任务,返回 `removed`) |
507
+ | `POST /api/tasks` | 新建一条任务(id 由页面生成 UUID,服务端校验) |
508
+ | `GET /api/tasks/<id>` | 读一条任务的完整内容 |
509
+ | `PUT /api/tasks/<id>` | 覆盖保存(改名、换工作目录、写入消息) |
510
+ | `DELETE /api/tasks/<id>` | 删除一条任务(连带删掉它的会话文件) |
511
+ | `POST /api/tasks/prune` | 立即按 15 天 / 20 条清理一次(同样连带删会话) |
512
+
513
+ > `/api/config` 里的 `tools` 是**当前真正可用的工具**:`bash` 未由宿主开启时不会出现在列表里。
514
+ > `/api/store`、`/api/tasks*` 与 `/api/settings` 和目录浏览一样只在本机监听时开放(开在 `0.0.0.0` 上时一律 403 —— 它们能读本机路径、也能改你的配置)。任务 id 必须是 UUID 形状,服务端按格式校验,`..%2F` 之类的穿越 id 一律 400。
515
+
516
+ ### 沙箱边界
517
+
518
+ 模型试图越界时会被拦下,并把拒绝原因作为工具结果回填,模型自己就能纠正方向:
519
+
520
+ - 所有路径都相对**工作目录**解析,`../` 之类的上跳一律以「路径越界」拒绝;
521
+ - 绝对路径、Windows 盘符路径同样按越界拒绝;
522
+ - 除了字符串层面的路径校验,还会对**最近存在的父目录取 `realpath` 再校验一次**,因此目录联接 / 符号链接也逃不出去(这一条有专门的测试覆盖);
523
+ - 目录浏览接口只在服务绑定在本机时开放,绑定 `0.0.0.0` 时必须显式加 `--allow-remote-fs`。
524
+
525
+ > `bash` 把安全面从**路径级**升到**命令级**,所以它**默认关闭**,必须由宿主显式开启(CLI 用 `--allow-bash`)才可用;开启后也只在工作目录内执行,且每次执行仍要过批准闸门。
526
+ > 密钥始终只在服务端进程里(或在 `<数据根>/credentials.json` 里,仅属主可读);任务记录直接写到**你本机的磁盘**上(不经过浏览器,也不上传服务器),见下方「任务记录存在磁盘上」。
527
+
528
+ ### 上下文记忆:模型不会每轮重读你的文件
529
+
530
+ 以前的任务页是**无状态**的:每轮请求都由浏览器把对话重新拼一遍发上去,而工具调用与工具结果在这一步被剥掉了 —— 模型手里从来没有过 `read_file` 读回来的正文。结果就是**每轮都在重新 `list_dir` / `grep`**:同一个文件读了五遍,时间和 token 全烧在重复探索上。
531
+
532
+ 现在**服务端持有会话**:任务页只发「新指令 + taskId」,模型态消息(`assistant.tool_calls` + `role:"tool"` 的工具结果)由服务端逐轮追加并落盘。于是:
533
+
534
+ - 第二轮开始,模型看得见第一轮读到的文件内容,**不再重复读**;
535
+ - 关掉页面、重启服务(甚至重启电脑)后继续这条任务,上下文仍在;
536
+ - 同一任务重复发起会被拒(409),不会两条流水线同时改你的文件。
537
+
538
+ 会话文件在 `<store>/sessions/<taskId>.json`,跟随任务的删除与过期一起清理,启动横幅里能看到条数与占用(`/api/store` 也报了)。**旧任务不用手动迁移**:第一次对老任务发起新指令时,服务端会就地把那份渲染态历史反推成模型态并落盘,状态栏会告知。
539
+
540
+ > 三种模式下都能看到这套记忆的效果;`tests/live.test.mjs` 的「任务七」就是拿真实模型钉住它:第二轮**零次 `read_file`**,直接答出第一轮读到的内容。
541
+
542
+ 两个成本控制开关(`gateway-agent config set` 可改,见下文「配置」):
543
+
544
+ - **历史预算** `history.maxChars`(默认 60000):超过预算时,**较早**的工具结果正文会被压成一行 `[已压缩的历史工具结果] read_file big.txt → 120 行`。注意是**只压正文、保留记录** —— 模型必须知道「这个我读过」,否则又会去读一遍。最近 `history.keepRecentTurns`(默认 3)轮始终保全文。
545
+ - **已读文件清单** `history.readFileDigest`(默认开):把「这个任务里读过哪些文件、各多少行、什么时候被改过」附在 system 后面。文件被外部改动过会标出「已被外部修改,请重新读取」,免得模型拿旧内容当现状。
546
+
547
+ > 压缩与清单都只在需要时才改(没超预算就一个字不动),这是为了不破坏上游的 prompt 前缀缓存 —— 每轮重新裁剪会把缓存全打掉,省下的 token 还不如赔的多。
548
+
549
+ ### 任务记录存在磁盘上
550
+
551
+ 任务模式一开始是「一次只留一个任务」,后来改成多任务,最新一版把数据从浏览器的 localStorage 搬到了**磁盘**:一次任务可能产生十几条工具输出、每条都带文件正文,浏览器那 ~5MB 根本装不下,只能一路截断降级。落盘之后这些限制就没有了,换浏览器、清缓存也不会丢。
552
+
553
+ **存储位置**:默认在**用户主目录**下的 `.llm-api-gateway-cli`,跟 `.npm` / `.config` 这些做邻居 —— 各平台一致,找得到、删得掉、整个目录拷走就能带走。
554
+
555
+ | 内容 | 默认路径 |
556
+ | --- | --- |
557
+ | 任务数据 | `~/.llm-api-gateway-cli/tasks/`(Windows 同样是 `C:\Users\<你>\.llm-api-gateway-cli\tasks`) |
558
+ | 任务会话(模式五的上下文) | `~/.llm-api-gateway-cli/tasks/sessions/` |
559
+ | CLI 会话(模式六的 `--continue`) | `~/.llm-api-gateway-cli/sessions/` |
560
+ | 配置文件 | `~/.llm-api-gateway-cli/config.json` |
561
+
562
+ > 老版本用的是平台数据目录(`%LOCALAPPDATA%` / `~/Library/Application Support` / `$XDG_DATA_HOME`),
563
+ > 早期版本还会退到**脚本目录下的 `.tasks/`** —— 同一台机器上数据可能在两三个地方,而且会把 `.tasks`
564
+ > 写进项目目录。现在统一到主目录一处:**启动时如果发现旧位置有数据,会自动复制到新位置**,
565
+ > 并在日志里写明搬了什么、旧目录在哪(复制不是移动,旧目录确认无误后自己删)。
566
+ > 搬过一次会留记号(`.legacy-migrated.json`),之后不再重复搬 —— 所以你在新位置删掉的任务不会被旧目录「复活」。
567
+
568
+ 选目录时按 `--store` → `~/.llm-api-gateway-cli/tasks` → 旧位置里那份有数据的(主目录不可写时的保命通道)→ 系统临时目录的顺序找第一个**能写**的,启动时会把最终位置和原因打出来。用 `--store <目录>` 或环境变量 `TASK_STORE_DIR` 指定,指定了但不可写会直接报错退出,而不是偷偷换地方;想整根换地方(连同配置与会话)用 `LLM_GATEWAY_DATA_DIR`。
569
+
570
+ > **「主目录不可写、而旧位置里有历史任务」时会继续用旧位置**,并在启动日志里警告。
571
+ > 加这条是因为「能不能写主目录」会随环境变 —— 受限沙箱里不可写、普通终端里可写,同一台机器因此可能落到**两个不同目录**,表现就是**历史任务莫名不见了**(列表空白,但旧目录里文件明明还在)。
572
+ > 想彻底固定下来,就用 `--store` 明确指定:
573
+ >
574
+ > ```bash
575
+ > node server.js --store ~/.llm-api-gateway-cli/tasks # 固定用新位置
576
+ > LLM_GATEWAY_DATA_DIR=/data/lgw node server.js # 或者整根搬到别处
577
+ > ```
578
+
579
+ 页面里也能看到:侧边栏底部有「存储 …」一行(hover 看完整路径),设置面板里有**存储位置**和**占用空间**。
580
+
581
+ 目录结构:
582
+
583
+ ```
584
+ <store>/index.json 任务索引(只有元信息,列表用,很小)
585
+ <store>/tasks/<id>.json 单条任务的完整数据
586
+ ```
587
+
588
+ 索引与数据分开存,列表页不必把每条任务的正文都读出来;索引丢了或坏了也能扫描 `tasks/` 重建,所以不会「索引一坏数据全没」。写入用「先写 `.tmp` 再 rename」,进程被杀也不会留下半截 JSON;Windows 上 rename 覆盖会偶发被占用,代码里有重试并退化为直接覆盖。
589
+
590
+ 自动管理规则:
591
+
592
+ 1. **自动创建** —— 点「+ 新任务」只进入草稿态、不落盘,发出第一条指令时才创建,避免留下一堆空任务;
593
+ 2. **自动命名** —— 取首条指令前 24 字作标题(服务端算,保证不依赖前端);手动改过名的标记 `renamed`,之后不再被覆盖;
594
+ 3. **滑动过期** —— 从最后一次活动算起超过 15 天,连任务文件一并删除;
595
+ 4. **数量配额** —— 超过 20 条淘汰最旧的,同样连文件一起删;
596
+ 5. **状态可查** —— 侧边栏显示任务数与最早过期时间;底部「清理」可立即执行一次。
597
+
598
+ 浏览器这边只留「当前打开哪条任务」这类纯 UI 状态,**服务端是任务数据的唯一真相**,所以多个标签页 / 多个浏览器看到的是同一份。UI 状态默认也落盘(`ui.persist` = `server`:`activeId`、折叠了哪些目录/任务存进 `config.json`,清缓存不丢);想退回「每个浏览器各自一份」的老语义,就 `gateway-agent config set ui.persist browser`。草稿态(还没落盘的任务)无论如何都留在浏览器。
599
+
600
+ 「+ 新任务」**会沿用当前的工作目录**(多数情况是在同一个项目里连着做几件事),点顶部的工作目录条可随时改;如果还没有选过目录,则自动弹出选择器。想**在指定目录下开新任务**,用侧边栏该目录分组行上的 `+`(同一目录允许多条任务)。
601
+
602
+ > **从旧版本升级**:之前存在浏览器里的任务(`lgw.tasks.v2` + `lgw.task.<id>`,以及更早的单任务 `lgw.task.v1`)会在首次打开、且磁盘上还没有任务时自动搬到磁盘,搬完清掉旧的浏览器数据,状态栏会告知搬了几条。
603
+
604
+ > **安全边界**:任务记录里含工具读过的文件正文,属于本机数据,所以存储接口和目录浏览一样**只在服务绑定本机时开放**,绑定 `0.0.0.0` 需显式 `--allow-remote-fs`。另外,如果你把工作目录正好选成了存储目录所在的位置(比如回退到 `.tasks/` 时选了本项目),模型的文件工具就能读到任务历史 —— 服务端检测到这种情况会在状态栏提示。
605
+
606
+ > 与聊天的区别:聊天没有工作目录、不碰文件系统,是纯对话;任务是「选目录 → 干活」。两者是同一个服务上的两个页面(/ 和 /task),可以同时开着,右上角互相切换。
607
+
608
+ 两个页面互相留了入口,不用记端口:
609
+
610
+ - 聊天页侧边栏「**+ 新任务**」在「+ 新对话」**上面**,点它新开标签页进入任务模式;
611
+ - 任务页顶栏「**聊天 ↗**」反向回到聊天模式。
612
+
613
+ 两边都用当前主机名推导对方地址(所以通过局域网 IP 访问时不会跳回 `127.0.0.1`);端口若改过,可用 `?task=http://host:port/` 或 `?chat=http://host:port/` 覆盖。
614
+
615
+ ## 模式六 · 原生 Agent CLI(推荐,不依赖 Claude Code)
616
+
617
+ 模式五把 Agent 跑在浏览器里。模式六把**同一套内核搬进终端**:`cli-agent.js` 复用 `lib/agent.js` 的工具循环与 `lib/tools.js` 的沙箱,用 Node 原生 `fetch` 直连网关,**不依赖任何外部 Agent(无需安装 Claude Code)**,当前目录即工作目录。
618
+
619
+ ```bash
620
+ node cli-agent.js -p "看下 README 结构,帮我总结一下这个项目" # 单轮任务
621
+ node cli-agent.js -i # 多轮交互(当前目录为工作目录)
622
+ node cli-agent.js -C ./myproject -p "给 src 里每个文件补一行文件头注释"
623
+ echo "列出目录并总结" | node cli-agent.js # 从标准输入取提示词
624
+ node cli-agent.js -C ./myproject -p "批量改名" --yes # 自动批准所有写入(危险)
625
+ node cli-agent.js -i --continue # 接着最近一次会话继续聊
626
+ node cli-agent.js -i --resume <会话id> # 切到指定会话
627
+ ```
628
+
629
+ 选项:
630
+
631
+ | 选项 | 作用 |
632
+ | --- | --- |
633
+ | `-p, --prompt <文本>` | 单轮任务(省略则读位置参数 / 标准输入) |
634
+ | `-i, --interactive` | 多轮交互(REPL),会话上下文在进程内保持 |
635
+ | `-C, --cwd <目录>` | 工作目录,工具被沙箱限制在该目录内(默认当前目录) |
636
+ | `-m, --model <模型>` | 模型名(默认 `GATEWAY_MODEL` 或 `qwen3:8b`) |
637
+ | `-s, --system <文本>` | 追加的系统提示词 |
638
+ | `--temperature` / `--max-tokens` | 采样温度 / 最大生成 token 数 |
639
+ | `--continue` | 接着最近一次会话继续(恢复上下文与待批准态) |
640
+ | `--resume <会话id>` | 恢复指定会话(id 是 UUID) |
641
+ | `--session-dir <目录>` | 会话落盘目录(默认在用户主目录下的 `.llm-api-gateway-cli/sessions`) |
642
+ | `--no-session` | 不落盘会话(纯内存,退出即丢) |
643
+ | `--no-memory` | 不读取 `AGENTS.md` / `CLAUDE.md` 项目记忆 |
644
+ | `--allow-bash` | 开启 `bash` 工具(默认关闭;开启后每次执行仍需批准) |
645
+ | `--output-format <值>` | `text`(默认)或 `stream-json`(逐行 JSON 事件,便于管道 / CI) |
646
+ | `--yes` | 自动批准所有**文件写入**(**危险**:模型可直接改文件,慎用)。MCP 调用不需要它 |
647
+ | `--verbose` | 打印工具结果正文(默认只显示一行摘要) |
648
+ | `--no-color` | 关闭彩色输出(也遵循 `NO_COLOR`) |
649
+
650
+ 工具集与模式五完全相同(`list_dir` / `read_file` / `search_files` / `glob` / `grep` / `apply_patch` / `write_file` / `bash`),其中 `bash` **默认关闭**、加 `--allow-bash` 才可用。单个任务默认最多 100 轮模型调用(`--max-steps` 可调)。**写入闸门在终端里,而且只管本机文件**:模型每次写入(`write_file` / `apply_patch`,以及开启后的 `bash`)都会先打印预览框(路径、新建/覆盖、行数与字节数;覆盖已有文件时还打印行级 `- / +` diff),再问你 `y/N`。非交互环境(如管道)**默认拒绝文件写入**,确需自动批准请显式加 `--yes` —— 这样脚本误改文件的风险最小。
651
+
652
+ > **MCP 调用不走这道闸门**(每次调用照样在终端打印一行 `⚙ MCP 高德地图 · maps_geo(…)`,看得见,但不需要逐个按 y)。
653
+ > 原因是一个真实踩到的坑:早先 MCP 也算「写入类」,于是非交互 `-p` 模式下**每一次 MCP 调用都被拒**
654
+ > (一次调研要查十几个地点,等于地图工具全废);而唯一绕法是 `--yes`,那又把**任意文件写入**一起放开——
655
+ > 用一个更大的权限换一个更小的功能,这个交换不成立。**仍然成立的是那句提醒**:MCP 结果由第三方返回、
656
+ > 会进入对话上下文,属于**不可信输入**——远端返回的内容不要当指令执行。
657
+ > 「计划模式不给 MCP 工具」这条边界**没有变**(它只做本机只读调研):它是「给不给模型」的问题,
658
+ > 和「要不要人工批准」是两件事,在代码里也是两个谓词(`isWriteTool` / `isFileWriteTool`)。
659
+
660
+ **会话落盘**:交互会话默认写到用户主目录下的 `.llm-api-gateway-cli/sessions/`(与任务存储同一个根;主目录不可写时才退到旧位置、再到系统临时目录),滑动保留 15 天、最多 50 条、单文件 1MB 上限。`--continue` 续最近一条、`--resume <id>` 切指定一条;恢复时会连同「待批准的写入」一起还原,进程退出前没批完的写入,回来还能接着批。用 `--no-session` 可完全关掉落盘。
661
+
662
+ **项目记忆**:启动时若工作目录下有 `AGENTS.md` 或 `CLAUDE.md`,会读进来注入 system prompt(上限 4KB,超出截断);`--no-memory` 可关闭。
663
+
664
+ 交互模式里:直接输入任务;`/help` 看命令(`/help <命令名>` 看单条用法,别名 `/?` `/h`)、`/model` 切换模型(保留上下文)、`/config` 查看或修改持久化配置(与 `gateway-agent config` 同一套、同一个文件)、`/cost` 看累计 token 与费用粗估(第二行会自报口径:**内置单价表估算,不是网关计费口径**)、`/resume` 切换会话、`/compact` 压缩上下文、`/reset`(`/clear`)重开会话(工作目录不变);`/exit`(或裸词 `exit`/`quit`)退出。命令名**大小写不敏感**,全角 `/` 也认;只打一半(如 `/co`)**只列候选、不执行**(REPL 没有候选面板,所以打印成文本列表);想让 `/clear` 这样的文字原样发给模型,写成 `//clear`。行尾写 `\` 可续行(多行提示词不用引号包一坨)。任务执行中按 `Ctrl+C` 中断当前任务,空闲时按 `Ctrl+C` 退出。
665
+
666
+ > 命令表(`lib/commands.js`)与网关 Web 对话页的斜杠指令**共用一套元数据**(`capability` 能不能跑 / `surfaces` 在哪一端有意义 / `args`);同名命令两端语义一致,这是被两侧的跨端契约断言钉住的。网关 Web 的「恢复默认设置」因此把 `/reset` 让名给 CLI 的「重开会话」、自己改叫 `/defaults`(口径见网关仓库 `docs/SLASH-CLI-ALIGNMENT-20260916.md`)。
667
+
668
+ 任务页(`/task`)另有一组**只影响这个页面、不发模型**的斜杠指令(E 组,与上面两端同一套元数据口径,共 14 条):
669
+
670
+ | 组 | 指令 | 作用 |
671
+ | --- | --- | --- |
672
+ | 帮助 | `/help [指令名]`(`/?` `/h`) | 列全部指令 / 看单条用法 |
673
+ | 计划与审批 | `/plan [任务描述]`(`/p`) | 切计划模式;带描述就直接按计划模式跑一轮(这一轮模型只能读) |
674
+ | | `/approve`(`/ok`)、`/reject`(`/no`) | 批准 / 拒绝当前挂起的写入 |
675
+ | | `/mode [manual\|auto\|plan]` | 看或切审批模式(与页面上的模式按钮同一实现) |
676
+ | 模型与请求参数 | `/model [模型名]` | 看可选模型或切换当前模型(**只能从网关 `/v1/models` 拉到的列表里选**,与设置面板同一项) |
677
+ | 会话与用量 | `/cost` | 这条任务累计 token 与费用粗估(与 CLI `/cost` 同一份单价表,口径由 `/api/cost` 提供) |
678
+ | | `/resume [任务id]` | 不带参数列最近任务,带 id 切过去(等同点侧边栏) |
679
+ | | `/stop` | 停止正在跑的这一轮(等同「停止」按钮;不影响后台其它任务) |
680
+ | 工作目录 | `/files [子路径]`(`/ls`) | 列目录 |
681
+ | | `/cwd [路径]`(`/pwd`) | 看或换工作目录 |
682
+ | | `/init [--force]` | 在工作目录生成 `AGENTS.md` 骨架(已存在不覆盖) |
683
+ | | `/instructions`(`/memory`) | 看这个目录注入给模型的项目记忆 |
684
+ | 手册 | `/manual` | 打开下面的操作手册页 |
685
+
686
+ **输入一个 `/` 就弹全部候选**(`↑↓` 选择 · `Tab` 补全 · `Enter` 执行 · `Esc` 关闭),接着打字母按前缀收窄(`/pl` → `/plan`)。`//plan` 是转义:把 `/plan` 当普通任务文本发给模型。**未知的 `/xxx` 不会被发给模型**,只提示改用 `//`(在任务页把 `/clear` 当任务描述发出去几乎不可能是本意)。`/init` 是页面自己决定内容的写入(不含模型产物),因此**不走审批闸门**,但**已存在就不覆盖**、要重写骨架得显式 `/init --force`。标 `fs` 的指令需要服务端放行本机文件访问(服务没绑定在本机地址时会说明原因并拒绝执行)。
687
+
688
+ ### 操作手册页(`/manual`)
689
+
690
+ 两个页面顶栏都有「手册」入口,任务页里也可以敲 `/manual`:**http://127.0.0.1:3100/manual**。
691
+
692
+ 它把四件事放在一页里:**安装与启动**(一键脚本 bash / PowerShell、npm 全局安装、源码 `npm link`,前置 Node ≥ 18,**首次配置:密钥是启动前提 / `.env` 查找顺序 / 网关地址与模型 / `config.json`**,启动自检与卸载,参数表与换镜像)、任务页操作全流程(工作目录 / 三种模式 / 审批卡片 / 计划与「按计划执行」/ 子任务 / 停止)、**三端指令对照表**(同一条指令在命令行 REPL、本机聊天页、任务页哪一端能用)、以及各端指令逐条用法。
693
+
694
+ 关键的几点:
695
+
696
+ - **清单不是手抄的**:页面通过 `GET /api/commands` 取数据,而那份数据直接来自 `lib/commands.js`(CLI)与 `public/task-slash.js`(任务页)两张表 —— 改代码即改手册,不存在"文档里少几条"的可能;
697
+ - **安装与首次配置也不是随口写的**:手册里的仓库地址、脚本名(`scripts/install.sh` / `scripts/install.ps1`)、主命令 `gateway-agent`、最低 Node 版本、默认安装目录、两套参数名、密钥别名、密钥文件名(`lib/secrets.js` 的 `SECRET_FILE_NAME`)、`.env` 查找顺序、默认网关地址(`lib/common.js` 的 `DEFAULT_BASE_URL`)、`config.json` 位置(`lib/taskstore.js` 的 `DATA_DIR_NAME`),以及「Web 服务没有密钥也能起、命令行 agent 缺密钥即退」这两半行为,全部由 `tests/manual.test.mjs` 与 `package.json`、两份安装脚本、`lib/common.js`、`lib/settings.js`、`lib/secrets.js` 交叉断言 —— 改脚本或改默认值漏改手册会直接红;
698
+ - `GET /api/cost?prompt=&completion=&model=` 提供与 CLI `/cost` 同口径的费用粗估(单价表只有 `lib/pricing.js` 一份);
699
+ - `tests/manual.test.mjs` 拿真实接口数据断言「表里每一条都出现在手册里」,这就是这条契约的回归闸门。
700
+
701
+ > 本模式与模式三是**两种不同的接入姿态**:模式三让 Claude Code 这个外部 Agent 走网关(验证「网关能当 Anthropic 后端」),模式六是**网关自带 Agent**(验证「不装 Claude Code 也能在终端干活」)。两者共存,按需选用。
702
+
703
+ ### 与网关任务模式共用的内核
704
+
705
+ 模式五(Web)与模式六(CLI)共用同一套实现,避免两处各写一遍后行为漂移:
706
+
707
+ - `lib/hub.js`:**唯一的 Web 服务实现** —— 静态文件(`/` → 聊天页、`/task` → 任务页、`/manual` → 操作手册页)、两页共用的 `/api/config` 与 `/api/models`、手册页数据 `/api/commands` 与 `/api/cost`、聊天的 `/api/chat`、任务的全部接口都在这里;`server.js` 与 `task-server.js` 只是它的两个薄入口。
708
+ - `lib/tools.js`:八个工具(`list_dir` / `read_file` / `search_files` / `glob` / `grep` / `apply_patch` / `write_file` / `bash`)+ 沙箱(`realpath` 符号链接逃逸检查)+ 写入预览 `writePreview`;`bash` 由 `setBashEnabled` 控制开关,未开启时 `availableTools()` 直接把它摘掉。
709
+ - `lib/agent.js`:模型 → 工具 → 回填 → 再问的循环、SSE 解析、按 `index` 拼接 `tool_calls`、挂起/批准/续跑、带退避的 `fetchWithRetry`;
710
+ - `lib/runner.js`:把上面两者包成「建会话 → 跑一轮 → 遇写入挂起 → 等批准 → 断点续跑」的传输无关流程,Web 与 CLI 都基于它(`createSession` / `restoreSession` / `pushUser` / `runTurn` / `resumePendingTurn` / `buildTaskSystemPrompt`);
711
+ - `lib/taskstore.js`:模式五的任务落盘(`TaskStore`:索引 + 每条任务一个文件、原子写入、15 天/20 条清理、UUID 校验防目录穿越、损坏自愈)与存储目录探测 `resolveStoreDir`;
712
+ - `lib/taskstore.js`:任务记录的磁盘存储(索引 + 单任务文件、原子写、TTL 与配额清理、脏数据清洗);
713
+ - `lib/tasksession.js`:**模型态会话**落盘(方案 C)+ 旧数据懒迁移 + 历史预算压缩 + 已读文件清单;
714
+ - `lib/plandoc.js`:**计划模式的计划落盘** —— `<工作目录>/docs/YYYYMMDD-<概要>.md`,目录自动创建、概要取自计划第一行标题、同名只追加不覆盖;`buildTaskSystemPrompt` 拿到 `planFile` 后会把「按这份计划执行并回填结果」拼进 system;
715
+ - `lib/settings.js`:配置的**唯一实现**(白名单、四层优先级、来源、密钥红线),Web 与 CLI 共用;
716
+ - `lib/secrets.js`:**密钥的唯一实现** —— `credentials.json` 的读写(原子写、POSIX `chmod 600`、坏文件不抛只告警)、优先级合并(`--key` / env / `.env` > 文件)、`keyRow()`(界面与命令行共用的那一行,只给掩码);`config.json` 里永远没有密钥,就是靠它把密钥分流的;
717
+ - `lib/configcmd.js`:`gateway-agent config list/get/set/unset/path` 与 REPL `/config` 共用的输出层;
718
+ - `lib/runstore.js`:待批准的挂起态落盘(原子写入、TTL 清理、UUID 校验),让刷新页面/重启服务后仍能批准;
719
+ - `lib/sessionstore.js`:模式六的会话落盘(`--continue` / `--resume`,TTL 15 天 / 50 条 / 单文件 1MB);
720
+ - `lib/jsonstore.js`:共享的原子写入(tmp → rename,Windows 上 EPERM/EACCES/EBUSY 重试 5 次),`runstore` 与 `sessionstore` 都引用它;
721
+ - `lib/config.js`:四个 CLI 共用的配置解析(`--flag > env > .env` 收在 `resolveConfig` 一处)+ 统一的失败出口 `fail(e, { exit })`;
722
+ - `lib/commands.js`:模式六的斜杠命令表(`COMMANDS` / `parseSlash` / `helpText`)+ 审批预览用的极简行级 `diffLines`;
723
+ - `public/task-slash.js`:任务页的斜杠指令表(14 条)+ `commandRows()`(与 `lib/commands.js` 同字段,供 `/api/commands` 与手册页共用);`public/manual.js` 把接口数据渲染成手册页的对照表 —— 页面里没有第二份手抄清单;
724
+ - `public/models.js`:设置面板「模型」下拉的唯一实现(两页共用)—— 选项只来自 `/api/models`,并处理「当前值不在列表里」「一个都没拉到」这两种不能丢配置的情况;`public/render.js` 同理是两页共用的渲染实现;
725
+ - `lib/pricing.js`:token 单价表与费用粗估(`/cost` 命令复用);
726
+ - `lib/common.js`:`.env` 加载、参数解析、密钥/地址/端口解析、SSE 头、静态文件路径校验等两边共用的小件。
727
+
728
+ ### 测试
729
+
730
+ ```bash
731
+ npm test # 离线:文件工具与沙箱、Agent 循环、运行器、配置、会话、命令、契约检查(不联网、不耗 token)
732
+ npm run test:live # 在线:需要先启动 server.js(网关/模型可达),会真实调用模型
733
+ npm run test:all # 先离线再联网
734
+ ```
735
+
736
+ > `npm test` 通过 `scripts/run-tests.mjs` 聚合执行(清单式,加测试只往数组里加一行);离线组不依赖网关,live 组需要真实环境。
737
+
738
+ | 测试 | 覆盖内容 |
739
+ | --- | --- |
740
+ | `tests/tools.test.mjs` | 路径越界(`../`、绝对路径、盘符、多级上跳)、**符号链接 / 目录联接逃逸**、写入批准闸门、越界写入不落盘、二进制与大文件拒绝、搜索跳过 `node_modules`、目录浏览接口;以及新工具 `glob` / `grep` / `apply_patch`(一处不匹配就整体不落盘)/ `bash`(默认关闭、开启后可跑、超时被终止) |
741
+ | `tests/agent.test.mjs` | 用模拟网关跑完整工具循环:SSE 分片重组、多工具并行、`tool_calls` 参数字符串拼装、批准 / 拒绝 / 坏参数 / 工具报错的回填、轮次上限(默认 100、可用 `cfg.maxSteps` 压低、撞线后走收尾轮)、上游报错透传 |
742
+ | `tests/runner.test.mjs` | 运行器:会话创建与系统提示词、空输入报错、单轮/多工具循环、**一次 `runTurn` 内连续多次写入的重复挂起与续跑**、拒绝后不落盘 |
743
+ | `tests/config.test.mjs` | 配置解析优先级(`--flag > env > .env > 默认值`)、`--` 透传、非法数字不塞 NaN、`fail(e, { exit:false })` 不杀 REPL |
744
+ | `tests/sessionstore.test.mjs` | 会话落盘:目录选择、脏文件与 TTL、体积裁剪、`isSafeId` 防穿越、与 `runTurn` 的 `persist` 回调打通、挂起态恢复与续批 |
745
+ | `tests/commands.test.mjs` | 斜杠命令表逐项(含 `/model` / `/cost` / `/compact` / `/resume`)、`diffLines` 行级 diff、定价表 |
746
+ | `tests/model-select.test.mjs` | 「模型」只可选:默认项在首位、选项只来自接口、**当前值不在列表里时补项并保持选中(不悄悄换成别的模型)**、空列表只剩「默认 + 当前值」、重复模型名去重、`ensure()` 补项(对照真实 `<select>` 赋不存在的值会变空)、`match()` 大小写;以及真服务一侧:`GET /api/models` 拉不到上游时**也回 `default`**、`/models.js` 可取、两个页面都是 `<select>` 且没有 datalist 退路 |
747
+ | `tests/task-slash.test.mjs` | 任务页指令表(14 条):元数据与自检、解析口径(别名 / 大小写 / 全角斜杠 / `//` 转义 / 未知指令不发给模型)、**空前缀返回全部候选**(敲 `/` 就该看见全貌)、逐条行为、**`/model` 只认列表里的名字**(列表外被拒并列出可选项)、`commandRows()` 字段与 CLI 对齐、fs 闸门 |
748
+ | `tests/manual.test.mjs` | 操作手册页:`/manual` 与静态资源可访问、正文覆盖操作流程要点、**安装与启动一节(三条路线 + 首次配置 + 启动自检)与 `package.json` / 两份安装脚本 / `lib/common.js`(密钥别名、`.env` 查找顺序、默认网关地址)/ `lib/settings.js`(config.json 拒收密钥)/ `lib/secrets.js`(密钥文件名与落点)交叉一致,且「Web 服务没密钥也能起」与「命令行 agent 缺密钥即退」两半都写明**、`GET /api/commands` 一条不多一条不少地覆盖两张表、`GET /api/cost` 与 `lib/pricing.js` 逐字同口径,以及**「表里每一条都出现在手册里」**的回归闸门(含渲染后的文本断言) |
749
+ | `tests/mcp-batch.test.mjs` | **MCP ×「一批工具调用」**:模拟网关**真的**执行「带 tool_calls 的 assistant 消息必须被逐条回应」这条协议约束(破了就回 400,与真实网关一致),据此压两个实测踩到的问题——一批里排在被批准调用**后面**的调用原先既不执行也不回应、恢复时又只补一个 ⇒ 下一轮请求 400 打死整条任务;以及 MCP 原先被当「写入类」逐个要批准 ⇒ 非交互 `-p` 下所有 MCP 调用全被拒。同时守住反向边界:本机写入仍然必须批准、计划模式仍然拿不到 MCP 工具 |
750
+ | `tests/modes.test.mjs` | 三种审批模式:计划模式拿不到写入工具、硬写也被挡下且不落盘、自动模式直接执行并标 `auto`、自动模式仍守沙箱、非计划模式不误标 `planReady`、系统提示词按模式分流;以及挂起态落盘:TTL 清理、脏文件容错、id 防穿越、**换模块实例模拟重启后仍能取回待批准** |
751
+ | `tests/plandoc.test.mjs` | **计划落盘**:概要清洗与日期命名(`docs/YYYYMMDD-<概要>.md`)、`docs/` 不存在时自动创建、同名同内容幂等、同名异内容**只追加「更新」不覆盖**、概要里的 `../` 出不去工作目录、系统提示词按「有没有已落盘计划」分流;以及端到端:真起 hub 服务 + 模拟上游跑一轮计划模式,确认 SSE 发过 `plan_saved`、磁盘上真有那份计划、下一轮自动模式的 system 里带着它的路径、会话文件里记着 `planFile` |
752
+ | `tests/dom.test.mjs` | JS 引用的 DOM id 是否都在 HTML 中、`dom.xxx` 引用的 key 是否都定义过、服务端每种事件前端是否都处理、CSS 变量与 class 是否都有定义、两个页面的侧边栏是否同构、**两个页面的「模型」都是 `<select>` 且没有 datalist 退路**;以及**切任务不打断**的源码契约(`switchTask` 里不许再出现 `abort`、必须走缓存、落盘按任务走、停止只停当前那条) |
753
+ | `tests/taskstore.test.mjs` | 磁盘存储(`lib/taskstore.js`):路径探测与降级、id 防目录穿越、增删改查、15 天/20 条清理、原子写入、索引损坏重建、任务文件损坏容错、脏数据清洗、重开进程后数据仍在;以及**没改东西的保存不算更新**(时间戳不动、文件不重写,真变化才更新,任务文件丢了会补写) |
754
+ | `tests/task-store.test.mjs` | 任务页客户端逻辑:调接口的路径与方法对不对、草稿态不建任务、`ensureTask` 幂等、自动命名与重命名保护、从浏览器旧数据迁移、接口报错时有可读提示 |
755
+ | `tests/page-runtime.test.mjs` | 把 `task.js` 放进最小 DOM 垫片 + 假服务端里真跑一遍:`init()` 不抛异常、侧边栏渲染、**按工作目录分组**(同目录多任务、分组 `+` 新建、折叠/展开、切过去自动展开)、**模型下拉的选项来自 `/api/models`**(配置里的模型不在列表里时保留并选中、拉不到列表时只剩默认+当前值并说明原因)、存储路径展示、点「新任务」/切换/删除的完整请求路径、迁移浏览器旧数据、服务端报错时页面不崩;用「卡住的流」验证**切走不中断**(后台跑完并落盘到它自己的历史)、**切回走缓存不重新拉**、看别的任务时输入不被禁用;**点标题只换视图**(切走与切回都不产生 PUT、时间戳原封不动,内容真变了才落盘、同内容连存两次只发一次请求) |
756
+ | `tests/theme.test.mjs` | 主题与单端口契约:两套 CSS 里没有裸颜色、浅色覆盖了深色的**每一个**颜色变量、`task.css` 用到的变量都有定义、两个页面都在 `<head>` 里内联防闪白脚本且 key 与 `theme.js` 一致、`theme.js` 在 DOM 垫片里真跑(跟随系统 / 切换 / 记住 / 非法值容错 / 点按钮生效 / 系统变化在跟随模式下生效、明确选过后不再被覆盖)、`server.js` 与 `task-server.js` 是同一服务的薄入口、两页路由与两套接口都在同一个 handler 里、存储路径只在放行时才进配置 |
757
+ | `tests/tint.test.mjs` | 外部集成配色(`?bg=`):两页都在首次绘制前阻塞接上 `tint.js`、颜色解析(hex / rgb / hsl / 具名色,以及 `;`、`url()`、`var()` 这类注入写法一律拒绝)、**传默认深/浅底推导结果贴住默认那一套配色**、任意底色下的层次关系(深底加亮、浅底更亮、描边始终可见)、垫片里真跑(参数优先 / 同标签页记忆 / `?bg=none` 清除 / 认不出就忽略 / `sessionStorage` 不可用也能用 / 页内跳转带参数)、`theme.js` 遇到底色让位 |
758
+ | `tests/chat.test.mjs` | 聊天端到端:静态资源、目录穿越防护、真实流式对话(含思维链)、错误处理、配置不下发真实密钥与本机路径 |
759
+ | `tests/live.test.mjs` | 任务端到端:真实模型调用工具读写真实文件、批准后落盘、拒绝后不落盘、过期运行态返回 410 |
760
+ | `tests/escape.test.mjs` | 沙箱对抗:诱导真实模型尝试越界读写,验证被拦下且不泄漏(需要活的 `server.js` + 真模型,归在 live 组) |
761
+ | `tests/task-session.test.mjs` | 任务会话(方案 C)与历史预算:工具结果跨轮活下来、纯工具轮不被丢、旧任务懒迁移(含**带 `args` 时还原成原生 `tool_calls`**)、同一任务并发 409、请求校验 400、旧格式兼容开关、删任务连带删会话、`/api/store` 汇报会话占用;`history.maxChars` 超预算时压正文留记录、改配置立刻生效、`readFileDigest` 清单注入与「已被外部修改」告警 |
762
+ | `tests/settings.test.mjs` | 配置落盘:18 个键的默认值与往返、四层优先级与来源列、越界值/未知键(含模糊建议)、`config.json` 拒收密钥(改名夹带也拒)、`unset` 回默认、坏文件降级与拒绝覆盖、UI 状态键与 `config` 子命令(`list/get/set/unset/path`,含「被环境变量盖住」的警告)、`GET/PUT /api/settings` |
763
+ | `tests/secrets.test.mjs` | 免 `.env` 的密钥配置:密钥文件路径(跟数据根走 / `LLM_GATEWAY_SECRET_FILE` 覆盖)、读写往返与覆盖、POSIX `0600` 与「权限被放松」告警、坏文件降级、优先级(`--key`/env > 文件)、`keyRow` 只回掩码、`config set/get/list/unset/path key` 的落点,以及**没有密钥也能起服务** + 接口回 409 `needsKey` + 界面 PUT 一次即生效/手工改文件刷新即生效 |
764
+
765
+ ## 配置:住在磁盘上,CLI / Web / REPL 同一套
766
+
767
+ **优先级一句话**:`--flag` > 环境变量 > `config.json` > 内置默认;密钥是唯一的例外 —— `--key` / 环境变量 / `.env` > `credentials.json`,而它**永远不进 `config.json`**。
768
+
769
+ 配置文件的默认位置是 `~/.llm-api-gateway-cli/config.json`(Windows 也就是 `C:\Users\<你>\.llm-api-gateway-cli\config.json`;与任务、会话同一个数据根,整体想换地方用 `LLM_GATEWAY_DATA_DIR`),也可以用环境变量 `LLM_GATEWAY_CONFIG` 只把配置文件指到别处。文件不存在也没关系,第一次 `config set` 会自动建。
770
+
771
+ ```bash
772
+ gateway-agent config list # 列出所有键、当前值、以及「当前值来自哪一层」
773
+ gateway-agent config get history.maxChars # 单项详情:当前值/来源/默认值/说明/对应的环境变量与 flag
774
+ gateway-agent config set model qwen3:8b # 写入(原子写;值里有空格就整段写上)
775
+ gateway-agent config set system 你是一个严谨的助手 回答尽量简短
776
+ gateway-agent config unset temperature # 删掉这一项,回到内置默认
777
+ gateway-agent config path # 打印配置文件路径(同时打印密钥文件路径)
778
+ gateway-agent config set key sk-xxx # 密钥:写 credentials.json(不进 config.json),界面里填的也是它
779
+ gateway-agent config get key # 只回掩码 + 来源 + 文件路径,不回明文
780
+ ```
781
+
782
+ REPL 里同一套(模式六):`/config`、`/config set <键> <值>`、`/config unset <键>`。Web 页面的设置面板也读写同一份 —— 三个入口**不会各存一份**。
783
+
784
+ 任务页的「设置」面板里,模型 / 附加要求 / 温度 / 最大 tokens 是手写的四个运行参数(各有专门的控件);**白名单里其余的键全在下面的「其他配置」里** —— 网关地址、新任务默认审批模式、轮次上限、历史预算、`session.maxBytes`、保留策略、存储目录、`ui.persist`、`compat.legacyTaskApi`,以及**排在最前面的「密钥」**(`type: secret` → password 输入框,只在未配置时提示粘贴,已配置时显示掩码且留空 = 不改动)。这一块是**照服务端下发的白名单(`GET /api/settings` 的 `rows`)渲染的**,页面不再抄第二份:以后往 `SETTINGS_SCHEMA` 里加键,页面上会自动多出对应控件(`bool` → 复选框、`enum` → 下拉、`int` → 数字框),不会再出现「配置里有、设置页面没地方改」。每行标出当前值来自哪一层(配置文件 / 环境变量 / 启动参数,密钥行标的是密钥文件 / 环境变量),要重启才生效的键单独标「重启生效」。
785
+
786
+ 面板**宽度可以拖**:抓住它左边那条边往左拉就行(拖出来的宽度记在浏览器本地,`localStorage` 的 `lgw.settings.width`,跟配置文件无关)。底部「网关地址 / 可用工具 / 轮次上限…」那几行信息的值很长,面板窄了会显示省略号 —— 悬停能看到全文,觉得挤就拖宽一点。
787
+
788
+ **`config list` 的「来源」列是关键**:改了配置却没生效时,一眼就能看出当前值其实来自环境变量或命令行,而不是反复改文件试。
789
+
790
+ ```
791
+ 配置项 当前值 来源
792
+ key sk-abcd****wxyz(存 …\credentials.json) 密钥文件
793
+ model deepseek-v4-flash 环境变量
794
+ temperature (未设) 内置默认
795
+ store.dir * (未设) 内置默认
796
+ history.maxChars 60000 内置默认
797
+ ```
798
+
799
+ 命令还会主动提醒这类坑:`config set model xxx` 时若 `GATEWAY_MODEL` 已经设了,会直接告诉你「当前生效值来自环境变量 GATEWAY_MODEL,你写的值现在不会生效」。带 `*`(`store.dir` / `store.sessionDir` / `retention.days` / `retention.maxTasks`)的项要**重启服务**才生效,其余改完即时生效(Web 刷新一下页面即可)。
800
+
801
+ 密钥的口径变了(20260921):`--key` > `SK` / `GATEWAY_KEY` / `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` > `.env` > **密钥文件**。`config set key sk-xxx` **不再被拒绝**,它写的是 `~/.llm-api-gateway-cli/credentials.json`(仅属主可读),**不是 `config.json`** —— 页面「设置」里的密钥输入框写的是同一个文件,`config list` 的第一行就是它(只显示掩码),`config unset key` 删掉它。`config.json` 这条红线没变:它里面永远不会有密钥(改名夹带、或把 `sk-…` 塞进 `system` 都会被拒)。
802
+
803
+ | 键 | 默认值 | 说明 |
804
+ | --- | --- | --- |
805
+ | `model` | `qwen3:8b` | 默认模型名 |
806
+ | `baseUrl` | `http://127.0.0.1:9000` | 网关地址 |
807
+ | `temperature` / `maxTokens` | 未设 | 不设就不发这两个字段,由网关决定 |
808
+ | `system` | 空 | 附带的系统提示词 |
809
+ | `mode` | `manual` | 任务页默认审批模式 |
810
+ | `maxSteps` | 100 | 单个任务最多几轮模型调用 |
811
+ | `lastWorkDir` | 当前目录 | 下次打开任务页的默认工作目录 |
812
+ | `store.dir` / `store.sessionDir` * | 未设 | 任务目录 / 会话目录(后者可放到本机快盘) |
813
+ | `retention.days` / `retention.maxTasks` * | 15 / 20 | 任务过期天数与数量配额 |
814
+ | `session.maxBytes` | 8388608 | 单会话文件硬闸(防跑飞的兜底) |
815
+ | `history.maxChars` | 60000 | **工具历史预算**,超了就压较早的正文 |
816
+ | `history.keepRecentTurns` | 3 | 最近几轮工具结果保全文 |
817
+ | `history.readFileDigest` | `true` | 是否注入「已读文件清单」 |
818
+ | `ui.persist` | `server` | UI 偏好落盘还是只存浏览器(`browser`) |
819
+ | `compat.legacyTaskApi` | `true` | 旧格式 `/api/task`(**已废弃**,下个 minor 再评估) |
820
+
821
+ > 故意**不做成配置项**的东西:密钥、工具截断阈值、沙箱可达范围、挂起态 TTL、会话消息格式。这些是安全边界或协议,不是偏好。
822
+
823
+ ### 给工具切换器 / 集成方的「可识别形状」
824
+
825
+ 想让别的工具(配置切换器、会话管理器、启动器)认出并接上本 CLI,下面是**稳定下来的那几个约定** —— 它们不会因为内部重构而变:
826
+
827
+ | 形状 | 值 |
828
+ | --- | --- |
829
+ | 主命令 | `gateway-agent`(同一入口的别名 `llm-api-gateway-cli`;另有 `gateway-web` / `gateway-task` / `gateway-openai` / `gateway-anthropic` / `gateway-claude-code`) |
830
+ | 数据根目录 | `~/.llm-api-gateway-cli`(各平台一致;整根可用 `LLM_GATEWAY_DATA_DIR` 换地方) |
831
+ | 配置文件 | `<数据根目录>/config.json`,可用 `LLM_GATEWAY_CONFIG` 覆盖;schema 见本文件「配置」一节的键表与 `docs/20260916-迭代计划-记忆持久化与一键安装.md` §7.2 |
832
+ | 密钥文件 | `<数据根目录>/credentials.json`(界面 / `config set key` 写的那一个,`chmod 600`;可用 `LLM_GATEWAY_SECRET_FILE` 换地方)—— `config.json` 里永远没有密钥 |
833
+ | 任务会话 | `<任务存储目录>/sessions/<taskId>.json`(模型态消息;任务目录默认 `<数据根目录>/tasks`,`--store` 可改) |
834
+ | CLI 会话 | `<数据根目录>/sessions/<id>.json`(模式六的 `/resume`;`--session-dir` 可改) |
835
+ | 任务记录 | `<任务存储目录>/tasks/<id>.json` + `index.json`(渲染态消息,给人看的) |
836
+ | 挂起态 | `<任务存储目录>/pending/<runId>.json`(待批准的写入) |
837
+ | 搬家记号 | `<数据根目录>/.legacy-migrated.json`(从旧位置搬过一次就留,之后不再搬) |
838
+ | 环境变量 | 密钥 `SK` / `GATEWAY_KEY` / `OPENAI_API_KEY` / `ANTHROPIC_API_KEY`(优先 `--key`,也都优先于 `credentials.json`);`GATEWAY_MODEL`、`GATEWAY_BASE_URL`、`TASK_STORE_DIR`、`LLM_GATEWAY_DATA_DIR`、`WEB_PORT` / `TASK_WEB_PORT`、`LLM_GATEWAY_CONFIG`、`LLM_GATEWAY_SECRET_FILE` |
839
+ | HTTP(仅本机) | 配置读写 `GET/PUT/DELETE /api/settings`;任务与存储见下文「接口」 |
840
+
841
+ > **关于 cc-switch 这类切换器**:能不能被它检测,**不取决于本项目,取决于上游是否收录**。我们核对过 cc-switch 当前的 README,它列的是另外九款工具,**没有本 CLI**。所以这里只保证「形状稳定、可被识别」,并为上游收录提 issue/PR;不把它算作本项目的验收项。
842
+
843
+ ## Docker 容器化部署
844
+
845
+ 把整个应用打成一个镜像,用 Docker Desktop 起一个容器就能用:聊天页 `/` 与任务页 `/task` 都在里面,Agent 的「工作目录」映射到宿主机的某个目录。**本节命令全部是 Windows PowerShell 写法。**
846
+
847
+ | 新增文件 | 作用 |
848
+ | --- | --- |
849
+ | `Dockerfile` | 两阶段构建:`npm ci --omit=dev` 装依赖 + `node:22-bookworm-slim` 运行;非 root(`node` 用户)启动,带 `HEALTHCHECK` |
850
+ | `.dockerignore` | 把 `.env`(里面有 sk- 明文)、`node_modules`、`docs/`、`images/` 等挡在构建上下文外,**密钥不会被烤进镜像** |
851
+ | `docker-compose.yml` | 端口映射、环境变量、`/data` 数据卷、`/workspace` 工作目录、`host.docker.internal` 回宿主机的网关 |
852
+ | `docker.ps1` | Windows 启动脚本:`up / build / logs / status / shell / agent / test / down / clean` 一条命令搞定 |
853
+ | `docker-push.ps1` | 构建并推送到阿里云 ACR / Docker Hub(`-Tag` / `-DockerHub` / `-SkipAliyun` / `-SkipPush` / `-Login`) |
854
+ | `docs/20260921-发布步骤.md` | **维护者的发布 runbook**:改版本号 → 跑测试 → 打 tag → CI 发 Release → 手工 `npm publish` → 装一遍验证 → 故障表 / 回滚(与 `.github/workflows/release.yml`、两份安装脚本交叉断言) |
855
+
856
+ ### 前置条件
857
+
858
+ ```powershell
859
+ # 1) Docker Desktop 已安装并启动(任务栏鲸鱼图标为绿色)
860
+ docker version
861
+ docker compose version
862
+
863
+ # 2) 宿主机上的网关在跑(容器要能连到它);输出 True 即可
864
+ (Test-NetConnection 127.0.0.1 -Port 9000).TcpTestSucceeded
865
+
866
+ # 3) .env 里的密钥是真的(占位符 sk-请替换… 会在启动时被脚本警告)
867
+ Select-String -Path E:\AI\python\llm-api-gateway-cli\.env -Pattern '^GATEWAY_KEY='
868
+ ```
869
+
870
+ ### 一键启动(推荐)
871
+
872
+ 下面的命令**可以直接复制执行**:不用先 `cd`、不用改执行策略,在 PowerShell / cmd / Windows Terminal 里都能跑。仓库路径按你机器上的实际位置改。
873
+
874
+ ```powershell
875
+ # 构建镜像 + 后台启动 + 等就绪 + 打印访问地址
876
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 up
877
+
878
+ # 只构建镜像,不启动容器
879
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 build
880
+
881
+ # 强制不用缓存重新构建
882
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 build -Rebuild
883
+ ```
884
+
885
+ 启动后:
886
+
887
+ | 页面 | 地址 |
888
+ | --- | --- |
889
+ | 聊天(模式四) | http://127.0.0.1:3100/ |
890
+ | 任务(模式五) | http://127.0.0.1:3100/task |
891
+
892
+ 任务页里选工作目录时,选 **`/workspace`** —— 它就是映射进来的宿主机目录(默认正是本仓库)。
893
+
894
+ > 如果你已经 `cd` 到仓库目录、且终端允许执行脚本,上面所有命令都可以简写成 `.\docker.ps1 <动作>`(把 `powershell.exe -ExecutionPolicy Bypass -File <绝对路径>` 换掉即可)。
895
+
896
+ ### 启动脚本的全部动作
897
+
898
+ 下面每一行都能直接复制执行(前缀固定,不依赖当前目录):
899
+
900
+ ```powershell
901
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 up # 构建(必要时)+ 后台启动 + 等就绪
902
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 up -Rebuild # 强制不用缓存重新构建再启动
903
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 build # 只构建镜像
904
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 start # 启动已存在的容器
905
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 stop # 停止容器(不删,数据都在)
906
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 restart # 重启容器
907
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 down # 停止并删除容器(数据卷保留)
908
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 logs # 看最后 200 行日志
909
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 logs -Follow # 持续跟踪日志(Ctrl+C 只退出跟踪)
910
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 status # 状态 + 健康检查 + 端口探测
911
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 shell # 进容器内的 bash
912
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 agent # 容器里的原生 Agent CLI(交互)
913
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 agent -Prompt "总结 /workspace 的目录结构" # 一次性任务
914
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 agent -Prompt "跑一下测试" -AllowBash # 额外开 bash 工具
915
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 test # 容器里跑离线测试(npm test)
916
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 config # 打印最终 Compose 配置
917
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 clean -Force # 删容器 + 删数据卷(历史会丢)
918
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 help # 帮助
919
+ ```
920
+
921
+ ### 常用参数
922
+
923
+ ```powershell
924
+ # 换宿主机端口(容器里始终是 3100)
925
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 up -Port 3200
926
+
927
+ # 换模型 / 临时换密钥(不写进 .env,只作用于这次启动)
928
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 up -Model deepseek-v4-pro
929
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 up -Key sk-你的新密钥
930
+
931
+ # 把别的项目挂进容器当工作目录(容器内固定是 /workspace)
932
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 up -Workspace D:\code\my-app
933
+
934
+ # 让同局域网的设备也能访问(先读下面「安全边界」)
935
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 up -Bind 0.0.0.0
936
+
937
+ # 网关不在本机 9000,或跑在另一台机器上
938
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 up -GatewayBaseUrl http://192.168.1.20:9000
939
+ ```
940
+
941
+ | 参数 | 默认值 | 说明 |
942
+ | --- | --- | --- |
943
+ | `-Port` | `3100` | 宿主机监听端口 |
944
+ | `-Bind` | `127.0.0.1` | 宿主机绑定地址,默认只有本机能访问 |
945
+ | `-Workspace` | 本仓库目录 | 映射到容器 `/workspace` |
946
+ | `-GatewayBaseUrl` | `http://host.docker.internal:9000` | 容器内视角的网关地址 |
947
+ | `-Model` / `-Key` | 取 `.env` | 覆盖 `GATEWAY_MODEL` / `GATEWAY_KEY` |
948
+ | `-NpmRegistry` | 自动读宿主机的 `npm config get registry` | 构建时装依赖用的 npm 源 |
949
+ | `-Image` / `-Name` | `llm-api-gateway-cli:1.0.0` | 镜像名 / 容器名 |
950
+ | `-Rebuild` | 关 | 构建时不走缓存 |
951
+ | `-Follow` | 关 | `logs` 持续跟踪 |
952
+ | `-Force` | 关 | `clean` 免确认 |
953
+
954
+ ### 不用脚本:原生 Docker 命令
955
+
956
+ 脚本只是 `docker compose` 的包装,下面是等价的命令(`docker.ps1` 内部就是这些)。带上 `-f <compose 文件的绝对路径>` 后在任何目录都能直接执行:
957
+
958
+ ```powershell
959
+ # 构建(国内网络建议把宿主机用的 npm 源透进去,否则可能报 npm EINTEGRITY)
960
+ docker compose -f E:\AI\python\llm-api-gateway-cli\docker-compose.yml build
961
+ $env:LGW_NPM_REGISTRY = 'https://registry.npmmirror.com'; docker compose -f E:\AI\python\llm-api-gateway-cli\docker-compose.yml build
962
+
963
+ # 启动(后台)
964
+ docker compose -f E:\AI\python\llm-api-gateway-cli\docker-compose.yml up -d
965
+
966
+ # 看状态 / 日志
967
+ docker compose -f E:\AI\python\llm-api-gateway-cli\docker-compose.yml ps
968
+ docker compose -f E:\AI\python\llm-api-gateway-cli\docker-compose.yml logs -f --tail 200
969
+
970
+ # 停止并删除容器(数据卷保留)
971
+ docker compose -f E:\AI\python\llm-api-gateway-cli\docker-compose.yml down
972
+
973
+ # 连数据卷一起删(任务历史会丢)
974
+ docker compose -f E:\AI\python\llm-api-gateway-cli\docker-compose.yml down -v
975
+ ```
976
+
977
+ 不想要 Compose 时,也可以直接 `docker build` + `docker run`:
978
+
979
+ ```powershell
980
+ # 构建:--build-arg 不传就自动用你机器上 npm config get registry 的值
981
+ docker build -t llm-api-gateway-cli:1.0.0 E:\AI\python\llm-api-gateway-cli
982
+ docker build -t llm-api-gateway-cli:1.0.0 --build-arg NPM_REGISTRY=https://registry.npmmirror.com E:\AI\python\llm-api-gateway-cli
983
+
984
+ docker run -d `
985
+ --name llm-api-gateway-cli `
986
+ --restart unless-stopped `
987
+ -p 127.0.0.1:3100:3100 `
988
+ --add-host "host.docker.internal:host-gateway" `
989
+ -e GATEWAY_BASE_URL=http://host.docker.internal:9000 `
990
+ -e GATEWAY_KEY=sk-你的密钥 `
991
+ -e GATEWAY_MODEL=deepseek-v4-flash `
992
+ -v llm-api-gateway-data:/data `
993
+ -v "E:\AI\python\llm-api-gateway-cli:/workspace" `
994
+ llm-api-gateway-cli:1.0.0
995
+ ```
996
+
997
+ > `-e` 传进去的变量优先级高于镜像内的默认值;镜像本身不含任何密钥。
998
+
999
+ ### 推送到阿里云 ACR / Docker Hub
1000
+
1001
+ `docker-push.ps1` 负责「构建 → 打标签 → 推送」,命令风格与 `docker-build-all.ps1` 一致:
1002
+
1003
+ ```powershell
1004
+ # 首次先登录(凭据存进 ~\.docker\config.json,之后不用再登;-Login 会交互式提示账号密码)
1005
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker-push.ps1 -Login
1006
+
1007
+ # 构建 + 打标签 + 推送阿里云(默认只推阿里云)—— 这就是「构建镜像并推送」的那条命令
1008
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker-push.ps1
1009
+
1010
+ # 指定版本号(会同时打一个 latest)
1011
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker-push.ps1 -Tag v1.0.0
1012
+
1013
+ # 同时推 Docker Hub
1014
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker-push.ps1 -DockerHub
1015
+
1016
+ # 只推 Docker Hub,不推阿里云
1017
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker-push.ps1 -DockerHub -SkipAliyun
1018
+
1019
+ # 只构建 + 打标签,先不推(本地验证用)
1020
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker-push.ps1 -SkipPush
1021
+
1022
+ # 强制不用缓存重新构建
1023
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker-push.ps1 -Tag v1.0.0 -NoCache
1024
+ ```
1025
+
1026
+ 默认镜像名与目标仓库(都可用参数覆盖):
1027
+
1028
+ | 目标 | 镜像地址 |
1029
+ | --- | --- |
1030
+ | 本地 | `llm-api-gateway-cli:<Tag>` |
1031
+ | 阿里云 ACR | `crpi-8anss0ka7g2biva3.cn-chengdu.personal.cr.aliyuncs.com/hrgk/llm-api-gateway-cli:<Tag>` |
1032
+ | Docker Hub | `boonyadocker/llm-api-gateway-cli:<Tag>` |
1033
+
1034
+ | 参数 | 默认值 | 说明 |
1035
+ | --- | --- | --- |
1036
+ | `-Tag` | `latest` | 镜像标签;不是 `latest` 时会额外打一个 `latest` |
1037
+ | `-DockerHub` | 关 | 是否推 Docker Hub(不传则只推阿里云) |
1038
+ | `-SkipAliyun` | 关 | 跳过阿里云 |
1039
+ | `-SkipPush` | 关 | 只构建 + 打标签,不推送 |
1040
+ | `-Login` | 关 | 推送前先 `docker login`(交互式,密码不会出现在命令历史里) |
1041
+ | `-ImageName` | `llm-api-gateway-cli` | 镜像名 |
1042
+ | `-DockerHubUser` | `boonyadocker` | Docker Hub 命名空间 |
1043
+ | `-AliyunRepo` | `.../hrgk` | 阿里云 ACR 命名空间(含地域域名) |
1044
+ | `-Platform` | `linux/amd64` | 目标平台,服务器是 x86 就保持默认 |
1045
+ | `-NpmRegistry` | 自动读宿主机的 `npm config get registry` | 构建时用的 npm 源 |
1046
+ | `-NoCache` | 关 | 构建不走缓存 |
1047
+
1048
+ 推送完成后,在部署机器上拉取运行:
1049
+
1050
+ ```powershell
1051
+ # 部署机上(记得先登录同一个仓库)
1052
+ docker pull crpi-8anss0ka7g2biva3.cn-chengdu.personal.cr.aliyuncs.com/hrgk/llm-api-gateway-cli:v1.0.0
1053
+
1054
+ # 或者直接让本仓库的 compose 用推送上去的镜像,而不是本地重新构建
1055
+ $env:LGW_IMAGE = 'crpi-8anss0ka7g2biva3.cn-chengdu.personal.cr.aliyuncs.com/hrgk/llm-api-gateway-cli:v1.0.0'
1056
+ docker compose -f E:\AI\python\llm-api-gateway-cli\docker-compose.yml pull
1057
+ docker compose -f E:\AI\python\llm-api-gateway-cli\docker-compose.yml up -d --no-build
1058
+ ```
1059
+
1060
+ > 阿里云个人版 ACR **不会自动建仓库**:先到容器镜像服务控制台建好命名空间 `hrgk` 与仓库 `llm-api-gateway-cli`,否则推送会报 `denied: requested access to the resource is denied`。
1061
+ > 镜像里不含任何密钥,推公共仓库也安全;`.env` 被 `.dockerignore` 挡在构建上下文外。
1062
+ > 脚本任一次推送失败都会在结尾汇总并以退出码 1 结束,方便接 CI。
1063
+ > 脚本构建时固定加了 `--provenance=false --sbom=false`:buildx 默认附带的证明清单阿里云个人版 ACR 不认,会报 `unknown manifest class for application/vnd.oci.empty.v1+json`。
1064
+ > Docker Hub 需要先 `docker login`(用户名 `boonyadocker`,密码用访问令牌);本机目前只登录了阿里云。
1065
+
1066
+ ### 容器内的配置从哪来
1067
+
1068
+ | 变量 | 容器里的值 | 说明 |
1069
+ | --- | --- | --- |
1070
+ | `GATEWAY_BASE_URL` | `http://host.docker.internal:9000` | **容器里的 `127.0.0.1` 是容器自己**,所以必须走 `host.docker.internal` 回到宿主机的网关 |
1071
+ | `GATEWAY_KEY` / `GATEWAY_MODEL` | 取自宿主机 `.env` | Compose 会自动读本目录的 `.env` 做变量替换,无需额外配置 |
1072
+ | `TASK_STORE_DIR` | `/data/tasks` | 任务记录落盘位置(挂到数据卷里) |
1073
+ | `LLM_GATEWAY_DATA_DIR` | `/data` | 数据根目录(任务以外的配置、CLI 会话都挂在它下面) |
1074
+ | `WEB_HOST` / `WEB_PORT` | `0.0.0.0` / `3100` | 容器内必须听 `0.0.0.0`,否则端口映射进不来 |
1075
+
1076
+ 想改默认的模型/模式,直接改宿主机的 `.env` 后重启容器即可:
1077
+
1078
+ ```powershell
1079
+ notepad E:\AI\python\llm-api-gateway-cli\.env
1080
+ powershell.exe -ExecutionPolicy Bypass -File E:\AI\python\llm-api-gateway-cli\docker.ps1 restart
1081
+ ```
1082
+
1083
+ ### 数据持久化
1084
+
1085
+ | 位置 | 内容 | 生命周期 |
1086
+ | --- | --- | --- |
1087
+ | Docker 卷 `llm-api-gateway-data` → `/data` | 任务历史(`/data/tasks`)、待批准挂起态、CLI 会话 | `down` 不删;只有 `.\docker.ps1 clean -Force` 或 `docker compose down -v` 才删 |
1088
+ | 宿主机目录 → `/workspace` | 模型真正读写的项目文件 | 就在你的磁盘上,容器删了也在 |
1089
+
1090
+ ```powershell
1091
+ # 看一眼卷在哪、多大
1092
+ docker volume inspect llm-api-gateway-data
1093
+
1094
+ # 备份数据卷到当前目录(PowerShell 写法)
1095
+ docker run --rm -v llm-api-gateway-data:/data -v "${PWD}:/backup" alpine tar czf /backup/gateway-data.tar.gz -C /data .
1096
+ ```
1097
+
1098
+ ### 安全边界(务必看一眼)
1099
+
1100
+ - 容器里的服务监听 `0.0.0.0`,并带 `--allow-remote-fs` —— 任务页因此能浏览**容器内**的文件系统、读写 `/workspace`。这是任务模式能用的前提,隔离边界是**容器本身**。
1101
+ - 端口默认只发布到宿主机的 `127.0.0.1`,所以外面访问不到。**不要**随手改成 `-Bind 0.0.0.0` 就完事:这个服务没有登录鉴权,等于把「容器内的文件读写能力」开放给整个局域网。
1102
+ - Agent 的沙箱是「工作目录之内」:路径上跳、绝对路径、符号链接逃逸都会被 `lib/tools.js` 拦下;`bash` 工具默认关闭(CLI 的 `cli-agent.js` 用 `--allow-bash`,任务页的 `task-server.js` / `server.js` 同样是这个旗标)。
1103
+ - 容器以非 root 的 `node` 用户运行,且 `/data`、`/workspace` 的属主已交给它。
1104
+
1105
+ ### 排错
1106
+
1107
+ | 现象 | 处理 |
1108
+ | --- | --- |
1109
+ | `Docker 引擎没有响应` | Docker Desktop 没启动,等鲸鱼图标变绿再试 |
1110
+ | 构建时 `failed to solve` / 拉不动基础镜像 | 给 Docker Desktop 配镜像加速,或 `docker pull node:22-bookworm-slim` 先手动拉 |
1111
+ | 构建时 `npm error code EINTEGRITY` | 容器走了 `registry.npmjs.org` 而不是你宿主机的源:`.\docker.ps1 build -NpmRegistry https://registry.npmmirror.com` |
1112
+ | 推送时 `TLS handshake timeout` / `broken pipe`(目标 `...:3128`) | Docker Desktop 继承了系统代理,而那个代理没开(本机 WinINET 指向已失效的 `127.0.0.1:7890`)。关掉系统代理,或在 Docker Desktop → Settings → Resources → Proxies 里把 `*.aliyuncs.com` 加进 bypass 列表;也可以隔一会儿重试(已推上去的层会跳过) |
1113
+ | 推送时 `unknown manifest class for application/vnd.oci.empty.v1+json` | buildx 的证明清单,个人版 ACR 不支持;`docker-push.ps1` 已自动加 `--provenance=false --sbom=false`,手动推时自己加上 |
1114
+ | 页面能开但发消息报「连接网关失败」 | 容器连不到宿主机网关。先自查:`.\docker.ps1 shell`,然后 `curl -sS -o /dev/null -w "%{http_code}\n" $GATEWAY_BASE_URL/v1/models -H "Authorization: Bearer $GATEWAY_KEY"`;`000` 就是不通。确认宿主机网关在跑,且没被防火墙拦下 |
1115
+ | 网关只监听 `127.0.0.1:9000` 仍然连不上 | 少数环境下 `host.docker.internal` 到不了宿主机的回环地址:让网关改听 `0.0.0.0:9000`,或用 `-GatewayBaseUrl http://<宿主机局域网IP>:9000` |
1116
+ | `端口 3100 已被占用` | `.\docker.ps1 up -Port 3200` |
1117
+ | 任务页选目录只有容器内的路径 | 这是正常的:任务页看到的是**容器内**的文件系统,你挂进来的目录是 `/workspace` |
1118
+ | 想确认变量到底生效没有 | `.\docker.ps1 config` 看最终解析结果 |
1119
+
1120
+ ## 常见问题
1121
+
1122
+ - **401**:密钥不是网关自己发放的 `sk-`(不是上游/中转方 Key)。到网关「密钥管理」创建并绑定上游后再用。
1123
+ - **404 模型 not found**:密钥未绑定上游、回落到了默认上游(Ollama 等)。核对「密钥管理」里的上游绑定,并用 `--list-models` 或 `--smoke` 确认模型名。
1124
+ - **模型名**:按密钥绑定的上游选择 —— Ollama 常用 `qwen3:8b` / `qwen3:8b-nothink`;DeepSeek 常用 `deepseek-v4-pro` / `deepseek-v4-flash`(详见网关 `USEAGE.md`)。
1125
+ - **模式六只出思维链没有正文**:思考模型会先把 token 花在 `reasoning_content` 上,`--max-tokens` 给小了就会如此。调大 `--max-tokens` 再试。
1126
+ - **想接着上次的会话继续**:`node cli-agent.js -i --continue`(或 `--resume <id>`)。会话默认存在 `~/.llm-api-gateway-cli/sessions`,`--session-dir` 可换地方,`--no-session` 完全关闭落盘。
1127
+ - **Web 服务怎么关 / 端口被占**:见上面「[关闭服务](#关闭服务)」。一句话版 —— 同一个终端里 `Ctrl+C`;找不到终端就 `netstat -ano | findstr :3100` 拿 PID 再 `Stop-Process -Id <PID> -Force`(Linux/macOS 用 `lsof -i :3100` 或 `fuser -k 3100/tcp`)。