pi-web-ui 0.72.0 → 0.73.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 +35 -1
- package/README.md +17 -9
- package/README.zh-CN.md +22 -11
- 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/web/dist/assets/{TerminalPanel-SG88Uj0n.js → TerminalPanel-Bz-58xBx.js} +1 -1
- package/web/dist/assets/index-DB1SxjbK.js +335 -0
- package/web/dist/assets/index-Dqbw78CU.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
|
@@ -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,
|
|
@@ -1742,7 +1742,9 @@ export class DshClientSession {
|
|
|
1742
1742
|
});
|
|
1743
1743
|
}
|
|
1744
1744
|
}
|
|
1745
|
-
async dismissConversation(id) {
|
|
1745
|
+
async dismissConversation(id, _withFinishedSubagents, force) {
|
|
1746
|
+
// DSH 引擎无第一方子代理:withFinishedSubagents 恒为 no-op(签名与标准引擎对齐)。
|
|
1747
|
+
// force 放行 active/终端限制;运行中仍拒绝(DSH 单 runtime 无法单独 abort 一个对话,请先点停止)。
|
|
1746
1748
|
const conv = this.convs.get(id);
|
|
1747
1749
|
if (!conv) {
|
|
1748
1750
|
this.emit({
|
|
@@ -1753,7 +1755,7 @@ export class DshClientSession {
|
|
|
1753
1755
|
});
|
|
1754
1756
|
return;
|
|
1755
1757
|
}
|
|
1756
|
-
if (id === this.activeId) {
|
|
1758
|
+
if (id === this.activeId && !force) {
|
|
1757
1759
|
this.emit({
|
|
1758
1760
|
type: "notice",
|
|
1759
1761
|
level: "warning",
|
|
@@ -1775,7 +1777,7 @@ export class DshClientSession {
|
|
|
1775
1777
|
});
|
|
1776
1778
|
return;
|
|
1777
1779
|
}
|
|
1778
|
-
if (conv.terminals.list().length > 0) {
|
|
1780
|
+
if (conv.terminals.list().length > 0 && !force) {
|
|
1779
1781
|
this.emit({
|
|
1780
1782
|
type: "notice",
|
|
1781
1783
|
level: "warning",
|
|
@@ -1784,10 +1786,37 @@ export class DshClientSession {
|
|
|
1784
1786
|
});
|
|
1785
1787
|
return;
|
|
1786
1788
|
}
|
|
1789
|
+
// force + active:先让出 active(切到其他对话或新建),再移除。
|
|
1790
|
+
if (id === this.activeId) {
|
|
1791
|
+
const other = [...this.convs.values()].find((c) => c.id !== id);
|
|
1792
|
+
if (other)
|
|
1793
|
+
await this.switchConversation(other.id);
|
|
1794
|
+
else
|
|
1795
|
+
await this.newChat();
|
|
1796
|
+
if (id === this.activeId) {
|
|
1797
|
+
this.emit({
|
|
1798
|
+
type: "notice",
|
|
1799
|
+
level: "warning",
|
|
1800
|
+
text: `当前对话「${conv.title}」暂时无法移出(无法创建接替对话)`,
|
|
1801
|
+
textEn: `Cannot dismiss the active conversation "${conv.title}" right now (no replacement chat available)`,
|
|
1802
|
+
});
|
|
1803
|
+
return;
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1787
1806
|
this.removeConversation(id);
|
|
1788
1807
|
this.emitConversations();
|
|
1789
1808
|
this.flushSnapshot();
|
|
1790
1809
|
}
|
|
1810
|
+
/** DSH 引擎无第一方子代理(emitConversations 恒 isSubagent:false):批量
|
|
1811
|
+
* 关闭退化为空操作提示,保持与 pi 引擎同一 wire 行为。 */
|
|
1812
|
+
async dismissFinishedSubagents(_parentId) {
|
|
1813
|
+
this.emit({
|
|
1814
|
+
type: "notice",
|
|
1815
|
+
level: "info",
|
|
1816
|
+
text: "没有可关闭的已结束子代理",
|
|
1817
|
+
textEn: "No finished subagents to dismiss",
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1791
1820
|
/** 切换会话:读 JSONL 回放 → 新建 conversation(同一 sessionId 续聊)。 */
|
|
1792
1821
|
async switchSession(path) {
|
|
1793
1822
|
try {
|
|
@@ -3029,6 +3058,14 @@ export class DshClientSession {
|
|
|
3029
3058
|
async listModelsConfig() {
|
|
3030
3059
|
this.emit({ type: "models_config", providers: [] });
|
|
3031
3060
|
}
|
|
3061
|
+
async reloadModelsConfig() {
|
|
3062
|
+
this.emit({
|
|
3063
|
+
type: "notice",
|
|
3064
|
+
level: "warning",
|
|
3065
|
+
text: "DSH 引擎不支持自定义 provider",
|
|
3066
|
+
textEn: "The DSH engine does not support custom providers",
|
|
3067
|
+
});
|
|
3068
|
+
}
|
|
3032
3069
|
async saveModelConfig(_providerId, _config) {
|
|
3033
3070
|
this.emit({
|
|
3034
3071
|
type: "notice",
|