pi-web-ui 0.72.0 → 0.74.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 +48 -1
- package/README.md +568 -554
- package/README.zh-CN.md +25 -8
- package/dist/server/agent-service.js +405 -49
- package/dist/server/dsh/dsh-agent-service.js +40 -3
- package/dist/server/index.js +7 -1
- package/dist/server/model-admin.js +98 -14
- package/dist/server/patch-remote-catalog.js +59 -0
- package/dist/server/subagents.js +51 -4
- package/package.json +1 -1
- package/themes/cyberpunk.css +3 -0
- package/themes/dazzle.css +3 -0
- package/themes/md-preview.css +3 -0
- package/themes/transparent.css +32 -2
- package/themes/white.css +3 -0
- package/web/dist/assets/{TerminalPanel-SG88Uj0n.js → TerminalPanel-DgR_8K2X.js} +1 -1
- package/web/dist/assets/index-BplWuHvM.js +335 -0
- package/web/dist/assets/index-CbshsMO6.css +10 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-CBZtMKV2.js +0 -335
- package/web/dist/assets/index-DAu6etAV.css +0 -10
package/README.zh-CN.md
CHANGED
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
- **编辑重问** —— 把任意历史问题 fork 成新分支重新提问,原对话不受影响。
|
|
34
34
|
- 超过 30 条的消息自动折叠为摘要行(惰性渲染,点击展开)。
|
|
35
35
|
- 问题导航 —— 右侧浮动导航条 + 每个问题顶部的序号标签,一键跳转。
|
|
36
|
+
- **提示词模板** —— 空对话状态展示一键模板库(仓库初始化、代码审查、调研、合并冲突……);点卡片即填入输入框,也可把当前草稿存成自己的模板。
|
|
37
|
+
- **模型报错自动重试** —— 按对话可配置重试次数;次数用完则失败轮次标红,红色报错旁有一键「重试」按钮。
|
|
36
38
|
|
|
37
39
|
**子代理与模板**
|
|
38
40
|
|
|
@@ -59,7 +61,7 @@
|
|
|
59
61
|
- 主题切换 —— 顶栏选择主题;主题是纯 `:root` 调色板覆盖(布局唯一在 styles.css)。如何添加自定义主题或向仓库贡献主题,见 [主题](#主题)。
|
|
60
62
|
- 思考强度(thinking level)按模型切换(只显示该模型实际支持的档位)。
|
|
61
63
|
- 首次配置引导(PiSetupModal)。
|
|
62
|
-
- 设置面板 ——
|
|
64
|
+
- 设置面板 —— 系统提示词(追加或整体替换)、输入历史与快捷短语、共享标记工具(todo / 通知)、技能/插件一键开关(即时生效)、设置预设保存/应用/删除、视觉桥模型与开关、子代理模板。
|
|
63
65
|
|
|
64
66
|
**目标(Goal)模式**
|
|
65
67
|
|
|
@@ -90,7 +92,7 @@
|
|
|
90
92
|
- WebSocket Origin/Host 同权威校验 —— 跨源页面直接拒绝(403);反代场景用 `PI_WEB_ALLOW_ORIGINS` 白名单。
|
|
91
93
|
- 本地控制 socket 提供 `server status|quiesce|unquiesce`(排空模式:拒绝新工作、存量跑完)。
|
|
92
94
|
- 凭据不下发浏览器 —— provider headers(可能含 Authorization/API key)永不发送到前端。
|
|
93
|
-
-
|
|
95
|
+
- 声音提醒、9 种界面语言(中英内置 + 8 个可下载语言包:德/西/法/意/日/韩/葡/俄)、对话壁纸、最近项目列表(点击即切换工作目录)。
|
|
94
96
|
|
|
95
97
|
**部署与更新**
|
|
96
98
|
|
|
@@ -99,13 +101,25 @@
|
|
|
99
101
|
|
|
100
102
|
## 界面截图
|
|
101
103
|
|
|
102
|
-

|
|
103
105
|
|
|
104
|
-
|
|
106
|
+
*对话 + 提示词模板*
|
|
105
107
|
|
|
106
|
-

|
|
107
109
|
|
|
108
|
-
|
|
110
|
+
*运行轨迹时间线(run-trace 插件)*
|
|
111
|
+
|
|
112
|
+

|
|
113
|
+
|
|
114
|
+
*设置面板*
|
|
115
|
+
|
|
116
|
+

|
|
117
|
+
|
|
118
|
+
*内置终端*
|
|
119
|
+
|
|
120
|
+

|
|
121
|
+
|
|
122
|
+
*Git 源代码管理面板*
|
|
109
123
|
|
|
110
124
|
## 安装
|
|
111
125
|
|
|
@@ -261,7 +275,10 @@ pi-web-ui server install --engine dsh --port 9000 --cwd /path/to/project
|
|
|
261
275
|
| 📬 [网页邮箱 webmail](https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/webmail) | IMAP 收件箱浏览/搜索/阅读/标记/删除 + SMTP 发信、新邮件通知,可选「允许 AI 管理邮箱」(六个 `mail_*` AI 工具)。首次激活自动补装 npm 依赖。 |
|
|
262
276
|
| 🗄️ [数据库 db-client](https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/db-client) | 数据库工作台:MySQL / PostgreSQL / SQLite / SQL Server / MongoDB / Redis 连接管理 + 库表树 —— 表结构、分页排序、SQL 编辑器、行编辑。驱动首次使用自动安装。 |
|
|
263
277
|
| 📝 [编辑器 + SSH vscode-editor](https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/vscode-editor) | 类 VSCode 工作台:多根文件树(本地 + SSH 主机)、CodeMirror 多标签编辑器、Remote-SSH 远程文件浏览/编辑、可拖拽多终端面板(xterm.js)、SFTP 同步与下载到电脑。自动安装 `ssh2`。 |
|
|
264
|
-
|
|
|
278
|
+
| 📊 [图表 mermaid](https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/mermaid) | 把对话里的 ` ```mermaid ` 围栏渲染成 SVG 图表(fenced-code 渲染插件,本地引擎离线优先)。 |
|
|
279
|
+
| 🧭 [运行轨迹 run-trace](https://github.com/xing-shuyin/pi-web-ui/tree/main/plugins/run-trace) | 运行轨迹:任务 → 思考 → 工具 → 文件改动 → 结果的时间线聚合视图,支持回放与节点详情。 |
|
|
280
|
+
|
|
281
|
+
`plugins/demo-mailbox` 作为最小插件模板保留在仓库里(服务端入口 + 客户端视图 + 双向消息协议),兼作测试夹具——想自己写插件从这里入手。
|
|
265
282
|
|
|
266
283
|
安装示例(网页邮箱):
|
|
267
284
|
|
|
@@ -329,7 +346,7 @@ pi-web-ui uninstall <id> # 卸载插件
|
|
|
329
346
|
|
|
330
347
|
每个主题是**一份纯 `:root` 调色板覆盖** —— 只写 CSS 变量的声明文件(变量全集见 `web/src/styles.css` 的 `:root`:`--bg/--accent/--term-*` 基础色,加 `--tooltip-bg/--code-bg/--notice-*` 等派生色)。布局只存在于打包的 `web/src/styles.css` 里,选主题只是覆盖变量,因此任何主题都能在所有版本上工作,改布局也不需要碰主题文件。内置主题由 `node make-light-theme.mjs` 生成。
|
|
331
348
|
|
|
332
|
-
内置主题随 npm 包分发(`themes
|
|
349
|
+
内置主题随 npm 包分发(`themes/`):`white`(浅色)、`cyberpunk` / `dazzle`(深色)、`translucent` / `transparent`(壁纸友好半透明/全透明,可配对话壁纸)。主题选择器在顶栏(🌞 图标),当前选择按浏览器存在 `localStorage`。
|
|
333
350
|
|
|
334
351
|
### 使用主题
|
|
335
352
|
|
|
@@ -10,6 +10,10 @@
|
|
|
10
10
|
* snapshots. The frontend is snapshot-driven (server is the source of truth),
|
|
11
11
|
* so reconnects just re-request a snapshot.
|
|
12
12
|
*/
|
|
13
|
+
// MUST be the first import: rewrites the SDK's installed remote-catalog
|
|
14
|
+
// provider so built-in model lists follow the official pi.dev catalog
|
|
15
|
+
// wholesale (no union merge / no stale built-in leftovers).
|
|
16
|
+
import "./patch-remote-catalog.js";
|
|
13
17
|
import { spawn } from "node:child_process";
|
|
14
18
|
import { randomUUID } from "node:crypto";
|
|
15
19
|
import { createRequire } from "node:module";
|
|
@@ -34,7 +38,7 @@ import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates
|
|
|
34
38
|
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
|
|
35
39
|
import { WebUIContext, mockThemeProxy } from "./webui-context.js";
|
|
36
40
|
import { makeEditSoftTool, SOFT_EDIT_TOOL_NAME } from "./edit-soft-tool.js";
|
|
37
|
-
import { makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
|
|
41
|
+
import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
|
|
38
42
|
import { buildAttachmentMessages } from "./attachments.js";
|
|
39
43
|
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
40
44
|
import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
|
|
@@ -400,8 +404,9 @@ const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
|
|
|
400
404
|
const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
|
|
401
405
|
return Number.isFinite(v) && v > 0 ? v : 20 * 60_000;
|
|
402
406
|
})();
|
|
403
|
-
/** Cap on simultaneously open conversations of ONE project (each keeps a full
|
|
404
|
-
* runtime alive; conversations of other projects keep their own lists).
|
|
407
|
+
/** Cap on simultaneously open NON-subagent conversations of ONE project (each keeps a full
|
|
408
|
+
* runtime alive; conversations of other projects keep their own lists).
|
|
409
|
+
* 子代理不计入:子代理是 inMemory 后台任务,不参与此上限,既不占位也不被此上限拦截。 */
|
|
405
410
|
const MAX_OPEN_CONVERSATIONS = 8;
|
|
406
411
|
const DEFAULT_CONV_TITLE = "新对话";
|
|
407
412
|
/** First user text in a session, truncated for the conversation list. */
|
|
@@ -1384,9 +1389,11 @@ export class ClientSession {
|
|
|
1384
1389
|
// 父对话 = 真正调用 spawn 的那个会话(本 runtime 所属会话),而不是
|
|
1385
1390
|
// 派发瞬间的 active——后台对话继续产出时用户可能已切到别的项目,用
|
|
1386
1391
|
// activeId 会把孩子记到无关会话名下、沉到别的组/底部(issue #95)。
|
|
1387
|
-
// ownerId 即本 runtime
|
|
1392
|
+
// ownerId 即本 runtime 所属会话(创建时就已知,见各调用点),一身二任:
|
|
1393
|
+
// spawn 的 parentId(子代理记到真正的派发会话名下)+ wait_all 的
|
|
1394
|
+
// selfConvId(调用者自身永不计入等待,防 self-wait deadlock)。
|
|
1388
1395
|
...(ownerId
|
|
1389
|
-
? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId))
|
|
1396
|
+
? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId), undefined, ownerId)
|
|
1390
1397
|
: makeSubagentTools(this.subagentHost)),
|
|
1391
1398
|
// 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
|
|
1392
1399
|
makeMarkersListTool(() => this.activeId, this.markerSvc),
|
|
@@ -2689,6 +2696,9 @@ export class ClientSession {
|
|
|
2689
2696
|
listModelsConfig() {
|
|
2690
2697
|
return this.modelAdmin.listModelsConfig();
|
|
2691
2698
|
}
|
|
2699
|
+
reloadModelsConfig() {
|
|
2700
|
+
return this.modelAdmin.reloadModelsConfig();
|
|
2701
|
+
}
|
|
2692
2702
|
fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
|
|
2693
2703
|
return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
|
|
2694
2704
|
}
|
|
@@ -3478,8 +3488,9 @@ export class ClientSession {
|
|
|
3478
3488
|
}
|
|
3479
3489
|
}
|
|
3480
3490
|
// Cap is per project — conversations of other projects keep their own
|
|
3481
|
-
// lists and don't consume this project's slots.
|
|
3482
|
-
|
|
3491
|
+
// lists and don't consume this project's slots. Subagents don't count
|
|
3492
|
+
// (inMemory 后台任务,不占位)。
|
|
3493
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === this.cwd && !c.isSubagent).length;
|
|
3483
3494
|
if (openInProject >= MAX_OPEN_CONVERSATIONS) {
|
|
3484
3495
|
this.emit({
|
|
3485
3496
|
type: "notice",
|
|
@@ -3549,6 +3560,9 @@ export class ClientSession {
|
|
|
3549
3560
|
* The active conversation is being left (new_chat / switch_conversation /
|
|
3550
3561
|
* set_cwd). Runs the running-list lifecycle:
|
|
3551
3562
|
*
|
|
3563
|
+
* - 子代理豁免:活动的是子代理时永远保留(listed=true,返回 null)——点开
|
|
3564
|
+
* 查看后切走也不释放 runtime,后台任务继续跑、随时可点开看;清理走
|
|
3565
|
+
* dismiss_conversation / dismiss_finished_subagents(用户显式动作)。
|
|
3552
3566
|
* - still streaming → it becomes a background run: ensure it is listed;
|
|
3553
3567
|
* - idle + listed + continued → keep it (the user did continue it);
|
|
3554
3568
|
* - any retained terminal state → keep it listed until the terminals are closed;
|
|
@@ -3558,6 +3572,11 @@ export class ClientSession {
|
|
|
3558
3572
|
*/
|
|
3559
3573
|
displaceActive() {
|
|
3560
3574
|
const conv = this.conv;
|
|
3575
|
+
// 子代理不受切换关闭影响(见上)。
|
|
3576
|
+
if (conv.isSubagent) {
|
|
3577
|
+
conv.listed = true;
|
|
3578
|
+
return null;
|
|
3579
|
+
}
|
|
3561
3580
|
// An isolated reviewer can keep working while the main session is idle;
|
|
3562
3581
|
// retain that conversation so its review is not disposed when the user
|
|
3563
3582
|
// switches away without sending another prompt.
|
|
@@ -3943,11 +3962,40 @@ export class ClientSession {
|
|
|
3943
3962
|
if (changed)
|
|
3944
3963
|
this.emitConversations();
|
|
3945
3964
|
}
|
|
3965
|
+
/** Dismiss 口径的「已结束子代理」:非 streaming 且无保留态(存活终端/
|
|
3966
|
+
* 审查/后台唤醒等),与 dismissFinishedSubagents 的候选口径一致。 */
|
|
3967
|
+
isDismissableFinishedSubagent(conv) {
|
|
3968
|
+
let streaming = true;
|
|
3969
|
+
try {
|
|
3970
|
+
streaming = conv.session.isStreaming;
|
|
3971
|
+
}
|
|
3972
|
+
catch {
|
|
3973
|
+
// 会话替换中——按运行中处理,绝不误删。
|
|
3974
|
+
}
|
|
3975
|
+
if (streaming)
|
|
3976
|
+
return false;
|
|
3977
|
+
return !shouldRetainActive({
|
|
3978
|
+
reviewing: conv.goal.reviewing,
|
|
3979
|
+
wizardRunning: conv.wizardRunning,
|
|
3980
|
+
streaming: false,
|
|
3981
|
+
openTerminals: conv.terminals.countLive(),
|
|
3982
|
+
listed: false,
|
|
3983
|
+
promptedSinceActive: false,
|
|
3984
|
+
hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
|
|
3985
|
+
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
3986
|
+
});
|
|
3987
|
+
}
|
|
3946
3988
|
/** Dismiss a running conversation from the left-panel list without deleting its
|
|
3947
3989
|
* transcript file. Only idle (non-streaming) conversations that are not
|
|
3948
3990
|
* retained by terminal/wake/review state can be dismissed. The session stays
|
|
3949
|
-
* in history and can be reopened.
|
|
3950
|
-
|
|
3991
|
+
* in history and can be reopened.
|
|
3992
|
+
*
|
|
3993
|
+
* withFinishedSubagents=true 时连带关闭该对话下已结束的子代理(传递后代,
|
|
3994
|
+
* 与 dismissFinishedSubagents 同口径;active 的子代理跳过)——只关不运行的:
|
|
3995
|
+
* 运行中的后代不受影响;关完后若还有后代剩下(运行中/保留中/active),父级
|
|
3996
|
+
* 暂留并提示。只有运行中的后代(无可关的)时拒绝。不传 + 存在已结束子代理
|
|
3997
|
+
* 后代时拒绝并提示(由前端确认框先问用户,避免静默 orphan)。 */
|
|
3998
|
+
async dismissConversation(id, withFinishedSubagents, force) {
|
|
3951
3999
|
const conv = this.convs.get(id);
|
|
3952
4000
|
if (!conv) {
|
|
3953
4001
|
this.emit({
|
|
@@ -3958,72 +4006,363 @@ export class ClientSession {
|
|
|
3958
4006
|
});
|
|
3959
4007
|
return;
|
|
3960
4008
|
}
|
|
3961
|
-
if (
|
|
4009
|
+
if (!conv.listed) {
|
|
4010
|
+
// Not in list anyway — nothing to do.
|
|
4011
|
+
this.emitConversations();
|
|
4012
|
+
return;
|
|
4013
|
+
}
|
|
4014
|
+
// Streaming / retained conversations refuse dismissal — mirrors displaceActive retention.
|
|
4015
|
+
// 运行中的子代理后代也阻止关闭(绝不连带 abort);已结束的子代理后代:
|
|
4016
|
+
// withFinishedSubagents 才连带,否则拒绝并提示(前端确认框先问用户)。
|
|
4017
|
+
const isStreaming = (c) => {
|
|
4018
|
+
try {
|
|
4019
|
+
return c.session.isStreaming;
|
|
4020
|
+
}
|
|
4021
|
+
catch {
|
|
4022
|
+
return true;
|
|
4023
|
+
}
|
|
4024
|
+
};
|
|
4025
|
+
const descendants = collectSubagentDescendantIds([...this.convs.values()].map((c) => ({ id: c.id, parentId: c.parentId, isSubagent: c.isSubagent })), id)
|
|
4026
|
+
.map((did) => this.convs.get(did))
|
|
4027
|
+
.filter((c) => !!c);
|
|
4028
|
+
if (force) {
|
|
4029
|
+
await this.forceDismissConversation(conv, descendants, isStreaming);
|
|
4030
|
+
return;
|
|
4031
|
+
}
|
|
4032
|
+
const runningKids = descendants.filter((c) => isStreaming(c));
|
|
4033
|
+
// 父对话自身的保留态(流式/终端/审查/后台唤醒)——子代理后代另算。
|
|
4034
|
+
const selfStreaming = (() => {
|
|
4035
|
+
try {
|
|
4036
|
+
return conv.session.isStreaming;
|
|
4037
|
+
}
|
|
4038
|
+
catch {
|
|
4039
|
+
return true;
|
|
4040
|
+
}
|
|
4041
|
+
})();
|
|
4042
|
+
if (selfStreaming) {
|
|
3962
4043
|
this.emit({
|
|
3963
4044
|
type: "notice",
|
|
3964
4045
|
level: "warning",
|
|
3965
|
-
text:
|
|
3966
|
-
textEn: "
|
|
4046
|
+
text: `对话「${conv.title}」仍在运行中,请先等待结束或点击停止后再移出`,
|
|
4047
|
+
textEn: `Conversation "${conv.title}" is still running — wait for it to finish or press Stop before removing`,
|
|
3967
4048
|
});
|
|
3968
4049
|
return;
|
|
3969
4050
|
}
|
|
3970
|
-
if (
|
|
3971
|
-
|
|
3972
|
-
|
|
4051
|
+
if (conv.terminals.countLive() > 0) {
|
|
4052
|
+
this.emit({
|
|
4053
|
+
type: "notice",
|
|
4054
|
+
level: "warning",
|
|
4055
|
+
text: `对话「${conv.title}」还有未关闭的终端,请先关闭终端后再移出`,
|
|
4056
|
+
textEn: `Conversation "${conv.title}" still has open terminals — close them before removing`,
|
|
4057
|
+
});
|
|
3973
4058
|
return;
|
|
3974
4059
|
}
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
4060
|
+
if (shouldRetainActive({
|
|
4061
|
+
reviewing: conv.goal.reviewing,
|
|
4062
|
+
wizardRunning: conv.wizardRunning,
|
|
4063
|
+
streaming: false,
|
|
4064
|
+
openTerminals: 0,
|
|
4065
|
+
listed: conv.listed,
|
|
4066
|
+
promptedSinceActive: conv.promptedSinceActive,
|
|
4067
|
+
hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
|
|
4068
|
+
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
4069
|
+
})) {
|
|
4070
|
+
this.emit({
|
|
4071
|
+
type: "notice",
|
|
4072
|
+
level: "warning",
|
|
4073
|
+
text: `对话「${conv.title}」暂时无法移出(存在待处理的后台任务/审查)`,
|
|
4074
|
+
textEn: `Conversation "${conv.title}" cannot be removed right now (pending background task/review)`,
|
|
3988
4075
|
});
|
|
3989
|
-
|
|
3990
|
-
|
|
4076
|
+
return;
|
|
4077
|
+
}
|
|
4078
|
+
const finishedKids = descendants.filter((c) => c.listed && c.id !== this.activeId && this.isDismissableFinishedSubagent(c));
|
|
4079
|
+
if (runningKids.length > 0 && finishedKids.length === 0) {
|
|
4080
|
+
// 只有运行中的后代:连 flag 也变不出可关的,拒绝(绝不连带 abort)。
|
|
4081
|
+
this.emit({
|
|
4082
|
+
type: "notice",
|
|
4083
|
+
level: "warning",
|
|
4084
|
+
text: `对话「${conv.title}」还有 ${runningKids.length} 个运行中的子代理,请先等待结束或停止后再移出`,
|
|
4085
|
+
textEn: `Conversation "${conv.title}" still has ${runningKids.length} running subagent(s) — wait for them to finish or stop them before removing`,
|
|
4086
|
+
});
|
|
4087
|
+
return;
|
|
4088
|
+
}
|
|
4089
|
+
if (finishedKids.length > 0 && !withFinishedSubagents) {
|
|
4090
|
+
this.emit({
|
|
4091
|
+
type: "notice",
|
|
4092
|
+
level: "warning",
|
|
4093
|
+
text: `对话「${conv.title}」下还有 ${finishedKids.length} 个已结束的子代理:连带关闭请确认,仅关闭父级请先在右键菜单清理子代理`,
|
|
4094
|
+
textEn: `Conversation "${conv.title}" still has ${finishedKids.length} finished subagent(s): confirm to dismiss them together, or clear the subagents first (right-click menu) to dismiss only the parent`,
|
|
4095
|
+
});
|
|
4096
|
+
return;
|
|
4097
|
+
}
|
|
4098
|
+
// 只关不运行的:运行中的后代绝不连带 abort;关完后若还有后代剩下
|
|
4099
|
+
// (运行中/保留中/active),父级暂留并提示。
|
|
4100
|
+
let removedKids = 0;
|
|
4101
|
+
for (const kid of finishedKids) {
|
|
4102
|
+
if (kid.id === this.activeId)
|
|
4103
|
+
continue;
|
|
4104
|
+
if (this.convs.get(kid.id) !== kid)
|
|
4105
|
+
continue;
|
|
4106
|
+
this.removeConversation(kid.id);
|
|
4107
|
+
removedKids++;
|
|
4108
|
+
}
|
|
4109
|
+
if (withFinishedSubagents && removedKids > 0) {
|
|
4110
|
+
const remaining = descendants.filter((c) => this.convs.get(c.id) === c);
|
|
4111
|
+
if (remaining.length > 0) {
|
|
4112
|
+
const stillRunning = remaining.filter((c) => isStreaming(c)).length;
|
|
3991
4113
|
this.emit({
|
|
3992
4114
|
type: "notice",
|
|
3993
|
-
level: "
|
|
3994
|
-
text:
|
|
3995
|
-
textEn: `
|
|
4115
|
+
level: "info",
|
|
4116
|
+
text: `已关闭 ${removedKids} 个已结束的子代理,还有 ${remaining.length} 个子代理未关闭${stillRunning > 0 ? `(${stillRunning} 个运行中)` : ""},父对话暂留`,
|
|
4117
|
+
textEn: `Dismissed ${removedKids} finished subagent(s); ${remaining.length} subagent(s) remain${stillRunning > 0 ? ` (${stillRunning} running)` : ""}, keeping the parent`,
|
|
3996
4118
|
});
|
|
4119
|
+
this.emitConversations();
|
|
4120
|
+
this.flushSnapshot();
|
|
4121
|
+
return;
|
|
3997
4122
|
}
|
|
3998
|
-
|
|
4123
|
+
}
|
|
4124
|
+
// Dismissing the ACTIVE conversation: move active elsewhere first
|
|
4125
|
+
// (another listed conversation, else a fresh chat), then remove.
|
|
4126
|
+
if (id === this.activeId) {
|
|
4127
|
+
const vacated = await this.vacateActive(id);
|
|
4128
|
+
if (!vacated) {
|
|
3999
4129
|
this.emit({
|
|
4000
4130
|
type: "notice",
|
|
4001
4131
|
level: "warning",
|
|
4002
|
-
text:
|
|
4003
|
-
textEn: `
|
|
4132
|
+
text: `当前对话「${conv.title}」暂时无法移出(无法创建接替对话)`,
|
|
4133
|
+
textEn: `Cannot dismiss the active conversation "${conv.title}" right now (no replacement chat available)`,
|
|
4004
4134
|
});
|
|
4135
|
+
this.emitConversations();
|
|
4136
|
+
this.flushSnapshot();
|
|
4137
|
+
return;
|
|
4005
4138
|
}
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4139
|
+
}
|
|
4140
|
+
this.removeConversation(id);
|
|
4141
|
+
this.emitConversations();
|
|
4142
|
+
this.flushSnapshot();
|
|
4143
|
+
}
|
|
4144
|
+
/** Move the active marker away from id so that conversation can be removed.
|
|
4145
|
+
* Prefers another listed conversation; falls back to creating a fresh chat.
|
|
4146
|
+
* Returns true when id is no longer active. */
|
|
4147
|
+
async vacateActive(id) {
|
|
4148
|
+
if (id !== this.activeId)
|
|
4149
|
+
return true;
|
|
4150
|
+
const other = [...this.convs.values()].find((c) => c.id !== id && c.listed);
|
|
4151
|
+
if (other) {
|
|
4152
|
+
await this.switchConversation(other.id);
|
|
4153
|
+
}
|
|
4154
|
+
else {
|
|
4155
|
+
await this.newChat();
|
|
4156
|
+
}
|
|
4157
|
+
return this.activeId !== id;
|
|
4158
|
+
}
|
|
4159
|
+
/** 强行关闭:中止自身运行(如在跑)与全部子代理后代(运行中的也停),
|
|
4160
|
+
* 再整体移出;终端/审查/后台唤醒等保留态一并放行。active 的目标先让出
|
|
4161
|
+
* active(vacateActive),active 的后代跳过、让出后再补移。 */
|
|
4162
|
+
async forceDismissConversation(conv, descendants, isStreaming) {
|
|
4163
|
+
const title = conv.title;
|
|
4164
|
+
let stopped = 0;
|
|
4165
|
+
for (const d of descendants) {
|
|
4166
|
+
if (d.id === conv.id)
|
|
4167
|
+
continue;
|
|
4168
|
+
if (this.convs.get(d.id) !== d)
|
|
4169
|
+
continue;
|
|
4170
|
+
if (isStreaming(d)) {
|
|
4171
|
+
try {
|
|
4172
|
+
await this.subagentHost.stopSubagent(d.id);
|
|
4173
|
+
stopped++;
|
|
4174
|
+
}
|
|
4175
|
+
catch {
|
|
4176
|
+
// best effort — removal below disposes the runtime anyway.
|
|
4177
|
+
}
|
|
4013
4178
|
}
|
|
4014
|
-
|
|
4179
|
+
}
|
|
4180
|
+
let selfAborted = false;
|
|
4181
|
+
if (isStreaming(conv)) {
|
|
4182
|
+
selfAborted = true;
|
|
4183
|
+
await this.interruptRun(conv, "已强行关闭");
|
|
4184
|
+
}
|
|
4185
|
+
let removedKids = 0;
|
|
4186
|
+
const deferred = [];
|
|
4187
|
+
for (const d of descendants) {
|
|
4188
|
+
const cur = this.convs.get(d.id);
|
|
4189
|
+
if (!cur || cur.id === conv.id)
|
|
4190
|
+
continue;
|
|
4191
|
+
if (cur.id === this.activeId) {
|
|
4192
|
+
deferred.push(cur);
|
|
4193
|
+
continue;
|
|
4194
|
+
}
|
|
4195
|
+
this.removeConversation(cur.id);
|
|
4196
|
+
removedKids++;
|
|
4197
|
+
}
|
|
4198
|
+
if (conv.id === this.activeId) {
|
|
4199
|
+
const vacated = await this.vacateActive(conv.id);
|
|
4200
|
+
if (!vacated) {
|
|
4015
4201
|
this.emit({
|
|
4016
4202
|
type: "notice",
|
|
4017
4203
|
level: "warning",
|
|
4018
|
-
text: `对话「${
|
|
4019
|
-
textEn: `
|
|
4204
|
+
text: `对话「${title}」暂时无法强行关闭(无法创建接替对话)`,
|
|
4205
|
+
textEn: `Cannot force-dismiss conversation "${title}" right now (no replacement chat available)`,
|
|
4020
4206
|
});
|
|
4207
|
+
this.emitConversations();
|
|
4208
|
+
this.flushSnapshot();
|
|
4209
|
+
return;
|
|
4210
|
+
}
|
|
4211
|
+
}
|
|
4212
|
+
for (const d of deferred) {
|
|
4213
|
+
if (this.convs.get(d.id) === d && d.id !== this.activeId) {
|
|
4214
|
+
this.removeConversation(d.id);
|
|
4215
|
+
removedKids++;
|
|
4021
4216
|
}
|
|
4217
|
+
}
|
|
4218
|
+
if (this.convs.get(conv.id) === conv && conv.id !== this.activeId) {
|
|
4219
|
+
this.removeConversation(conv.id);
|
|
4220
|
+
}
|
|
4221
|
+
this.emitConversations();
|
|
4222
|
+
this.flushSnapshot();
|
|
4223
|
+
const remaining = descendants.filter((c) => this.convs.get(c.id) === c).length;
|
|
4224
|
+
this.emit({
|
|
4225
|
+
type: "notice",
|
|
4226
|
+
level: "info",
|
|
4227
|
+
text: `已强行关闭对话「${title}」${removedKids > 0 ? `(含 ${removedKids} 个子代理)` : ""}${selfAborted ? ",本轮运行已中止" : ""}${stopped > 0 ? `,${stopped} 个运行中的子代理已中止` : ""}${remaining > 0 ? `;还有 ${remaining} 个子代理未关闭(已切为当前对话)` : ""}`,
|
|
4228
|
+
textEn: `Force-dismissed conversation "${title}"${removedKids > 0 ? ` (incl. ${removedKids} subagent(s))` : ""}${selfAborted ? ", its run was aborted" : ""}${stopped > 0 ? `, ${stopped} running subagent(s) stopped` : ""}${remaining > 0 ? `; ${remaining} subagent(s) remain (now active)` : ""}`,
|
|
4229
|
+
});
|
|
4230
|
+
}
|
|
4231
|
+
/** Bulk-dismiss finished subagents (left-panel right-click menu).
|
|
4232
|
+
*
|
|
4233
|
+
* parentId omitted = every finished subagent in the running list;
|
|
4234
|
+
* given = the transitive subagent descendants of that conversation
|
|
4235
|
+
* (children, grandchildren, … — parentId chain followed recursively),
|
|
4236
|
+
* plus the conversation itself when IT is a finished subagent.
|
|
4237
|
+
* Finished = idle (not streaming, no retained terminal/review/wake
|
|
4238
|
+
* state). Running ones are skipped, never aborted. Children are removed
|
|
4239
|
+
* before parents so the "parent with live children refuses" guard in
|
|
4240
|
+
* dismissConversation never blocks the batch. The active conversation is
|
|
4241
|
+
* never removed. */
|
|
4242
|
+
async dismissFinishedSubagents(parentId) {
|
|
4243
|
+
const root = parentId?.trim() ? parentId.trim() : undefined;
|
|
4244
|
+
if (root && !this.convs.has(root)) {
|
|
4245
|
+
this.emit({
|
|
4246
|
+
type: "notice",
|
|
4247
|
+
level: "warning",
|
|
4248
|
+
text: "该对话不存在或已关闭",
|
|
4249
|
+
textEn: "This conversation does not exist or is already closed",
|
|
4250
|
+
});
|
|
4022
4251
|
return;
|
|
4023
4252
|
}
|
|
4024
|
-
|
|
4253
|
+
// Collect the subtree: every conversation whose parentId chain leads to
|
|
4254
|
+
// root (or every subagent when root is omitted). Child-before-parent
|
|
4255
|
+
// order via depth so parents become dismissable as children leave.
|
|
4256
|
+
const depthOf = (id) => {
|
|
4257
|
+
let d = 0;
|
|
4258
|
+
let cur = this.convs.get(id);
|
|
4259
|
+
const seen = new Set([id]);
|
|
4260
|
+
while (cur?.parentId) {
|
|
4261
|
+
if (seen.has(cur.parentId))
|
|
4262
|
+
break;
|
|
4263
|
+
seen.add(cur.parentId);
|
|
4264
|
+
d++;
|
|
4265
|
+
cur = this.convs.get(cur.parentId);
|
|
4266
|
+
if (!cur)
|
|
4267
|
+
break;
|
|
4268
|
+
}
|
|
4269
|
+
return d;
|
|
4270
|
+
};
|
|
4271
|
+
const inScope = (conv) => {
|
|
4272
|
+
if (!conv.isSubagent)
|
|
4273
|
+
return false;
|
|
4274
|
+
if (conv.id === this.activeId)
|
|
4275
|
+
return false;
|
|
4276
|
+
if (!conv.listed)
|
|
4277
|
+
return false;
|
|
4278
|
+
if (!root)
|
|
4279
|
+
return true;
|
|
4280
|
+
if (conv.id === root)
|
|
4281
|
+
return true;
|
|
4282
|
+
let cur = conv;
|
|
4283
|
+
const seen = new Set();
|
|
4284
|
+
while (cur?.parentId) {
|
|
4285
|
+
if (cur.parentId === root)
|
|
4286
|
+
return true;
|
|
4287
|
+
if (seen.has(cur.parentId))
|
|
4288
|
+
return false;
|
|
4289
|
+
seen.add(cur.parentId);
|
|
4290
|
+
cur = this.convs.get(cur.parentId);
|
|
4291
|
+
if (!cur)
|
|
4292
|
+
return false;
|
|
4293
|
+
}
|
|
4294
|
+
return false;
|
|
4295
|
+
};
|
|
4296
|
+
const isStreaming = (conv) => {
|
|
4297
|
+
try {
|
|
4298
|
+
return conv.session.isStreaming;
|
|
4299
|
+
}
|
|
4300
|
+
catch {
|
|
4301
|
+
return true;
|
|
4302
|
+
}
|
|
4303
|
+
};
|
|
4304
|
+
const candidates = [...this.convs.values()]
|
|
4305
|
+
.filter(inScope)
|
|
4306
|
+
// Running first would be pointless — drop streaming/retained up front.
|
|
4307
|
+
.filter((conv) => !isStreaming(conv))
|
|
4308
|
+
.filter((conv) => !shouldRetainActive({
|
|
4309
|
+
reviewing: conv.goal.reviewing,
|
|
4310
|
+
wizardRunning: conv.wizardRunning,
|
|
4311
|
+
streaming: false,
|
|
4312
|
+
openTerminals: conv.terminals.countLive(),
|
|
4313
|
+
listed: false,
|
|
4314
|
+
promptedSinceActive: false,
|
|
4315
|
+
hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
|
|
4316
|
+
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
4317
|
+
}))
|
|
4318
|
+
.sort((a, b) => depthOf(b.id) - depthOf(a.id));
|
|
4319
|
+
if (candidates.length === 0) {
|
|
4320
|
+
this.emit({
|
|
4321
|
+
type: "notice",
|
|
4322
|
+
level: "info",
|
|
4323
|
+
text: "没有可关闭的已结束子代理",
|
|
4324
|
+
textEn: "No finished subagents to dismiss",
|
|
4325
|
+
});
|
|
4326
|
+
return;
|
|
4327
|
+
}
|
|
4328
|
+
let removed = 0;
|
|
4329
|
+
let skippedRunning = 0;
|
|
4330
|
+
for (const conv of candidates) {
|
|
4331
|
+
const cur = this.convs.get(conv.id);
|
|
4332
|
+
if (!cur || cur.id === this.activeId)
|
|
4333
|
+
continue;
|
|
4334
|
+
if (isStreaming(cur)) {
|
|
4335
|
+
skippedRunning++;
|
|
4336
|
+
continue;
|
|
4337
|
+
}
|
|
4338
|
+
// Re-check live children: earlier removals in this same batch may
|
|
4339
|
+
// have cleared the guard; still-running children block the parent.
|
|
4340
|
+
const liveChild = [...this.convs.values()].some((child) => child.parentId === cur.id && isStreaming(child));
|
|
4341
|
+
if (liveChild) {
|
|
4342
|
+
skippedRunning++;
|
|
4343
|
+
continue;
|
|
4344
|
+
}
|
|
4345
|
+
this.removeConversation(cur.id);
|
|
4346
|
+
removed++;
|
|
4347
|
+
}
|
|
4025
4348
|
this.emitConversations();
|
|
4026
4349
|
this.flushSnapshot();
|
|
4350
|
+
if (removed > 0) {
|
|
4351
|
+
this.emit({
|
|
4352
|
+
type: "notice",
|
|
4353
|
+
level: "info",
|
|
4354
|
+
text: `已关闭 ${removed} 个已结束的子代理${skippedRunning > 0 ? `(${skippedRunning} 个仍在运行,已跳过)` : ""}`,
|
|
4355
|
+
textEn: `Dismissed ${removed} finished subagent(s)${skippedRunning > 0 ? ` (${skippedRunning} still running, skipped)` : ""}`,
|
|
4356
|
+
});
|
|
4357
|
+
}
|
|
4358
|
+
else {
|
|
4359
|
+
this.emit({
|
|
4360
|
+
type: "notice",
|
|
4361
|
+
level: "info",
|
|
4362
|
+
text: "没有可关闭的已结束子代理(剩余的仍在运行)",
|
|
4363
|
+
textEn: "No finished subagents to dismiss (the rest are still running)",
|
|
4364
|
+
});
|
|
4365
|
+
}
|
|
4027
4366
|
}
|
|
4028
4367
|
/** Open a persisted session as the active conversation (from listSessions).
|
|
4029
4368
|
*
|
|
@@ -4062,7 +4401,9 @@ export class ClientSession {
|
|
|
4062
4401
|
// is known-good. This keeps a failed history open entirely non-destructive.
|
|
4063
4402
|
const oldListed = this.conv.listed;
|
|
4064
4403
|
const displaced = this.displaceActive();
|
|
4065
|
-
const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd).length +
|
|
4404
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === targetCwd && !c.isSubagent).length +
|
|
4405
|
+
1 -
|
|
4406
|
+
(displaced?.cwd === targetCwd && !displaced?.isSubagent ? 1 : 0);
|
|
4066
4407
|
if (openInProject > MAX_OPEN_CONVERSATIONS) {
|
|
4067
4408
|
// displaceActive() may have promoted a streaming conversation into the
|
|
4068
4409
|
// running list. Roll that presentation-only mutation back because no
|
|
@@ -4466,14 +4807,29 @@ export class ClientSession {
|
|
|
4466
4807
|
}
|
|
4467
4808
|
this.flushSnapshot();
|
|
4468
4809
|
}
|
|
4810
|
+
/** Strip the "(New)" freshness marker some catalogs append to display names
|
|
4811
|
+
* (pi.dev data, e.g. "DeepSeek V4 Pro (New)") — display-only; the model id
|
|
4812
|
+
* is untouched so switching still uses the exact official id. */
|
|
4813
|
+
cleanModelDisplayName(name) {
|
|
4814
|
+
return name.replace(/\s*\(new\)$/i, "").trim();
|
|
4815
|
+
}
|
|
4469
4816
|
/** List models that have valid authentication configured. */
|
|
4470
4817
|
async listModels() {
|
|
4471
4818
|
try {
|
|
4472
4819
|
const mr = this.runtime.services.modelRuntime;
|
|
4820
|
+
// Reconcile built-in provider catalogs with the official pi.dev
|
|
4821
|
+
// endpoint before listing: within the SDK's 4h freshness window this
|
|
4822
|
+
// is a fast 304; past it the newest catalog is downloaded WHOLESALE
|
|
4823
|
+
// (patch-remote-catalog.ts) — no union merge, no stale built-in
|
|
4824
|
+
// leftovers, no "新增 N 个模型" noise. Network failure falls back to
|
|
4825
|
+
// the cached catalog silently.
|
|
4826
|
+
await mr.refresh({ allowNetwork: true, signal: AbortSignal.timeout(15_000) }).catch(() => {
|
|
4827
|
+
// list must never fail because the catalog sync did
|
|
4828
|
+
});
|
|
4473
4829
|
const available = await mr.getAvailable();
|
|
4474
4830
|
const models = available.map((m) => ({
|
|
4475
4831
|
id: `${m.provider}/${m.id}`,
|
|
4476
|
-
name: m.name,
|
|
4832
|
+
name: this.cleanModelDisplayName(m.name),
|
|
4477
4833
|
provider: m.provider,
|
|
4478
4834
|
reasoning: m.reasoning,
|
|
4479
4835
|
vision: m.input?.includes("image") ?? false,
|