pi-web-ui 0.83.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 +52 -3
- package/dist/server/agent-service.js +73 -11
- package/dist/server/dsh/dsh-agent-service.js +10 -3
- package/dist/server/index.js +2 -0
- package/dist/server/plugin-facilities.js +13 -3
- package/dist/server/plugins.js +16 -0
- package/dist/server/terminals.js +44 -4
- package/dist/server/tool-manager.js +3 -3
- package/package.json +1 -1
- package/plugins/catalog.json +9 -0
- package/web/dist/assets/{TerminalPanel-BF99sld0.js → TerminalPanel-CNz4vxHW.js} +1 -1
- package/web/dist/assets/index-BjODtvwn.css +10 -0
- package/web/dist/assets/index-DM-A59lk.js +348 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-B1XPfcM0.css +0 -10
- package/web/dist/assets/index-D__1RagK.js +0 -348
package/CHANGELOG.md
CHANGED
|
@@ -8,9 +8,57 @@
|
|
|
8
8
|
每个版本的内容按"实际合入该版本发布的提交"归档(以 `package.json` 的 version 变更提交为准),
|
|
9
9
|
而不是按提交日期聚类——连续快速发布的 patch 版本以此为准最准确。
|
|
10
10
|
|
|
11
|
-
## [
|
|
11
|
+
## [0.85.0] — 2026-09-14
|
|
12
12
|
|
|
13
|
-
|
|
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 -->
|
|
38
|
+
|
|
39
|
+
## [0.84.0] — 2026-09-13
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **Git 视图的左栏可以拖宽了**(issue #139)—— 改动文件 / 提交历史那一栏原来固定 300px,路径或分支名一长就被截断且无处可展。现在拖它右边的分隔条即可调宽(**双击复位**),宽度**跨会话记住**(`localStorage`);拖动时按容器宽度收敛,保证 diff 区不会被挤没(额外还有一条 `max-width` 兜底)。
|
|
44
|
+
- 与左右主面板同一套手感(拖动分隔条、双击复位、松开才写存档);纯函数 `web/src/scm-sidebar.ts` + 单测 `tests/unit/scm-sidebar.test.ts`,真浏览器回归 `tests/scm-test.mjs`(拖 +120px → 刷新后保持 → 双击回 300px)。
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **正在聊的那条对话不再从左栏「运行的对话」里消失**(issue #140)—— 原来新对话只有「被换到后台且仍在流式输出」时才入列,于是你在当前对话里发消息时,左栏里根本没有它;只开一条对话时连「运行的对话」这个标题都不渲染,观感像是对话丢了。现在**当前对话一旦有内容**(有消息、或已被首条提示词命名)就立刻出现在列表里(还在流式输出时也会亮起绿点),行上标「当前」、点击是安全的空动作。
|
|
49
|
+
- **空白新对话仍然不入列**(连点「新建对话」不会堆出一排空条目),历史对话列表也不受影响。
|
|
50
|
+
- 这是**纯展示口径**:`listed` 的语义,以及「被挤出后台时是保留还是释放 runtime」「每项目 8 个上限」这些规则完全没变(换走时该释放的仍然释放);列表里当前对话那一行的 ✕ 按同一口径判定,真能移出(不再静默无反应)。
|
|
51
|
+
- 回归:`tests/unit/…` 无(口径在服务端),零 token 协议测试 `tests/running-list-test.mjs`(空白不入列 / 流式中入列 / 跑完留住 / 当前行 ✕ 可移出 / 后台运行语义不变),真浏览器 `tests/panel-layout-test.mjs`(区标题、「当前」副标签、流式绿点、只有一行)。DSH 引擎同口径一并改了(共用同一套左栏与 wire 协议)。
|
|
52
|
+
|
|
53
|
+
- **切项目时左栏不再闪一下项目名**(#140 那个改动的回归)—— 切项目会自动切到该项目的对话;如果切过去时那个项目只有一条对话(就是这条当前对话),列表里只有一组,它顶上原来会闪出一行项目名再消失(实测约 8ms 一帧)。原因是两个信号不同时到达:`conversations` 推送先到(activeId 已是新项目的对话),带新 `cwd` 的快照后到,只按 `cwd` 分组的那一帧就把当前项目当成了「别的项目」。
|
|
54
|
+
- 现在**只要当前对话在列表里,就认它所在的分组为当前项目**(`currentCwd` 只在它不在列表里时——空白新对话——作为回落),并直接置顶,顺带免掉随后的位置跳动;其他项目的后台运行照旧显示项目名。
|
|
55
|
+
- 分组逻辑抽成纯函数 `web/src/conv-groups.ts` + 单测 `tests/unit/conv-groups.test.ts`,另有真浏览器逐帧回归 `tests/conv-group-flash-test.mjs`(MutationObserver 记录每一帧 DOM,两个方向都断言不再渲染项目名;改回只看 `cwd` 即变红)。
|
|
56
|
+
|
|
57
|
+
<!-- auto-i18n:start -->
|
|
58
|
+
### i18n
|
|
59
|
+
|
|
60
|
+
- 服务端新增 key(1):`terminals.cwd.outside.workspace`
|
|
61
|
+
<!-- auto-i18n:end -->
|
|
14
62
|
|
|
15
63
|
## [0.83.0] — 2026-09-13
|
|
16
64
|
|
|
@@ -686,7 +734,8 @@
|
|
|
686
734
|
- 0.35.1(2026-08-27):编辑重问保留附件(#18)+ 全窗口拖放(#19)。
|
|
687
735
|
- 0.29.0(2026-08-23):全局搜索弹窗(Ctrl+K)+ 消息列表惰性窗口化。
|
|
688
736
|
|
|
689
|
-
[Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.
|
|
737
|
+
[Unreleased]: https://github.com/xing-shuyin/pi-web-ui/compare/v0.84.0...main
|
|
738
|
+
[0.84.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.84.0
|
|
690
739
|
[0.83.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.83.0
|
|
691
740
|
[0.80.1]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.80.1
|
|
692
741
|
[0.80.0]: https://github.com/xing-shuyin/pi-web-ui/releases/tag/v0.80.0
|
|
@@ -20,7 +20,7 @@ import { createRequire } from "node:module";
|
|
|
20
20
|
import { existsSync, readFileSync, rmSync, statSync, mkdirSync, watch } from "node:fs";
|
|
21
21
|
import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
22
22
|
import { fileURLToPath } from "node:url";
|
|
23
|
-
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices,
|
|
23
|
+
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashToolDefinition, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
24
24
|
import { Type } from "typebox";
|
|
25
25
|
import { BgServerTracker } from "./bg-servers.js";
|
|
26
26
|
import { checkAll as checkAllUpdates, collectTargets, compareVersions as compareSemver, } from "./update-check.js";
|
|
@@ -133,7 +133,7 @@ export function makeKillableBashTool(cwd, kills,
|
|
|
133
133
|
/** per-call 返回文本的服务端语言(默认英文);工具 definition 走 bilingual 内联双语。 */
|
|
134
134
|
lang = () => "en") {
|
|
135
135
|
const base = createLocalBashOperations();
|
|
136
|
-
const tool =
|
|
136
|
+
const tool = createBashToolDefinition(cwd, {
|
|
137
137
|
operations: {
|
|
138
138
|
exec: async (command, c, opts) => {
|
|
139
139
|
const ac = new AbortController();
|
|
@@ -151,7 +151,7 @@ lang = () => "en") {
|
|
|
151
151
|
},
|
|
152
152
|
},
|
|
153
153
|
});
|
|
154
|
-
//
|
|
154
|
+
// Keep the SDK definition so execute receives the current session context.
|
|
155
155
|
return {
|
|
156
156
|
name: tool.name,
|
|
157
157
|
label: tool.label,
|
|
@@ -175,8 +175,8 @@ lang = () => "en") {
|
|
|
175
175
|
}),
|
|
176
176
|
prepareArguments: tool.prepareArguments,
|
|
177
177
|
executionMode: tool.executionMode,
|
|
178
|
-
execute: async (toolCallId, params, signal, onUpdate) => {
|
|
179
|
-
const result = (await tool.execute(toolCallId, params, signal, onUpdate));
|
|
178
|
+
execute: async (toolCallId, params, signal, onUpdate, ctx) => {
|
|
179
|
+
const result = (await tool.execute(toolCallId, params, signal, onUpdate, ctx));
|
|
180
180
|
// head/tail 后处理(native 无终端,直接截返回行即可)。
|
|
181
181
|
const p = params;
|
|
182
182
|
if ((p?.head || p?.tail) && result?.content?.[0]?.text != null) {
|
|
@@ -1740,8 +1740,12 @@ export class ClientSession {
|
|
|
1740
1740
|
session: runtime.session,
|
|
1741
1741
|
cwd: runtime.cwd,
|
|
1742
1742
|
createdAt: Date.now(),
|
|
1743
|
-
// A brand-new conversation is not yet
|
|
1744
|
-
// only when it is displaced to the background while still
|
|
1743
|
+
// A brand-new conversation is not yet LISTED — it enters the running
|
|
1744
|
+
// list only when it is displaced to the background while still
|
|
1745
|
+
// streaming (its runtime is what `listed` protects). A blank chat is
|
|
1746
|
+
// also kept out of the left panel's running list; it shows up there as
|
|
1747
|
+
// soon as it has content while it is the active chat — see
|
|
1748
|
+
// shownInRunningList (#140).
|
|
1745
1749
|
listed: false,
|
|
1746
1750
|
promptedSinceActive: false,
|
|
1747
1751
|
lastActiveAt: Date.now(),
|
|
@@ -2327,6 +2331,10 @@ export class ClientSession {
|
|
|
2327
2331
|
const task = conv.pendingTask;
|
|
2328
2332
|
conv.pendingTask = undefined;
|
|
2329
2333
|
this.emitRun(conv, task ? { type: "run_start", task } : { type: "run_start" });
|
|
2334
|
+
// #140:本轮真的开跑了(session.isStreaming 此刻已为 true)—— 左栏
|
|
2335
|
+
// 那行的「流式中」标识要立刻亮起来:首条提示词的入列 emit 早于本轮
|
|
2336
|
+
// 启动,那时它还是 false;后台对话被唤醒重跑也靠这里刷新。
|
|
2337
|
+
this.emitConversations();
|
|
2330
2338
|
break;
|
|
2331
2339
|
}
|
|
2332
2340
|
case "turn_start": {
|
|
@@ -3523,6 +3531,8 @@ export class ClientSession {
|
|
|
3523
3531
|
if (conv.title === DEFAULT_CONV_TITLE && text.trim() && !conv.session.sessionName?.trim()) {
|
|
3524
3532
|
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
3525
3533
|
conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
3534
|
+
// 同时也是 #140 的入列时刻:这条对话从此有内容了,左栏「运行的对话」
|
|
3535
|
+
// 立刻要有它(此刻还在流式输出,不能等 agent_end 的防抖刷新)。
|
|
3526
3536
|
this.emitConversations();
|
|
3527
3537
|
}
|
|
3528
3538
|
// Attach files as independent nextTurn context messages (asides) so the
|
|
@@ -4128,10 +4138,40 @@ export class ClientSession {
|
|
|
4128
4138
|
this.notifyConversationChanged();
|
|
4129
4139
|
this.flushSnapshot();
|
|
4130
4140
|
}
|
|
4141
|
+
/** 左栏「运行的对话」的展示口径(issue #140)。
|
|
4142
|
+
*
|
|
4143
|
+
* 老口径只有 listed:新对话要等「被换到后台且仍在跑」才入列 —— 用户正在聊的
|
|
4144
|
+
* 那条反而不在列表里(只有它一条时,左栏连「运行的对话」标题都不渲染,观感
|
|
4145
|
+
* 像是对话丢了)。新口径把「当前对话 + 已经有内容」也算进来:有消息的对话
|
|
4146
|
+
* (或已被首条提示词命名 —— 命名与首条消息是同一时刻,见 prompt() 里的
|
|
4147
|
+
* rename 块)立刻出现在列表里;空白新对话仍然不入列(防连点「新建对话」
|
|
4148
|
+
* 堆出一排空条目)。
|
|
4149
|
+
*
|
|
4150
|
+
* 只影响「列表里推什么」:listed 本身的语义、以及 displaceActive /
|
|
4151
|
+
* shouldRetainActive / MAX_OPEN_CONVERSATIONS 那套「什么算运行中」的规则
|
|
4152
|
+
* 完全不变(换走时该释放的仍然释放,不会被这次展示口径改动永久钉在列表里)。
|
|
4153
|
+
* Display-only: retention and disposal rules are deliberately untouched. */
|
|
4154
|
+
shownInRunningList(conv) {
|
|
4155
|
+
if (conv.listed)
|
|
4156
|
+
return true;
|
|
4157
|
+
if (conv.id !== this.activeId)
|
|
4158
|
+
return false;
|
|
4159
|
+
// 首条提示词给对话命名 = 用户真的开始聊了(此刻消息可能还没落进会话统计)。
|
|
4160
|
+
if (conv.title !== DEFAULT_CONV_TITLE)
|
|
4161
|
+
return true;
|
|
4162
|
+
try {
|
|
4163
|
+
return conv.session.getSessionStats().totalMessages > 0;
|
|
4164
|
+
}
|
|
4165
|
+
catch {
|
|
4166
|
+
// 会话替换中 —— 先不列,下一次 emit 会补上
|
|
4167
|
+
return false;
|
|
4168
|
+
}
|
|
4169
|
+
}
|
|
4131
4170
|
/** Push every running conversation across ALL projects to the client. The
|
|
4132
4171
|
* running-conversation list is global so a background run from another
|
|
4133
4172
|
* workspace stays visible; clicking one switches both the conversation and
|
|
4134
|
-
* its project (see switchConversation). The client groups the list by cwd.
|
|
4173
|
+
* its project (see switchConversation). The client groups the list by cwd.
|
|
4174
|
+
* 推什么见 shownInRunningList(listed + 当前对话有内容时)。 */
|
|
4135
4175
|
emitConversations() {
|
|
4136
4176
|
const conversations = [];
|
|
4137
4177
|
// Active parents are normally absent from Running. Keep them visible while
|
|
@@ -4141,7 +4181,7 @@ export class ClientSession {
|
|
|
4141
4181
|
.map((conv) => conv.parentId)
|
|
4142
4182
|
.filter(Boolean));
|
|
4143
4183
|
for (const conv of this.convs.values()) {
|
|
4144
|
-
if (!conv
|
|
4184
|
+
if (!this.shownInRunningList(conv) && !visibleParents.has(conv.id))
|
|
4145
4185
|
continue;
|
|
4146
4186
|
let messageCount = 0;
|
|
4147
4187
|
let isStreaming = false;
|
|
@@ -4462,8 +4502,9 @@ export class ClientSession {
|
|
|
4462
4502
|
});
|
|
4463
4503
|
return;
|
|
4464
4504
|
}
|
|
4465
|
-
if (!conv
|
|
4466
|
-
// Not in list anyway — nothing to do.
|
|
4505
|
+
if (!this.shownInRunningList(conv)) {
|
|
4506
|
+
// Not in list anyway — nothing to do. 展示口径见 shownInRunningList
|
|
4507
|
+
// (当前对话有内容时也在列表里,对它的 ✕ 必须真的移出,不能静默 no-op)。
|
|
4467
4508
|
this.emitConversations();
|
|
4468
4509
|
return;
|
|
4469
4510
|
}
|
|
@@ -5532,6 +5573,27 @@ export class AgentService {
|
|
|
5532
5573
|
}
|
|
5533
5574
|
return best;
|
|
5534
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
|
+
}
|
|
5535
5597
|
/** index.ts calls this when a browser socket opens/closes. */
|
|
5536
5598
|
noteSocketOpen() {
|
|
5537
5599
|
this.socketCount += 1;
|
|
@@ -1121,11 +1121,18 @@ export class DshClientSession {
|
|
|
1121
1121
|
pendingMessages() {
|
|
1122
1122
|
return 0;
|
|
1123
1123
|
}
|
|
1124
|
+
/** 左栏展示口径(issue #140,与 pi 引擎同义):listed 之外,当前对话只要有
|
|
1125
|
+
* 内容(DSH 的对话消息全在内存里,直接数 messages)也在列表里;空白新对话
|
|
1126
|
+
* 不入列。只影响展示,不动 listed 的语义。 */
|
|
1127
|
+
shownInRunningList(conv) {
|
|
1128
|
+
return conv.id === this.activeId && conv.messages.length > 0;
|
|
1129
|
+
}
|
|
1124
1130
|
emitConversations() {
|
|
1125
1131
|
const list = [];
|
|
1126
1132
|
for (const conv of this.convs.values()) {
|
|
1127
|
-
//
|
|
1128
|
-
|
|
1133
|
+
// 被置换到后台的会话 + 当前对话(一旦有内容就是用户正在聊的那条,
|
|
1134
|
+
// 不该在列表里缺席——issue #140;空白新对话仍然不入列)。
|
|
1135
|
+
if (!conv.listed && !this.shownInRunningList(conv))
|
|
1129
1136
|
continue;
|
|
1130
1137
|
list.push({
|
|
1131
1138
|
id: conv.id,
|
|
@@ -1797,7 +1804,7 @@ export class DshClientSession {
|
|
|
1797
1804
|
});
|
|
1798
1805
|
return;
|
|
1799
1806
|
}
|
|
1800
|
-
if (!conv.listed) {
|
|
1807
|
+
if (!conv.listed && !this.shownInRunningList(conv)) {
|
|
1801
1808
|
this.emitConversations();
|
|
1802
1809
|
return;
|
|
1803
1810
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
|
|
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 */
|
package/dist/server/plugins.js
CHANGED
|
@@ -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);
|
package/dist/server/terminals.js
CHANGED
|
@@ -678,7 +678,7 @@ export class TerminalManager {
|
|
|
678
678
|
this.history.delete(id);
|
|
679
679
|
const safeCwd = this.safeCwd(cwd || fallbackCwd);
|
|
680
680
|
if (!safeCwd) {
|
|
681
|
-
this.fail(id, "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace");
|
|
681
|
+
this.fail(id, pick(this.lang?.() ?? "en", "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace", "terminals.cwd.outside.workspace"), "Terminal cwd must be inside the current workspace");
|
|
682
682
|
return null;
|
|
683
683
|
}
|
|
684
684
|
if (this.spawnShell(id, safeCwd, cols, rows, title || `终端 ${++this.seq}`, undefined, opts?.forceBash, opts?.agentBash, opts?.locale)) {
|
|
@@ -720,7 +720,7 @@ export class TerminalManager {
|
|
|
720
720
|
const command = expandPwd(def.command.trim(), pwd);
|
|
721
721
|
const title = def.name || command || `终端 ${++this.seq}`;
|
|
722
722
|
if (!dir) {
|
|
723
|
-
this.fail(id, "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace");
|
|
723
|
+
this.fail(id, pick(this.lang?.() ?? "en", "终端工作目录必须位于当前工作区内", "Terminal cwd must be inside the current workspace", "terminals.cwd.outside.workspace"), "Terminal cwd must be inside the current workspace");
|
|
724
724
|
return;
|
|
725
725
|
}
|
|
726
726
|
if (existing) {
|
|
@@ -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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
72
|
-
// customTool 发出),列在那里只会让用户关一个不存在的工具。
|
|
73
|
-
{ name: BROWSER_PAGE_TOOL_NAME, group: "other", defaultOn:
|
|
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.
|
|
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": {
|
package/plugins/catalog.json
CHANGED
|
@@ -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-
|
|
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
|