pi-web-ui 0.92.0 → 0.94.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 +74 -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 +577 -127
- package/dist/server/auth-cookie.js +26 -0
- package/dist/server/claim-files-tool.js +6 -7
- package/dist/server/client-state.js +125 -8
- 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 +79 -27
- package/dist/server/http-proxy.js +72 -0
- package/dist/server/index.js +84 -17
- 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/soft-cap.js +38 -4
- 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-B-CMQuzm.js} +1 -1
- package/web/dist/assets/index-18jK6al2.js +364 -0
- package/web/dist/assets/index-B3S9MxnN.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,16 +1906,41 @@ 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();
|
|
1927
|
+
// 同步全局默认模型至 SDK settingsManager(若 settings.json 尚未写入),防底层 session 创建时 findInitialModel 兜底回退硬编码模型
|
|
1928
|
+
const globalDefault = stateStore.getDefaultModel();
|
|
1929
|
+
if (globalDefault) {
|
|
1930
|
+
const slash = globalDefault.indexOf("/");
|
|
1931
|
+
if (slash > 0 && slash < globalDefault.length - 1) {
|
|
1932
|
+
const p = globalDefault.slice(0, slash);
|
|
1933
|
+
const id = globalDefault.slice(slash + 1);
|
|
1934
|
+
try {
|
|
1935
|
+
if (!cs.session.settingsManager.getDefaultModel()) {
|
|
1936
|
+
cs.session.settingsManager.setDefaultModelAndProvider(p, id);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
catch {
|
|
1940
|
+
/* 会话未就绪时忽略 */
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1865
1944
|
await cs.restoreProjectProviderKeysForCwd(cwd);
|
|
1866
1945
|
await cs.restoreProjectModelForCwd(cwd);
|
|
1867
1946
|
return cs;
|
|
@@ -1875,7 +1954,7 @@ export class ClientSession {
|
|
|
1875
1954
|
* 应用(prompt replace/append + 白名单),其余(终端接管、Windows persona
|
|
1876
1955
|
* 等)仍跟随主会话设置。undefined = 按主会话设置(普通对话/不选模板的子代理)。
|
|
1877
1956
|
*/
|
|
1878
|
-
makeRuntimeFactory(terminals, apply, ownerId) {
|
|
1957
|
+
makeRuntimeFactory(terminals, apply, ownerId, initialModel) {
|
|
1879
1958
|
return async ({ cwd: effectiveCwd, sessionManager }) => {
|
|
1880
1959
|
const services = await createAgentSessionServices({
|
|
1881
1960
|
cwd: effectiveCwd,
|
|
@@ -2015,9 +2094,38 @@ export class ClientSession {
|
|
|
2015
2094
|
// anchor 在拿到 created.session 后回填(SDK 的 runtime.session 就是它)。
|
|
2016
2095
|
const bridgeAnchor = {};
|
|
2017
2096
|
const bridge = this.bridgeTarget(bridgeAnchor, ownerId);
|
|
2097
|
+
// 为全新会话(0 条消息的空白对话/新对话)提前解析目标模型并注入,
|
|
2098
|
+
// 避免 SDK findInitialModel 在无 model 时回退到内置硬编码默认(如 deepseek-v4-pro):
|
|
2099
|
+
let sessionModel = initialModel;
|
|
2100
|
+
if (!sessionModel) {
|
|
2101
|
+
const isBlank = sessionManager.buildSessionContext().messages.length === 0;
|
|
2102
|
+
if (isBlank) {
|
|
2103
|
+
const savedModelId = this.stateStore.getProjectModel(this.clientId, effectiveCwd) ?? this.stateStore.getDefaultModel();
|
|
2104
|
+
if (savedModelId) {
|
|
2105
|
+
const slash = savedModelId.indexOf("/");
|
|
2106
|
+
if (slash > 0 && slash < savedModelId.length - 1) {
|
|
2107
|
+
const p = savedModelId.slice(0, slash);
|
|
2108
|
+
const id = savedModelId.slice(slash + 1);
|
|
2109
|
+
const found = services.modelRuntime.getModel(p, id);
|
|
2110
|
+
if (found) {
|
|
2111
|
+
try {
|
|
2112
|
+
await this.restoreKeyForModel(savedModelId, effectiveCwd);
|
|
2113
|
+
if (services.modelRuntime.hasConfiguredAuth(p)) {
|
|
2114
|
+
sessionModel = found;
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
catch {
|
|
2118
|
+
/* 密钥恢复失败则由 SDK 自行解析 */
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
}
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2018
2125
|
const created = await createAgentSessionFromServices({
|
|
2019
2126
|
services,
|
|
2020
2127
|
sessionManager,
|
|
2128
|
+
model: sessionModel,
|
|
2021
2129
|
// 覆盖 SDK 内置 bash(customTools 按 name 覆盖)。双实现分流:
|
|
2022
2130
|
// 「默认 bash 覆盖」开关(terminalBash)关 → 原生 SDK bash(纯进程、不开终端);
|
|
2023
2131
|
// 开 → 终端接管 bash(persist 决定一次性/持久,可静默自动转后台)。
|
|
@@ -2091,8 +2199,8 @@ export class ClientSession {
|
|
|
2091
2199
|
...(c.note ? { note: c.note } : {}),
|
|
2092
2200
|
})),
|
|
2093
2201
|
}),
|
|
2094
|
-
// 文件认领(claim_files
|
|
2095
|
-
//
|
|
2202
|
+
// 文件认领(claim_files):开关走统一工具 tab(ActiveSet 门控)。
|
|
2203
|
+
// ownerId 语义同 subagent/skill(本 runtime 所属会话)。
|
|
2096
2204
|
// DSH 引擎无 customTool 注册面,不接(提醒里照样能看到认领)。
|
|
2097
2205
|
makeClaimFilesTool(this.claimToolHost(ownerId), () => this.getLang()),
|
|
2098
2206
|
// 展示文件给用户(present_files,issue #231):模型给路径清单,服务端
|
|
@@ -2371,25 +2479,48 @@ export class ClientSession {
|
|
|
2371
2479
|
}
|
|
2372
2480
|
}, 30_000);
|
|
2373
2481
|
}
|
|
2482
|
+
/** 当前生效的基础工具看门狗超时(毫秒)。0 = 禁用看门狗。
|
|
2483
|
+
* Hard cap on how long ONE tool call may run before the watchdog aborts the
|
|
2484
|
+
* session. The SDK bash tool has NO default timeout, so a command that never
|
|
2485
|
+
* finishes (servers, watchers, infinite loops) would otherwise hang the whole
|
|
2486
|
+
* conversation indefinitely. 优先取设置面板「工具」页的
|
|
2487
|
+
* ClientSettings.toolWatchdogTimeoutMs(逐 run 实时读取,无需 reload);
|
|
2488
|
+
* 未设时回落 PI_WEB_TOOL_TIMEOUT_MS 环境变量,再回落默认 20 分钟。 */
|
|
2489
|
+
getBaseToolWatchdogTimeoutMs() {
|
|
2490
|
+
const fromSettings = this.settingsSvc.current.toolWatchdogTimeoutMs;
|
|
2491
|
+
if (typeof fromSettings === "number" && Number.isFinite(fromSettings) && fromSettings >= 0) {
|
|
2492
|
+
return fromSettings;
|
|
2493
|
+
}
|
|
2494
|
+
return DEFAULT_TOOL_WATCHDOG_TIMEOUT_MS;
|
|
2495
|
+
}
|
|
2374
2496
|
/** Arm the hang-guard for a tool call: if it is still running after
|
|
2375
2497
|
* 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
|
-
|
|
2498
|
+
* conversation hang forever (the SDK bash tool has no default timeout).
|
|
2499
|
+
* 若工具调用显式指定了更长超时(如 bash args.timeout),看门狗自动顺延。 */
|
|
2500
|
+
armToolWatchdog(conv, toolCallId, toolName, args) {
|
|
2501
|
+
const timeoutMs = effectiveToolWatchdogMs(this.getBaseToolWatchdogTimeoutMs(), toolName, args);
|
|
2502
|
+
// 0 = 用户显式禁用看门狗(设置面板填 0)。
|
|
2503
|
+
if (timeoutMs <= 0)
|
|
2504
|
+
return;
|
|
2505
|
+
this.rearmToolWatchdog(conv, toolCallId, timeoutMs, timeoutMs);
|
|
2379
2506
|
}
|
|
2380
2507
|
/** (重)布工具挂死看门狗:delayMs 后仍在跑则 abort 整轮。过户时用剩余时间重布
|
|
2381
2508
|
* (已逾期的立即触发,语义不变). */
|
|
2382
|
-
rearmToolWatchdog(conv, toolCallId, delayMs) {
|
|
2509
|
+
rearmToolWatchdog(conv, toolCallId, delayMs, totalTimeoutMs) {
|
|
2510
|
+
const totalMs = totalTimeoutMs ?? delayMs;
|
|
2383
2511
|
const t = setTimeout(() => {
|
|
2384
2512
|
conv.toolWatchdogs.delete(toolCallId);
|
|
2385
2513
|
// The tool finished before the deadline — nothing to do.
|
|
2386
2514
|
if (!conv.toolStartTimes.has(toolCallId))
|
|
2387
2515
|
return;
|
|
2516
|
+
const durationMin = Math.round(totalMs / 60_000);
|
|
2517
|
+
const durationDescZh = durationMin >= 1 ? `${durationMin} 分钟` : `${Math.round(totalMs / 1000)} 秒`;
|
|
2518
|
+
const durationDescEn = durationMin >= 1 ? `${durationMin} min` : `${Math.round(totalMs / 1000)}s`;
|
|
2388
2519
|
this.emit({
|
|
2389
2520
|
type: "notice",
|
|
2390
2521
|
level: "warning",
|
|
2391
|
-
text: `工具执行超过 ${
|
|
2392
|
-
textEn: `Tool ran over ${
|
|
2522
|
+
text: `工具执行超过 ${durationDescZh},已自动终止(防止挂死)。可调整超时:设置面板「工具」或环境变量 PI_WEB_TOOL_TIMEOUT_MS。`,
|
|
2523
|
+
textEn: `Tool ran over ${durationDescEn} and was auto-terminated (hang guard). Tune via Settings -> Tools or PI_WEB_TOOL_TIMEOUT_MS env var.`,
|
|
2393
2524
|
});
|
|
2394
2525
|
conv.toolStartTimes.delete(toolCallId);
|
|
2395
2526
|
// Abort the run (kills the process tree via the SDK's abort signal);
|
|
@@ -2470,9 +2601,12 @@ export class ClientSession {
|
|
|
2470
2601
|
catch {
|
|
2471
2602
|
/* 尽力而为 */
|
|
2472
2603
|
}
|
|
2604
|
+
const curModel = target.session.model;
|
|
2605
|
+
const modelId = curModel ? `${curModel.provider}/${curModel.id}` : undefined;
|
|
2473
2606
|
return {
|
|
2474
2607
|
conversationId: target.id,
|
|
2475
2608
|
title: target.title,
|
|
2609
|
+
model: modelId,
|
|
2476
2610
|
at: target.lastActiveAt,
|
|
2477
2611
|
isStreaming: target.session.isStreaming,
|
|
2478
2612
|
messages: this.messagesOf(target),
|
|
@@ -2785,7 +2919,7 @@ export class ClientSession {
|
|
|
2785
2919
|
// (默认 20 分钟会把还在思考的用户连对话一起剁掉)。它的收场自有路子:
|
|
2786
2920
|
// 用户回答/取消、会话 dispose(cancelPendingQuestions),不限时。
|
|
2787
2921
|
if (event.toolName !== ASK_USER_QUESTION_TOOL_NAME) {
|
|
2788
|
-
this.armToolWatchdog(conv, event.toolCallId);
|
|
2922
|
+
this.armToolWatchdog(conv, event.toolCallId, event.toolName, event.args);
|
|
2789
2923
|
}
|
|
2790
2924
|
// 插件扩展点:工具开始执行(异常由 emitToolEvent 隔离)。
|
|
2791
2925
|
this.onToolEvent?.({
|
|
@@ -2900,7 +3034,9 @@ export class ClientSession {
|
|
|
2900
3034
|
conv.compactionState = { reason: event.reason, startedAt: Date.now() };
|
|
2901
3035
|
conv.lastCompactionTokens = null;
|
|
2902
3036
|
this.markCompactionPending(conv);
|
|
2903
|
-
|
|
3037
|
+
// 进度条只有激活对话看得到(后台对话的快照没有接收方,见 onEvent 末尾)。
|
|
3038
|
+
if (conv.id === this.conv.id)
|
|
3039
|
+
this.flushSnapshot();
|
|
2904
3040
|
break;
|
|
2905
3041
|
}
|
|
2906
3042
|
case "compaction_end": {
|
|
@@ -3148,7 +3284,7 @@ export class ClientSession {
|
|
|
3148
3284
|
messageId: `stream-${m?.timestamp ?? 0}`,
|
|
3149
3285
|
usage: (() => {
|
|
3150
3286
|
try {
|
|
3151
|
-
const t = this.
|
|
3287
|
+
const t = this.sessionStats().tokens;
|
|
3152
3288
|
return t ? { input: t.input, output: t.output, total: t.total } : null;
|
|
3153
3289
|
}
|
|
3154
3290
|
catch {
|
|
@@ -3172,6 +3308,16 @@ export class ClientSession {
|
|
|
3172
3308
|
// Snapshot checkpoint policy: deltas carry live rendering during streaming;
|
|
3173
3309
|
// full snapshots are reconciliation checkpoints taken immediately at
|
|
3174
3310
|
// run/tool boundaries and on a slow timer otherwise.
|
|
3311
|
+
//
|
|
3312
|
+
// 只服务**激活对话**(口径同上面的 message_update):flushSnapshot /
|
|
3313
|
+
// scheduleSnapshot 推的都是 this.conv 的整份状态,而这里的事件可能来自后台
|
|
3314
|
+
// 对话——运行中的子代理每次 tool_execution_end / agent_end 都会走到这一点。
|
|
3315
|
+
// 不按 conv.id 分流 = 子代理的每一次工具调用都替激活对话做一次快照:issue
|
|
3316
|
+
// #259 实测 8 子代理 × 5 次 bash → 8 条全量快照共 39.5MB,而激活对话一个
|
|
3317
|
+
// 字节都没变。后台对话的内容在切过去时取(switch_session / get_state 强制
|
|
3318
|
+
// 全量),它在左栏的运行态由 emitConversations 走另一条通道。
|
|
3319
|
+
if (conv.id !== this.conv.id)
|
|
3320
|
+
return;
|
|
3175
3321
|
if (event.type === "agent_end" ||
|
|
3176
3322
|
event.type === "tool_execution_end" ||
|
|
3177
3323
|
event.type === "compaction_end" ||
|
|
@@ -3215,8 +3361,11 @@ export class ClientSession {
|
|
|
3215
3361
|
serializeCached(m) {
|
|
3216
3362
|
return this.serializeCachedFor(this.conv, m);
|
|
3217
3363
|
}
|
|
3218
|
-
/**
|
|
3219
|
-
|
|
3364
|
+
/** 稳定缓存键 + 该消息的序号种子 n(见 serializeCachedFor)。
|
|
3365
|
+
*
|
|
3366
|
+
* messagesOf 一次扫描里同时要 key(建 live 集合)与 n(算 user seq),所以
|
|
3367
|
+
* 两者一起返回、只算一次;单独调用的路径不传 key,按需现算。 */
|
|
3368
|
+
uiMessageKey(conv, m) {
|
|
3220
3369
|
// toolResult messages are keyed by toolCallId; everything else by
|
|
3221
3370
|
// role+timestamp. A single prompt can emit several same-role messages
|
|
3222
3371
|
// within the SAME millisecond (multiple attachment asides), so the
|
|
@@ -3229,8 +3378,13 @@ export class ClientSession {
|
|
|
3229
3378
|
n = conv.nextMsgId++;
|
|
3230
3379
|
conv.msgIds.set(key, n);
|
|
3231
3380
|
}
|
|
3232
|
-
|
|
3233
|
-
|
|
3381
|
+
return { cacheKey: `${key}#${n}`, n };
|
|
3382
|
+
}
|
|
3383
|
+
/** serializeCached 的按对话版本(插件快照读非活跃对话用;缓存仍按对话隔离)。
|
|
3384
|
+
* key 可由 messagesOf 预计算传入(同一次扫描里它已经算过一遍)。 */
|
|
3385
|
+
serializeCachedFor(conv, m, key) {
|
|
3386
|
+
const k = key ?? this.uiMessageKey(conv, m);
|
|
3387
|
+
const cached = conv.uiMessageCache.get(k.cacheKey);
|
|
3234
3388
|
if (cached)
|
|
3235
3389
|
return cached;
|
|
3236
3390
|
// User-message id suffix is a 1-based count of user messages sharing
|
|
@@ -3238,28 +3392,17 @@ export class ClientSession {
|
|
|
3238
3392
|
// a global per-conversation counter across ALL roles, so it can't be
|
|
3239
3393
|
// reused as the seq — otherwise editing anything but the first question
|
|
3240
3394
|
// fails to resolve ("找不到要编辑的消息").
|
|
3241
|
-
let seq = n;
|
|
3395
|
+
let seq = k.n;
|
|
3242
3396
|
if (m.role === "user") {
|
|
3243
3397
|
const ts = m.timestamp ?? 0;
|
|
3244
3398
|
seq = (conv.userSeqByTs.get(ts) ?? 0) + 1;
|
|
3245
3399
|
conv.userSeqByTs.set(ts, seq);
|
|
3246
3400
|
}
|
|
3247
3401
|
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
|
-
}
|
|
3402
|
+
// 上界在 messagesOf 的 pruneMessageCache 里按 live 集合处理:见那里的注释
|
|
3403
|
+
// (FIFO 淘汰仍在转写里的条目会让每次快照都退化成全量,issue #259)。
|
|
3404
|
+
if (msg)
|
|
3405
|
+
conv.uiMessageCache.set(k.cacheKey, msg);
|
|
3263
3406
|
return msg;
|
|
3264
3407
|
}
|
|
3265
3408
|
/** Current messages array (with the existing sig-reuse optimization).
|
|
@@ -3270,13 +3413,21 @@ export class ClientSession {
|
|
|
3270
3413
|
}
|
|
3271
3414
|
/** currentMessages 的按对话版本(插件快照读非活跃对话用)。 */
|
|
3272
3415
|
messagesOf(conv) {
|
|
3416
|
+
// 一次扫描同时收齐「当前转写里的全部缓存键」(live 集合,供
|
|
3417
|
+
// pruneMessageCache 精确回收死条目)与各自的序列化结果。
|
|
3418
|
+
const live = new Set();
|
|
3273
3419
|
let rawMessages = conv.session.agent.state.messages
|
|
3274
|
-
.map((m) =>
|
|
3420
|
+
.map((m) => {
|
|
3421
|
+
const k = this.uiMessageKey(conv, m);
|
|
3422
|
+
live.add(k.cacheKey);
|
|
3423
|
+
return this.serializeCachedFor(conv, m, k);
|
|
3424
|
+
})
|
|
3275
3425
|
.filter((m) => m !== null);
|
|
3276
3426
|
// 自动重试等待期:SDK 暂留在 state 末尾的 error 气泡只是中间态(随后被
|
|
3277
3427
|
// 摘掉重跑),不进快照——成功则用户永远看不到,耗尽才标红。否则 agent_end
|
|
3278
3428
|
// 的立即 flush 会先画红、摘掉后又消失(红色一闪而过)。
|
|
3279
3429
|
rawMessages = stripTransientRetryErrors(rawMessages, !!conv.retryState);
|
|
3430
|
+
this.pruneMessageCache(conv, live);
|
|
3280
3431
|
// Reuse the previous array when nothing changed: the element objects are
|
|
3281
3432
|
// cached (reference-stable) anyway, and a stable array reference lets the
|
|
3282
3433
|
// frontend memoize derived maps instead of rebuilding them every 60ms.
|
|
@@ -3286,6 +3437,27 @@ export class ClientSession {
|
|
|
3286
3437
|
conv.lastMessagesArray = rawMessages;
|
|
3287
3438
|
return messages;
|
|
3288
3439
|
}
|
|
3440
|
+
/** 序列化缓存上界:**先按「还在转写里」淘汰,绝不为省内存淘汰仍在转写里的条目**。
|
|
3441
|
+
*
|
|
3442
|
+
* 为什么不能 FIFO 淘汰(issue #259 实测的机理):缓存上限一旦低于转写长度,
|
|
3443
|
+
* 每次快照扫描前缀条目全部 miss → 重新序列化出**新对象**,emitSnapshotNow 的
|
|
3444
|
+
* identity walk 在 i=0 就失配 ⇒ 每个 checkpoint 都退化成整份全量快照,且每次
|
|
3445
|
+
* 都要重算整份转写(颠簸,成本随转写线性甚至更差)。实测 6000 条转写的激活
|
|
3446
|
+
* 对话 + 8 子代理 × 5 次 bash:40 次工具调用换来 8 条全量快照共 39.5MB,
|
|
3447
|
+
* `snapshot_delta` 一条都没有。
|
|
3448
|
+
*
|
|
3449
|
+
* 按 live 集合淘汰后:仍在转写里的消息对象恒定(identity walk 命中,增量通路
|
|
3450
|
+
* 恢复),被回收的只有 fork / 压缩 / 换会话留下的死条目 —— 内存上界仍等于
|
|
3451
|
+
* 「当前转写」本身(这份数组本来就要常驻),不再随历史累积。 */
|
|
3452
|
+
pruneMessageCache(conv, live) {
|
|
3453
|
+
const cache = conv.uiMessageCache;
|
|
3454
|
+
// 只有「超上限」且「确实有死条目」时才扫一遍;转写单调增长时这里是零成本。
|
|
3455
|
+
if (cache.size <= UI_MESSAGE_CACHE_CAP || cache.size <= live.size)
|
|
3456
|
+
return;
|
|
3457
|
+
for (const key of cache.keys())
|
|
3458
|
+
if (!live.has(key))
|
|
3459
|
+
cache.delete(key);
|
|
3460
|
+
}
|
|
3289
3461
|
/** Build every UiState field EXCEPT messages (the expensive part). */
|
|
3290
3462
|
buildLightState(rev, withDraft = false) {
|
|
3291
3463
|
const conv = this.conv;
|
|
@@ -3298,7 +3470,7 @@ export class ClientSession {
|
|
|
3298
3470
|
contextUsage: { tokens: null, contextWindow: 0, percent: null },
|
|
3299
3471
|
};
|
|
3300
3472
|
try {
|
|
3301
|
-
const s = this.
|
|
3473
|
+
const s = this.sessionStats();
|
|
3302
3474
|
stats = {
|
|
3303
3475
|
totalMessages: s.totalMessages,
|
|
3304
3476
|
tokens: s.tokens,
|
|
@@ -3490,11 +3662,18 @@ export class ClientSession {
|
|
|
3490
3662
|
this.pendingQuestions.set(id, { resolve, questions, conversationId });
|
|
3491
3663
|
// 运行列表的「?」角标靠 conversations 推送(问卷登记/解决不经过快照通道)。
|
|
3492
3664
|
this.emitConversations();
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3665
|
+
// 只向目标会话的当前激活端推送弹窗,避免后台问卷污染当前前台会话。
|
|
3666
|
+
if (shouldPopQuestion(conversationId, this.activeId)) {
|
|
3667
|
+
const conv = conversationId ? this.convs.get(conversationId) : this.conv;
|
|
3668
|
+
const conversationTitle = conv?.title;
|
|
3669
|
+
this.emit({
|
|
3670
|
+
type: "question_pending",
|
|
3671
|
+
id,
|
|
3672
|
+
questions,
|
|
3673
|
+
...(conversationId !== undefined ? { conversationId } : {}),
|
|
3674
|
+
...(conversationTitle ? { conversationTitle } : {}),
|
|
3675
|
+
});
|
|
3676
|
+
}
|
|
3498
3677
|
});
|
|
3499
3678
|
}
|
|
3500
3679
|
/** 前端回答模型提问(question_answer → 恢复 askUser 的 Promise)。id 需匹配
|
|
@@ -3519,7 +3698,14 @@ export class ClientSession {
|
|
|
3519
3698
|
for (const [id, p] of this.pendingQuestions) {
|
|
3520
3699
|
if (p.conversationId !== undefined && p.conversationId !== this.activeId)
|
|
3521
3700
|
continue;
|
|
3522
|
-
|
|
3701
|
+
const conv = p.conversationId !== undefined ? this.convs.get(p.conversationId) : this.conv;
|
|
3702
|
+
const conversationTitle = conv?.title;
|
|
3703
|
+
return {
|
|
3704
|
+
id,
|
|
3705
|
+
questions: p.questions,
|
|
3706
|
+
...(p.conversationId !== undefined ? { conversationId: p.conversationId } : {}),
|
|
3707
|
+
...(conversationTitle ? { conversationTitle } : {}),
|
|
3708
|
+
};
|
|
3523
3709
|
}
|
|
3524
3710
|
return null;
|
|
3525
3711
|
}
|
|
@@ -3540,14 +3726,34 @@ export class ClientSession {
|
|
|
3540
3726
|
/** 取某对话的等答复问卷原文(跨页作答的 peek 用;只读,不改变状态)。 */
|
|
3541
3727
|
peekPendingQuestion(convId) {
|
|
3542
3728
|
for (const [id, p] of this.pendingQuestions) {
|
|
3543
|
-
if (p.conversationId === convId)
|
|
3544
|
-
|
|
3729
|
+
if (p.conversationId === convId) {
|
|
3730
|
+
const conv = this.convs.get(convId);
|
|
3731
|
+
return { id, questions: p.questions, conversationTitle: conv?.title };
|
|
3732
|
+
}
|
|
3733
|
+
}
|
|
3734
|
+
return undefined;
|
|
3735
|
+
}
|
|
3736
|
+
/** 取某对话的等答复问卷简要信息(用于会话列表与横幅展示)。 */
|
|
3737
|
+
getPendingQuestionForConv(convId) {
|
|
3738
|
+
for (const [id, p] of this.pendingQuestions) {
|
|
3739
|
+
if (p.conversationId === undefined || p.conversationId === convId) {
|
|
3740
|
+
const q0 = p.questions[0];
|
|
3741
|
+
const title = q0?.header?.trim() || q0?.question?.trim() || undefined;
|
|
3742
|
+
return { id, title };
|
|
3743
|
+
}
|
|
3545
3744
|
}
|
|
3546
3745
|
return undefined;
|
|
3547
3746
|
}
|
|
3548
3747
|
/** 跨页作答:把别处问卷的原文推给本页弹框(回答经 answerElsewhereQuestion 回去)。 */
|
|
3549
3748
|
pushElsewhereQuestion(owner, convId, q) {
|
|
3550
|
-
this.emit({
|
|
3749
|
+
this.emit({
|
|
3750
|
+
type: "elsewhere_question",
|
|
3751
|
+
owner,
|
|
3752
|
+
convId,
|
|
3753
|
+
id: q.id,
|
|
3754
|
+
questions: q.questions,
|
|
3755
|
+
...(q.conversationTitle ? { conversationTitle: q.conversationTitle } : {}),
|
|
3756
|
+
});
|
|
3551
3757
|
}
|
|
3552
3758
|
/** 关闭所有挂起提问(dispose 时清理):以「取消」解析,避免模型挂死。 */
|
|
3553
3759
|
cancelPendingQuestions() {
|
|
@@ -3964,6 +4170,22 @@ export class ClientSession {
|
|
|
3964
4170
|
}
|
|
3965
4171
|
this.emitSnapshotNow(forceFull);
|
|
3966
4172
|
}
|
|
4173
|
+
/**
|
|
4174
|
+
* Cached `session.getSessionStats()` — the SDK computes it by walking the whole
|
|
4175
|
+
* transcript, and the message_delta path used to call it per streaming frame
|
|
4176
|
+
* (measured: 27.6% of streaming CPU at 6000 messages, see STATS_CACHE_MS).
|
|
4177
|
+
* Callers that need the authoritative value can still call the session
|
|
4178
|
+
* directly; every cache hit here is at most STATS_CACHE_MS stale.
|
|
4179
|
+
*/
|
|
4180
|
+
sessionStats() {
|
|
4181
|
+
const now = Date.now();
|
|
4182
|
+
const hit = this.sessionStatsCache;
|
|
4183
|
+
if (hit && hit.session === this.session && now - hit.at < STATS_CACHE_MS)
|
|
4184
|
+
return hit.value;
|
|
4185
|
+
const value = this.session.getSessionStats();
|
|
4186
|
+
this.sessionStatsCache = { at: now, session: this.session, value };
|
|
4187
|
+
return value;
|
|
4188
|
+
}
|
|
3967
4189
|
scheduleSnapshot() {
|
|
3968
4190
|
if (this.snapshotTimer || this.disposed)
|
|
3969
4191
|
return;
|
|
@@ -4037,6 +4259,29 @@ export class ClientSession {
|
|
|
4037
4259
|
pushSlashCommands() {
|
|
4038
4260
|
return this.slash.push();
|
|
4039
4261
|
}
|
|
4262
|
+
/**
|
|
4263
|
+
* 取一条工具的**定义说明**(工具卡右键 → 「显示工具详细信息」)→ `tool_info`。
|
|
4264
|
+
*
|
|
4265
|
+
* 定义从活动会话现取(`getAllTools` 是目录全集,含被禁用的工具),
|
|
4266
|
+
* `getActiveToolNames` 只用来标记「当前是否启用」—— 禁用名单里的工具仍在目录里,
|
|
4267
|
+
* 用户点开看定义是合理的,只是模型看不到它。
|
|
4268
|
+
*
|
|
4269
|
+
* 失败(会话未就绪 / 引擎抛错)一律回 `found: false`:这是只读的展示请求,
|
|
4270
|
+
* 不该因为拿不到定义就在 UI 上报警。
|
|
4271
|
+
*/
|
|
4272
|
+
getToolInfo(name) {
|
|
4273
|
+
let raw;
|
|
4274
|
+
try {
|
|
4275
|
+
const defs = this.session.getAllTools();
|
|
4276
|
+
const found = defs.find((d) => d.name === name);
|
|
4277
|
+
if (found)
|
|
4278
|
+
raw = { ...found, active: this.session.getActiveToolNames().includes(name) };
|
|
4279
|
+
}
|
|
4280
|
+
catch {
|
|
4281
|
+
// Session not ready (or the engine threw) — fall through to found:false.
|
|
4282
|
+
}
|
|
4283
|
+
this.emit({ type: "tool_info", ...normalizeToolInfo(name, raw) });
|
|
4284
|
+
}
|
|
4040
4285
|
/** 模型/服务商配置管理 —— 自包含模块,见 model-admin.ts。 */
|
|
4041
4286
|
modelAdmin;
|
|
4042
4287
|
/** Persist an api-key credential for a provider (auth.json). */
|
|
@@ -4246,8 +4491,20 @@ export class ClientSession {
|
|
|
4246
4491
|
const curId = cur ? `${cur.provider}/${cur.id}` : null;
|
|
4247
4492
|
// Restore the model's provider key first so setModel's auth check passes.
|
|
4248
4493
|
await this.restoreKeyForModel(savedModel, cwd);
|
|
4249
|
-
if (curId === savedModel)
|
|
4494
|
+
if (curId === savedModel) {
|
|
4495
|
+
// 即使模型已是目标模型,也确保恢复其专属思考强度或全局默认思考强度
|
|
4496
|
+
const sm = this.session.settingsManager;
|
|
4497
|
+
const targetThinking = sm.getModelThinkingLevel(model.provider, model.id) ?? sm.getDefaultThinkingLevel();
|
|
4498
|
+
if (targetThinking && this.session.thinkingLevel !== targetThinking) {
|
|
4499
|
+
try {
|
|
4500
|
+
this.session.setThinkingLevel(targetThinking);
|
|
4501
|
+
}
|
|
4502
|
+
catch {
|
|
4503
|
+
/* 模型可能不支持该强度 */
|
|
4504
|
+
}
|
|
4505
|
+
}
|
|
4250
4506
|
return;
|
|
4507
|
+
}
|
|
4251
4508
|
await this.session.setModel(model);
|
|
4252
4509
|
}
|
|
4253
4510
|
catch {
|
|
@@ -4571,21 +4828,39 @@ export class ClientSession {
|
|
|
4571
4828
|
}
|
|
4572
4829
|
return out;
|
|
4573
4830
|
}
|
|
4574
|
-
/** issue #145
|
|
4575
|
-
* 手动过户的目标定位:convId + 是否有等答复问卷)。
|
|
4831
|
+
/** issue #145:本实例别处可见的对话摘要(elsewhere 列表的本机一半 +
|
|
4832
|
+
* 手动过户的目标定位:convId + 是否有等答复问卷)。
|
|
4833
|
+
*
|
|
4834
|
+
* 口径 = 运行中 + 已结束但本会话仍持有的可见对话(shownInRunningList:
|
|
4835
|
+
* listed 或当前有内容的对话;空白新对话不入列)。只推 running 时,对话
|
|
4836
|
+
* 一结束 elsewhere 行就消失,另一处想过户查看只能趁运行中动手 —— 跑完
|
|
4837
|
+
* 即失联。空闲行带 isStreaming:false + sessionFile,照样可过户(搬 runtime
|
|
4838
|
+
* 本体,单 writer 不变;takeoverBriefs 本来就含空闲对话)。子代理不单列
|
|
4839
|
+
* (随主对话一起搬)。 */
|
|
4576
4840
|
streamingSummariesAll() {
|
|
4577
4841
|
const out = [];
|
|
4578
4842
|
for (const conv of this.convs.values()) {
|
|
4579
4843
|
if (conv.isSubagent)
|
|
4580
4844
|
continue;
|
|
4581
|
-
|
|
4845
|
+
const streaming = this.conversationStreaming(conv);
|
|
4846
|
+
if (!streaming && !this.shownInRunningList(conv))
|
|
4582
4847
|
continue;
|
|
4848
|
+
const pq = this.getPendingQuestionForConv(conv.id);
|
|
4849
|
+
let sessionFile;
|
|
4850
|
+
try {
|
|
4851
|
+
sessionFile = conv.session.sessionFile ?? undefined;
|
|
4852
|
+
}
|
|
4853
|
+
catch {
|
|
4854
|
+
sessionFile = undefined;
|
|
4855
|
+
}
|
|
4583
4856
|
out.push({
|
|
4584
4857
|
title: conv.title,
|
|
4585
4858
|
cwd: conv.cwd,
|
|
4586
|
-
isStreaming:
|
|
4859
|
+
isStreaming: streaming,
|
|
4587
4860
|
convId: conv.id,
|
|
4588
|
-
hasQuestion:
|
|
4861
|
+
hasQuestion: !!pq,
|
|
4862
|
+
...(pq?.title ? { questionTitle: pq.title } : {}),
|
|
4863
|
+
...(sessionFile ? { sessionFile } : {}),
|
|
4589
4864
|
});
|
|
4590
4865
|
}
|
|
4591
4866
|
return out;
|
|
@@ -5347,11 +5622,12 @@ export class ClientSession {
|
|
|
5347
5622
|
// Carry the model chosen in the active chat over to the new chat so it
|
|
5348
5623
|
// doesn't silently revert to the ModelRuntime default model.
|
|
5349
5624
|
const prevModel = this.conv.session.agent.state.model ?? null;
|
|
5625
|
+
const prevThinking = this.conv.session.thinkingLevel ?? null;
|
|
5350
5626
|
let ready = false;
|
|
5351
5627
|
try {
|
|
5352
5628
|
const conversationId = this.nextConversationId();
|
|
5353
5629
|
const terminals = this.makeTerminalManager(conversationId, this.cwd);
|
|
5354
|
-
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
|
|
5630
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId, prevModel ?? undefined), {
|
|
5355
5631
|
cwd: this.cwd,
|
|
5356
5632
|
agentDir: this.agentDir,
|
|
5357
5633
|
sessionManager: SessionManager.create(this.cwd),
|
|
@@ -5367,15 +5643,35 @@ export class ClientSession {
|
|
|
5367
5643
|
this.invalidateSessionInfos();
|
|
5368
5644
|
// New session seeds with the ModelRuntime default model — restore the
|
|
5369
5645
|
// model the user had selected in the previous chat.
|
|
5370
|
-
|
|
5646
|
+
let modelRestored = !!this.session.model;
|
|
5647
|
+
if (!modelRestored && prevModel && this.sharedModelRuntime) {
|
|
5371
5648
|
try {
|
|
5372
|
-
await this.session.setModel(prevModel);
|
|
5373
5649
|
const p = prevModel.provider;
|
|
5374
5650
|
const mid = `${p}/${prevModel.id}`;
|
|
5651
|
+
// 先恢复 provider key,再 setModel(否则 checkAuth 鉴权失败)
|
|
5375
5652
|
await this.restoreKeyForModel(mid, this.cwd);
|
|
5653
|
+
await this.session.setModel(prevModel);
|
|
5654
|
+
modelRestored = true;
|
|
5376
5655
|
}
|
|
5377
5656
|
catch {
|
|
5378
|
-
// model no longer resolvable
|
|
5657
|
+
// model no longer resolvable
|
|
5658
|
+
}
|
|
5659
|
+
}
|
|
5660
|
+
if (!modelRestored) {
|
|
5661
|
+
// 上个会话模型未能恢复(或无上个会话):回落项目记忆或全局默认模型
|
|
5662
|
+
try {
|
|
5663
|
+
await this.restoreProjectModelForCwd(this.cwd);
|
|
5664
|
+
}
|
|
5665
|
+
catch {
|
|
5666
|
+
/* 保持默认 */
|
|
5667
|
+
}
|
|
5668
|
+
}
|
|
5669
|
+
if (prevThinking) {
|
|
5670
|
+
try {
|
|
5671
|
+
this.session.setThinkingLevel(prevThinking);
|
|
5672
|
+
}
|
|
5673
|
+
catch {
|
|
5674
|
+
// model may not support previous thinking level
|
|
5379
5675
|
}
|
|
5380
5676
|
}
|
|
5381
5677
|
this.emitConversations();
|
|
@@ -5712,7 +6008,7 @@ export class ClientSession {
|
|
|
5712
6008
|
this.pendingQuestions.delete(qid);
|
|
5713
6009
|
questions.push({ resolve: p.resolve, questions: p.questions, conversationId: p.conversationId });
|
|
5714
6010
|
// 源页面的对话框可能是即时通道弹出的(live),快照为 null 收不掉它 ——
|
|
5715
|
-
//
|
|
6011
|
+
// 明确撤回,让源页面立即收起(目标页主对话由转入方重推 question_pending 或快照呈现)。
|
|
5716
6012
|
this.emit({ type: "question_retracted", id: qid });
|
|
5717
6013
|
}
|
|
5718
6014
|
}
|
|
@@ -5733,8 +6029,8 @@ export class ClientSession {
|
|
|
5733
6029
|
* - id 冲突(两边计数器都从 c1 开始,大概率撞上)→ 给搬入方分配新 id,move
|
|
5734
6030
|
* 集合内的 parentId/问卷归属同步改写。模型手里旧 runId 的后续子代理工具调用
|
|
5735
6031
|
* 会报 unknown(可经列表查新 id);定时唤醒的旧 id 同理回落无头执行。
|
|
5736
|
-
* -
|
|
5737
|
-
*
|
|
6032
|
+
* - 事件订阅/终端投递/问卷/页调用全部重接到本会话,迁入的主对话立即触发弹窗
|
|
6033
|
+
* (子代理待答问卷通过角标与快照呈现)。看门狗按剩余时间重布(已逾期的立即触发)。
|
|
5738
6034
|
*/
|
|
5739
6035
|
insertTakeoverConvs(payload) {
|
|
5740
6036
|
const remap = new Map();
|
|
@@ -5758,7 +6054,10 @@ export class ClientSession {
|
|
|
5758
6054
|
conv.unsubscribe = conv.session.subscribe((event) => this.onEvent(conv, event));
|
|
5759
6055
|
for (const [toolCallId, start] of conv.toolStartTimes) {
|
|
5760
6056
|
if (!conv.toolWatchdogs.has(toolCallId)) {
|
|
5761
|
-
|
|
6057
|
+
const baseMs = this.getBaseToolWatchdogTimeoutMs();
|
|
6058
|
+
if (baseMs > 0) {
|
|
6059
|
+
this.rearmToolWatchdog(conv, toolCallId, baseMs - (Date.now() - start), baseMs);
|
|
6060
|
+
}
|
|
5762
6061
|
}
|
|
5763
6062
|
}
|
|
5764
6063
|
this.convs.set(conv.id, conv);
|
|
@@ -5767,12 +6066,23 @@ export class ClientSession {
|
|
|
5767
6066
|
mainId = payload.convs[0]?.id ?? "";
|
|
5768
6067
|
for (const q of payload.questions) {
|
|
5769
6068
|
const nid = `q-${++this.questionSeq}`;
|
|
6069
|
+
const qConvId = fix(q.conversationId);
|
|
5770
6070
|
this.pendingQuestions.set(nid, {
|
|
5771
6071
|
resolve: q.resolve,
|
|
5772
6072
|
questions: q.questions,
|
|
5773
|
-
conversationId:
|
|
6073
|
+
conversationId: qConvId,
|
|
5774
6074
|
});
|
|
5775
|
-
|
|
6075
|
+
// 迁入的主对话立即触发弹窗(子代理问卷留在后台,由角标与切换会话呈现)。
|
|
6076
|
+
if (qConvId === mainId) {
|
|
6077
|
+
const conv = this.convs.get(qConvId);
|
|
6078
|
+
this.emit({
|
|
6079
|
+
type: "question_pending",
|
|
6080
|
+
id: nid,
|
|
6081
|
+
questions: q.questions,
|
|
6082
|
+
conversationId: qConvId,
|
|
6083
|
+
...(conv?.title ? { conversationTitle: conv.title } : {}),
|
|
6084
|
+
});
|
|
6085
|
+
}
|
|
5776
6086
|
}
|
|
5777
6087
|
for (const p of payload.pageCalls) {
|
|
5778
6088
|
const nid = `p-${++this.pageSeq}`;
|
|
@@ -5931,16 +6241,21 @@ export class ClientSession {
|
|
|
5931
6241
|
// 子代理带 error 标记:左栏红点提示(普通对话不参与)。
|
|
5932
6242
|
...(conv.isSubagent ? this.subagentRunOutcome(conv) : {}),
|
|
5933
6243
|
// 等答复的问卷:左栏「?」角标(主对话/子代理各自挂名下,切过去即可回答)。
|
|
5934
|
-
...(
|
|
6244
|
+
...(() => {
|
|
6245
|
+
const pq = this.getPendingQuestionForConv(conv.id);
|
|
6246
|
+
return pq ? { hasQuestion: true, questionId: pq.id, questionTitle: pq.title } : {};
|
|
6247
|
+
})(),
|
|
5935
6248
|
parentId: conv.parentId,
|
|
5936
6249
|
});
|
|
5937
6250
|
}
|
|
5938
6251
|
// issue #145:流式集合签名变化 → 通知其他客户端重推(左栏「另一处正在运行」近实时)。
|
|
5939
6252
|
// 签名含等问卷态(问卷挂起/解决不改变流式集合,不带它已打开的别处页面永远看不到 `?`)。
|
|
6253
|
+
// elsewhere 口径含已结束的空闲行:签名同样覆盖它们(流式位 + 等问卷位),
|
|
6254
|
+
// 否则对方跑完(streaming→idle)或空闲行出现/消失时这边收不到重推。
|
|
5940
6255
|
try {
|
|
5941
6256
|
const sig = JSON.stringify([...this.convs.values()]
|
|
5942
|
-
.filter((c) => this.conversationStreaming(c))
|
|
5943
|
-
.map((c) => `${c.id}:${this.isWaitingOnUser(c.id) ? 1 : 0}`)
|
|
6257
|
+
.filter((c) => this.conversationStreaming(c) || (!c.isSubagent && this.shownInRunningList(c)))
|
|
6258
|
+
.map((c) => `${c.id}:${this.conversationStreaming(c) ? 1 : 0}:${this.isWaitingOnUser(c.id) ? 1 : 0}`)
|
|
5944
6259
|
.sort());
|
|
5945
6260
|
if (sig !== this.lastRunningSig) {
|
|
5946
6261
|
this.lastRunningSig = sig;
|
|
@@ -6013,8 +6328,6 @@ export class ClientSession {
|
|
|
6013
6328
|
void this.refreshSessions();
|
|
6014
6329
|
}
|
|
6015
6330
|
async pushSessions() {
|
|
6016
|
-
if (!this.sessionsRequested)
|
|
6017
|
-
return;
|
|
6018
6331
|
if (!this.sessionsRequested)
|
|
6019
6332
|
return;
|
|
6020
6333
|
try {
|
|
@@ -6022,8 +6335,23 @@ export class ClientSession {
|
|
|
6022
6335
|
// (<agentDir>/sessions/--<cwd>--/), the same files the pi CLI/TUI
|
|
6023
6336
|
// use — one listing covers every conversation of the current folder.
|
|
6024
6337
|
const infos = await this.loadSessionInfos();
|
|
6338
|
+
// 隐藏「被 fork 掉的父会话」,历史列表只保留每条 fork 链最新的链尾会话
|
|
6339
|
+
// (全文搜索 searchSessions 保留全部会话,不受此影响)。
|
|
6340
|
+
const normSessionPath = (p) => String(p).replace(/\\/g, "/").replace(/\/$/, "").toLowerCase();
|
|
6341
|
+
const forkedParentPaths = new Set();
|
|
6342
|
+
for (const info of infos) {
|
|
6343
|
+
if (typeof info.parentSessionPath === "string" && info.parentSessionPath) {
|
|
6344
|
+
forkedParentPaths.add(normSessionPath(info.parentSessionPath));
|
|
6345
|
+
}
|
|
6346
|
+
}
|
|
6347
|
+
const visibleInfos = forkedParentPaths.size > 0
|
|
6348
|
+
? (() => {
|
|
6349
|
+
const kept = infos.filter((info) => !forkedParentPaths.has(normSessionPath(info.path)));
|
|
6350
|
+
return kept.length > 0 ? kept : infos;
|
|
6351
|
+
})()
|
|
6352
|
+
: infos;
|
|
6025
6353
|
const sessions = new Map();
|
|
6026
|
-
for (const s of
|
|
6354
|
+
for (const s of visibleInfos) {
|
|
6027
6355
|
sessions.set(s.path, {
|
|
6028
6356
|
path: s.path,
|
|
6029
6357
|
name: s.name,
|
|
@@ -6255,6 +6583,84 @@ export class ClientSession {
|
|
|
6255
6583
|
hasPendingWake: () => hasPendingWaitSubscription({ sessionId: conv.session.sessionFile }),
|
|
6256
6584
|
});
|
|
6257
6585
|
}
|
|
6586
|
+
/**
|
|
6587
|
+
* 将内存子代理(inMemory)固化为普通持久化对话:
|
|
6588
|
+
* 写入磁盘 .jsonl 会话文件,清除 isSubagent 标记,使它进入历史会话列表并长久保留。
|
|
6589
|
+
*/
|
|
6590
|
+
async persistConversation(id) {
|
|
6591
|
+
const conv = this.convs.get(id);
|
|
6592
|
+
if (!conv) {
|
|
6593
|
+
this.emit({
|
|
6594
|
+
type: "notice",
|
|
6595
|
+
level: "warning",
|
|
6596
|
+
text: "该对话不存在或已关闭",
|
|
6597
|
+
textEn: "This conversation does not exist or is already closed",
|
|
6598
|
+
});
|
|
6599
|
+
return;
|
|
6600
|
+
}
|
|
6601
|
+
const sm = conv.session.sessionManager;
|
|
6602
|
+
if (!conv.isSubagent && sm?.isPersisted?.()) {
|
|
6603
|
+
this.emit({
|
|
6604
|
+
type: "notice",
|
|
6605
|
+
level: "info",
|
|
6606
|
+
text: `对话「${conv.title}」已是持久化对话,无需固化`,
|
|
6607
|
+
textEn: `Conversation "${conv.title}" is already persistent`,
|
|
6608
|
+
});
|
|
6609
|
+
return;
|
|
6610
|
+
}
|
|
6611
|
+
try {
|
|
6612
|
+
const cwd = conv.cwd || this.cwd;
|
|
6613
|
+
const sampleSm = SessionManager.create(cwd);
|
|
6614
|
+
const sessionDir = sampleSm.getSessionDir();
|
|
6615
|
+
if (!existsSync(sessionDir)) {
|
|
6616
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
6617
|
+
}
|
|
6618
|
+
const timestamp = new Date().toISOString();
|
|
6619
|
+
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
6620
|
+
const sessionId = sm?.getSessionId?.() || randomUUID();
|
|
6621
|
+
const sessionFile = join(sessionDir, `${fileTimestamp}_${sessionId}.jsonl`);
|
|
6622
|
+
// 获取所有已存在的 entries 并写盘
|
|
6623
|
+
const entries = sm?.fileEntries ?? [];
|
|
6624
|
+
let entriesToWrite = entries;
|
|
6625
|
+
if (!entriesToWrite.some((e) => e?.type === "session")) {
|
|
6626
|
+
const header = {
|
|
6627
|
+
type: "session",
|
|
6628
|
+
version: 3,
|
|
6629
|
+
id: sessionId,
|
|
6630
|
+
timestamp,
|
|
6631
|
+
cwd,
|
|
6632
|
+
};
|
|
6633
|
+
entriesToWrite = [header, ...entriesToWrite];
|
|
6634
|
+
}
|
|
6635
|
+
writeFileSync(sessionFile, entriesToWrite.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf8");
|
|
6636
|
+
// 切换 SessionManager 内部状态,后续消息自动追加写盘
|
|
6637
|
+
if (sm) {
|
|
6638
|
+
sm.setSessionFile(sessionFile);
|
|
6639
|
+
sm.sessionDir = sessionDir;
|
|
6640
|
+
sm.persist = true;
|
|
6641
|
+
sm.flushed = true;
|
|
6642
|
+
}
|
|
6643
|
+
conv.isSubagent = false;
|
|
6644
|
+
this.emitConversations();
|
|
6645
|
+
await this.pushProjects();
|
|
6646
|
+
this.flushSnapshot();
|
|
6647
|
+
this.emit({
|
|
6648
|
+
type: "notice",
|
|
6649
|
+
level: "info",
|
|
6650
|
+
text: `已将子代理「${conv.title}」固化为普通对话,并保存至历史记录`,
|
|
6651
|
+
textEn: `Solidified subagent "${conv.title}" into a regular conversation saved to history`,
|
|
6652
|
+
});
|
|
6653
|
+
}
|
|
6654
|
+
catch (err) {
|
|
6655
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6656
|
+
this.emit({
|
|
6657
|
+
type: "notice",
|
|
6658
|
+
level: "error",
|
|
6659
|
+
text: `固化子代理失败:${msg}`,
|
|
6660
|
+
textEn: `Failed to persist subagent: ${msg}`,
|
|
6661
|
+
});
|
|
6662
|
+
}
|
|
6663
|
+
}
|
|
6258
6664
|
/** Dismiss a running conversation from the left-panel list without deleting its
|
|
6259
6665
|
* transcript file. Only idle (non-streaming) conversations that are not
|
|
6260
6666
|
* retained by terminal/wake/review state can be dismissed. The session stays
|
|
@@ -6843,6 +7249,7 @@ export class ClientSession {
|
|
|
6843
7249
|
// Preserve the model the user had selected — fork() seeds a new
|
|
6844
7250
|
// branch with the ModelRuntime default model otherwise.
|
|
6845
7251
|
const prevModel = this.session.agent.state.model ?? null;
|
|
7252
|
+
const prevThinking = this.session.thinkingLevel ?? null;
|
|
6846
7253
|
const result = await this.runtime.fork(entryId);
|
|
6847
7254
|
if (result.cancelled) {
|
|
6848
7255
|
this.emit({
|
|
@@ -6858,14 +7265,23 @@ export class ClientSession {
|
|
|
6858
7265
|
// Restore the previously-selected model on the forked branch.
|
|
6859
7266
|
if (prevModel && this.sharedModelRuntime) {
|
|
6860
7267
|
try {
|
|
6861
|
-
await this.session.setModel(prevModel);
|
|
6862
7268
|
const pm = prevModel;
|
|
7269
|
+
// 先恢复 provider key,再 setModel(否则 checkAuth 鉴权失败)
|
|
6863
7270
|
await this.restoreKeyForModel(`${pm.provider}/${pm.id}`, this.cwd);
|
|
7271
|
+
await this.session.setModel(prevModel);
|
|
6864
7272
|
}
|
|
6865
7273
|
catch {
|
|
6866
7274
|
// model no longer resolvable — keep the default
|
|
6867
7275
|
}
|
|
6868
7276
|
}
|
|
7277
|
+
if (prevThinking) {
|
|
7278
|
+
try {
|
|
7279
|
+
this.session.setThinkingLevel(prevThinking);
|
|
7280
|
+
}
|
|
7281
|
+
catch {
|
|
7282
|
+
// model no longer supports previous thinking level
|
|
7283
|
+
}
|
|
7284
|
+
}
|
|
6869
7285
|
await this.prompt(trimmed, attachments);
|
|
6870
7286
|
this.emit({
|
|
6871
7287
|
type: "notice",
|
|
@@ -7245,18 +7661,20 @@ export class ClientSession {
|
|
|
7245
7661
|
});
|
|
7246
7662
|
this.flushSnapshot();
|
|
7247
7663
|
// First visit to this project: resume its most recent session —
|
|
7248
|
-
// unless that transcript is
|
|
7664
|
+
// unless that transcript is still held on another client (#145):
|
|
7249
7665
|
// default-opening it would strand the tab on a conversation it
|
|
7250
|
-
// cannot use (the prompt guard refuses) with a
|
|
7251
|
-
// forks history once
|
|
7666
|
+
// cannot use (the prompt guard refuses while streaming) with a
|
|
7667
|
+
// stale leaf that forks history once both sides send (idle-held
|
|
7668
|
+
// files fork the same way — 第二个写者不只跑着时才危险). Land
|
|
7669
|
+
// blank instead.
|
|
7252
7670
|
let resumeSkipped = null;
|
|
7253
|
-
//
|
|
7254
|
-
if (this.
|
|
7671
|
+
// 别处无可见行时不扫目录(首访切项目的常见情形零开销)。
|
|
7672
|
+
if ((this.listExternalRunning?.() ?? []).length > 0) {
|
|
7255
7673
|
try {
|
|
7256
7674
|
const infos = await SessionManager.list(abs, piSessionsRoot());
|
|
7257
7675
|
const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
|
|
7258
7676
|
const owner = recent ? this.findSessionOwner?.(recent) : null;
|
|
7259
|
-
if (owner
|
|
7677
|
+
if (owner && (owner.connected || owner.isStreaming)) {
|
|
7260
7678
|
resumeSkipped = owner;
|
|
7261
7679
|
}
|
|
7262
7680
|
}
|
|
@@ -7290,12 +7708,19 @@ export class ClientSession {
|
|
|
7290
7708
|
}
|
|
7291
7709
|
await this.bindSession();
|
|
7292
7710
|
if (resumeSkipped) {
|
|
7293
|
-
this.emit(
|
|
7294
|
-
|
|
7295
|
-
|
|
7296
|
-
|
|
7297
|
-
|
|
7298
|
-
|
|
7711
|
+
this.emit(resumeSkipped.isStreaming
|
|
7712
|
+
? {
|
|
7713
|
+
type: "notice",
|
|
7714
|
+
level: "info",
|
|
7715
|
+
text: `该项目最近的对话「${resumeSkipped.title}」正在另一处运行,为你停在了新对话 —— 直接打开会造出第二个写者。左栏「运行的对话」里能看到它(标着“另一处”),等它跑完再打开。`,
|
|
7716
|
+
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.`,
|
|
7717
|
+
}
|
|
7718
|
+
: {
|
|
7719
|
+
type: "notice",
|
|
7720
|
+
level: "info",
|
|
7721
|
+
text: `该项目最近的对话「${resumeSkipped.title}」在另一处开着(当前空闲),为你停在了空白新对话 —— 可在左栏「运行的对话」里把它过户过来继续看,或从历史对话里打开(只留一处发送消息,否则历史分叉)。`,
|
|
7722
|
+
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).`,
|
|
7723
|
+
});
|
|
7299
7724
|
}
|
|
7300
7725
|
}
|
|
7301
7726
|
this.pushTerminals();
|
|
@@ -7442,8 +7867,9 @@ export class ClientSession {
|
|
|
7442
7867
|
const model = mr.getModel(provider, id);
|
|
7443
7868
|
if (!model)
|
|
7444
7869
|
throw new Error(`模型不存在:${modelId}`);
|
|
7445
|
-
|
|
7870
|
+
// 先恢复 provider key,再 setModel(否则 checkAuth 鉴权失败)
|
|
7446
7871
|
await this.restoreKeyForModel(modelId, this.cwd);
|
|
7872
|
+
await this.session.setModel(model);
|
|
7447
7873
|
// Immediately remember the model + the key it uses for the current
|
|
7448
7874
|
// project (not only after a turn). This is what makes project switching
|
|
7449
7875
|
// restore both the model and the provider key.
|
|
@@ -7480,6 +7906,13 @@ export class ClientSession {
|
|
|
7480
7906
|
const active = this.modelAdmin.getActiveKeyName(provider);
|
|
7481
7907
|
if (active)
|
|
7482
7908
|
this.stateStore.saveDefaultProviderKey(provider, active);
|
|
7909
|
+
// 同步写入 SDK 的 settingsManager,使底层 session 创建时 findInitialModel 也能识别该默认模型
|
|
7910
|
+
try {
|
|
7911
|
+
this.session.settingsManager.setDefaultModelAndProvider(provider, id);
|
|
7912
|
+
}
|
|
7913
|
+
catch {
|
|
7914
|
+
/* 会话未就绪时忽略 */
|
|
7915
|
+
}
|
|
7483
7916
|
this.pushDefaultModel();
|
|
7484
7917
|
this.emit({
|
|
7485
7918
|
type: "notice",
|
|
@@ -7501,6 +7934,12 @@ export class ClientSession {
|
|
|
7501
7934
|
/** Clear the GLOBAL default model (new projects fall back to the SDK default). */
|
|
7502
7935
|
clearDefaultModel() {
|
|
7503
7936
|
this.stateStore.clearDefaultModel();
|
|
7937
|
+
try {
|
|
7938
|
+
this.session.settingsManager.setDefaultModelAndProvider(undefined, undefined);
|
|
7939
|
+
}
|
|
7940
|
+
catch {
|
|
7941
|
+
/* 忽略 */
|
|
7942
|
+
}
|
|
7504
7943
|
this.pushDefaultModel();
|
|
7505
7944
|
this.emit({
|
|
7506
7945
|
type: "notice",
|
|
@@ -7536,7 +7975,12 @@ export class ClientSession {
|
|
|
7536
7975
|
/** Set the thinking level for future turns. */
|
|
7537
7976
|
setThinking(level) {
|
|
7538
7977
|
try {
|
|
7539
|
-
|
|
7978
|
+
const thinkingLevel = level;
|
|
7979
|
+
this.session.setThinkingLevel(thinkingLevel, { persist: true });
|
|
7980
|
+
const cur = this.session.model;
|
|
7981
|
+
if (cur) {
|
|
7982
|
+
this.session.settingsManager.setModelThinkingLevel(cur.provider, cur.id, thinkingLevel);
|
|
7983
|
+
}
|
|
7540
7984
|
}
|
|
7541
7985
|
catch (err) {
|
|
7542
7986
|
this.emit({
|
|
@@ -7550,7 +7994,11 @@ export class ClientSession {
|
|
|
7550
7994
|
}
|
|
7551
7995
|
cycleThinking() {
|
|
7552
7996
|
try {
|
|
7553
|
-
this.session.cycleThinkingLevel();
|
|
7997
|
+
const nextLevel = this.session.cycleThinkingLevel({ persist: true });
|
|
7998
|
+
const cur = this.session.model;
|
|
7999
|
+
if (cur && nextLevel) {
|
|
8000
|
+
this.session.settingsManager.setModelThinkingLevel(cur.provider, cur.id, nextLevel);
|
|
8001
|
+
}
|
|
7554
8002
|
}
|
|
7555
8003
|
catch (err) {
|
|
7556
8004
|
this.emit({
|
|
@@ -8492,17 +8940,19 @@ export class AgentService {
|
|
|
8492
8940
|
}
|
|
8493
8941
|
}
|
|
8494
8942
|
// Sessions use the SDK default per-project dir — no per-client dir.
|
|
8495
|
-
// issue #145:新标签页默认恢复项目最近的会话 ——
|
|
8496
|
-
//
|
|
8497
|
-
//
|
|
8943
|
+
// issue #145:新标签页默认恢复项目最近的会话 —— 若那条仍被别处持有
|
|
8944
|
+
// (跑着或空闲),建之前就决定空白(第二个 writer 根本不会被打开,
|
|
8945
|
+
// 也无需事后拆 runtime)。之前只拦 running:空闲持有照样恢复出双
|
|
8946
|
+
// writer,两边轮流发送分叉历史。其他客户端不存在时不扫目录。
|
|
8498
8947
|
let createOpts;
|
|
8499
|
-
if (this.
|
|
8948
|
+
if (this.clients.size > 0) {
|
|
8500
8949
|
try {
|
|
8501
8950
|
const infos = await SessionManager.list(cwd, piSessionsRoot());
|
|
8502
8951
|
const recent = infos[0]?.path ? resolve(infos[0].path) : undefined;
|
|
8503
8952
|
const owner = recent ? this.findSessionOwner(recent, clientId) : null;
|
|
8504
|
-
if (owner
|
|
8505
|
-
createOpts = { blank: true, blankTitle: owner.title };
|
|
8953
|
+
if (owner && (owner.connected || owner.isStreaming)) {
|
|
8954
|
+
createOpts = { blank: true, blankTitle: owner.title, ...(owner.isStreaming ? {} : { idleHeld: true }) };
|
|
8955
|
+
}
|
|
8506
8956
|
}
|
|
8507
8957
|
catch {
|
|
8508
8958
|
// 列表失败不挡正常恢复
|