Commit 588934a8730fdfcb067eda499524c559907eb889
1 parent
084d1716
补写 HANDOFF.md 交接文档 + h3-console 公网子路径接入完整改动
HANDOFF.md 加了 2026-08-20 夜的最新状态一节(前置,不覆盖历史):记录当前各服务 实际运行状态、今天做的十项改动摘要、以及一个重要发现——qwen.wudaima.com 现在 实际上是 minimax-h3 的 gateway.py 在占用(llm38 容器当前未运行),不是 Qwen LLM。 代码改动(此前几轮已分别验证部署,这次一并入库): - h3-console/server.py、static/index.html:H3_CONSOLE_BASE_PATH 子路径支持, 用于挂到 qwen.wudaima.com/console - h3-console/README.md:同步子路径部署说明、gateway.wudaima.com 排查结论 - minimax-h3/api.py:ROWS_LIMIT 支持环境变量覆盖(边界探测用) - CLAUDE.md:当前负载表格更新 h3-console 的双实例/公网访问方式
Showing
8 changed files
with
268 additions
and
43 deletions
.gitignore
CHANGELOG.md
| 1 | 1 | # 变更记录 |
| 2 | 2 | |
| 3 | +## 2026-08-20(夜,又续四)— h3-console 公网访问改走 qwen.wudaima.com/console 子路径 | |
| 4 | + | |
| 5 | +**背景**:`gateway.wudaima.com` 那条独立域名线路确认走不通(缺口在 60.205.214.169 那台 frp | |
| 6 | +服务端,本机改不了,见上一节)。用户决定不等那边修,改用已经确认能打通的 `qwen.wudaima.com` | |
| 7 | +域名,把 h3-console 挂在它的一个子路径下(`/console`),不影响它现在对外的 Qwen LLM 接口。 | |
| 8 | + | |
| 9 | +**结果(三处改动,缺一不可)**: | |
| 10 | + | |
| 11 | +1. **`h3-console/server.py` 加子路径支持**:新增 `H3_CONSOLE_BASE_PATH` 环境变量(默认空, | |
| 12 | + 行为不变)。所有 `/v1/*` 接口从直接挂在 `app` 上改成先挂到一个 `APIRouter`,最后 | |
| 13 | + `app.include_router(router, prefix=BASE_PATH)` 整体套前缀;`/assets` 静态挂载点、 | |
| 14 | + `_row_view()` 里的 `poll`/`download` 字段也跟着加前缀。三个页面路由(`/`、`/task.html/{tid}`、 | |
| 15 | + `/newtask.html`)不再直接 `FileResponse` 原文件,改成读进来做一次字符串替换,把 | |
| 16 | + `index.html` 里的占位符 `__H3_BASE_PATH__` 换成实际前缀。 | |
| 17 | +2. **`static/index.html` 前端**:加一个 `const H3_BASE = "__H3_BASE_PATH__"`,把原来所有硬编码 | |
| 18 | + 的绝对路径(13 处:`fetch('/v1/...')`、`history.pushState(...'/task.html/'...)`、下载/播放 | |
| 19 | + 用的 `/v1/videos/{id}/file` 等)都在前面拼上 `H3_BASE`。`H3_BASE` 留空时全部退化成原来的 | |
| 20 | + 写法,本地开发和局域网直连(`http://192.168.110.67:8080`)行为完全不变——这两处改动上线前 | |
| 21 | + 都用本地 uvicorn 分别起了 `BASE_PATH=""` 和 `BASE_PATH="/console"` 两种模式验证过, | |
| 22 | + 根路径/task.html/newtask.html/`/v1/backends` 全部行为符合预期才部署。 | |
| 23 | +3. **架构上跑两个 console 进程,不是改一个**:`BASE_PATH` 是进程级的全局设置,一个进程只能要么 | |
| 24 | + 服务根路径要么服务子路径,两者不能共存。所以局域网那个 `:8080` 实例(`BASE_PATH` 留空)不动, | |
| 25 | + 另起一个 `:8081` 实例专门服务子路径(`H3_CONSOLE_BASE_PATH=/console`,不开 | |
| 26 | + `H3_ENABLE_SUPERVISOR`——两个调度器同时抢着 flip 分区会重现"一个请求切三张卡"那个老 bug, | |
| 27 | + 弹性调度这件事只能有一个进程做主)。两个进程指向同一个工作目录,**共享同一个 | |
| 28 | + `console.sqlite3` 和 `assets/`**,所以从局域网和从公网子路径看到的任务列表、上传的图片是 | |
| 29 | + 同一份,不会分叉成两份数据。`H3_CONSOLE_PUBLIC_BASE` 两个实例都设成局域网地址 | |
| 30 | + `http://192.168.110.67:8080`(不是公网域名)——图片上传后是 `api.py`(同一台机器上的 GPU | |
| 31 | + worker)反向拉取,走局域网地址最直接可靠,没必要绕一圈公网域名和陌生的上游 nginx。 | |
| 32 | + | |
| 33 | +**frp 配置**:`/home/admin/llm/frp/frpc.qwen.ini` 里新增一段(改前已经 `cp` 了一份 | |
| 34 | +`.bak-<时间戳>` 备份): | |
| 35 | +``` | |
| 36 | +[qwen-console-subpath] | |
| 37 | +type = http | |
| 38 | +local_ip = 127.0.0.1 | |
| 39 | +local_port = 8081 | |
| 40 | +remote_port = 7002 | |
| 41 | +custom_domains = qwen.wudaima.com | |
| 42 | +locations = /console | |
| 43 | +``` | |
| 44 | +`custom_domains` 跟 `qwen-llm` 那条完全一样,`locations` 不同——frp 按路径前缀做子路由,没 | |
| 45 | +声明 `locations` 的 `qwen-llm` 相当于兜底捕获所有其它路径,两条互不冲突。frpc 由 PM2 管着 | |
| 46 | +(进程名 `frpc-qwen-client`),用 `pm2 restart frpc-qwen-client`(不是 `kill`)让它读新配置, | |
| 47 | +重启后确认 `qwen-llm`/`qwen-gw-20260610`/`qwen-console-subpath` 三个代理全部 `start proxy | |
| 48 | +success`。 | |
| 49 | + | |
| 50 | +**验证**:`qwen.wudaima.com/health`(LLM,200)没受影响;`qwen.wudaima.com/console/` 拿到 | |
| 51 | +真实的 `H3 Console` 页面(不再是 nginx 默认页);`/console/v1/backends`、`/console/task.html/ | |
| 52 | +<真实任务id>`、`/console/newtask.html` 都返回 200 且内容正确;页面里 `H3_BASE` 占位符确认被 | |
| 53 | +替换成了 `"/console"`。 | |
| 54 | + | |
| 55 | +⚠️ 这次动了共享的 `frpc-qwen-client` 进程(`qwen.wudaima.com` 生产线路用的同一个),重启会有 | |
| 56 | +几秒重连间隙——跟上一次重启一样,属于已经跟用户确认过的操作。 | |
| 57 | + | |
| 58 | +--- | |
| 59 | + | |
| 60 | +## 2026-08-20(夜,又续三)— 排查清楚:gateway.wudaima.com 公网转发为什么一直不通 | |
| 61 | + | |
| 62 | +**背景**:用户问"服务器的反向代理地址能不能访问我们的服务",实测 `http://gateway.wudaima.com/` | |
| 63 | +拿到的是 nginx 默认欢迎页(`Test Page for the Nginx HTTP Server on Fedora`),`/v1/backends` | |
| 64 | +是 nginx 默认 404 页——都不是 h3-console 的内容。局域网 `http://192.168.110.67:8080` 直连完全 | |
| 65 | +正常,两边一对比确认:**这条公网线路目前没有真的通到我们的服务**,跟 README 早先写的"配置已存在、 | |
| 66 | +一直空挂,还没验证公网访问"是同一件事,这次是第一次实测坐实。 | |
| 67 | + | |
| 68 | +**排查结论(重要:不是本机这边的配置问题)**: | |
| 69 | +- `/home/admin/llm/frp/frpc.qwen.ini` 里 `[qwen-gw-20260614]` 这段配置是对的 | |
| 70 | + (`local_port=8080` 对准 h3-console,`custom_domains=gateway.wudaima.com`)。 | |
| 71 | +- 这个 frpc 由 PM2 管着(进程名 `frpc-qwen-client`,同一个进程也扛着 `qwen.wudaima.com` 那条 | |
| 72 | + 生产线路,**PM2 托管的进程不能 `kill`,得 `pm2 restart`**——踩过这个坑,这次没再踩)。 | |
| 73 | +- `pm2 restart frpc-qwen-client` 后确认 `qwen.wudaima.com` 没受影响(仍 200),但 `gateway. | |
| 74 | + wudaima.com` 还是拿到 nginx 默认页——**排除了"只是没重启生效"这个假设**。 | |
| 75 | +- 翻 pm2 日志发现关键信息:`qwen-gw-20260614` 这条代理其实**从 8月14日就已经 `start proxy | |
| 76 | + success` 了**(不是这次才注册),frpc 客户端这边一直是通的、一直在正常向 frps 服务端报到。 | |
| 77 | + 也就是说 frps(`60.205.214.169:7009`)**已经接受了这条代理注册**。 | |
| 78 | + | |
| 79 | +**结论**:既然 frpc 客户端注册早就成功,但公网请求还是落到 nginx 默认页而不是 frps 路由过去的 | |
| 80 | +内容,缺口在 `60.205.214.169` 那台 frp 服务端本身——大概率是它前面(或本机)还有一层 nginx | |
| 81 | +按 Host 头转发到 frps 的 vhost 端口,`qwen.wudaima.com` 配了这层转发、`gateway.wudaima.com` | |
| 82 | +没配。**这台机器(192.168.110.67)没有权限也没有信息去改那台服务器**,需要能登录 | |
| 83 | +`60.205.214.169` 的人补一条 `gateway.wudaima.com` → frps vhost 端口的 nginx 转发规则。 | |
| 84 | +在此之前,h3-console 只能走局域网 `http://192.168.110.67:8080` 访问。 | |
| 85 | + | |
| 86 | +--- | |
| 87 | + | |
| 3 | 88 | ## 2026-08-20(夜,又续二)— api.py 任务记录改成落盘,不再随进程重启清空 |
| 4 | 89 | |
| 5 | 90 | **背景**:`h3-console` 的弹性调度器会常规性地 kill+restart `api.py` 进程来切 fl2va/ref2va 分区, | ... | ... |
CLAUDE.md
| ... | ... | @@ -80,7 +80,7 @@ cat llm-setup.md | ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 'cat > /h |
| 80 | 80 | |------|--------|----|------|------| |
| 81 | 81 | | `deploy-scripts/` | Qwen3.8-27B-Q5_K_M(llama.cpp,容器 `llm38`) | 0-3 分片 | **57.3 GB**(256K 上下文,2 槽位) | 8000 → `qwen.wudaima.com` | |
| 82 | 82 | | `minimax-h3/` | MiniMax-H3 FL2VA/Ref2VA NF4 文生视频,`api.py` 常驻四实例(GPU0-3,端口 8010-8013)+ `gateway.py` 调度层(端口 8000) | 四卡各一进程 | 单卡 fp32 峰值约 9-14 GB(视时长/分辨率),另占 CPU 内存约 32 GB/worker | 8000(内部,gateway.py) | |
| 83 | -| `h3-console/` | H3 Web 管理台:上传/生成/预览三界面 + 任务持久化 + fl2va/ref2va 弹性调度器 | 不占显存(自己不跑模型,管理 minimax-h3 的四卡) | — | 8080 → `gateway.wudaima.com`(隧道已配好,公网访问未验证) | | |
| 83 | +| `h3-console/` | H3 Web 管理台:上传/生成/预览三界面 + 任务持久化 + fl2va/ref2va 弹性调度器。两个进程共享同一份 sqlite/assets:**8080**(局域网直连,`BASE_PATH` 空,开弹性调度器)+ **8081**(`H3_CONSOLE_BASE_PATH=/console`,不开调度器,专供公网子路径用) | 不占显存(自己不跑模型,管理 minimax-h3 的四卡) | — | 局域网 `http://192.168.110.67:8080`;公网 `http://qwen.wudaima.com/console`(frp `locations=/console` 分流,`gateway.wudaima.com` 那条独立域名确认走不通,缺口在 frp 服务端 60.205.214.169,见 CHANGELOG) | | |
| 84 | 84 | | (常驻) | whisper | GPU3 | 3.2 GB | — | |
| 85 | 85 | |
| 86 | 86 | 排卡前先 `nvidia-smi` 看实际余量:GPU3 被 whisper 占着,**llama.cpp 默认 tensor-split 按总显存而非剩余显存均分**,起新负载不加 `-ts` 容易把公网服务挤 OOM。 | ... | ... |
HANDOFF.md
| 1 | -# 交接文档 · 2026-08-18 | |
| 1 | +# 交接文档 · 2026-08-20(夜,最新) | |
| 2 | 2 | |
| 3 | 3 | > 给下次进来的人(或进程)。**先读这份,再读 `CLAUDE.md`。** |
| 4 | -> 权威细节在 `llm-setup.md`(整机)和 `minimax-h3/README.md`(视频线), | |
| 5 | -> 时间线在 `CHANGELOG.md`。这里只写「现在是什么状态、下一步该干什么」。 | |
| 4 | +> 权威细节在 `llm-setup.md`(整机)、`minimax-h3/README.md`(视频线)、 | |
| 5 | +> `h3-console/README.md`(Web 管理台)。时间线在 `CHANGELOG.md`。 | |
| 6 | +> 这里只写「现在是什么状态、下一步该干什么」——下面 2026-08-18/19 那几节是历史记录, | |
| 7 | +> 仍有效的踩坑经验保留,但**状态类的表格/结论以这一节为准,不要用旧的**。 | |
| 8 | + | |
| 9 | +## 2026-08-20 夜:现在是什么状态 | |
| 10 | + | |
| 11 | +**一句话**:`h3-console`(Web 管理台)今天从零做到部署上线并经过好几轮真实使用反馈迭代 | |
| 12 | +(重跑、回收站、任务记录落盘、假运行中修复、公网子路径接入),本地代码已建 git 仓库推到 | |
| 13 | +`git@gitlab-wuwei.hexpm.com:yibo/2080tiserver.git`。**同时发现一个容易踩的坑**: | |
| 14 | +`qwen.wudaima.com` 这个域名现在实际没有在提供 Qwen LLM 服务——见下面「重要」。 | |
| 15 | + | |
| 16 | +### ⚠️ 重要:qwen.wudaima.com 现在不是 LLM,是 minimax-h3 的 gateway.py | |
| 17 | + | |
| 18 | +- `docker ps` 是空的——**`llm38` 容器现在没有在跑**(不是本次改的,是之前某个时间点被停的, | |
| 19 | + 可能是之前的会话为了腾显存给 H3 四卡停的,没有记录明确是谁什么时候停的)。 | |
| 20 | +- 端口 8000 现在被 `minimax-h3/gateway.py` 占着(`ss -lptn` 能看到是个 python 进程,不是 | |
| 21 | + llama.cpp)。frp 的 `qwen-llm` 代理转发的正是本机 8000 端口,所以**公网 | |
| 22 | + `http://qwen.wudaima.com/health` 现在返回的是 gateway.py 的 JSON(`{"ready":4,...,"backends": | |
| 23 | + [...]}`),不是 Qwen 的 `/health`**,`qwen.wudaima.com/v1/videos` 现在也是能直接无鉴权提交 | |
| 24 | + 视频生成任务的(跳过了 h3-console 那层)。 | |
| 25 | +- **要恢复 Qwen LLM 公网服务**:先确认 minimax-h3 的四个 worker 不在关键任务中途, | |
| 26 | + 停掉占着 8000 的 gateway.py(`ss -lptn "sport = :8000" | grep -o "pid=[0-9]*"` 找 PID | |
| 27 | + 再 `kill`——不是 PM2 托管的,直接 kill 有效),再 `docker start llm38`(约 15 秒)。 | |
| 28 | + 两者不能共存在同一个 8000 端口上。 | |
| 29 | + | |
| 30 | +### 当前各服务实际状态(2026-08-20 深夜实测,不是文档推测) | |
| 31 | + | |
| 32 | +| 服务 | 状态 | 说明 | | |
| 33 | +|---|---|---| | |
| 34 | +| `llm38`(Qwen3.8-27B,docker) | **停**(`docker ps` 为空) | 恢复见上面「重要」一节 | | |
| 35 | +| `whisper`(docker,8002) | **停** | 同上,docker 里没有容器在跑 | | |
| 36 | +| PM2 `m3e` / `sd-forge` | **停** | 沿用 2026-08-19 就定的目标状态,没有变化 | | |
| 37 | +| minimax-h3 四个 `api.py` worker(8010-8013) | **在跑** | 分区会被弹性调度器动态 flip,此刻实测是 3 张 fl2va + 1 张 ref2va(GPU0 ref2va,GPU1/2/3 fl2va),不是固定的,正常 | | |
| 38 | +| `minimax-h3/gateway.py`(8000) | **在跑**,占着 `qwen.wudaima.com` | 见上面「重要」 | | |
| 39 | +| `h3-console`(8080) | **在跑** | 局域网直连 `http://192.168.110.67:8080`,开着弹性调度器(`H3_ENABLE_SUPERVISOR=1`) | | |
| 40 | +| `h3-console`(8081,子路径专用) | **在跑** | 公网 `http://qwen.wudaima.com/console`,`H3_CONSOLE_BASE_PATH=/console`,**不开**弹性调度器(两个调度器同时抢会重现"一个请求切三张卡"的老 bug),跟 8080 那个共享同一份 `console.sqlite3`/`assets/` | | |
| 41 | +| GPU 显存(`nvidia-smi`) | GPU0 5.3G / GPU1 21.3G / GPU2 19.4G / GPU3 18.5G(共 88G) | llm38 停着,所以余量看着比 CLAUDE.md 顶部旧表格写的宽松,但四张卡跑满 minimax-h3 也会吃紧,起新负载前还是先看一眼 | | |
| 42 | + | |
| 43 | +### 今天做的事(详细踩坑记录都在 CHANGELOG.md,这里只列条目) | |
| 44 | + | |
| 45 | +1. h3-console 任务详情页加「重跑」按钮(第一版直接后台重投,被否决后改成回填表单跳转新建页)。 | |
| 46 | +2. 修复「后端进程重启后任务永远显示假运行中」——`_refresh_from_gateway` 遇到 404 现在正确判 `failed`。 | |
| 47 | +3. `api.py` 的 `frames` 硬顶 124 改成按 `rows`(分辨率×帧数)动态判定,`ROWS_LIMIT=20000` | |
| 48 | + (支持 `H3_ROWS_LIMIT` 环境变量临时覆盖,仅供边界探测用)。 | |
| 49 | +4. `start-all.sh` 冷启动默认分区从「4 张全 ref2va」改成 2/2 固定分裂(GPU0/1 ref2va,GPU2/3 fl2va)。 | |
| 50 | +5. 左下角后端状态行补上 `running×N`/`queued×N`,之前明明有任务在跑也只显示 `ok`。 | |
| 51 | +6. `api.py` 任务记录改成落盘(`jobs_gpu{N}.sqlite3`),弹性调度器 kill+restart 某张卡时不再 | |
| 52 | + 把这张卡上正在跑的任务从内存里一起清空。 | |
| 53 | +7. 任务列表加软删除/回收站(`deleted_at` 列 + `?trash=true` + 恢复接口)。 | |
| 54 | +8. 排查清楚 `gateway.wudaima.com` 独立域名为什么公网打不通——缺口在 frp 服务端 | |
| 55 | + `60.205.214.169`,不是这台机器能改的,**没有再等它**。 | |
| 56 | +9. 改走 `qwen.wudaima.com/console` 子路径对外开放 h3-console(`H3_CONSOLE_BASE_PATH` 支持 | |
| 57 | + + 起第二个 8081 实例 + frp `locations=/console` 分流),验证通过、`qwen.wudaima.com` 原有 | |
| 58 | + 服务没受影响(因为它现在本来就不是 LLM,见上面「重要」)。 | |
| 59 | +10. 补齐了本地 `minimax-h3/` 镜像里此前一直缺失的 `gateway.py` 等 13 个文件(和服务器漂移的 | |
| 60 | + 缺口),本地建了 git 仓库并推到 `git@gitlab-wuwei.hexpm.com:yibo/2080tiserver.git`。 | |
| 61 | + `CLAUDE.md` 里明文写着的 SSH 密码也一起提交了,跟用户确认过、这是有意为之。 | |
| 62 | + | |
| 63 | +### 下一步(按优先级) | |
| 64 | + | |
| 65 | +1. **决定 Qwen LLM 要不要恢复**——现在公网 `qwen.wudaima.com` 实际在裸露 minimax-h3 的 | |
| 66 | + gateway API(无鉴权),如果这不是有意的,需要判断是先恢复 llm38 还是先给 gateway.py/ | |
| 67 | + h3-console 补鉴权。这是产品/安全决策,需要用户拍板,不要擅自停 gateway.py 起 llm38 | |
| 68 | + ——四个 `api.py` worker 现在正被 `gateway.py` 管着,直接停可能打断正在跑的生成任务。 | |
| 69 | +2. **`gateway.wudaima.com` 独立域名**:如果还想要这条线(而不是永久用 qwen 子路径顶替), | |
| 70 | + 需要找到能登录 `60.205.214.169` 的人补一条 nginx vhost 转发规则。 | |
| 71 | +3. **鉴权**:h3-console 和 gateway.py 现在都无鉴权,公网可直调。`h3-console/DESIGN.md` | |
| 72 | + 问题3 有现成方案(`Authorization: Bearer` + 环境变量),跑通了再加,一直没排上。 | |
| 73 | +4. 磁盘 `/` 只剩 29G,`/data` 83% 满——暂时没出问题,但没有余量报警,真紧张时会和其它任务一样 | |
| 74 | + 直接写失败。 | |
| 75 | + | |
| 76 | +--- | |
| 77 | + | |
| 78 | +# 交接文档 · 2026-08-18(历史,状态类结论已过时,见上面最新一节) | |
| 6 | 79 | |
| 7 | 80 | ## 一句话 |
| 8 | 81 | ... | ... |
h3-console/README.md
| ... | ... | @@ -104,8 +104,38 @@ PORT=8080 \ |
| 104 | 104 | setsid nohup ./run.sh > console.log 2>&1 < /dev/null & |
| 105 | 105 | ``` |
| 106 | 106 | |
| 107 | -对外访问用现成的 FRP 隧道 `gateway.wudaima.com`(配置已存在、一直空挂,见 DESIGN.md 问题2)—— | |
| 108 | -这一步还没验证公网访问,只验证过局域网内 `http://192.168.110.67:8080` 直连。 | |
| 107 | +**外加第二个实例专供公网子路径**(同一个目录,端口和环境变量不同,其它不变): | |
| 108 | + | |
| 109 | +```bash | |
| 110 | +cd /data/models/minimax-h3/console | |
| 111 | +H3_GATEWAY_URL=http://127.0.0.1:8000 \ | |
| 112 | +H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080 \ | |
| 113 | +H3_CONSOLE_BASE_PATH=/console \ | |
| 114 | +PORT=8081 \ | |
| 115 | +setsid nohup ./run.sh > console-subpath.log 2>&1 < /dev/null & | |
| 116 | +``` | |
| 117 | + | |
| 118 | +对外访问原计划用独立域名 `gateway.wudaima.com`(配置已存在,见 DESIGN.md 问题2)—— | |
| 119 | +**2026-08-20 实测坐实这条线路走不通**,缺口在 frp 服务端 `60.205.214.169` 那边(本机这边 | |
| 120 | +`frpc-qwen-client` 配置是对的、早就 `start proxy success` 注册上了,问题不在这台机器,详见 | |
| 121 | +顶层 `CHANGELOG.md`「排查清楚:gateway.wudaima.com 公网转发为什么一直不通」)——**不是这台 | |
| 122 | +机器能改的**,需要能登录 `60.205.214.169` 的人补上,暂不等它。 | |
| 123 | + | |
| 124 | +**改走 `qwen.wudaima.com/console` 子路径**(同一天改的,详见 CHANGELOG「h3-console 公网访问 | |
| 125 | +改走 qwen.wudaima.com/console 子路径」),已验证可用: | |
| 126 | +- 局域网直连:`http://192.168.110.67:8080`(端口 8080 的实例,`H3_CONSOLE_BASE_PATH` 留空, | |
| 127 | + 开着弹性调度器) | |
| 128 | +- 公网:`http://qwen.wudaima.com/console`(另起的端口 8081 实例,`H3_CONSOLE_BASE_PATH=/console`, | |
| 129 | + **不开弹性调度器**——两个进程的调度器同时抢着 flip 分区会重现"一个请求切三张卡"那个老 bug, | |
| 130 | + 这件事只能一个进程做主) | |
| 131 | + | |
| 132 | +两个进程共享同一个工作目录(同一份 `console.sqlite3`、`assets/`),局域网和公网看到的任务列表、 | |
| 133 | +上传的图片是同一份数据,不会分叉。`server.py` 支持 `H3_CONSOLE_BASE_PATH` 这个环境变量后, | |
| 134 | +`static/index.html` 里所有原来硬编码的绝对路径都改成了拼 `H3_BASE` 前缀(页面里的占位符 | |
| 135 | +`__H3_BASE_PATH__` 由 `server.py` 渲染时替换),留空就是原来的根路径行为,两种模式都有本地 | |
| 136 | +uvicorn 验证过。frp 那边在 `frpc.qwen.ini` 里新增了一段 `locations = /console` 的代理, | |
| 137 | +跟 `qwen-llm`(没有 `locations`,相当于兜底捕获其它所有路径)共用同一个 `custom_domains`, | |
| 138 | +两边不冲突。 | |
| 109 | 139 | |
| 110 | 140 | 重启/重新部署时**不要用 `pkill -f`**(见上面「揪出的坑」第3条,会误杀自己的 SSH 会话),改用: |
| 111 | 141 | ```bash | ... | ... |
h3-console/server.py
| ... | ... | @@ -15,8 +15,8 @@ from pathlib import Path |
| 15 | 15 | from typing import Optional |
| 16 | 16 | |
| 17 | 17 | import httpx |
| 18 | -from fastapi import FastAPI, HTTPException, UploadFile, File, Request | |
| 19 | -from fastapi.responses import StreamingResponse, FileResponse | |
| 18 | +from fastapi import APIRouter, FastAPI, HTTPException, UploadFile, File, Request | |
| 19 | +from fastapi.responses import StreamingResponse, FileResponse, HTMLResponse | |
| 20 | 20 | from fastapi.staticfiles import StaticFiles |
| 21 | 21 | from PIL import Image |
| 22 | 22 | |
| ... | ... | @@ -29,6 +29,9 @@ GATEWAY_URL = os.environ.get("H3_GATEWAY_URL", "http://192.168.110.67:8000") |
| 29 | 29 | CONSOLE_PUBLIC_BASE = os.environ.get("H3_CONSOLE_PUBLIC_BASE") # 靘垢 worker 粉銝嚗 README |
| 30 | 30 | MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20MB嚗/擐偏撣改甇W說蝤 |
| 31 | 31 | ENABLE_SUPERVISOR = os.environ.get("H3_ENABLE_SUPERVISOR") == "1" |
| 32 | +# 蝵脣鈭怠楝敺霈曇葵嚗 qwen.wudaima.com/console 蝡 | |
| 33 | +# 閫 README楝敺蝵脯征撠望蛹嚗頝臬/撅餈蔣 | |
| 34 | +BASE_PATH = os.environ.get("H3_CONSOLE_BASE_PATH", "").rstrip("/") | |
| 32 | 35 | |
| 33 | 36 | # GPU/蝡臬笆摨銝 start-all.sh 香 supervisor 敺 |
| 34 | 37 | BACKEND_TOPOLOGY = [ |
| ... | ... | @@ -41,6 +44,10 @@ H3_ROOT = Path("/data/models/minimax-h3") # 隞銝嚗撘 |
| 41 | 44 | |
| 42 | 45 | app = FastAPI(title="H3 Console") |
| 43 | 46 | client = httpx.AsyncClient(timeout=30.0) |
| 47 | +# /v1/* 餈葵 router 銝 app 銝銝箔雿撅 BASE_PATH | |
| 48 | +# 嚗pp.include_router(router, prefix=BASE_PATH)嚗辣撠橘ASE_PATH 征 | |
| 49 | +# 蝛箏泵銝莎蛹 app 銝銝 | |
| 50 | +router = APIRouter() | |
| 44 | 51 | |
| 45 | 52 | |
| 46 | 53 | def db(): |
| ... | ... | @@ -79,7 +86,7 @@ init_db() |
| 79 | 86 | # api.py GenReq 霈 URL 摮挾嚗irst_frame_url / reference_image_urls嚗 |
| 80 | 87 | # urllib.urlopen 隞乩暑辣 -> -> 銝芸垢 URL |
| 81 | 88 | # 銝 api.py 銝銵誨 |
| 82 | -@app.post("/v1/assets") | |
| 89 | +@router.post("/v1/assets") | |
| 83 | 90 | async def upload_asset(file: UploadFile = File(...)): |
| 84 | 91 | ext = Path(file.filename or "").suffix.lower() |
| 85 | 92 | if ext not in (".jpg", ".jpeg", ".png", ".webp"): |
| ... | ... | @@ -108,7 +115,7 @@ def _public_base() -> str: |
| 108 | 115 | "餈葵隡◤銝 api.py worker README") |
| 109 | 116 | |
| 110 | 117 | |
| 111 | -app.mount("/assets", StaticFiles(directory=str(ASSETS)), name="assets") | |
| 118 | +app.mount(f"{BASE_PATH}/assets", StaticFiles(directory=str(ASSETS)), name="assets") | |
| 112 | 119 | |
| 113 | 120 | |
| 114 | 121 | # ---------------- 遙嚗onsole 撌望蜓鈭綽ateway.py "敶征銵" ---------------- |
| ... | ... | @@ -141,9 +148,9 @@ def _row_view(row) -> dict: |
| 141 | 148 | # 撌梁 id嚗銝 console 撌梁 sqlite 銝楝敺 404舅銝 |
| 142 | 149 | # 摮挾撠梯砲頝 `id` 銝嚗 console id |
| 143 | 150 | if "poll" in view: |
| 144 | - view["poll"] = f"/v1/videos/{row['id']}" | |
| 151 | + view["poll"] = f"{BASE_PATH}/v1/videos/{row['id']}" | |
| 145 | 152 | if "download" in view: |
| 146 | - view["download"] = f"/v1/videos/{row['id']}/file" | |
| 153 | + view["download"] = f"{BASE_PATH}/v1/videos/{row['id']}/file" | |
| 147 | 154 | return view |
| 148 | 155 | |
| 149 | 156 | |
| ... | ... | @@ -217,7 +224,7 @@ async def _refresh_from_gateway(cid: str, gateway_id: str): |
| 217 | 224 | ) |
| 218 | 225 | |
| 219 | 226 | |
| 220 | -@app.post("/v1/videos", status_code=202) | |
| 227 | +@router.post("/v1/videos", status_code=202) | |
| 221 | 228 | async def create_video(req: dict): |
| 222 | 229 | partition = _partition_of(req) |
| 223 | 230 | cid = uuid.uuid4().hex[:12] |
| ... | ... | @@ -235,7 +242,7 @@ async def create_video(req: dict): |
| 235 | 242 | return _row_view(row) |
| 236 | 243 | |
| 237 | 244 | |
| 238 | -@app.get("/v1/videos/{jid}") | |
| 245 | +@router.get("/v1/videos/{jid}") | |
| 239 | 246 | async def get_video(jid: str): |
| 240 | 247 | with db() as con: |
| 241 | 248 | row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() |
| ... | ... | @@ -250,7 +257,7 @@ async def get_video(jid: str): |
| 250 | 257 | return _row_view(row) |
| 251 | 258 | |
| 252 | 259 | |
| 253 | -@app.get("/v1/videos/{jid}/file") | |
| 260 | +@router.get("/v1/videos/{jid}/file") | |
| 254 | 261 | async def download_video(jid: str, request: Request): |
| 255 | 262 | with db() as con: |
| 256 | 263 | row = con.execute( |
| ... | ... | @@ -302,7 +309,7 @@ async def download_video(jid: str, request: Request): |
| 302 | 309 | return StreamingResponse(stream(), status_code=upstream.status_code, headers=passthrough) |
| 303 | 310 | |
| 304 | 311 | |
| 305 | -@app.get("/v1/videos") | |
| 312 | +@router.get("/v1/videos") | |
| 306 | 313 | async def list_videos(limit: int = 50, trash: bool = False): |
| 307 | 314 | # console 撌梁 sqlite 隞餃”銝皞葉遙嚗ateway 銝嚗 |
| 308 | 315 | # 瘝⊥ gateway 嚗餈漲 reconciler_loop 賒餈 last_known_json嚗 |
| ... | ... | @@ -316,7 +323,7 @@ async def list_videos(limit: int = 50, trash: bool = False): |
| 316 | 323 | return {"jobs": [_row_view(r) for r in rows]} |
| 317 | 324 | |
| 318 | 325 | |
| 319 | -@app.delete("/v1/videos/{jid}") | |
| 326 | +@router.delete("/v1/videos/{jid}") | |
| 320 | 327 | async def delete_video(jid: str): |
| 321 | 328 | """頧臬嚗銝餃”嚗1 gateway/api.py 銝遙頨恬餈頝辣""" |
| 322 | 329 | with db() as con: |
| ... | ... | @@ -326,7 +333,7 @@ async def delete_video(jid: str): |
| 326 | 333 | return {"id": jid, "deleted": True} |
| 327 | 334 | |
| 328 | 335 | |
| 329 | -@app.post("/v1/videos/{jid}/restore") | |
| 336 | +@router.post("/v1/videos/{jid}/restore") | |
| 330 | 337 | async def restore_video(jid: str): |
| 331 | 338 | with db() as con: |
| 332 | 339 | cur = con.execute("UPDATE jobs SET deleted_at=NULL WHERE id=? AND deleted_at IS NOT NULL", (jid,)) |
| ... | ... | @@ -335,14 +342,14 @@ async def restore_video(jid: str): |
| 335 | 342 | return {"id": jid, "deleted": False} |
| 336 | 343 | |
| 337 | 344 | |
| 338 | -@app.get("/v1/backends") | |
| 345 | +@router.get("/v1/backends") | |
| 339 | 346 | async def backends(): |
| 340 | 347 | resp = await client.get(f"{GATEWAY_URL}/health") |
| 341 | 348 | resp.raise_for_status() |
| 342 | 349 | return resp.json() |
| 343 | 350 | |
| 344 | 351 | |
| 345 | -@app.get("/v1/models") | |
| 352 | +@router.get("/v1/models") | |
| 346 | 353 | def models(): |
| 347 | 354 | # 縑嚗笆摨銝 api.py PARTITIONS 撣賊霂餃內嚗 |
| 348 | 355 | return { |
| ... | ... | @@ -488,17 +495,34 @@ async def _startup(): |
| 488 | 495 | |
| 489 | 496 | |
| 490 | 497 | # 隞餃霂行/撱箏銝芰 URL嚗靘踹蝎斐澈銝腺銝芸△摨 |
| 491 | -# 嚗tatic/index.html 撌梢隞 JS 霂 location.pathname 摰蝷箏銝芾嚗舅頝舐霈 | |
| 492 | -# 瘚蝚血楝敺銝支葵銝△辣◆ StaticFiles 蝸銋 | |
| 493 | -# 銝 `/task.html/xxx` 隡◤敶銝芯辣 404 | |
| 494 | -@app.get("/task.html/{tid}") | |
| 498 | +# 嚗tatic/index.html 撌梢隞 JS 霂 location.pathname 摰蝷箏銝芾嚗頝舐霈 | |
| 499 | +# 瘚蝚血楝敺銝葵銝△辣 | |
| 500 | +# | |
| 501 | +# 憿菟銝 FileResponse 辣嚗霂餉甈∪泵銝脫嚗 __H3_BASE_PATH__ 泵 | |
| 502 | +# BASE_PATH蝵脣鈭怠楝敺嚗 qwen.wudaima.com/console嚗垢 JS | |
| 503 | +# fetch/history.pushState 笆頝臬敺蒂銝葵嚗窈瘙頝臬賑 | |
| 504 | +# 餈葵摮楝敺ASE_PATH 征征摮泵銝莎蛹摰銝 | |
| 505 | +def render_index() -> HTMLResponse: | |
| 506 | + html = (BASE / "static" / "index.html").read_text(encoding="utf-8") | |
| 507 | + return HTMLResponse(html.replace("__H3_BASE_PATH__", BASE_PATH)) | |
| 508 | + | |
| 509 | + | |
| 510 | +@router.get("/") | |
| 511 | +async def index_page(): | |
| 512 | + return render_index() | |
| 513 | + | |
| 514 | + | |
| 515 | +@router.get("/task.html/{tid}") | |
| 495 | 516 | async def task_page(tid: str): |
| 496 | - return FileResponse(str(BASE / "static" / "index.html")) | |
| 517 | + return render_index() | |
| 497 | 518 | |
| 498 | 519 | |
| 499 | -@app.get("/newtask.html") | |
| 520 | +@router.get("/newtask.html") | |
| 500 | 521 | async def newtask_page(): |
| 501 | - return FileResponse(str(BASE / "static" / "index.html")) | |
| 522 | + return render_index() | |
| 502 | 523 | |
| 503 | 524 | |
| 504 | -app.mount("/", StaticFiles(directory=str(BASE / "static"), html=True), name="static") | |
| 525 | +# BASE_PATH 征餈葵蝛箏泵銝莎outer 銝楝敺 app 銝蛹頝誑 | |
| 526 | +# @app.xxx 銝璅∩嚗征嚗 "/console"嚗雿撅嚗甈⊥抒 | |
| 527 | +# 銝瘥葵頝舐撌望 | |
| 528 | +app.include_router(router, prefix=BASE_PATH) | ... | ... |
h3-console/static/index.html
| ... | ... | @@ -176,6 +176,12 @@ |
| 176 | 176 | </div> |
| 177 | 177 | |
| 178 | 178 | <script> |
| 179 | +// server.py 渲染这个页面时会把这个占位符换成 BASE_PATH 环境变量(比如部署在共享域名的 | |
| 180 | +// 子路径 /console 下时)。留空就是原来的行为,直接挂在域名根路径,局域网直连不受影响。 | |
| 181 | +// 所有原本硬编码 "/xxx" 的绝对路径(fetch、pushState、下载/播放链接)都要在前面拼上它, | |
| 182 | +// 否则子路径部署时请求会打到域名根路径,而不是我们这个子路径。 | |
| 183 | +const H3_BASE = "__H3_BASE_PATH__"; | |
| 184 | + | |
| 179 | 185 | const uploaded = { 'fl2va-first': null, 'fl2va-last': null, 'ref2va-refs': [] }; |
| 180 | 186 | let jobsCache = []; |
| 181 | 187 | let trashCache = []; |
| ... | ... | @@ -186,7 +192,9 @@ let openMenuFor = null; |
| 186 | 192 | // server.py 里 /task.html/{tid} 和 /newtask.html 两条路由都返回这同一份 index.html, |
| 187 | 193 | // 这里只是从地址栏读出当前该显示哪个视图,方便复制链接分享、刷新页面不丢状态。 |
| 188 | 194 | function tidFromPath() { |
| 189 | - const m = location.pathname.match(/^\/task\.html\/(.+)$/); | |
| 195 | + let p = location.pathname; | |
| 196 | + if (H3_BASE && p.startsWith(H3_BASE)) p = p.slice(H3_BASE.length); | |
| 197 | + const m = p.match(/^\/task\.html\/(.+)$/); | |
| 190 | 198 | return m ? decodeURIComponent(m[1]) : null; |
| 191 | 199 | } |
| 192 | 200 | let selectedId = tidFromPath(); |
| ... | ... | @@ -199,7 +207,7 @@ document.querySelectorAll('.tabs button').forEach(btn => btn.onclick = () => { |
| 199 | 207 | |
| 200 | 208 | function showCreate(pushUrl = true) { |
| 201 | 209 | selectedId = null; |
| 202 | - if (pushUrl) history.pushState(null, '', '/newtask.html'); | |
| 210 | + if (pushUrl) history.pushState(null, '', H3_BASE + '/newtask.html'); | |
| 203 | 211 | document.getElementById('create-view').style.display = 'block'; |
| 204 | 212 | document.getElementById('detail').style.display = 'none'; |
| 205 | 213 | renderSidebar(); |
| ... | ... | @@ -277,7 +285,7 @@ function initParamSelects(prefix) { |
| 277 | 285 | |
| 278 | 286 | async function uploadFile(file) { |
| 279 | 287 | const fd = new FormData(); fd.append('file', file); |
| 280 | - const r = await fetch('/v1/assets', { method: 'POST', body: fd }); | |
| 288 | + const r = await fetch(H3_BASE + '/v1/assets', { method: 'POST', body: fd }); | |
| 281 | 289 | if (!r.ok) throw new Error(await r.text()); |
| 282 | 290 | return await r.json(); |
| 283 | 291 | } |
| ... | ... | @@ -341,7 +349,7 @@ async function submitJob(kind) { |
| 341 | 349 | } |
| 342 | 350 | if (!body.prompt) { errBox.textContent = '请填写 prompt'; return; } |
| 343 | 351 | try { |
| 344 | - const r = await fetch('/v1/videos', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); | |
| 352 | + const r = await fetch(H3_BASE + '/v1/videos', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); | |
| 345 | 353 | if (!r.ok) throw new Error(await r.text()); |
| 346 | 354 | const job = await r.json(); |
| 347 | 355 | await refreshJobs(); |
| ... | ... | @@ -404,7 +412,7 @@ function prefillCreate(req) { |
| 404 | 412 | |
| 405 | 413 | async function rerunJob(id) { |
| 406 | 414 | try { |
| 407 | - const r = await fetch(`/v1/videos/${id}`); | |
| 415 | + const r = await fetch(`${H3_BASE}/v1/videos/${id}`); | |
| 408 | 416 | if (!r.ok) throw new Error(await r.text()); |
| 409 | 417 | const req = (await r.json()).req || {}; |
| 410 | 418 | prefillCreate(req); |
| ... | ... | @@ -498,7 +506,7 @@ function toggleJobMenu(id) { |
| 498 | 506 | async function deleteJob(id) { |
| 499 | 507 | openMenuFor = null; |
| 500 | 508 | try { |
| 501 | - const r = await fetch(`/v1/videos/${id}`, { method: 'DELETE' }); | |
| 509 | + const r = await fetch(`${H3_BASE}/v1/videos/${id}`, { method: 'DELETE' }); | |
| 502 | 510 | if (!r.ok) throw new Error(await r.text()); |
| 503 | 511 | jobsCache = jobsCache.filter(j => j.id !== id); |
| 504 | 512 | if (selectedId === id) showCreate(); |
| ... | ... | @@ -508,7 +516,7 @@ async function deleteJob(id) { |
| 508 | 516 | |
| 509 | 517 | async function restoreJob(id) { |
| 510 | 518 | try { |
| 511 | - const r = await fetch(`/v1/videos/${id}/restore`, { method: 'POST' }); | |
| 519 | + const r = await fetch(`${H3_BASE}/v1/videos/${id}/restore`, { method: 'POST' }); | |
| 512 | 520 | if (!r.ok) throw new Error(await r.text()); |
| 513 | 521 | trashCache = trashCache.filter(j => j.id !== id); |
| 514 | 522 | renderSidebar(); |
| ... | ... | @@ -517,7 +525,7 @@ async function restoreJob(id) { |
| 517 | 525 | |
| 518 | 526 | async function loadTrash() { |
| 519 | 527 | try { |
| 520 | - const r = await fetch('/v1/videos?trash=true&limit=50'); | |
| 528 | + const r = await fetch(H3_BASE + '/v1/videos?trash=true&limit=50'); | |
| 521 | 529 | trashCache = (await r.json()).jobs || []; |
| 522 | 530 | } catch (e) { /* 下一轮轮询再试 */ } |
| 523 | 531 | } |
| ... | ... | @@ -536,7 +544,7 @@ function backToList() { |
| 536 | 544 | |
| 537 | 545 | async function selectJob(id, pushUrl = true) { |
| 538 | 546 | selectedId = id; |
| 539 | - if (pushUrl) history.pushState(null, '', '/task.html/' + encodeURIComponent(id)); | |
| 547 | + if (pushUrl) history.pushState(null, '', H3_BASE + '/task.html/' + encodeURIComponent(id)); | |
| 540 | 548 | document.getElementById('create-view').style.display = 'none'; |
| 541 | 549 | document.getElementById('detail').style.display = 'block'; |
| 542 | 550 | renderSidebar(); |
| ... | ... | @@ -553,15 +561,15 @@ async function refreshDetail() { |
| 553 | 561 | if (!selectedId) return; |
| 554 | 562 | const box = document.getElementById('detail'); |
| 555 | 563 | try { |
| 556 | - const r = await fetch(`/v1/videos/${selectedId}`); | |
| 564 | + const r = await fetch(`${H3_BASE}/v1/videos/${selectedId}`); | |
| 557 | 565 | if (!r.ok) { box.innerHTML = `<div class="back" onclick="showCreate()">← 新建任务</div><div class="err">找不到这个任务</div>`; return; } |
| 558 | 566 | const j = await r.json(); |
| 559 | 567 | const req = j.req || {}; |
| 560 | 568 | |
| 561 | 569 | let resultHtml = ''; |
| 562 | 570 | if (j.status === 'done') { |
| 563 | - resultHtml = `<video controls src="/v1/videos/${j.id}/file"></video> | |
| 564 | - <a class="download" href="/v1/videos/${j.id}/file" download>下载 mp4${j.size_bytes ? ' ('+(j.size_bytes/1024/1024).toFixed(1)+'MB)' : ''}</a>`; | |
| 571 | + resultHtml = `<video controls src="${H3_BASE}/v1/videos/${j.id}/file"></video> | |
| 572 | + <a class="download" href="${H3_BASE}/v1/videos/${j.id}/file" download>下载 mp4${j.size_bytes ? ' ('+(j.size_bytes/1024/1024).toFixed(1)+'MB)' : ''}</a>`; | |
| 565 | 573 | } else if (j.status === 'failed') { |
| 566 | 574 | resultHtml = `<div class="err">${(j.error || '未知错误').replace(/</g,'<')}</div>`; |
| 567 | 575 | } else if (j.status === 'running') { |
| ... | ... | @@ -601,7 +609,7 @@ async function refreshDetail() { |
| 601 | 609 | async function refreshJobs() { |
| 602 | 610 | if (viewingTrash) { await loadTrash(); renderSidebar(); return; } |
| 603 | 611 | try { |
| 604 | - const r = await fetch('/v1/videos?limit=50'); const d = await r.json(); | |
| 612 | + const r = await fetch(H3_BASE + '/v1/videos?limit=50'); const d = await r.json(); | |
| 605 | 613 | jobsCache = d.jobs || []; |
| 606 | 614 | renderSidebar(); |
| 607 | 615 | // 只有还在变化的任务(排队/运行中)才需要重新拉详情。`refreshDetail()` 是整段 |
| ... | ... | @@ -617,7 +625,7 @@ async function refreshJobs() { |
| 617 | 625 | |
| 618 | 626 | async function refreshBackends() { |
| 619 | 627 | try { |
| 620 | - const r = await fetch('/v1/backends'); const d = await r.json(); | |
| 628 | + const r = await fetch(H3_BASE + '/v1/backends'); const d = await r.json(); | |
| 621 | 629 | document.getElementById('backends').textContent = |
| 622 | 630 | `后端: ${d.ready}/${d.total} ready · 排队 ${d.queued} · 运行中 ${d.running}\n` + |
| 623 | 631 | d.backends.map(b => { | ... | ... |
minimax-h3/api.py
| ... | ... | @@ -39,7 +39,9 @@ def _rows(width: int, height: int, frames: int) -> float: |
| 39 | 39 | # 832x480x124f rows=14430 稳定基准;448x256x362f rows=11984、576x320x362f rows=19260 都能跑 |
| 40 | 40 | # (后者峰值 14.5G,换入换出吃紧但没崩);640x384x362f rows=25680 OOM(撞到 20.6G)。 |
| 41 | 41 | # 20000 卡在 19260(能跑)和 25680(OOM)之间,留安全余量。 |
| 42 | -ROWS_LIMIT = 20000 | |
| 42 | +# 支持环境变量覆盖:只用于临时拉高某一个 worker 做边界探测实验,默认值(生产用的这道 | |
| 43 | +# 安全线)不变——测完记得把起这个 worker 的进程重启回不带这个环境变量的状态。 | |
| 44 | +ROWS_LIMIT = int(os.environ.get("H3_ROWS_LIMIT", 20000)) | |
| 43 | 45 | |
| 44 | 46 | # ---------------- 任务模型 ---------------- |
| 45 | 47 | class GenReq(BaseModel): | ... | ... |