From 588934a8730fdfcb067eda499524c559907eb889 Mon Sep 17 00:00:00 2001 From: yibo Date: Thu, 20 Aug 2026 19:47:06 +0800 Subject: [PATCH] 补写 HANDOFF.md 交接文档 + h3-console 公网子路径接入完整改动 --- .gitignore | 3 +++ CHANGELOG.md | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 2 +- HANDOFF.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- h3-console/README.md | 34 ++++++++++++++++++++++++++++++++-- h3-console/server.py | 68 ++++++++++++++++++++++++++++++++++++++++++++++---------------------- h3-console/static/index.html | 36 ++++++++++++++++++++++-------------- minimax-h3/api.py | 4 +++- 8 files changed, 268 insertions(+), 43 deletions(-) diff --git a/.gitignore b/.gitignore index b5e41b4..2a1ca9b 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ logs/ # macOS .DS_Store + +# Claude Code 本地运行时状态,不是项目源码 +.claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ca306b..93435ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,90 @@ # 变更记录 +## 2026-08-20(夜,又续四)— h3-console 公网访问改走 qwen.wudaima.com/console 子路径 + +**背景**:`gateway.wudaima.com` 那条独立域名线路确认走不通(缺口在 60.205.214.169 那台 frp +服务端,本机改不了,见上一节)。用户决定不等那边修,改用已经确认能打通的 `qwen.wudaima.com` +域名,把 h3-console 挂在它的一个子路径下(`/console`),不影响它现在对外的 Qwen LLM 接口。 + +**结果(三处改动,缺一不可)**: + +1. **`h3-console/server.py` 加子路径支持**:新增 `H3_CONSOLE_BASE_PATH` 环境变量(默认空, + 行为不变)。所有 `/v1/*` 接口从直接挂在 `app` 上改成先挂到一个 `APIRouter`,最后 + `app.include_router(router, prefix=BASE_PATH)` 整体套前缀;`/assets` 静态挂载点、 + `_row_view()` 里的 `poll`/`download` 字段也跟着加前缀。三个页面路由(`/`、`/task.html/{tid}`、 + `/newtask.html`)不再直接 `FileResponse` 原文件,改成读进来做一次字符串替换,把 + `index.html` 里的占位符 `__H3_BASE_PATH__` 换成实际前缀。 +2. **`static/index.html` 前端**:加一个 `const H3_BASE = "__H3_BASE_PATH__"`,把原来所有硬编码 + 的绝对路径(13 处:`fetch('/v1/...')`、`history.pushState(...'/task.html/'...)`、下载/播放 + 用的 `/v1/videos/{id}/file` 等)都在前面拼上 `H3_BASE`。`H3_BASE` 留空时全部退化成原来的 + 写法,本地开发和局域网直连(`http://192.168.110.67:8080`)行为完全不变——这两处改动上线前 + 都用本地 uvicorn 分别起了 `BASE_PATH=""` 和 `BASE_PATH="/console"` 两种模式验证过, + 根路径/task.html/newtask.html/`/v1/backends` 全部行为符合预期才部署。 +3. **架构上跑两个 console 进程,不是改一个**:`BASE_PATH` 是进程级的全局设置,一个进程只能要么 + 服务根路径要么服务子路径,两者不能共存。所以局域网那个 `:8080` 实例(`BASE_PATH` 留空)不动, + 另起一个 `:8081` 实例专门服务子路径(`H3_CONSOLE_BASE_PATH=/console`,不开 + `H3_ENABLE_SUPERVISOR`——两个调度器同时抢着 flip 分区会重现"一个请求切三张卡"那个老 bug, + 弹性调度这件事只能有一个进程做主)。两个进程指向同一个工作目录,**共享同一个 + `console.sqlite3` 和 `assets/`**,所以从局域网和从公网子路径看到的任务列表、上传的图片是 + 同一份,不会分叉成两份数据。`H3_CONSOLE_PUBLIC_BASE` 两个实例都设成局域网地址 + `http://192.168.110.67:8080`(不是公网域名)——图片上传后是 `api.py`(同一台机器上的 GPU + worker)反向拉取,走局域网地址最直接可靠,没必要绕一圈公网域名和陌生的上游 nginx。 + +**frp 配置**:`/home/admin/llm/frp/frpc.qwen.ini` 里新增一段(改前已经 `cp` 了一份 +`.bak-<时间戳>` 备份): +``` +[qwen-console-subpath] +type = http +local_ip = 127.0.0.1 +local_port = 8081 +remote_port = 7002 +custom_domains = qwen.wudaima.com +locations = /console +``` +`custom_domains` 跟 `qwen-llm` 那条完全一样,`locations` 不同——frp 按路径前缀做子路由,没 +声明 `locations` 的 `qwen-llm` 相当于兜底捕获所有其它路径,两条互不冲突。frpc 由 PM2 管着 +(进程名 `frpc-qwen-client`),用 `pm2 restart frpc-qwen-client`(不是 `kill`)让它读新配置, +重启后确认 `qwen-llm`/`qwen-gw-20260610`/`qwen-console-subpath` 三个代理全部 `start proxy +success`。 + +**验证**:`qwen.wudaima.com/health`(LLM,200)没受影响;`qwen.wudaima.com/console/` 拿到 +真实的 `H3 Console` 页面(不再是 nginx 默认页);`/console/v1/backends`、`/console/task.html/ +<真实任务id>`、`/console/newtask.html` 都返回 200 且内容正确;页面里 `H3_BASE` 占位符确认被 +替换成了 `"/console"`。 + +⚠️ 这次动了共享的 `frpc-qwen-client` 进程(`qwen.wudaima.com` 生产线路用的同一个),重启会有 +几秒重连间隙——跟上一次重启一样,属于已经跟用户确认过的操作。 + +--- + +## 2026-08-20(夜,又续三)— 排查清楚:gateway.wudaima.com 公网转发为什么一直不通 + +**背景**:用户问"服务器的反向代理地址能不能访问我们的服务",实测 `http://gateway.wudaima.com/` +拿到的是 nginx 默认欢迎页(`Test Page for the Nginx HTTP Server on Fedora`),`/v1/backends` +是 nginx 默认 404 页——都不是 h3-console 的内容。局域网 `http://192.168.110.67:8080` 直连完全 +正常,两边一对比确认:**这条公网线路目前没有真的通到我们的服务**,跟 README 早先写的"配置已存在、 +一直空挂,还没验证公网访问"是同一件事,这次是第一次实测坐实。 + +**排查结论(重要:不是本机这边的配置问题)**: +- `/home/admin/llm/frp/frpc.qwen.ini` 里 `[qwen-gw-20260614]` 这段配置是对的 + (`local_port=8080` 对准 h3-console,`custom_domains=gateway.wudaima.com`)。 +- 这个 frpc 由 PM2 管着(进程名 `frpc-qwen-client`,同一个进程也扛着 `qwen.wudaima.com` 那条 + 生产线路,**PM2 托管的进程不能 `kill`,得 `pm2 restart`**——踩过这个坑,这次没再踩)。 +- `pm2 restart frpc-qwen-client` 后确认 `qwen.wudaima.com` 没受影响(仍 200),但 `gateway. + wudaima.com` 还是拿到 nginx 默认页——**排除了"只是没重启生效"这个假设**。 +- 翻 pm2 日志发现关键信息:`qwen-gw-20260614` 这条代理其实**从 8月14日就已经 `start proxy + success` 了**(不是这次才注册),frpc 客户端这边一直是通的、一直在正常向 frps 服务端报到。 + 也就是说 frps(`60.205.214.169:7009`)**已经接受了这条代理注册**。 + +**结论**:既然 frpc 客户端注册早就成功,但公网请求还是落到 nginx 默认页而不是 frps 路由过去的 +内容,缺口在 `60.205.214.169` 那台 frp 服务端本身——大概率是它前面(或本机)还有一层 nginx +按 Host 头转发到 frps 的 vhost 端口,`qwen.wudaima.com` 配了这层转发、`gateway.wudaima.com` +没配。**这台机器(192.168.110.67)没有权限也没有信息去改那台服务器**,需要能登录 +`60.205.214.169` 的人补一条 `gateway.wudaima.com` → frps vhost 端口的 nginx 转发规则。 +在此之前,h3-console 只能走局域网 `http://192.168.110.67:8080` 访问。 + +--- + ## 2026-08-20(夜,又续二)— api.py 任务记录改成落盘,不再随进程重启清空 **背景**:`h3-console` 的弹性调度器会常规性地 kill+restart `api.py` 进程来切 fl2va/ref2va 分区, diff --git a/CLAUDE.md b/CLAUDE.md index 675b73b..5abb6f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,7 +80,7 @@ cat llm-setup.md | ssh -o ControlPath=~/.ssh/cm67 admin@192.168.110.67 'cat > /h |------|--------|----|------|------| | `deploy-scripts/` | Qwen3.8-27B-Q5_K_M(llama.cpp,容器 `llm38`) | 0-3 分片 | **57.3 GB**(256K 上下文,2 槽位) | 8000 → `qwen.wudaima.com` | | `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) | -| `h3-console/` | H3 Web 管理台:上传/生成/预览三界面 + 任务持久化 + fl2va/ref2va 弹性调度器 | 不占显存(自己不跑模型,管理 minimax-h3 的四卡) | — | 8080 → `gateway.wudaima.com`(隧道已配好,公网访问未验证) | +| `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) | | (常驻) | whisper | GPU3 | 3.2 GB | — | 排卡前先 `nvidia-smi` 看实际余量:GPU3 被 whisper 占着,**llama.cpp 默认 tensor-split 按总显存而非剩余显存均分**,起新负载不加 `-ts` 容易把公网服务挤 OOM。 diff --git a/HANDOFF.md b/HANDOFF.md index c7434de..23e51ab 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,8 +1,81 @@ -# 交接文档 · 2026-08-18 +# 交接文档 · 2026-08-20(夜,最新) > 给下次进来的人(或进程)。**先读这份,再读 `CLAUDE.md`。** -> 权威细节在 `llm-setup.md`(整机)和 `minimax-h3/README.md`(视频线), -> 时间线在 `CHANGELOG.md`。这里只写「现在是什么状态、下一步该干什么」。 +> 权威细节在 `llm-setup.md`(整机)、`minimax-h3/README.md`(视频线)、 +> `h3-console/README.md`(Web 管理台)。时间线在 `CHANGELOG.md`。 +> 这里只写「现在是什么状态、下一步该干什么」——下面 2026-08-18/19 那几节是历史记录, +> 仍有效的踩坑经验保留,但**状态类的表格/结论以这一节为准,不要用旧的**。 + +## 2026-08-20 夜:现在是什么状态 + +**一句话**:`h3-console`(Web 管理台)今天从零做到部署上线并经过好几轮真实使用反馈迭代 +(重跑、回收站、任务记录落盘、假运行中修复、公网子路径接入),本地代码已建 git 仓库推到 +`git@gitlab-wuwei.hexpm.com:yibo/2080tiserver.git`。**同时发现一个容易踩的坑**: +`qwen.wudaima.com` 这个域名现在实际没有在提供 Qwen LLM 服务——见下面「重要」。 + +### ⚠️ 重要:qwen.wudaima.com 现在不是 LLM,是 minimax-h3 的 gateway.py + +- `docker ps` 是空的——**`llm38` 容器现在没有在跑**(不是本次改的,是之前某个时间点被停的, + 可能是之前的会话为了腾显存给 H3 四卡停的,没有记录明确是谁什么时候停的)。 +- 端口 8000 现在被 `minimax-h3/gateway.py` 占着(`ss -lptn` 能看到是个 python 进程,不是 + llama.cpp)。frp 的 `qwen-llm` 代理转发的正是本机 8000 端口,所以**公网 + `http://qwen.wudaima.com/health` 现在返回的是 gateway.py 的 JSON(`{"ready":4,...,"backends": + [...]}`),不是 Qwen 的 `/health`**,`qwen.wudaima.com/v1/videos` 现在也是能直接无鉴权提交 + 视频生成任务的(跳过了 h3-console 那层)。 +- **要恢复 Qwen LLM 公网服务**:先确认 minimax-h3 的四个 worker 不在关键任务中途, + 停掉占着 8000 的 gateway.py(`ss -lptn "sport = :8000" | grep -o "pid=[0-9]*"` 找 PID + 再 `kill`——不是 PM2 托管的,直接 kill 有效),再 `docker start llm38`(约 15 秒)。 + 两者不能共存在同一个 8000 端口上。 + +### 当前各服务实际状态(2026-08-20 深夜实测,不是文档推测) + +| 服务 | 状态 | 说明 | +|---|---|---| +| `llm38`(Qwen3.8-27B,docker) | **停**(`docker ps` 为空) | 恢复见上面「重要」一节 | +| `whisper`(docker,8002) | **停** | 同上,docker 里没有容器在跑 | +| PM2 `m3e` / `sd-forge` | **停** | 沿用 2026-08-19 就定的目标状态,没有变化 | +| minimax-h3 四个 `api.py` worker(8010-8013) | **在跑** | 分区会被弹性调度器动态 flip,此刻实测是 3 张 fl2va + 1 张 ref2va(GPU0 ref2va,GPU1/2/3 fl2va),不是固定的,正常 | +| `minimax-h3/gateway.py`(8000) | **在跑**,占着 `qwen.wudaima.com` | 见上面「重要」 | +| `h3-console`(8080) | **在跑** | 局域网直连 `http://192.168.110.67:8080`,开着弹性调度器(`H3_ENABLE_SUPERVISOR=1`) | +| `h3-console`(8081,子路径专用) | **在跑** | 公网 `http://qwen.wudaima.com/console`,`H3_CONSOLE_BASE_PATH=/console`,**不开**弹性调度器(两个调度器同时抢会重现"一个请求切三张卡"的老 bug),跟 8080 那个共享同一份 `console.sqlite3`/`assets/` | +| GPU 显存(`nvidia-smi`) | GPU0 5.3G / GPU1 21.3G / GPU2 19.4G / GPU3 18.5G(共 88G) | llm38 停着,所以余量看着比 CLAUDE.md 顶部旧表格写的宽松,但四张卡跑满 minimax-h3 也会吃紧,起新负载前还是先看一眼 | + +### 今天做的事(详细踩坑记录都在 CHANGELOG.md,这里只列条目) + +1. h3-console 任务详情页加「重跑」按钮(第一版直接后台重投,被否决后改成回填表单跳转新建页)。 +2. 修复「后端进程重启后任务永远显示假运行中」——`_refresh_from_gateway` 遇到 404 现在正确判 `failed`。 +3. `api.py` 的 `frames` 硬顶 124 改成按 `rows`(分辨率×帧数)动态判定,`ROWS_LIMIT=20000` + (支持 `H3_ROWS_LIMIT` 环境变量临时覆盖,仅供边界探测用)。 +4. `start-all.sh` 冷启动默认分区从「4 张全 ref2va」改成 2/2 固定分裂(GPU0/1 ref2va,GPU2/3 fl2va)。 +5. 左下角后端状态行补上 `running×N`/`queued×N`,之前明明有任务在跑也只显示 `ok`。 +6. `api.py` 任务记录改成落盘(`jobs_gpu{N}.sqlite3`),弹性调度器 kill+restart 某张卡时不再 + 把这张卡上正在跑的任务从内存里一起清空。 +7. 任务列表加软删除/回收站(`deleted_at` 列 + `?trash=true` + 恢复接口)。 +8. 排查清楚 `gateway.wudaima.com` 独立域名为什么公网打不通——缺口在 frp 服务端 + `60.205.214.169`,不是这台机器能改的,**没有再等它**。 +9. 改走 `qwen.wudaima.com/console` 子路径对外开放 h3-console(`H3_CONSOLE_BASE_PATH` 支持 + + 起第二个 8081 实例 + frp `locations=/console` 分流),验证通过、`qwen.wudaima.com` 原有 + 服务没受影响(因为它现在本来就不是 LLM,见上面「重要」)。 +10. 补齐了本地 `minimax-h3/` 镜像里此前一直缺失的 `gateway.py` 等 13 个文件(和服务器漂移的 + 缺口),本地建了 git 仓库并推到 `git@gitlab-wuwei.hexpm.com:yibo/2080tiserver.git`。 + `CLAUDE.md` 里明文写着的 SSH 密码也一起提交了,跟用户确认过、这是有意为之。 + +### 下一步(按优先级) + +1. **决定 Qwen LLM 要不要恢复**——现在公网 `qwen.wudaima.com` 实际在裸露 minimax-h3 的 + gateway API(无鉴权),如果这不是有意的,需要判断是先恢复 llm38 还是先给 gateway.py/ + h3-console 补鉴权。这是产品/安全决策,需要用户拍板,不要擅自停 gateway.py 起 llm38 + ——四个 `api.py` worker 现在正被 `gateway.py` 管着,直接停可能打断正在跑的生成任务。 +2. **`gateway.wudaima.com` 独立域名**:如果还想要这条线(而不是永久用 qwen 子路径顶替), + 需要找到能登录 `60.205.214.169` 的人补一条 nginx vhost 转发规则。 +3. **鉴权**:h3-console 和 gateway.py 现在都无鉴权,公网可直调。`h3-console/DESIGN.md` + 问题3 有现成方案(`Authorization: Bearer` + 环境变量),跑通了再加,一直没排上。 +4. 磁盘 `/` 只剩 29G,`/data` 83% 满——暂时没出问题,但没有余量报警,真紧张时会和其它任务一样 + 直接写失败。 + +--- + +# 交接文档 · 2026-08-18(历史,状态类结论已过时,见上面最新一节) ## 一句话 diff --git a/h3-console/README.md b/h3-console/README.md index ef6898c..d92ab12 100644 --- a/h3-console/README.md +++ b/h3-console/README.md @@ -104,8 +104,38 @@ PORT=8080 \ setsid nohup ./run.sh > console.log 2>&1 < /dev/null & ``` -对外访问用现成的 FRP 隧道 `gateway.wudaima.com`(配置已存在、一直空挂,见 DESIGN.md 问题2)—— -这一步还没验证公网访问,只验证过局域网内 `http://192.168.110.67:8080` 直连。 +**外加第二个实例专供公网子路径**(同一个目录,端口和环境变量不同,其它不变): + +```bash +cd /data/models/minimax-h3/console +H3_GATEWAY_URL=http://127.0.0.1:8000 \ +H3_CONSOLE_PUBLIC_BASE=http://192.168.110.67:8080 \ +H3_CONSOLE_BASE_PATH=/console \ +PORT=8081 \ +setsid nohup ./run.sh > console-subpath.log 2>&1 < /dev/null & +``` + +对外访问原计划用独立域名 `gateway.wudaima.com`(配置已存在,见 DESIGN.md 问题2)—— +**2026-08-20 实测坐实这条线路走不通**,缺口在 frp 服务端 `60.205.214.169` 那边(本机这边 +`frpc-qwen-client` 配置是对的、早就 `start proxy success` 注册上了,问题不在这台机器,详见 +顶层 `CHANGELOG.md`「排查清楚:gateway.wudaima.com 公网转发为什么一直不通」)——**不是这台 +机器能改的**,需要能登录 `60.205.214.169` 的人补上,暂不等它。 + +**改走 `qwen.wudaima.com/console` 子路径**(同一天改的,详见 CHANGELOG「h3-console 公网访问 +改走 qwen.wudaima.com/console 子路径」),已验证可用: +- 局域网直连:`http://192.168.110.67:8080`(端口 8080 的实例,`H3_CONSOLE_BASE_PATH` 留空, + 开着弹性调度器) +- 公网:`http://qwen.wudaima.com/console`(另起的端口 8081 实例,`H3_CONSOLE_BASE_PATH=/console`, + **不开弹性调度器**——两个进程的调度器同时抢着 flip 分区会重现"一个请求切三张卡"那个老 bug, + 这件事只能一个进程做主) + +两个进程共享同一个工作目录(同一份 `console.sqlite3`、`assets/`),局域网和公网看到的任务列表、 +上传的图片是同一份数据,不会分叉。`server.py` 支持 `H3_CONSOLE_BASE_PATH` 这个环境变量后, +`static/index.html` 里所有原来硬编码的绝对路径都改成了拼 `H3_BASE` 前缀(页面里的占位符 +`__H3_BASE_PATH__` 由 `server.py` 渲染时替换),留空就是原来的根路径行为,两种模式都有本地 +uvicorn 验证过。frp 那边在 `frpc.qwen.ini` 里新增了一段 `locations = /console` 的代理, +跟 `qwen-llm`(没有 `locations`,相当于兜底捕获其它所有路径)共用同一个 `custom_domains`, +两边不冲突。 重启/重新部署时**不要用 `pkill -f`**(见上面「揪出的坑」第3条,会误杀自己的 SSH 会话),改用: ```bash diff --git a/h3-console/server.py b/h3-console/server.py index 539cb5b..3095cc5 100644 --- a/h3-console/server.py +++ b/h3-console/server.py @@ -15,8 +15,8 @@ from pathlib import Path from typing import Optional import httpx -from fastapi import FastAPI, HTTPException, UploadFile, File, Request -from fastapi.responses import StreamingResponse, FileResponse +from fastapi import APIRouter, FastAPI, HTTPException, UploadFile, File, Request +from fastapi.responses import StreamingResponse, FileResponse, HTMLResponse from fastapi.staticfiles import StaticFiles from PIL import Image @@ -29,6 +29,9 @@ GATEWAY_URL = os.environ.get("H3_GATEWAY_URL", "http://192.168.110.67:8000") CONSOLE_PUBLIC_BASE = os.environ.get("H3_CONSOLE_PUBLIC_BASE") # 供后端 worker 回读上传图片用;见 README MAX_UPLOAD_BYTES = 20 * 1024 * 1024 # 20MB,够单张参考图/首尾帧,防止占满磁盘 ENABLE_SUPERVISOR = os.environ.get("H3_ENABLE_SUPERVISOR") == "1" +# 部署在共享域名的子路径下时设这个(比如挂在 qwen.wudaima.com/console 而不是独立域名), +# 见 README「子路径部署」。留空就是原来的行为,挂在根路径,本地/局域网直连不受影响。 +BASE_PATH = os.environ.get("H3_CONSOLE_BASE_PATH", "").rstrip("/") # GPU/端口映射,对应服务器上 start-all.sh 写死的分配。仅 supervisor 用得到。 BACKEND_TOPOLOGY = [ @@ -41,6 +44,10 @@ H3_ROOT = Path("/data/models/minimax-h3") # 仅服务器上存在,本地开å� app = FastAPI(title="H3 Console") client = httpx.AsyncClient(timeout=30.0) +# 所有 /v1/* 接口挂在这个 router 上而不是直接挂在 app 上,是为了能整体套一层 BASE_PATH +# 前缀(app.include_router(router, prefix=BASE_PATH),见文件末尾)——BASE_PATH 留空时 +# 前缀是空字符串,行为和直接挂在 app 上完全一样。 +router = APIRouter() def db(): @@ -79,7 +86,7 @@ init_db() # api.py 的 GenReq 只认 URL 字段(first_frame_url / reference_image_urls),内部用 # urllib.urlopen 拉取。所以上传接口的活是「收文件 -> 落盘 -> 吐一个后端能拉到的 URL」, # 不改 api.py 一行代码。 -@app.post("/v1/assets") +@router.post("/v1/assets") async def upload_asset(file: UploadFile = File(...)): ext = Path(file.filename or "").suffix.lower() if ext not in (".jpg", ".jpeg", ".png", ".webp"): @@ -108,7 +115,7 @@ def _public_base() -> str: "这个地址会被服务器上的 api.py worker 反向拉取,见 README。") -app.mount("/assets", StaticFiles(directory=str(ASSETS)), name="assets") +app.mount(f"{BASE_PATH}/assets", StaticFiles(directory=str(ASSETS)), name="assets") # ---------------- 生成任务:console 自己是排队的主人,gateway.py 只是"当前有空的执行者" ---------------- @@ -141,9 +148,9 @@ def _row_view(row) -> dict: # 自己的 id),照抄会指向一个 console 自己的 sqlite 里根本查不到的路径,直接 404——这两个 # 字段本来就该跟 `id` 一样,统一改成用 console 的 id 重新拼。 if "poll" in view: - view["poll"] = f"/v1/videos/{row['id']}" + view["poll"] = f"{BASE_PATH}/v1/videos/{row['id']}" if "download" in view: - view["download"] = f"/v1/videos/{row['id']}/file" + view["download"] = f"{BASE_PATH}/v1/videos/{row['id']}/file" return view @@ -217,7 +224,7 @@ async def _refresh_from_gateway(cid: str, gateway_id: str): ) -@app.post("/v1/videos", status_code=202) +@router.post("/v1/videos", status_code=202) async def create_video(req: dict): partition = _partition_of(req) cid = uuid.uuid4().hex[:12] @@ -235,7 +242,7 @@ async def create_video(req: dict): return _row_view(row) -@app.get("/v1/videos/{jid}") +@router.get("/v1/videos/{jid}") async def get_video(jid: str): with db() as con: row = con.execute("SELECT * FROM jobs WHERE id=?", (jid,)).fetchone() @@ -250,7 +257,7 @@ async def get_video(jid: str): return _row_view(row) -@app.get("/v1/videos/{jid}/file") +@router.get("/v1/videos/{jid}/file") async def download_video(jid: str, request: Request): with db() as con: row = con.execute( @@ -302,7 +309,7 @@ async def download_video(jid: str, request: Request): return StreamingResponse(stream(), status_code=upstream.status_code, headers=passthrough) -@app.get("/v1/videos") +@router.get("/v1/videos") async def list_videos(limit: int = 50, trash: bool = False): # console 自己的 sqlite 是任务列表的唯一真相来源(本地排队中的任务,gateway 根本不知道它存在, # 没法从 gateway 反查出来)。实时进度由后台 reconciler_loop 持续刷新进 last_known_json, @@ -316,7 +323,7 @@ async def list_videos(limit: int = 50, trash: bool = False): return {"jobs": [_row_view(r) for r in rows]} -@app.delete("/v1/videos/{jid}") +@router.delete("/v1/videos/{jid}") async def delete_video(jid: str): """软删除:只挪出主列表,不碰 gateway/api.py 上的任务本身(可能还在跑),不删磁盘文件。""" with db() as con: @@ -326,7 +333,7 @@ async def delete_video(jid: str): return {"id": jid, "deleted": True} -@app.post("/v1/videos/{jid}/restore") +@router.post("/v1/videos/{jid}/restore") async def restore_video(jid: str): with db() as con: 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): return {"id": jid, "deleted": False} -@app.get("/v1/backends") +@router.get("/v1/backends") async def backends(): resp = await client.get(f"{GATEWAY_URL}/health") resp.raise_for_status() return resp.json() -@app.get("/v1/models") +@router.get("/v1/models") def models(): # 静态信息,对应服务器上 api.py 里的 PARTITIONS 常量。只读展示,不做运行时切换。 return { @@ -488,17 +495,34 @@ async def _startup(): # 任务详情/新建各自有一个真实的 URL,方便复制粘贴分享、刷新不丢状态——都是同一个单页应用 -# (static/index.html 自己那份 JS 读 location.pathname 决定显示哪个视图),这两条路由只是让 -# 浏览器地址栏里出现符合预期的路径,不是两个不同的页面文件。必须放在 StaticFiles 挂载之前, -# 不然 `/task.html/xxx` 会被当成一个不存在的静态文件直接 404。 -@app.get("/task.html/{tid}") +# (static/index.html 自己那份 JS 读 location.pathname 决定显示哪个视图),这三条路由只是让 +# 浏览器地址栏里出现符合预期的路径,不是三个不同的页面文件。 +# +# 页面不再直接 FileResponse 原文件,而是读进来做一次字符串替换:把 __H3_BASE_PATH__ 占位符 +# 换成 BASE_PATH。部署在共享域名子路径下时(比如 qwen.wudaima.com/console),前端 JS 里所有 +# fetch/history.pushState 用的绝对路径都得带上这个前缀,否则请求会打到域名根路径而不是我们 +# 这个子路径——BASE_PATH 留空时替换成空字符串,跟原来的行为完全一样。 +def render_index() -> HTMLResponse: + html = (BASE / "static" / "index.html").read_text(encoding="utf-8") + return HTMLResponse(html.replace("__H3_BASE_PATH__", BASE_PATH)) + + +@router.get("/") +async def index_page(): + return render_index() + + +@router.get("/task.html/{tid}") async def task_page(tid: str): - return FileResponse(str(BASE / "static" / "index.html")) + return render_index() -@app.get("/newtask.html") +@router.get("/newtask.html") async def newtask_page(): - return FileResponse(str(BASE / "static" / "index.html")) + return render_index() -app.mount("/", StaticFiles(directory=str(BASE / "static"), html=True), name="static") +# BASE_PATH 留空时这个前缀是空字符串,router 上的路径原样挂到 app 根上,行为跟以前 +# 直接用 @app.xxx 一模一样;非空时(比如 "/console")整体套一层前缀,一次性生效, +# 不用每个路由自己拼。 +app.include_router(router, prefix=BASE_PATH) diff --git a/h3-console/static/index.html b/h3-console/static/index.html index dfc5e2c..88ead57 100644 --- a/h3-console/static/index.html +++ b/h3-console/static/index.html @@ -176,6 +176,12 @@