pi-web-ui 0.84.0 → 0.85.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/CHANGELOG.md CHANGED
@@ -8,7 +8,33 @@
8
8
  每个版本的内容按"实际合入该版本发布的提交"归档(以 `package.json` 的 version 变更提交为准),
9
9
  而不是按提交日期聚类——连续快速发布的 patch 版本以此为准最准确。
10
10
 
11
- ## [Unreleased]
11
+ ## [0.85.0] — 2026-09-14
12
+
13
+ ### Added
14
+
15
+ - **微信里也能指挥 agent 了**:新插件 `wechat-ilink`(💬 微信通道)——微信扫码登录,直连微信 ilink 后端(与腾讯官方 `openclaw-weixin` 同源协议),出站长轮询收消息,家里内网、公司内网都能跑,不用开端口、不用公网 IP。白名单用户(或填 `*` 全放行)的消息自动投给 agent,跑完回结果;陌生人先挂「待配对」,在视图里点允许/拒绝。附带 `wechat_send` 工具,agent 可主动发微信消息。
16
+ - 通道能力由宿主新扩展点 `host.chat` 承载(`server/plugins.ts` + `AgentService.chatFromPlugin`):无头调用、无浏览器也能跑,每个(插件,账号)独立伪客户端,fire-and-forget 投递、运行结果经 `onRunEvent(run_end)` 按 conversationId 关联回包;需要 manifest `chat` 能力声明,文本 8000 字封顶。
17
+ - v1 诚实范围:单账号、文本全双工,图片/语音/文件只转写占位;默认只回私聊;与该服务 cwd 下最近会话共享(peer 名拼进前缀,per-peer 独立会话以后再加)。`bot_token` 存宿主加密机密。
18
+ - **「浏览器操作」面板有了扩展安装引导** —— 扩展不在 Chrome 应用商店,以前用户卡在第一步。现在面板里直接给下载按钮(`page-picker-extension.zip`,永远最新版,走 GitHub Release latest 别名)+ 四步图文(解压 → 开发者模式 → 加载已解压 → 点图标设为服务地址,地址端口不用手填)。
19
+ - **image-toolkit 的配置搬进插件自己家里了** —— 以前 7 项设置走 ⚙ 面板 → 界面插件的声明式 `settings`(manifest 已删);现在存在插件自己的 storage(`config` 键),在 🖼 视图右上角 ⚙ 里改,并新增内部配置存取路由(视图 ↔ 服务端经 `settings` 广播同步,改完即时生效,按需上/下架 AI 工具)。老版本声明式设置一次性自动迁移(读不到就全默认,坏值归一化回默认值,绝不带崩工具/视图)。
20
+ - **`legado_book_sources` 多了 `unmark`** —— AI 修完源、验证通过后调它摘掉废源/可疑标记,否则页面默认隐藏废源、用户会以为源丢了(`rules.md` 修源流程已同步到 6 步:先 `probe` 验证、再 `unmark`、最后提醒刷新)。
21
+ - **Legado 阅读页不再拿旧规则误判废源** —— AI 刚修完规则时,页面内存里还是旧的,之前失败一次就记废源。现在失败前先比对磁盘新规则的「可读性指纹」(搜索/发现/头/四条解析规则变了才算新),命中则按新规则重读并提示重试,这次失败不记废源。另:发现页书源下拉的筛选词重渲染不再丢失。
22
+
23
+ ### Changed
24
+
25
+ - **`browser_page` 工具默认关了**(原来默认开)—— AI 动用户浏览器,应该是 opt-in 才开;关掉后顶栏「浏览器操作」入口整个隐藏(面板里的例子与「引用到对话」都是教模型用 `browser_page` 的,工具不在留着只会给出做不到的承诺)。扩展侧总开关关了则保留面板,好把人送去开开关。
26
+
27
+ ### Fixed
28
+
29
+ - **机密存储重启后全炸的 bug 修了** —— `secrets.key` 的读法把文件缓冲直接 `.toString("hex")`(hex 文本又做了一次 hex 编码),重启后 key 变成 65 字节,AES-256-GCM 全线报 "Invalid key length"。现在按 UTF-8 读 hex 文本再解码,并校验 32 字节:长度不对视为损坏、重新生成(旧机密按 fail closed 丢弃,总比所有机密操作全炸好;单测 `plugin-facilities.test.ts` 回归)。
30
+ - **Windows 上新终端偶发吃掉首字节** —— 刚 spawn 的 PTY 在 shell 还没开始读时就写入,ConPTY 会丢最前面的字节,经典症状是 `pi-web-ui` 到达时变成 `i-web-ui`。现在复用输出就绪信号(首包输出即 shell 可读,1.5s 超时兜底;重启/kill 掉的过期条目跳过),命令只在就绪后写入。
31
+ - **目标(goal)展开时内容不再被挤扁**(issue #141)—— `.goalbar` 纵向容器原来是 `align-items: center`,展开态的两行被压成内容宽度,输入框缩成一小截。现在容器改 `align-items: stretch` 让展开的行铺满整列,只有折叠态(小药丸)保留居中。
32
+
33
+ <!-- auto-i18n:start -->
34
+ ### i18n
35
+
36
+ - 前端新增 key(7):`browserControlInstall`、`browserControlInstallLead`、`browserControlInstallDownload`、`browserControlInstallStep1`、`browserControlInstallStep2`、`browserControlInstallStep3`、`browserControlInstallStep4`
37
+ <!-- auto-i18n:end -->
12
38
 
13
39
  ## [0.84.0] — 2026-09-13
14
40
 
@@ -5573,6 +5573,27 @@ export class AgentService {
5573
5573
  }
5574
5574
  return best;
5575
5575
  }
