pi-web-ui 0.49.0 → 0.51.0

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
@@ -84,7 +84,7 @@ theme switching, and a full settings panel — tuned for daily development.
84
84
 
85
85
  ### 🎛️ Models & settings
86
86
 
87
- - Theme switching — pick a theme in the top bar; each theme is a full standalone stylesheet (default dark + a bundled light). See [Themes](#themes) for how to add your own or contribute one.
87
+ - Theme switching — pick a theme in the top bar; themes are pure `:root` palette overrides on top of the single layout stylesheet (default dark + bundled light/dark palettes). See [Themes](#themes) for how to add your own or contribute one.
88
88
  - Model management — edit `models.json` in the UI and set per-provider API keys (keys/headers never leave the server).
89
89
  - Thinking level per model (only the levels the model actually supports are shown).
90
90
  - First-run setup wizard.
@@ -303,7 +303,7 @@ pi-web-ui uninstall <id> # remove a plugin
303
303
 
304
304
  ## Themes
305
305
 
306
- Each theme is a **complete standalone stylesheet** — a full copy of the bundled dark `web/src/styles.css` with a different palette (no CSS-variable extraction, no base file to include). Picking a theme swaps the whole file, so any theme works with every build.
306
+ Each theme is a **pure `:root` palette override** — a small CSS file that only sets CSS variables (see the `:root` block in `web/src/styles.css` for the full variable list: base colors `--bg/--accent/--term-*` plus derived colors like `--tooltip-bg/--code-bg/--notice-*`). The layout lives ONLY in the bundled `web/src/styles.css`; picking a theme overrides the variables, so every theme works with every build and layout changes never touch themes. Built-in themes are generated by `node make-light-theme.mjs`.
307
307
 
308
308
  Built-in themes ship in the npm package (`themes/`, e.g. the bundled light theme). The theme picker lives in the top bar (🌞 icon); the current choice is stored per browser in `localStorage`.
309
309
 
@@ -325,10 +325,10 @@ Any CSS file dropped into your **data-dir themes folder** shows up in the theme
325
325
  └── my-theme.css # appears in the menu as "my-theme"
326
326
  ```
327
327
 
328
- Easiest way to write one: copy `themes/light.css` (or the bundled dark `web/src/styles.css` from the source repo) and change the `:root` colors plus any hardcoded values the file must be **self-contained**. Notes:
328
+ Easiest way to write one: copy a built-in palette (e.g. `themes/white.css` from the source repo) and change the `:root` colors list every variable you want to override; unlisted ones fall back to the dark defaults in `styles.css`. Notes:
329
329
 
330
- - The **terminal follows the theme** — set the `--term-*` variables (terminal ANSI palette + `--term-bg`) in your `:root` and both the xterm canvas and its padded container adapt automatically (see the defaults in `styles.css` and the light values in `themes/light.css`).
331
- - Syntax-highlight colors (`highlight.js`'s `github-dark.css` is bundled) must be overridden in your theme file or code will be unreadable — see the `.hljs` overrides at the bottom of `themes/light.css` for the pattern.
330
+ - The **terminal follows the theme** — set the `--term-*` variables (terminal ANSI palette + `--term-bg`) in your `:root` and both the xterm canvas and its padded container adapt automatically (see the defaults in `styles.css`).
331
+ - Syntax-highlight colors (`highlight.js`'s `github-dark.css` is bundled) must be overridden in your theme file or code will be unreadable on light themes — see the `.hljs` overrides at the bottom of `themes/white.css` for the pattern (dark themes can skip it).
332
332
  - Theme ids must match `^[A-Za-z0-9_-]+$` (no dots/slashes — path-traversal guard on the server).
333
333
 
334
334
  ### Contributing a theme to the repository (GitHub)
@@ -336,12 +336,12 @@ Easiest way to write one: copy `themes/light.css` (or the bundled dark `web/src/
336
336
  Want your theme shipped to everyone? Open a pull request at [github.com/xing-shuyin/pi-web-ui](https://github.com/xing-shuyin/pi-web-ui):
337
337
 
338
338
  1. Fork the repo and clone it.
339
- 2. Create your theme as `themes/<id>.css` — a **self-contained** stylesheet. Copy `themes/light.css` as the starting template (it's the generator output for a full standalone theme).
339
+ 2. Create your theme as `themes/<id>.css` — a pure `:root` palette. Copy `themes/white.css` (or `themes/cyberpunk.css` for a dark palette) as the starting template.
340
340
  3. Verify locally: run `npm run dev`, then use the top bar theme picker — your theme must be listed and render correctly (chat cards, code blocks, tool-call cards, git/terminal panels).
341
- 4. If you only changed colors in `styles.css` and want the bundled light theme updated too, regenerate it with `node make-light-theme.mjs`.
341
+ 4. Regenerate all built-in themes with `node make-light-theme.mjs` when you changed the variable list in `styles.css`.
342
342
  5. Commit (`git add themes/<id>.css`) and open the PR. The `themes/` folder is already in the npm package `files` whitelist, so once merged and released, `npm i -g pi-web-ui` will ship your theme to everyone.
343
343
 
344
- Rules for merged themes: the file must be a single self-contained CSS file, be a full standalone theme (no imports of the base `styles.css`), set the `--term-*` variables for a readable terminal, and override `.hljs` syntax colors for readable code.
344
+ Rules for merged themes: the file must be a single CSS file, set the `--term-*` variables for a readable terminal, and override `.hljs` syntax colors for readable code on light themes.
345
345
 
346
346
 
347
347
  ## Security
@@ -436,7 +436,7 @@ pi-web-ui is a small open-source project — **your contributions are what make
436
436
  | Way to contribute | How to get started |
437
437
  | --- | --- |
438
438
  | 🧩 **Write a plugin** | Build your own UI tab + agent tools. Copy `dev/plugins/demo-mailbox` as the minimal template (it doubles as the test fixture), develop locally, then either open a PR to ship it in the [catalog](#plugin-catalog) or [publish it standalone](https://github.com/xing-shuyin/pi-web-ui/tree/main/dev/plugins). |
439
- | 🎨 **Contribute a theme** | Copy `themes/light.css` as a self-contained template, tweak the `:root` palette + `--term-*` + `.hljs`, verify with `npm run dev`, then open a PR — full walkthrough in [Contributing a theme](#contributing-a-theme-to-the-repository-github). |
439
+ | 🎨 **Contribute a theme** | Copy `themes/white.css` (light) or `themes/cyberpunk.css` (dark) as a pure-palette template, tweak the `:root` palette + `--term-*` + `.hljs`, verify with `npm run dev`, then open a PR — full walkthrough in [Contributing a theme](#contributing-a-theme-to-the-repository-github). |
440
440
  | 💻 **Fix a bug / add a feature** | Look for [open issues](https://github.com/xing-shuyin/pi-web-ui/issues) or propose something new. Fork → branch → PR. Keep the code conventions in `AGENTS.md` (tabs, i18n keys in both languages, protocol changes in `server/protocol.ts`). |
441
441
  | 📖 **Docs & translations** | Improve the READMEs, write plugin docs, fix typos, or help translate the UI / docs into more languages. |
442
442
  | 💡 **Ideas & feedback** | Open an [issue](https://github.com/xing-shuyin/pi-web-ui/issues) or start a [discussion](https://github.com/xing-shuyin/pi-web-ui/discussions) — feature requests, bug reports, UI polish ideas, deployment experience reports. |
package/README.zh-CN.md CHANGED
@@ -51,7 +51,7 @@
51
51
  **模型与设置**
52
52
 
53
53
  - 模型管理 —— UI 里编辑 models.json、按 provider 设置 API key(密钥/headers 永不下发浏览器)。
54
- - 主题切换 —— 顶栏选择主题;每个主题是完整独立的样式表(默认深色 + 内置亮色)。如何添加自定义主题或向仓库贡献主题,见 [主题](#主题)。
54
+ - 主题切换 —— 顶栏选择主题;主题是纯 `:root` 调色板覆盖(布局唯一在 styles.css)。如何添加自定义主题或向仓库贡献主题,见 [主题](#主题)。
55
55
  - 思考强度(thinking level)按模型切换(只显示该模型实际支持的档位)。
56
56
  - 首次配置引导(PiSetupModal)。
57
57
  - 设置面板 —— 系统提示词(追加或整体替换)、技能/插件一键开关(即时生效)、设置预设保存/应用/删除、视觉桥模型与开关。
@@ -241,7 +241,7 @@ pi-web-ui uninstall <id> # 卸载插件
241
241
 
242
242
  ## 主题
243
243
 
244
- 每个主题是**一份完整独立的样式表** —— 即内置深色 `web/src/styles.css` 的整份副本,只是配色不同(不做 CSS 变量抽取、不需要引入基础文件)。切换主题就是整文件替换,因此任何主题都能在所有版本上工作。
244
+ 每个主题是**一份纯 `:root` 调色板覆盖** —— 只写 CSS 变量的声明文件(变量全集见 `web/src/styles.css` `:root`:`--bg/--accent/--term-*` 基础色,加 `--tooltip-bg/--code-bg/--notice-*` 等派生色)。布局只存在于打包的 `web/src/styles.css` 里,选主题只是覆盖变量,因此任何主题都能在所有版本上工作,改布局也不需要碰主题文件。内置主题由 `node make-light-theme.mjs` 生成。
245
245
 
246
246
  内置主题随 npm 包分发(`themes/`,例如自带的亮色主题)。主题选择器在顶栏(🌞 图标),当前选择按浏览器存在 `localStorage`。
247
247
 
@@ -263,10 +263,10 @@ pi-web-ui uninstall <id> # 卸载插件
263
263
  └── my-theme.css # 菜单里显示为 "my-theme"
264
264
  ```
265
265
 
266
- 最容易的写法:复制 `themes/light.css`(或源码仓库里内置的深色 `web/src/styles.css`),改 `:root` 颜色和必要的硬编码值即可 —— 文件必须**自包含**。注意:
266
+ 最容易的写法:复制一个内置调色板(如源码仓库里的 `themes/white.css`),改 `:root` 颜色即可 —— 想覆盖哪些变量就列哪些,没列的会落到 `styles.css` 的深色默认值。注意:
267
267
 
268
- - **终端跟随主题** —— 在你的 `:root` 里设置 `--term-*` 变量(终端 ANSI 配色 + `--term-bg`),xterm 画布和它的内边距容器都会自动适配(默认值见 `styles.css`,亮色值见 `themes/light.css`)。
269
- - 代码高亮色(打包自带 `highlight.js` 的 `github-dark.css`)必须在你的主题文件里覆盖,否则代码会看不清 —— 参照 `themes/light.css` 末尾的 `.hljs` 覆盖写法。
268
+ - **终端跟随主题** —— 在你的 `:root` 里设置 `--term-*` 变量(终端 ANSI 配色 + `--term-bg`),xterm 画布和它的内边距容器都会自动适配(默认值见 `styles.css`)。
269
+ - 代码高亮色(打包自带 `highlight.js` 的 `github-dark.css`)在浅色主题下必须覆盖,否则代码会看不清 —— 参照 `themes/white.css` 末尾的 `.hljs` 覆盖写法(深色主题可跳过)。
270
270
  - 主题 id 必须匹配 `^[A-Za-z0-9_-]+$`(不能有点和斜杠 —— 服务端有路径穿越防护)。
271
271
 
272
272
  ### 向仓库贡献主题(GitHub)
@@ -274,12 +274,12 @@ pi-web-ui uninstall <id> # 卸载插件
274
274
  想让你的主题随包分发给所有人?在 [github.com/xing-shuyin/pi-web-ui](https://github.com/xing-shuyin/pi-web-ui) 开一个 Pull Request:
275
275
 
276
276
  1. Fork 并 clone 仓库。
277
- 2. 创建 `themes/<id>.css` —— 一份**自包含**的样式表。以 `themes/light.css` 为模板(它是生成器产出的完整独立主题)。
277
+ 2. 创建 `themes/<id>.css` —— 一份纯 `:root` 调色板。以 `themes/white.css`(浅色)或 `themes/cyberpunk.css`(深色)为模板。
278
278
  3. 本地验证:运行 `npm run dev`,用顶栏主题选择器确认你的主题能被列出、渲染正确(对话卡片、代码块、工具调用卡片、Git/终端面板)。
279
- 4. 如果你只改了 `styles.css` 里的颜色、想让内置亮色主题同步更新,用 `node make-light-theme.mjs` 重新生成。
279
+ 4. 如果你改了 `styles.css` 的变量清单,用 `node make-light-theme.mjs` 重新生成全部内置主题。
280
280
  5. 提交(`git add themes/<id>.css`)并开 PR。`themes/` 已在 npm 包 `files` 白名单里,合并发布后 `npm i -g pi-web-ui` 即可把你的主题带给所有人。
281
281
 
282
- 合并主题的规则:必须是单一自包含 CSS 文件、是完整独立主题(不得 import 基础 `styles.css`)、保持 xterm 区域深色、覆盖 `.hljs` 语法高亮色以保证代码可读。
282
+ 合并主题的规则:必须是单一 CSS 文件、设置 `--term-*` 变量保证终端可读、浅色主题覆盖 `.hljs` 语法高亮色以保证代码可读。
283
283
 
284
284
  ## 安全
285
285
 
@@ -361,7 +361,7 @@ pi-web-ui 是一个小型开源项目 —— **你的贡献就是它成长的力
361
361
  | 贡献方式 | 如何开始 |
362
362
  | --- | --- |
363
363
  | 🧩 **写插件** | 打造你自己的界面 tab + AI 工具。以 `dev/plugins/demo-mailbox` 为最小模板(它兼作测试夹具),本地开发后既可开 PR 收录进[插件目录](#插件目录),也可独立发布。 |
364
- | 🎨 **贡献主题** | 以 `themes/light.css` 为自包含模板,调整 `:root` 配色 + `--term-*` + `.hljs`,用 `npm run dev` 验证后开 PR —— 完整步骤见[向仓库贡献主题](#向仓库贡献主题github)。 |
364
+ | 🎨 **贡献主题** | 以 `themes/white.css`(浅色)或 `themes/cyberpunk.css`(深色)为纯调色板模板,调整 `:root` 配色 + `--term-*` + `.hljs`,用 `npm run dev` 验证后开 PR —— 完整步骤见[向仓库贡献主题](#向仓库贡献主题github)。 |
365
365
  | 💻 **修 bug / 加功能** | 在 [Issues](https://github.com/xing-shuyin/pi-web-ui/issues) 里挑一个,或提出新想法。Fork → 分支 → PR。代码约定见 `AGENTS.md`(Tab 缩进、i18n 双语 key、协议改动只动 `server/protocol.ts`)。 |
366
366
  | 📖 **文档与翻译** | 完善 README、补插件文档、改错别字,或帮忙把界面/文档翻译成更多语言。 |
367
367
  | 💡 **想法与反馈** | 在 [Issues](https://github.com/xing-shuyin/pi-web-ui/issues) 或 [Discussions](https://github.com/xing-shuyin/pi-web-ui/discussions) 里开帖 —— 功能建议、bug 报告、界面优化点子、部署经验分享都欢迎。 |
@@ -17,6 +17,7 @@ import { fileURLToPath } from "node:url";
17
17
  import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
18
18
  import { Type } from "typebox";
19
19
  import { BgServerTracker } from "./bg-servers.js";
20
+ import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, } from "./update-check.js";
20
21
  import { hasPendingWaitSubscription, shouldRetainActive } from "./wait-subscription-scan.js";
21
22
  import { syncPluginToolsIntoSession } from "./plugins.js";
22
23
  import { SettingsService } from "./settings-service.js";
@@ -1027,6 +1028,42 @@ export class ClientSession {
1027
1028
  conv.queueSteering = [...event.steering];
1028
1029
  conv.queueFollowUp = [...event.followUp];
1029
1030
  break;
1031
+ // 手动 /compact 或阈值/溢出自动压缩开始——立即反馈,避免「没反应」
1032
+ // (此前 compaction_start/end 事件被 switch 静默丢弃,issue #33)。
1033
+ case "compaction_start": {
1034
+ this.emit({
1035
+ type: "notice",
1036
+ level: "info",
1037
+ text: "正在压缩上下文…(压缩摘要将显示在消息区)",
1038
+ });
1039
+ break;
1040
+ }
1041
+ case "compaction_end": {
1042
+ if (event.errorMessage) {
1043
+ this.emit({
1044
+ type: "notice",
1045
+ level: "error",
1046
+ text: `压缩上下文失败:${event.errorMessage}`,
1047
+ });
1048
+ }
1049
+ else if (event.aborted) {
1050
+ this.emit({
1051
+ type: "notice",
1052
+ level: "warning",
1053
+ text: "压缩上下文已取消",
1054
+ });
1055
+ }
1056
+ else if (event.result) {
1057
+ const { tokensBefore, estimatedTokensAfter } = event.result;
1058
+ const after = estimatedTokensAfter ?? tokensBefore;
1059
+ this.emit({
1060
+ type: "notice",
1061
+ level: "info",
1062
+ text: `上下文压缩完成:${tokensBefore.toLocaleString()} → ${after.toLocaleString()} tokens(摘要已插入消息区)`,
1063
+ });
1064
+ }
1065
+ break;
1066
+ }
1030
1067
  // A run finished or a new entry was persisted — keep the session list fresh
1031
1068
  // (new chat + first message, completed turns, compaction, etc.).
1032
1069
  case "agent_end": {
@@ -1108,7 +1145,9 @@ export class ClientSession {
1108
1145
  // Snapshot checkpoint policy: deltas carry live rendering during streaming;
1109
1146
  // full snapshots are reconciliation checkpoints taken immediately at
1110
1147
  // run/tool boundaries and on a slow timer otherwise.
1111
- if (event.type === "agent_end" || event.type === "tool_execution_end") {
1148
+ if (event.type === "agent_end" ||
1149
+ event.type === "tool_execution_end" ||
1150
+ event.type === "compaction_end") {
1112
1151
  this.flushSnapshot();
1113
1152
  }
1114
1153
  else {
@@ -1437,15 +1476,7 @@ export class ClientSession {
1437
1476
  }
1438
1477
  /** Simple numeric semver compare: >0 means a newer than b. */
1439
1478
  static compareVersions(a, b) {
1440
- const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
1441
- const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
1442
- for (let i = 0; i < 3; i++) {
1443
- const x = pa[i] ?? 0;
1444
- const y = pb[i] ?? 0;
1445
- if (x !== y)
1446
- return x - y;
1447
- }
1448
- return 0;
1479
+ return compareSemver(a, b);
1449
1480
  }
1450
1481
  /** Set by index.ts: called when /pi-web-ui:quit is invoked. */
1451
1482
  onQuit = undefined;
@@ -1488,6 +1519,48 @@ export class ClientSession {
1488
1519
  });
1489
1520
  }
1490
1521
  }
1522
+ /** Cache window for the all-source check: 30 minutes. */
1523
+ static UPDATE_ALL_CACHE_MS = 30 * 60_000;
1524
+ updatesAllCache = null;
1525
+ /**
1526
+ * All-source update check: pi-web-ui + the pi core + direct pi extensions
1527
+ * from the agent manifest (fallback: raw walk). Re-emits the cached list
1528
+ * within UPDATE_ALL_CACHE_MS; pass force=true (explicit refresh) to bypass.
1529
+ */
1530
+ async checkUpdatesAll(force = false) {
1531
+ if (!force &&
1532
+ this.updatesAllCache &&
1533
+ Date.now() - this.updatesAllCache.at <
1534
+ ClientSession.UPDATE_ALL_CACHE_MS) {
1535
+ this.emit({
1536
+ type: "update_status_all",
1537
+ items: this.updatesAllCache.items,
1538
+ });
1539
+ return;
1540
+ }
1541
+ try {
1542
+ const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion());
1543
+ const items = await checkAllUpdates(targets);
1544
+ this.updatesAllCache = { at: Date.now(), items };
1545
+ this.emit({ type: "update_status_all", items });
1546
+ }
1547
+ catch (err) {
1548
+ // checkAll degrades per-item; only local enumeration blowing up lands
1549
+ // here — still report a usable (webui-only) error item.
1550
+ const items = [
1551
+ {
1552
+ name: "pi-web-ui",
1553
+ kind: "webui",
1554
+ current: ClientSession.currentAppVersion(),
1555
+ latest: null,
1556
+ latestPublishedAt: null,
1557
+ upToDate: false,
1558
+ error: `检查更新失败:${err.message}`,
1559
+ },
1560
+ ];
1561
+ this.emit({ type: "update_status_all", items });
1562
+ }
1563
+ }
1491
1564
  async installPiAgent() {
1492
1565
  try {
1493
1566
  mkdirSync(this.agentDir, { recursive: true });
@@ -625,6 +625,9 @@ wss.on("connection", (ws) => {
625
625
  case "check_update":
626
626
  void cs.checkUpdate();
627
627
  break;
628
+ case "check_updates_all":
629
+ void cs.checkUpdatesAll(msg.force === true);
630
+ break;
628
631
  case "dialog_response":
629
632
  cs.resolveDialog(msg.id, msg.value);
630
633
  break;
@@ -157,12 +157,10 @@ export class SlashCommandsService {
157
157
  try {
158
158
  await this.host.getSession().compact(args || undefined);
159
159
  }
160
- catch (err) {
161
- this.host.emit({
162
- type: "notice",
163
- level: "error",
164
- text: `压缩上下文失败:${err.message}`,
165
- });
160
+ catch {
161
+ // 压缩过程/结果/错误反馈统一由 agent-service onEvent 的
162
+ // compaction_start / compaction_end 事件处理(含 errorMessage),
163
+ // 这里不重复发通知;SDK 在 throw 前必发 compaction_end(issue #33)。
166
164
  }
167
165
  return true;
168
166
  case "cwd":
@@ -1,7 +1,10 @@
1
1
  /**
2
- * Theme management: complete standalone CSS files that replace the whole UI
3
- * stylesheet. Each theme is a full copy of web/src/styles.css with a different
4
- * palette no variable extraction, the browser just swaps the entire file.
2
+ * Theme management: pure `:root` palette overrides. The whole UI layout lives
3
+ * in web/src/styles.css (single source); a theme is just a small CSS file that
4
+ * overrides the :root CSS variables (--bg/--accent/--term-* plus derived ones
5
+ * like --tooltip-bg/--code-bg). The frontend injects the theme file as a
6
+ * <link> AFTER the bundled styles.css, so its :root variables win the cascade.
7
+ * Builtin themes are generated by node make-light-theme.mjs.
5
8
  *
6
9
  * Theme sources (merged, user wins over builtin on id collision):
7
10
  * - builtin: <pkgRoot>/themes/*.css (ships with the npm package)
@@ -0,0 +1,274 @@
1
+ /**
2
+ * All-source update check: pi-web-ui itself, the installed pi core
3
+ * (@earendil-works/pi-coding-agent — probed via `pi --version`, with a
4
+ * vendored-copy fallback), plus the DIRECT pi extensions declared in
5
+ * <agentDir>/npm/package.json (fallback: raw node_modules walk).
6
+ * Pure logic lives here so it can be unit-tested with an injected fetcher
7
+ * (and an injected pi-core probe); ClientSession only wires it to the wire
8
+ * protocol.
9
+ */
10
+ import { spawnSync } from "node:child_process";
11
+ import { readdirSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ const PI_CORE_PACKAGE = "@earendil-works/pi-coding-agent";
14
+ const REGISTRY = "https://registry.npmjs.org";
15
+ const FETCH_TIMEOUT_MS = 8_000;
16
+ /** Parallel registry lookups per batch. */
17
+ const CONCURRENCY = 5;
18
+ /** Simple numeric semver compare: >0 means a newer than b. */
19
+ export function compareVersions(a, b) {
20
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
21
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
22
+ for (let i = 0; i < 3; i++) {
23
+ const x = pa[i] ?? 0;
24
+ const y = pb[i] ?? 0;
25
+ if (x !== y)
26
+ return x - y;
27
+ }
28
+ return 0;
29
+ }
30
+ /**
31
+ * Cache a zero-arg function's value for ttlMs. Plain value memoization: the
32
+ * pi probe returns null on failure instead of throwing, so errors thread
33
+ * through as ordinary values and there is nothing to rethrow.
34
+ */
35
+ export function memoizeWithTtl(fn, ttlMs) {
36
+ let entry = null;
37
+ return () => {
38
+ const now = Date.now();
39
+ if (!entry || now - entry.at >= ttlMs) {
40
+ entry = { at: now, value: fn() };
41
+ }
42
+ return entry.value;
43
+ };
44
+ }
45
+ /**
46
+ * Parse `pi --version` stdout into a version string, or null. Two-stage:
47
+ * prefer a line that is exactly the version (optional leading "v", optional
48
+ * prerelease/build suffix) so a stdout preamble like "Update available:
49
+ * 0.85.0" cannot forge it; otherwise fall back to the first loose
50
+ * semver-looking token. The exact-line match keeps the FULL version incl.
51
+ * prerelease (0.85.0-beta.1 stays 0.85.0-beta.1).
52
+ */
53
+ export function parsePiVersionOutput(stdout) {
54
+ const exact = stdout.match(/^\s*v?(\d+\.\d+\.\d+(?:[-+][\w.]+)*)\s*$/m)?.[1];
55
+ if (exact)
56
+ return exact;
57
+ return stdout.match(/\d+\.\d+\.\d+/)?.[0] ?? null;
58
+ }
59
+ /**
60
+ * Enumerate installed pi packages for the "check all updates" list, matching
61
+ * what the TUI shows: the DIRECT dependencies declared in
62
+ * <agentDir>/npm/package.json, with each installed version resolved from
63
+ * node_modules/<name>/package.json (not the manifest range). Transitive deps
64
+ * are not listed.
65
+ *
66
+ * Fallback: when the manifest is missing/unreadable or declares no
67
+ * dependencies, fall back to the historical raw node_modules walk.
68
+ */
69
+ export function listInstalledPackages(agentDir) {
70
+ const direct = readManifestDeps(agentDir);
71
+ if (direct)
72
+ return direct;
73
+ return walkNodeModules(agentDir);
74
+ }
75
+ /** Direct deps from the npm manifest with installed versions, or null. */
76
+ function readManifestDeps(agentDir) {
77
+ let manifest;
78
+ try {
79
+ manifest = JSON.parse(readFileSync(join(agentDir, "npm", "package.json"), "utf8"));
80
+ // Literal `null` parses fine but explodes on property access — treat as
81
+ // unreadable (fallback to the raw walk), per the documented contract.
82
+ if (!manifest || typeof manifest !== "object")
83
+ return null;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ const deps = manifest.dependencies;
89
+ if (!deps || typeof deps !== "object" || Object.keys(deps).length === 0) {
90
+ return null;
91
+ }
92
+ const root = join(agentDir, "npm", "node_modules");
93
+ const out = [];
94
+ for (const name of Object.keys(deps)) {
95
+ const item = readLocalPackage(join(root, ...name.split("/")));
96
+ // Broken/uninstalled entries are skipped (the registry never sees them).
97
+ if (item)
98
+ out.push(item);
99
+ }
100
+ // Deterministic order (manifest key order is arbitrary).
101
+ return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
102
+ }
103
+ /**
104
+ * Legacy fallback: raw walk of <agentDir>/npm/node_modules — top-level plain
105
+ * names plus one level inside @scope dirs. Skips .bin, dotfiles and anything
106
+ * without a readable package.json.
107
+ */
108
+ function walkNodeModules(agentDir) {
109
+ const root = join(agentDir, "npm", "node_modules");
110
+ const out = [];
111
+ let entries;
112
+ try {
113
+ entries = readdirSync(root);
114
+ }
115
+ catch {
116
+ return out;
117
+ }
118
+ for (const entry of entries) {
119
+ if (entry.startsWith(".") || entry === ".bin")
120
+ continue;
121
+ if (entry.startsWith("@")) {
122
+ let scoped;
123
+ try {
124
+ scoped = readdirSync(join(root, entry));
125
+ }
126
+ catch {
127
+ continue;
128
+ }
129
+ for (const name of scoped) {
130
+ if (name.startsWith("."))
131
+ continue;
132
+ const item = readLocalPackage(join(root, entry, name));
133
+ if (item)
134
+ out.push(item);
135
+ }
136
+ }
137
+ else {
138
+ const item = readLocalPackage(join(root, entry));
139
+ if (item)
140
+ out.push(item);
141
+ }
142
+ }
143
+ // Deterministic order (readdir order is FS-dependent).
144
+ return out.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
145
+ }
146
+ function readLocalPackage(dir) {
147
+ try {
148
+ const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
149
+ if (!pkg.name || !pkg.version)
150
+ return null;
151
+ return { name: pkg.name, version: pkg.version, kind: "package" };
152
+ }
153
+ catch {
154
+ return null;
155
+ }
156
+ }
157
+ /** Uncached pi core probe (memoized machine-wide below). */
158
+ function rawProbePiCore() {
159
+ try {
160
+ const res = spawnSync("pi", ["--version"], {
161
+ timeout: 5000,
162
+ stdio: "pipe",
163
+ shell: process.platform === "win32",
164
+ });
165
+ if (res.error || res.status !== 0)
166
+ return null;
167
+ return parsePiVersionOutput(res.stdout?.toString() ?? "");
168
+ }
169
+ catch {
170
+ return null;
171
+ }
172
+ }
173
+ /** How long a pi probe result stays hot (mirrors ClientSession.piCliProbe). */
174
+ const PI_PROBE_TTL_MS = 10_000;
175
+ /**
176
+ * Default pi core probe: run the globally installed `pi --version`, memoized
177
+ * machine-wide for PI_PROBE_TTL_MS so repeated collectTargets calls never
178
+ * re-block the event loop on a 5s spawnSync. Null on any failure or absence.
179
+ * Mirrors ClientSession.isPiCliInstalled() (same spawnSync shape; Windows
180
+ * resolves `pi` to a pi.cmd shim that only execs through a shell).
181
+ */
182
+ export const defaultProbePiCore = memoizeWithTtl(rawProbePiCore, PI_PROBE_TTL_MS);
183
+ /**
184
+ * Fallback when the CLI probe yields nothing: the version of the vendored pi
185
+ * core copy in <agentDir>/npm/node_modules, or null if that is absent too.
186
+ */
187
+ function readVendoredPiCore(agentDir) {
188
+ try {
189
+ const pkg = JSON.parse(readFileSync(join(agentDir, "npm", "node_modules", ...PI_CORE_PACKAGE.split("/"), "package.json"), "utf8"));
190
+ if (pkg.name !== PI_CORE_PACKAGE || !pkg.version)
191
+ return null;
192
+ return pkg.version;
193
+ }
194
+ catch {
195
+ return null;
196
+ }
197
+ }
198
+ /**
199
+ * Build the full local target list: webui + the pi core + installed packages.
200
+ * The pi core version comes from the CLI probe (injectable for tests), falling
201
+ * back to the vendored copy under <agentDir>/npm/node_modules. Packages
202
+ * listing the core directly are filtered out so the pi-core row wins — never
203
+ * two rows for the same package.
204
+ */
205
+ export function collectTargets(agentDir, webuiVersion, probePiCore = defaultProbePiCore) {
206
+ const targets = [
207
+ { name: "pi-web-ui", version: webuiVersion, kind: "webui" },
208
+ ];
209
+ const coreVersion = probePiCore() ?? readVendoredPiCore(agentDir);
210
+ if (coreVersion) {
211
+ targets.push({
212
+ name: PI_CORE_PACKAGE,
213
+ version: coreVersion,
214
+ kind: "pi-core",
215
+ });
216
+ }
217
+ targets.push(...listInstalledPackages(agentDir).filter((pkg) => pkg.name !== PI_CORE_PACKAGE));
218
+ return targets;
219
+ }
220
+ /** Default fetcher (real network). Tests inject a fake. */
221
+ export const defaultFetcher = (url, init) => fetch(url, init);
222
+ /** Look up one package's latest version + publish time in the npm registry. */
223
+ export async function fetchLatest(fetcher, name) {
224
+ const res = await fetcher(`${REGISTRY}/${encodeURIComponent(name)}`, {
225
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
226
+ });
227
+ if (!res.ok)
228
+ throw new Error(`HTTP ${res.status}`);
229
+ const data = (await res.json());
230
+ const latest = data["dist-tags"]?.latest ?? null;
231
+ return {
232
+ latest,
233
+ latestPublishedAt: latest && data.time ? (data.time[latest] ?? null) : null,
234
+ };
235
+ }
236
+ /**
237
+ * Check every target against the registry. One failed lookup degrades to an
238
+ * error item (upToDate: false) without failing the rest. Results keep the
239
+ * input order. Bounded concurrency (CONCURRENCY) keeps registry load polite.
240
+ */
241
+ export async function checkAll(targets, fetcher = defaultFetcher) {
242
+ const results = new Array(targets.length);
243
+ let cursor = 0;
244
+ async function worker() {
245
+ while (cursor < targets.length) {
246
+ const i = cursor++;
247
+ const t = targets[i];
248
+ try {
249
+ const { latest, latestPublishedAt } = await fetchLatest(fetcher, t.name);
250
+ results[i] = {
251
+ name: t.name,
252
+ kind: t.kind,
253
+ current: t.version,
254
+ latest,
255
+ latestPublishedAt,
256
+ upToDate: latest === null || compareVersions(t.version, latest) >= 0,
257
+ };
258
+ }
259
+ catch (err) {
260
+ results[i] = {
261
+ name: t.name,
262
+ kind: t.kind,
263
+ current: t.version,
264
+ latest: null,
265
+ latestPublishedAt: null,
266
+ upToDate: false,
267
+ error: `检查更新失败:${err.message}`,
268
+ };
269
+ }
270
+ }
271
+ }
272
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, targets.length) }, worker));
273
+ return results;
274
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.49.0",
3
+ "version": "0.51.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "author": {