@oversk7/pi-web-dsh-style 1.1.0 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,148 +1,68 @@
1
- # @oversk7/pi-web-dsh-style
1
+ <div align="center">
2
2
 
3
- 采用 DeepSeek Harness(DSH)视觉风格、面向 Windows 的非官方 Pi Coding Agent 本地浏览器界面扩展。后端使用 Pi 的公开 RPC 模式,每个会话对应独立的 RPC 进程;历史会话可直接从 Pi 会话文件恢复和渲染。
3
+ # Pi Web
4
4
 
5
- > 仅承诺 Windows 10/11。首次启动只监听 `127.0.0.1`;电脑网页的“设置 → 手机访问”可配置仅本机、局域网和服务器中转。之后 `pi --web` 使用已保存的模式。
5
+ **Your agent. Your workspace. Any screen.**
6
6
 
7
- ## 安装
7
+ 为 [Pi Coding Agent](https://pi.dev) 打开一个浏览器窗口。
8
8
 
9
- 需要 Node.js 22.19 或更高版本,以及 Pi Coding Agent 0.85.0 或更高版本。
9
+ [![npm](https://img.shields.io/npm/v/%40oversk7%2Fpi-web-dsh-style?style=flat-square&color=18181b)](https://www.npmjs.com/package/@oversk7/pi-web-dsh-style)
10
+ [![Windows](https://img.shields.io/badge/platform-Windows-18181b?style=flat-square)](#quick-start)
11
+ [![MIT](https://img.shields.io/badge/license-MIT-18181b?style=flat-square)](LICENSE)
10
12
 
11
- ```powershell
12
- pi install npm:@oversk7/pi-web-dsh-style
13
- ```
13
+ [快速开始](#quick-start) · [使用指南](https://github.com/oversk7/pi-web-dsh-style/blob/main/docs/guide.md) · [Pi 扩展目录](https://pi.dev/packages/@oversk7/pi-web-dsh-style) · [反馈与建议](https://github.com/oversk7/pi-web-dsh-style/issues)
14
14
 
15
- 也可以从源码目录安装:
15
+ </div>
16
16
 
17
- ```powershell
18
- pi install C:\path\to\pi-web
19
- ```
17
+ ---
20
18
 
21
- 安装后重启 Pi,或执行 `/reload`。
19
+ 灵感来自 DeepSeek Harness 的界面,熟悉的 Pi 工作流。
20
+ 在桌面展开工作区,在手机接着看进度。模型、工具和会话仍运行在自己的电脑上。
22
21
 
23
- ## 使用
22
+ ## Highlights
24
23
 
25
- 直接启动:
24
+ - **并行工作** — 多工作区、多会话,随时切换正在进行的任务。
25
+ - **保留上下文** — 浏览会话分支,回到之前的检查点,继续探索。
26
+ - **看清过程** — 流式回答、工具调用、思考过程和文件预览。
27
+ - **随处接入** — 桌面与手机,共用同一套会话;支持局域网和自托管中转。
28
+ - **延续习惯** — 模型切换、斜杠命令、扩展与技能,融入浏览器界面。
29
+ - **自己的风格** — 明暗主题、配色与自定义背景。
26
30
 
27
- ```powershell
28
- pi --web
29
- ```
31
+ ## Quick start
30
32
 
31
- 也可以在 Pi 终端中执行:
33
+ 需要 **Windows 10/11**、**Node.js ≥ 22.19** 和 **Pi ≥ 0.85.0**。
32
34
 
33
- ```text
34
- /web
35
- /web --no-open
36
- /web --port 18789
37
- /web --lan
38
- /web --relay
39
- /web --local
40
- /web --stop
35
+ ```powershell
36
+ pi install npm:@oversk7/pi-web-dsh-style
41
37
  ```
42
38
 
43
- ### 网页会话中的斜杠命令
44
-
45
- 输入 `/` 可查看当前可用命令。Web 内置命令包括 `/settings`、`/model`、`/thinking`、`/export`、`/copy`、`/name`、`/session`、`/new`、`/resume`、`/rewind`、`/tree`、`/compact` 和 `/reload`。
46
-
47
- - `/model provider/model` 精确匹配时切换模型;未唯一匹配时打开带搜索词的模型选择器。`/thinking high` 切换当前模型支持的思考强度,两者不带参数时打开选择器。
48
- - `/compact 压缩要求` 会将要求传给 Pi;`/name 名称` 修改会话名称,不带参数时显示当前名称。
49
- - `/resume` 打开侧栏会话搜索;`/export` 下载 HTML 到浏览器下载目录,暂不接受导出路径或 JSONL 格式参数。
50
- - 网页会话中的 `/reload` 只重载当前会话的扩展与配置。要重载整个 Web 服务,请使用设置中的“重新加载”或在宿主 Pi 终端执行 `/reload`。
51
-
52
- 扩展命令、提示词模板和 `/skill:名称` 由当前会话的 Pi RPC 进程执行。未知命令会报错并保留草稿,不会作为普通消息发送给模型;普通消息需要避免以 `/` 开头。
53
-
54
- `/scoped-models`、`/import`、`/share`、`/fork`、`/clone`、`/trust`、`/changelog`、`/hotkeys`、`/login`、`/logout` 和 `/quit` 尚未提供对应的 Web 操作,输入时会显示说明。需要这些 Pi 功能时请使用终端。
55
-
56
- ### 手机通过局域网访问
57
-
58
- 电脑和手机连接同一 Wi-Fi,在电脑本机网页“设置 → 手机访问”中选择“局域网”并保存。也可以执行 `/web --lan`,或直接以 `pi --web-lan` 启动。首次使用时,在同一设置中设置固定密码;之后手机打开显示的地址,输入该密码即可登录。界面只显示一个手机访问地址,优先使用 Wi-Fi,并过滤 WSL、Hyper-V 等虚拟网卡。
59
-
60
- 手机登录状态保留 30 天。密码在服务重启后继续有效,可在电脑本机网页中修改;修改密码后,已登录的手机需要重新登录。
61
-
62
- 手机与电脑共用工作区和会话,可各自切换、发送消息、停止生成及回答扩展提问。手机上点击左上角按钮展开会话列表;输入框回车换行,点击发送按钮提交;图片按钮选择手机图片。手机切回前台或网络重连时会刷新会话内容。
63
-
64
- 设置面板及 `/web --lan`、`/web --relay`、`/web --local` 会保存模式并切换正在运行的服务,保留后台会话进程。切换时浏览器实时连接会重新连接。执行 `/reload` 会保留端口和访问模式;重载或重启服务会保留密码和未过期的登录状态。
65
-
66
- 首次开启时,如 Windows 弹出防火墙提示,请允许 Node.js 在当前可信网络上接收连接。若手机仍无法打开,检查防火墙是否允许该端口,以及 Wi-Fi 是否启用了客户端隔离。电脑需要保持运行且未休眠。
67
-
68
- 局域网直连使用 HTTP,请只在可信局域网内使用。
69
-
70
- ### 通过服务器中转
39
+ 重启 Pi,然后启动:
71
40
 
72
- 电脑运行扩展和原生 `frpc`,服务器运行 `frps`、作为 STCP 访问端的 `frpc` 以及 Nginx。手机通过服务器的 HTTPS 域名访问,适用于校园网客户端隔离等无法直连的网络。电脑上的 `frpc` 由扩展启动和关闭,中转请求直接进入扩展的 Web 服务。
73
-
74
- 1. 从 [frp 官方 Releases](https://github.com/fatedier/frp/releases) 下载电脑对应架构的 Windows 版本,将 `frpc.exe` 解压到固定位置。客户端和服务器建议使用相同版本(已验证 0.70.1)。
75
- 2. 在电脑网页“设置 → 手机访问”选择“服务器中转”,填写服务器域名或 IPv4、frps 端口(默认 `7000`)、frps 认证密钥、手机 HTTPS 入口(如 `https://web.example.com`),以及 `frpc.exe` 路径。路径留空时从 PATH 查找 `frpc`;相对路径以状态目录为基准。认证密钥留空会保留已保存的值。
76
- 3. 保存后,下载设置中生成的 `frps.toml`、`visitor.toml` 和 `nginx.conf`。隧道名称和私有密钥自动生成;隧道名称及服务器内部端口可在高级配置中调整。将域名 DNS 指向服务器,并为该域名准备有效的 HTTPS 证书。
77
- 4. 服务器安装原生 frp 和 Nginx。新服务器使用 `frps -c frps.toml`,访问端使用 `frpc -c visitor.toml`,并用 systemd 等服务管理器保持运行。已有 frps 时复用现有端口和认证密钥,保留其配置。把生成的 Nginx 配置中的证书路径替换成实际证书路径,再安装到 Nginx 的站点配置目录,执行 `nginx -t` 验证并重载。
78
-
79
- 手机打开显示的 HTTPS 地址,输入原有固定密码即可使用。设置会显示 frpc 的连接或错误状态;服务器暂时不可达时 frpc 自动重连,本机网页仍可使用。后续执行 `pi --web` 会自动恢复保存的中转模式;也可以用 `pi --web-relay` 或 `/web --relay` 明确启用已配置的中转。
80
-
81
- 服务器需开放 HTTPS 端口和 frps 控制端口;生成的 STCP 访问端仅监听服务器 `127.0.0.1`,无须开放其内部端口。Nginx 配置保留域名 Host、附加代理密钥、限制登录请求速率,并关闭缓冲以支持实时消息。公网访问由扩展直接验证来源和固定密码,登录 Cookie 带有 `Secure` 标记。中转模式下,电脑 Web 服务仅监听本机回环地址。
82
-
83
- 网络配置和服务器配置下载仅对电脑本机网页开放。更换服务器域名、隧道名称、认证密钥或服务器内部端口后,需要同步部署重新下载的服务器配置。电脑上的 Pi Web 需要保持运行且未休眠。
84
-
85
- ## 功能
86
-
87
- - 多工作区、多会话和后台会话并行运行。
88
- - 按需启动 Pi RPC 进程,最多保留一个空闲进程。
89
- - Windows 会话使用 Job Object 托管进程树:停止 Web 或宿主退出时,清理 RPC 及其启动的后台服务、子进程和孙进程。
90
- - 模型、思考等级、中断、压缩和扩展交互对话框。
91
- - 运行中逐条显示待处理消息,可单条撤回并重新编辑;`Alt+Q` 可将全部队列消息恢复到输入框。
92
- - 会话历史保留完整分支与检查点,支持回溯后返回切换前的位置,以及 `pi-rewind` 文件恢复。
93
- - Windows 系统剪贴板图片和文本粘贴;手机支持选择或粘贴本机图片。
94
- - 会话中的本地文件链接可在右侧分栏预览,支持语法高亮、行定位和拖拽调整宽度。
95
- - 设置面板内更新扩展并热重载 Pi 运行时,无需退出 Web UI。
96
- - 会话 HTML 导出。
97
-
98
- ## 进程生命周期
99
-
100
- `/web --stop`、宿主 Pi 退出或运行时重载会关闭扩展托管的 frpc 和 Web 会话进程及其后代,包括 detached 后台进程;即使宿主被强制结束,Windows 托管进程也会检测到宿主退出并清理整棵进程树。不会扫描或结束工作区外独立启动的进程,也不会结束其他 Pi 实例。
101
-
102
- 单个 RPC 进程退出、异常终止或被空闲进程回收时,它启动的后台进程也会一起结束。需要独立于会话长期运行的服务,应从 Web 会话之外的终端启动。通过系统服务管理器、计划任务等外部机制启动的进程不属于此进程树。
103
-
104
- 只关闭浏览器标签页不会停止 Web 服务或后台会话;要结束它们,请执行 `/web --stop` 或退出宿主 Pi。进程托管依赖 Windows 10/11 自带的 Windows PowerShell 和 Win32 Job Object;如果无法建立托管,RPC 启动会失败,不会回退为无托管进程。
105
-
106
- ## 数据与升级
107
-
108
- 运行状态保存在:
109
-
110
- ```text
111
- ~/.pi/agent/pi-web/state.json
41
+ ```powershell
42
+ pi --web
112
43
  ```
113
44
 
114
- 访问密码的加盐哈希和登录签名密钥保存在同一目录的 `auth.json` 中,不保存明文密码。
115
-
116
- 访问模式及中转配置保存在同一目录的 `network.json` 中。中转所需的 frps 认证密钥、STCP 私有密钥和代理密钥保存在此文件;普通设置响应不返回这些密钥,服务器配置下载会包含部署所需的密钥。配置文件不属于发布包。运行时的 frpc 配置写入状态目录下的 `runtime/`,正常停止时清理。
45
+ 也可以在已有 Pi 终端中输入 `/web`。
117
46
 
118
- 从旧版目录安装首次升级时,如果新位置尚无状态文件,扩展会从扩展目录中的旧 `state.json` 原样复制并继续使用。旧文件不会被删除或改写,可作为迁移备份。
47
+ 手机访问从 **设置 → 手机访问** 开始。更多配置见[使用指南](https://github.com/oversk7/pi-web-dsh-style/blob/main/docs/guide.md)。
119
48
 
120
- 可用环境变量:
49
+ ## Contributing
121
50
 
122
- - `PI_WEB_STATE_DIR`:覆盖状态目录。
123
- - `PI_WEB_STATE_FILE`:覆盖状态文件。
124
- - `PI_WEB_LEGACY_STATE_FILE`:指定迁移来源。
125
- - `PI_WEB_RPC_ENTRY`:覆盖 Pi 的公开 RPC 入口文件。
126
- - `PI_WEB_PI_CLI`:兼容旧版的 Pi CLI 路径覆盖。
127
- - `PI_WEB_POWERSHELL`:覆盖用于剪贴板读取的 `powershell.exe`。
128
-
129
- 截图会临时写入系统临时目录,并在 24 小时后或服务停止时清理。Pi 会话内容仍由 Pi 自身保存在其会话目录中。
130
-
131
- ## 开发验证
51
+ 欢迎 Issue 和 PR。Bug、想法、使用体验,都可以从[这里](https://github.com/oversk7/pi-web-dsh-style/issues)开始。
132
52
 
133
53
  ```powershell
54
+ git clone https://github.com/oversk7/pi-web-dsh-style.git
55
+ cd pi-web-dsh-style
134
56
  npm install
135
57
  npm run verify
136
- npm pack --dry-run
137
58
  ```
138
59
 
139
- 发布正式版:
60
+ ## Credits
140
61
 
141
- ```powershell
142
- npm login --registry=https://registry.npmjs.org
143
- npm publish --access public --tag latest
144
- ```
62
+ Built on [Pi](https://pi.dev). Inspired by [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
63
+
64
+ 这是一个社区扩展,与 Pi 和 DeepSeek 官方无隶属关系。
145
65
 
146
- ## 许可证
66
+ 另一个小工具:[pi-pwsh-notify](https://github.com/oversk7/pi-pwsh-notify) — 为 Pi 带来 PowerShell 7 与后台任务通知。
147
67
 
148
- 原创代码使用 MIT License。Web UI 的部分样式和设计 token 改编自 DeepSeek Harness,字体资源来自 KaTeX,代码高亮使用 highlight.js。完整归属与许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
68
+ [MIT](LICENSE) · [Third-party notices](THIRD_PARTY_NOTICES.md)
package/docs/guide.md ADDED
@@ -0,0 +1,152 @@
1
+ # 使用指南
2
+
3
+ [← 返回项目首页](../README.md)
4
+
5
+ ## 安装
6
+
7
+ 需要 Node.js 22.19 或更高版本,以及 Pi Coding Agent 0.85.0 或更高版本。
8
+
9
+ ```powershell
10
+ pi install npm:@oversk7/pi-web-dsh-style
11
+ ```
12
+
13
+ 也可以从源码目录安装:
14
+
15
+ ```powershell
16
+ pi install C:\path\to\pi-web
17
+ ```
18
+
19
+ 安装后重启 Pi,或执行 `/reload`。
20
+
21
+ ## 使用
22
+
23
+ 直接启动:
24
+
25
+ ```powershell
26
+ pi --web
27
+ ```
28
+
29
+ 也可以在 Pi 终端中执行:
30
+
31
+ ```text
32
+ /web
33
+ /web --no-open
34
+ /web --port 18789
35
+ /web --lan
36
+ /web --relay
37
+ /web --local
38
+ /web --stop
39
+ ```
40
+
41
+ ### 网页会话中的斜杠命令
42
+
43
+ 输入 `/` 可查看当前可用命令。Web 内置命令包括 `/settings`、`/model`、`/thinking`、`/export`、`/copy`、`/name`、`/session`、`/new`、`/resume`、`/rewind`、`/tree`、`/compact` 和 `/reload`。
44
+
45
+ - `/model provider/model` 精确匹配时切换模型;未唯一匹配时打开带搜索词的模型选择器。`/thinking high` 切换当前模型支持的思考强度,两者不带参数时打开选择器。
46
+ - `/compact 压缩要求` 会将要求传给 Pi;`/name 名称` 修改会话名称,不带参数时显示当前名称。
47
+ - `/resume` 打开侧栏会话搜索;`/export` 下载 HTML 到浏览器下载目录,暂不接受导出路径或 JSONL 格式参数。
48
+ - 网页会话中的 `/reload` 只重载当前会话的扩展与配置。要重载整个 Web 服务,请使用设置中的“重新加载”或在宿主 Pi 终端执行 `/reload`。
49
+
50
+ 扩展命令、提示词模板和 `/skill:名称` 由当前会话的 Pi RPC 进程执行。未知命令会报错并保留草稿,不会作为普通消息发送给模型;普通消息需要避免以 `/` 开头。
51
+
52
+ `/scoped-models`、`/import`、`/share`、`/fork`、`/clone`、`/trust`、`/changelog`、`/hotkeys`、`/login`、`/logout` 和 `/quit` 尚未提供对应的 Web 操作,输入时会显示说明。需要这些 Pi 功能时请使用终端。
53
+
54
+ ### 手机通过局域网访问
55
+
56
+ 电脑和手机连接同一 Wi-Fi,在电脑本机网页“设置 → 手机访问”中选择“局域网”并保存。也可以执行 `/web --lan`,或直接以 `pi --web-lan` 启动。首次使用时,在同一设置中设置固定密码;之后手机打开显示的地址,输入该密码即可登录。界面只显示一个手机访问地址,优先使用 Wi-Fi,并过滤 WSL、Hyper-V 等虚拟网卡。
57
+
58
+ 手机登录状态保留 30 天。密码在服务重启后继续有效,可在电脑本机网页中修改;修改密码后,已登录的手机需要重新登录。
59
+
60
+ 手机与电脑共用工作区和会话,可各自切换、发送消息、停止生成及回答扩展提问。手机上点击左上角按钮展开会话列表;输入框回车换行,点击发送按钮提交;图片按钮选择手机图片。手机切回前台或网络重连时会刷新会话内容。
61
+
62
+ 设置面板及 `/web --lan`、`/web --relay`、`/web --local` 会保存模式并切换正在运行的服务,保留后台会话进程。切换时浏览器实时连接会重新连接。执行 `/reload` 会保留端口和访问模式;重载或重启服务会保留密码和未过期的登录状态。
63
+
64
+ 首次开启时,如 Windows 弹出防火墙提示,请允许 Node.js 在当前可信网络上接收连接。若手机仍无法打开,检查防火墙是否允许该端口,以及 Wi-Fi 是否启用了客户端隔离。电脑需要保持运行且未休眠。
65
+
66
+ 局域网直连使用 HTTP,请只在可信局域网内使用。
67
+
68
+ ### 通过服务器中转
69
+
70
+ 电脑运行扩展和原生 `frpc`,服务器运行 `frps`、作为 STCP 访问端的 `frpc` 以及 Nginx。手机通过服务器的 HTTPS 域名访问,适用于校园网客户端隔离等无法直连的网络。电脑上的 `frpc` 由扩展启动和关闭,中转请求直接进入扩展的 Web 服务。
71
+
72
+ 1. 从 [frp 官方 Releases](https://github.com/fatedier/frp/releases) 下载电脑对应架构的 Windows 版本,将 `frpc.exe` 解压到固定位置。客户端和服务器建议使用相同版本(已验证 0.70.1)。
73
+ 2. 在电脑网页“设置 → 手机访问”选择“服务器中转”,填写服务器域名或 IPv4、frps 端口(默认 `7000`)、frps 认证密钥、手机 HTTPS 入口(如 `https://web.example.com`),以及 `frpc.exe` 路径。路径留空时从 PATH 查找 `frpc`;相对路径以状态目录为基准。认证密钥留空会保留已保存的值。
74
+ 3. 保存后,下载设置中生成的 `frps.toml`、`visitor.toml` 和 `nginx.conf`。隧道名称和私有密钥自动生成;隧道名称及服务器内部端口可在高级配置中调整。将域名 DNS 指向服务器,并为该域名准备有效的 HTTPS 证书。
75
+ 4. 服务器安装原生 frp 和 Nginx。新服务器使用 `frps -c frps.toml`,访问端使用 `frpc -c visitor.toml`,并用 systemd 等服务管理器保持运行。已有 frps 时复用现有端口和认证密钥,保留其配置。把生成的 Nginx 配置中的证书路径替换成实际证书路径,再安装到 Nginx 的站点配置目录,执行 `nginx -t` 验证并重载。
76
+
77
+ 手机打开显示的 HTTPS 地址,输入原有固定密码即可使用。设置会显示 frpc 的连接或错误状态;服务器暂时不可达时 frpc 自动重连,本机网页仍可使用。后续执行 `pi --web` 会自动恢复保存的中转模式;也可以用 `pi --web-relay` 或 `/web --relay` 明确启用已配置的中转。
78
+
79
+ 服务器需开放 HTTPS 端口和 frps 控制端口;生成的 STCP 访问端仅监听服务器 `127.0.0.1`,无须开放其内部端口。Nginx 配置保留域名 Host、附加代理密钥、限制登录请求速率,并关闭缓冲以支持实时消息。公网访问由扩展直接验证来源和固定密码,登录 Cookie 带有 `Secure` 标记。中转模式下,电脑 Web 服务仅监听本机回环地址。
80
+
81
+ 网络配置和服务器配置下载仅对电脑本机网页开放。更换服务器域名、隧道名称、认证密钥或服务器内部端口后,需要同步部署重新下载的服务器配置。电脑上的 Pi Web 需要保持运行且未休眠。
82
+
83
+ ## 功能
84
+
85
+ - 多工作区、多会话和后台会话并行运行。
86
+ - 按需启动 Pi RPC 进程,最多保留一个空闲进程。
87
+ - Windows 会话使用 Job Object 托管进程树:停止 Web 或宿主退出时,清理 RPC 及其启动的后台服务、子进程和孙进程。
88
+ - 模型、思考等级、中断、压缩和扩展交互对话框。
89
+ - 运行中逐条显示待处理消息,可单条撤回并重新编辑;`Alt+Q` 可将全部队列消息恢复到输入框。
90
+ - 会话历史保留完整分支与检查点,支持回溯后返回切换前的位置,以及 `pi-rewind` 文件恢复。
91
+ - Windows 系统剪贴板图片和文本粘贴;手机支持选择或粘贴本机图片。
92
+ - 会话中的本地文件链接可在右侧分栏预览,支持语法高亮、行定位和拖拽调整宽度。
93
+ - 设置面板内更新扩展并热重载 Pi 运行时,无需退出 Web UI。
94
+ - 会话 HTML 导出。
95
+
96
+ ## 进程生命周期
97
+
98
+ `/web --stop`、宿主 Pi 退出或运行时重载会关闭扩展托管的 frpc 和 Web 会话进程及其后代,包括 detached 后台进程;即使宿主被强制结束,Windows 托管进程也会检测到宿主退出并清理整棵进程树。不会扫描或结束工作区外独立启动的进程,也不会结束其他 Pi 实例。
99
+
100
+ 单个 RPC 进程退出、异常终止或被空闲进程回收时,它启动的后台进程也会一起结束。需要独立于会话长期运行的服务,应从 Web 会话之外的终端启动。通过系统服务管理器、计划任务等外部机制启动的进程不属于此进程树。
101
+
102
+ 只关闭浏览器标签页不会停止 Web 服务或后台会话;要结束它们,请执行 `/web --stop` 或退出宿主 Pi。进程托管依赖 Windows 10/11 自带的 Windows PowerShell 和 Win32 Job Object;如果无法建立托管,RPC 启动会失败,不会回退为无托管进程。
103
+
104
+ ## 数据与升级
105
+
106
+ 运行状态保存在:
107
+
108
+ ```text
109
+ ~/.pi/agent/pi-web/state.json
110
+ ```
111
+
112
+ 访问密码的加盐哈希和登录签名密钥保存在同一目录的 `auth.json` 中,不保存明文密码。
113
+
114
+ 访问模式及中转配置保存在同一目录的 `network.json` 中。中转所需的 frps 认证密钥、STCP 私有密钥和代理密钥保存在此文件;普通设置响应不返回这些密钥,服务器配置下载会包含部署所需的密钥。配置文件不属于发布包。运行时的 frpc 配置写入状态目录下的 `runtime/`,正常停止时清理。
115
+
116
+ 从旧版目录安装首次升级时,如果新位置尚无状态文件,扩展会从扩展目录中的旧 `state.json` 原样复制并继续使用。旧文件不会被删除或改写,可作为迁移备份。
117
+
118
+ 可用环境变量:
119
+
120
+ - `PI_WEB_STATE_DIR`:覆盖状态目录。
121
+ - `PI_WEB_STATE_FILE`:覆盖状态文件。
122
+ - `PI_WEB_LEGACY_STATE_FILE`:指定迁移来源。
123
+ - `PI_WEB_RPC_ENTRY`:覆盖 Pi 的公开 RPC 入口文件。
124
+ - `PI_WEB_PI_CLI`:兼容旧版的 Pi CLI 路径覆盖。
125
+ - `PI_WEB_POWERSHELL`:覆盖用于剪贴板读取的 `powershell.exe`。
126
+
127
+ 截图会临时写入系统临时目录,并在 24 小时后或服务停止时清理。Pi 会话内容仍由 Pi 自身保存在其会话目录中。
128
+
129
+ ## 开发验证
130
+
131
+ ```powershell
132
+ npm install
133
+ npm run verify
134
+ npm pack --dry-run
135
+ ```
136
+
137
+ 发布正式版:
138
+
139
+ ```powershell
140
+ npm login --registry=https://registry.npmjs.org
141
+ npm publish --access public --tag latest
142
+ ```
143
+
144
+ ## 反馈与相关扩展
145
+
146
+ 欢迎通过 [GitHub Issues](https://github.com/oversk7/pi-web-dsh-style/issues) 反馈问题、提出建议,或分享使用场景。报告问题时,请附上扩展版本、Pi 版本、Windows 版本和复现步骤;涉及手机访问时,请说明使用的是局域网还是服务器中转。
147
+
148
+ 同一作者的 [pi-pwsh-notify](https://github.com/oversk7/pi-pwsh-notify) 为 Pi 提供 Windows PowerShell 7 工具,以及后台任务完成自动通知功能。
149
+
150
+ ## 许可证
151
+
152
+ 原创代码使用 MIT License。Web UI 的部分样式和设计 token 改编自 DeepSeek Harness,字体资源来自 KaTeX,代码高亮使用 highlight.js。完整归属与许可证见 [THIRD_PARTY_NOTICES.md](../THIRD_PARTY_NOTICES.md)。
package/package.json CHANGED
@@ -1,10 +1,18 @@
1
1
  {
2
2
  "name": "@oversk7/pi-web-dsh-style",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "An unofficial DSH-styled Windows browser UI for pi coding agent with configurable LAN and native frp relay access",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "oversk7",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/oversk7/pi-web-dsh-style.git"
11
+ },
12
+ "homepage": "https://github.com/oversk7/pi-web-dsh-style#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/oversk7/pi-web-dsh-style/issues"
15
+ },
8
16
  "keywords": [
9
17
  "pi-package",
10
18
  "pi-coding-agent",
@@ -40,6 +48,7 @@
40
48
  "windows-clipboard.ts",
41
49
  "windows-rpc-job.ps1",
42
50
  "web/",
51
+ "docs/",
43
52
  "README.md",
44
53
  "LICENSE",
45
54
  "THIRD_PARTY_NOTICES.md"
package/server.ts CHANGED
@@ -23,7 +23,7 @@ import { isLocalRequest, WebAccess } from "./network-access.ts";
23
23
  import { FrpProcess } from "./frp-process.ts";
24
24
  import { loadTerminalProxy, saveTerminalProxy, validateTerminalProxy, type TerminalProxySettings } from "./terminal-proxy.ts";
25
25
  import { frpcConfig, loadNetworkSettings, proxyForNetwork, publicNetworkSettings, relayServerFiles, resolveFrpc, saveNetworkSettings, updateNetworkSettings, type NetworkMode, type NetworkSettings } from "./network-settings.ts";
26
- import { closeSseClient, createSseClient, sendSseEvent, type SseClient } from "./sse-channel.ts";
26
+ import { closeSseClient, createSseClient, createSnapshotScheduler, sendSseEvent, type SseClient } from "./sse-channel.ts";
27
27
  import { closeAllPiRpcs, spawnPiRpc, type Json, type PiRpc } from "./pi-rpc.ts";
28
28
  import { loadLocalSession } from "./local-session.ts";
29
29
  import { loadJsonWithLegacyMigration, writeJsonAtomically, writeTextAtomically } from "./state-store.ts";
@@ -666,7 +666,14 @@ function setPendingQueue(record: RuntimeSession, queue: RpcQueueSnapshot): void
666
666
  };
667
667
  }
668
668
 
669
+ const snapshotScheduler = createSnapshotScheduler<RuntimeSession>(emitSessionSnapshot);
670
+
669
671
  function broadcastSessionSnapshot(record: RuntimeSession): void {
672
+ if (shuttingDown) return;
673
+ snapshotScheduler.schedule(record.id, record, !record.streaming || record.dialogs.length > 0);
674
+ }
675
+
676
+ function emitSessionSnapshot(record: RuntimeSession): void {
670
677
  broadcastEvent({
671
678
  type: "snapshot",
672
679
  sessionId: record.id,
@@ -1361,8 +1368,7 @@ async function applyPendingSettings(record: RuntimeSession): Promise<void> {
1361
1368
  await proc.send({ type: "set_model", provider: pending.provider, modelId: pending.modelId });
1362
1369
  record.pendingModel = undefined;
1363
1370
  } catch (error) {
1364
- console.error("[pi-web] applying pending model failed:", error);
1365
- record.pendingModel = undefined;
1371
+ throw new Error(`无法使用模型 ${pending.provider}/${pending.modelId},请在模型菜单中选择当前可用的模型:${error instanceof Error ? error.message : String(error)}`);
1366
1372
  }
1367
1373
  }
1368
1374
  if (record.pendingThinkingLevel) {
@@ -1420,9 +1426,16 @@ async function ensureProcess(record: RuntimeSession): Promise<void> {
1420
1426
  broadcastWorkspaces();
1421
1427
  evictIdleProcesses();
1422
1428
  } catch (error) {
1429
+ if (record.proc === proc) record.proc = undefined;
1430
+ try {
1431
+ await proc.close(2500);
1432
+ } catch (closeError) {
1433
+ console.error("[pi-web] closing failed session process:", closeError);
1434
+ }
1423
1435
  record.status = "error";
1424
1436
  record.error = error instanceof Error ? error.message : String(error);
1425
1437
  broadcastSessionSnapshot(record);
1438
+ throw error;
1426
1439
  } finally {
1427
1440
  record.starting = undefined;
1428
1441
  }
@@ -1563,6 +1576,7 @@ async function deleteWorkspaceSession(workspace: Workspace, sessionId: string):
1563
1576
  candidate.updatedAt = updatedAt;
1564
1577
  }
1565
1578
  state.sessions = state.sessions.filter((session) => session.id !== sessionId);
1579
+ snapshotScheduler.cancel(sessionId);
1566
1580
  sessions.delete(sessionId);
1567
1581
  if (state.currentSessionId === sessionId) state.currentSessionId = undefined;
1568
1582
  scheduleSave();
@@ -1576,14 +1590,23 @@ async function createSession(
1576
1590
  ): Promise<RuntimeSession> {
1577
1591
  const ws = state.workspaces.find((w) => w.id === workspaceId);
1578
1592
  if (!ws) throw new Error("workspace not found");
1593
+ const info = modelInfoFromCatalog(
1594
+ await discoverFreshModelCatalog(ws.path),
1595
+ preferences.model,
1596
+ preferences.thinkingLevel,
1597
+ );
1598
+ const selected = info.defaultModel as Json | undefined;
1599
+ const model = selected && typeof selected.provider === "string" && typeof selected.id === "string"
1600
+ ? { provider: selected.provider, modelId: selected.id }
1601
+ : undefined;
1579
1602
  const id = randomUUID();
1580
1603
  const cleanTitle = (title ?? "").trim().replace(/\s+/g, " ").slice(0, 60) || "新会话";
1581
1604
  const stored: StoredSession = {
1582
1605
  id,
1583
1606
  workspaceId,
1584
1607
  title: cleanTitle,
1585
- model: preferences.model,
1586
- thinkingLevel: preferences.thinkingLevel,
1608
+ model,
1609
+ thinkingLevel: info.thinkingLevel,
1587
1610
  needsAttention: false,
1588
1611
  createdAt: nowIso(),
1589
1612
  updatedAt: nowIso(),
@@ -1608,29 +1631,11 @@ async function createSession(
1608
1631
  streaming: false,
1609
1632
  needsAttention: false,
1610
1633
  lastUsed: Date.now(),
1611
- pendingModel: preferences.model,
1612
- pendingThinkingLevel: preferences.thinkingLevel,
1634
+ pendingModel: model,
1635
+ pendingThinkingLevel: info.thinkingLevel,
1613
1636
  };
1614
- applyCachedModelInfo(record);
1637
+ applyModelInfo(record, info);
1615
1638
  applyCachedCommands(record);
1616
- if (preferences.model) {
1617
- const full = (record.models as Json[]).find(
1618
- (model) => model?.provider === preferences.model?.provider && model.id === preferences.model?.modelId,
1619
- );
1620
- record.state = {
1621
- ...(record.state ?? {}),
1622
- model: full ?? { provider: preferences.model.provider, id: preferences.model.modelId, name: preferences.model.modelId },
1623
- };
1624
- if (full) record.thinkingLevels = supportedThinkingLevels(full);
1625
- }
1626
- if (preferences.thinkingLevel) {
1627
- const effectiveLevel = record.thinkingLevels.includes(preferences.thinkingLevel)
1628
- ? preferences.thinkingLevel
1629
- : record.thinkingLevels.includes("off") ? "off" : record.thinkingLevels[0] ?? "off";
1630
- record.pendingThinkingLevel = effectiveLevel;
1631
- stored.thinkingLevel = effectiveLevel;
1632
- record.state = { ...(record.state ?? {}), thinkingLevel: effectiveLevel };
1633
- }
1634
1639
  sessions.set(id, record);
1635
1640
  scheduleSave();
1636
1641
  broadcastWorkspaces();
@@ -2521,14 +2526,19 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
2521
2526
  && typeof requestedModel.modelId === "string"
2522
2527
  ? { provider: requestedModel.provider, modelId: requestedModel.modelId }
2523
2528
  : undefined;
2524
- const record = await createSession(
2525
- ws.id,
2526
- typeof body.title === "string" ? body.title : undefined,
2527
- {
2528
- model,
2529
- thinkingLevel: typeof body.thinkingLevel === "string" ? body.thinkingLevel : undefined,
2530
- },
2531
- );
2529
+ let record: RuntimeSession;
2530
+ try {
2531
+ record = await createSession(
2532
+ ws.id,
2533
+ typeof body.title === "string" ? body.title : undefined,
2534
+ {
2535
+ model,
2536
+ thinkingLevel: typeof body.thinkingLevel === "string" ? body.thinkingLevel : undefined,
2537
+ },
2538
+ );
2539
+ } catch (error) {
2540
+ return sendJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2541
+ }
2532
2542
  state.currentWorkspaceId = ws.id;
2533
2543
  state.currentSessionId = record.id;
2534
2544
  scheduleSave();
@@ -3261,6 +3271,7 @@ function shutdown(): Promise<void> {
3261
3271
  }
3262
3272
 
3263
3273
  async function shutdownServer(): Promise<void> {
3274
+ snapshotScheduler.clear();
3264
3275
  await networkChange;
3265
3276
  await stopRelay();
3266
3277
  for (const client of [...sseClients]) {
package/sse-channel.ts CHANGED
@@ -1,5 +1,42 @@
1
1
  export type SseEvent = Record<string, unknown>;
2
2
 
3
+ // Coalesce before serializing full histories, so a burst of tokens does not
4
+ // become a queue of obsolete snapshots on a slower relay connection.
5
+ export function createSnapshotScheduler<T>(emit: (value: T) => void, intervalMs = 100) {
6
+ const pending = new Map<string, { timer: ReturnType<typeof setTimeout>; latest?: T }>();
7
+ function cancel(key: string): void {
8
+ const entry = pending.get(key);
9
+ if (entry) clearTimeout(entry.timer);
10
+ pending.delete(key);
11
+ }
12
+ return {
13
+ schedule(key: string, value: T, immediate = false): void {
14
+ if (immediate) {
15
+ cancel(key);
16
+ emit(value);
17
+ return;
18
+ }
19
+ const current = pending.get(key);
20
+ if (current) {
21
+ current.latest = value;
22
+ return;
23
+ }
24
+ const entry: { timer: ReturnType<typeof setTimeout>; latest?: T } = {
25
+ timer: setTimeout(() => {
26
+ pending.delete(key);
27
+ if (entry.latest !== undefined) emit(entry.latest);
28
+ }, intervalMs),
29
+ };
30
+ pending.set(key, entry);
31
+ emit(value);
32
+ },
33
+ cancel,
34
+ clear(): void {
35
+ for (const key of pending.keys()) cancel(key);
36
+ },
37
+ };
38
+ }
39
+
3
40
  export interface SseWritable {
4
41
  write(payload: string): boolean;
5
42
  once(event: "drain", listener: () => void): unknown;
package/web/app.js CHANGED
@@ -17,6 +17,7 @@ const S = {
17
17
  currentSessionId: null,
18
18
  snapshots: new Map(),
19
19
  newSessionSnapshot: null,
20
+ newSessionPreferenceWorkspaceId: null,
20
21
  collapsed: new Set(),
21
22
  expandedSessionLists: new Set(),
22
23
  sidebarCollapsed: localStorage.getItem("pi-web-sidebar-collapsed") === "1",
@@ -31,6 +32,7 @@ const S = {
31
32
  search: "",
32
33
  searchOpen: false,
33
34
  loadingSession: null,
35
+ pendingCreation: null,
34
36
  pendingSends: new Map(),
35
37
  withdrawingQueueItems: new Set(),
36
38
  dequeueSessions: new Set(),
@@ -313,6 +315,7 @@ function currentSnapshot() {
313
315
  }
314
316
 
315
317
  function draftKey(sessionId = S.currentSessionId, workspaceId = S.currentWorkspaceId) {
318
+ if (!sessionId && S.pendingCreation?.generation === S.openGeneration && workspaceId === S.pendingCreation.workspaceId) return S.pendingCreation.draftKey;
316
319
  return sessionId ? `session:${sessionId}` : `new:${workspaceId || "default"}`;
317
320
  }
318
321
 
@@ -806,7 +809,7 @@ function renderHero() {
806
809
  <span class="pXSMma_headlineText">和你的代码库对话</span>
807
810
  <span class="pXSMma_previewBadge">Web 预览版</span>
808
811
  </div>
809
- <div class="pXSMma_body"></div>
812
+ <div class="pXSMma_body">${S.pendingCreation?.generation === S.openGeneration ? '<div class="gdEzaW_retryRow" role="status"><span class="gdEzaW_retryText">正在创建会话…</span></div>' : ""}</div>
810
813
  </div></div>
811
814
  <div class="wSkVaW_heroWorkspaceRow">
812
815
  <div class="pXSMma_workspaceRow">${renderWorkspacePicker()}</div>
@@ -864,7 +867,7 @@ function renderComposerBar(hero = false) {
864
867
  const level = state.thinkingLevel || null;
865
868
  const streaming = Boolean(snap.session?.streaming);
866
869
  const waitingForDialog = Boolean(snap.dialogs?.length);
867
- const disabled = (!S.currentSessionId && !hero) || Boolean(S.loadingSession && !hero) || waitingForDialog;
870
+ const disabled = (!S.currentSessionId && !hero) || Boolean(S.loadingSession && !hero) || S.pendingCreation?.generation === S.openGeneration || waitingForDialog;
868
871
  const inputPlaceholder = waitingForDialog ? "请先回答对话中的问题" : "给智能体发消息";
869
872
  const rawContextPct = snap.stats?.contextUsage?.percent;
870
873
  const hasContextPct = typeof rawContextPct === "number" && Number.isFinite(rawContextPct);
@@ -2987,6 +2990,14 @@ async function loadBootstrap() {
2987
2990
  messages: [],
2988
2991
  dialogs: [],
2989
2992
  };
2993
+ if (!S.currentSessionId && S.currentWorkspaceId) {
2994
+ try {
2995
+ const defaults = await post(`/api/workspaces/${encodeURIComponent(S.currentWorkspaceId)}/models`, {});
2996
+ S.newSessionSnapshot = { ...S.newSessionSnapshot, ...defaults };
2997
+ } catch (error) {
2998
+ console.error("[pi-web] loading new session defaults failed:", error);
2999
+ }
3000
+ }
2990
3001
  renderApp();
2991
3002
  if (S.currentSessionId) await openSession(S.currentSessionId, { silent: true });
2992
3003
  }
@@ -2998,8 +3009,8 @@ async function openSession(id, opts = {}) {
2998
3009
  ++S.historyGeneration;
2999
3010
  S.conversationTab = "conversation";
3000
3011
  S.historyView = null;
3001
- const generation = ++S.openGeneration;
3002
3012
  if (!opts.skipRemember) rememberCurrentDraft();
3013
+ const generation = ++S.openGeneration;
3003
3014
  S.currentSessionId = id;
3004
3015
  S.loadingSession = id;
3005
3016
  if (!opts.silent) {
@@ -3017,7 +3028,7 @@ async function openSession(id, opts = {}) {
3017
3028
  renderSidebar();
3018
3029
  renderConversation();
3019
3030
  try {
3020
- const data = await post(`/api/sessions/${encodeURIComponent(id)}/open`);
3031
+ const data = opts.initialSnapshot ?? await post(`/api/sessions/${encodeURIComponent(id)}/open`);
3021
3032
  S.snapshots.set(id, data);
3022
3033
  if (generation !== S.openGeneration) return;
3023
3034
  const ws = data.session?.workspaceId;
@@ -3033,28 +3044,74 @@ async function openSession(id, opts = {}) {
3033
3044
  }
3034
3045
 
3035
3046
  async function createAndOpenSession(workspaceId, title, options = {}) {
3047
+ if (S.pendingCreation) return null;
3048
+ const source = S.newSessionPreferenceWorkspaceId === workspaceId && !S.currentSessionId
3049
+ ? S.newSessionSnapshot : null;
3050
+ const previous = {
3051
+ sessionId: S.currentSessionId, workspaceId: S.currentWorkspaceId,
3052
+ conversationTab: S.conversationTab, historyView: S.historyView,
3053
+ draft: S.draft, attachments: [...S.draftAttachments],
3054
+ };
3055
+ rememberCurrentDraft();
3056
+ const generation = ++S.openGeneration;
3057
+ const pending = { generation, workspaceId, draftKey: `creating:${generation}` };
3058
+ S.pendingCreation = pending;
3059
+ closeMobileSidebar();
3060
+ closeCommandMenu();
3061
+ ++S.historyGeneration;
3062
+ S.conversationTab = "conversation";
3063
+ S.historyView = null;
3064
+ S.currentSessionId = null;
3065
+ S.currentWorkspaceId = workspaceId;
3066
+ S.loadingSession = null;
3067
+ sessionStorage.removeItem("pi-web-current-session");
3068
+ if (!options.keepDraft) {
3069
+ S.draft = "";
3070
+ S.draftAttachments = [];
3071
+ }
3072
+ renderSidebar();
3073
+ renderConversation();
3036
3074
  try {
3037
- const source = currentSnapshot();
3038
- const model = source.state?.model;
3075
+ const model = source?.state?.model;
3039
3076
  const data = await post(`/api/workspaces/${encodeURIComponent(workspaceId)}/sessions`, {
3040
3077
  title,
3041
3078
  model: model?.provider && model?.id ? { provider: model.provider, modelId: model.id } : undefined,
3042
- thinkingLevel: source.state?.thinkingLevel || undefined,
3079
+ thinkingLevel: source?.state?.thinkingLevel || undefined,
3043
3080
  });
3044
3081
  const id = data.session?.id;
3045
3082
  if (!id) throw new Error("create session failed");
3046
3083
  S.snapshots.set(id, data);
3047
- if (!options.keepDraft) {
3048
- rememberCurrentDraft();
3049
- S.draft = "";
3050
- S.draftAttachments = [];
3084
+ if (generation !== S.openGeneration) {
3085
+ // Navigation owns the composer now; retain any pending draft with the created session.
3086
+ const draft = S.drafts.get(pending.draftKey);
3087
+ const attachments = S.attachmentDrafts.get(pending.draftKey);
3088
+ if (draft) S.drafts.set(draftKey(id), draft);
3089
+ if (attachments) S.attachmentDrafts.set(draftKey(id), attachments);
3090
+ return null;
3051
3091
  }
3052
- S.currentWorkspaceId = workspaceId;
3053
- await openSession(id, { silent: true, skipRemember: !options.keepDraft });
3054
- return id;
3092
+ S.newSessionPreferenceWorkspaceId = null;
3093
+ await openSession(id, { silent: true, skipRemember: true, initialSnapshot: data });
3094
+ return generation + 1 === S.openGeneration && S.currentSessionId === id ? id : null;
3055
3095
  } catch (error) {
3056
- alert(`新建会话失败:${error.message}`);
3096
+ if (generation === S.openGeneration) {
3097
+ S.pendingCreation = null;
3098
+ S.currentSessionId = previous.sessionId;
3099
+ S.currentWorkspaceId = previous.workspaceId;
3100
+ S.loadingSession = null;
3101
+ S.conversationTab = previous.conversationTab;
3102
+ S.historyView = previous.historyView;
3103
+ S.draftAttachments = previous.attachments;
3104
+ setDraftValue(previous.draft);
3105
+ if (previous.sessionId) sessionStorage.setItem("pi-web-current-session", previous.sessionId);
3106
+ renderSidebar();
3107
+ renderConversation();
3108
+ alert(`新建会话失败:${error.message}`);
3109
+ }
3057
3110
  return null;
3111
+ } finally {
3112
+ if (S.pendingCreation === pending) S.pendingCreation = null;
3113
+ S.drafts.delete(pending.draftKey);
3114
+ S.attachmentDrafts.delete(pending.draftKey);
3058
3115
  }
3059
3116
  }
3060
3117
 
@@ -3875,7 +3932,10 @@ async function selectModelFromCommand(reference) {
3875
3932
  if (sessionId) await post(`/api/sessions/${encodeURIComponent(sessionId)}/set_model`, { provider: model.provider, modelId: model.id });
3876
3933
  const next = { ...snapshotFor(sessionId), state: { ...(snapshotFor(sessionId).state || {}), model, thinkingLevel }, thinkingLevels };
3877
3934
  if (sessionId) S.snapshots.set(sessionId, next);
3878
- else if (!S.currentSessionId && S.currentWorkspaceId === workspaceId) S.newSessionSnapshot = next;
3935
+ else if (!S.currentSessionId && S.currentWorkspaceId === workspaceId) {
3936
+ S.newSessionSnapshot = next;
3937
+ S.newSessionPreferenceWorkspaceId = workspaceId;
3938
+ }
3879
3939
  queueRender(true);
3880
3940
  showToast(`模型已切换为 ${model.provider}/${model.id}`);
3881
3941
  }
@@ -3894,7 +3954,10 @@ async function selectThinkingFromCommand(value) {
3894
3954
  if (sessionId) await post(`/api/sessions/${encodeURIComponent(sessionId)}/set_thinking_level`, { level });
3895
3955
  const next = { ...snapshotFor(sessionId), state: { ...(snapshotFor(sessionId).state || {}), thinkingLevel: level } };
3896
3956
  if (sessionId) S.snapshots.set(sessionId, next);
3897
- else S.newSessionSnapshot = next;
3957
+ else {
3958
+ S.newSessionSnapshot = next;
3959
+ S.newSessionPreferenceWorkspaceId = S.currentWorkspaceId;
3960
+ }
3898
3961
  queueRender(true);
3899
3962
  showToast(`思考强度已切换为 ${level}`);
3900
3963
  }
@@ -4003,6 +4066,7 @@ async function executeLocalSlashCommand(text) {
4003
4066
  }
4004
4067
 
4005
4068
  async function sendDraft() {
4069
+ if (S.pendingCreation) return;
4006
4070
  if (S.clipboardBusy) return showToast("正在上传并解析文件,请稍候", "warning");
4007
4071
  const text = S.draft.trim();
4008
4072
  const attachments = [...S.draftAttachments];
@@ -4027,6 +4091,7 @@ async function sendDraft() {
4027
4091
  showToast(error.message || String(error), "error");
4028
4092
  return;
4029
4093
  }
4094
+ if (S.pendingCreation) return;
4030
4095
  let sessionId = S.currentSessionId;
4031
4096
  if (!sessionId) {
4032
4097
  const ws = currentWorkspace();
@@ -4385,11 +4450,17 @@ async function onClick(ev) {
4385
4450
  ++S.openGeneration;
4386
4451
  S.currentSessionId = null;
4387
4452
  S.loadingSession = null;
4453
+ S.newSessionPreferenceWorkspaceId = null;
4388
4454
  loadCurrentDraftValue();
4455
+ try {
4456
+ await refreshModelCatalog(null, S.currentWorkspaceId, { state: null });
4457
+ } catch (error) {
4458
+ console.error("[pi-web] loading workspace models failed:", error);
4459
+ }
4389
4460
  queueRender(true);
4390
4461
  }
4391
4462
  } else if (action === "brand") {
4392
- if (S.currentSessionId) {
4463
+ if (S.currentSessionId || S.pendingCreation?.generation === S.openGeneration) {
4393
4464
  rememberCurrentDraft();
4394
4465
  ++S.openGeneration;
4395
4466
  S.currentSessionId = null;
@@ -4528,6 +4599,7 @@ async function onClick(ev) {
4528
4599
  state: { ...(snap.state || {}), model: selected, thinkingLevel },
4529
4600
  thinkingLevels,
4530
4601
  };
4602
+ S.newSessionPreferenceWorkspaceId = S.currentWorkspaceId;
4531
4603
  }
4532
4604
  closePopovers();
4533
4605
  queueRender(true);
@@ -4546,6 +4618,7 @@ async function onClick(ev) {
4546
4618
  ...S.newSessionSnapshot,
4547
4619
  state: { ...(S.newSessionSnapshot.state || {}), thinkingLevel: target.dataset.level },
4548
4620
  };
4621
+ S.newSessionPreferenceWorkspaceId = S.currentWorkspaceId;
4549
4622
  }
4550
4623
  closePopovers();
4551
4624
  queueRender(true);
@@ -4964,12 +5037,14 @@ function requireLogin() {
4964
5037
  }
4965
5038
 
4966
5039
  let eventSource;
5040
+ let eventWatchdog;
5041
+ let lastEventAt = 0;
4967
5042
  let resyncPending;
4968
5043
 
4969
5044
  async function resyncAfterReconnect() {
4970
5045
  if (resyncPending) return resyncPending;
4971
5046
  resyncPending = (async () => {
4972
- const boot = await api("/api/bootstrap");
5047
+ const boot = await api("/api/bootstrap", { signal: AbortSignal.timeout(15_000) });
4973
5048
  S.workspaces = applySavedWorkspaceOrder(boot.workspaces || []);
4974
5049
  S.serverInstanceId = boot.instanceId || null;
4975
5050
  S.lanEnabled = boot.lanEnabled === true;
@@ -4981,7 +5056,9 @@ async function resyncAfterReconnect() {
4981
5056
  const generation = S.openGeneration;
4982
5057
  if (id && S.workspaces.some((workspace) => workspace.sessions?.some((session) => session.id === id))) {
4983
5058
  const before = S.snapshots.get(id);
4984
- const data = await post(`/api/sessions/${encodeURIComponent(id)}/open`);
5059
+ const data = await api(`/api/sessions/${encodeURIComponent(id)}/open`, {
5060
+ method: "POST", body: "{}", signal: AbortSignal.timeout(15_000),
5061
+ });
4985
5062
  if (S.snapshots.get(id) === before) S.snapshots.set(id, data);
4986
5063
  } else if (id && generation === S.openGeneration) {
4987
5064
  rememberCurrentDraft();
@@ -5001,8 +5078,21 @@ function connectEvents() {
5001
5078
  eventSource?.close();
5002
5079
  const es = new EventSource("/api/events");
5003
5080
  eventSource = es;
5004
- es.onopen = () => { void resyncAfterReconnect(); };
5081
+ lastEventAt = Date.now();
5082
+ clearInterval(eventWatchdog);
5083
+ // Heartbeats arrive every 25s, including when the model is idle. Some mobile
5084
+ // networks leave a dead stream open without notifying EventSource.
5085
+ eventWatchdog = setInterval(() => {
5086
+ if (document.hidden || loginPromise || eventSource !== es) return;
5087
+ if (Date.now() - lastEventAt > 60_000) connectEvents();
5088
+ }, 5_000);
5089
+ es.onopen = () => {
5090
+ lastEventAt = Date.now();
5091
+ void resyncAfterReconnect();
5092
+ };
5005
5093
  es.onmessage = (ev) => {
5094
+ if (eventSource !== es) return;
5095
+ lastEventAt = Date.now();
5006
5096
  try {
5007
5097
  const event = JSON.parse(ev.data);
5008
5098
  if (event.type === "heartbeat") return;