5576
+ /** 插件无头调用(host.chat 的落地):外部通道(微信等)把文本投给 agent。
5577
+ * 每个 (pluginId, accountId) 独立伪客户端——复用 attach 完整链路
5578
+ * (会话恢复/持久化/工具注入/快照),无浏览器也能跑;sink 是空函数,
5579
+ * 快照/notice 发了即丢,不攒内存。fire-and-forget:prompt 投递即返回,
5580
+ * 运行结果经 onRunEvent(run_end) 按 conversationId 关联。
5581
+ * v1 语义:与该服务 cwd 下最近会话共享(单用户视角连续);peer 名由插件
5582
+ * 拼进文本前缀,per-peer 会话隔离以后再加。 */
5583
+ async chatFromPlugin(pluginId, req) {
5584
+ const safe = String(pluginId ?? "plugin").replace(/[^A-Za-z0-9_-]/g, "") || "plugin";
5585
+ const acct = String(req?.accountId ?? "default").replace(/[^A-Za-z0-9_-]/g, "") || "default";
5586
+ const clientId = `plugin:${safe}:${acct}`;
5587
+ const text = String(req?.text ?? "");
5588
+ if (!text.trim())
5589
+ throw new Error("chatFromPlugin: text 为空");
5590
+ if (this.quiesced)
5591
+ throw new QuiesceRejectedError("插件无头调用被拒绝,请等服务器恢复后重试");
5592
+ const cs = await this.attach(clientId, () => { });
5593
+ const before = cs.readConversationForPlugins()?.conversationId ?? "";
5594
+ void cs.prompt(text);
5595
+ return { conversationId: before, clientId };
5596
+ }
5576
5597
  /** index.ts calls this when a browser socket opens/closes. */
