Files
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
93 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "1024xengineer/trpg-master", "files": {".github/workflows/pr-preview.yml": {"dfc9a884366666f29dd42785bb4af2471f38fb2e": {"t": 1785745255.0909424, "n": 12113, "h": "e69b1fc84efcc24c", "c": "name: PR Preview\n\n# CI Preview(issue #200):PR 开/更新时自动构建 backend+frontend 镜像、部署到\n# 预览服务器、在 PR 上评论访问链接;PR 关闭时自动回收。\n#\n# 顶层不再用 paths 过滤(跟旧版的关键差异):paths 过滤器是基于\"这个事件\n# 触发时 PR 的完整 diff\"判断的——如果 PR 曾经改过 trpg-backend/** 触发了\n# 部署,后续又把这些改动全部撤销、最终 diff 只剩不在监视列表里的文件,\n# 后续 synchronize 甚至最终的 closed 事件都不会再匹配 paths,已部署的容器\n# 和目录会永久遗留、PR 评论也不会更新成\"已回收\"(fennoai review 抓到的\n# 真问题)。改成 workflow 本身总是对 opened/synchronize/reopened/closed\n# 触发,路径判断挪到 job 内部(见 changes job),只门控是否需要真的构建\n# 部署;teardown job 不受这个判断影响,只要事件是 closed 就一定跑。\non:\n pull_request:\n types: [opened, synchronize, reopened, closed]\n\n# 按 PR 号分组只能防止\"同一个 PR 的两次 push 互相打架\",防不了\"两个不同\n# PR 算出同一个端口槽位、同时部署互相抢占同一个宿主机端口\"(fennoai review\n# 抓到的真问题:docker compose 对同一个端口第二次 up 会直接报\n# \"port is already allocated\" 失败,不是\"后部署的把先部署的挤掉\")。改成\n# 全仓库级别的单一队列——预览部署这种操作频率本来就不高,排队执行换来\n# 正确性,比并发换来的那点速度更值。\nconcurrency:\n group: pr-preview-deploy-lock\n cancel-in-progress: false\n\njobs:\n changes:\n if: github.event.action != 'closed'\n runs-on: ubuntu-latest\n outputs:\n relevant: ${{ steps.filter.outputs.relevant }}\n steps:\n - uses: actions/checkout@v4\n - uses: dorny/paths-filter@v3\n id: filter\n with:\n filters: |\n relevant:\n - \"trpg-backend/**\"\n - \"trpg-frontend/**\"\n - \"trpg-sdk/**\"\n - \"agent-collaboration-framework/**\"\n - \"docker-compose.preview.yml\"\n - \".github/workflows/pr-preview.yml\"\n\n deploy:\n needs: changes\n # fork 来的 PR 触发 `pull_request` 时,GITHUB_TOKEN 平台级强制降级成只读\n # ——不管这里声明多少 permissions 都没用,push 镜像到 GHCR、在 PR 上评论\n # 这两步必然失败。与其让每个 fork PR 都红一次,不如直接跳过整个 job:\n # 见 issue #200「本期不做 · fork PR 的真实 AI 预览」,这里把范围收紧到\n # \"fork PR 完全不跑 Preview\",比\"跑起来但降级\"更准确地反映了 GitHub\n # 的实际限制。\n if: |\n github.event.action != 'closed' &&\n needs.changes.outputs.relevant == 'true' &&\n github.event.pull_request.head.repo.full_name == github.repository\n runs-on: ubuntu-latest\n permissions:\n contents: read\n packages: write\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n\n - name: 计算镜像 tag 与预览端口\n id: meta\n run: |\n PR=${{ github.event.pull_request.number }}\n OWNER_LOWER=$(echo \"${{ github.repository_owner }}\" | tr '[:upper:]' '[:lower:]')\n\n # 预览服务器是 PortForward 网关(没有独立公网 IP),不是想开多少\n # 端口就能开多少——只有网关那边显式配置过的端口才能从外部访问。\n # 主分支持久预览固定占用 80→10005,PR 预览不能复用这个入口。\n # 剩余显式配置了 4 对(内部端口 → 外部端口):\n # 12500→10496 5010→10497 5000→10498 9091→10499\n # 用 PR 号取模从这 4 对里选一对:内部端口是 docker-compose 里\n # frontend 服务实际绑定的宿主机端口,外部端口是评论里给出的访问\n # 链接。这意味着**同时最多 4 个 PR 能有预览**——超过 4 个同时开着\n # 的 PR 会撞进同一个槽位;\"部署到预览服务器\"那一步会显式检测这种\n # 冲突并直接失败(不是静默覆盖),见下面的说明。真需要更多并发\n # 槽位得找网关加映射。\n INTERNAL_PORTS=(12500 5010 5000 9091)\n EXTERNAL_PORTS=(10496 10497 10498 10499)\n SLOT=$((PR % 4))\n\n {\n echo \"backend_image=ghcr.io/$OWNER_LOWER/trpg-master-backend:pr-$PR\"\n echo \"frontend_image=ghcr.io/$OWNER_LOWER/trpg-master-frontend:pr-$PR\"\n # 同一个 PR 号在生命周期内槽位固定,重新部署(push 新 commit)\n # 不会换端口,评论里的链接不用改。\n echo \"internal_port=${INTERNAL_PORTS[$SLOT]}\"\n echo \"external_port=${EXTERNAL_PORTS[$SLOT]}\"\n } >> \"$GITHUB_OUTPUT\"\n\n - name: 登录 GHCR\n uses: docker/login-action@v3\n with:\n registry: ghcr.io\n username: ${{ github.actor }}\n password: ${{ secrets.GITHUB_TOKEN }}\n\n - uses: docker/setup-buildx-action@v3\n\n - name: 构建并推送 backend 镜像\n uses: docker/build-push-action@v6\n with:\n context: .\n file: trpg-backend/Dockerfile\n push: true\n tags: ${{ steps.meta.outputs.backend_image }}\n cache-from: type=gha,scope=preview-backend\n cache-to: type=gha,mode=max,scope=preview-backend\n\n - name: 构建并推送 frontend 镜像\n uses: docker/build-push-action@v6\n with:\n context: .\n file: trpg-frontend/Dockerfile\n push: true\n tags: ${{ steps.meta.outputs.frontend_image }}\n cache-from: type=gha,scope=preview-frontend\n cache-to: type=gha,mode=max,scope=preview-frontend\n\n - name: 准备 SSH\n env:\n SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}\n # 固定服务器 host key,不做运行时 ssh-keyscan:运行时 keyscan 拿到\n # 的公钥没有可信来源,网络中的攻击者可以在 runner 和预览服务器之间\n # 冒充服务器、拿到随后发送的 GHCR token 和 DeepSeek key(review\n # 抓到的真问题)。这里写的是我直接从服务器本机 `ssh-keyscan`\n # 出来、经过 SSH 连接亲自核实过指纹的三条 host key,作为 secret\n # 存着(不是密钥,只是拓扑信息,但同样不写进这个公开文件)。\n KNOWN_HOSTS: ${{ secrets.PREVIEW_SSH_KNOWN_HOSTS }}\n run: |\n mkdir -p ~/.ssh\n echo \"$SSH_KEY\" > ~/.ssh/preview_deploy_key\n chmod 600 ~/.ssh/preview_deploy_key\n echo \"$KNOWN_HOSTS\" > ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\n\n - name: 部署到预览服务器\n env:\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}\n SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}\n BACKEND_IMAGE: ${{ steps.meta.outputs.backend_image }}\n FRONTEND_IMAGE: ${{ steps.meta.outputs.frontend_image }}\n PORT: ${{ steps.meta.outputs.internal_port }}\n PR: ${{ github.event.pull_request.number }}\n GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n GHCR_ACTOR: ${{ github.actor }}\n # Preview 专用 key,跟本地/生产用的 key 分开管理,方便单独限额/吊销。\n DEEPSEEK_API_KEY: ${{ secrets.PREVIEW_DEEPSEEK_API_KEY }}\n run: |\n # 远程 provider 模式缺 key 时后端会启动失败(不是优雅降级,\n # README「主持模型配置」一节已说明)——这个判断必须在部署前做,\n # 不能让 compose 直接把空字符串当 key 传给 deepseek provider。\n if [ -n \"$DEEPSEEK_API_KEY\" ]; then\n HOST_MODEL_PROVIDER=deepseek\n else\n HOST_MODEL_PROVIDER=fake\n fi\n\n # 注意这里不再 scp 仓库里的 docker-compose.preview.yml——PR 分支\n # 能修改这个文件的内容,而部署账号在 docker 组里(等同宿主机\n # root)。如果直接执行 PR 自己带来的 compose 文件,任何有权限\n # 给这个仓库开同仓库 PR 的人都能在 compose 里加宿主机根目录/\n # docker.sock 挂载或 privileged 容器,从而拿到预览服务器的\n # root(review 抓到的真问题,五条里最严重的一条)。改成部署脚本\n # 只把镜像引用和端口当参数传给服务器上早就放好、PR 完全碰不到的\n # 固定 compose 模板(~/trpg-previews/compose-template/,这份文件\n # 只由有服务器访问权限的人手动维护,不经过 CI)——PR 能决定\"用\n # 哪个镜像\",决定不了\"以什么权限、挂载什么运行这个镜像\"。\n ssh -p \"$SSH_PORT\" -i ~/.ssh/preview_deploy_key \"$SSH_USER@$SSH_HOST\" bash -s <<EOF\n set -e\n mkdir -p ~/trpg-previews/pr-$PR\n\n # 端口槽位冲突检测:这个内部端口如果已经被别的 PR 的容器占着,\n # docker compose up 只会报 \"port is already allocated\" 失败,\n # 不会像旧注释说的那样\"后部署的把先部署的挤掉\"(review 抓到的\n # 真问题)。这里显式查出占用者是谁,是自己就放行(重新部署),\n # 是别人就直接失败并给出清楚的原因,不静默、也不驱逐别人的预览。\n OCCUPANT=\\$(docker ps --format '{{.Names}}\\t{{.Ports}}' | grep \":$PORT->\" | awk '{print \\$1}' | head -1 || true)\n if [ -n \"\\$OCCUPANT\" ]; then\n OCCUPANT_PR=\\$(echo \"\\$OCCUPANT\" | sed -E 's/^pr-([0-9]+)-.*/\\1/')\n if [ \"\\$OCCUPANT_PR\" != \"$PR\" ]; then\n echo \"端口槽位(内部 $PORT)当前被 PR #\\$OCCUPANT_PR 的预览占用,本次 PR #$PR 部署中止。\" >&2\n echo \"预览服务器目前只有 4 个 PR 槽位(另有 1 个主分支持久预览槽位),同时开着的 PR 超过 4 个时会撞车——等 PR #\\$OCCUPANT_PR 关闭回收后再重试,或者手动清理它的预览。\" >&2\n exit 1\n fi\n fi\n\n cp ~/trpg-previews/compose-template/docker-compose.yml ~/trpg-previews/pr-$PR/docker-compose.yml\n cd ~/trpg-previews/pr-$PR\n echo \"$GHCR_TOKEN\" | docker login ghcr.io -u \"$GHCR_ACTOR\" --password-stdin\n export BACKEND_IMAGE=\"$BACKEND_IMAGE\"\n export FRONTEND_IMAGE=\"$FRONTEND_IMAGE\"\n export PREVIEW_PORT=\"$PORT\"\n export HOST_MODEL_PROVIDER=\"$HOST_MODEL_PROVIDER\"\n export DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\"\n docker compose -p pr-$PR pull\n # --wait:等 healthcheck 真的通过再算这一步成功,镜像坏了/\n # entrypoint 启动即崩溃会让这里失败,而不是\"容器建起来了\"就\n # 当作部署成功、去发一条骗人的\"已部署\"评论(review 抓到的真\n # 问题)。healthcheck 定义在 compose 模板里。\n docker compose -p pr-$PR up -d --force-recreate --remove-orphans --wait --wait-timeout 90\n EOF\n\n - name: 在 PR 上评论预览链接\n uses: actions/github-script@v7\n env:\n # 评论里给用户点的必须是外部端口(网关对外映射的那个),不是\n # docker-compose 实际绑定的内部端口——这两个数字不一样,见上面\n # \"计算镜像 tag 与预览端口\" 步骤的说明。上一步用 --wait 确认过\n # 健康检查通过才会走到这里,失败时 GitHub Actions 默认跳过后续\n # 步骤,不会执行到这条评论。\n EXTERNAL_PORT: ${{ steps.meta.outputs.external_port }}\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n with:\n script: |\n const marker = '<!-- pr-preview-comment -->';\n const url = `http://${process.env.SSH_HOST}:${process.env.EXTERNAL_PORT}`;\n const body = `${marker}\\n### 🔍 CI Preview\\n\\n预览环境已部署:${url}\\n\\n> 未配置 Preview 专用 DeepSeek key 时,守秘人回复会是确定性占位文案,不影响其余功能验证。\\n\\n_最新一次 push 会自动更新这个预览,不需要重新打开链接。_`;\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n });\n const existing = comments.find((c) => c.body.includes(marker));\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n body,\n });\n }\n\n teardown:\n # 不依赖 changes job(那个 job 对 closed 事件根本不跑),也不看\n # paths——只要是 closed 事件就一定尝试回收,见文件顶部的说明。\n if: github.event.action == 'closed'\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - name: 计算槽位归属(仅用于按 PR 号定位目录,不涉及部署)\n id: meta\n run: |\n echo \"pr=${{ github.event.pull_request.number }}\" >> \"$GITHUB_OUTPUT\"\n\n - name: 准备 SSH\n env:\n SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}\n KNOWN_HOSTS: ${{ secrets.PREVIEW_SSH_KNOWN_HOSTS }}\n run: |\n mkdir -p ~/.ssh\n echo \"$SSH_KEY\" > ~/.ssh/preview_deploy_key\n chmod 600 ~/.ssh/preview_deploy_key\n echo \"$KNOWN_HOSTS\" > ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\n\n - name: 回收预览环境\n env:\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}\n SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}\n PR: ${{ steps.meta.outputs.pr }}\n run: |\n # 同上:故意不加引号,$PR 要在 runner 这一侧展开(这里没有\n # secret,纯粹是为了跟部署脚本保持同一套写法)。\n ssh -p \"$SSH_PORT\" -i ~/.ssh/preview_deploy_key \"$SSH_USER@$SSH_HOST\" bash -s <<EOF\n set -e\n if [ -d ~/trpg-previews/pr-$PR ]; then\n cd ~/trpg-previews/pr-$PR\n docker compose -p pr-$PR down -v --remove-orphans || true\n cd ~\n rm -rf ~/trpg-previews/pr-$PR\n fi\n EOF\n\n - name: 在 PR 上更新评论为已回收\n uses: actions/github-script@v7\n with:\n script: |\n const marker = '<!-- pr-preview-comment -->';\n const body = `${marker}\\n### 🔍 CI Preview\\n\\n预览环境已回收。`;\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n });\n const existing = comments.find((c) => c.body.includes(marker));\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body,\n });\n }\n"}, "8f03d286b361aa308113c8dbbb88371d9b1f7b2d": {"t": 1785749299.2768552, "n": 12113, "h": "e69b1fc84efcc24c", "c": "name: PR Preview\n\n# CI Preview(issue #200):PR 开/更新时自动构建 backend+frontend 镜像、部署到\n# 预览服务器、在 PR 上评论访问链接;PR 关闭时自动回收。\n#\n# 顶层不再用 paths 过滤(跟旧版的关键差异):paths 过滤器是基于\"这个事件\n# 触发时 PR 的完整 diff\"判断的——如果 PR 曾经改过 trpg-backend/** 触发了\n# 部署,后续又把这些改动全部撤销、最终 diff 只剩不在监视列表里的文件,\n# 后续 synchronize 甚至最终的 closed 事件都不会再匹配 paths,已部署的容器\n# 和目录会永久遗留、PR 评论也不会更新成\"已回收\"(fennoai review 抓到的\n# 真问题)。改成 workflow 本身总是对 opened/synchronize/reopened/closed\n# 触发,路径判断挪到 job 内部(见 changes job),只门控是否需要真的构建\n# 部署;teardown job 不受这个判断影响,只要事件是 closed 就一定跑。\non:\n pull_request:\n types: [opened, synchronize, reopened, closed]\n\n# 按 PR 号分组只能防止\"同一个 PR 的两次 push 互相打架\",防不了\"两个不同\n# PR 算出同一个端口槽位、同时部署互相抢占同一个宿主机端口\"(fennoai review\n# 抓到的真问题:docker compose 对同一个端口第二次 up 会直接报\n# \"port is already allocated\" 失败,不是\"后部署的把先部署的挤掉\")。改成\n# 全仓库级别的单一队列——预览部署这种操作频率本来就不高,排队执行换来\n# 正确性,比并发换来的那点速度更值。\nconcurrency:\n group: pr-preview-deploy-lock\n cancel-in-progress: false\n\njobs:\n changes:\n if: github.event.action != 'closed'\n runs-on: ubuntu-latest\n outputs:\n relevant: ${{ steps.filter.outputs.relevant }}\n steps:\n - uses: actions/checkout@v4\n - uses: dorny/paths-filter@v3\n id: filter\n with:\n filters: |\n relevant:\n - \"trpg-backend/**\"\n - \"trpg-frontend/**\"\n - \"trpg-sdk/**\"\n - \"agent-collaboration-framework/**\"\n - \"docker-compose.preview.yml\"\n - \".github/workflows/pr-preview.yml\"\n\n deploy:\n needs: changes\n # fork 来的 PR 触发 `pull_request` 时,GITHUB_TOKEN 平台级强制降级成只读\n # ——不管这里声明多少 permissions 都没用,push 镜像到 GHCR、在 PR 上评论\n # 这两步必然失败。与其让每个 fork PR 都红一次,不如直接跳过整个 job:\n # 见 issue #200「本期不做 · fork PR 的真实 AI 预览」,这里把范围收紧到\n # \"fork PR 完全不跑 Preview\",比\"跑起来但降级\"更准确地反映了 GitHub\n # 的实际限制。\n if: |\n github.event.action != 'closed' &&\n needs.changes.outputs.relevant == 'true' &&\n github.event.pull_request.head.repo.full_name == github.repository\n runs-on: ubuntu-latest\n permissions:\n contents: read\n packages: write\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n\n - name: 计算镜像 tag 与预览端口\n id: meta\n run: |\n PR=${{ github.event.pull_request.number }}\n OWNER_LOWER=$(echo \"${{ github.repository_owner }}\" | tr '[:upper:]' '[:lower:]')\n\n # 预览服务器是 PortForward 网关(没有独立公网 IP),不是想开多少\n # 端口就能开多少——只有网关那边显式配置过的端口才能从外部访问。\n # 主分支持久预览固定占用 80→10005,PR 预览不能复用这个入口。\n # 剩余显式配置了 4 对(内部端口 → 外部端口):\n # 12500→10496 5010→10497 5000→10498 9091→10499\n # 用 PR 号取模从这 4 对里选一对:内部端口是 docker-compose 里\n # frontend 服务实际绑定的宿主机端口,外部端口是评论里给出的访问\n # 链接。这意味着**同时最多 4 个 PR 能有预览**——超过 4 个同时开着\n # 的 PR 会撞进同一个槽位;\"部署到预览服务器\"那一步会显式检测这种\n # 冲突并直接失败(不是静默覆盖),见下面的说明。真需要更多并发\n # 槽位得找网关加映射。\n INTERNAL_PORTS=(12500 5010 5000 9091)\n EXTERNAL_PORTS=(10496 10497 10498 10499)\n SLOT=$((PR % 4))\n\n {\n echo \"backend_image=ghcr.io/$OWNER_LOWER/trpg-master-backend:pr-$PR\"\n echo \"frontend_image=ghcr.io/$OWNER_LOWER/trpg-master-frontend:pr-$PR\"\n # 同一个 PR 号在生命周期内槽位固定,重新部署(push 新 commit)\n # 不会换端口,评论里的链接不用改。\n echo \"internal_port=${INTERNAL_PORTS[$SLOT]}\"\n echo \"external_port=${EXTERNAL_PORTS[$SLOT]}\"\n } >> \"$GITHUB_OUTPUT\"\n\n - name: 登录 GHCR\n uses: docker/login-action@v3\n with:\n registry: ghcr.io\n username: ${{ github.actor }}\n password: ${{ secrets.GITHUB_TOKEN }}\n\n - uses: docker/setup-buildx-action@v3\n\n - name: 构建并推送 backend 镜像\n uses: docker/build-push-action@v6\n with:\n context: .\n file: trpg-backend/Dockerfile\n push: true\n tags: ${{ steps.meta.outputs.backend_image }}\n cache-from: type=gha,scope=preview-backend\n cache-to: type=gha,mode=max,scope=preview-backend\n\n - name: 构建并推送 frontend 镜像\n uses: docker/build-push-action@v6\n with:\n context: .\n file: trpg-frontend/Dockerfile\n push: true\n tags: ${{ steps.meta.outputs.frontend_image }}\n cache-from: type=gha,scope=preview-frontend\n cache-to: type=gha,mode=max,scope=preview-frontend\n\n - name: 准备 SSH\n env:\n SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}\n # 固定服务器 host key,不做运行时 ssh-keyscan:运行时 keyscan 拿到\n # 的公钥没有可信来源,网络中的攻击者可以在 runner 和预览服务器之间\n # 冒充服务器、拿到随后发送的 GHCR token 和 DeepSeek key(review\n # 抓到的真问题)。这里写的是我直接从服务器本机 `ssh-keyscan`\n # 出来、经过 SSH 连接亲自核实过指纹的三条 host key,作为 secret\n # 存着(不是密钥,只是拓扑信息,但同样不写进这个公开文件)。\n KNOWN_HOSTS: ${{ secrets.PREVIEW_SSH_KNOWN_HOSTS }}\n run: |\n mkdir -p ~/.ssh\n echo \"$SSH_KEY\" > ~/.ssh/preview_deploy_key\n chmod 600 ~/.ssh/preview_deploy_key\n echo \"$KNOWN_HOSTS\" > ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\n\n - name: 部署到预览服务器\n env:\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}\n SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}\n BACKEND_IMAGE: ${{ steps.meta.outputs.backend_image }}\n FRONTEND_IMAGE: ${{ steps.meta.outputs.frontend_image }}\n PORT: ${{ steps.meta.outputs.internal_port }}\n PR: ${{ github.event.pull_request.number }}\n GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n GHCR_ACTOR: ${{ github.actor }}\n # Preview 专用 key,跟本地/生产用的 key 分开管理,方便单独限额/吊销。\n DEEPSEEK_API_KEY: ${{ secrets.PREVIEW_DEEPSEEK_API_KEY }}\n run: |\n # 远程 provider 模式缺 key 时后端会启动失败(不是优雅降级,\n # README「主持模型配置」一节已说明)——这个判断必须在部署前做,\n # 不能让 compose 直接把空字符串当 key 传给 deepseek provider。\n if [ -n \"$DEEPSEEK_API_KEY\" ]; then\n HOST_MODEL_PROVIDER=deepseek\n else\n HOST_MODEL_PROVIDER=fake\n fi\n\n # 注意这里不再 scp 仓库里的 docker-compose.preview.yml——PR 分支\n # 能修改这个文件的内容,而部署账号在 docker 组里(等同宿主机\n # root)。如果直接执行 PR 自己带来的 compose 文件,任何有权限\n # 给这个仓库开同仓库 PR 的人都能在 compose 里加宿主机根目录/\n # docker.sock 挂载或 privileged 容器,从而拿到预览服务器的\n # root(review 抓到的真问题,五条里最严重的一条)。改成部署脚本\n # 只把镜像引用和端口当参数传给服务器上早就放好、PR 完全碰不到的\n # 固定 compose 模板(~/trpg-previews/compose-template/,这份文件\n # 只由有服务器访问权限的人手动维护,不经过 CI)——PR 能决定\"用\n # 哪个镜像\",决定不了\"以什么权限、挂载什么运行这个镜像\"。\n ssh -p \"$SSH_PORT\" -i ~/.ssh/preview_deploy_key \"$SSH_USER@$SSH_HOST\" bash -s <<EOF\n set -e\n mkdir -p ~/trpg-previews/pr-$PR\n\n # 端口槽位冲突检测:这个内部端口如果已经被别的 PR 的容器占着,\n # docker compose up 只会报 \"port is already allocated\" 失败,\n # 不会像旧注释说的那样\"后部署的把先部署的挤掉\"(review 抓到的\n # 真问题)。这里显式查出占用者是谁,是自己就放行(重新部署),\n # 是别人就直接失败并给出清楚的原因,不静默、也不驱逐别人的预览。\n OCCUPANT=\\$(docker ps --format '{{.Names}}\\t{{.Ports}}' | grep \":$PORT->\" | awk '{print \\$1}' | head -1 || true)\n if [ -n \"\\$OCCUPANT\" ]; then\n OCCUPANT_PR=\\$(echo \"\\$OCCUPANT\" | sed -E 's/^pr-([0-9]+)-.*/\\1/')\n if [ \"\\$OCCUPANT_PR\" != \"$PR\" ]; then\n echo \"端口槽位(内部 $PORT)当前被 PR #\\$OCCUPANT_PR 的预览占用,本次 PR #$PR 部署中止。\" >&2\n echo \"预览服务器目前只有 4 个 PR 槽位(另有 1 个主分支持久预览槽位),同时开着的 PR 超过 4 个时会撞车——等 PR #\\$OCCUPANT_PR 关闭回收后再重试,或者手动清理它的预览。\" >&2\n exit 1\n fi\n fi\n\n cp ~/trpg-previews/compose-template/docker-compose.yml ~/trpg-previews/pr-$PR/docker-compose.yml\n cd ~/trpg-previews/pr-$PR\n echo \"$GHCR_TOKEN\" | docker login ghcr.io -u \"$GHCR_ACTOR\" --password-stdin\n export BACKEND_IMAGE=\"$BACKEND_IMAGE\"\n export FRONTEND_IMAGE=\"$FRONTEND_IMAGE\"\n export PREVIEW_PORT=\"$PORT\"\n export HOST_MODEL_PROVIDER=\"$HOST_MODEL_PROVIDER\"\n export DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\"\n docker compose -p pr-$PR pull\n # --wait:等 healthcheck 真的通过再算这一步成功,镜像坏了/\n # entrypoint 启动即崩溃会让这里失败,而不是\"容器建起来了\"就\n # 当作部署成功、去发一条骗人的\"已部署\"评论(review 抓到的真\n # 问题)。healthcheck 定义在 compose 模板里。\n docker compose -p pr-$PR up -d --force-recreate --remove-orphans --wait --wait-timeout 90\n EOF\n\n - name: 在 PR 上评论预览链接\n uses: actions/github-script@v7\n env:\n # 评论里给用户点的必须是外部端口(网关对外映射的那个),不是\n # docker-compose 实际绑定的内部端口——这两个数字不一样,见上面\n # \"计算镜像 tag 与预览端口\" 步骤的说明。上一步用 --wait 确认过\n # 健康检查通过才会走到这里,失败时 GitHub Actions 默认跳过后续\n # 步骤,不会执行到这条评论。\n EXTERNAL_PORT: ${{ steps.meta.outputs.external_port }}\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n with:\n script: |\n const marker = '<!-- pr-preview-comment -->';\n const url = `http://${process.env.SSH_HOST}:${process.env.EXTERNAL_PORT}`;\n const body = `${marker}\\n### 🔍 CI Preview\\n\\n预览环境已部署:${url}\\n\\n> 未配置 Preview 专用 DeepSeek key 时,守秘人回复会是确定性占位文案,不影响其余功能验证。\\n\\n_最新一次 push 会自动更新这个预览,不需要重新打开链接。_`;\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n });\n const existing = comments.find((c) => c.body.includes(marker));\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n body,\n });\n }\n\n teardown:\n # 不依赖 changes job(那个 job 对 closed 事件根本不跑),也不看\n # paths——只要是 closed 事件就一定尝试回收,见文件顶部的说明。\n if: github.event.action == 'closed'\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - name: 计算槽位归属(仅用于按 PR 号定位目录,不涉及部署)\n id: meta\n run: |\n echo \"pr=${{ github.event.pull_request.number }}\" >> \"$GITHUB_OUTPUT\"\n\n - name: 准备 SSH\n env:\n SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}\n KNOWN_HOSTS: ${{ secrets.PREVIEW_SSH_KNOWN_HOSTS }}\n run: |\n mkdir -p ~/.ssh\n echo \"$SSH_KEY\" > ~/.ssh/preview_deploy_key\n chmod 600 ~/.ssh/preview_deploy_key\n echo \"$KNOWN_HOSTS\" > ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\n\n - name: 回收预览环境\n env:\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}\n SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}\n PR: ${{ steps.meta.outputs.pr }}\n run: |\n # 同上:故意不加引号,$PR 要在 runner 这一侧展开(这里没有\n # secret,纯粹是为了跟部署脚本保持同一套写法)。\n ssh -p \"$SSH_PORT\" -i ~/.ssh/preview_deploy_key \"$SSH_USER@$SSH_HOST\" bash -s <<EOF\n set -e\n if [ -d ~/trpg-previews/pr-$PR ]; then\n cd ~/trpg-previews/pr-$PR\n docker compose -p pr-$PR down -v --remove-orphans || true\n cd ~\n rm -rf ~/trpg-previews/pr-$PR\n fi\n EOF\n\n - name: 在 PR 上更新评论为已回收\n uses: actions/github-script@v7\n with:\n script: |\n const marker = '<!-- pr-preview-comment -->';\n const body = `${marker}\\n### 🔍 CI Preview\\n\\n预览环境已回收。`;\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n });\n const existing = comments.find((c) => c.body.includes(marker));\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body,\n });\n }\n"}, "5857686f77ee58674c856e7c71c3f149721021af": {"t": 1786067322.8922427, "n": 12113, "h": "e69b1fc84efcc24c", "c": "name: PR Preview\n\n# CI Preview(issue #200):PR 开/更新时自动构建 backend+frontend 镜像、部署到\n# 预览服务器、在 PR 上评论访问链接;PR 关闭时自动回收。\n#\n# 顶层不再用 paths 过滤(跟旧版的关键差异):paths 过滤器是基于\"这个事件\n# 触发时 PR 的完整 diff\"判断的——如果 PR 曾经改过 trpg-backend/** 触发了\n# 部署,后续又把这些改动全部撤销、最终 diff 只剩不在监视列表里的文件,\n# 后续 synchronize 甚至最终的 closed 事件都不会再匹配 paths,已部署的容器\n# 和目录会永久遗留、PR 评论也不会更新成\"已回收\"(fennoai review 抓到的\n# 真问题)。改成 workflow 本身总是对 opened/synchronize/reopened/closed\n# 触发,路径判断挪到 job 内部(见 changes job),只门控是否需要真的构建\n# 部署;teardown job 不受这个判断影响,只要事件是 closed 就一定跑。\non:\n pull_request:\n types: [opened, synchronize, reopened, closed]\n\n# 按 PR 号分组只能防止\"同一个 PR 的两次 push 互相打架\",防不了\"两个不同\n# PR 算出同一个端口槽位、同时部署互相抢占同一个宿主机端口\"(fennoai review\n# 抓到的真问题:docker compose 对同一个端口第二次 up 会直接报\n# \"port is already allocated\" 失败,不是\"后部署的把先部署的挤掉\")。改成\n# 全仓库级别的单一队列——预览部署这种操作频率本来就不高,排队执行换来\n# 正确性,比并发换来的那点速度更值。\nconcurrency:\n group: pr-preview-deploy-lock\n cancel-in-progress: false\n\njobs:\n changes:\n if: github.event.action != 'closed'\n runs-on: ubuntu-latest\n outputs:\n relevant: ${{ steps.filter.outputs.relevant }}\n steps:\n - uses: actions/checkout@v4\n - uses: dorny/paths-filter@v3\n id: filter\n with:\n filters: |\n relevant:\n - \"trpg-backend/**\"\n - \"trpg-frontend/**\"\n - \"trpg-sdk/**\"\n - \"agent-collaboration-framework/**\"\n - \"docker-compose.preview.yml\"\n - \".github/workflows/pr-preview.yml\"\n\n deploy:\n needs: changes\n # fork 来的 PR 触发 `pull_request` 时,GITHUB_TOKEN 平台级强制降级成只读\n # ——不管这里声明多少 permissions 都没用,push 镜像到 GHCR、在 PR 上评论\n # 这两步必然失败。与其让每个 fork PR 都红一次,不如直接跳过整个 job:\n # 见 issue #200「本期不做 · fork PR 的真实 AI 预览」,这里把范围收紧到\n # \"fork PR 完全不跑 Preview\",比\"跑起来但降级\"更准确地反映了 GitHub\n # 的实际限制。\n if: |\n github.event.action != 'closed' &&\n needs.changes.outputs.relevant == 'true' &&\n github.event.pull_request.head.repo.full_name == github.repository\n runs-on: ubuntu-latest\n permissions:\n contents: read\n packages: write\n pull-requests: write\n steps:\n - uses: actions/checkout@v4\n\n - name: 计算镜像 tag 与预览端口\n id: meta\n run: |\n PR=${{ github.event.pull_request.number }}\n OWNER_LOWER=$(echo \"${{ github.repository_owner }}\" | tr '[:upper:]' '[:lower:]')\n\n # 预览服务器是 PortForward 网关(没有独立公网 IP),不是想开多少\n # 端口就能开多少——只有网关那边显式配置过的端口才能从外部访问。\n # 主分支持久预览固定占用 80→10005,PR 预览不能复用这个入口。\n # 剩余显式配置了 4 对(内部端口 → 外部端口):\n # 12500→10496 5010→10497 5000→10498 9091→10499\n # 用 PR 号取模从这 4 对里选一对:内部端口是 docker-compose 里\n # frontend 服务实际绑定的宿主机端口,外部端口是评论里给出的访问\n # 链接。这意味着**同时最多 4 个 PR 能有预览**——超过 4 个同时开着\n # 的 PR 会撞进同一个槽位;\"部署到预览服务器\"那一步会显式检测这种\n # 冲突并直接失败(不是静默覆盖),见下面的说明。真需要更多并发\n # 槽位得找网关加映射。\n INTERNAL_PORTS=(12500 5010 5000 9091)\n EXTERNAL_PORTS=(10496 10497 10498 10499)\n SLOT=$((PR % 4))\n\n {\n echo \"backend_image=ghcr.io/$OWNER_LOWER/trpg-master-backend:pr-$PR\"\n echo \"frontend_image=ghcr.io/$OWNER_LOWER/trpg-master-frontend:pr-$PR\"\n # 同一个 PR 号在生命周期内槽位固定,重新部署(push 新 commit)\n # 不会换端口,评论里的链接不用改。\n echo \"internal_port=${INTERNAL_PORTS[$SLOT]}\"\n echo \"external_port=${EXTERNAL_PORTS[$SLOT]}\"\n } >> \"$GITHUB_OUTPUT\"\n\n - name: 登录 GHCR\n uses: docker/login-action@v3\n with:\n registry: ghcr.io\n username: ${{ github.actor }}\n password: ${{ secrets.GITHUB_TOKEN }}\n\n - uses: docker/setup-buildx-action@v3\n\n - name: 构建并推送 backend 镜像\n uses: docker/build-push-action@v6\n with:\n context: .\n file: trpg-backend/Dockerfile\n push: true\n tags: ${{ steps.meta.outputs.backend_image }}\n cache-from: type=gha,scope=preview-backend\n cache-to: type=gha,mode=max,scope=preview-backend\n\n - name: 构建并推送 frontend 镜像\n uses: docker/build-push-action@v6\n with:\n context: .\n file: trpg-frontend/Dockerfile\n push: true\n tags: ${{ steps.meta.outputs.frontend_image }}\n cache-from: type=gha,scope=preview-frontend\n cache-to: type=gha,mode=max,scope=preview-frontend\n\n - name: 准备 SSH\n env:\n SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}\n # 固定服务器 host key,不做运行时 ssh-keyscan:运行时 keyscan 拿到\n # 的公钥没有可信来源,网络中的攻击者可以在 runner 和预览服务器之间\n # 冒充服务器、拿到随后发送的 GHCR token 和 DeepSeek key(review\n # 抓到的真问题)。这里写的是我直接从服务器本机 `ssh-keyscan`\n # 出来、经过 SSH 连接亲自核实过指纹的三条 host key,作为 secret\n # 存着(不是密钥,只是拓扑信息,但同样不写进这个公开文件)。\n KNOWN_HOSTS: ${{ secrets.PREVIEW_SSH_KNOWN_HOSTS }}\n run: |\n mkdir -p ~/.ssh\n echo \"$SSH_KEY\" > ~/.ssh/preview_deploy_key\n chmod 600 ~/.ssh/preview_deploy_key\n echo \"$KNOWN_HOSTS\" > ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\n\n - name: 部署到预览服务器\n env:\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}\n SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}\n BACKEND_IMAGE: ${{ steps.meta.outputs.backend_image }}\n FRONTEND_IMAGE: ${{ steps.meta.outputs.frontend_image }}\n PORT: ${{ steps.meta.outputs.internal_port }}\n PR: ${{ github.event.pull_request.number }}\n GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n GHCR_ACTOR: ${{ github.actor }}\n # Preview 专用 key,跟本地/生产用的 key 分开管理,方便单独限额/吊销。\n DEEPSEEK_API_KEY: ${{ secrets.PREVIEW_DEEPSEEK_API_KEY }}\n run: |\n # 远程 provider 模式缺 key 时后端会启动失败(不是优雅降级,\n # README「主持模型配置」一节已说明)——这个判断必须在部署前做,\n # 不能让 compose 直接把空字符串当 key 传给 deepseek provider。\n if [ -n \"$DEEPSEEK_API_KEY\" ]; then\n HOST_MODEL_PROVIDER=deepseek\n else\n HOST_MODEL_PROVIDER=fake\n fi\n\n # 注意这里不再 scp 仓库里的 docker-compose.preview.yml——PR 分支\n # 能修改这个文件的内容,而部署账号在 docker 组里(等同宿主机\n # root)。如果直接执行 PR 自己带来的 compose 文件,任何有权限\n # 给这个仓库开同仓库 PR 的人都能在 compose 里加宿主机根目录/\n # docker.sock 挂载或 privileged 容器,从而拿到预览服务器的\n # root(review 抓到的真问题,五条里最严重的一条)。改成部署脚本\n # 只把镜像引用和端口当参数传给服务器上早就放好、PR 完全碰不到的\n # 固定 compose 模板(~/trpg-previews/compose-template/,这份文件\n # 只由有服务器访问权限的人手动维护,不经过 CI)——PR 能决定\"用\n # 哪个镜像\",决定不了\"以什么权限、挂载什么运行这个镜像\"。\n ssh -p \"$SSH_PORT\" -i ~/.ssh/preview_deploy_key \"$SSH_USER@$SSH_HOST\" bash -s <<EOF\n set -e\n mkdir -p ~/trpg-previews/pr-$PR\n\n # 端口槽位冲突检测:这个内部端口如果已经被别的 PR 的容器占着,\n # docker compose up 只会报 \"port is already allocated\" 失败,\n # 不会像旧注释说的那样\"后部署的把先部署的挤掉\"(review 抓到的\n # 真问题)。这里显式查出占用者是谁,是自己就放行(重新部署),\n # 是别人就直接失败并给出清楚的原因,不静默、也不驱逐别人的预览。\n OCCUPANT=\\$(docker ps --format '{{.Names}}\\t{{.Ports}}' | grep \":$PORT->\" | awk '{print \\$1}' | head -1 || true)\n if [ -n \"\\$OCCUPANT\" ]; then\n OCCUPANT_PR=\\$(echo \"\\$OCCUPANT\" | sed -E 's/^pr-([0-9]+)-.*/\\1/')\n if [ \"\\$OCCUPANT_PR\" != \"$PR\" ]; then\n echo \"端口槽位(内部 $PORT)当前被 PR #\\$OCCUPANT_PR 的预览占用,本次 PR #$PR 部署中止。\" >&2\n echo \"预览服务器目前只有 4 个 PR 槽位(另有 1 个主分支持久预览槽位),同时开着的 PR 超过 4 个时会撞车——等 PR #\\$OCCUPANT_PR 关闭回收后再重试,或者手动清理它的预览。\" >&2\n exit 1\n fi\n fi\n\n cp ~/trpg-previews/compose-template/docker-compose.yml ~/trpg-previews/pr-$PR/docker-compose.yml\n cd ~/trpg-previews/pr-$PR\n echo \"$GHCR_TOKEN\" | docker login ghcr.io -u \"$GHCR_ACTOR\" --password-stdin\n export BACKEND_IMAGE=\"$BACKEND_IMAGE\"\n export FRONTEND_IMAGE=\"$FRONTEND_IMAGE\"\n export PREVIEW_PORT=\"$PORT\"\n export HOST_MODEL_PROVIDER=\"$HOST_MODEL_PROVIDER\"\n export DEEPSEEK_API_KEY=\"$DEEPSEEK_API_KEY\"\n docker compose -p pr-$PR pull\n # --wait:等 healthcheck 真的通过再算这一步成功,镜像坏了/\n # entrypoint 启动即崩溃会让这里失败,而不是\"容器建起来了\"就\n # 当作部署成功、去发一条骗人的\"已部署\"评论(review 抓到的真\n # 问题)。healthcheck 定义在 compose 模板里。\n docker compose -p pr-$PR up -d --force-recreate --remove-orphans --wait --wait-timeout 90\n EOF\n\n - name: 在 PR 上评论预览链接\n uses: actions/github-script@v7\n env:\n # 评论里给用户点的必须是外部端口(网关对外映射的那个),不是\n # docker-compose 实际绑定的内部端口——这两个数字不一样,见上面\n # \"计算镜像 tag 与预览端口\" 步骤的说明。上一步用 --wait 确认过\n # 健康检查通过才会走到这里,失败时 GitHub Actions 默认跳过后续\n # 步骤,不会执行到这条评论。\n EXTERNAL_PORT: ${{ steps.meta.outputs.external_port }}\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n with:\n script: |\n const marker = '<!-- pr-preview-comment -->';\n const url = `http://${process.env.SSH_HOST}:${process.env.EXTERNAL_PORT}`;\n const body = `${marker}\\n### 🔍 CI Preview\\n\\n预览环境已部署:${url}\\n\\n> 未配置 Preview 专用 DeepSeek key 时,守秘人回复会是确定性占位文案,不影响其余功能验证。\\n\\n_最新一次 push 会自动更新这个预览,不需要重新打开链接。_`;\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n });\n const existing = comments.find((c) => c.body.includes(marker));\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body,\n });\n } else {\n await github.rest.issues.createComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n body,\n });\n }\n\n teardown:\n # 不依赖 changes job(那个 job 对 closed 事件根本不跑),也不看\n # paths——只要是 closed 事件就一定尝试回收,见文件顶部的说明。\n if: github.event.action == 'closed'\n runs-on: ubuntu-latest\n permissions:\n pull-requests: write\n steps:\n - name: 计算槽位归属(仅用于按 PR 号定位目录,不涉及部署)\n id: meta\n run: |\n echo \"pr=${{ github.event.pull_request.number }}\" >> \"$GITHUB_OUTPUT\"\n\n - name: 准备 SSH\n env:\n SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}\n KNOWN_HOSTS: ${{ secrets.PREVIEW_SSH_KNOWN_HOSTS }}\n run: |\n mkdir -p ~/.ssh\n echo \"$SSH_KEY\" > ~/.ssh/preview_deploy_key\n chmod 600 ~/.ssh/preview_deploy_key\n echo \"$KNOWN_HOSTS\" > ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\n\n - name: 回收预览环境\n env:\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}\n SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}\n PR: ${{ steps.meta.outputs.pr }}\n run: |\n # 同上:故意不加引号,$PR 要在 runner 这一侧展开(这里没有\n # secret,纯粹是为了跟部署脚本保持同一套写法)。\n ssh -p \"$SSH_PORT\" -i ~/.ssh/preview_deploy_key \"$SSH_USER@$SSH_HOST\" bash -s <<EOF\n set -e\n if [ -d ~/trpg-previews/pr-$PR ]; then\n cd ~/trpg-previews/pr-$PR\n docker compose -p pr-$PR down -v --remove-orphans || true\n cd ~\n rm -rf ~/trpg-previews/pr-$PR\n fi\n EOF\n\n - name: 在 PR 上更新评论为已回收\n uses: actions/github-script@v7\n with:\n script: |\n const marker = '<!-- pr-preview-comment -->';\n const body = `${marker}\\n### 🔍 CI Preview\\n\\n预览环境已回收。`;\n const { data: comments } = await github.rest.issues.listComments({\n owner: context.repo.owner,\n repo: context.repo.repo,\n issue_number: context.issue.number,\n });\n const existing = comments.find((c) => c.body.includes(marker));\n if (existing) {\n await github.rest.issues.updateComment({\n owner: context.repo.owner,\n repo: context.repo.repo,\n comment_id: existing.id,\n body,\n });\n }\n"}}, "docker-compose.preview.yml": {"dfc9a884366666f29dd42785bb4af2471f38fb2e": {"t": 1785746354.843201, "n": 2870, "h": "a95c6c480d183d1c", "c": "# CI Preview 用(issue #200)。一份文件两种用法:\n#\n# 本地直接复现(构建上下文是仓库根目录里的源码):\n# docker compose -f docker-compose.preview.yml up --build\n#\n# 服务器上部署(workflow 在部署前已经把镜像 build 好推到 GHCR,只 pull\n# 预置好的 BACKEND_IMAGE/FRONTEND_IMAGE,不在服务器上跑 build——见\n# issue #200「决策 2」,不占用生产服务器的算力编译):\n# BACKEND_IMAGE=ghcr.io/.../trpg-master-backend:pr-123 \\\n# FRONTEND_IMAGE=ghcr.io/.../trpg-master-frontend:pr-123 \\\n# docker compose -f docker-compose.preview.yml pull\n# docker compose -f docker-compose.preview.yml up -d\n#\n# `image:` 和 `build:` 在同一个 service 里可以共存:`up --build` 用\n# build 产物打上 `image:` 里那个 tag;不传 --build 时 `pull`/`up` 只认\n# `image:`,不会碰 build 那部分——这就是两种用法不用拆两份文件的原因。\n#\n# 数据库故意用容器内 SQLite(不挂宿主机 volume):预览环境的数据没有留存\n# 价值,容器销毁即丢,完全不碰生产/开发用的 Postgres——见 issue #200\n# 「决策 5」。\n#\n# HOST_MODEL_PROVIDER / DEEPSEEK_API_KEY 从宿主环境变量透传:同仓库 PR 由\n# workflow 注入真实 key;fork PR 因 GitHub Actions 平台限制拿不到 secrets、\n# 甚至连这个 workflow 的部署 job 都不会跑(见 pr-preview.yml 里的 job\n# 条件),本文件里的 fallback 只覆盖\"本地跑、不传任何 key\"这一种情况——\n# 远程 provider 模式缺 key 时后端会启动失败,不是优雅降级,所以本地不传\n# key 时必须落到 fake,不能让 HOST_MODEL_PROVIDER 意外等于 deepseek。\n\nservices:\n backend:\n image: ${BACKEND_IMAGE:-trpg-backend-preview:local}\n build:\n context: .\n dockerfile: trpg-backend/Dockerfile\n environment:\n APP_ENV: production\n DATABASE_URL: sqlite+aiosqlite:///./app.db\n ENABLE_DOCS: \"true\"\n LOG_LEVEL: INFO\n # 前端是同源反代(Caddy 转发 /api、/ws 到这个服务),浏览器请求走的是\n # 前端自己的 origin,不是这个容器的地址——理论上不需要 CORS;保留一个\n # 宽松值只是兜底(比如直接拿后端端口调试),不依赖它生效。\n CORS_ORIGINS: '[\"*\"]'\n HOST_MODEL_PROVIDER: ${HOST_MODEL_PROVIDER:-fake}\n # 开场超时独立于 Provider HTTP 超时,便于预览环境快速降级到安全模板。\n OPENING_NARRATION_MODE: ${OPENING_NARRATION_MODE:-model}\n OPENING_NARRATION_TIMEOUT_SECONDS: ${OPENING_NARRATION_TIMEOUT_SECONDS:-10}\n DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}\n DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}\n DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}\n expose:\n - \"8000\"\n # `docker compose up -d` 只保证容器\"被创建/启动\",不代表 entrypoint 里\n # 的迁移/种子加载/uvicorn 真的跑成功了——镜像坏了或启动即崩溃,之前的\n # 写法照样会在几秒后把预览评论成\"已部署\"(review 抓到的真问题)。\n # python:3.13-slim 镜像没有 curl/wget,用内置 urllib 探活,不用额外装包。\n healthcheck:\n test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health', timeout=3)\"]\n interval: 3s\n timeout: 3s\n retries: 20\n start_period: 5s\n\n frontend:\n image: ${FRONTEND_IMAGE:-trpg-frontend-preview:local}\n build:\n context: .\n dockerfile: trpg-frontend/Dockerfile\n depends_on:\n backend:\n condition: service_healthy\n ports:\n - \"${PREVIEW_PORT:-8080}:80\"\n healthcheck:\n test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://localhost:80/\"]\n interval: 3s\n timeout: 3s\n retries: 10\n start_period: 3s\n"}, "5857686f77ee58674c856e7c71c3f149721021af": {"t": 1785749299.9740286, "n": 2870, "h": "a95c6c480d183d1c", "c": "# CI Preview 用(issue #200)。一份文件两种用法:\n#\n# 本地直接复现(构建上下文是仓库根目录里的源码):\n# docker compose -f docker-compose.preview.yml up --build\n#\n# 服务器上部署(workflow 在部署前已经把镜像 build 好推到 GHCR,只 pull\n# 预置好的 BACKEND_IMAGE/FRONTEND_IMAGE,不在服务器上跑 build——见\n# issue #200「决策 2」,不占用生产服务器的算力编译):\n# BACKEND_IMAGE=ghcr.io/.../trpg-master-backend:pr-123 \\\n# FRONTEND_IMAGE=ghcr.io/.../trpg-master-frontend:pr-123 \\\n# docker compose -f docker-compose.preview.yml pull\n# docker compose -f docker-compose.preview.yml up -d\n#\n# `image:` 和 `build:` 在同一个 service 里可以共存:`up --build` 用\n# build 产物打上 `image:` 里那个 tag;不传 --build 时 `pull`/`up` 只认\n# `image:`,不会碰 build 那部分——这就是两种用法不用拆两份文件的原因。\n#\n# 数据库故意用容器内 SQLite(不挂宿主机 volume):预览环境的数据没有留存\n# 价值,容器销毁即丢,完全不碰生产/开发用的 Postgres——见 issue #200\n# 「决策 5」。\n#\n# HOST_MODEL_PROVIDER / DEEPSEEK_API_KEY 从宿主环境变量透传:同仓库 PR 由\n# workflow 注入真实 key;fork PR 因 GitHub Actions 平台限制拿不到 secrets、\n# 甚至连这个 workflow 的部署 job 都不会跑(见 pr-preview.yml 里的 job\n# 条件),本文件里的 fallback 只覆盖\"本地跑、不传任何 key\"这一种情况——\n# 远程 provider 模式缺 key 时后端会启动失败,不是优雅降级,所以本地不传\n# key 时必须落到 fake,不能让 HOST_MODEL_PROVIDER 意外等于 deepseek。\n\nservices:\n backend:\n image: ${BACKEND_IMAGE:-trpg-backend-preview:local}\n build:\n context: .\n dockerfile: trpg-backend/Dockerfile\n environment:\n APP_ENV: production\n DATABASE_URL: sqlite+aiosqlite:///./app.db\n ENABLE_DOCS: \"true\"\n LOG_LEVEL: INFO\n # 前端是同源反代(Caddy 转发 /api、/ws 到这个服务),浏览器请求走的是\n # 前端自己的 origin,不是这个容器的地址——理论上不需要 CORS;保留一个\n # 宽松值只是兜底(比如直接拿后端端口调试),不依赖它生效。\n CORS_ORIGINS: '[\"*\"]'\n HOST_MODEL_PROVIDER: ${HOST_MODEL_PROVIDER:-fake}\n # 开场超时独立于 Provider HTTP 超时,便于预览环境快速降级到安全模板。\n OPENING_NARRATION_MODE: ${OPENING_NARRATION_MODE:-model}\n OPENING_NARRATION_TIMEOUT_SECONDS: ${OPENING_NARRATION_TIMEOUT_SECONDS:-10}\n DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}\n DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}\n DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}\n expose:\n - \"8000\"\n # `docker compose up -d` 只保证容器\"被创建/启动\",不代表 entrypoint 里\n # 的迁移/种子加载/uvicorn 真的跑成功了——镜像坏了或启动即崩溃,之前的\n # 写法照样会在几秒后把预览评论成\"已部署\"(review 抓到的真问题)。\n # python:3.13-slim 镜像没有 curl/wget,用内置 urllib 探活,不用额外装包。\n healthcheck:\n test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health', timeout=3)\"]\n interval: 3s\n timeout: 3s\n retries: 20\n start_period: 5s\n\n frontend:\n image: ${FRONTEND_IMAGE:-trpg-frontend-preview:local}\n build:\n context: .\n dockerfile: trpg-frontend/Dockerfile\n depends_on:\n backend:\n condition: service_healthy\n ports:\n - \"${PREVIEW_PORT:-8080}:80\"\n healthcheck:\n test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://localhost:80/\"]\n interval: 3s\n timeout: 3s\n retries: 10\n start_period: 3s\n"}, "8f03d286b361aa308113c8dbbb88371d9b1f7b2d": {"t": 1785749301.0436065, "n": 2675, "h": "a94d4fab048673a9", "c": "# CI Preview 用(issue #200)。一份文件两种用法:\n#\n# 本地直接复现(构建上下文是仓库根目录里的源码):\n# docker compose -f docker-compose.preview.yml up --build\n#\n# 服务器上部署(workflow 在部署前已经把镜像 build 好推到 GHCR,只 pull\n# 预置好的 BACKEND_IMAGE/FRONTEND_IMAGE,不在服务器上跑 build——见\n# issue #200「决策 2」,不占用生产服务器的算力编译):\n# BACKEND_IMAGE=ghcr.io/.../trpg-master-backend:pr-123 \\\n# FRONTEND_IMAGE=ghcr.io/.../trpg-master-frontend:pr-123 \\\n# docker compose -f docker-compose.preview.yml pull\n# docker compose -f docker-compose.preview.yml up -d\n#\n# `image:` 和 `build:` 在同一个 service 里可以共存:`up --build` 用\n# build 产物打上 `image:` 里那个 tag;不传 --build 时 `pull`/`up` 只认\n# `image:`,不会碰 build 那部分——这就是两种用法不用拆两份文件的原因。\n#\n# 数据库故意用容器内 SQLite(不挂宿主机 volume):预览环境的数据没有留存\n# 价值,容器销毁即丢,完全不碰生产/开发用的 Postgres——见 issue #200\n# 「决策 5」。\n#\n# HOST_MODEL_PROVIDER / DEEPSEEK_API_KEY 从宿主环境变量透传:同仓库 PR 由\n# workflow 注入真实 key;fork PR 因 GitHub Actions 平台限制拿不到 secrets、\n# 甚至连这个 workflow 的部署 job 都不会跑(见 pr-preview.yml 里的 job\n# 条件),本文件里的 fallback 只覆盖\"本地跑、不传任何 key\"这一种情况——\n# 远程 provider 模式缺 key 时后端会启动失败,不是优雅降级,所以本地不传\n# key 时必须落到 fake,不能让 HOST_MODEL_PROVIDER 意外等于 deepseek。\n\nservices:\n backend:\n image: ${BACKEND_IMAGE:-trpg-backend-preview:local}\n build:\n context: .\n dockerfile: trpg-backend/Dockerfile\n environment:\n APP_ENV: production\n DATABASE_URL: sqlite+aiosqlite:///./app.db\n ENABLE_DOCS: \"true\"\n LOG_LEVEL: INFO\n # 前端是同源反代(Caddy 转发 /api、/ws 到这个服务),浏览器请求走的是\n # 前端自己的 origin,不是这个容器的地址——理论上不需要 CORS;保留一个\n # 宽松值只是兜底(比如直接拿后端端口调试),不依赖它生效。\n CORS_ORIGINS: '[\"*\"]'\n HOST_MODEL_PROVIDER: ${HOST_MODEL_PROVIDER:-fake}\n DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}\n DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.deepseek.com}\n DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-chat}\n expose:\n - \"8000\"\n # `docker compose up -d` 只保证容器\"被创建/启动\",不代表 entrypoint 里\n # 的迁移/种子加载/uvicorn 真的跑成功了——镜像坏了或启动即崩溃,之前的\n # 写法照样会在几秒后把预览评论成\"已部署\"(review 抓到的真问题)。\n # python:3.13-slim 镜像没有 curl/wget,用内置 urllib 探活,不用额外装包。\n healthcheck:\n test: [\"CMD\", \"python\", \"-c\", \"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health', timeout=3)\"]\n interval: 3s\n timeout: 3s\n retries: 20\n start_period: 5s\n\n frontend:\n image: ${FRONTEND_IMAGE:-trpg-frontend-preview:local}\n build:\n context: .\n dockerfile: trpg-frontend/Dockerfile\n depends_on:\n backend:\n condition: service_healthy\n ports:\n - \"${PREVIEW_PORT:-8080}:80\"\n healthcheck:\n test: [\"CMD\", \"wget\", \"--spider\", \"-q\", \"http://localhost:80/\"]\n interval: 3s\n timeout: 3s\n retries: 10\n start_period: 3s\n"}}, ".github/workflows/main-preview.yml": {"b18d60294081de2bf88e21da2fdbe4fd488252ed": {"t": 1786068221.0689712, "n": 8624, "h": "58f614b636eb6db0", "c": "name: Main Preview\n\n# main 有新提交(包括 PR 合并)后,使用最新代码更新持久预览环境。这个入口\n# 不回收,端口和访问地址始终不变,适合作为 README 中给用户的演示链接。\non:\n push:\n branches: [main]\n\nconcurrency:\n group: pr-preview-deploy-lock\n cancel-in-progress: false\n\njobs:\n deploy:\n runs-on: ubuntu-latest\n permissions:\n contents: read\n packages: write\n steps:\n - uses: actions/checkout@v4\n\n - name: 计算镜像 tag\n id: meta\n run: |\n OWNER_LOWER=$(echo \"${{ github.repository_owner }}\" | tr '[:upper:]' '[:lower:]')\n {\n echo \"backend_image=ghcr.io/$OWNER_LOWER/trpg-master-backend:main\"\n echo \"frontend_image=ghcr.io/$OWNER_LOWER/trpg-master-frontend:main\"\n } >> \"$GITHUB_OUTPUT\"\n\n - name: 登录 GHCR\n uses: docker/login-action@v3\n with:\n registry: ghcr.io\n username: ${{ github.actor }}\n password: ${{ secrets.GITHUB_TOKEN }}\n\n - uses: docker/setup-buildx-action@v3\n\n - name: 构建并推送 backend 镜像\n uses: docker/build-push-action@v6\n with:\n context: .\n file: trpg-backend/Dockerfile\n push: true\n tags: ${{ steps.meta.outputs.backend_image }}\n cache-from: type=gha,scope=main-preview-backend\n cache-to: type=gha,mode=max,scope=main-preview-backend\n\n - name: 构建并推送 frontend 镜像\n uses: docker/build-push-action@v6\n with:\n context: .\n file: trpg-frontend/Dockerfile\n push: true\n tags: ${{ steps.meta.outputs.frontend_image }}\n cache-from: type=gha,scope=main-preview-frontend\n cache-to: type=gha,mode=max,scope=main-preview-frontend\n\n - name: 准备 SSH\n env:\n SSH_KEY: ${{ secrets.PREVIEW_SSH_KEY }}\n KNOWN_HOSTS: ${{ secrets.PREVIEW_SSH_KNOWN_HOSTS }}\n run: |\n mkdir -p ~/.ssh\n echo \"$SSH_KEY\" > ~/.ssh/preview_deploy_key\n chmod 600 ~/.ssh/preview_deploy_key\n echo \"$KNOWN_HOSTS\" > ~/.ssh/known_hosts\n chmod 600 ~/.ssh/known_hosts\n\n - name: 部署持久预览\n env:\n SSH_HOST: ${{ secrets.PREVIEW_SSH_HOST }}\n SSH_USER: ${{ secrets.PREVIEW_SSH_USER }}\n SSH_PORT: ${{ secrets.PREVIEW_SSH_PORT }}\n BACKEND_IMAGE: ${{ steps.meta.outputs.backend_image }}\n FRONTEND_IMAGE: ${{ steps.meta.outputs.frontend_image }}\n GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n GHCR_ACTOR: ${{ github.actor }}\n DEEPSEEK_API_KEY: ${{ secrets.PREVIEW_DEEPSEEK_API_KEY }}\n DEEPSEEK_BASE_URL: ${{ vars.PREVIEW_DEEPSEEK_BASE_URL }}\n DEEPSEEK_MODEL: ${{ vars.PREVIEW_DEEPSEEK_MODEL }}\n # 当前凭证同时支持 DeepSeek v4 Pro 和 Sufy Gemini 生图;仍按用途\n # 使用两个 Secret 名称,后续可以独立轮换。不要把图片 key 放进前端。\n SUFY_API_KEY: ${{ secrets.PREVIEW_SUFY_API_KEY }}\n SUFY_BASE_URL: ${{ vars.PREVIEW_SUFY_BASE_URL }}\n SUFY_IMAGE_MODEL: ${{ vars.PREVIEW_SUFY_IMAGE_MODEL }}\n DOUBAO_TTS_API_KEY: ${{ secrets.PREVIEW_DOUBAO_TTS_API_KEY }}\n DOUBAO_TTS_VOICES: ${{ secrets.PREVIEW_DOUBAO_TTS_VOICES }}\n DOUBAO_TTS_DEFAULT_VOICE_TYPE: ${{ secrets.PREVIEW_DOUBAO_TTS_DEFAULT_VOICE_TYPE }}\n run: |\n SUFY_BASE_URL=\"${SUFY_BASE_URL:-https://openai.sufy.com/v1}\"\n SUFY_IMAGE_MODEL=\"${SUFY_IMAGE_MODEL:-google/gemini-3-pro-image}\"\n # DeepSeek 与 Sufy 是同一条角色生图链路的两个阶段:前者整理提示词,\n # 后者生成图片。两套 key 必须成对存在;只配置一套会让预览出现\n # 难以发现的半真实链路,因此在部署前直接失败。\n if [ -n \"$DEEPSEEK_API_KEY\" ] && [ -n \"$SUFY_API_KEY\" ]; then\n if [ -z \"$DEEPSEEK_BASE_URL\" ] || [ -z \"$DEEPSEEK_MODEL\" ]; then\n echo \"::error::已配置 Preview 模型密钥,但缺少 PREVIEW_DEEPSEEK_BASE_URL 或 PREVIEW_DEEPSEEK_MODEL\"\n exit 1\n fi\n if [[ ! \"$DEEPSEEK_BASE_URL\" =~ ^https?://[^/[:space:]]+(/.*)?$ ]]; then\n echo \"::error::PREVIEW_DEEPSEEK_BASE_URL 必须是包含主机名的 HTTP(S) 根地址\"\n exit 1\n fi\n if [[ \"$DEEPSEEK_BASE_URL\" == */chat/completions* ]]; then\n echo \"::error::PREVIEW_DEEPSEEK_BASE_URL 应填写 API 根地址,不能包含 /chat/completions\"\n exit 1\n fi\n if [[ ! \"$SUFY_BASE_URL\" =~ ^https?://[^/[:space:]]+(/.*)?$ ]]; then\n echo \"::error::PREVIEW_SUFY_BASE_URL 必须是包含主机名的 HTTP(S) 根地址\"\n exit 1\n fi\n if [[ \"$SUFY_BASE_URL\" == */images/generations* ]]; then\n echo \"::error::PREVIEW_SUFY_BASE_URL 应填写 API 根地址,不能包含 /images/generations\"\n exit 1\n fi\n if [ -z \"$SUFY_IMAGE_MODEL\" ]; then\n echo \"::error::已配置 Preview 生图密钥,但 PREVIEW_SUFY_IMAGE_MODEL 为空\"\n exit 1\n fi\n HOST_MODEL_PROVIDER=deepseek\n PORTRAIT_PROMPT_PROVIDER=deepseek\n PORTRAIT_IMAGE_PROVIDER=sufy\n else\n if [ -n \"$DEEPSEEK_API_KEY\" ] || [ -n \"$SUFY_API_KEY\" ]; then\n echo \"::error::Preview 的 DeepSeek 和 Sufy 密钥必须同时配置,不能只启用其中一段\"\n exit 1\n fi\n HOST_MODEL_PROVIDER=fake\n PORTRAIT_PROMPT_PROVIDER=deterministic\n PORTRAIT_IMAGE_PROVIDER=mock\n fi\n if [ -n \"$DOUBAO_TTS_API_KEY\" ] && [ -n \"$DOUBAO_TTS_VOICES\" ] && [ -n \"$DOUBAO_TTS_DEFAULT_VOICE_TYPE\" ]; then\n HOST_SPEECH_PROVIDER=doubao\n else\n HOST_SPEECH_PROVIDER=disabled\n fi\n DEEPSEEK_API_KEY_B64=$(printf '%s' \"$DEEPSEEK_API_KEY\" | base64 | tr -d '\\n')\n DEEPSEEK_BASE_URL_B64=$(printf '%s' \"$DEEPSEEK_BASE_URL\" | base64 | tr -d '\\n')\n DEEPSEEK_MODEL_B64=$(printf '%s' \"$DEEPSEEK_MODEL\" | base64 | tr -d '\\n')\n if [ -n \"$DEEPSEEK_API_KEY_B64\" ]; then\n echo \"::add-mask::$DEEPSEEK_API_KEY_B64\"\n fi\n SUFY_API_KEY_B64=$(printf '%s' \"$SUFY_API_KEY\" | base64 | tr -d '\\n')\n SUFY_BASE_URL_B64=$(printf '%s' \"$SUFY_BASE_URL\" | base64 | tr -d '\\n')\n SUFY_IMAGE_MODEL_B64=$(printf '%s' \"$SUFY_IMAGE_MODEL\" | base64 | tr -d '\\n')\n if [ -n \"$SUFY_API_KEY_B64\" ]; then\n echo \"::add-mask::$SUFY_API_KEY_B64\"\n fi\n DOUBAO_TTS_API_KEY_B64=$(printf '%s' \"$DOUBAO_TTS_API_KEY\" | base64 | tr -d '\\n')\n DOUBAO_TTS_VOICES_B64=$(printf '%s' \"$DOUBAO_TTS_VOICES\" | base64 | tr -d '\\n')\n DOUBAO_TTS_DEFAULT_VOICE_TYPE_B64=$(printf '%s' \"$DOUBAO_TTS_DEFAULT_VOICE_TYPE\" | base64 | tr -d '\\n')\n\n # runner 需先展开部署参数与编码后的配置,不能给 heredoc delimiter 加引号。\n # shellcheck disable=SC2087\n ssh -p \"$SSH_PORT\" -i ~/.ssh/preview_deploy_key \"$SSH_USER@$SSH_HOST\" bash -s <<EOF\n set -e\n mkdir -p ~/trpg-previews/main\n cp ~/trpg-previews/compose-template/docker-compose.yml ~/trpg-previews/main/docker-compose.yml\n cd ~/trpg-previews/main\n echo \"$GHCR_TOKEN\" | docker login ghcr.io -u \"$GHCR_ACTOR\" --password-stdin\n export BACKEND_IMAGE=\"$BACKEND_IMAGE\"\n export FRONTEND_IMAGE=\"$FRONTEND_IMAGE\"\n export PREVIEW_PORT=80\n export HOST_MODEL_PROVIDER=\"$HOST_MODEL_PROVIDER\"\n export DEEPSEEK_API_KEY=\"\\$(printf '%s' '$DEEPSEEK_API_KEY_B64' | base64 -d)\"\n export DEEPSEEK_BASE_URL=\"\\$(printf '%s' '$DEEPSEEK_BASE_URL_B64' | base64 -d)\"\n export DEEPSEEK_MODEL=\"\\$(printf '%s' '$DEEPSEEK_MODEL_B64' | base64 -d)\"\n export CHARACTER_PORTRAIT_ENABLED=true\n export PORTRAIT_PROMPT_PROVIDER=\"$PORTRAIT_PROMPT_PROVIDER\"\n export PORTRAIT_IMAGE_PROVIDER=\"$PORTRAIT_IMAGE_PROVIDER\"\n export SUFY_API_KEY=\"\\$(printf '%s' '$SUFY_API_KEY_B64' | base64 -d)\"\n export SUFY_BASE_URL=\"\\$(printf '%s' '$SUFY_BASE_URL_B64' | base64 -d)\"\n export SUFY_IMAGE_MODEL=\"\\$(printf '%s' '$SUFY_IMAGE_MODEL_B64' | base64 -d)\"\n export PORTRAIT_REFERENCE_IMAGE_PATH=app/assets/portrait-style-reference.png\n export PORTRAIT_GENERATION_TIMEOUT_SECONDS=120\n export HOST_SPEECH_PROVIDER=\"$HOST_SPEECH_PROVIDER\"\n # 编码后穿过未加引号的 SSH heredoc,避免 token/JSON 中的 shell\n # 元字符在部署机上被二次解析;解码结果只作为赋值结果,不会重新执行。\n export DOUBAO_TTS_API_KEY=\"\\$(printf '%s' '$DOUBAO_TTS_API_KEY_B64' | base64 -d)\"\n export DOUBAO_TTS_VOICES=\"\\$(printf '%s' '$DOUBAO_TTS_VOICES_B64' | base64 -d)\"\n export DOUBAO_TTS_DEFAULT_VOICE_TYPE=\"\\$(printf '%s' '$DOUBAO_TTS_DEFAULT_VOICE_TYPE_B64' | base64 -d)\"\n docker compose -p main-preview pull\n docker compose -p main-preview up -d --force-recreate --remove-orphans --wait --wait-timeout 90\n EOF\n"}}, "README.md": {"b18d60294081de2bf88e21da2fdbe4fd488252ed": {"t": 1786068457.9645567, "n": 16106, "h": "61a0d40a1f79c075", "c": "<p align=\"center\">\n <img src=\"https://img.shields.io/badge/milestone-MS1-brass?style=flat-square\" alt=\"MS1\" />\n <img src=\"https://img.shields.io/badge/frontend-React_19_|_Vite_7-61dafb?style=flat-square\" alt=\"React 19 and Vite 7\" />\n <img src=\"https://img.shields.io/badge/backend-FastAPI_|_Python_3.12+-teal?style=flat-square\" alt=\"FastAPI and Python 3.12+\" />\n <img src=\"https://img.shields.io/badge/realtime-WebSocket-7050a0?style=flat-square\" alt=\"WebSocket\" />\n</p>\n\n# 🎲 TRPG-master\n\n> **有人就能跑。** 面向移动端的多人在线 TRPG 应用,目标是由 AI 承担守秘人(KP)的叙事工作。\n\n当前仓库是一个已经完成前后端联调的 **MS1 可运行版本**,包含 React 前端、TypeScript SDK 和 FastAPI 后端。用户可以完成注册登录、创建或加入房间、选择模组、创建角色、进入大厅、开始游戏和房间内互动等基础流程。\n\n当前版本仍属于阶段性实现:主持人意图理解与叙事支持离线 Fake、OpenAI 和阿里云百炼千问三种模式,默认使用不访问网络的 Fake;复盘摘要等非主链能力仍未实现。账号、房间、角色、模组内容、规则 Runtime、事件和已完成动作均由 SQL Store 持久化。\n\n## 当前功能\n\n### 在线体验\n\n`main` 有新提交(包括 PR 合并)后会自动更新持久预览环境,前端入口固定使用\n网关端口 `10005`,地址不会随着部署变化:\n\n- [TRPG-master 持久预览](http://218.11.5.114:10005)\n\n该环境只保留面向用户的前端入口,`/api` 和 `/ws` 由 Caddy 反向代理到后端;\n数据库随容器重建而重置,未配置 Preview 专用 DeepSeek key 时使用 Fake Provider。\n\n| 模块 | 当前实现 |\n| --- | --- |\n| 账号 | 注册、登录、退出登录、获取个人信息、修改昵称 |\n| 首页 | 创建房间、输入房间码加入、查看我的房间、个人资料 |\n| 房间 | 房主选择模组、玩家列表、准备状态、房主开始与结束游戏 |\n| 角色 | CoC 风格建卡流程、属性与技能配置、装备和背景信息、完成建卡 |\n| 实时通信 | WebSocket 会话绑定、准备、开始游戏、提交行动、房间叙事广播 |\n| AI 主持 | 玩家安全上下文、结构化意图解析、确定性规则执行、结果叙事;支持 OpenAI 与千问 3.7 Plus |\n| 游戏界面 | 对话区、角色卡、技能、地图、笔记和 D100/D20/D6 本地投骰交互 |\n| 语音输入 | 支持浏览器原生语音识别,转写结果回填行动或讨论区输入框,由玩家编辑确认后发送 |\n| API SDK | 封装认证、房间、角色和房间 WebSocket;与后端 DTO 对应的类型由 `npm run codegen` 生成 |\n\n### 当前限制\n\n- 默认 `HOST_MODEL_PROVIDER=fake`,不会访问真实大模型;远程 Host Agent 或 Narrator 失败时当前回合安全中止并允许重试,不会静默回退到 Fake。\n- 当前唯一承诺可运行的模组是「追书人」;另外三个示例 JSON 只用于解析与 Schema 回归,不会自动写入运行数据库。\n- 技能检定保留 `check.request → check.roll → check.result` 两阶段协议;玩家提交 D100 点数,后端规则引擎权威结算并持久化结果。\n- Director、世界知识检索、长期记忆、主动剧情推进、RAG、持久即兴内容和完整重连恢复不在当前阶段。\n- 复盘摘要和完整事件记录等能力尚未完成。\n- 语音输入不需要项目配置第三方 Key,项目后端不会接收或保存原始录音;部分浏览器可能使用厂商远程服务完成识别,数据处理方式受浏览器服务条款约束。\n- 语音输入依赖浏览器 Web Speech API 和安全上下文。localhost 或 HTTPS 可用;当前纯 HTTP 的持久预览会明确显示语音输入不可用,键盘输入不受影响。\n\n## 系统结构\n\n```text\ntrpg-frontend (React)\n │\n ▼\ntrpg-sdk (REST + WebSocket)\n │\n ▼\ntrpg-backend (FastAPI)\n ├── /api/v1/* REST API\n ├── /ws/{roomId} 房间实时通道\n ├── TurnApplication Host Agent → 两阶段检定 → RuleEngine → Narrator\n ├── 模型适配器 Fake / OpenAI Responses / Qwen Agents SDK\n └── SQL Store 业务数据、模组、Runtime、事件与幂等记录\n```\n\n统一 REST 响应格式如下:\n\n```json\n{\n \"success\": true,\n \"data\": {},\n \"error\": null\n}\n```\n\nWebSocket 使用独立事件信封:客户端发送 `{ \"type\", \"playerId\", \"payload\" }`,服务端发送 `{ \"type\", \"payload\" }`。\n\n## 技术栈\n\n| 层 | 技术 |\n| --- | --- |\n| 前端 | React 19、TypeScript 5、Vite 7、Tailwind CSS 3、Zustand 5、React Router 7 |\n| SDK | TypeScript、Rollup 4 |\n| 后端 | Python 3.12+、FastAPI、Pydantic 2、SQLAlchemy Async、Uvicorn |\n| 实时通信 | WebSocket |\n| AI 主持 | Host Orchestrator、OpenAI Responses API、阿里云百炼千问 JSON Mode |\n| 数据与安全 | SQLite、PostgreSQL 异步驱动、bcrypt |\n| 工程质量 | pytest、ruff、ty、GitHub Actions |\n\n## 项目目录\n\n```text\nTRPG-master/\n├── trpg-frontend/ # 移动端 React 应用\n├── trpg-sdk/ # 前后端通信 SDK,前端通过本地依赖引用\n├── trpg-backend/ # FastAPI 服务、REST API、WebSocket 和测试\n├── .github/workflows/ # 三个独立 CI:后端、SDK、前端\n└── README.md\n```\n\n## 本地运行\n\n### 环境要求\n\n- Git\n- Node.js 与 npm(版本需支持 Vite 7)\n- Python 3.12 或更高版本;仓库的 `.python-version` 当前指定 3.13\n- 推荐安装 [uv](https://docs.astral.sh/uv/) 管理后端环境\n\n### 1. 克隆仓库\n\n```bash\ngit clone https://github.com/1024XEngineer/TRPG-master.git\ncd TRPG-master\n```\n\n### 2. 构建 SDK\n\n前端通过 `file:../trpg-sdk` 引用 SDK,因此首次启动前需要先生成 `dist`。\n\n```bash\ncd trpg-sdk\nnpm ci\nnpm run build\ncd ..\n```\n\n### 3. 启动后端\n\n```bash\ncd trpg-backend\nuv sync --locked\nuv run alembic upgrade head # 建表:首次启动、以及之后表结构有变更时都要先跑\nuv run uvicorn app.main:app --reload\n```\n\n> 建表由 Alembic 迁移负责(不再由应用启动时自动 `create_all`)。跳过\n> `alembic upgrade head` 直接启动会因为表不存在、种子数据写入失败而崩溃。\n>\n> **如果你之前跑过旧版本、本地已有 `trpg-backend/app.db`**:旧版本靠应用启动时\n> `create_all` 建表、没有 Alembic 迁移历史,直接 `alembic upgrade head` 会因为\n> `rooms` 等表已存在而报错。旧版本的业务数据存在内存里(重启即丢),那个 `app.db`\n> 里只有空的历史表、没有真实数据,**直接删掉重新迁移即可**(`rm trpg-backend/app.db`\n> 再 `alembic upgrade head`)。\n\n应用 Seed 只会创建 COC7 规则系统和 `wip` 状态的追书人目录,不会内嵌简化版\n模组内容。执行固定的本地加载命令,将仓库中的追书人\nModuleContent 经过 Validation 后原子写入数据库,并把目录标记为 `ready`:\n\n```bash\ncd trpg-backend\nuv run python scripts/load_paper_chase.py\n```\n\n该命令只读取\n`agent-collaboration-framework/docs/module-parser/examples/module-content-validation/追书人/module-content-draft.json`。\n脚本可直接在刚迁移的空数据库运行:缺少 Seed 时会先执行同一套幂等 Seed。重复\n执行相同内容会返回 `unchanged`;同一版本已有不同内容时会拒绝覆盖。\n\n追书人当前发布版本为 `1.0.3`,使用 `content_schema_version=2`;原始 `1.0.1`\n归档仍可供已固定版本的房间读取。玩家可见的模组简介、推荐人数和开局页来自发布内容的\n`presentation` 字段,不读取面向叙述 Agent 的 `background`;升级旧数据库后需要重新执行\n上述加载命令,已有固定到旧版本的游戏不会被改写。\n\n后端默认地址:<http://127.0.0.1:8000>\n\n- 健康检查:<http://127.0.0.1:8000/api/v1/health>\n- Swagger API 文档:<http://127.0.0.1:8000/docs>\n- ReDoc API 文档:<http://127.0.0.1:8000/redoc>\n\n复制 `.env.example` 为 `.env` 后可以覆盖默认配置;不复制也可以使用代码内置的本地开发默认值。\n\n### 4. 启动前端\n\n另开一个终端:\n\n```bash\ncd trpg-frontend\nnpm ci\nnpm run dev\n```\n\n浏览器打开:<http://localhost:9877>\n\n默认后端 CORS 配置允许 `http://localhost:9877`。如果修改前端地址或端口,需要同步调整后端的 `CORS_ORIGINS`。\n\n## 环境变量\n\n### 后端 `trpg-backend/.env`\n\n| 变量 | 默认值 | 说明 |\n| --- | --- | --- |\n| `APP_ENV` | `development` | 运行环境:`development`、`production` 或 `test` |\n| `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | SQLAlchemy 异步数据库地址 |\n| `ENABLE_DOCS` | `true` | 是否开放 `/docs`、`/redoc` 和 `/openapi.json` |\n| `LOG_LEVEL` | `INFO` | 后端日志级别 |\n| `CORS_ORIGINS` | `[\"http://localhost:9877\"]` | 允许跨域访问的前端来源列表 |\n| `HOST_MODEL_PROVIDER` | `fake` | 主持模型:`fake`、`openai`、`qwen` 或 `deepseek` |\n| `OPENAI_API_KEY` | 空 | `openai` 提供商的 API 密钥 |\n| `OPENAI_BASE_URL` | `https://api.openai.com/v1` | OpenAI Responses API 根地址 |\n| `OPENAI_MODEL` | `gpt-5.6-luna` | OpenAI 模型名称 |\n| `OPENAI_TIMEOUT_SECONDS` | `30` | OpenAI 请求超时秒数 |\n| `QWEN_API_KEY` | 空 | 阿里云百炼 API 密钥 |\n| `QWEN_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | 千问 OpenAI 兼容接口根地址 |\n| `QWEN_MODEL` | `qwen3.7-plus` | 千问模型名称 |\n| `QWEN_TIMEOUT_SECONDS` | `30` | 千问请求超时秒数 |\n| `DEEPSEEK_API_KEY` | 空 | DeepSeek 或其他兼容 provider 的 API 密钥 |\n| `DEEPSEEK_BASE_URL` | `https://api.deepseek.com` | OpenAI-compatible Chat Completions 根地址 |\n| `DEEPSEEK_MODEL` | `deepseek-chat` | DeepSeek 模型名称 |\n| `DEEPSEEK_TIMEOUT_SECONDS` | `30` | DeepSeek 请求超时秒数 |\n| `HOST_SPEECH_PROVIDER` | `disabled` | 主持人语音:`disabled`、`fake` 或 `doubao`;`fake` 仅用于测试 |\n| `DOUBAO_TTS_API_KEY` | 空 | 新版豆包语音控制台 API Key(按 SecretStr 读取且禁止写日志) |\n| `DOUBAO_TTS_RESOURCE_ID` | `seed-tts-2.0` | DouBao TTS 2.0 固定服务标识 |\n| `DOUBAO_TTS_VOICES` | `[]` | 房间允许选择的音色 JSON,如 `[{\"voiceType\":\"...\",\"label\":\"旁白\"}]` |\n| `DOUBAO_TTS_DEFAULT_VOICE_TYPE` | 空 | 部署默认音色,必须包含在音色白名单中 |\n| `DOUBAO_TTS_TIMEOUT_SECONDS` | `15` | 单句合成超时;失败不自动重试,避免重复计费 |\n| `HOST_AGENT_MAX_TURNS` | `6` | 单次 Host Agent 最大模型轮数 |\n| `HOST_AGENT_MAX_TOOL_CALLS` | `8` | 单次 Host Agent 最大工具调用数 |\n| `HOST_AGENT_TOOL_TIMEOUT_SECONDS` | `5` | 单工具超时秒数 |\n| `HOST_AGENT_TIMEOUT_SECONDS` | `30` | Host Agent 整轮超时秒数 |\n| `OPENING_NARRATION_MODE` | `model` | 权威开场生成方式:`model` 或确定性 `template` |\n| `OPENING_NARRATION_TIMEOUT_SECONDS` | `10` | 开场模型生成的独立总超时秒数;失败后使用安全模板 |\n| `RECENT_HISTORY_ENABLED` | `true` | 是否向 Host/Narrator 提供玩家安全的近期回合 |\n| `RECENT_HISTORY_MAX_TURNS` | `6` | 近期历史最多保留的回合数 |\n| `RECENT_HISTORY_MAX_CHARS` | `6000` | 近期历史文本总字符预算 |\n| `CHARACTER_PORTRAIT_ENABLED` | `true` | 是否启用角色生图后端接口;前端入口默认显示 |\n| `PORTRAIT_PROMPT_PROVIDER` | `deterministic` | 提示词生成:`deterministic` 或 `deepseek` |\n| `PORTRAIT_IMAGE_PROVIDER` | `auto` | 图片 provider:自动选择 `sufy` / `dashscope` / `mock`,也可显式指定 |\n| `DASHSCOPE_API_KEY` | 空 | 阿里云百炼 API Key;只存在后端环境变量中 |\n| `DASHSCOPE_BASE_URL` | `https://dashscope.aliyuncs.com/api/v1` | 通义万相 API 根地址 |\n| `DASHSCOPE_IMAGE_MODEL` | `wan2.2-t2i-flash` | 通义万相文生图模型 |\n| `SUFY_API_KEY` | 空 | Sufy API Key;只存在后端环境变量中 |\n| `SUFY_BASE_URL` | `https://openai.sufy.com/v1` | Sufy OpenAI-compatible API 根地址 |\n| `SUFY_IMAGE_MODEL` | `google/gemini-3-pro-image` | Sufy 高质量图片生成模型 |\n| `PORTRAIT_GENERATION_TIMEOUT_SECONDS` | `120` | 图片生成和任务轮询的总超时秒数 |\n| `PORTRAIT_REFERENCE_IMAGE_PATH` | `app/assets/portrait-style-reference.png` | 后端内置漫画风格参考图路径;留空或不可读时使用纯提示词 |\n\n生图入口由前端默认显示,不再配置前端环境变量。后端默认启用且使用 `auto`:依次检查 `SUFY_API_KEY` 和 `DASHSCOPE_API_KEY`,自动选择可用的真实 provider;两者都未填写时使用 mock。如需禁用后端生图,只需设置 `CHARACTER_PORTRAIT_ENABLED=false`。\n\n### 主持模型配置\n\n`HOST_MODEL_PROVIDER` 决定意图理解和结果叙事使用的模型路径:\n\n| 值 | 请求方式 | 适用场景 |\n| --- | --- | --- |\n| `fake` | 不发送网络请求 | 默认值;本地开发、自动化测试和无密钥运行 |\n| `openai` | OpenAI Responses API + 严格 JSON Schema | 使用原生支持 `text.format=json_schema` 的模型 |\n| `qwen` | 千问 Chat Completions JSON Mode + 本地 Pydantic 校验 | 阿里云百炼千问 3.7 Plus |\n| `deepseek` | OpenAI-compatible Chat Completions + JSON Mode + 本地 Pydantic 校验 | DeepSeek;兼容同一协议的 provider 可复用 |\n\n无论使用哪种远程模型,模型只负责提出结构化意图或叙事候选;目标、场景、技能和事实引用仍会在应用边界重新校验,最终状态只由规则引擎修改。\n\n#### 使用千问 3.7 Plus\n\n1. 在[阿里云百炼控制台](https://bailian.console.aliyun.com/)创建 API Key,并取得业务空间的 `WorkspaceId`。\n2. 复制本地配置文件:\n\n ```bash\n cd trpg-backend\n cp .env.example .env\n ```\n\n PowerShell 可使用:\n\n ```powershell\n Copy-Item .env.example .env\n ```\n\n3. 只在本地 `.env` 中填写以下配置:\n\n ```dotenv\n HOST_MODEL_PROVIDER=qwen\n QWEN_API_KEY=你的百炼_API_Key\n QWEN_BASE_URL=https://你的WorkspaceId.cn-beijing.maas.aliyuncs.com/compatible-mode/v1\n QWEN_MODEL=qwen3.7-plus\n QWEN_TIMEOUT_SECONDS=30\n ```\n\n 新加坡地域将地址替换为:\n\n ```dotenv\n QWEN_BASE_URL=https://你的WorkspaceId.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1\n ```\n\n 其他地域及最新端点以[阿里云百炼 OpenAI 兼容接口文档](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-responses)为准。`.env` 已被 Git 忽略;不要把真实密钥写入 `.env.example`、README 或提交记录。\n\n4. 重启后端使配置生效:\n\n ```bash\n uv run uvicorn app.main:app --reload\n ```\n\n健康检查只能确认后端存活,不会调用模型。请进入房间提交一次自然语言行动进行验证。\n远程 provider 模式缺少 Key 时后端启动失败;Host Agent 超时、预算耗尽、非法输出或越权候选\n会发送玩家安全的 `turn.failed`,规则引擎不会执行。Narrator 失败不会重跑 Host\nAgent、重新掷骰或重复写入状态;使用同一 `clientActionId` 重试只会复用已提交结果。\n\n#### 使用 DeepSeek 或兼容 provider\n\n复制 `trpg-backend/.env.example` 为 `.env`,填写:\n\n```dotenv\nHOST_MODEL_PROVIDER=deepseek\nDEEPSEEK_API_KEY=你的_API_Key\nDEEPSEEK_BASE_URL=https://api.deepseek.com\nDEEPSEEK_MODEL=deepseek-chat\nDEEPSEEK_TIMEOUT_SECONDS=30\n```\n\nHost Agent 工具调用和结构化 Narrator 都使用 OpenAI-compatible Chat Completions。\n其他厂商如果遵循同一协议,可复用这组配置和 adapter;使用私有协议时应新增独立\nprovider adapter,而不是把私有字段混入通用请求。\n\n#### Preview 环境切换兼容 Provider\n\nPR Preview 和 Main Preview 共用一套 Preview 专用模型配置。API key 使用 GitHub\nRepository Secret,非敏感的 API 根地址和模型名使用 Repository Variables:\n\n| GitHub 配置 | 类型 | 示例值 |\n| --- | --- | --- |\n| `PREVIEW_DEEPSEEK_API_KEY` | Repository Secret | 新服务商签发的 Preview 专用 key(不要写入仓库或日志) |\n| `PREVIEW_DEEPSEEK_BASE_URL` | Repository Variable | `https://api.qnaigc.com/v1` |\n| `PREVIEW_DEEPSEEK_MODEL` | Repository Variable | `deepseek/deepseek-v4-pro-202606` |\n| `PREVIEW_SUFY_API_KEY` | Repository Secret | 与 `PREVIEW_DEEPSEEK_API_KEY` 使用同一把支持两个模型的 key |\n| `PREVIEW_SUFY_BASE_URL` | Repository Variable | `https://openai.sufy.com/v1`,可留空使用默认值 |\n| `PREVIEW_SUFY_IMAGE_MODEL` | Repository Variable | `google/gemini-3-pro-image`,可留空使用默认值 |\n\n`PREVIEW_DEEPSEEK_BASE_URL` 必须是 OpenAI-compatible API 根地址,不能填完整的\n`https://api.qnaigc.com/v1/chat/completions`;客户端会自行追加 `/chat/completions`。\n\n两份 Preview workflow 遵循同一配置规则:\n\n- key 为空时使用 `HOST_MODEL_PROVIDER=fake`,其余预览功能仍可验证;\n- DeepSeek 和 Sufy key 必须成对存在;只有一套 key 时部署立即失败,避免出现半真实链路;\n- 两套 key 都存在时,`DEEPSEEK_MODEL` 先整理角色图片提示词,`SUFY_IMAGE_MODEL` 再生成图片;\n- 当前推荐的两套模型分别为 `deepseek/deepseek-v4-pro-202606` 和 `google/gemini-3-pro-image`;\n- DeepSeek key 非空时,其 Base URL 和模型名必须同时存在,否则部署立即失败;Sufy 的 Base URL 和模型名可留空使用上述默认值;\n- 配置真实 provider 时不会静默使用代码中的旧厂商默认值;\n- fork PR 不执行部署 job,也不能读取 Repository Secret。\n\n部署服务器不会直接执行仓库中的 `docker-compose.preview.yml`,而是复制受信任的\n固定模板 `~/trpg-previews/compose-template/docker-compose.yml`。模板中的 backend\nservice 必须透传相同配置:\n\n```yaml\nenvironment:\n HOST_MODEL_PROVIDER: ${HOST_MODEL_PROVIDER:-fake}\n DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}\n DEEPSEEK_BASE_URL: ${DEEPSEEK_BASE_URL:-https://api.qnaigc.com/v1}\n DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek/deepseek-v4-pro-202606}\n CHARACTER_PORTRAIT_ENABLED: ${CHARACTER_PORTRAIT_ENABLED:-true}\n PORTRAIT_PROMPT_PROVIDER: ${PORTRAIT_PROMPT_PROVIDER:-deterministic}\n PORTRAIT_IMAGE_PROVIDER: ${PORTRAIT_IMAGE_PROVIDER:-mock}\n SUFY_API_KEY: ${SUFY_API_KEY:-}\n SUFY_BASE_URL: ${SUFY_BASE_URL:-https://openai.sufy.com/v1}\n SUFY_IMAGE_MODEL: ${SUFY_IMAGE_MODEL:-google/gemini-3-pro-image}\n PORTRAIT_REFERENCE_IMAGE_PATH: ${PORTRAIT_REFERENCE_IMAGE_PATH:-app/assets/portrait-style-reference.png}\n PORTRAIT_GENERATION_TIMEOUT_SECONDS: ${PORTRAIT_GENERATION_TIMEOUT_SECONDS:-120}\n```\n\n切换或轮换服务商时按以下顺序操作,避免 workflow 已更新但外部配置尚未就绪:\n\n1. 先更新服务器固定 compose 模板,确认 `DEEPSEEK_*`、`SUFY_*` 和 `PORTRAIT_*` 环境变量都会进入 backend。\n2. 在主仓库创建或更新 `PREVIEW_DEEPSEEK_BASE_URL`、`PREVIEW_DEEPSEEK_MODEL`、`PREVIEW_SUFY_BASE_URL` 和 `PREVIEW_SUFY_IMAGE_MODEL`。\n3. 新建 `PREVIEW_SUFY_API_KEY`,其值暂时复用 `PREVIEW_DEEPSEEK_API_KEY`;两套 Secret 按用途分别映射。\n4. 合并 workflow 改动后重新运行 Main Preview,并用同仓库测试 PR 验证 PR Preview。\n5. 在两个预览环境各完成一次自然语言行动和一次角色生图,确认 DeepSeek v4 Pro 与 Sufy Gemini 都返回真实结果。\n\n健康检查只证明容器和 HTTP 服务可用,不会产生计费模型请求,也不能证明模型配置\n正确。真实验证必须覆盖一次 Host Agent 工具调用和最终结构化输出。轮换期间不要在\nIssue、PR、Actions 参数或服务器命令历史中粘贴明文 key。\n\n公共 WebSocket 只发送安全进度:`turn.started`、`turn.phase_changed`、\n`tool.started`、`tool.completed`、`turn.failed` 和 `view.updated`。内部 call id、\n工具参数/结果、Prompt、raw model output、reasoning、异常栈和模组秘密不会进入浏览器。\n\n### 前端 `trpg-frontend/.env`\n\n| 变量 | 默认值 | 说明 |\n| --- | --- | --- |\n| `VITE_API_BASE_URL` | `http://127.0.0.1:8000/api/v1` | REST API 根地址;WebSocket 地址由 SDK 自动推导 |\n\n## 构建与检查\n\n### SDK\n\n```bash\ncd trpg-sdk\nnpm ci\nnpm run lint\nnpm run typecheck\nnpm run build\nnpm test\n```\n\n### 前端\n\n```bash\ncd trpg-frontend\nnpm ci\nnpm run lint\nnpm run build # 内部先跑 tsc -b 做类型检查,再用 vite build 打包\n```\n\n### 后端\n\n```bash\ncd trpg-backend\nuv sync --locked\nuv run ruff check .\nuv run ruff format --check .\nuv run ty check\nuv run pytest\n```\n\n## 类型生成(codegen)\n\n`trpg-sdk/src/generated/dto.ts` 里跟后端 DTO 对应的 TS 类型,是从\n`trpg-backend/app/dto/*.py` 的 Pydantic 模型自动生成的,不再手写。**改了后端\nDTO(REST 请求/响应体,或 `app/dto/ws.py` 里的 WebSocket 事件 payload)之后**,\n需要依次跑:\n\n```bash\n# 1. 后端:把 DTO 导出成 JSON Schema(临时中间产物,不进 git)\ncd trpg-backend\nuv run python scripts/export_schema.py\n\n# 2. SDK:从 JSON Schema 生成 TS 类型,写入 src/generated/dto.ts\ncd ../trpg-sdk\nnpm run codegen\n```\n\n然后把 `trpg-sdk/src/generated/dto.ts` 的改动**跟 DTO 改动一起提交**——这个\n文件是生成产物但会进 git(跟 `dist/` 不同:`dist/` 的消费者是机器,这个文件\n的消费者是人和 CI,见 issue #75 的决策记录)。忘记重新生成会被 Backend CI 的\n`codegen-drift` job 拦下(见下面「持续集成」)。\n\n## 持续集成\n\n`.github/workflows/` 下有多个互相独立的 workflow,各自按路径过滤器触发,只有\n真正改到对应目录才会跑:\n\n| Workflow | 触发路径 | 检查内容 |\n| --- | --- | --- |\n| `trpg-backend-ci.yml`(Backend CI) | `trpg-backend/**`;另外 `trpg-sdk/scripts/generate-types.ts` 和 `trpg-sdk/src/generated/**` 也会触发(见下) | `ruff check`、`ruff format --check`、`ty check`、`pytest`;另有 `codegen-drift` job:重新跑一遍 DTO → JSON Schema → TS 生成管线,用 `git diff` 确认 `trpg-sdk/src/generated/` 跟提交的一致,不一致就报错 |\n| `trpg-sdk-ci.yml`(SDK CI) | `trpg-sdk/**` | `npm run lint`、`npm run typecheck`、`npm run build` |\n| `trpg-frontend-ci.yml`(Frontend CI) | `trpg-frontend/**` | `npm run lint`、`npm run build` |\n| `pr-preview.yml`(PR Preview) | PR 打开、更新、重开、关闭 | 部署或回收 PR 专属预览环境 |\n| `main-preview.yml`(Main Preview) | PR 合并到 `main` | 更新固定端口的持久预览环境 |\n\n`codegen-drift` 放在 Backend CI 而不是 SDK CI:它要在\"改了 DTO 却忘记重新\n生成\"的那个 PR 上就亮红灯,而 SDK CI 只在 `trpg-sdk/**` 变化时触发——一个纯\n改后端 DTO 的 PR 根本不会碰 `trpg-sdk/**`,放在 SDK CI 里等于没测。这也是\nBackend CI 的路径过滤器额外加了两条 `trpg-sdk/` 路径的原因。\n\n## 团队\n\n| 成员 | GitHub |\n| --- | --- |\n| 高俊周 (GJZ) | [@WELT5350](https://github.com/WELT5350) |\n| 凌铭辉 (LMH) | [@LMH168](https://github.com/LMH168) |\n| 李敏譞 (LMX) | [@Ximaohu-LMX](https://github.com/Ximaohu-LMX) |\n| 张家豪 (ZJH) | [@JoshuaZ16](https://github.com/JoshuaZ16) |\n| 黄女珊 (HNS) | [@badadal](https://github.com/badadal) |\n| 卢玮晨 (LWC) | [@Lyltrum](https://github.com/Lyltrum) |\n\n## 协作约定\n\n- 通过 fork + Pull Request 提交变更,不直接向主仓库主分支提交。\n- Commit message 遵循 [Conventional Commits](https://www.conventionalcommits.org/zh-hans/v1.0.0/)。\n- 后端 DTO(REST 或 WebSocket)发生变化时,按上面「类型生成(codegen)」的步骤重新生成 `trpg-sdk` 的类型并把生成结果一起提交,不再手动改 `trpg-sdk/src/types.ts`。\n\n---\n\n[1024 XEngineer Camp](https://github.com/1024XEngineer) Season 6\n"}}}}