{"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 <\" | 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 = '';\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 < 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 <\" | 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 = '';\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 < 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 <\" | 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 = '';\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 < 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 <\n \"MS1\"\n \"React\n \"FastAPI\n \"WebSocket\"\n

\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后端默认地址:\n\n- 健康检查:\n- Swagger API 文档:\n- ReDoc API 文档:\n\n复制 `.env.example` 为 `.env` 后可以覆盖默认配置;不复制也可以使用代码内置的本地开发默认值。\n\n### 4. 启动前端\n\n另开一个终端:\n\n```bash\ncd trpg-frontend\nnpm ci\nnpm run dev\n```\n\n浏览器打开:\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"}}}}