5577
5598
  noteSocketOpen() {
5578
5599
  this.socketCount += 1;
@@ -649,6 +649,8 @@ service.onConversationChanged = () => pluginMgr.emitConversationChanged();
649
649
  // 插件扩展点:当前打开对话的快照(轨迹视图直接显示打开对话的时间线;
650
650
  // dsh 引擎无此方法时回退 null,插件显示空态)。
651
651
  pluginMgr.conversationProvider = () => service.readConversationForPlugins?.() ?? null;
652
+ // 插件扩展点:无头调用 agent(微信通道等经 host.chat 投递外部消息,无浏览器也能跑)。
653
+ pluginMgr.chatProvider = (pluginId, req) => service.chatFromPlugin?.(pluginId, req) ?? Promise.reject(new Error("当前引擎不支持无头调用(仅标准 pi 引擎)"));
652
654
  // 插件扩展点:插件注册的 AI 工具(registerAgentTool)+ MCP 桥工具 → 会话创建时
653
655
  // 带上 + 变化时动态注入/移除已有会话。
654
656
  service.pluginToolsProvider = () => [...pluginMgr.getAgentTools(), ...mcpBridge.getTools()];
@@ -93,12 +93,22 @@ function unseal(key, blob) {
93
93
  decipher.setAuthTag(Buffer.from(blob.tag, "hex"));
94
94
  return Buffer.concat([decipher.update(Buffer.from(blob.ct, "hex")), decipher.final()]).toString("utf8");
95
95
  }
96
- /** 读或创建全局密钥文件(懒加载一次)。 */
96
+ /** 读或创建全局密钥文件(懒加载一次)。
97
+ *
98
+ * 曾有一个 bug:`readFileSync` 拿到的缓冲直接 `.toString("hex")`(把 hex 文本
99
+ * 又做了一次 hex 编码),导致重启后 key 变成 65 字节,AES-256-GCM 全线报
100
+ * "Invalid key length”。此处按 UTF-8 读 hex 文本再解码,并校验 32 字节——
101
+ * 长度不对即视为损坏,重新生成(旧 secrets.bin 用旧 key 解不开→按 fail closed
102
+ * 丢弃,总比所有机密操作全炸好;见 tests/unit/plugin-facilities.test.ts 回归)。 */
97
103
  function loadOrCreateKey(dataDir) {
98
104
  const keyFile = join(dataDir, "secrets.key");
99
105
  try {
100
- if (existsSync(keyFile))
101
- return Buffer.from(readFileSync(keyFile).toString("hex").trim(), "hex");
106
+ if (existsSync(keyFile)) {
107
+ const loaded = Buffer.from(readFileSync(keyFile, "utf8").trim(), "hex");
108
+ if (loaded.length === 32)
109
+ return loaded;
110
+ console.warn(`[secrets] ${keyFile} 长度异常(${loaded.length} 字节),重新生成密钥(旧机密将不可读)`);
111
+ }
102
112
  }
103
113
  catch {
104
114
  /* fallthrough → regenerate */
@@ -491,6 +491,8 @@ export class PluginManager {
491
491
  }
492
492
  /** index.ts 注入:读取当前打开对话的快照(轨迹类插件经 host.getActiveConversation 调用)。 */
493
493
  conversationProvider = undefined;
494
+ /** index.ts 注入:插件无头调用 agent(微信通道等经 host.chat 调用)。 */
495
+ chatProvider = undefined;
494
496
  /** 当前打开对话的快照(无提供者/暂无对话时返回 null)。 */
495
497
  getActiveConversation() {
496
498
  try {
@@ -877,6 +879,20 @@ export class PluginManager {
877
879
  return () => convChangeHandlers.delete(h);
878
880
  },
879
881
  getActiveConversation: () => self.getActiveConversation(),
882
+ chat: (req) => {
883
+ if (!can("chat"))
884
+ return Promise.reject(new Error(`插件未声明能力 "chat"(manifest.permissions)——请求被拒`));
885
+ if (!self.chatProvider) {
886
+ return Promise.reject(new Error("宿主未提供无头调用(chatProvider 未接入)——请升级 pi-web-ui"));
887
+ }
888
+ const text = String(req?.text ?? "").trim();
889
+ if (!text)
890
+ return Promise.reject(new Error("chat: text 为空"));
891
+ if (text.length > 8000)
892
+ return Promise.reject(new Error("chat: text 超长(>8000 字),请裁剪后重发"));
893
+ const accountId = String(req?.accountId ?? "default").slice(0, 64);
894
+ return self.chatProvider(info.id, { text, accountId });
895
+ },
880
896
  onAttach: (h) => {
881
897
  attachHandlers.add(h);
882
898
  return () => attachHandlers.delete(h);
@@ -748,7 +748,7 @@ export class TerminalManager {
748
748
  return;
749
749
  this.emitList();
750
750
  // Clear the previous run's output, then show a banner and run the command
751
- // (the PTY input buffer holds it until the shell is ready).
751
+ // once the fresh shell is ready to read (see writeWhenReady).
752
752
  const banner = "\x1b[2J\x1b[3J\x1b[H" + `\x1b[90m> ${command}\x1b[0m \x1b[90m(${dir})\x1b[0m\r\n`;
753
753
  const fresh = this.terms.get(id);
754
754
  if (fresh)
@@ -756,7 +756,47 @@ export class TerminalManager {
756
756
  this.writeOut(id, banner);
757
757
  this.maybeEmitTccHint(id);
758
758
  if (command)
759
- this.input(id, command + "\r");
759
+ this.writeWhenReady(id, command + "\r");
760
+ }
761
+ /**
762
+ * Type text into a freshly spawned shell once it is ready to read.
763
+ *
764
+ * Input written to a brand-new PTY before its shell is reading can lose
765
+ * the first bytes (Windows ConPTY is the usual victim — the classic
766
+ * symptom is `pi-web-ui` arriving as `i-web-ui`). The shell's first
767
+ * output is the readiness signal; a timeout covers shells that start
768
+ * silent. Stale entries (restarted/killed while waiting) are skipped.
769
+ */
770
+ writeWhenReady(id, text, timeoutMs = 1500) {
771
+ const entry = this.terms.get(id);
772
+ if (!entry || entry.exited)
773
+ return;
774
+ let sent = false;
775
+ let disp;
776
+ const send = () => {
777
+ if (sent)
778
+ return;
779
+ sent = true;
780
+ clearTimeout(timer);
781
+ try {
782
+ disp?.dispose();
783
+ }
784
+ catch {
785
+ // PTY already gone — the stale-entry guard below no-ops.
786
+ }
787
+ const live = this.terms.get(id);
788
+ if (!live || live !== entry || live.exited)
789
+ return;
790
+ this.input(id, text);
791
+ };
792
+ const timer = setTimeout(send, timeoutMs);
793
+ timer.unref?.();
794
+ try {
795
+ disp = entry.pty.onData(() => send());
796
+ }
797
+ catch {
798
+ // Spawn failed between the lookup and now — timer fallback no-ops.
799
+ }
760
800
  }
761
801
  /** Spawn the user's shell as a PTY. Returns false when the spawn failed. */
762
802
  spawnShell(id, cwd, cols, rows, title, command, forceBash, agentBash = false, locale) {
@@ -68,9 +68,9 @@ export const AGENT_TOOL_CATALOG = [
68
68
  { name: DELEGATE_TASK_TOOL_NAME, group: "other", defaultOn: true, dshVisible: false },
69
69
  { name: ASK_USER_QUESTION_TOOL_NAME, group: "other", defaultOn: true, dshVisible: true },
70
70
  { name: MARKERS_LIST_TOOL_NAME, group: "other", defaultOn: true, dshVisible: true },
71
- // 默认开但 dshVisible=false:DSH 引擎没有页面桥(page_request 由 pi 引擎的
72
- // customTool 发出),列在那里只会让用户关一个不存在的工具。
73
- { name: BROWSER_PAGE_TOOL_NAME, group: "other", defaultOn: true, dshVisible: false },
71
+ // 默认关(AI 动用户浏览器,opt-in 才开)且 dshVisible=false:DSH 引擎没有页面桥
72
+ // (page_request 由 pi 引擎的 customTool 发出),列在那里只会让用户关一个不存在的工具。
73
+ { name: BROWSER_PAGE_TOOL_NAME, group: "other", defaultOn: false, dshVisible: false },
74
74
  ];
75
75
  const KNOWN_NAMES = new Set(AGENT_TOOL_CATALOG.map((t) => t.name));
76
76
  /** 是否为本表登记的可开关工具(未知名一律 false,不抛错)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.84.0",
3
+ "version": "0.85.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": {
@@ -53,6 +53,15 @@
53
53
  "source": "xing-shuyin/pi-web-ui/plugins/legado-web",
54
54
  "homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/legado-web"
55
55
  },
56
+ {
57
+ "id": "wechat-ilink",
58
+ "name": "微信通道",
59
+ "icon": "💬",
60
+ "description": "微信扫码登录(与腾讯 openclaw-weixin 同源的 ilink 协议):出站长轮询收消息,微信里直接指挥 agent,无需公网 IP。",
61
+ "descriptionEn": "WeChat channel (ilink protocol, same origin as Tencent openclaw-weixin): outbound long-polling, drive the agent from WeChat, no public IP needed.",
62
+ "source": "xing-shuyin/pi-web-ui/plugins/wechat-ilink",
63
+ "homepage": "https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/wechat-ilink"
64
+ },
56
65
  {
57
66
  "id": "image-toolkit",
58
67
  "name": "image-toolkit",
@@ -1,4 +1,4 @@
1
- import{a as l,j as n}from"./markdown-D3PKeHAZ.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-B1_0cm7B.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-w24rH0km.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(h,u)=>(c=Number(u),`\r
1
+ import{a as l,j as n}from"./markdown-D3PKeHAZ.js";import{u as P,b as O,T as M,a as p,c as W,F as Y,d as z,e as Z,f as H,g as ee,h as ne,i as te,j as se,r as ae}from"./index-DM-A59lk.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-w24rH0km.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(h,u)=>(c=Number(u),`\r
2
2
  `)).replace(/\r?\n?\x1b\[90m\[(?:进程已退出,退出码 |Process exited with code )-?\d+\]\x1b\[0m\r?\n?/g,`\r
3
3
  `),exitCode:c}}function ce({conversationId:t,terminalId:c,command:s,cwd:h,title:u,active:w,running:f,exitCode:k,register:g}){const C=l.useRef(null),j=l.useRef(null),{locale:o}=P(),y=l.useRef(o);y.current=o;const E=s?JSON.stringify(s):"";l.useEffect(()=>{const m=C.current;if(!m)return;const r=new re({theme:O(),fontFamily:'"SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',fontSize:13,cursorBlink:!0,scrollback:8e3}),v=new ie;r.loadAddon(v),r.open(m),j.current={term:r,fit:v},w&&r.focus();const b=()=>{r.options.theme=O()};window.addEventListener(M,b),r.attachCustomKeyEventHandler(d=>{if(d.type!=="keydown")return!0;const S=d.key?.toLowerCase();if((d.ctrlKey||d.metaKey)&&S==="v")return!1;if(d.ctrlKey&&!d.shiftKey&&!d.altKey&&S==="c"&&r.hasSelection()){const D=r.textarea;return D&&(D.value=r.getSelection(),D.select()),!1}return!0});const _=g(t,c,{write:d=>r.write(le(d).clean),dispose:()=>r.dispose()}),$=()=>{try{v.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.cols,rows:r.rows})}catch{}},B=requestAnimationFrame(()=>{try{v.fit()}catch{}s?p({type:"run_command",terminalId:c,conversationId:t,command:s,cols:r.cols,rows:r.rows}):p({type:"terminal_create",terminalId:c,title:u,locale:y.current,conversationId:t,cwd:h,cols:r.cols,rows:r.rows})}),R=r.onData(d=>{p({type:"terminal_input",terminalId:c,conversationId:t,data:d})});let T=null;return typeof ResizeObserver<"u"&&(T=new ResizeObserver(()=>{m.offsetWidth>0&&m.offsetHeight>0&&$()}),T.observe(m)),()=>{cancelAnimationFrame(B),R.dispose(),window.removeEventListener(M,b),T?.disconnect(),_(),r.dispose(),j.current=null}},[t,c,E,g]),l.useEffect(()=>{if(!w)return;const m=requestAnimationFrame(()=>{const r=j.current;if(r){try{r.fit.fit(),p({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.term.cols,rows:r.term.rows})}catch{}r.term.focus()}});return()=>cancelAnimationFrame(m)},[w]);const{t:F}=P(),x=l.useRef(void 0);return l.useEffect(()=>{if(f===x.current||(x.current=f,f!==!1))return;const m=j.current;m&&m.term.write(`\r
4
4
  \x1B[90m${F("exitBanner",{code:k??""})}\x1B[0m\r