AGENTS.md · git:20260913.a421bed · 2026-09-13 · sha256 18fe1e1cf405937b
AGENTS.md git:20260913.a421bedA
Immutable. This exact content is served forever at /api/v1/blob/18fe1e1cf405937b.
# AGENTS.md — pi-web-ui 项目指南
> 本文件是给 AI 编码助手(pi / Claude Code / Cursor 等)看的高层项目说明书。
> 详细文档按主题分拆在 `docs/` 目录下。
> 修改本文件后,在 pi 中运行 `/reload` 生效。
## 1. 项目是什么
pi-web-ui 是 pi 编码智能体(`@earendil-works/pi-coding-agent` SDK)的 Web 聊天界面:
浏览器里对话、查看文件树、附加文件、内置终端(xterm.js + node-pty)、模型管理、
声音提醒、中英文切换。一条命令可跑(`pi-web-ui`),可 Docker / systemd / launchd /
Windows 计划任务部署。
另有 **Electron 桌面壳**(`desktop/`):随机空闲口起同一个 server + BrowserWindow,网页版零改动;
Windows 安装包随 GitHub Release 发布(CI 出包,当前未签名,见 `desktop/README.md`)。
- 仓库(公开):`git@github.com:xing-shuyin/pi-web-ui.git`
- npm 包:`pi-web-ui`(发布者 npm 账号 `xingshuyin`)
- Node 要求:**>= 22.19.0**(pi SDK 的 dist 使用了 `import … with { type: "json" }` 语法)
- 版本:`package.json` 与 `package-lock.json` 两处同步维护
## 2. 技术栈
| 层 | 技术 |
| ------ | ----------------------------------------------------------------------------- |
| 后端 | Node + Express(静态 + `/api/health`)+ `ws`(`/ws` WebSocket 协议) |
| 前端 | React 18 + Vite 6 + react-markdown + highlight.js + xterm.js |
| 智能体 | `@earendil-works/pi-coding-agent` SDK(进程内,读 `~/.pi/agent` 配置) |
| 终端 | node-pty(服务端 PTY)+ `@xterm/xterm`(浏览器渲染,经 terminal bridge 转发) |
| 样式 | 单文件 `web/src/styles.css`(CSS 变量主题,深色) |
## 3. 目录结构
```
pi-web-ui/
├── server/ # 后端(Node ESM,编译到 dist/server/)
│ ├── index.ts # 入口:express 静态 + /ws 端点、消息分发、心跳、优雅停机
│ ├── protocol.ts # ★ 唯一事实源:wire 协议类型(client↔server 消息)
│ ├── agent-service.ts # 核心:ClientSession(每客户端一个会话组,可并行多个对话)+ AgentService
│ ├── serialize.ts # SDK 消息 → UiMessage 序列化
│ ├── text-sniff.ts # 文件预览纯函数(previewKind/looksLikeText/decodeText/sniffImageMime/hexDump/countLines)
│ ├── queue-utils.ts # 排队消息纯函数(removeFirstOccurrence:只移除第一条匹配,重复文本不连带删除)
│ ├── process-utils.ts # 进程工具:snapshotListeningPorts/killPidTree/lookupProcessName
│ ├── client-state.ts # ClientStateStore:<dataDir>/client-state.json 持久化
│ ├── uploads.ts # 文件对话上传 + 保留期清理
│ ├── bg-servers.ts # 后台任务跟踪(bash 前后端口快照 diff + 存活刷新)
│ ├── settings-service.ts # 设置面板状态机
│ ├── goal-service.ts # 目标/审查循环/调研向导
│ ├── i18n.ts # 服务端语言协商 + 翻译表注册(resolveServerLang/pick/bilingual/getServerBlock;v2 见下)
│ ├── tool-manager.ts # ★ Agent 工具统一开关:AGENT_TOOL_CATALOG(终端 7+子代理 7+其他 5:edit_soft/delegate_task/ask_user_question/todo_list/browser_page)+ tool_manage 出入口(setAgentToolEnabled/applyAgentToolsGating),持久化只有 disabledAgentTools,遗留三开关双向同步
│ ├── edit-soft-tool.ts # 独立宽松编辑工具 edit_soft(行核心匹配,忽略缩进差异;开关走统一工具 tab)
│ ├── subagents.ts # 第一方子代理:subagent_* 工具(spawn/get_result/steer/list/stop/templates)+ 运行态快照
│ ├── subagent-templates.ts # 子代理模板库(全局 <dataDir>/subagent-templates.json;白名单语义;可选模型/思考强度,空=跟随主对话;enabled=false 对 AI 不可见)
│ ├── slash-commands.ts # 斜杠命令(NATIVE_COMMANDS 内置命令拦截执行 + 目录推送)
│ ├── model-admin.ts # 模型/服务商配置管理(含内置服务商多密钥:provider-keys.json + add/activate/remove_provider_key,模型目录复用系统默认,不复制)
│ ├── attachments.ts # 附件构建(inline/reference/lines/imageData/fileData + 视觉桥)
│ ├── webui-context.ts # 扩展 UI 桥(WebUIContext:widgets/statuses/dialog → 浏览器)
│ ├── themes.ts # 主题管理(listThemes/resolveThemeFile)
│ ├── plugins.ts # 可选界面组件插件(扫描 <dataDir>/plugins/<id>/;renderer 插件字段 view:false + renderers)
│ ├── plugin-catalog.ts # 插件市场列表(builtin plugins/catalog.json + 用户自定义 <dataDir>/plugin-catalog.json)
│ ├── vision-bridge.ts # 视觉桥:纯文本主模型看图转写
│ ├── files-service.ts # 文件服务(readDirForUI/readFile/searchFiles/watcher)
│ ├── scm.ts # SCM 只读 git 查询(execFile git status/branches/history/filediff/commit)
│ ├── patch-node-pty.ts # node-pty × Node --watch 兼容自愈补丁
│ ├── patch-remote-catalog.ts # pi.dev 模型目录整表替换补丁(幂等改写 SDK remote-catalog-provider:刷新后内置服务商列表=官方目录整表,不保留内置旧模型/不报“新增 N 个”)
│ ├── ensure-bash.ts # Windows 轻量 bash 兜底(busybox-w32)
│ ├── control-socket.ts # 本地控制 socket(status / quiesce / unquiesce)
│ ├── launch-origin.ts # ★ 启动来源探测:本实例是否被 launchd/systemd/Windows watchdog 托管(更新面板「重启服务」的前置条件)
│ └── terminals.ts # TerminalManager(PTY 管理 + 增量输出/按键工具)
├── web/ # 前端(React + Vite,编译到 web/dist/)
│ ├── vite.config.ts # dev 端口 5173,/ws 代理到后端
│ ├── src/
│ │ ├── App.tsx # 顶层布局
│ │ ├── use-chat.ts # ★ useChat():WebSocket 连接管理、reducer 状态机、终端 bridge
│ │ ├── app-globals.ts # ★ 全局运行态 store(engine/managed/tabs/版本号 + 全局发送器 appSend)
│ │ ├── types.ts # ★ wire 协议 re-export shim(`export type * from "../../server/protocol"`)
│ │ ├── i18n.tsx # ★ 多语文案(zh/en/it,zh 默认),新增 key 必须三处都加
│ │ ├── styles.css # ★ 全部样式(按组件分区,带注释分隔线);也是默认深色主题本体
│ │ ├── theme.ts # 主题切换(/api/themes 列表 + localStorage 持久化 + applyTheme)
│ │ ├── sounds.ts # WebAudio 提示音
│ │ ├── notify.ts # 桌面/OS 通知(PWA):是否吞掉通知的判定(Windows 最小化检测)+ 诊断",
│ │ ├── download.ts # 下载(fetch→blob,绕开 Chrome Safe Browsing)
│ │ ├── composer-bridge.ts # ★ 输入框注入桥:宿主(扩展/插件)把内容塞进输入框草稿的模块级 sink 注册点,有单测(配 composer-draft.ts 的合并/去重纯函数)
│ │ ├── caret-visual-line.ts # ★ 输入框光标的首/末**视觉行**判定(镜像 div 量 offsetTop,自动折行算行;无布局时回落逻辑行),供 ↑/↓ 翻输入历史用,有单测 + 真浏览器回归(tests/composer-history-test.mjs)
│ │ ├── message-delta.ts # message_delta 增量 patch 纯函数,有单测
│ │ ├── lazy-window.ts # 消息列表惰性窗口化纯函数,有单测
│ │ ├── search-text.ts # 会话内搜索索引纯函数,有单测
│ │ ├── skill-block.ts # parseSkillBlock:<skill> 块解析,有单测
│ │ ├── tool-args.ts # 工具卡头参数提示纯函数(路径/超时安全提取,脏参数不抛错),有单测
│ │ ├── auth-token.ts # PI_WEB_TOKEN 口令注入,有单测
│ │ ├── image-paste.ts # 粘贴图片等比缩放 ≤1568px + PNG/JPEG 转码
│ │ ├── uuid.ts # randomUuid(crypto 兜底),有单测
│ │ ├── protocol-version.ts # 协议版本常量
│ │ ├── main.tsx # 入口:首帧前应用主题防闪烁 + initAuthToken
│ │ └── components/ # 见下
│ └── dist/ # 构建产物(gitignore,但打进 npm 包)
├── bin/pi-web-ui.mjs # CLI:前台启动 / server install|uninstall|start|stop|restart|status
├── desktop/ # Electron 桌面壳(sidecar:随机空闲口起 dist/server + BrowserWindow)
│ ├── main.ts # 主进程:ready → 选空闲口 → spawn server(ELECTRON_RUN_AS_NODE) → loadURL
│ ├── electron-builder.yml # 打包配置(win nsis / mac dmg / linux AppImage;npmRebuild:false)
│ └── README.md # 桌面版说明:跑起来 / 约定 / 发布 / 签名(SignPath)
├── deploy/ # 部署示例:launchd plist / systemd unit / Windows 任务 XML
├── themes/ # 内置主题(纯 :root 调色板覆盖,不含布局)
├── make-light-theme.mjs # 主题生成器(从 styles.css 的 :root 变量清单生成纯调色板)
├── tests/ # 全部测试脚本(自包含:独立端口 ≥8900 + 临时 data-dir)
│ ├── run-smoke.mjs # 零 token 协议冒烟聚合跑器
│ ├── unit/ # vitest 纯函数单测
│ ├── *-test.mjs # 手写 Playwright E2E / WS 协议测试
│ └── scratch/ # 一次性调试脚本(gitignore,不入库)
├── scripts/check-protocol-sync.mjs # 守护 types.ts shim 单源机制 + protocol.ts 纯类型约束
├── .github/workflows/ci.yml # CI:协议同步 → typecheck → build → vitest → 冒烟
├── .github/workflows/release-notes.yml # tag 推送 → 按 CHANGELOG 建/更新 GitHub Release
├── .github/workflows/desktop-release.yml # tag 推送 → windows-latest 出 NSIS 安装包并附到 Release(签名以后加这里)
├── extensions/ # pi 扩展:webui.ts(/webui 命令启动本机服务并打开浏览器)
├── plugins/ # 官方插件(webmail / db-client / vscode-editor / demo-mailbox / mermaid / run-trace / legado-web / image-toolkit,各自的 README.md 见其目录;page-picker/extension 是**浏览器扩展**,不是 pi-web-ui 插件)
│ └── catalog.json # ★ 插件市场内置列表(随包发布;社区加插件 = 在此加一条 + PR)
├── dev/ # 本地开发辅助(notice/search 预览等,不入 npm 包)
├── Dockerfile / docker-compose.yml
├── docs/ # 详细文档(本文件的分拆)
│ ├── architecture-core.md # 核心架构:快照驱动、协议单源、安全边界、主题切换、多对话并发
│ ├── architecture-attachments.md # 附件、图片、视觉桥、文件上传/预览/下载
│ ├── architecture-terminal.md # 终端架构:PTY 管理、SCM 查询、活力检测、终端接管 bash
│ ├── architecture-plugins.md # 插件系统:形态、协议、宿主扩展点、MCP 桥
│ ├── architecture-system-prompt.md # 系统提示词组成:base/追加段/项目上下文/技能段 组装链路与 override 钩子
│ ├── development.md # 开发工作流、CI、编码约定、测试规范
│ ├── release.md # 发布流程(GitHub + npm)
│ ├── deployment.md # 部署(CLI / Docker)
│ └── env-vars.md # 环境变量参考
├── tsconfig.server.json / tsconfig.extensions.json / tsconfig.tests.json / web/tsconfig.json
```
`web/src/components/` 速览:
| 组件 | 职责 |
| ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FilePreview.tsx` | 文件预览弹窗:行号、点选/拖拽/Shift 选区、添加到对话;Markdown 预览可切换原文;可编辑保存 |
| `LeftPanel.tsx` | 左栏:最近项目、运行的对话、历史对话(含删除) |
| `RightPanel.tsx` | 文件树浏览(list_files),文件名点击→预览,📎/🔗/👁 附件按钮;服务端原生递归 watcher |
| `ChatInput.tsx` | 输入框 + 附件 chips(inline/reference/lines 三色);全窗口拖放目标;followUp 排队/steer 插队;斜杠命令选择器 |
| `Message.tsx` / `MessageList.tsx` | 消息渲染(附件卡片、流式光标、tool 结果关联);编辑重问保留原附件;技能卡片折叠;惰性窗口化;问题导航双通道;流式 StreamMarkdown |
| `ToolCallBlock.tsx` / `ThinkingBlock.tsx` / `BashBlock` | 工具调用卡片(卡头状态图标右侧显示关键参数提示:文件路径 `.toolcall-path` / 超时 `.toolcall-timeout`,任何工具都试取,脏参数静默不显示)、思考块、bash 输出 |
| `TerminalPanel.tsx` / `TermXterm.tsx` | 终端视图 + xterm 实例桥接 |
| `SCMPanel.tsx` | 源代码管理(Git)视图:status/branch/diff;提交/推送/拉取/切换分支;左栏宽度可拖分隔条调、双击复位、宽度存 localStorage(#139) |
| `BrowserControl.tsx` | 顶栏「浏览器操作」入口 + 状态面板(扩展在不在 / 授权几个页面 / 总开关 / 打开扩展设置页 / 可照抄例子);已授权页面一键「引用到对话」→ `mode:"page"` 的网页引用附件 chip(单页面时顶栏按钮直接变成该页面标题,点主体=引用、右侧 ▾=开面板) |
| `TopBar.tsx` / `FooterBar.tsx` | 顶栏(模型/思考强度/后台任务/声音/新对话/视图切换)、底栏(上下文/成本/工作目录) |
| `Dialog.tsx` | 扩展 `ui.select/confirm/input` → 浏览器弹窗(正文/选项走 `Markdown(rawHtml)` 富渲染) |
| `DshQuestionDialog.tsx` | 模型提问对话框(`question_pending`,DSH 引擎经 goal-rpc userQuestions、标准 pi 引擎经 pi-web-ui 注册的 `ask_user_question` customTool 共用):单选/多选/自定义文本 + 选中带 `preview` 的选项时「选项预览」富文本;question/detail/description/preview 走 `Markdown(rawHtml)`。待答问卷同时挂在快照(`UiState.pendingQuestion`)上,刷新/重连后由 `web/src/pending-question.ts` 恢复面板 |
| `ModelConfigModal.tsx` / `PiSetupModal.tsx` | models.json 管理 / 首次配置引导 |
| `SettingsModal.tsx` | 设置面板(侧边栏分页:提示词/工具(含终端+标记管理)/消息显示/技能/插件/界面插件/目标审查/视觉桥/预设/子代理模板;DSH 另有问卷页、无工具页) |
| `GoalBar.tsx` | 输入框上方目标条:设目标/清除/AI 提炼/轮数下拉 |
| `BgTasksModal.tsx` | 后台任务弹窗:AI 启动的监听端口进程列表 |
| `ModelThinking.tsx` | 模型 + 思考强度下拉(模型下拉左侧按服务商筛选 + 顶部搜索过滤框) |
| `GlobalSearchModal.tsx` | 全局搜索弹窗(Ctrl+K):搜历史对话/最近项目/工作区文件名 |
| `PluginView.tsx` | 插件视图宿主:薄 React 壳 + 动态 import client bundle |
| `CollapsedMessage.tsx` / `LazyMount.tsx` | 消息折叠摘要行 / 消息级惰性挂载包装 |
| `SearchBar.tsx` | 会话内搜索栏(Ctrl+F,CSS Custom Highlight API 高亮) |
| `Markdown.tsx` / `Dropdown.tsx` / `copy-button.tsx` / `HintTip.tsx` / `SoundSettings.tsx` | 通用件(HintTip:`?` 悬浮提示 portal 顶层渲染) |
## 4. 核心架构(摘要)
> 详细文档见 `docs/architecture-*.md`
| 主题 | 文档 | 要点 |
| -------------------- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **快照驱动** | `docs/architecture-core.md` | 服务端是唯一事实源,60ms 节流推快照;增量快照(snapshot_delta);message_delta 实时增量通道不经 snapshot 通道;WS permessage-deflate 压缩;多标签页序列化共享;协议版本协商 |
| **协议单源** | `docs/architecture-core.md` | `server/protocol.ts` 是唯一事实源;`web/src/types.ts` 是 `export type *` shim;新增消息只改 protocol.ts,两端 switch 各加分支 |
| **全局运行态** | `docs/architecture-core.md` | `web/src/app-globals.ts`:身份/能力(engine/managed/tabs/版本号)+ 连接态与 cwd 这类「整棵树都要」的信息放模块级 store,窄 props 组件用 `useAppField(key)` 单字段订阅、不再要 prop(吃整个 ChatState 的 App/TopBar/FooterBar 仍直读 `chat.*`);全局发送器 `appSend` 也在这里(组件不收 `send` prop,面板的 `panelSend` 包装除外);快照流里的数据严禁进去(store 通知绕过 memo) |
| **安全边界** | `docs/architecture-core.md` | 默认只绑 loopback;WS Origin/Host 同权威校验;quiesce 准入控制;控制 socket;provider headers 不下发浏览器 |
| **主题切换** | `docs/architecture-core.md` | styles.css 是唯一布局文件,主题 = 纯 `:root` 变量覆盖(非整文件副本);内置主题由 make-light-theme.mjs 从 styles.css 变量清单生成;改布局永不碰主题;终端跟随主题 |
| **多对话并发** | `docs/architecture-core.md` | 每对话独立 AgentSessionRuntime;对话按项目归属;set_cwd 切到目标项目对话;8 个上限/项目(子代理不计入);共享同一个 ModelRuntime;左栏「运行的对话」的展示口径 = listed ∪「当前对话 + 已有内容」(#140:空白对话不入列、只影响展示,不动保留/释放规则) |
| **附件** | `docs/architecture-attachments.md` | 三种模式(inline/reference/lines);图片问答(base64 + 缩放);文件上传(fileData 落盘);视觉桥(纯文本模型看图转写) |
| **文件预览** | `docs/architecture-attachments.md` | 512KB 上限 + 内容嗅探(文本/二进制 + GBK 回退);媒体预览走 HTTP Range;下载绕开 Chrome Safe Browsing |
| **终端** | `docs/architecture-terminal.md` | 每 Conversation 一个 TerminalManager;spawn 统一准入;按键编码纯函数;输出微批合并;node-pty × --watch 兼容自愈 |
| **SCM** | `docs/architecture-terminal.md` | 只读 git 查询走 execFile 直跑(不经过 shell);git-dir watcher;写操作走可见终端 tab |
| **终端接管 bash** | `docs/architecture-terminal.md` | 覆盖 SDK bash;设置开关 `terminalBash` 分流(关=原生 SDK 纯进程 bash,开=可见终端);开时 `persist` 决定一次性/持久(false=跑完进程结束、输出保留;true=持久 ai-bash,shell 状态跨调用保留);`head`/`tail` 截返回行;哨兵行技术;静默解阻(持久);ai-bash/ai-bash-<n> 前端「AI bash」折叠分组且不计入终端数量上限 |
| **插件** | `docs/architecture-plugins.md` | <dataDir>/plugins/<id>/ 目录(manifest.json + index.mjs + client/entry.mjs);attach 时热重扫;fenced-code 渲染插件(renderers + view:false,命中 ```lang 才懒加载,见 plugin-fence.ts);官方插件走 `pi-web-ui install`(含子目录 source)分发,不进 npm 包;插件市场(plugins/catalog.json 内置列表 + 用户自定义,设置面板一键 `install --name <id>`,见 plugin-catalog.ts);插件→宿主动作桥 `window.__piWebUiHost`(setView / startChat:新建对话+自动发消息,时序见 web/src/plugin-host.ts;compose:塞进输入框草稿等用户自己发,见 web/src/composer-bridge.ts);MCP 工具桥 |
| **子代理模板** | `server/subagents.ts` + `server/subagent-templates.ts` | 设置面板配置角色系统提示词(append/replace)+ 技能/扩展白名单 + 可选模型/思考强度(空 = 跟随主对话当前模型/强度);AI 经 subagent_templates 查询、subagent_spawn(template=) 选用也可不传按默认;停用模板对 AI 不可见;全局共享 |
| **工具结束实时状态** | `docs/architecture-core.md` | tool_status 先于快照落盘,浏览器卡片立即从「执行中」→「已结束」 |
| **工具挂死看门狗** | `docs/architecture-core.md` | 20 分钟超时自动 abort 会话;只停止运行不碰后台服务;**`ask_user_question` 豁免**(等人类回答不限时,问卷挂着也不算失联) |
| **待答问卷进快照** | `docs/architecture-core.md` | `UiState.pendingQuestion` + `web/src/pending-question.ts`:刷新/重连后恢复问卷对话框(即时通道只推给提问那一刻在线的连接) |
| **后台任务列表** | `docs/architecture-core.md` | bash 前后端口快照 diff;按客户端持久;单停/全部关闭 |
| **扩展 UI 桥** | `docs/architecture-core.md` | setWidget/setStatus/notify/select/confirm/input → 浏览器消息;dialog_response 回传 |
| **模型操作浏览器页面** | `docs/architecture-core.md` | `browser_page` 工具:模型 → 服务端(`page_request`)→ 浏览器里的 pi-web-ui 页面(宿主桥 `pageCall`,宿主 API v3)→ page-picker 扩展 → **被授权的目标页面**(`page_response`);闸门全在扩展侧(授权表 + 动作白名单 + 总开关 + `eval` 开关);页面关着时工具报错,不做服务端代理 |
## 5. 开发工作流
> 详细文档见 `docs/development.md`
```bash
npm run dev # 并行:node --watch 后端(:8788) + vite 前端(:5173)
npm run typecheck # 双端 tsc --noEmit(提交前必跑)
npm run format # prettier 全仓库格式化(提交前必跑;只检查用 format:check)
npm run lint # oxlint(未使用变量/危险模式;提交前必跑;自动修用 lint:fix)
npm run build # build:web (vite) + build:server (tsc)
npm start # 跑编译产物 dist/server/index.js(生产)
npm test # vitest 纯函数单测
npm run test:smoke # 零 token 协议冒烟聚合跑器
npm run desktop:dev # Electron 桌面壳指到本地 sidecar(:随机口,需先 npm run build)
npm run build:extension # 打包 page-picker 浏览器扩展(esbuild → plugins/page-picker/extension/dist/)
npm run pack:extension # 打浏览器扩展 zip → release/(打 tag 时 CI 自动出包并挂 Release)
npm run desktop:dist # 本地打桌面安装包 → release/(gitignore;CI 也跑同一条)
```
**关键约定**:缩进用 Tab;i18n 走 `useT()`(核心只含 `zh`/`en`,其余语言是 `locales/*.json` 可下载语言包、不进 npm,缺 key 自动回落英文;新 key 加 zh+en 即可,`tests/unit/locales.test.ts` 锁 key 对齐;8 个语言包也要同步加 key 且顺序与 zh 一致);服务端多语言(issue #91):`server/i18n.ts` 中英内联 + 翻译表(v2)。`resolveServerLang` 只做归一(zh-CN→zh、pt-BR→pt,空→en);`pick(lang,zh,en,key?)` 第 4 参数是全局唯一翻译 key(`<模块>.<slug>`,如 `subagents.list.empty`),zh 走内联中文、其他语言查表、缺表/缺 key 回落英文;多行块用 `getServerBlock`(表里存 `\n` 拼接的一行);tool definition 用 `bilingual(en,zh)` 静态双语(无 key,模型看英文无碍);模板内容/用户覆盖保持 zh/en 字段(配置品,不进表)。第三语言的表放在语言包的 `serverStrings` 节(与 `strings` 同文件、一次下载全带走,`validatePack` 校验;缺 key 自动回英文所以部分翻译可安全上线);服务端启动 + 包安装/删除时经 `loadServerStrings`/`unloadServerStrings` 注册。浏览器经 `hello.locale`/`set_locale` 上报 UI 语言(`client-state.json` 的 `locale` 持久化,切换经 settings reload 通道自动应用);maker 统一收可选 `lang?: () => ServerLang`,推 UI 的 notice 走 `text`+`textEn` 双字段(前端按 locale 自选);样式全部在 `styles.css`;新增协议消息只改 `protocol.ts` 再两端 switch 加分支;**前端新增服务端 URL(`/ws`、`/api/*`、`/plugins/*`、`/themes/*`)一律用 `web/src/base-url.ts` 的 `appUrl()` 包一层**(nginx 子路径反代依赖应用根前缀,裸写根路径会在子路径部署下 404)。
**测试规范**:端口隔离(≥8900);data-dir 隔离(`mkdtempSync`);精确清理自己进程;不允许 `pkill -f` 杀全局。
## 6. 发布流程
> 详细文档见 `docs/release.md`
```bash
# 升版本 → 写 CHANGELOG(含 npm run changelog:i18n 自动记文案增量)→ 自检构建 → commit → push → 打 tag(Action 自动建 Release + 出桌面安装包)→ npm publish
npm run typecheck && npm run build
npm run changelog:i18n # 文案有增减时必跑:自动刷新 CHANGELOG Unreleased 的 ### i18n
# 预览 Release 说明(只看不发):node scripts/release-notes.mjs X.Y.Z --base v<上个版本>
git add -A && git commit -m "feat(xxx): 描述"
git push origin main
git tag vX.Y.Z && git push origin vX.Y.Z # tag 带 v 前缀,数字与 npm 版本一致;推送后 Action 自动创建/更新 GitHub Release(含现场生成的 ### i18n),无需手跑 gh release create
npm publish
```
注意事项:版本号必须高于 npm registry;提交信息不要带 `Co-authored-by`;升级后需手动重启服务 `pi-web-ui server restart`;发布前检查示例文件不泄密。
## 7. 环境变量
> 完整列表见 `docs/env-vars.md`
| 变量 | 默认 | 一句话作用 |
| ------------------------ | --------------- | -------------------------------------------------------------- |
| `PI_WEB_PORT` | `8787` | HTTP 端口 |
| `PI_WEB_HOST` | `127.0.0.1` | 监听地址(默认只绑 loopback) |
| `PI_WEB_CWD` | `process.cwd()` | 智能体工作区 |
| `PI_WEB_DATA_DIR` | `~/.pi-web` | 数据目录(client-state / uploads / plugins) |
| `PI_WEB_TOKEN` | 空 | 可选共享口令鉴权 |
| `PI_WEB_TOOL_TIMEOUT_MS` | 20 分钟 | 工具挂死看门狗超时(`ask_user_question` 问卷豁免,不受此限制) |
## 8. 部署
> 详细文档见 `docs/deployment.md`
- **CLI 前台**:`pi-web-ui --port 9000 --cwd /path`
- **开机自启**:`pi-web-ui server install`(macOS→launchd / Linux→systemd / Windows→登录 Run 键,无需管理员)
- **Docker**:`docker compose up -d`
## 9. 常见坑
- **服务活着时跑 `npm run build` 会黑屏**:vite 先清空 `web/dist` 再写新文件,构建窗口内打开页面,`index.html` 与 hash 产物对不上;旧版里缺失的产物会穿透 `express.static` 落进 SPA catch-all 回 200 的 HTML,浏览器当 JS 执行失败,且 SW 会把它按 200 缓进 STATIC_CACHE(之后服务恢复了也好不了,必须 Unregister SW)。正确姿势:先停服务 → build → 启动 → 黑页标签 Unregister SW 后重载。服务端已加 `/assets/*` 等缺失 404(不再回 HTML)、SW 只缓存 content-type 对得上的资源。
- **改了 `protocol.ts` 后忘了在两端 dispatch/onmessage switch 加分支** → 前端收到未知消息类型被 switch 静默丢弃,表现为"没反应"。先跑 `npm run typecheck`。
- **nginx 子路径部署(页面在 /pi/ 下)插件/WS/API 加载失败** → 大概率是新增的请求路径没走 `appUrl()`(见关键约定),请求落在网站根绕过了 `location /pi/` 的剥离转发;排查时先看浏览器 Network 的请求带没带 `/pi` 前缀。
- **快照 60ms 节流**:调试时 `get_state` 可立即推一次(`cs.flushSnapshot()`)。
- **snapshot 发送背压**:`send()` 在序列化之前检查 `ws.bufferedAmount`,超过阈值时丢弃 snapshot(全量幂等且稍后必有更新);丢弃时安排 250ms 重试 timer。
- **`hello` 前/会话未就绪时的命令**:`server/index.ts` 的 `pending` 队列会缓存并在 attach 后重放。
- **clientId 每标签页独立**(issue #10):前端 `getClientId()` 存 sessionStorage(非 localStorage),同源多标签页是多个独立客户端。回归:`multi-tab-test.mjs`。
- **socket 半开**:服务端 10s 心跳,客户端 30s 无消息主动断开重连(指数退避 1s→10s)。
- **预览与附件行号**:`countLines` 不算尾随换行;前端 `split("\n")` 后也要 pop 掉末尾空串。
- **Windows 老中文文件乱码**:预览/内联附件/行附件统一走 `decodeText`(严格 UTF-8 失败 → GBK → latin1)。
- **Windows 窗口最小化后收不到桌面通知**(Win11 + Edge/Chrome 实测,2026-09):最小化后 `document.hasFocus()` 仍为 `true`、`visibilityState` 仍为 `"visible"`,连 `blur`/`visibilitychange` 都不发 —— 只有原生窗口矩形会变(`screenX/screenY` 变成 -21334(Edge)/-32000(Chrome)、`outerWidth/Height` 塌成标题栏 108×20 / 160×28)。判定全在 `web/src/notify.ts`(`isCollapsedWindow` + `shouldSuppressNotify`,纯函数 + 单测);Windows 上还额外要求「最近 2 分钟内有页面交互」才肯吞掉通知(焦点/可见性本来就在骗人,宁可多提醒也不静默)。**另一个坑:通知不能带 `tag`** —— Windows 把同 tag 的新通知当成「替掉旧条目」而且静默(没横幅、没提示音),只要通知中心里还留着一条 pi-web-ui 通知,后面每条都会被无声替换(`showNotification` 仍然 resolve,页面上看不出问题;`renotify: true` 实测救不回来)。排查入口:顶栏声音下拉 → 通知块底部的「发送测试通知」按钮(`NotifyToggle.tsx` 的 `SHOW_NOTIFY_TEST_PANEL` 常量,默认关闭,排障时改成 `true`),它显示实际通道(sw/page)、浏览器是否真的持有这条通知(`getNotifications()` 计数)与判定依据(焦点/可见性/最小化/空闲)。
- **模型列表刷新 = 官方目录整表替换(非并集)**:内置服务商(opencode-go 等)的模型目录来自 pi.dev(`https://pi.dev/api/models/providers/<id>`),`server/patch-remote-catalog.ts` 在启动时幂等改写 SDK 的 `remote-catalog-provider.js`,使 `getModels` 在远程数据存在时**整表返回官方目录**(无内置旧模型残留、无“新增 N 个”合并);`listModels()` 先 `mr.refresh({ allowNetwork: true })` 与官方接口校验(SDK 4h 窗口内走 304)。注意:改 `node_modules` 的补丁在 `npm install`/SDK 升级后会失效,服务重启时自动重打;SDK 源码结构变化时自动跳过(回落 SDK 默认并集语义,不崩溃)。验证:`tests/scratch/verify-patch.mjs`。
- **PI_WEB_TOKEN 改口令后旧 cookie 卡死**(issue #71):有效 token 请求会刷新 `pi_web_token` cookie 为当前值,401 且带失效 cookie 时自动 Expire——用户改了口令后**一次正确的 `?token=` 进入即永久恢复,无需清缓存**;别再实现「仅在无 cookie 时才下发」的旧逻辑(那是卡死根因)。回归:`tests/token-auth-test.mjs`。
- **CSS 变量引用必须先有定义**:`var(--ghost)` 引用一个全仓没有 `--ghost:` 声明的变量按规范是 guaranteed-invalid —— 整条声明在计算值阶段失效(`background` 直接没背景、`box-shadow` 连投影与聚焦光环一起丢),带 fallback 的则静默用硬编码值(浅色主题下必是深色块)。`--bg-elev1`(正确名 `--bg-elev`)全史未定义还被 10 处引用,goalbar 因此一直没填充。回归靠 `tests/unit/css-tokens.test.ts`(静态体检,CI 必跑);新增豁免(运行时注入的变量、刻意的中性兜底)要在该测试的豁免表里写上理由。
- **Playwright 脚本**:Chrome 路径由 `tests/lib/chrome.mjs` 逐平台探测(`PI_WEB_CHROME` 可覆盖),不再写死本机路径;脚本里取仓库根一律用 `fileURLToPath(new URL("..", import.meta.url))`——`URL.pathname` 在 Windows 上得到 `/E:/...`,`spawn` 会直接 ENOENT;服务端进程清理在 win32 走 `tests/lib/port-utils.mjs` 的 `freePort`(负数 PID 的进程组在 Windows 上不存在)。
---
_结构/流程变更时同步更新本文件及相关 `docs/` 文档。修改后运行 `/reload` 生效。_