单端口架构#
一个进程、一个端口,Python worker 是唯一源站。端口的配置入口与冲突 处理见 ports.zh.md。
1. 三进程带来的问题#
双端口运行时是三个进程组成的脆弱依赖链:
Electron 壳 → Next.js 服务(Node,web 端口) → Python worker(后端端口)
- Next 的
rewrites()在构建时固定/ws的代理目标。build 时环境变量 不对,/ws就指向死端口,所有客户端集体 "disconnected"。现有两个补丁 纯粹为绕开它:_patch_manifest_ports正则改写.next/routes-manifest.json(openprogram/worker/web.py),以及/api/[...path]route handler 每请求重读worker.port。 - worker 还要拉起并看护 Next 进程:
openprogram/worker/web.py约 330 行的 端口回收、孤儿next-server查杀、BUILD_ID 监视、manifest 补丁、父进程 PID 监视(web/scripts/with-parent-watch.mjs)。 - 用户运行时必须装 Node,只为渲染一个本来就 100% 客户端的 UI。
2. 为什么合并很便宜#
前端没有任何东西需要 Node 服务:
- 应用壳用
next/dynamic+ssr: false加载 (web/app/(shell)/layout.tsx),所有实际页面都是"use client"。 - 没有
middleware.ts、server actions、next/image,也没有output/basePath/headers等需要拆的配置。 - 仅有的两个 route handler(
app/api/[...path]、app/files/[...path]) 就是转发到 worker 的代理——单端口下 worker 本身就是源站。 - worker 的 FastAPI 已经在托管静态内容(
/docs文档站、/files/raw), 且docs_url=None,没有路由冲突。
3. 方案#
一个进程、一个端口,Python worker 托管一切:
Electron 壳 → Python worker(FastAPI,单端口)
├─ /ws 原生 WebSocket(index-0 路由)
├─ /api/* 原生 router
├─ /files/raw
├─ /docs/*
└─ /* Next 静态导出(out/)+ SPA 回退
3.1 前端是静态导出#
web/next.config.mjs:
output: "export"→next build输出纯 HTML/JS/CSS 到web/out/。- 没有
rewrites(),也没有resolveBackend()——不存在需要解析的代理目标。 - 前端只跟自己的源站说话(
/ws、/api/...相对路径)。
动态路由段((shell)/s/[sessionId]、(shell)/skills/[...name]、
(shell)/settings/providers/[providerId]、plugin/[name]/[...slug])都是
返回 null 或纯客户端从 pathname 解析参数的占位页。静态导出不带
generateStaticParams 会拒绝它们,因此这些 page 文件不存在;SPA 回退
(3.2)为这些路径返回壳页面,客户端路由处理其余部分。若某段真有服务端
逻辑,则保留它并加一个返回占位值的 generateStaticParams。
app/api/[...path]/route.ts 和 app/files/[...path]/route.ts 不存在。
3.2 worker 托管导出产物#
openprogram/webui/frontend.py,在 create_app() 最后挂载:
- 静态文件来自
web/out/(/_next/static加 immutable 缓存头,HTML no-cache)。 - SPA 回退:任何未命中文件也未命中 API 路由的 GET,返回壳 HTML
(
out/chat.html——应用本来就/→/chat重定向,其余全靠pathname客户端解析)。 - 构建门:
web/out/缺失或比web/源码旧,就在启动时跑一次npm run build。Node 从此只是构建期依赖;打包发布版直接携带预构建的out/,运行时完全不碰 Node。
3.3 没有进程看护#
没有任何东西拉起或监视 Node 进程。openprogram/worker/web.py(spawn、
端口回收、manifest 补丁、BUILD_ID 监视)、web/scripts/with-parent-watch.mjs
以及 openprogram/worker/runner.py 里的 start_web_frontend 调用在这里都
没有对应物。
3.4 端口语义#
后端端口就是唯一端口,web 端口相关旋钮退役:
| 双端口 | 单端口 | |
|---|---|---|
| stable | web 18100 / 后端 18109 | 18100 |
| dev | web 18200 / 后端 18209 | 18200 |
OPENPROGRAM_WEB_PORT与 UI 偏好web_port:过渡期作为后端端口的别名 接受(打警告日志),之后移除。worker.port文件:不变,仍是端口发现的唯一权威。- Electron
desktop/main.js:WEB_PORT常量(dev 18200 / 发布 18100) 现在就是 worker 端口;三处使用(启动 URL、origin 校验、导航守卫)无需 结构性改动。 scripts/promote_stable.sh:npm run build输出out/。
4. 不变量#
- 后端是唯一源站。 没有代理层、没有第二个服务、任何地方都不在构建时
固定端口。
/ws目标天然正确,因为它就是页面加载来源。 - API 路由永远优先于静态。 前端挂载注册在最后,SPA 回退只处理没有 任何 router 认领的路径。
- Node 只在构建期。 运行时依赖是 Python 加 worker。
5. 取舍#
- 开发迭代失去
next devHMR 对合并源站的直连。npm run dev仍可用—— ws/api 客户端工具在设置了 dev 专用环境变量 (NEXT_PUBLIC_BACKEND_ORIGIN)时指向运行中的 worker;生产代码路径仍是 同源相对路径。 - 真渲染内容的动态段,在其 page 文件不存在后深链会 404。由 SPA 回退兜底, 四个段逐一核实。
- 拉取前端改动后
out/可能过期。启动时的构建门(mtime 检查)覆盖;拉代码 后openprogram restart是既定工作流。
6. 验收标准#
openprogram(dev profile)启动后只监听一个端口;lsof里没有next-server。- 直接刷新
/chat、/s/<id>、/settings/providers/<id>、/skills/<name>全部能渲染;/ws连通;/api/pick-folder可用。 - 杀掉 worker:已加载页面显示 disconnected;重启后重连。任何端口上都没有 孤儿进程。
- 不带任何 profile 环境变量做一次 build,实例照样工作——构建时固定 端口这一整类故障从结构上消失。
- 全量测试通过;桌面应用重新打包并验证。
路线图定位#
单端口是通向零依赖安装的三步中的第一步:
- 单端口(本文):worker 托管前端,Node 降为构建期依赖。
- 壳监管 worker:Electron 负责 spawn、监视、重启 worker,配真实状态页, 覆盖首启引导进度。
- 零依赖安装(uv 引导):安装包携带 Electron、预构建的
out/和独立的 uv 二进制(约 15 MB)。首次启动uv python install拉取 python-build-standalone 的独立 Python(装进应用私有目录,不碰系统 Python),再按锁文件uv sync装依赖;之后启动复用已装环境。国内首启 可靠性要求配置镜像(UV_PYTHON_INSTALL_MIRROR+ 国内 PyPI 源)。 不用 PyInstaller。
附录:实现状态#
设计已定。路线图第 2、3 步建立在本文之上,另有各自的设计文档。