pi-web-ui 0.92.0 → 0.93.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 +58 -2
- package/README.md +69 -14
- package/README.zh-CN.md +36 -10
- package/bin/pi-web-ui.mjs +18 -6
- package/dist/server/agent-service.js +497 -120
- package/dist/server/auth-cookie.js +26 -0
- package/dist/server/claim-files-tool.js +6 -7
- package/dist/server/client-state.js +65 -6
- package/dist/server/conversation-read-tool.js +2 -1
- package/dist/server/delegate-task.js +10 -3
- package/dist/server/dsh/dsh-agent-service.js +43 -1
- package/dist/server/files-service.js +78 -26
- package/dist/server/http-proxy.js +72 -0
- package/dist/server/index.js +62 -7
- package/dist/server/patch-node-pty.js +11 -0
- package/dist/server/plugins.js +1 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +37 -3
- package/dist/server/resolve-global-sdk.js +72 -0
- package/dist/server/sdk-origin.js +64 -0
- package/dist/server/serialize.js +59 -21
- package/dist/server/settings-service.js +12 -1
- package/dist/server/skill-tool.js +2 -1
- package/dist/server/subagents.js +30 -13
- package/dist/server/terminals.js +38 -18
- package/dist/server/themes.js +15 -0
- package/dist/server/tool-info.js +89 -0
- package/dist/server/tool-manager.js +107 -14
- package/package.json +2 -2
- package/plugins/catalog.json +10 -0
- package/themes/ayu-light.css +136 -0
- package/themes/catppuccin-latte.css +4 -0
- package/themes/catppuccin.css +4 -0
- package/themes/codex.css +136 -0
- package/themes/cyberpunk.css +4 -0
- package/themes/dazzle.css +4 -0
- package/themes/everforest-light.css +136 -0
- package/themes/geist.css +136 -0
- package/themes/gruvbox-light.css +136 -0
- package/themes/kanagawa-lotus.css +136 -0
- package/themes/md-preview.css +4 -0
- package/themes/mist.css +4 -0
- package/themes/nord.css +4 -0
- package/themes/one-dark.css +4 -0
- package/themes/paper.css +4 -0
- package/themes/rose-pine-dawn.css +136 -0
- package/themes/sakura.css +4 -0
- package/themes/solarized-light.css +4 -0
- package/themes/tokyo-night.css +4 -0
- package/themes/white.css +4 -0
- package/web/dist/assets/{TerminalPanel-RACpk6AX.js → TerminalPanel-C1A23aI-.js} +1 -1
- package/web/dist/assets/index-B48QWf0I.js +364 -0
- package/web/dist/assets/index-DGL5OInG.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BgrnP9Jz.js +0 -364
- package/web/dist/assets/index-Cy8dSTZt.css +0 -1
|
@@ -35,7 +35,7 @@ import { MarkerService } from "./marker-service.js";
|
|
|
35
35
|
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
36
36
|
import { ModelAdminService } from "./model-admin.js";
|
|
37
37
|
import { FilesService, MACHINE_ROOT, desktopDirWire, workspacePath } from "./files-service.js";
|
|
38
|
-
import { isExtensionDisabled, isExtensionEnabled, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
|
|
38
|
+
import { DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS, effectiveToolWatchdogMs, isExtensionDisabled, isExtensionEnabled, normalizeDisabledPluginTools, normalizeRetryMaxAttempts, normalizeSkillList, ClientStateStore, } from "./client-state.js";
|
|
39
39
|
import { bilingual, pick, resolveServerLang } from "./i18n.js";
|
|
40
40
|
import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
|
|
41
41
|
import { ComposerDraftsStore } from "./composer-drafts.js";
|
|
@@ -49,6 +49,8 @@ import { makeEditSoftTool } from "./edit-soft-tool.js";
|
|
|
49
49
|
import { makeReadDirTool } from "./read-tool.js";
|
|
50
50
|
// 展示文件给用户(present_files):图片/视频内联、文本开预览弹窗、本地打开按钮。
|
|
51
51
|
import { makePresentFilesTool } from "./present-files-tool.js";
|
|
52
|
+
// 工具定义说明的归一化(工具卡右键 → 「显示工具详细信息」,见 getToolInfo)。
|
|
53
|
+
import { normalizeToolInfo } from "./tool-info.js";
|
|
52
54
|
import { collectSubagentDescendantIds, makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
|
|
53
55
|
import { makeDelegateTaskTool } from "./delegate-task.js";
|
|
54
56
|
import { makeConversationReadTool, parseTranscriptLines, toTranscriptInput, } from "./conversation-read-tool.js";
|
|
@@ -80,6 +82,13 @@ const DESKTOP_WIRE = desktopDirWire(HOME_WIRE);
|
|
|
80
82
|
const STREAMING_SNAPSHOT_INTERVAL_MS = 2000;
|
|
81
83
|
/** Deltas newer than this keep the streaming (low-frequency) snapshot cadence. */
|
|
82
84
|
const DELTA_ACTIVE_WINDOW_MS = 1500;
|
|
85
|
+
/**
|
|
86
|
+
* `session.getSessionStats()` 会遍历整份转写,而 `message_delta` 曾经**每一帧**都调它
|
|
87
|
+
* (只为了填 usage)。实测 6000 条转写 × 6002 帧时这一条链占了流式阶段 **27.6%** 的 CPU
|
|
88
|
+
* (2123ms),而一个「最多旧 250ms」的读数对进度条/上下文指示器来说与实时值无法区分。
|
|
89
|
+
* 加这层短缓存后实测流式 CPU 4.859s → 1.328s(3.7×),快照字节数完全不变(issue #259)。
|
|
90
|
+
*/
|
|
91
|
+
const STATS_CACHE_MS = 250;
|
|
83
92
|
const WIDGET_REFRESH_MS = 2000;
|
|
84
93
|
/** SCM「AI 生成提交信息」的单次补全超时——慢供应商不该让按钮转圈到天荒地老。 */
|
|
85
94
|
const SCM_COMMITMSG_TIMEOUT_MS = 60_000;
|
|
@@ -92,9 +101,13 @@ const STALL_NOTIFY_MS = (() => {
|
|
|
92
101
|
const v = Number(process.env.PI_WEB_STALL_NOTIFY_MS);
|
|
93
102
|
return Number.isFinite(v) && v >= 0 ? v : 180_000;
|
|
94
103
|
})();
|
|
95
|
-
/** Serialization-cache cap per conversation (see
|
|
96
|
-
* UiMessage objects are pure-function results, so
|
|
97
|
-
* recompute on
|
|
104
|
+
/** Serialization-cache soft cap per conversation (see serializeCachedFor /
|
|
105
|
+
* pruneMessageCache). Cached UiMessage objects are pure-function results, so a
|
|
106
|
+
* miss only costs a recompute — but a miss on a message that is STILL in the
|
|
107
|
+
* transcript is not free: it hands back a fresh object identity, which fails
|
|
108
|
+
* emitSnapshotNow's identity walk and degrades every checkpoint to a full
|
|
109
|
+
* snapshot (issue #259). Eviction is therefore by "no longer in the
|
|
110
|
+
* transcript", never FIFO; this constant only says when that sweep runs. */
|
|
98
111
|
const UI_MESSAGE_CACHE_CAP = 4096;
|
|
99
112
|
/** Preview panel cap: only the first 512KB of a file is ever read/sent. */
|
|
100
113
|
/** Thrown when the service is quiesced (draining) and the request is NEW work
|
|
@@ -224,7 +237,14 @@ export function makeAdaptiveBashTool(killable, terminalBacked, useTerminal) {
|
|
|
224
237
|
"Setting ON → runs in a visible terminal. persist=true keeps that terminal alive ('ai-bash': shell state such as cd/venv/ssh retained across calls, silent commands move to the background and notify when done); persist=false (default in terminal mode) creates a one-shot terminal that exits when the command finishes while its output stays for review.\n" +
|
|
225
238
|
"Run the bare command — do NOT pipe through head/tail/more/less (use the head/tail parameters to trim the returned output instead; piping also hides live progress in the visible terminal). For interactive commands (REPLs, prompts, installers asking y/n) set persist=true (terminal mode) and drive them with terminal_input / terminal_key.",
|
|
226
239
|
promptSnippet: "run shell commands",
|
|
227
|
-
execute: (id, params, signal, onUpdate, ctx) =>
|
|
240
|
+
execute: (id, params, signal, onUpdate, ctx) => {
|
|
241
|
+
const p = params;
|
|
242
|
+
// Windows 下 ConPTY 架构限制(MSYS2 全局控制台上限 128,且高频创建销毁容易句柄耗尽/卡顿):
|
|
243
|
+
// 一次性命令(persist !== true)走原生 spawn(基于 pipe,无需分配控制台,速度快 60 倍且免死锁,issue #269);
|
|
244
|
+
// 只有明确需要持久交互(persist === true)才进可见终端 ai-bash。
|
|
245
|
+
const shouldUseTerminal = useTerminal() && (process.platform !== "win32" || p?.persist === true);
|
|
246
|
+
return (shouldUseTerminal ? terminalBacked : killable).execute(id, params, signal, onUpdate, ctx);
|
|
247
|
+
},
|
|
228
248
|
};
|
|
229
249
|
}
|
|
230
250
|
/**
|
|
@@ -323,6 +343,10 @@ ownerId) {
|
|
|
323
343
|
},
|
|
324
344
|
};
|
|
325
345
|
}
|
|
346
|
+
/** 判定是否应当即时向前端推送问卷弹窗(只推给当前前台会话,未指定会话按全局放行)。 */
|
|
347
|
+
export function shouldPopQuestion(conversationId, activeId) {
|
|
348
|
+
return conversationId === undefined || conversationId === activeId;
|
|
349
|
+
}
|
|
326
350
|
// ---------------------------------------------------------------------------
|
|
327
351
|
// 浏览器页面工具(标准 pi 引擎的 browser_page customTool)
|
|
328
352
|
//
|
|
@@ -650,15 +674,6 @@ function previewToolResult(result) {
|
|
|
650
674
|
return "[unserializable result]";
|
|
651
675
|
}
|
|
652
676
|
}
|
|
653
|
-
/** Hard cap on how long ONE tool call may run before the watchdog aborts the
|
|
654
|
-
* session. The SDK bash tool has NO default timeout, so a command that never
|
|
655
|
-
* finishes (servers, watchers, infinite loops) would otherwise hang the whole
|
|
656
|
-
* conversation indefinitely. Override with the PI_WEB_TOOL_TIMEOUT_MS env var
|
|
657
|
-
* (milliseconds). */
|
|
658
|
-
const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
|
|
659
|
-
const v = Number(process.env.PI_WEB_TOOL_TIMEOUT_MS);
|
|
660
|
-
return Number.isFinite(v) && v > 0 ? v : 20 * 60_000;
|
|
661
|
-
})();
|
|
662
677
|
/** Cap on simultaneously open NON-subagent conversations of ONE project (each keeps a full
|
|
663
678
|
* runtime alive; conversations of other projects keep their own lists).
|
|
664
679
|
* 子代理不计入:子代理是 inMemory 后台任务,不参与此上限,既不占位也不被此上限拦截。 */
|
|
@@ -797,14 +812,26 @@ function collectSessionAnchors(filePath, q, cap = 10) {
|
|
|
797
812
|
export function piSessionsRoot() {
|
|
798
813
|
return process.env.PI_CODING_AGENT_SESSION_DIR || undefined;
|
|
799
814
|
}
|
|
800
|
-
/** Guardrail: only transcripts under
|
|
801
|
-
*
|
|
815
|
+
/** Guardrail: only transcripts under a sessions root may be opened/deleted/renamed
|
|
816
|
+
* — never arbitrary files. Two roots count as “a sessions root”, and they must stay
|
|
817
|
+
* the **same two** the history list reads from (`loadSessionInfos` →
|
|
818
|
+
* `SessionManager.list(cwd, piSessionsRoot())`):
|
|
819
|
+
*
|
|
820
|
+
* 1. `<agentDir>/sessions/`(SDK 默认的每-cwd 子目录布局)
|
|
821
|
+
* 2. `PI_CODING_AGENT_SESSION_DIR`(扁平「额外会话根」,设了就以它为准扫盘)
|
|
822
|
+
*
|
|
823
|
+
* 只认第 1 条会让设了该变量的用户「历史列得出来、却点不开/删不掉/改不了名」
|
|
824
|
+
* (列表与打开两边口径不一致)。守卫的意图是「不许开任意文件」,不是「只许开
|
|
825
|
+
* 默认目录下的文件」,所以放宽到两个根仍然成立。
|
|
802
826
|
* Shared by deleteSession/renameSession/switchSession so the open path cannot
|
|
803
827
|
* escape the confinement the write paths already enforce. */
|
|
804
828
|
export function isInsideSessionsDir(agentDir, targetPath) {
|
|
805
829
|
const abs = resolve(targetPath);
|
|
806
|
-
const
|
|
807
|
-
|
|
830
|
+
const roots = [resolve(agentDir, "sessions")];
|
|
831
|
+
const extra = piSessionsRoot();
|
|
832
|
+
if (extra)
|
|
833
|
+
roots.push(resolve(extra));
|
|
834
|
+
return roots.some((root) => abs.startsWith(root + sep));
|
|
808
835
|
}
|
|
809
836
|
/** 会话当前模型的 "provider/id"(无模型时 null;软上限按模型覆盖用,issue #229)。 */
|
|
810
837
|
function modelKeyOf(session) {
|
|
@@ -1044,13 +1071,22 @@ export class ClientSession {
|
|
|
1044
1071
|
* `model`(可选):"provider/id",显式指定子代理模型。不传时由调用方决定是否
|
|
1045
1072
|
* 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
|
|
1046
1073
|
* runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
|
|
1047
|
-
async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId) {
|
|
1074
|
+
async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId, persist) {
|
|
1048
1075
|
// 数量上限先行:每个子代理都是完整 runtime + TerminalManager,无上限时一次
|
|
1049
|
-
//
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1076
|
+
// 并行派发几十个会把服务进程拖垮。持久化普通对话则受项目会话上限限制。
|
|
1077
|
+
if (persist) {
|
|
1078
|
+
const baseCwdForLimit = parentId ? (this.convs.get(parentId)?.cwd ?? this.cwd) : this.cwd;
|
|
1079
|
+
const resolvedCwdForLimit = cwd ? resolve(baseCwdForLimit, cwd) : baseCwdForLimit;
|
|
1080
|
+
const openInProject = [...this.convs.values()].filter((c) => c.cwd === resolvedCwdForLimit && !c.isSubagent).length;
|
|
1081
|
+
if (openInProject >= MAX_OPEN_CONVERSATIONS) {
|
|
1082
|
+
throw new Error(pick(this.getLang(), `目标项目运行的普通对话已达上限(${MAX_OPEN_CONVERSATIONS} 个),无法创建持久化对话。请先关闭不需要的对话,或以轻量子代理(persist=false)方式运行`, `The project already has max open regular conversations (${MAX_OPEN_CONVERSATIONS}). Please close some or run as a lightweight subagent (persist=false)`, "agent.conv.limit.reached", { limit: MAX_OPEN_CONVERSATIONS }));
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
else {
|
|
1086
|
+
const liveSubagents = [...this.convs.values()].filter((c) => c.isSubagent).length;
|
|
1087
|
+
if (liveSubagents >= MAX_SUBAGENTS) {
|
|
1088
|
+
throw new Error(pick(this.getLang(), `子代理数量已达上限(${MAX_SUBAGENTS} 个),请先用 subagent_wait_all 等一部分完成、或用 subagent_stop 停掉不需要的再派发`, `Subagent limit reached (${MAX_SUBAGENTS} live). Wait for some with subagent_wait_all or stop unneeded ones with subagent_stop before spawning more`, "agent.subagent.limit.reached", { limit: MAX_SUBAGENTS }));
|
|
1089
|
+
}
|
|
1054
1090
|
}
|
|
1055
1091
|
// 真正的派发者(withSubagentOwner 按 runtime 归属填入):cwd 基准 / 跟随模型 /
|
|
1056
1092
|
// 跟随思考强度一律读它,而不是派发瞬间的 active——后台对话产出时用户可能正
|
|
@@ -1060,15 +1096,16 @@ export class ClientSession {
|
|
|
1060
1096
|
const baseCwd = spawner?.cwd ?? this.cwd;
|
|
1061
1097
|
// 相对 cwd 按派发者所在目录解析:直接透传会相对 server 进程 cwd 落到别处。
|
|
1062
1098
|
const resolvedCwd = cwd ? resolve(baseCwd, cwd) : baseCwd;
|
|
1063
|
-
const conversationId = `sa-${randomUUID().slice(0, 8)}`;
|
|
1099
|
+
const conversationId = persist ? `conv-${randomUUID().slice(0, 8)}` : `sa-${randomUUID().slice(0, 8)}`;
|
|
1064
1100
|
const terminals = this.makeTerminalManager(conversationId, resolvedCwd);
|
|
1101
|
+
const sessionManager = persist ? SessionManager.create(resolvedCwd) : SessionManager.inMemory(resolvedCwd);
|
|
1065
1102
|
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
|
|
1066
1103
|
cwd: resolvedCwd,
|
|
1067
1104
|
agentDir: this.agentDir,
|
|
1068
|
-
sessionManager
|
|
1105
|
+
sessionManager,
|
|
1069
1106
|
});
|
|
1070
1107
|
const conv = this.makeConversation(runtime, conversationId, terminals);
|
|
1071
|
-
conv.isSubagent =
|
|
1108
|
+
conv.isSubagent = !persist;
|
|
1072
1109
|
// 父对话 = 真正派发它的会话(按会话归属的 host 包装填入)。直接用 active
|
|
1073
1110
|
// 会错:后台对话运行时用户可能正看着别的项目对话,孩子会被记到无关
|
|
1074
1111
|
// 对话名下、沉到别的项目组底部(issue #95)。缺省才回退到 active。
|
|
@@ -1174,18 +1211,28 @@ export class ClientSession {
|
|
|
1174
1211
|
textEn: `Subagent ${conversationId} failed to start: ${err instanceof Error ? err.message : String(err)}`,
|
|
1175
1212
|
});
|
|
1176
1213
|
});
|
|
1214
|
+
if (persist) {
|
|
1215
|
+
this.pushProjects().catch(() => { });
|
|
1216
|
+
}
|
|
1177
1217
|
this.emitConversations();
|
|
1178
1218
|
return conv.id;
|
|
1179
1219
|
}
|
|
1180
1220
|
getSubagentSnapshot(convId) {
|
|
1181
1221
|
const conv = this.convs.get(convId);
|
|
1182
|
-
if (!conv?.
|
|
1222
|
+
if (!conv?.session)
|
|
1183
1223
|
return undefined;
|
|
1184
1224
|
return this.toSubagentSnapshot(conv);
|
|
1185
1225
|
}
|
|
1186
|
-
listSubagentSnapshots() {
|
|
1226
|
+
listSubagentSnapshots(scope) {
|
|
1187
1227
|
return [...this.convs.values()]
|
|
1188
|
-
.filter((c) =>
|
|
1228
|
+
.filter((c) => {
|
|
1229
|
+
const isPersisted = !c.isSubagent;
|
|
1230
|
+
if (scope === "subagent")
|
|
1231
|
+
return c.isSubagent;
|
|
1232
|
+
if (scope === "persistent")
|
|
1233
|
+
return isPersisted;
|
|
1234
|
+
return c.isSubagent || Boolean(c.parentId) || Boolean(c.subagentPrompt);
|
|
1235
|
+
})
|
|
1189
1236
|
.sort((a, b) => a.createdAt - b.createdAt)
|
|
1190
1237
|
.map((c) => this.toSubagentSnapshot(c));
|
|
1191
1238
|
}
|
|
@@ -1203,6 +1250,8 @@ export class ClientSession {
|
|
|
1203
1250
|
// session being replaced — report defaults
|
|
1204
1251
|
}
|
|
1205
1252
|
const fullPrompt = conv.subagentPrompt ?? "";
|
|
1253
|
+
const sm = conv.session.sessionManager;
|
|
1254
|
+
const isPersisted = !conv.isSubagent || (typeof sm?.isPersisted === "function" && sm.isPersisted());
|
|
1206
1255
|
return {
|
|
1207
1256
|
convId: conv.id,
|
|
1208
1257
|
type: conv.subagentType ?? "general",
|
|
@@ -1218,6 +1267,7 @@ export class ClientSession {
|
|
|
1218
1267
|
model: conv.session.model?.id,
|
|
1219
1268
|
output: conv.session.getLastAssistantText() ?? "",
|
|
1220
1269
|
parentId: conv.parentId,
|
|
1270
|
+
persisted: isPersisted,
|
|
1221
1271
|
};
|
|
1222
1272
|
}
|
|
1223
1273
|
/** 子代理最近一次运行的结局:最后一条 assistant 消息的 errorMessage / stopReason。
|
|
@@ -1414,17 +1464,17 @@ export class ClientSession {
|
|
|
1414
1464
|
* 切换查看 / 输入补充(steer)/ 中止(abort)/ 移出全部复用现有对话机制。
|
|
1415
1465
|
*/
|
|
1416
1466
|
subagentHost = {
|
|
1417
|
-
spawnSubagent: (prompt, type, cwd, templateName, model, parentId) => {
|
|
1467
|
+
spawnSubagent: (prompt, type, cwd, templateName, model, parentId, persist) => {
|
|
1418
1468
|
// 模板:存在且启用时应用;传了名字但不可用 → 抛错让工具转给 AI。
|
|
1419
1469
|
const tpl = templateName ? this.subagentTemplates.get(templateName) : undefined;
|
|
1420
1470
|
if (templateName && (!tpl || !tpl.enabled)) {
|
|
1421
1471
|
throw new Error(pick(this.getLang(), `子代理模板不可用:${templateName}(不存在或已停用)`, `Subagent template unavailable: ${templateName} (missing or disabled)`, "agent.subagent.template.unavailable", { templateName: templateName }));
|
|
1422
1472
|
}
|
|
1423
1473
|
// 模型优先级:显式 model 参数 > 模板自带模型 > 设置面板默认模型;都不给 = 跟随主对话。
|
|
1424
|
-
return this.spawnSubagentConversation(prompt, type, cwd, tpl, model, parentId);
|
|
1474
|
+
return this.spawnSubagentConversation(prompt, type, cwd, tpl, model, parentId, persist);
|
|
1425
1475
|
},
|
|
1426
1476
|
getSubagent: (convId) => this.getSubagentSnapshot(convId),
|
|
1427
|
-
listSubagents: () => this.listSubagentSnapshots(),
|
|
1477
|
+
listSubagents: (scope) => this.listSubagentSnapshots(scope),
|
|
1428
1478
|
steerSubagent: async (convId, message) => {
|
|
1429
1479
|
const conv = this.convs.get(convId);
|
|
1430
1480
|
if (!conv?.session)
|
|
@@ -1437,6 +1487,7 @@ export class ClientSession {
|
|
|
1437
1487
|
await this.interruptRun(conv, pick(this.getLang(), "用户停止子代理", "User stopped the subagent", "agent.subagent.stop.user"));
|
|
1438
1488
|
}
|
|
1439
1489
|
},
|
|
1490
|
+
getWatchdogTimeoutMs: () => this.getBaseToolWatchdogTimeoutMs(),
|
|
1440
1491
|
// issue #91:子代理工具返回按客户端 UI 语言出中英(英文默认)。
|
|
1441
1492
|
lang: () => this.getLang(),
|
|
1442
1493
|
// 只向 AI 暴露 enabled 的模板(停用的对 AI 不可见)。
|
|
@@ -1649,6 +1700,9 @@ export class ClientSession {
|
|
|
1649
1700
|
/** Timestamp of the most recent message_delta push — while fresh, snapshots
|
|
1650
1701
|
* use the slower STREAMING_SNAPSHOT_INTERVAL_MS cadence. */
|
|
1651
1702
|
lastDeltaAt = 0;
|
|
1703
|
+
/** Short-lived `getSessionStats()` memo — see STATS_CACHE_MS. Keyed by the
|
|
1704
|
+
* session instance so a conversation switch never serves the previous one. */
|
|
1705
|
+
sessionStatsCache = null;
|
|
1652
1706
|
sessionsTimer = null;
|
|
1653
1707
|
version = 0;
|
|
1654
1708
|
/** Snapshot revision counter (see emitSnapshotNow / protocol snapshot_delta). */
|
|
@@ -1852,14 +1906,22 @@ export class ClientSession {
|
|
|
1852
1906
|
});
|
|
1853
1907
|
}
|
|
1854
1908
|
}
|
|
1855
|
-
// issue #145
|
|
1909
|
+
// issue #145:因别处仍持有而跳过恢复 —— 首帧即被告之(pendingNotices 随 attachSink 下发)。
|
|
1910
|
+
// 跑着/空闲都拦:直接恢复会造出第二个写者(两边轮流发送分叉历史)。
|
|
1856
1911
|
if (opts?.blank && opts?.blankTitle) {
|
|
1857
|
-
cs.pendingNotices.push(
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1912
|
+
cs.pendingNotices.push(opts.idleHeld
|
|
1913
|
+
? {
|
|
1914
|
+
type: "notice",
|
|
1915
|
+
level: "info",
|
|
1916
|
+
text: `该项目最近的对话「${opts.blankTitle}」在另一处开着(当前空闲),为你停在了空白新对话 —— 可在左栏「运行的对话」里把它过户过来继续看,或从历史对话里打开(只留一处发送消息,否则历史分叉)。`,
|
|
1917
|
+
textEn: `The most recent conversation ("${opts.blankTitle}") is still open in another window (currently idle), so you landed on a blank chat instead — take it over from Running chats (tagged "Elsewhere") or reopen it from History (send new messages from only one place, or the history will fork).`,
|
|
1918
|
+
}
|
|
1919
|
+
: {
|
|
1920
|
+
type: "notice",
|
|
1921
|
+
level: "info",
|
|
1922
|
+
text: `该项目最近的对话「${opts.blankTitle}」正在另一处运行,为你停在了新对话 —— 直接打开会造出第二个写者。左栏「运行的对话」里能看到它(标着“另一处”),等它跑完再打开。`,
|
|
1923
|
+
textEn: `The most recent conversation ("${opts.blankTitle}") is running in another window, so you landed on a new chat instead — opening it here would create a second writer. It is listed under Running chats (tagged "Elsewhere"); open it after it finishes.`,
|
|
1924
|
+
});
|
|
1863
1925
|
}
|
|
1864
1926
|
await cs.bindSession();
|
|
1865
1927
|
await cs.restoreProjectProviderKeysForCwd(cwd);
|
|
@@ -2091,8 +2153,8 @@ export class ClientSession {
|
|
|
2091
2153
|
...(c.note ? { note: c.note } : {}),
|
|
2092
2154
|
})),
|
|
2093
2155
|
}),
|
|
2094
|
-
// 文件认领(claim_files
|
|
2095
|
-
//
|
|
2156
|
+
// 文件认领(claim_files):开关走统一工具 tab(ActiveSet 门控)。
|
|
2157
|
+
// ownerId 语义同 subagent/skill(本 runtime 所属会话)。
|
|
2096
2158
|
// DSH 引擎无 customTool 注册面,不接(提醒里照样能看到认领)。
|
|
2097
2159
|
makeClaimFilesTool(this.claimToolHost(ownerId), () => this.getLang()),
|
|
2098
2160
|
// 展示文件给用户(present_files,issue #231):模型给路径清单,服务端
|
|
@@ -2371,25 +2433,48 @@ export class ClientSession {
|
|
|
2371
2433
|
}
|
|
2372
2434
|
}, 30_000);
|
|
2373
2435
|
}
|
|
2436
|
+
/** 当前生效的基础工具看门狗超时(毫秒)。0 = 禁用看门狗。
|
|
2437
|
+
* Hard cap on how long ONE tool call may run before the watchdog aborts the
|
|
2438
|
+
* session. The SDK bash tool has NO default timeout, so a command that never
|
|
2439
|
+
* finishes (servers, watchers, infinite loops) would otherwise hang the whole
|
|
2440
|
+
* conversation indefinitely. 优先取设置面板「工具」页的
|
|
2441
|
+
* ClientSettings.toolWatchdogTimeoutMs(逐 run 实时读取,无需 reload);
|
|
2442
|
+
* 未设时回落 PI_WEB_TOOL_TIMEOUT_MS 环境变量,再回落默认 20 分钟。 */
|
|
2443
|
+
getBaseToolWatchdogTimeoutMs() {
|
|
2444
|
+
const fromSettings = this.settingsSvc.current.toolWatchdogTimeoutMs;
|
|
2445
|
+
if (typeof fromSettings === "number" && Number.isFinite(fromSettings) && fromSettings >= 0) {
|
|
2446
|
+
return fromSettings;
|
|
2447
|
+
}
|
|
2448
|
+
return DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS;
|
|
2449
|
+
}
|
|
2374
2450
|
/** Arm the hang-guard for a tool call: if it is still running after
|
|
2375
2451
|
* TOOL_WATCHDOG_TIMEOUT_MS, abort the session instead of letting the
|
|
2376
|
-
* conversation hang forever (the SDK bash tool has no default timeout).
|
|
2377
|
-
|
|
2378
|
-
|
|
2452
|
+
* conversation hang forever (the SDK bash tool has no default timeout).
|
|
2453
|
+
* 若工具调用显式指定了更长超时(如 bash args.timeout),看门狗自动顺延。 */
|
|
2454
|
+
armToolWatchdog(conv, toolCallId, toolName, args) {
|
|
2455
|
+
const timeoutMs = effectiveToolWatchdogMs(this.getBaseToolWatchdogTimeoutMs(), toolName, args);
|
|
2456
|
+
// 0 = 用户显式禁用看门狗(设置面板填 0)。
|
|
2457
|
+
if (timeoutMs <= 0)
|
|
2458
|
+
return;
|
|
2459
|
+
this.rearmToolWatchdog(conv, toolCallId, timeoutMs, timeoutMs);
|
|
2379
2460
|
}
|
|
2380
2461
|
/** (重)布工具挂死看门狗:delayMs 后仍在跑则 abort 整轮。过户时用剩余时间重布
|
|
2381
2462
|
* (已逾期的立即触发,语义不变). */
|
|
2382
|
-
rearmToolWatchdog(conv, toolCallId, delayMs) {
|
|
2463
|
+
rearmToolWatchdog(conv, toolCallId, delayMs, totalTimeoutMs) {
|
|
2464
|
+
const totalMs = totalTimeoutMs ?? delayMs;
|
|
2383
2465
|
const t = setTimeout(() => {
|
|
2384
2466
|
conv.toolWatchdogs.delete(toolCallId);
|
|
2385
2467
|
// The tool finished before the deadline — nothing to do.
|
|
2386
2468
|
if (!conv.toolStartTimes.has(toolCallId))
|
|
2387
2469
|
return;
|
|
2470
|
+
const durationMin = Math.round(totalMs / 60_000);
|
|
2471
|
+
const durationDescZh = durationMin >= 1 ? `${durationMin} 分钟` : `${Math.round(totalMs / 1000)} 秒`;
|
|
2472
|
+
const durationDescEn = durationMin >= 1 ? `${durationMin} min` : `${Math.round(totalMs / 1000)}s`;
|
|
2388
2473
|
this.emit({
|
|
2389
2474
|
type: "notice",
|
|
2390
2475
|
level: "warning",
|
|
2391
|
-
text: `工具执行超过 ${
|
|
2392
|
-
textEn: `Tool ran over ${
|
|
2476
|
+
text: `工具执行超过 ${durationDescZh},已自动终止(防止挂死)。可调整超时:设置面板「工具」或环境变量 PI_WEB_TOOL_TIMEOUT_MS。`,
|
|
2477
|
+
textEn: `Tool ran over ${durationDescEn} and was auto-terminated (hang guard). Tune via Settings -> Tools or PI_WEB_TOOL_TIMEOUT_MS env var.`,
|
|
2393
2478
|
});
|
|
2394
2479
|
conv.toolStartTimes.delete(toolCallId);
|
|
2395
2480
|
// Abort the run (kills the process tree via the SDK's abort signal);
|
|
@@ -2470,9 +2555,12 @@ export class ClientSession {
|
|
|
2470
2555
|
catch {
|
|
2471
2556
|
/* 尽力而为 */
|
|
2472
2557
|
}
|
|
2558
|
+
const curModel = target.session.model;
|
|
2559
|
+
const modelId = curModel ? `${curModel.provider}/${curModel.id}` : undefined;
|
|
2473
2560
|
return {
|
|
2474
2561
|
conversationId: target.id,
|
|
2475
2562
|
title: target.title,
|
|
2563
|
+
model: modelId,
|
|
2476
2564
|
at: target.lastActiveAt,
|
|
2477
2565
|
isStreaming: target.session.isStreaming,
|
|
2478
2566
|
messages: this.messagesOf(target),
|
|
@@ -2785,7 +2873,7 @@ export class ClientSession {
|
|
|
2785
2873
|
// (默认 20 分钟会把还在思考的用户连对话一起剁掉)。它的收场自有路子:
|
|
2786
2874
|
// 用户回答/取消、会话 dispose(cancelPendingQuestions),不限时。
|
|
2787
2875
|
if (event.toolName !== ASK_USER_QUESTION_TOOL_NAME) {
|
|
2788
|
-
this.armToolWatchdog(conv, event.toolCallId);
|
|
2876
|
+
this.armToolWatchdog(conv, event.toolCallId, event.toolName, event.args);
|
|
2789
2877
|
}
|
|
2790
2878
|
// 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
|
|
2791
2879
|
this.onToolEvent?.({
|
|
@@ -2900,7 +2988,9 @@ export class ClientSession {
|
|
|
2900
2988
|
conv.compactionState = { reason: event.reason, startedAt: Date.now() };
|
|
2901
2989
|
conv.lastCompactionTokens = null;
|
|
2902
2990
|
this.markCompactionPending(conv);
|
|
2903
|
-
|
|
2991
|
+
// 进度条只有激活对话看得到(后台对话的快照没有接收方,见 onEvent 末尾)。
|
|
2992
|
+
if (conv.id === this.conv.id)
|
|
2993
|
+
this.flushSnapshot();
|
|
2904
2994
|
break;
|
|
2905
2995
|
}
|
|
2906
2996
|
case "compaction_end": {
|
|
@@ -3148,7 +3238,7 @@ export class ClientSession {
|
|
|
3148
3238
|
messageId: `stream-${m?.timestamp ?? 0}`,
|
|
3149
3239
|
usage: (() => {
|
|
3150
3240
|
try {
|
|
3151
|
-
const t = this.
|
|
3241
|
+
const t = this.sessionStats().tokens;
|
|
3152
3242
|
return t ? { input: t.input, output: t.output, total: t.total } : null;
|
|
3153
3243
|
}
|
|
3154
3244
|
catch {
|
|
@@ -3172,6 +3262,16 @@ export class ClientSession {
|
|
|
3172
3262
|
// Snapshot checkpoint policy: deltas carry live rendering during streaming;
|
|
3173
3263
|
// full snapshots are reconciliation checkpoints taken immediately at
|
|
3174
3264
|
// run/tool boundaries and on a slow timer otherwise.
|
|
3265
|
+
//
|
|
3266
|
+
// 只服务**激活对话**(口径同上面的 message_update):flushSnapshot /
|
|
3267
|
+
// scheduleSnapshot 推的都是 this.conv 的整份状态,而这里的事件可能来自后台
|
|
3268
|
+
// 对话——运行中的子代理每次 tool_execution_end / agent_end 都会走到这一点。
|
|
3269
|
+
// 不按 conv.id 分流 = 子代理的每一次工具调用都替激活对话做一次快照:issue
|
|
3270
|
+
// #259 实测 8 子代理 × 5 次 bash → 8 条全量快照共 39.5MB,而激活对话一个
|
|
3271
|
+
// 字节都没变。后台对话的内容在切过去时取(switch_session / get_state 强制
|
|
3272
|
+
// 全量),它在左栏的运行态由 emitConversations 走另一条通道。
|
|
3273
|
+
if (conv.id !== this.conv.id)
|
|
3274
|
+
return;
|
|
3175
3275
|
if (event.type === "agent_end" ||
|
|
3176
3276
|
event.type === "tool_execution_end" ||
|
|
3177
3277
|
event.type === "compaction_end" ||
|
|
@@ -3215,8 +3315,11 @@ export class ClientSession {
|
|
|
3215
3315
|
serializeCached(m) {
|
|
3216
3316
|
return this.serializeCachedFor(this.conv, m);
|
|
3217
3317
|
}
|
|
3218
|
-
/**
|
|
3219
|
-
|
|
3318
|
+
/** 稳定缓存键 + 该消息的序号种子 n(见 serializeCachedFor)。
|
|
3319
|
+
*
|
|
3320
|
+
* messagesOf 一次扫描里同时要 key(建 live 集合)与 n(算 user seq),所以
|
|
3321
|
+
* 两者一起返回、只算一次;单独调用的路径不传 key,按需现算。 */
|
|
3322
|
+
uiMessageKey(conv, m) {
|
|
3220
3323
|
// toolResult messages are keyed by toolCallId; everything else by
|
|
3221
3324
|
// role+timestamp. A single prompt can emit several same-role messages
|
|
3222
3325
|
// within the SAME millisecond (multiple attachment asides), so the
|
|
@@ -3229,8 +3332,13 @@ export class ClientSession {
|
|
|
3229
3332
|
n = conv.nextMsgId++;
|
|
3230
3333
|
conv.msgIds.set(key, n);
|
|
3231
3334
|
}
|
|
3232
|
-
|
|
3233
|
-
|
|
3335
|
+
return { cacheKey: `${key}#${n}`, n };
|
|
3336
|
+
}
|
|
3337
|
+
/** serializeCached 的按对话版本(插件快照读非活跃对话用;缓存仍按对话隔离)。
|
|
3338
|
+
* key 可由 messagesOf 预计算传入(同一次扫描里它已经算过一遍)。 */
|
|
3339
|
+
serializeCachedFor(conv, m, key) {
|
|
3340
|
+
const k = key ?? this.uiMessageKey(conv, m);
|
|
3341
|
+
const cached = conv.uiMessageCache.get(k.cacheKey);
|
|
3234
3342
|
if (cached)
|
|
3235
3343
|
return cached;
|
|
3236
3344
|
// User-message id suffix is a 1-based count of user messages sharing
|
|
@@ -3238,28 +3346,17 @@ export class ClientSession {
|
|
|
3238
3346
|
// a global per-conversation counter across ALL roles, so it can't be
|
|
3239
3347
|
// reused as the seq — otherwise editing anything but the first question
|
|
3240
3348
|
// fails to resolve ("找不到要编辑的消息").
|
|
3241
|
-
let seq = n;
|
|
3349
|
+
let seq = k.n;
|
|
3242
3350
|
if (m.role === "user") {
|
|
3243
3351
|
const ts = m.timestamp ?? 0;
|
|
3244
3352
|
seq = (conv.userSeqByTs.get(ts) ?? 0) + 1;
|
|
3245
3353
|
conv.userSeqByTs.set(ts, seq);
|
|
3246
3354
|
}
|
|
3247
3355
|
const msg = serializeMessage(m, seq);
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
|
|
3251
|
-
|
|
3252
|
-
// insertion order, so dropping from the front evicts the oldest —
|
|
3253
|
-
// recent messages (the ones every snapshot touches) always survive.
|
|
3254
|
-
// Safe: a miss just recomputes an identical object on next access.
|
|
3255
|
-
let excess = conv.uiMessageCache.size - UI_MESSAGE_CACHE_CAP;
|
|
3256
|
-
while (excess-- > 0) {
|
|
3257
|
-
const oldest = conv.uiMessageCache.keys().next().value;
|
|
3258
|
-
if (oldest === undefined)
|
|
3259
|
-
break;
|
|
3260
|
-
conv.uiMessageCache.delete(oldest);
|
|
3261
|
-
}
|
|
3262
|
-
}
|
|
3356
|
+
// 上界在 messagesOf 的 pruneMessageCache 里按 live 集合处理:见那里的注释
|
|
3357
|
+
// (FIFO 淘汰仍在转写里的条目会让每次快照都退化成全量,issue #259)。
|
|
3358
|
+
if (msg)
|
|
3359
|
+
conv.uiMessageCache.set(k.cacheKey, msg);
|
|
3263
3360
|
return msg;
|
|
3264
3361
|
}
|
|
3265
3362
|
/** Current messages array (with the existing sig-reuse optimization).
|
|
@@ -3270,13 +3367,21 @@ export class ClientSession {
|
|
|
3270
3367
|
}
|
|
3271
3368
|
/** currentMessages 的按对话版本(插件快照读非活跃对话用)。 */
|
|
3272
3369
|
messagesOf(conv) {
|
|
3370
|
+
// 一次扫描同时收齐「当前转写里的全部缓存键」(live 集合,供
|
|
3371
|
+
// pruneMessageCache 精确回收死条目)与各自的序列化结果。
|
|
3372
|
+
const live = new Set();
|
|
3273
3373
|
let rawMessages = conv.session.agent.state.messages
|
|
3274
|
-
.map((m) =>
|
|
3374
|
+
.map((m) => {
|
|
3375
|
+
const k = this.uiMessageKey(conv, m);
|
|
3376
|
+
live.add(k.cacheKey);
|
|
3377
|
+
return this.serializeCachedFor(conv, m, k);
|
|
3378
|
+
})
|
|
3275
3379
|
.filter((m) => m !== null);
|
|
3276
3380
|
// 自动重试等待期:SDK 暂留在 state 末尾的 error 气泡只是中间态(随后被
|
|
3277
3381
|
// 摘掉重跑),不进快照——成功则用户永远看不到,耗尽才标红。否则 agent_end
|
|
3278
3382
|
// 的立即 flush 会先画红、摘掉后又消失(红色一闪而过)。
|
|
3279
3383
|
rawMessages = stripTransientRetryErrors(rawMessages, !!conv.retryState);
|
|
3384
|
+
this.pruneMessageCache(conv, live);
|
|
3280
3385
|
// Reuse the previous array when nothing changed: the element objects are
|
|
3281
3386
|
// cached (reference-stable) anyway, and a stable array reference lets the
|
|
3282
3387
|
// frontend memoize derived maps instead of rebuilding them every 60ms.
|
|
@@ -3286,6 +3391,27 @@ export class ClientSession {
|
|
|
3286
3391
|
conv.lastMessagesArray = rawMessages;
|
|
3287
3392
|
return messages;
|
|
3288
3393
|
}
|
|
3394
|
+
/** 序列化缓存上界:**先按「还在转写里」淘汰,绝不为省内存淘汰仍在转写里的条目**。
|
|
3395
|
+
*
|
|
3396
|
+
* 为什么不能 FIFO 淘汰(issue #259 实测的机理):缓存上限一旦低于转写长度,
|
|
3397
|
+
* 每次快照扫描前缀条目全部 miss → 重新序列化出**新对象**,emitSnapshotNow 的
|
|
3398
|
+
* identity walk 在 i=0 就失配 ⇒ 每个 checkpoint 都退化成整份全量快照,且每次
|
|
3399
|
+
* 都要重算整份转写(颠簸,成本随转写线性甚至更差)。实测 6000 条转写的激活
|
|
3400
|
+
* 对话 + 8 子代理 × 5 次 bash:40 次工具调用换来 8 条全量快照共 39.5MB,
|
|
3401
|
+
* `snapshot_delta` 一条都没有。
|
|
3402
|
+
*
|
|
3403
|
+
* 按 live 集合淘汰后:仍在转写里的消息对象恒定(identity walk 命中,增量通路
|
|
3404
|
+
* 恢复),被回收的只有 fork / 压缩 / 换会话留下的死条目 —— 内存上界仍等于
|
|
3405
|
+
* 「当前转写」本身(这份数组本来就要常驻),不再随历史累积。 */
|
|
3406
|
+
pruneMessageCache(conv, live) {
|
|
3407
|
+
const cache = conv.uiMessageCache;
|
|
3408
|
+
// 只有「超上限」且「确实有死条目」时才扫一遍;转写单调增长时这里是零成本。
|
|
3409
|
+
if (cache.size <= UI_MESSAGE_CACHE_CAP || cache.size <= live.size)
|
|
3410
|
+
return;
|
|
3411
|
+
for (const key of cache.keys())
|
|
3412
|
+
if (!live.has(key))
|
|
3413
|
+
cache.delete(key);
|
|
3414
|
+
}
|
|
3289
3415
|
/** Build every UiState field EXCEPT messages (the expensive part). */
|
|
3290
3416
|
buildLightState(rev, withDraft = false) {
|
|
3291
3417
|
const conv = this.conv;
|
|
@@ -3298,7 +3424,7 @@ export class ClientSession {
|
|
|
3298
3424
|
contextUsage: { tokens: null, contextWindow: 0, percent: null },
|
|
3299
3425
|
};
|
|
3300
3426
|
try {
|
|
3301
|
-
const s = this.
|
|
3427
|
+
const s = this.sessionStats();
|
|
3302
3428
|
stats = {
|
|
3303
3429
|
totalMessages: s.totalMessages,
|
|
3304
3430
|
tokens: s.tokens,
|
|
@@ -3490,11 +3616,18 @@ export class ClientSession {
|
|
|
3490
3616
|
this.pendingQuestions.set(id, { resolve, questions, conversationId });
|
|
3491
3617
|
// 运行列表的「?」角标靠 conversations 推送(问卷登记/解决不经过快照通道)。
|
|
3492
3618
|
this.emitConversations();
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3619
|
+
// 只向目标会话的当前激活端推送弹窗,避免后台问卷污染当前前台会话。
|
|
3620
|
+
if (shouldPopQuestion(conversationId, this.activeId)) {
|
|
3621
|
+
const conv = conversationId ? this.convs.get(conversationId) : this.conv;
|
|
3622
|
+
const conversationTitle = conv?.title;
|
|
3623
|
+
this.emit({
|
|
3624
|
+
type: "question_pending",
|
|
3625
|
+
id,
|
|
3626
|
+
questions,
|
|
3627
|
+
...(conversationId !== undefined ? { conversationId } : {}),
|
|
3628
|
+
...(conversationTitle ? { conversationTitle } : {}),
|
|
3629
|
+
});
|
|
3630
|
+
}
|
|
3498
3631
|
});
|
|
3499
3632
|
}
|
|
3500
3633
|
/** 前端回答模型提问(question_answer → 恢复 askUser 的 Promise)。id 需匹配
|
|
@@ -3519,7 +3652,14 @@ export class ClientSession {
|
|
|
3519
3652
|
for (const [id, p] of this.pendingQuestions) {
|
|
3520
3653
|
if (p.conversationId !== undefined && p.conversationId !== this.activeId)
|
|
3521
3654
|
continue;
|
|
3522
|
-
|
|
3655
|
+
const conv = p.conversationId !== undefined ? this.convs.get(p.conversationId) : this.conv;
|
|
3656
|
+
const conversationTitle = conv?.title;
|
|
3657
|
+
return {
|
|
3658
|
+
id,
|
|
3659
|
+
questions: p.questions,
|
|
3660
|
+
...(p.conversationId !== undefined ? { conversationId: p.conversationId } : {}),
|
|
3661
|
+
...(conversationTitle ? { conversationTitle } : {}),
|
|
3662
|
+
};
|
|
3523
3663
|
}
|
|
3524
3664
|
return null;
|
|
3525
3665
|
}
|
|
@@ -3540,14 +3680,34 @@ export class ClientSession {
|
|
|
3540
3680
|
/** 取某对话的等答复问卷原文(跨页作答的 peek 用;只读,不改变状态)。 */
|
|
3541
3681
|
peekPendingQuestion(convId) {
|
|
3542
3682
|
for (const [id, p] of this.pendingQuestions) {
|
|
3543
|
-
if (p.conversationId === convId)
|
|
3544
|
-
|
|
3683
|
+
if (p.conversationId === convId) {
|
|
3684
|
+
const conv = this.convs.get(convId);
|
|
3685
|
+
return { id, questions: p.questions, conversationTitle: conv?.title };
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
return undefined;
|
|
3689
|
+
}
|
|
3690
|
+
/** 取某对话的等答复问卷简要信息(用于会话列表与横幅展示)。 */
|
|
3691
|
+
getPendingQuestionForConv(convId) {
|
|
3692
|
+
for (const [id, p] of this.pendingQuestions) {
|
|
3693
|
+
if (p.conversationId === undefined || p.conversationId === convId) {
|
|
3694
|
+
const q0 = p.questions[0];
|
|
3695
|
+
const title = q0?.header?.trim() || q0?.question?.trim() || undefined;
|
|
3696
|
+
return { id, title };
|
|
3697
|
+
}
|
|
3545
3698
|
}
|
|
3546
3699
|
return undefined;
|
|
3547
3700
|
}
|
|
3548
3701
|
/** 跨页作答:把别处问卷的原文推给本页弹框(回答经 answerElsewhereQuestion 回去)。 */
|
|
3549
3702
|
pushElsewhereQuestion(owner, convId, q) {
|
|
3550
|
-
this.emit({
|
|
3703
|
+
this.emit({
|
|
3704
|
+
type: "elsewhere_question",
|
|
3705
|
+
owner,
|
|
3706
|
+
convId,
|
|
3707
|
+
id: q.id,
|
|
3708
|
+
questions: q.questions,
|
|
3709
|
+
...(q.conversationTitle ? { conversationTitle: q.conversationTitle } : {}),
|
|
3710
|
+
});
|
|
3551
3711
|
}
|
|
3552
3712
|
/** 关闭所有挂起提问(dispose 时清理):以「取消」解析,避免模型挂死。 */
|
|
3553
3713
|
cancelPendingQuestions() {
|
|
@@ -3964,6 +4124,22 @@ export class ClientSession {
|
|
|
3964
4124
|
}
|
|
3965
4125
|
this.emitSnapshotNow(forceFull);
|
|
3966
4126
|
}
|
|
4127
|
+
/**
|
|
4128
|
+
* Cached `session.getSessionStats()` — the SDK computes it by walking the whole
|
|
4129
|
+
* transcript, and the message_delta path used to call it per streaming frame
|
|
4130
|
+
* (measured: 27.6% of streaming CPU at 6000 messages, see STATS_CACHE_MS).
|
|
4131
|
+
* Callers that need the authoritative value can still call the session
|
|
4132
|
+
* directly; every cache hit here is at most STATS_CACHE_MS stale.
|
|
4133
|
+
*/
|
|
4134
|
+
sessionStats() {
|
|
4135
|
+
const now = Date.now();
|
|
4136
|
+
const hit = this.sessionStatsCache;
|
|
4137
|
+
if (hit && hit.session === this.session && now - hit.at < STATS_CACHE_MS)
|
|
4138
|
+
return hit.value;
|
|
4139
|
+
const value = this.session.getSessionStats();
|
|
4140
|
+
this.sessionStatsCache = { at: now, session: this.session, value };
|
|
4141
|
+
return value;
|
|
4142
|
+
}
|
|
3967
4143
|
scheduleSnapshot() {
|
|
3968
4144
|
if (this.snapshotTimer || this.disposed)
|
|
3969
4145
|
return;
|
|
@@ -4037,6 +4213,29 @@ export class ClientSession {
|
|
|
4037
4213
|
pushSlashCommands() {
|
|
4038
4214
|
return this.slash.push();
|
|
4039
4215
|
}
|
|
4216
|
+
/**
|
|
4217
|
+
* 取一条工具的**定义说明**(工具卡右键 → 「显示工具详细信息」)→ `tool_info`。
|
|
4218
|
+
*
|
|
4219
|
+
* 定义从活动会话现取(`getAllTools` 是目录全集,含被禁用的工具),
|
|
4220
|
+
* `getActiveToolNames` 只用来标记「当前是否启用」—— 禁用名单里的工具仍在目录里,
|
|
4221
|
+
* 用户点开看定义是合理的,只是模型看不到它。
|
|
4222
|
+
*
|
|
4223
|
+
* 失败(会话未就绪 / 引擎抛错)一律回 `found: false`:这是只读的展示请求,
|
|
4224
|
+
* 不该因为拿不到定义就在 UI 上报警。
|
|
4225
|
+
*/
|
|
4226
|
+
getToolInfo(name) {
|
|
4227
|
+
let raw;
|
|
4228
|
+
try {
|
|
4229
|
+
const defs = this.session.getAllTools();
|
|
4230
|
+
const found = defs.find((d) => d.name === name);
|
|
4231
|
+
if (found)
|
|
4232
|
+
raw = { ...found, active: this.session.getActiveToolNames().includes(name) };
|
|
4233
|
+
}
|
|
4234
|
+
catch {
|
|
4235
|
+
// Session not ready (or the engine threw) — fall through to found:false.
|
|
4236
|
+
}
|
|
4237
|
+
this.emit({ type: "tool_info", ...normalizeToolInfo(name, raw) });
|
|
4238
|
+
}
|
|
4040
4239
|
/** 模型/服务商配置管理 —— 自包含模块,见 model-admin.ts。 */
|
|
4041
4240
|
modelAdmin;
|
|
4042
4241
|
/** Persist an api-key credential for a provider (auth.json). */
|
|
@@ -4246,8 +4445,20 @@ export class ClientSession {
|
|
|
4246
4445
|
const curId = cur ? `${cur.provider}/${cur.id}` : null;
|
|
4247
4446
|
// Restore the model's provider key first so setModel's auth check passes.
|
|
4248
4447
|
await this.restoreKeyForModel(savedModel, cwd);
|
|
4249
|
-
if (curId === savedModel)
|
|
4448
|
+
if (curId === savedModel) {
|
|
4449
|
+
// 即使模型已是目标模型,也确保恢复其专属思考强度或全局默认思考强度
|
|
4450
|
+
const sm = this.session.settingsManager;
|
|
4451
|
+
const targetThinking = sm.getModelThinkingLevel(model.provider, model.id) ?? sm.getDefaultThinkingLevel();
|
|
4452
|
+
if (targetThinking && this.session.thinkingLevel !== targetThinking) {
|
|
4453
|
+
try {
|
|
4454
|
+
this.session.setThinkingLevel(targetThinking);
|
|
4455
|
+
}
|
|
4456
|
+
catch {
|
|
4457
|
+
/* 模型可能不支持该强度 */
|
|
4458
|
+
}
|
|
4459
|
+
}
|
|
4250
4460
|
return;
|
|
4461
|
+
}
|
|
4251
4462
|
await this.session.setModel(model);
|
|
4252
4463
|
}
|
|
4253
4464
|
catch {
|
|
@@ -4571,21 +4782,39 @@ export class ClientSession {
|
|
|
4571
4782
|
}
|
|
4572
4783
|
return out;
|
|
4573
4784
|
}
|
|
4574
|
-
/** issue #145
|
|
4575
|
-
* 手动过户的目标定位:convId + 是否有等答复问卷)。
|
|
4785
|
+
/** issue #145:本实例别处可见的对话摘要(elsewhere 列表的本机一半 +
|
|
4786
|
+
* 手动过户的目标定位:convId + 是否有等答复问卷)。
|
|
4787
|
+
*
|
|
4788
|
+
* 口径 = 运行中 + 已结束但本会话仍持有的可见对话(shownInRunningList:
|
|
4789
|
+
* listed 或当前有内容的对话;空白新对话不入列)。只推 running 时,对话
|
|
4790
|
+
* 一结束 elsewhere 行就消失,另一处想过户查看只能趁运行中动手 —— 跑完
|
|
4791
|
+
* 即失联。空闲行带 isStreaming:false + sessionFile,照样可过户(搬 runtime
|
|
4792
|
+
* 本体,单 writer 不变;takeoverBriefs 本来就含空闲对话)。子代理不单列
|
|
4793
|
+
* (随主对话一起搬)。 */
|
|
4576
4794
|
streamingSummariesAll() {
|
|
4577
4795
|
const out = [];
|
|
4578
4796
|
for (const conv of this.convs.values()) {
|
|
4579
4797
|
if (conv.isSubagent)
|
|
4580
4798
|
continue;
|
|
4581
|
-
|
|
4799
|
+
const streaming = this.conversationStreaming(conv);
|
|
4800
|
+
if (!streaming && !this.shownInRunningList(conv))
|
|
4582
4801
|
continue;
|
|
4802
|
+
const pq = this.getPendingQuestionForConv(conv.id);
|
|
4803
|
+
let sessionFile;
|
|
4804
|
+
try {
|
|
4805
|
+
sessionFile = conv.session.sessionFile ?? undefined;
|
|
4806
|
+
}
|
|
4807
|
+
catch {
|
|
4808
|
+
sessionFile = undefined;
|
|
4809
|
+
}
|
|
4583
4810
|
out.push({
|
|
4584
4811
|
title: conv.title,
|
|
4585
4812
|
cwd: conv.cwd,
|
|
4586
|
-
isStreaming:
|
|
4813
|
+
isStreaming: streaming,
|
|
4587
4814
|
convId: conv.id,
|
|
4588
|
-
hasQuestion:
|
|
4815
|
+
hasQuestion: !!pq,
|
|
4816
|
+
...(pq?.title ? { questionTitle: pq.title } : {}),
|
|
4817
|
+
...(sessionFile ? { sessionFile } : {}),
|
|
4589
4818
|
});
|
|
4590
4819
|
}
|
|
4591
4820
|
return out;
|
|
@@ -5347,6 +5576,7 @@ export class ClientSession {
|
|
|
5347
5576
|
// Carry the model chosen in the active chat over to the new chat so it
|
|
5348
5577
|
// doesn't silently revert to the ModelRuntime default model.
|
|
5349
5578
|
const prevModel = this.conv.session.agent.state.model ?? null;
|
|
5579
|
+
const prevThinking = this.conv.session.thinkingLevel ?? null;
|
|
5350
5580
|
let ready = false;
|
|
5351
5581
|
try {
|
|
5352
5582
|
const conversationId = this.nextConversationId();
|
|
@@ -5378,6 +5608,14 @@ export class ClientSession {
|
|
|
5378
5608
|
// model no longer resolvable — keep the default
|
|
5379
5609
|
}
|
|
5380
5610
|
}
|
|
5611
|
+
if (prevThinking) {
|
|
5612
|
+
try {
|
|
5613
|
+
this.session.setThinkingLevel(prevThinking);
|
|
5614
|
+
}
|
|
5615
|
+
catch {
|
|
5616
|
+
// model may not support previous thinking level
|
|
5617
|
+
}
|
|
5618
|
+
}
|
|
5381
5619
|
this.emitConversations();
|
|
5382
5620
|
this.goalSvc.emitGoalStatus();
|
|
5383
5621
|
this.pushTerminals();
|
|
@@ -5712,7 +5950,7 @@ export class ClientSession {
|
|
|
5712
5950
|
this.pendingQuestions.delete(qid);
|
|
5713
5951
|
questions.push({ resolve: p.resolve, questions: p.questions, conversationId: p.conversationId });
|
|
5714
5952
|
// 源页面的对话框可能是即时通道弹出的(live),快照为 null 收不掉它 ——
|
|
5715
|
-
//
|
|
5953
|
+
// 明确撤回,让源页面立即收起(目标页主对话由转入方重推 question_pending 或快照呈现)。
|
|
5716
5954
|
this.emit({ type: "question_retracted", id: qid });
|
|
5717
5955
|
}
|
|
5718
5956
|
}
|
|
@@ -5733,8 +5971,8 @@ export class ClientSession {
|
|
|
5733
5971
|
* - id 冲突(两边计数器都从 c1 开始,大概率撞上)→ 给搬入方分配新 id,move
|
|
5734
5972
|
* 集合内的 parentId/问卷归属同步改写。模型手里旧 runId 的后续子代理工具调用
|
|
5735
5973
|
* 会报 unknown(可经列表查新 id);定时唤醒的旧 id 同理回落无头执行。
|
|
5736
|
-
* -
|
|
5737
|
-
*
|
|
5974
|
+
* - 事件订阅/终端投递/问卷/页调用全部重接到本会话,迁入的主对话立即触发弹窗
|
|
5975
|
+
* (子代理待答问卷通过角标与快照呈现)。看门狗按剩余时间重布(已逾期的立即触发)。
|
|
5738
5976
|
*/
|
|
5739
5977
|
insertTakeoverConvs(payload) {
|
|
5740
5978
|
const remap = new Map();
|
|
@@ -5758,7 +5996,10 @@ export class ClientSession {
|
|
|
5758
5996
|
conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
|
|
5759
5997
|
for (const [toolCallId, start] of conv.toolStartTimes) {
|
|
5760
5998
|
if (!conv.toolWatchdogs.has(toolCallId)) {
|
|
5761
|
-
|
|
5999
|
+
const baseMs = this.getBaseToolWatchdogTimeoutMs();
|
|
6000
|
+
if (baseMs > 0) {
|
|
6001
|
+
this.rearmToolWatchdog(conv, toolCallId, baseMs - (Date.now() - start), baseMs);
|
|
6002
|
+
}
|
|
5762
6003
|
}
|
|
5763
6004
|
}
|
|
5764
6005
|
this.convs.set(conv.id, conv);
|
|
@@ -5767,12 +6008,23 @@ export class ClientSession {
|
|
|
5767
6008
|
mainId = payload.convs[0]?.id ?? "";
|
|
5768
6009
|
for (const q of payload.questions) {
|
|
5769
6010
|
const nid = `q-${++this.questionSeq}`;
|
|
6011
|
+
const qConvId = fix(q.conversationId);
|
|
5770
6012
|
this.pendingQuestions.set(nid, {
|
|
5771
6013
|
resolve: q.resolve,
|
|
5772
6014
|
questions: q.questions,
|
|
5773
|
-
conversationId:
|
|
6015
|
+
conversationId: qConvId,
|
|
5774
6016
|
});
|
|
5775
|
-
|
|
6017
|
+
// 迁入的主对话立即触发弹窗(子代理问卷留在后台,由角标与切换会话呈现)。
|
|
6018
|
+
if (qConvId === mainId) {
|
|
6019
|
+
const conv = this.convs.get(qConvId);
|
|
6020
|
+
this.emit({
|
|
6021
|
+
type: "question_pending",
|
|
6022
|
+
id: nid,
|
|
6023
|
+
questions: q.questions,
|
|
6024
|
+
conversationId: qConvId,
|
|
6025
|
+
...(conv?.title ? { conversationTitle: conv.title } : {}),
|
|
6026
|
+
});
|
|
6027
|
+
}
|
|
5776
6028
|
}
|
|
5777
6029
|
for (const p of payload.pageCalls) {
|
|
5778
6030
|
const nid = `p-${++this.pageSeq}`;
|
|
@@ -5931,16 +6183,21 @@ export class ClientSession {
|
|
|
5931
6183
|
// 子代理带 error 标记:左栏红点提示(普通对话不参与)。
|
|
5932
6184
|
...(conv.isSubagent ? this.subagentRunOutcome(conv) : {}),
|
|
5933
6185
|
// 等答复的问卷:左栏「?」角标(主对话/子代理各自挂名下,切过去即可回答)。
|
|
5934
|
-
...(
|
|
6186
|
+
...(() => {
|
|
6187
|
+
const pq = this.getPendingQuestionForConv(conv.id);
|
|
6188
|
+
return pq ? { hasQuestion: true, questionId: pq.id, questionTitle: pq.title } : {};
|
|
6189
|
+
})(),
|
|
5935
6190
|
parentId: conv.parentId,
|
|
5936
6191
|
});
|
|
5937
6192
|
}
|
|
5938
6193
|
// issue #145:流式集合签名变化 → 通知其他客户端重推(左栏「另一处正在运行」近实时)。
|
|
5939
6194
|
// 签名含等问卷态(问卷挂起/解决不改变流式集合,不带它已打开的别处页面永远看不到 `?`)。
|
|
6195
|
+
// elsewhere 口径含已结束的空闲行:签名同样覆盖它们(流式位 + 等问卷位),
|
|
6196
|
+
// 否则对方跑完(streaming→idle)或空闲行出现/消失时这边收不到重推。
|
|
5940
6197
|
try {
|
|
5941
6198
|
const sig = JSON.stringify([...this.convs.values()]
|
|
5942
|
-
.filter((c) => this.conversationStreaming(c))
|
|
5943
|
-
.map((c) => `${c.id}:${this.isWaitingOnUser(c.id) ? 1 : 0}`)
|
|
6199
|
+
.filter((c) => this.conversationStreaming(c) || (!c.isSubagent && this.shownInRunningList(c)))
|
|
6200
|
+
.map((c) => `${c.id}:${this.conversationStreaming(c) ? 1 : 0}:${this.isWaitingOnUser(c.id) ? 1 : 0}`)
|
|
5944
6201
|
.sort());
|
|
5945
6202
|
if (sig !== this.lastRunningSig) {
|
|
5946
6203
|
this.lastRunningSig = sig;
|
|
@@ -6013,8 +6270,6 @@ export class ClientSession {
|
|
|
6013
6270
|
void this.refreshSessions();
|
|
6014
6271
|
}
|
|
6015
6272
|
async pushSessions() {
|
|
6016
|
-
if (!this.sessionsRequested)
|
|
6017
|
-
return;
|
|
6018
6273
|
if (!this.sessionsRequested)
|
|
6019
6274
|
return;
|
|
6020
6275
|
try {
|
|
@@ -6022,8 +6277,23 @@ export class ClientSession {
|
|
|
6022
6277
|
// (<agentDir>/sessions/--<cwd>--/), the same files the pi CLI/TUI
|
|
6023
6278
|
// use — one listing covers every conversation of the current folder.
|
|
6024
6279
|
const infos = await this.loadSessionInfos();
|
|
6280
|
+
// 隐藏「被 fork 掉的父会话」,历史列表只保留每条 fork 链最新的链尾会话
|
|
6281
|
+
// (全文搜索 searchSessions 保留全部会话,不受此影响)。
|
|
6282
|
+
const normSessionPath = (p) => String(p).replace(/\\/g, "/").replace(/\/$/, "").toLowerCase();
|
|
6283
|
+
const forkedParentPaths = new Set();
|
|
6284
|
+
for (const info of infos) {
|
|
6285
|
+
if (typeof info.parentSessionPath === "string" && info.parentSessionPath) {
|
|
6286
|
+
forkedParentPaths.add(normSessionPath(info.parentSessionPath));
|
|
6287
|
+
}
|
|
6288
|
+
}
|
|
6289
|
+
const visibleInfos = forkedParentPaths.size > 0
|
|
6290
|
+
? (() => {
|
|
6291
|
+
const kept = infos.filter((info) => !forkedParentPaths.has(normSessionPath(info.path)));
|
|
6292
|
+
return kept.length > 0 ? kept : infos;
|
|
6293
|
+
})()
|
|
6294
|
+
: infos;
|
|
6025
6295
|
const sessions = new Map();
|
|
6026
|
-
for (const s of
|
|
6296
|
+
for (const s of visibleInfos) {
|
|
6027
6297
|
sessions.set(s.path, {
|
|
6028
6298
|
path: s.path,
|
|
6029
6299
|
name: s.name,
|
|
@@ -6255,6 +6525,84 @@ export class ClientSession {
|
|
|
6255
6525
|
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
6256
6526
|
});
|
|
6257
6527
|
}
|
|
6528
|
+
/**
|
|
6529
|
+
* 将内存子代理(inMemory)固化为普通持久化对话:
|
|
6530
|
+
* 写入磁盘 .jsonl 会话文件,清除 isSubagent 标记,使它进入历史会话列表并长久保留。
|
|
6531
|
+
*/
|
|
6532
|
+
async persistConversation(id) {
|
|
6533
|
+
const conv = this.convs.get(id);
|
|
6534
|
+
if (!conv) {
|
|
6535
|
+
this.emit({
|
|
6536
|
+
type: "notice",
|
|
6537
|
+
level: "warning",
|
|
6538
|
+
text: "该对话不存在或已关闭",
|
|
6539
|
+
textEn: "This conversation does not exist or is already closed",
|
|
6540
|
+
});
|
|
6541
|
+
return;
|
|
6542
|
+
}
|
|
6543
|
+
const sm = conv.session.sessionManager;
|
|
6544
|
+
if (!conv.isSubagent && sm?.isPersisted?.()) {
|
|
6545
|
+
this.emit({
|
|
6546
|
+
type: "notice",
|
|
6547
|
+
level: "info",
|
|
6548
|
+
text: `对话「${conv.title}」已是持久化对话,无需固化`,
|
|
6549
|
+
textEn: `Conversation "${conv.title}" is already persistent`,
|
|
6550
|
+
});
|
|
6551
|
+
return;
|
|
6552
|
+
}
|
|
6553
|
+
try {
|
|
6554
|
+
const cwd = conv.cwd || this.cwd;
|
|
6555
|
+
const sampleSm = SessionManager.create(cwd);
|
|
6556
|
+
const sessionDir = sampleSm.getSessionDir();
|
|
6557
|
+
if (!existsSync(sessionDir)) {
|
|
6558
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
6559
|
+
}
|
|
6560
|
+
const timestamp = new Date().toISOString();
|
|
6561
|
+
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
6562
|
+
const sessionId = sm?.getSessionId?.() || randomUUID();
|
|
6563
|
+
const sessionFile = join(sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);
|
|
6564
|
+
// 获取所有已存在的 entries 并写盘
|
|
6565
|
+
const entries = sm?.fileEntries ?? [];
|
|
6566
|
+
let entriesToWrite = entries;
|
|
6567
|
+
if (!entriesToWrite.some((e) => e?.type === "session")) {
|
|
6568
|
+
const header = {
|
|
6569
|
+
type: "session",
|
|
6570
|
+
version: 3,
|
|
6571
|
+
id: sessionId,
|
|
6572
|
+
timestamp,
|
|
6573
|
+
cwd,
|
|
6574
|
+
};
|
|
6575
|
+
entriesToWrite = [header, ...entriesToWrite];
|
|
6576
|
+
}
|
|
6577
|
+
writeFileSync(sessionFile, entriesToWrite.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf8");
|
|
6578
|
+
// 切换 SessionManager 内部状态,后续消息自动追加写盘
|
|
6579
|
+
if (sm) {
|
|
6580
|
+
sm.setSessionFile(sessionFile);
|
|
6581
|
+
sm.sessionDir = sessionDir;
|
|
6582
|
+
sm.persist = true;
|
|
6583
|
+
sm.flushed = true;
|
|
6584
|
+
}
|
|
6585
|
+
conv.isSubagent = false;
|
|
6586
|
+
this.emitConversations();
|
|
6587
|
+
await this.pushProjects();
|
|
6588
|
+
this.flushSnapshot();
|
|
6589
|
+
this.emit({
|
|
6590
|
+
type: "notice",
|
|
6591
|
+
level: "info",
|
|
6592
|
+
text: `已将子代理「${conv.title}」固化为普通对话,并保存至历史记录`,
|
|
6593
|
+
textEn: `Solidified subagent "${conv.title}" into a regular conversation saved to history`,
|
|
6594
|
+
});
|
|
6595
|
+
}
|
|
6596
|
+
catch (err) {
|
|
6597
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6598
|
+
this.emit({
|
|
6599
|
+
type: "notice",
|
|
6600
|
+
level: "error",
|
|
6601
|
+
text: `固化子代理失败:${msg}`,
|
|
6602
|
+
textEn: `Failed to persist subagent: ${msg}`,
|
|
6603
|
+
});
|
|
6604
|
+
}
|
|
6605
|
+
}
|
|
6258
6606
|
/** Dismiss a running conversation from the left-panel list without deleting its
|
|
6259
6607
|
* transcript file. Only idle (non-streaming) conversations that are not
|
|
6260
6608
|
* retained by terminal/wake/review state can be dismissed. The session stays
|
|
@@ -6843,6 +7191,7 @@ export class ClientSession {
|
|
|
6843
7191
|
// Preserve the model the user had selected — fork() seeds a new
|
|
6844
7192
|
// branch with the ModelRuntime default model otherwise.
|
|
6845
7193
|
const prevModel = this.session.agent.state.model ?? null;
|
|
7194
|
+
const prevThinking = this.session.thinkingLevel ?? null;
|
|
6846
7195
|
const result = await this.runtime.fork(entryId);
|
|
6847
7196
|
if (result.cancelled) {
|
|
6848
7197
|
this.emit({
|
|
@@ -6866,6 +7215,14 @@ export class ClientSession {
|
|
|
6866
7215
|
// model no longer resolvable — keep the default
|
|
6867
7216
|
}
|
|
6868
7217
|
}
|
|
7218
|
+
if (prevThinking) {
|
|
7219
|
+
try {
|
|
7220
|
+
this.session.setThinkingLevel(prevThinking);
|
|
7221
|
+
}
|
|
7222
|
+
catch {
|
|
7223
|
+
// model no longer supports previous thinking level
|
|
7224
|
+
}
|
|
7225
|
+
}
|
|
6869
7226
|
await this.prompt(trimmed, attachments);
|
|
6870
7227
|
this.emit({
|
|
6871
7228
|
type: "notice",
|
|
@@ -7245,18 +7602,20 @@ export class ClientSession {
|
|
|
7245
7602
|
});
|
|
7246
7603
|
this.flushSnapshot();
|
|
7247
7604
|
// First visit to this project: resume its most recent session —
|
|
7248
|
-
// unless that transcript is
|
|
7605
|
+
// unless that transcript is still held on another client (#145):
|
|
7249
7606
|
// default-opening it would strand the tab on a conversation it
|
|
7250
|
-
// cannot use (the prompt guard refuses) with a
|
|
7251
|
-
// forks history once
|
|
7607
|
+
// cannot use (the prompt guard refuses while streaming) with a
|
|
7608
|
+
// stale leaf that forks history once both sides send (idle-held
|
|
7609
|
+
// files fork the same way — 第二个写者不只跑着时才危险). Land
|
|
7610
|
+
// blank instead.
|
|
7252
7611
|
let resumeSkipped = null;
|
|
7253
|
-
//
|
|
7254
|
-
if (this.
|
|
7612
|
+
// 别处无可见行时不扫目录(首访切项目的常见情形零开销)。
|
|
7613
|
+
if ((this.listExternalRunning?.() ?? []).length > 0) {
|
|
7255
7614
|
try {
|
|
7256
7615
|
const infos = await SessionManager.list(abs, piSessionsRoot());
|
|
7257
7616
|
const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
|
|
7258
7617
|
const owner = recent ? this.findSessionOwner?.(recent) : null;
|
|
7259
|
-
if (owner
|
|
7618
|
+
if (owner && (owner.connected || owner.isStreaming)) {
|
|
7260
7619
|
resumeSkipped = owner;
|
|
7261
7620
|
}
|
|
7262
7621
|
}
|
|
@@ -7290,12 +7649,19 @@ export class ClientSession {
|
|
|
7290
7649
|
}
|
|
7291
7650
|
await this.bindSession();
|
|
7292
7651
|
if (resumeSkipped) {
|
|
7293
|
-
this.emit(
|
|
7294
|
-
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
|
|
7652
|
+
this.emit(resumeSkipped.isStreaming
|
|
7653
|
+
? {
|
|
7654
|
+
type: "notice",
|
|
7655
|
+
level: "info",
|
|
7656
|
+
text: `该项目最近的对话「${resumeSkipped.title}」正在另一处运行,为你停在了新对话 —— 直接打开会造出第二个写者。左栏「运行的对话」里能看到它(标着“另一处”),等它跑完再打开。`,
|
|
7657
|
+
textEn: `The most recent conversation ("${resumeSkipped.title}") is running in another window, so you landed on a new chat instead — opening it here would create a second writer. It is listed under Running chats (tagged "Elsewhere"); open it after it finishes.`,
|
|
7658
|
+
}
|
|
7659
|
+
: {
|
|
7660
|
+
type: "notice",
|
|
7661
|
+
level: "info",
|
|
7662
|
+
text: `该项目最近的对话「${resumeSkipped.title}」在另一处开着(当前空闲),为你停在了空白新对话 —— 可在左栏「运行的对话」里把它过户过来继续看,或从历史对话里打开(只留一处发送消息,否则历史分叉)。`,
|
|
7663
|
+
textEn: `The most recent conversation ("${resumeSkipped.title}") is still open in another window (currently idle), so you landed on a blank chat instead — take it over from Running chats (tagged "Elsewhere") or reopen it from History (send new messages from only one place, or the history will fork).`,
|
|
7664
|
+
});
|
|
7299
7665
|
}
|
|
7300
7666
|
}
|
|
7301
7667
|
this.pushTerminals();
|
|
@@ -7536,7 +7902,12 @@ export class ClientSession {
|
|
|
7536
7902
|
/** Set the thinking level for future turns. */
|
|
7537
7903
|
setThinking(level) {
|
|
7538
7904
|
try {
|
|
7539
|
-
|
|
7905
|
+
const thinkingLevel = level;
|
|
7906
|
+
this.session.setThinkingLevel(thinkingLevel, { persist: true });
|
|
7907
|
+
const cur = this.session.model;
|
|
7908
|
+
if (cur) {
|
|
7909
|
+
this.session.settingsManager.setModelThinkingLevel(cur.provider, cur.id, thinkingLevel);
|
|
7910
|
+
}
|
|
7540
7911
|
}
|
|
7541
7912
|
catch (err) {
|
|
7542
7913
|
this.emit({
|
|
@@ -7550,7 +7921,11 @@ export class ClientSession {
|
|
|
7550
7921
|
}
|
|
7551
7922
|
cycleThinking() {
|
|
7552
7923
|
try {
|
|
7553
|
-
this.session.cycleThinkingLevel();
|
|
7924
|
+
const nextLevel = this.session.cycleThinkingLevel({ persist: true });
|
|
7925
|
+
const cur = this.session.model;
|
|
7926
|
+
if (cur && nextLevel) {
|
|
7927
|
+
this.session.settingsManager.setModelThinkingLevel(cur.provider, cur.id, nextLevel);
|
|
7928
|
+
}
|
|
7554
7929
|
}
|
|
7555
7930
|
catch (err) {
|
|
7556
7931
|
this.emit({
|
|
@@ -8492,17 +8867,19 @@ export class AgentService {
|
|
|
8492
8867
|
}
|
|
8493
8868
|
}
|
|
8494
8869
|
// Sessions use the SDK default per-project dir — no per-client dir.
|
|
8495
|
-
// issue #145:新标签页默认恢复项目最近的会话 ——
|
|
8496
|
-
//
|
|
8497
|
-
//
|
|
8870
|
+
// issue #145:新标签页默认恢复项目最近的会话 —— 若那条仍被别处持有
|
|
8871
|
+
// (跑着或空闲),建之前就决定空白(第二个 writer 根本不会被打开,
|
|
8872
|
+
// 也无需事后拆 runtime)。之前只拦 running:空闲持有照样恢复出双
|
|
8873
|
+
// writer,两边轮流发送分叉历史。其他客户端不存在时不扫目录。
|
|
8498
8874
|
let createOpts;
|
|
8499
|
-
if (this.
|
|
8875
|
+
if (this.clients.size > 0) {
|
|
8500
8876
|
try {
|
|
8501
8877
|
const infos = await SessionManager.list(cwd, piSessionsRoot());
|
|
8502
8878
|
const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
|
|
8503
8879
|
const owner = recent ? this.findSessionOwner(recent, clientId) : null;
|
|
8504
|
-
if (owner
|
|
8505
|
-
createOpts = { blank: true, blankTitle: owner.title };
|
|
8880
|
+
if (owner && (owner.connected || owner.isStreaming)) {
|
|
8881
|
+
createOpts = { blank: true, blankTitle: owner.title, ...(owner.isStreaming ? {} : { idleHeld: true }) };
|
|
8882
|
+
}
|
|
8506
8883
|
}
|
|
8507
8884
|
catch {
|
|
8508
8885
|
// 列表失败不挡正常恢复
|