Commit 588934a8730fdfcb067eda499524c559907eb889

Authored by yibo
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 的双实例/公网访问方式
.gitignore
... ... @@ -20,3 +20,6 @@ logs/
20 20  
21 21 # macOS
22 22 .DS_Store
  23 +
  24 +# Claude Code 本地运行时状态,不是项目源码
  25 +.claude/
... ...
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 &#39;cat &gt; /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(&quot;H3_GATEWAY_URL&quot;, &quot;http://192.168.110.67:8000&quot;)
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(&quot;/data/models/minimax-h3&quot;) # 隞銝嚗撘
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() -&gt; 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) -&gt; 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(&#39;.tabs button&#39;).forEach(btn =&gt; btn.onclick = () =&gt; {
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,'&lt;')}</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) -&gt; 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):
... ...