pi-web-ui 0.68.1 → 0.69.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/dist/server/agent-service.js +306 -102
- package/dist/server/attachments.js +7 -2
- package/dist/server/client-state.js +31 -2
- package/dist/server/dsh/dsh-agent-service.js +154 -39
- package/dist/server/dsh/dsh-client.js +9 -8
- package/dist/server/dsh/dsh-sessions.js +4 -3
- package/dist/server/dsh/runtime/runtime-root.mjs +17 -11
- package/dist/server/edit-soft-tool.js +33 -26
- package/dist/server/files-service.js +12 -7
- package/dist/server/goal-service.js +85 -24
- package/dist/server/i18n.js +157 -0
- package/dist/server/index.js +124 -6
- package/dist/server/locales.js +210 -0
- package/dist/server/managed.js +61 -0
- package/dist/server/marker-service.js +20 -7
- package/dist/server/markers/builtins/notify.js +19 -6
- package/dist/server/markers/builtins/rename.js +41 -8
- package/dist/server/markers/builtins/todo.js +107 -30
- package/dist/server/markers/registry.js +2 -2
- package/dist/server/mcp-bridge.js +3 -1
- package/dist/server/model-admin.js +25 -14
- package/dist/server/plugin-catalog.js +7 -3
- package/dist/server/plugin-updater.js +6 -2
- package/dist/server/plugins.js +40 -17
- package/dist/server/prompt-composer.js +76 -16
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/scm.js +18 -25
- package/dist/server/serialize.js +1 -0
- package/dist/server/settings-service.js +29 -1
- package/dist/server/subagent-templates.js +105 -0
- package/dist/server/subagents.js +155 -54
- package/dist/server/tabs.js +87 -0
- package/dist/server/terminals.js +88 -48
- package/dist/server/update-check.js +56 -52
- package/dist/server/vision-bridge.js +34 -12
- package/package.json +4 -1
- package/web/dist/assets/TerminalPanel-Cj8zsjx-.js +6 -0
- package/web/dist/assets/TerminalPanel-DOrYoP_4.css +32 -0
- package/web/dist/assets/index-DCOcsPFm.js +334 -0
- package/web/dist/assets/index-jH2Bb-0X.css +10 -0
- package/web/dist/assets/markdown-Cpo0pNcR.js +51 -0
- package/web/dist/assets/{react-C9ovnpIm.js → react-CtudoG1_.js} +2 -2
- package/web/dist/assets/xterm-B96xOxS9.js +38 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +0 -32
- package/web/dist/assets/TerminalPanel-IJF_fssI.js +0 -6
- package/web/dist/assets/index-BmiyyjKp.css +0 -10
- package/web/dist/assets/index-qoTr5KXy.js +0 -332
- package/web/dist/assets/markdown-DRBrS2Nf.js +0 -51
- package/web/dist/assets/xterm-D1D2FVe3.js +0 -38
|
@@ -14,7 +14,7 @@ import { spawn } from "node:child_process";
|
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { createRequire } from "node:module";
|
|
16
16
|
import { existsSync, readFileSync, rmSync, statSync, mkdirSync, watch } from "node:fs";
|
|
17
|
-
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
17
|
+
import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, getAgentDir, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
20
20
|
import { Type } from "typebox";
|
|
@@ -28,14 +28,15 @@ import { MarkerService } from "./marker-service.js";
|
|
|
28
28
|
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
29
29
|
import { ModelAdminService } from "./model-admin.js";
|
|
30
30
|
import { FilesService, MACHINE_ROOT, workspacePath } from "./files-service.js";
|
|
31
|
-
import { isExtensionDisabled, isExtensionEnabled, ClientStateStore } from "./client-state.js";
|
|
32
|
-
import {
|
|
31
|
+
import { isExtensionDisabled, isExtensionEnabled, normalizeRetryMaxAttempts, ClientStateStore, } from "./client-state.js";
|
|
32
|
+
import { pick, resolveServerLang } from "./i18n.js";
|
|
33
|
+
import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
|
|
33
34
|
import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
|
|
34
35
|
import { WebUIContext, mockThemeProxy } from "./webui-context.js";
|
|
35
36
|
import { makeEditSoftTool, SOFT_EDIT_TOOL_NAME } from "./edit-soft-tool.js";
|
|
36
|
-
import { makeSubagentTools, subagentTitle, } from "./subagents.js";
|
|
37
|
+
import { makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
|
|
37
38
|
import { buildAttachmentMessages } from "./attachments.js";
|
|
38
|
-
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
39
|
+
import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
|
|
39
40
|
import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
|
|
40
41
|
import { loadCommands, saveCommandsFile, TerminalManager } from "./terminals.js";
|
|
41
42
|
const SNAPSHOT_INTERVAL_MS = 60;
|
|
@@ -196,11 +197,13 @@ function makeMarkersListTool(getActiveId, markerSvc) {
|
|
|
196
197
|
return {
|
|
197
198
|
name: "markers_list",
|
|
198
199
|
label: "List marker state",
|
|
199
|
-
description: "只读查询内联标记状态。状态【写】操作请一律用内联标记([[todo:new:...]] 等)写在回答正文里,不要调用本工具做写操作。",
|
|
200
|
+
description: "Read-only query of inline marker state. All WRITE operations must use inline markers ([[todo:new:...]] etc.) in the reply body — never use this tool for writes.\n只读查询内联标记状态。状态【写】操作请一律用内联标记([[todo:new:...]] 等)写在回答正文里,不要调用本工具做写操作。",
|
|
200
201
|
parameters: Type.Object({
|
|
201
202
|
action: Type.Unsafe({ enum: ["list"] }),
|
|
202
203
|
tool: Type.Optional(Type.Literal("todo")),
|
|
203
|
-
includeDeleted: Type.Optional(Type.Boolean({
|
|
204
|
+
includeDeleted: Type.Optional(Type.Boolean({
|
|
205
|
+
description: "Whether to include deleted tasks (tombstones, todo only).\n是否包含已删除任务(tombstone,仅 todo)。",
|
|
206
|
+
})),
|
|
204
207
|
}),
|
|
205
208
|
execute: async (_id, params) => {
|
|
206
209
|
const p = params;
|
|
@@ -215,6 +218,67 @@ function makeMarkersListTool(getActiveId, markerSvc) {
|
|
|
215
218
|
},
|
|
216
219
|
};
|
|
217
220
|
}
|
|
221
|
+
/**
|
|
222
|
+
* 标准 pi 引擎的 ask_user_question 工具:模型调用时把问题桥到浏览器(复用 DSH
|
|
223
|
+
* 引擎的 question_pending/question_answer 协议,前端 DshQuestionDialog 富渲染),
|
|
224
|
+
* 阻塞 agent 循环直到用户在浏览器回答或取消。
|
|
225
|
+
*
|
|
226
|
+
* 标准 SDK 没有内建 ask_user_question,故由 pi-web-ui 以 customTool 注册(与
|
|
227
|
+
* bash/edit 同机制)。DSH 引擎走 goal-rpc 的 userQuestions provider,两者互不
|
|
228
|
+
* 冲突(各引擎各走各的)。
|
|
229
|
+
*
|
|
230
|
+
* askUser 签名带 {aborted} 快照而非完整 AbortSignal:customTool 的 execute 信号
|
|
231
|
+
* 服务于整个 agent 生命周期,这里按「已中止即拒绝」的最小语义处理,避免与其它
|
|
232
|
+
* 工具的取消逻辑纠缠。
|
|
233
|
+
*/
|
|
234
|
+
export function makeAskUserQuestionTool(clientSession) {
|
|
235
|
+
const QuestionOptionSchema = Type.Object({
|
|
236
|
+
label: Type.String({ description: "Display label for the option" }),
|
|
237
|
+
description: Type.Optional(Type.String({ description: "Optional description shown below label" })),
|
|
238
|
+
preview: Type.Optional(Type.String({
|
|
239
|
+
description: "Optional preview rendered below when this option is selected (markdown or HTML — use for mockups/code/config).",
|
|
240
|
+
})),
|
|
241
|
+
});
|
|
242
|
+
const QuestionSchema = Type.Object({
|
|
243
|
+
id: Type.String({ description: "Unique identifier for this question" }),
|
|
244
|
+
question: Type.String({ description: "The full question text to display (markdown/HTML ok)" }),
|
|
245
|
+
detail: Type.Optional(Type.String({ description: "Optional detail/context shown under the question" })),
|
|
246
|
+
header: Type.Optional(Type.String({ description: "Optional short header for this question" })),
|
|
247
|
+
options: Type.Optional(Type.Array(QuestionOptionSchema, { description: "Available options to choose from" })),
|
|
248
|
+
multiSelect: Type.Optional(Type.Boolean({ description: "Allow selecting multiple options (default: false)" })),
|
|
249
|
+
});
|
|
250
|
+
return {
|
|
251
|
+
name: "ask_user_question",
|
|
252
|
+
label: "Ask the user",
|
|
253
|
+
description: "Ask the user focused questions to pin down ambiguous requirements. Use for clarifying the task, confirming decisions, or getting preferences. Each question renders a browser dialog with markdown/HTML rich text; options may carry a `preview`. Submit or cancel to resume.",
|
|
254
|
+
parameters: Type.Object({
|
|
255
|
+
questions: Type.Array(QuestionSchema, { description: "Questions to ask the user" }),
|
|
256
|
+
}),
|
|
257
|
+
execute: async (_id, params, signal) => {
|
|
258
|
+
const qs = params.questions;
|
|
259
|
+
if (!Array.isArray(qs) || qs.length === 0) {
|
|
260
|
+
throw new Error("ask_user_question requires at least one question");
|
|
261
|
+
}
|
|
262
|
+
const answers = await clientSession.askUser(qs, {
|
|
263
|
+
aborted: signal?.aborted,
|
|
264
|
+
});
|
|
265
|
+
if (answers === null) {
|
|
266
|
+
throw new Error("User cancelled the question.\n用户取消了提问。");
|
|
267
|
+
}
|
|
268
|
+
// 工具结果:把每道题的回答拼成简洁文本给模型,同时留 details 供 UI 展示。
|
|
269
|
+
const lines = answers.map((a) => {
|
|
270
|
+
const q = qs.find((q) => q.id === a.id);
|
|
271
|
+
const label = a.selected.join(", ");
|
|
272
|
+
const custom = a.custom?.trim() ? ` (wrote: ${a.custom.trim()})` : "";
|
|
273
|
+
return `${q?.header ?? q?.id ?? a.id}: ${label || "(no selection)"}${custom}`;
|
|
274
|
+
});
|
|
275
|
+
return {
|
|
276
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
277
|
+
details: { answers },
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
};
|
|
281
|
+
}
|
|
218
282
|
/**
|
|
219
283
|
* 插件结构化工具 → SDK ToolDefinition。
|
|
220
284
|
* execute 返回值宽容处理:{content,details} 原样收编;字符串/对象包成文本块。
|
|
@@ -514,6 +578,8 @@ export class ClientSession {
|
|
|
514
578
|
isDisposed: () => this.disposed,
|
|
515
579
|
getCwd: () => this.cwd,
|
|
516
580
|
getActiveCwd: () => this.conv?.cwd ?? this.cwd,
|
|
581
|
+
// issue #91:文件服务错误文案按客户端 UI 语言出中英(英文默认)。
|
|
582
|
+
getLang: () => this.getLang(),
|
|
517
583
|
});
|
|
518
584
|
bg = new BgServerTracker({
|
|
519
585
|
emit: (msg) => this.emit(msg),
|
|
@@ -568,7 +634,9 @@ export class ClientSession {
|
|
|
568
634
|
return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
|
|
569
635
|
}
|
|
570
636
|
makeTerminalManager(conversationId, cwd) {
|
|
571
|
-
const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd
|
|
637
|
+
const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd,
|
|
638
|
+
// issue #91:终端输入错误按客户端 UI 语言出中英(英文默认)。
|
|
639
|
+
() => this.getLang());
|
|
572
640
|
// 终端活力检测:AI 触碰过的终端静默 ≥ 阈值(PI_WEB_TERMINAL_IDLE_MS,
|
|
573
641
|
// 默认 15s)且该对话正在运行时,注入一条 steer 消息唤醒 AI 去检查。
|
|
574
642
|
mgr.onAgentIdle = (terminalId, idleMs, title, lastLines) => this.notifyTerminalIdle(conversationId, terminalId, idleMs, title, lastLines);
|
|
@@ -638,18 +706,20 @@ export class ClientSession {
|
|
|
638
706
|
* `model`(可选):"provider/id",显式指定子代理模型。不传时由调用方决定是否
|
|
639
707
|
* 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
|
|
640
708
|
* runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
|
|
641
|
-
async spawnSubagentConversation(prompt, type, cwd, apply, model) {
|
|
709
|
+
async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId) {
|
|
642
710
|
const conversationId = `sa-${randomUUID().slice(0, 8)}`;
|
|
643
711
|
const terminals = this.makeTerminalManager(conversationId, cwd);
|
|
644
|
-
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply), {
|
|
712
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
|
|
645
713
|
cwd,
|
|
646
714
|
agentDir: this.agentDir,
|
|
647
715
|
sessionManager: SessionManager.inMemory(cwd),
|
|
648
716
|
});
|
|
649
717
|
const conv = this.makeConversation(runtime, conversationId, terminals);
|
|
650
718
|
conv.isSubagent = true;
|
|
651
|
-
//
|
|
652
|
-
|
|
719
|
+
// 父对话 = 真正派发它的会话(按会话归属的 host 包装填入)。直接用 active
|
|
720
|
+
// 会错:后台对话运行时用户可能正看着别的项目对话,孩子会被记到无关
|
|
721
|
+
// 对话名下、沉到别的项目组底部(issue #95)。缺省才回退到 active。
|
|
722
|
+
conv.parentId = parentId ?? this.activeId ?? undefined;
|
|
653
723
|
conv.subagentType = type;
|
|
654
724
|
conv.listed = true;
|
|
655
725
|
conv.title = subagentTitle(prompt);
|
|
@@ -842,6 +912,8 @@ export class ClientSession {
|
|
|
842
912
|
windowsPersona: process.platform === "win32" ? WINDOWS_PERSONA : "",
|
|
843
913
|
terminalGuidance: this.settingsSvc.current.terminalToolsEnabled !== false ? TERMINAL_TOOLS_GUIDANCE : "",
|
|
844
914
|
markersGuidance: this.markerSvc.buildGuidance(),
|
|
915
|
+
// issue #91:组合模板各来源段按客户端 UI 语言渲染(英文默认)。
|
|
916
|
+
lang: this.getLang(),
|
|
845
917
|
contextFiles: src.contextFiles,
|
|
846
918
|
skills: src.skills,
|
|
847
919
|
};
|
|
@@ -868,6 +940,7 @@ export class ClientSession {
|
|
|
868
940
|
const active = sess.getActiveToolNames();
|
|
869
941
|
const snippets = {};
|
|
870
942
|
const guidelines = [];
|
|
943
|
+
const schemaEntries = [];
|
|
871
944
|
for (const name of active) {
|
|
872
945
|
const def = sess.getToolDefinition(name);
|
|
873
946
|
if (!def)
|
|
@@ -876,6 +949,11 @@ export class ClientSession {
|
|
|
876
949
|
snippets[name] = def.promptSnippet;
|
|
877
950
|
if (def.promptGuidelines)
|
|
878
951
|
guidelines.push(...def.promptGuidelines);
|
|
952
|
+
schemaEntries.push({
|
|
953
|
+
name,
|
|
954
|
+
description: def.description,
|
|
955
|
+
parameters: def.parameters,
|
|
956
|
+
});
|
|
879
957
|
}
|
|
880
958
|
const loader = sess.resourceLoader;
|
|
881
959
|
const texts = resolveSectionTexts(this.composeInputs({
|
|
@@ -895,7 +973,7 @@ export class ClientSession {
|
|
|
895
973
|
const ovs = this.settingsSvc.current.promptOverrides ?? {};
|
|
896
974
|
const hasOverride = Object.values(ovs).some((v) => typeof v === "string" && v.trim());
|
|
897
975
|
const rendered = !tpl && !hasOverride ? undefined : renderPromptTemplate(tpl || DEFAULT_PROMPT_TEMPLATE, texts, ovs);
|
|
898
|
-
return { texts, full: rendered ?? sess.systemPrompt };
|
|
976
|
+
return { texts, full: rendered ?? sess.systemPrompt, toolsSchema: buildToolsSchemaText(schemaEntries) };
|
|
899
977
|
}
|
|
900
978
|
catch {
|
|
901
979
|
// Session not ready yet.
|
|
@@ -905,7 +983,7 @@ export class ClientSession {
|
|
|
905
983
|
/** 设置面板预览用的 host 回调(见 SettingsHost.promptSnapshot):完整生效提示词
|
|
906
984
|
* + 各来源默认(自动)内容。会话未就绪时给空值,面板保持可编辑但不预览。 */
|
|
907
985
|
promptSnapshot() {
|
|
908
|
-
return this.sessionPromptSnapshot() ?? { full: "", texts: {} };
|
|
986
|
+
return this.sessionPromptSnapshot() ?? { full: "", texts: {}, toolsSchema: "" };
|
|
909
987
|
}
|
|
910
988
|
/** Web-facing extension UI context (widgets, notifications). */
|
|
911
989
|
webUi = new WebUIContext((msg) => this.emit(msg));
|
|
@@ -916,14 +994,14 @@ export class ClientSession {
|
|
|
916
994
|
* 切换查看 / 输入补充(steer)/ 中止(abort)/ 移出全部复用现有对话机制。
|
|
917
995
|
*/
|
|
918
996
|
subagentHost = {
|
|
919
|
-
spawnSubagent: (prompt, type, cwd, templateName, model) => {
|
|
997
|
+
spawnSubagent: (prompt, type, cwd, templateName, model, parentId) => {
|
|
920
998
|
// 模板:存在且启用时应用;传了名字但不可用 → 抛错让工具转给 AI。
|
|
921
999
|
const tpl = templateName ? this.subagentTemplates.get(templateName) : undefined;
|
|
922
1000
|
if (templateName && (!tpl || !tpl.enabled)) {
|
|
923
|
-
throw new Error(`子代理模板不可用:${templateName}
|
|
1001
|
+
throw new Error(pick(this.getLang(), `子代理模板不可用:${templateName}(不存在或已停用)`, `Subagent template unavailable: ${templateName} (missing or disabled)`, "agent.subagent.template.unavailable", { templateName: templateName }));
|
|
924
1002
|
}
|
|
925
1003
|
// 模型优先级:显式 model 参数 > 模板自带模型 > 设置面板默认模型;都不给 = 跟随主对话。
|
|
926
|
-
return this.spawnSubagentConversation(prompt, type, cwd, tpl, model);
|
|
1004
|
+
return this.spawnSubagentConversation(prompt, type, cwd, tpl, model, parentId);
|
|
927
1005
|
},
|
|
928
1006
|
getSubagent: (convId) => this.getSubagentSnapshot(convId),
|
|
929
1007
|
listSubagents: () => this.listSubagentSnapshots(),
|
|
@@ -936,14 +1014,16 @@ export class ClientSession {
|
|
|
936
1014
|
stopSubagent: async (convId) => {
|
|
937
1015
|
const conv = this.convs.get(convId);
|
|
938
1016
|
if (conv && (conv.session.isStreaming || !conv.session.isIdle)) {
|
|
939
|
-
await this.interruptRun(conv, "用户停止子代理");
|
|
1017
|
+
await this.interruptRun(conv, pick(this.getLang(), "用户停止子代理", "User stopped the subagent", "agent.subagent.stop.user"));
|
|
940
1018
|
}
|
|
941
1019
|
},
|
|
1020
|
+
// issue #91:子代理工具返回按客户端 UI 语言出中英(英文默认)。
|
|
1021
|
+
lang: () => this.getLang(),
|
|
942
1022
|
// 只向 AI 暴露 enabled 的模板(停用的对 AI 不可见)。
|
|
943
1023
|
listTemplates: () => this.subagentTemplates
|
|
944
1024
|
.list()
|
|
945
1025
|
.filter((t) => t.enabled)
|
|
946
|
-
.map((t) => ({ name: t.name, description: t.description, model: t.model })),
|
|
1026
|
+
.map((t) => ({ name: t.name, description: t.description, descriptionEn: t.descriptionEn, model: t.model })),
|
|
947
1027
|
isTemplateUsable: (name) => {
|
|
948
1028
|
const t = this.subagentTemplates.get(name);
|
|
949
1029
|
return !!t && t.enabled;
|
|
@@ -1000,6 +1080,14 @@ export class ClientSession {
|
|
|
1000
1080
|
subagentTemplates;
|
|
1001
1081
|
/** 内置标记服务(todo/notify/svc/rename 等,可全局/分组开关)。 */
|
|
1002
1082
|
markerSvc;
|
|
1083
|
+
// -----------------------------------------------------------------------
|
|
1084
|
+
// 用户提问桥(标准 pi 引擎的 ask_user_question customTool):与 DSH 引擎的
|
|
1085
|
+
// question_pending/question_answer 同协议。模型调 ask_user_question 工具 →
|
|
1086
|
+
// 本桥发 question_pending 给浏览器 → 等 question_answer → resolve/reject
|
|
1087
|
+
// 工具结果(agent 循环阻塞)。一次只展示一个提问(agent 阻塞在工具执行)。
|
|
1088
|
+
// -----------------------------------------------------------------------
|
|
1089
|
+
questionSeq = 0;
|
|
1090
|
+
pendingQuestions = new Map();
|
|
1003
1091
|
constructor(clientId, cwd, agentDir, stateStore) {
|
|
1004
1092
|
this.clientId = clientId;
|
|
1005
1093
|
this.cwd = cwd;
|
|
@@ -1022,6 +1110,8 @@ export class ClientSession {
|
|
|
1022
1110
|
// 复用现有重命名路径(内存标题 + 磁盘 session_info)
|
|
1023
1111
|
void this.renameConversation(convId, title);
|
|
1024
1112
|
},
|
|
1113
|
+
// issue #91:标记引导/错误按客户端 UI 语言出中英(英文默认)。
|
|
1114
|
+
lang: () => this.getLang(),
|
|
1025
1115
|
});
|
|
1026
1116
|
this.settingsSvc = new SettingsService({
|
|
1027
1117
|
clientId,
|
|
@@ -1035,10 +1125,14 @@ export class ClientSession {
|
|
|
1035
1125
|
isStreaming: () => this.session.isStreaming,
|
|
1036
1126
|
reloadSession: async () => {
|
|
1037
1127
|
await this.session.reload();
|
|
1128
|
+
// reload() 重读磁盘 settings.json,会丢掉内存 applyOverrides
|
|
1129
|
+
// (含重试次数覆盖)——依次重放:重试覆盖 → 终端门控。
|
|
1130
|
+
this.applyRetryOverrides();
|
|
1038
1131
|
// reload() 会把 custom 工具重新加回活跃集——重放终端开关。
|
|
1039
1132
|
this.applyToolGating(this.session);
|
|
1040
1133
|
await this.pushSlashCommands();
|
|
1041
1134
|
},
|
|
1135
|
+
applyRetryOverrides: () => this.applyRetryOverrides(),
|
|
1042
1136
|
promptSnapshot: () => this.promptSnapshot(),
|
|
1043
1137
|
getMarkerState: () => ({
|
|
1044
1138
|
markersEnabled: this.markerSvc.current.markersEnabled,
|
|
@@ -1055,6 +1149,10 @@ export class ClientSession {
|
|
|
1055
1149
|
flushSnapshot: () => this.flushSnapshot(),
|
|
1056
1150
|
isDisposed: () => this.disposed,
|
|
1057
1151
|
quiesceBlocked: () => this.quiesceBlocked(),
|
|
1152
|
+
// issue #91:目标/审查文案按客户端 UI 语言出中英(英文默认)。
|
|
1153
|
+
lang: () => this.getLang(),
|
|
1154
|
+
// 目标模式总开关(设置面板「目标审查」页):关 → 目标入口一律拒绝。
|
|
1155
|
+
goalModeEnabled: () => this.settingsSvc.current.goalModeEnabled !== false,
|
|
1058
1156
|
activeConvId: () => this.activeId,
|
|
1059
1157
|
activeConv: () => this.conv,
|
|
1060
1158
|
getConv: (id) => this.convs.get(id),
|
|
@@ -1082,7 +1180,7 @@ export class ClientSession {
|
|
|
1082
1180
|
const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
|
|
1083
1181
|
const conversationId = cs.nextConversationId();
|
|
1084
1182
|
const terminals = cs.makeTerminalManager(conversationId, cwd);
|
|
1085
|
-
const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals), {
|
|
1183
|
+
const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals, undefined, conversationId), {
|
|
1086
1184
|
cwd,
|
|
1087
1185
|
agentDir,
|
|
1088
1186
|
// Resume the most recent session for this project — the SDK default
|
|
@@ -1120,7 +1218,7 @@ export class ClientSession {
|
|
|
1120
1218
|
* 应用(prompt replace/append + 白名单),其余(终端接管、Windows persona
|
|
1121
1219
|
* 等)仍跟随主会话设置。undefined = 按主会话设置(普通对话/不选模板的子代理)。
|
|
1122
1220
|
*/
|
|
1123
|
-
makeRuntimeFactory(terminals, apply) {
|
|
1221
|
+
makeRuntimeFactory(terminals, apply, ownerId) {
|
|
1124
1222
|
return async ({ cwd: effectiveCwd, sessionManager }) => {
|
|
1125
1223
|
const services = await createAgentSessionServices({
|
|
1126
1224
|
cwd: effectiveCwd,
|
|
@@ -1143,8 +1241,8 @@ export class ClientSession {
|
|
|
1143
1241
|
systemPromptOverride: (base) => {
|
|
1144
1242
|
if (typeof base === "string" && base) {
|
|
1145
1243
|
this.lastBaseSystemPrompt = base;
|
|
1146
|
-
if (apply && apply.promptMode === "replace" && apply.
|
|
1147
|
-
return apply.
|
|
1244
|
+
if (apply && apply.promptMode === "replace" && pickTemplatePrompt(apply, this.getLang()).trim()) {
|
|
1245
|
+
return pickTemplatePrompt(apply, this.getLang());
|
|
1148
1246
|
}
|
|
1149
1247
|
}
|
|
1150
1248
|
return undefined;
|
|
@@ -1154,8 +1252,8 @@ export class ClientSession {
|
|
|
1154
1252
|
if (!apply)
|
|
1155
1253
|
this.lastSdkAppendFiles = base.slice();
|
|
1156
1254
|
const out = [...base];
|
|
1157
|
-
if (apply && apply.promptMode === "append" && apply.
|
|
1158
|
-
out.push(apply.
|
|
1255
|
+
if (apply && apply.promptMode === "append" && pickTemplatePrompt(apply, this.getLang()).trim()) {
|
|
1256
|
+
out.push(pickTemplatePrompt(apply, this.getLang()));
|
|
1159
1257
|
}
|
|
1160
1258
|
// 主会话自定义「追加」已并入组合模板的 {{append}} 覆盖,不再在此注入。
|
|
1161
1259
|
if (process.platform === "win32") {
|
|
@@ -1219,12 +1317,12 @@ export class ClientSession {
|
|
|
1219
1317
|
// 把灵魂段换成模板提示词,自动段保留;SYSTEM.md 情形已在
|
|
1220
1318
|
// systemPromptOverride 整体替换,此处边界不存在会自然跳过。
|
|
1221
1319
|
if (apply) {
|
|
1222
|
-
if (apply.promptMode !== "replace" || !apply.
|
|
1320
|
+
if (apply.promptMode !== "replace" || !pickTemplatePrompt(apply, this.getLang()).trim())
|
|
1223
1321
|
return undefined;
|
|
1224
1322
|
const boundary = event.systemPrompt.indexOf("\n\nAvailable tools:");
|
|
1225
1323
|
if (boundary === -1)
|
|
1226
1324
|
return undefined;
|
|
1227
|
-
const swapped = apply.
|
|
1325
|
+
const swapped = pickTemplatePrompt(apply, this.getLang()).trimEnd() + event.systemPrompt.slice(boundary);
|
|
1228
1326
|
return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
|
|
1229
1327
|
}
|
|
1230
1328
|
// 主会话:组合模板渲染(模板为空且无覆盖时返回 undefined = 用 SDK 默认)。
|
|
@@ -1262,20 +1360,32 @@ export class ClientSession {
|
|
|
1262
1360
|
idleMs: () => Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) || 0),
|
|
1263
1361
|
kills: this.bashKills,
|
|
1264
1362
|
notifyBackgroundDone: (info) => this.notifyTerminalBashDone(terminals, info),
|
|
1363
|
+
// issue #91:bash 返回按客户端 UI 语言出中英(英文默认)。
|
|
1364
|
+
lang: () => this.getLang(),
|
|
1265
1365
|
}),
|
|
1266
1366
|
// 设置关 → 原生 bash;开 → 终端 bash。
|
|
1267
1367
|
() => this.settingsSvc.current.terminalBash),
|
|
1268
|
-
...makePersistentTerminalTools(terminals, effectiveCwd),
|
|
1368
|
+
...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang()),
|
|
1269
1369
|
// 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置)。
|
|
1270
|
-
makeEditSoftTool(effectiveCwd),
|
|
1370
|
+
makeEditSoftTool(effectiveCwd, () => this.getLang()),
|
|
1271
1371
|
// 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
|
|
1272
1372
|
// refreshPluginTools 动态补入已有会话)。
|
|
1273
1373
|
...(this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition),
|
|
1274
1374
|
// 第一方子代理工具(spawn/get_result/steer/list/stop)。子代理会话
|
|
1275
|
-
// 也注册了它们,因此可自然嵌套派发。
|
|
1276
|
-
|
|
1375
|
+
// 也注册了它们,因此可自然嵌套派发。host 按 ownerId 包装:子代理的
|
|
1376
|
+
// 父对话 = 真正调用 spawn 的那个会话(本 runtime 所属会话),而不是
|
|
1377
|
+
// 派发瞬间的 active——后台对话继续产出时用户可能已切到别的项目,用
|
|
1378
|
+
// activeId 会把孩子记到无关会话名下、沉到别的组/底部(issue #95)。
|
|
1379
|
+
// ownerId 即本 runtime 所属会话(创建时就已知,见各调用点)。
|
|
1380
|
+
...(ownerId
|
|
1381
|
+
? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId))
|
|
1382
|
+
: makeSubagentTools(this.subagentHost)),
|
|
1277
1383
|
// 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
|
|
1278
1384
|
makeMarkersListTool(() => this.activeId, this.markerSvc),
|
|
1385
|
+
// 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
|
|
1386
|
+
// 的 question_pending/question_answer 协议,前端 DshQuestionDialog)。
|
|
1387
|
+
// DSH 引擎不经此(它走 goal-rpc 的 userQuestions provider)。
|
|
1388
|
+
makeAskUserQuestionTool(this),
|
|
1279
1389
|
],
|
|
1280
1390
|
});
|
|
1281
1391
|
// 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
|
|
@@ -1697,18 +1807,17 @@ export class ClientSession {
|
|
|
1697
1807
|
conv.queueSteering = [...event.steering];
|
|
1698
1808
|
conv.queueFollowUp = [...event.followUp];
|
|
1699
1809
|
break;
|
|
1700
|
-
// 手动 /compact
|
|
1701
|
-
//
|
|
1810
|
+
// 手动 /compact 或阈值/溢出自动压缩开始——常驻进度条(快照 compaction
|
|
1811
|
+
// 字段),而不是一次性 toast(toast 几秒就消失,而摘要生成可能持续
|
|
1812
|
+
// 数十秒,用户会以为「没反应」)。立即 flush 让进度条第一时间出现。
|
|
1702
1813
|
case "compaction_start": {
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
text: "正在压缩上下文…(压缩摘要将显示在消息区)",
|
|
1707
|
-
textEn: "Compacting context… (the summary will appear in the message list)",
|
|
1708
|
-
});
|
|
1814
|
+
conv.compactionState = { reason: event.reason, startedAt: Date.now() };
|
|
1815
|
+
conv.lastCompactionTokens = null;
|
|
1816
|
+
this.flushSnapshot();
|
|
1709
1817
|
break;
|
|
1710
1818
|
}
|
|
1711
1819
|
case "compaction_end": {
|
|
1820
|
+
conv.compactionState = null;
|
|
1712
1821
|
if (event.errorMessage) {
|
|
1713
1822
|
this.emit({
|
|
1714
1823
|
type: "notice",
|
|
@@ -1728,6 +1837,8 @@ export class ClientSession {
|
|
|
1728
1837
|
else if (event.result) {
|
|
1729
1838
|
const { tokensBefore, estimatedTokensAfter } = event.result;
|
|
1730
1839
|
const after = estimatedTokensAfter ?? tokensBefore;
|
|
1840
|
+
// 记住压缩后大小:SDK 在下轮响应前报 null,快照用此回填底栏。
|
|
1841
|
+
conv.lastCompactionTokens = estimatedTokensAfter ?? null;
|
|
1731
1842
|
this.emit({
|
|
1732
1843
|
type: "notice",
|
|
1733
1844
|
level: "info",
|
|
@@ -2065,13 +2176,25 @@ export class ClientSession {
|
|
|
2065
2176
|
totalMessages: s.totalMessages,
|
|
2066
2177
|
tokens: s.tokens,
|
|
2067
2178
|
cost: s.cost,
|
|
2068
|
-
contextUsage:
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2179
|
+
contextUsage: (() => {
|
|
2180
|
+
const cu = s.contextUsage;
|
|
2181
|
+
if (!cu)
|
|
2182
|
+
return stats.contextUsage;
|
|
2183
|
+
// 压缩刚结束、下轮响应未到:SDK 报 null,用压缩结果回填约数。
|
|
2184
|
+
if (cu.tokens == null && conv.lastCompactionTokens != null && cu.contextWindow > 0) {
|
|
2185
|
+
return {
|
|
2186
|
+
tokens: conv.lastCompactionTokens,
|
|
2187
|
+
contextWindow: cu.contextWindow,
|
|
2188
|
+
percent: (conv.lastCompactionTokens / cu.contextWindow) * 100,
|
|
2189
|
+
estimated: true,
|
|
2190
|
+
};
|
|
2073
2191
|
}
|
|
2074
|
-
|
|
2192
|
+
return {
|
|
2193
|
+
tokens: cu.tokens,
|
|
2194
|
+
contextWindow: cu.contextWindow,
|
|
2195
|
+
percent: cu.percent,
|
|
2196
|
+
};
|
|
2197
|
+
})(),
|
|
2075
2198
|
};
|
|
2076
2199
|
}
|
|
2077
2200
|
catch {
|
|
@@ -2108,6 +2231,7 @@ export class ClientSession {
|
|
|
2108
2231
|
queue: { steering: conv.queueSteering, followUp: conv.queueFollowUp },
|
|
2109
2232
|
errorMessage: state.errorMessage,
|
|
2110
2233
|
retry: conv.retryState ?? null,
|
|
2234
|
+
compaction: conv.compactionState ?? null,
|
|
2111
2235
|
tools: state.tools.map((t) => t.name),
|
|
2112
2236
|
version: ++this.version,
|
|
2113
2237
|
piConfigured: this.isPiConfigured(),
|
|
@@ -2168,6 +2292,55 @@ export class ClientSession {
|
|
|
2168
2292
|
resolveDialog(id, value) {
|
|
2169
2293
|
this.webUi.resolveDialog(id, value);
|
|
2170
2294
|
}
|
|
2295
|
+
// -----------------------------------------------------------------------
|
|
2296
|
+
// 用户提问桥(标准 pi 引擎 ask_user_question customTool)
|
|
2297
|
+
// -----------------------------------------------------------------------
|
|
2298
|
+
/** 标准引擎模型调 ask_user_question:发 question_pending 给浏览器并阻塞等待
|
|
2299
|
+
* question_answer。sig 为工具执行信号的当前状态(aborted → 立即 reject)。
|
|
2300
|
+
* 返回 answers(用户选中/自定义),或 null(用户取消)。 */
|
|
2301
|
+
askUser(questions, sig) {
|
|
2302
|
+
return new Promise((resolve, reject) => {
|
|
2303
|
+
if (sig?.aborted || this.disposed) {
|
|
2304
|
+
reject(new Error("ask_user_question 已中止"));
|
|
2305
|
+
return;
|
|
2306
|
+
}
|
|
2307
|
+
// 问卷开关(默认开):关 → 不弹对话框,立即报错让模型得知已禁用。
|
|
2308
|
+
if (this.settingsSvc.current.questionnaireEnabled === false) {
|
|
2309
|
+
reject(new Error("问卷功能已关闭,可在设置中重新开启"));
|
|
2310
|
+
return;
|
|
2311
|
+
}
|
|
2312
|
+
const id = `q-${++this.questionSeq}`;
|
|
2313
|
+
this.pendingQuestions.set(id, resolve);
|
|
2314
|
+
this.emit({
|
|
2315
|
+
type: "question_pending",
|
|
2316
|
+
id,
|
|
2317
|
+
questions,
|
|
2318
|
+
});
|
|
2319
|
+
});
|
|
2320
|
+
}
|
|
2321
|
+
/** 前端回答模型提问(question_answer → 恢复 askUser 的 Promise)。id 需匹配
|
|
2322
|
+
* pendingQuestions 中键;cancelled 或未匹配(例如用户早已切走)时按「取消」处理
|
|
2323
|
+
* —— 把挂起的提问全部 reject,让模型知道用户离开了。 */
|
|
2324
|
+
resolveQuestion(id, answers, cancelled) {
|
|
2325
|
+
const resolve = this.pendingQuestions.get(id);
|
|
2326
|
+
if (resolve) {
|
|
2327
|
+
this.pendingQuestions.delete(id);
|
|
2328
|
+
resolve(cancelled ? null : answers);
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
/** 标准引擎的 question_answer 路由入口(index.ts 经 cs.answerQuestion?. 转发)。
|
|
2332
|
+
* DSH 引擎的 AgentService 也实现了同名方法,此处为 ClientSession 的转发。 */
|
|
2333
|
+
answerQuestion(id, answers, cancelled) {
|
|
2334
|
+
this.resolveQuestion(id, answers, cancelled);
|
|
2335
|
+
return Promise.resolve();
|
|
2336
|
+
}
|
|
2337
|
+
/** 关闭所有挂起提问(切对话 / dispose 时清理):以「取消」解析,避免模型挂死。 */
|
|
2338
|
+
cancelPendingQuestions() {
|
|
2339
|
+
for (const [, resolve] of this.pendingQuestions) {
|
|
2340
|
+
resolve(null);
|
|
2341
|
+
}
|
|
2342
|
+
this.pendingQuestions.clear();
|
|
2343
|
+
}
|
|
2171
2344
|
/**
|
|
2172
2345
|
* Whether the pi agent has at least one usable model. ModelRuntime's
|
|
2173
2346
|
* available snapshot already accounts for models.json, auth.json, env-var
|
|
@@ -2188,65 +2361,32 @@ export class ClientSession {
|
|
|
2188
2361
|
* probe). Cached machine-wide (same binary for every client) for 10s —
|
|
2189
2362
|
* the check is only rerun after install or when the cache expires.
|
|
2190
2363
|
*
|
|
2191
|
-
* The probe
|
|
2192
|
-
*
|
|
2193
|
-
*
|
|
2194
|
-
*
|
|
2195
|
-
*
|
|
2196
|
-
* between fork and exec
|
|
2197
|
-
*
|
|
2198
|
-
* connections. Observed reproducibly on Android/Termux (Node 26).
|
|
2364
|
+
* The probe is FORK-FREE: it scans PATH for the pi executable instead of
|
|
2365
|
+
* spawning `pi --version`. Do not reintroduce a spawn here — ANY fork on
|
|
2366
|
+
* the main thread of this multi-threaded server can deadlock the whole
|
|
2367
|
+
* process on Android/Termux (issue #78): libuv's uv_spawn blocks its
|
|
2368
|
+
* caller reading the child's error pipe, and that pipe never closes when
|
|
2369
|
+
* the forked child deadlocks between fork and exec. This applies to
|
|
2370
|
+
* asynchronous spawns too — the previous async probe reproduced the hang.
|
|
2199
2371
|
*/
|
|
2200
2372
|
static piCliProbe = null;
|
|
2201
|
-
static piCliProbePending = false;
|
|
2202
2373
|
static PI_CLI_PROBE_TTL_MS = 10_000;
|
|
2203
2374
|
isPiCliInstalled() {
|
|
2204
2375
|
const now = Date.now();
|
|
2205
2376
|
const cached = ClientSession.piCliProbe;
|
|
2206
2377
|
if (cached && now - cached.at < ClientSession.PI_CLI_PROBE_TTL_MS)
|
|
2207
2378
|
return cached.installed;
|
|
2208
|
-
ClientSession.
|
|
2209
|
-
|
|
2379
|
+
const installed = ClientSession.piCliOnPath();
|
|
2380
|
+
ClientSession.piCliProbe = { at: now, installed };
|
|
2381
|
+
return installed;
|
|
2210
2382
|
}
|
|
2211
|
-
static
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
try {
|
|
2217
|
-
proc = spawn("pi", ["--version"], {
|
|
2218
|
-
stdio: "ignore",
|
|
2219
|
-
// Windows: `pi` resolves to a pi.cmd shim — spawn can only
|
|
2220
|
-
// exec those through a shell (else ENOENT).
|
|
2221
|
-
shell: process.platform === "win32",
|
|
2222
|
-
});
|
|
2383
|
+
static piCliOnPath() {
|
|
2384
|
+
const dirs = (process.env.PATH ?? "").split(delimiter);
|
|
2385
|
+
for (const dir of dirs) {
|
|
2386
|
+
if (dir && existsSync(join(dir, "pi")))
|
|
2387
|
+
return true;
|
|
2223
2388
|
}
|
|
2224
|
-
|
|
2225
|
-
ClientSession.piCliProbe = { at: Date.now(), installed: false };
|
|
2226
|
-
ClientSession.piCliProbePending = false;
|
|
2227
|
-
return;
|
|
2228
|
-
}
|
|
2229
|
-
const finish = (installed) => {
|
|
2230
|
-
ClientSession.piCliProbe = { at: Date.now(), installed };
|
|
2231
|
-
ClientSession.piCliProbePending = false;
|
|
2232
|
-
};
|
|
2233
|
-
const timer = setTimeout(() => {
|
|
2234
|
-
try {
|
|
2235
|
-
proc.kill();
|
|
2236
|
-
}
|
|
2237
|
-
catch {
|
|
2238
|
-
/* already exited */
|
|
2239
|
-
}
|
|
2240
|
-
finish(false);
|
|
2241
|
-
}, 5000);
|
|
2242
|
-
proc.on("error", () => {
|
|
2243
|
-
clearTimeout(timer);
|
|
2244
|
-
finish(false);
|
|
2245
|
-
});
|
|
2246
|
-
proc.on("close", (code) => {
|
|
2247
|
-
clearTimeout(timer);
|
|
2248
|
-
finish(code === 0);
|
|
2249
|
-
});
|
|
2389
|
+
return false;
|
|
2250
2390
|
}
|
|
2251
2391
|
static invalidatePiCliProbe() {
|
|
2252
2392
|
ClientSession.piCliProbe = null;
|
|
@@ -2371,7 +2511,7 @@ export class ClientSession {
|
|
|
2371
2511
|
}
|
|
2372
2512
|
try {
|
|
2373
2513
|
const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion());
|
|
2374
|
-
const items = await checkAllUpdates(targets);
|
|
2514
|
+
const items = await checkAllUpdates(targets, undefined, () => this.getLang());
|
|
2375
2515
|
this.updatesAllCache = { at: Date.now(), items };
|
|
2376
2516
|
this.emit({ type: "update_status_all", items });
|
|
2377
2517
|
}
|
|
@@ -2486,7 +2626,11 @@ export class ClientSession {
|
|
|
2486
2626
|
this.flushSnapshot();
|
|
2487
2627
|
},
|
|
2488
2628
|
refreshSessions: () => this.refreshSessions(),
|
|
2489
|
-
afterReload: () =>
|
|
2629
|
+
afterReload: () => {
|
|
2630
|
+
// /reload 同样重读磁盘 settings.json——重放重试覆盖 + 终端门控。
|
|
2631
|
+
this.applyRetryOverrides();
|
|
2632
|
+
this.applyToolGating(this.session);
|
|
2633
|
+
},
|
|
2490
2634
|
pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
|
|
2491
2635
|
execPluginCommand: async (name, args) => {
|
|
2492
2636
|
const def = this.pluginCommandsProvider?.().find((c) => c.name === name);
|
|
@@ -2531,10 +2675,10 @@ export class ClientSession {
|
|
|
2531
2675
|
return this.modelAdmin.listModelsConfig();
|
|
2532
2676
|
}
|
|
2533
2677
|
fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
|
|
2534
|
-
return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api);
|
|
2678
|
+
return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
|
|
2535
2679
|
}
|
|
2536
2680
|
refreshProviderModels(providerId, reqId) {
|
|
2537
|
-
return this.modelAdmin.refreshProviderModels(providerId, reqId);
|
|
2681
|
+
return this.modelAdmin.refreshProviderModels(providerId, reqId, () => this.getLang());
|
|
2538
2682
|
}
|
|
2539
2683
|
/** Copy a built-in provider into an editable custom-provider draft
|
|
2540
2684
|
* (clone_provider_result) — lets the user run a second API key without
|
|
@@ -2671,6 +2815,22 @@ export class ClientSession {
|
|
|
2671
2815
|
pushSettings() {
|
|
2672
2816
|
this.settingsSvc.push();
|
|
2673
2817
|
}
|
|
2818
|
+
/** 把设置面板的出错重试次数注入全部存活会话的 SDK SettingsManager。
|
|
2819
|
+
* applyOverrides 只改内存合并视图(不碰 ~/.pi/agent/settings.json),
|
|
2820
|
+
* 且 SDK 每次退避前都重读 getRetrySettings()——即时生效、无需 reload。
|
|
2821
|
+
* 但 session.reload() 会重读磁盘丢掉覆盖,每次 reload 后必须重放
|
|
2822
|
+
* (reloadSession / afterReload / 标记开关直载路径均已接)。 */
|
|
2823
|
+
applyRetryOverrides() {
|
|
2824
|
+
const n = normalizeRetryMaxAttempts(this.settingsSvc.current.retryMaxAttempts);
|
|
2825
|
+
for (const c of this.convs.values()) {
|
|
2826
|
+
try {
|
|
2827
|
+
c.session.settingsManager.applyOverrides({ retry: { maxRetries: n } });
|
|
2828
|
+
}
|
|
2829
|
+
catch {
|
|
2830
|
+
// 会话未就绪或已释放 → 其 runtime 创建时统一注入。
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2674
2834
|
/** Extensions/skills changed externally (e.g. `pi remove` finished in the
|
|
2675
2835
|
* terminal): re-run session.reload() and re-push state. Streaming-safe —
|
|
2676
2836
|
* deferred to agent_end, same as settings reloads. */
|
|
@@ -2697,6 +2857,7 @@ export class ClientSession {
|
|
|
2697
2857
|
if (!this.session.isStreaming) {
|
|
2698
2858
|
try {
|
|
2699
2859
|
await this.session.reload();
|
|
2860
|
+
this.applyRetryOverrides();
|
|
2700
2861
|
this.applyToolGating(this.session);
|
|
2701
2862
|
await this.pushSlashCommands();
|
|
2702
2863
|
this.pushSettings();
|
|
@@ -2775,6 +2936,26 @@ export class ClientSession {
|
|
|
2775
2936
|
// 兼容旧入口:reload + 刷目录在宿主回调里完成
|
|
2776
2937
|
return this.settingsSvc.applyRuntime();
|
|
2777
2938
|
}
|
|
2939
|
+
/** Server language for this client (issue #91): resolved LIVE from the
|
|
2940
|
+
* persisted UI locale — "zh" only for zh*; everything else (including
|
|
2941
|
+
* never-reported) is English. Per-call tool return values read this on
|
|
2942
|
+
* every invocation, so they follow language switches with no rebuild. */
|
|
2943
|
+
getLang() {
|
|
2944
|
+
return resolveServerLang(this.stateStore.get(this.clientId).locale);
|
|
2945
|
+
}
|
|
2946
|
+
/** Persist the browser UI locale (hello.locale / set_locale) and refresh
|
|
2947
|
+
* lang-aware prompt segments. Reuses the settings reload path, so it is
|
|
2948
|
+
* streaming-safe (deferred to agent_end mid-run, same as settings). */
|
|
2949
|
+
async setLocale(locale) {
|
|
2950
|
+
const code = locale.trim().slice(0, 16);
|
|
2951
|
+
if (!code)
|
|
2952
|
+
return;
|
|
2953
|
+
const prev = this.getLang();
|
|
2954
|
+
this.stateStore.saveLocale(this.clientId, code);
|
|
2955
|
+
if (this.getLang() === prev)
|
|
2956
|
+
return; // same server language — nothing to re-render
|
|
2957
|
+
await this.applySettingsReload();
|
|
2958
|
+
}
|
|
2778
2959
|
// ---------------------------------------------------------------------------
|
|
2779
2960
|
// Commands
|
|
2780
2961
|
// ---------------------------------------------------------------------------
|
|
@@ -2868,6 +3049,8 @@ export class ClientSession {
|
|
|
2868
3049
|
emit: (msg) => this.emit(msg),
|
|
2869
3050
|
settings: this.settingsSvc.current,
|
|
2870
3051
|
session: this.session,
|
|
3052
|
+
// issue #91:附件/视觉桥文案按客户端 UI 语言出中英(英文默认)。
|
|
3053
|
+
getLang: () => this.getLang(),
|
|
2871
3054
|
}, attachments);
|
|
2872
3055
|
for (const aside of asides) {
|
|
2873
3056
|
await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
|
|
@@ -3129,7 +3312,7 @@ export class ClientSession {
|
|
|
3129
3312
|
this.clearAllToolWatchdogs(conv);
|
|
3130
3313
|
conv.toolStartTimes.clear();
|
|
3131
3314
|
await conv.runtime.dispose();
|
|
3132
|
-
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals), {
|
|
3315
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, undefined, conv.id), {
|
|
3133
3316
|
cwd: conv.cwd,
|
|
3134
3317
|
agentDir: this.agentDir,
|
|
3135
3318
|
sessionManager: SessionManager.continueRecent(conv.cwd),
|
|
@@ -3208,7 +3391,7 @@ export class ClientSession {
|
|
|
3208
3391
|
try {
|
|
3209
3392
|
const conversationId = this.nextConversationId();
|
|
3210
3393
|
const terminals = this.makeTerminalManager(conversationId, this.cwd);
|
|
3211
|
-
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
|
|
3394
|
+
const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
|
|
3212
3395
|
cwd: this.cwd,
|
|
3213
3396
|
agentDir: this.agentDir,
|
|
3214
3397
|
sessionManager: SessionManager.create(this.cwd),
|
|
@@ -3762,7 +3945,7 @@ export class ClientSession {
|
|
|
3762
3945
|
const targetCwd = sessionManager.getCwd();
|
|
3763
3946
|
const conversationId = this.nextConversationId();
|
|
3764
3947
|
openedTerminals = this.makeTerminalManager(conversationId, targetCwd);
|
|
3765
|
-
openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(openedTerminals), {
|
|
3948
|
+
openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(openedTerminals, undefined, conversationId), {
|
|
3766
3949
|
cwd: targetCwd,
|
|
3767
3950
|
agentDir: this.agentDir,
|
|
3768
3951
|
sessionManager,
|
|
@@ -4114,7 +4297,7 @@ export class ClientSession {
|
|
|
4114
4297
|
// First visit to this project: resume its most recent session.
|
|
4115
4298
|
const conversationId = this.nextConversationId();
|
|
4116
4299
|
const terminals = this.makeTerminalManager(conversationId, abs);
|
|
4117
|
-
const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
|
|
4300
|
+
const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
|
|
4118
4301
|
cwd: abs,
|
|
4119
4302
|
agentDir: this.agentDir,
|
|
4120
4303
|
sessionManager: SessionManager.continueRecent(abs),
|
|
@@ -4327,6 +4510,8 @@ export class ClientSession {
|
|
|
4327
4510
|
this.files.unwatchDir();
|
|
4328
4511
|
this.files.unwatchGit();
|
|
4329
4512
|
this.webUi.dispose();
|
|
4513
|
+
// 关闭所有挂起的用户提问(dispose 时以「取消」解析,避免模型挂死)。
|
|
4514
|
+
this.cancelPendingQuestions();
|
|
4330
4515
|
this.bg.stop();
|
|
4331
4516
|
for (const conv of this.convs.values()) {
|
|
4332
4517
|
this.clearAllToolWatchdogs(conv);
|
|
@@ -4518,6 +4703,25 @@ export class AgentService {
|
|
|
4518
4703
|
for (const cs of this.clients.values())
|
|
4519
4704
|
cs.refreshPluginTools();
|
|
4520
4705
|
}
|
|
4706
|
+
/** Browser UI locale report (hello.locale / set_locale): persist per client
|
|
4707
|
+
* and refresh lang-aware prompts (streaming-safe via ClientSession). */
|
|
4708
|
+
async setLocale(clientId, locale) {
|
|
4709
|
+
const cs = this.clients.get(clientId);
|
|
4710
|
+
if (cs) {
|
|
4711
|
+
await cs.setLocale(locale);
|
|
4712
|
+
return;
|
|
4713
|
+
}
|
|
4714
|
+
// hello race: session still being created — wait for it, then apply.
|
|
4715
|
+
const inflight = this.pending.get(clientId);
|
|
4716
|
+
if (inflight) {
|
|
4717
|
+
try {
|
|
4718
|
+
await (await inflight).setLocale(locale);
|
|
4719
|
+
}
|
|
4720
|
+
catch {
|
|
4721
|
+
/* attach failed — nothing to apply to */
|
|
4722
|
+
}
|
|
4723
|
+
}
|
|
4724
|
+
}
|
|
4521
4725
|
/** 插件斜杠命令集合变化时由 index.ts 触发:重推各客户端的命令目录。 */
|
|
4522
4726
|
applyPluginCommandCatalog() {
|
|
4523
4727
|
for (const cs of this.clients.values())
|