pi-web-ui 0.50.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 +9 -9
- package/README.zh-CN.md +9 -9
- package/dist/server/agent-service.js +44 -9
- package/dist/server/index.js +3 -0
- package/dist/server/themes.js +6 -3
- package/dist/server/update-check.js +274 -0
- package/package.json +1 -1
- package/themes/cyberpunk.css +80 -7682
- package/themes/dazzle.css +80 -8009
- package/themes/md-preview.css +40 -7650
- package/themes/white.css +39 -7648
- package/web/dist/assets/{TerminalPanel-CqkXPEV8.js → TerminalPanel-uB-DaBC5.js} +1 -1
- package/web/dist/assets/index-DFCwWxY1.css +10 -0
- package/web/dist/assets/index-GrSZ6qyt.js +320 -0
- package/web/dist/index.html +2 -2
- package/themes/light.css +0 -6989
- package/web/dist/assets/index-OKDW_plu.css +0 -10
- package/web/dist/assets/index-gd3hCgKS.js +0 -320
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;
|
|
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 **
|
|
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
|
|
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`
|
|
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/
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
266
|
+
最容易的写法:复制一个内置调色板(如源码仓库里的 `themes/white.css`),改 `:root` 颜色即可 —— 想覆盖哪些变量就列哪些,没列的会落到 `styles.css` 的深色默认值。注意:
|
|
267
267
|
|
|
268
|
-
- **终端跟随主题** —— 在你的 `:root` 里设置 `--term-*` 变量(终端 ANSI 配色 + `--term-bg`),xterm 画布和它的内边距容器都会自动适配(默认值见 `styles.css
|
|
269
|
-
- 代码高亮色(打包自带 `highlight.js` 的 `github-dark.css
|
|
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` ——
|
|
277
|
+
2. 创建 `themes/<id>.css` —— 一份纯 `:root` 调色板。以 `themes/white.css`(浅色)或 `themes/cyberpunk.css`(深色)为模板。
|
|
278
278
|
3. 本地验证:运行 `npm run dev`,用顶栏主题选择器确认你的主题能被列出、渲染正确(对话卡片、代码块、工具调用卡片、Git/终端面板)。
|
|
279
|
-
4.
|
|
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
|
-
|
|
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/
|
|
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";
|
|
@@ -1475,15 +1476,7 @@ export class ClientSession {
|
|
|
1475
1476
|
}
|
|
1476
1477
|
/** Simple numeric semver compare: >0 means a newer than b. */
|
|
1477
1478
|
static compareVersions(a, b) {
|
|
1478
|
-
|
|
1479
|
-
const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
|
|
1480
|
-
for (let i = 0; i < 3; i++) {
|
|
1481
|
-
const x = pa[i] ?? 0;
|
|
1482
|
-
const y = pb[i] ?? 0;
|
|
1483
|
-
if (x !== y)
|
|
1484
|
-
return x - y;
|
|
1485
|
-
}
|
|
1486
|
-
return 0;
|
|
1479
|
+
return compareSemver(a, b);
|
|
1487
1480
|
}
|
|
1488
1481
|
/** Set by index.ts: called when /pi-web-ui:quit is invoked. */
|
|
1489
1482
|
onQuit = undefined;
|
|
@@ -1526,6 +1519,48 @@ export class ClientSession {
|
|
|
1526
1519
|
});
|
|
1527
1520
|
}
|
|
1528
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
|
+
}
|
|
1529
1564
|
async installPiAgent() {
|
|
1530
1565
|
try {
|
|
1531
1566
|
mkdirSync(this.agentDir, { recursive: true });
|
package/dist/server/index.js
CHANGED
|
@@ -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;
|
package/dist/server/themes.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Theme management:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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.
|
|
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": {
|