pi-web-ui 0.68.2 → 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.
Files changed (42) hide show
  1. package/dist/server/agent-service.js +157 -50
  2. package/dist/server/attachments.js +7 -2
  3. package/dist/server/client-state.js +27 -0
  4. package/dist/server/dsh/dsh-agent-service.js +139 -38
  5. package/dist/server/dsh/dsh-client.js +9 -8
  6. package/dist/server/dsh/dsh-sessions.js +4 -3
  7. package/dist/server/edit-soft-tool.js +33 -26
  8. package/dist/server/files-service.js +12 -7
  9. package/dist/server/goal-service.js +85 -24
  10. package/dist/server/i18n.js +157 -0
  11. package/dist/server/index.js +76 -18
  12. package/dist/server/locales.js +55 -1
  13. package/dist/server/managed.js +61 -0
  14. package/dist/server/marker-service.js +20 -7
  15. package/dist/server/markers/builtins/notify.js +19 -6
  16. package/dist/server/markers/builtins/rename.js +41 -8
  17. package/dist/server/markers/builtins/todo.js +107 -30
  18. package/dist/server/markers/registry.js +2 -2
  19. package/dist/server/mcp-bridge.js +3 -1
  20. package/dist/server/model-admin.js +25 -14
  21. package/dist/server/plugin-catalog.js +7 -3
  22. package/dist/server/plugin-updater.js +6 -2
  23. package/dist/server/plugins.js +40 -17
  24. package/dist/server/prompt-composer.js +42 -16
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/scm.js +18 -25
  27. package/dist/server/serialize.js +1 -0
  28. package/dist/server/settings-service.js +20 -1
  29. package/dist/server/subagent-templates.js +105 -0
  30. package/dist/server/subagents.js +155 -54
  31. package/dist/server/tabs.js +87 -0
  32. package/dist/server/terminals.js +88 -48
  33. package/dist/server/update-check.js +7 -2
  34. package/dist/server/vision-bridge.js +34 -12
  35. package/package.json +2 -1
  36. package/web/dist/assets/{TerminalPanel-BQ5NTB9Y.js → TerminalPanel-Cj8zsjx-.js} +1 -1
  37. package/web/dist/assets/index-DCOcsPFm.js +334 -0
  38. package/web/dist/assets/{index-C_I-6Zul.css → index-jH2Bb-0X.css} +1 -1
  39. package/web/dist/assets/{markdown-DOsihKaR.js → markdown-Cpo0pNcR.js} +1 -1
  40. package/web/dist/assets/{react-DIP6JKYk.js → react-CtudoG1_.js} +1 -1
  41. package/web/dist/index.html +4 -4
  42. package/web/dist/assets/index-Ck5pa3XK.js +0 -333
@@ -22,17 +22,22 @@ import { homedir } from "node:os";
22
22
  import { isAbsolute, join, resolve as nodeResolve } from "node:path";
23
23
  import { Type } from "typebox";
24
24
  import { defineTool, generateDiffString, generateUnifiedPatch, withFileMutationQueue, } from "@earendil-works/pi-coding-agent";
25
+ import { bilingual, pick } from "./i18n.js";
25
26
  export const SOFT_EDIT_TOOL_NAME = "edit_soft";
26
27
  const replaceEditSchema = Type.Object({
27
28
  oldText: Type.String({
28
- description: "要替换的文本。宽松匹配:每个非空行的内容(去掉首尾空白)需与文件中对应行一致;行首缩进(空格/制表符)的差异会被忽略。建议按整行/整块提供。",
29
+ description: bilingual("Text to replace. Loose matching: the content of each non-empty line (trimmed of leading/trailing whitespace) must match the corresponding file lines; leading-indentation (spaces/tabs) differences are ignored. Prefer whole lines/blocks.", "要替换的文本。宽松匹配:每个非空行的内容(去掉首尾空白)需与文件中对应行一致;行首缩进(空格/制表符)的差异会被忽略。建议按整行/整块提供。"),
30
+ }),
31
+ newText: Type.String({
32
+ description: bilingual("Replacement text (written verbatim; the indentation you provide is final).", "替换后的文本(原样写入,缩进即最终缩进)。"),
29
33
  }),
30
- newText: Type.String({ description: "替换后的文本(原样写入,缩进即最终缩进)。" }),
31
34
  }, {});
32
35
  const editSoftSchema = Type.Object({
33
- path: Type.String({ description: "要编辑的文件路径(相对或绝对)" }),
36
+ path: Type.String({
37
+ description: bilingual("Path of the file to edit (relative or absolute).", "要编辑的文件路径(相对或绝对)"),
38
+ }),
34
39
  edits: Type.Array(replaceEditSchema, {
35
- description: "一个或多个定向替换。每个 edit 相对原文件匹配(非增量);不要包含重叠/嵌套的 edit;同一块或相邻行请合并成一个 edit。",
40
+ description: bilingual("One or more targeted replacements. Each edit matches against the original file (not incrementally); do not include overlapping/nested edits; merge edits touching the same block or nearby lines into one.", "一个或多个定向替换。每个 edit 相对原文件匹配(非增量);不要包含重叠/嵌套的 edit;同一块或相邻行请合并成一个 edit。"),
36
41
  }),
37
42
  }, {});
38
43
  function isSingleEditInput(value) {
@@ -145,9 +150,9 @@ class SoftEditMatchError extends Error {
145
150
  * 命中一个 edit:先精确,后行核心宽松。返回原文件字符区间 + 待写入文本。
146
151
  * spans 全部相对 LF 归一化后的原文件正文。
147
152
  */
148
- function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIndex) {
153
+ function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIndex, lang = "en") {
149
154
  if (oldTextLF.length === 0) {
150
- throw new SoftEditMatchError(`edits[${editIndex}].oldText must not be empty in ${path}.`);
155
+ throw new SoftEditMatchError(pick(lang, `edits[${editIndex}].oldText 在 ${path} 中不能为空。`, `edits[${editIndex}].oldText must not be empty in ${path}.`, "editsoft.oldtext.empty", { editIndex, path }));
151
156
  }
152
157
  // 1) 精确子串匹配(等价普通 edit,支持片段)
153
158
  const exactIdx = normalizedContent.indexOf(oldTextLF);
@@ -155,14 +160,14 @@ function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIn
155
160
  // 唯一性:精确匹配出现多次 → 报错(模型应提供更多上下文)
156
161
  const occurrences = normalizedContent.split(oldTextLF).length - 1;
157
162
  if (occurrences > 1) {
158
- throw new SoftEditMatchError(`Found ${occurrences} occurrences of edits[${editIndex}].oldText in ${path}. Each oldText must be unique. Please provide more context to make it unique.`);
163
+ throw new SoftEditMatchError(pick(lang, `在 ${path} 中找到 ${occurrences} 处 edits[${editIndex}].oldText。每个 oldText 必须唯一,请提供更多上下文。`, `Found ${occurrences} occurrences of edits[${editIndex}].oldText in ${path}. Each oldText must be unique. Please provide more context to make it unique.`, "editsoft.exact.not.unique", { path, occurrences, editIndex }));
159
164
  }
160
165
  return { start: exactIdx, end: exactIdx + oldTextLF.length, insertText: newTextLF };
161
166
  }
162
167
  // 2) 行核心宽松匹配:逐行 trim 后序列一致(忽略缩进差异)
163
168
  const cores = oldTextCores(oldTextLF);
164
169
  if (cores.length === 0) {
165
- throw new SoftEditMatchError(`edits[${editIndex}].oldText is effectively empty in ${path}.`);
170
+ throw new SoftEditMatchError(pick(lang, `edits[${editIndex}].oldText 在 ${path} 中实际为空。`, `edits[${editIndex}].oldText is effectively empty in ${path}.`, "editsoft.oldtext.blank", { editIndex, path }));
166
171
  }
167
172
  const units = splitLineUnits(normalizedContent);
168
173
  const k = cores.length;
@@ -179,10 +184,10 @@ function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIn
179
184
  starts.push(i);
180
185
  }
181
186
  if (starts.length === 0) {
182
- throw new SoftEditMatchError(`Could not find the text (exact or indentation-insensitive) in ${path}. The oldText must match the file's line content; leading-whitespace differences are ignored, but the actual content must be identical.`);
187
+ throw new SoftEditMatchError(pick(lang, `在 ${path} 中找不到该文本(精确或忽略缩进均未命中)。oldText 必须与文件的行内容一致;行首空白差异会被忽略,但实际内容必须完全相同。`, `Could not find the text (exact or indentation-insensitive) in ${path}. The oldText must match the file's line content; leading-whitespace differences are ignored, but the actual content must be identical.`, "editsoft.match.not.found", { path }));
183
188
  }
184
189
  if (starts.length > 1) {
185
- throw new SoftEditMatchError(`Found ${starts.length} indentation-insensitive occurrences of edits[${editIndex}].oldText in ${path}. The text must be unique. Please provide more context to make it unique.`);
190
+ throw new SoftEditMatchError(pick(lang, `在 ${path} 中找到 ${starts.length} 处忽略缩进的 edits[${editIndex}].oldText。文本必须唯一,请提供更多上下文。`, `Found ${starts.length} indentation-insensitive occurrences of edits[${editIndex}].oldText in ${path}. The text must be unique. Please provide more context to make it unique.`, "editsoft.match.not.unique", { path, "starts.length": starts.length, editIndex }));
186
191
  }
187
192
  const li = starts[0];
188
193
  const ri = li + k;
@@ -196,18 +201,18 @@ function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIn
196
201
  return { start: spanStart, end: spanEnd, insertText };
197
202
  }
198
203
  /** 对 LF 归一化内容应用一组 edit(含重叠检测),返回 {base,next}。 */
199
- export function applySoftEdits(normalizedContent, edits, path) {
204
+ export function applySoftEdits(normalizedContent, edits, path, lang = "en") {
200
205
  const replacements = [];
201
206
  for (let i = 0; i < edits.length; i++) {
202
207
  const oldTextLF = normalizeToLF(edits[i].oldText);
203
208
  const newTextLF = normalizeToLF(edits[i].newText);
204
- replacements.push(locateReplacement(normalizedContent, oldTextLF, newTextLF, path, i));
209
+ replacements.push(locateReplacement(normalizedContent, oldTextLF, newTextLF, path, i, lang));
205
210
  }
206
211
  // 重叠检测
207
212
  const sorted = [...replacements].sort((a, b) => a.start - b.start);
208
213
  for (let i = 1; i < sorted.length; i++) {
209
214
  if (sorted[i - 1].end > sorted[i].start) {
210
- throw new SoftEditMatchError(`edits overlap in ${path}. Merge them into one edit or target disjoint regions.`);
215
+ throw new SoftEditMatchError(pick(lang, `在 ${path} 中的 edits 重叠。请合并为一个 edit 或定位到不相交的区域。`, `edits overlap in ${path}. Merge them into one edit or target disjoint regions.`, "editsoft.edits.overlap", { path }));
211
216
  }
212
217
  }
213
218
  // 逆序应用,保持左侧偏移稳定
@@ -217,39 +222,41 @@ export function applySoftEdits(normalizedContent, edits, path) {
217
222
  result = result.slice(0, r.start) + r.insertText + result.slice(r.end);
218
223
  }
219
224
  if (result === normalizedContent) {
220
- throw new SoftEditMatchError(`No changes made to ${path}. The replacement produced identical content.`);
225
+ throw new SoftEditMatchError(pick(lang, `${path} 没有变化。替换产生了完全相同的内容。`, `No changes made to ${path}. The replacement produced identical content.`, "editsoft.result.identical", { path }));
221
226
  }
222
227
  return { baseContent: normalizedContent, newContent: result };
223
228
  }
224
229
  /**
225
230
  * 生成 edit_soft 工具定义。与内置 edit 同结构执行体,但用宽松缩进匹配 + 原样 newText。
226
231
  * cwd 仅供创建时固定;执行时优先 ctx.cwd(会话工作区)。
232
+ * getLang 为可选的服务端语言取值器(每次调用时读取,默认英文,issue #91)。
227
233
  */
228
- export function makeEditSoftTool(fallbackCwd) {
234
+ export function makeEditSoftTool(fallbackCwd, getLang) {
229
235
  return defineTool({
230
236
  name: SOFT_EDIT_TOOL_NAME,
231
237
  label: "Edit (indentation-insensitive)",
232
- description: "Edit a single file using text replacement that is tolerant of indentation. Every edits[].oldText is matched to the file by line content: leading whitespace (spaces/tabs) differences between your oldText and the file are ignored, so an edit does not fail just because the indentation differs. Use this when the built-in edit tool rejects your oldText due to a whitespace mismatch (common in JS/JSON/etc.). Prefer whole-line/whole-block oldText. newText is written exactly as provided.",
233
- promptSnippet: "edit a file tolerating indentation differences (whitespace-insensitive match)",
238
+ description: bilingual("Edit a single file using text replacement that is tolerant of indentation. Every edits[].oldText is matched to the file by line content: leading whitespace (spaces/tabs) differences between your oldText and the file are ignored, so an edit does not fail just because the indentation differs. Use this when the built-in edit tool rejects your oldText due to a whitespace mismatch (common in JS/JSON/etc.). Prefer whole-line/whole-block oldText. newText is written exactly as provided.", "用对缩进不敏感的文本替换编辑单个文件。每个 edits[].oldText 按行内容与文件匹配:oldText 与文件之间的行首空白(空格/制表符)差异会被忽略,因此不会仅因缩进不同而失败。当内置 edit 工具因空白不匹配拒绝你的 oldText 时使用本工具(常见于 JS/JSON 等)。oldText 尽量按整行/整块提供。newText 按原样写入。"),
239
+ promptSnippet: bilingual("edit a file tolerating indentation differences (whitespace-insensitive match)", "编辑文件,容忍缩进差异(空白不敏感匹配)"),
234
240
  promptGuidelines: [
235
- "Use edit_soft when edit fails because the oldText indentation/spacing differs from the file",
236
- "Each edits[].oldText is matched to whole lines by trimmed content; leading whitespace is ignored",
237
- "newText is written verbatim — the indentation you provide is the final indentation in the file",
238
- "Keep edits[].oldText as small as possible while still being unique; merge nearby changes into one edit",
241
+ bilingual("Use edit_soft when edit fails because the oldText indentation/spacing differs from the file", "当 edit 因 oldText 缩进/空白与文件不一致而失败时,使用 edit_soft"),
242
+ bilingual("Each edits[].oldText is matched to whole lines by trimmed content; leading whitespace is ignored", "每个 edits[].oldText 按去首尾空白后的内容匹配到整行;行首空白会被忽略"),
243
+ bilingual("newText is written verbatim — the indentation you provide is the final indentation in the file", "newText 按原样写入——你提供的缩进就是文件中的最终缩进"),
244
+ bilingual("Keep edits[].oldText as small as possible while still being unique; merge nearby changes into one edit", "edits[].oldText 在保持唯一的前提下尽量小;相邻改动合并为一个 edit"),
239
245
  ],
240
246
  parameters: editSoftSchema,
241
247
  prepareArguments: prepareSoftEditArguments,
242
248
  async execute(_toolCallId, input, signal, _onUpdate, ctx) {
249
+ const lang = getLang?.() ?? "en";
243
250
  const { path, edits } = input;
244
251
  if (!Array.isArray(edits) || edits.length === 0) {
245
- throw new Error("edit_soft input is invalid. edits must contain at least one replacement.");
252
+ throw new Error(pick(lang, "edit_soft 输入无效:edits 必须包含至少一个替换。", "edit_soft input is invalid. edits must contain at least one replacement.", "editsoft.input.no.edits"));
246
253
  }
247
254
  const cwd = typeof ctx?.cwd === "string" ? ctx.cwd : fallbackCwd;
248
255
  const absolutePath = resolveToCwd(path, cwd);
249
256
  return withFileMutationQueue(absolutePath, async () => {
250
257
  const throwIfAborted = () => {
251
258
  if (signal?.aborted)
252
- throw new Error("Operation aborted");
259
+ throw new Error(pick(lang, "操作已中止", "Operation aborted", "editsoft.operation.aborted"));
253
260
  };
254
261
  throwIfAborted();
255
262
  try {
@@ -258,7 +265,7 @@ export function makeEditSoftTool(fallbackCwd) {
258
265
  catch (error) {
259
266
  throwIfAborted();
260
267
  const errorMessage = error instanceof Error && "code" in error ? `Error code: ${error.code}` : String(error);
261
- throw new Error(`Could not edit file: ${path}. ${errorMessage}.`);
268
+ throw new Error(pick(lang, `无法编辑文件:${path}。${errorMessage}。`, `Could not edit file: ${path}. ${errorMessage}.`, "editsoft.file.access.failed", { path, errorMessage }));
262
269
  }
263
270
  throwIfAborted();
264
271
  const buffer = await readFile(absolutePath);
@@ -267,7 +274,7 @@ export function makeEditSoftTool(fallbackCwd) {
267
274
  const { bom, text: content } = splitBom(rawContent);
268
275
  const originalEnding = detectLineEnding(content);
269
276
  const normalizedContent = normalizeToLF(content);
270
- const { baseContent, newContent } = applySoftEdits(normalizedContent, edits, path);
277
+ const { baseContent, newContent } = applySoftEdits(normalizedContent, edits, path, lang);
271
278
  throwIfAborted();
272
279
  const finalContent = bom + restoreLineEndings(newContent, originalEnding);
273
280
  await writeFile(absolutePath, finalContent, "utf-8");
@@ -278,7 +285,7 @@ export function makeEditSoftTool(fallbackCwd) {
278
285
  content: [
279
286
  {
280
287
  type: "text",
281
- text: `Successfully replaced ${edits.length} block(s) in ${path} (indentation-insensitive).`,
288
+ text: pick(lang, `已在 ${path} 中替换 ${edits.length} 个块(忽略缩进匹配)。`, `Successfully replaced ${edits.length} block(s) in ${path} (indentation-insensitive).`, "editsoft.replace.done", { path, "edits.length": edits.length }),
282
289
  },
283
290
  ],
284
291
  details: { diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { mkdirSync, statSync, writeFileSync, watch } from "node:fs";
9
9
  import { resolve, relative, sep } from "node:path";
10
+ import { pick } from "./i18n.js";
10
11
  import { previewKind, looksLikeText, decodeText, hexDump, countLines } from "./text-sniff.js";
11
12
  import { gitDirOf, isNotRepoError, scmStatus, scmHistory, scmFileDiff, scmCommitDetail } from "./scm.js";
12
13
  export const IS_WIN32 = process.platform === "win32";
@@ -209,6 +210,10 @@ export class FilesService {
209
210
  }
210
211
  return [{ name: "/", path: "/", type: "dir" }];
211
212
  }
213
+ /** 单字段错误文本的语言(host 未接线时英文默认)。 */
214
+ lang() {
215
+ return this.host.getLang?.() ?? "en";
216
+ }
212
217
  /** 目录列表失败的 notice:缺失路径(ENOENT/ENOTDIR)是删除/改名等正常场景,
213
218
  * 软提示“目录不存在”;其余(权限拒绝等)保留原平台语义——win32 软提示
214
219
  * (ACL 系统目录常见),posix 硬错误(error 级 notice,取代曾经的 throw)。 */
@@ -392,12 +397,12 @@ export class FilesService {
392
397
  this.watchGitDir(cwd);
393
398
  try {
394
399
  if (kind === "status") {
395
- const data = await scmStatus(cwd);
400
+ const data = await scmStatus(cwd, () => this.lang());
396
401
  this.host.emit({ type: "scm_data", reqId, kind, ok: true, ...data });
397
402
  return;
398
403
  }
399
404
  if (kind === "history") {
400
- const history = await scmHistory(cwd);
405
+ const history = await scmHistory(cwd, () => this.lang());
401
406
  this.host.emit({ type: "scm_data", reqId, kind, ok: true, history });
402
407
  return;
403
408
  }
@@ -407,8 +412,8 @@ export class FilesService {
407
412
  const { resolve, relative } = await import("node:path");
408
413
  const rel = relative(resolve(cwd), resolve(cwd, arg.path));
409
414
  if (rel.startsWith("..") || rel === "")
410
- throw new Error("路径超出工作区");
411
- const { staged, worktree } = await scmFileDiff(cwd, arg.path);
415
+ throw new Error(pick(this.lang(), "路径超出工作区", "Path is outside the workspace", "files.path.outside.workspace"));
416
+ const { staged, worktree } = await scmFileDiff(cwd, arg.path, () => this.lang());
412
417
  this.host.emit({
413
418
  type: "scm_data",
414
419
  reqId,
@@ -420,11 +425,11 @@ export class FilesService {
420
425
  return;
421
426
  }
422
427
  if (kind === "commit" && arg?.hash && /^[0-9a-f]{7,40}$/i.test(arg.hash)) {
423
- const text = await scmCommitDetail(cwd, arg.hash);
428
+ const text = await scmCommitDetail(cwd, arg.hash, () => this.lang());
424
429
  this.host.emit({ type: "scm_data", reqId, kind, ok: true, text });
425
430
  return;
426
431
  }
427
- throw new Error("无效的 scm 查询参数");
432
+ throw new Error(pick(this.lang(), "无效的 scm 查询参数", "Invalid scm query arguments", "files.scm.invalid.args"));
428
433
  }
429
434
  catch (err) {
430
435
  if (isNotRepoError(err)) {
@@ -885,7 +890,7 @@ export class FilesService {
885
890
  const home = homedir();
886
891
  let expanded = input.trim();
887
892
  if (!expanded)
888
- throw new Error("路径为空");
893
+ throw new Error(pick(this.lang(), "路径为空", "Empty path", "files.path.empty"));
889
894
  if (expanded === "~" || expanded === "~\\") {
890
895
  expanded = home;
891
896
  }
@@ -15,6 +15,7 @@
15
15
  import { join } from "node:path";
16
16
  import { Type } from "typebox";
17
17
  import { createAgentSessionFromServices, createAgentSessionServices, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
18
+ import { bilingual, pick } from "./i18n.js";
18
19
  import { parseModelSpec } from "./attachments.js";
19
20
  /** System prompt for the goal-wizard session. The wizard asks the user a few
20
21
  * questions (via its goal_ask tool) to scope a raw requirement into a precise,
@@ -75,6 +76,14 @@ export class GoalService {
75
76
  get reviewPrefs() {
76
77
  return this.prefs;
77
78
  }
79
+ /** 当前服务端语言(英文默认,未接线前保持原有英文行为)。 */
80
+ lang() {
81
+ return this.host.lang?.() ?? "en";
82
+ }
83
+ /** 目标模式总开关(设置面板可关)。关 → 所有目标入口拒绝、审查不再触发。 */
84
+ goalEnabled() {
85
+ return this.host.goalModeEnabled();
86
+ }
78
87
  /** Create independent goal state for one conversation. Preferences are
79
88
  * client-wide defaults, while goal text/review progress is not shared. */
80
89
  makeGoalStatus() {
@@ -120,6 +129,15 @@ export class GoalService {
120
129
  await this.clearGoal();
121
130
  return;
122
131
  }
132
+ if (!this.goalEnabled()) {
133
+ this.host.emit({
134
+ type: "notice",
135
+ level: "warning",
136
+ text: "目标模式已关闭:请先在设置「目标审查」中启用目标模式。",
137
+ textEn: "Goal mode is off: enable it under Settings → Goal review first.",
138
+ });
139
+ return;
140
+ }
123
141
  // A goal is scoped to the conversation that is active when it is set.
124
142
  // This prevents an agent_end from a newly-created/switched conversation
125
143
  // from consuming the previous conversation's goal.
@@ -175,7 +193,8 @@ export class GoalService {
175
193
  if (opts?.autoStart !== false) {
176
194
  try {
177
195
  const s = conv.session;
178
- await s.sendUserMessage(`【目标已设定】\n\n${text}\n\n请现在开始实现这个目标。`, {
196
+ const kick = pick(this.lang(), `【目标已设定】\n\n${text}\n\n请现在开始实现这个目标。`, `[Goal set]\n\n${text}\n\nStart implementing this goal now.`, "goal.set.kick", { text: text });
197
+ await s.sendUserMessage(kick, {
179
198
  deliverAs: s.isStreaming ? "steer" : "followUp",
180
199
  });
181
200
  }
@@ -196,6 +215,15 @@ export class GoalService {
196
215
  async startGoalWizard(text, opts) {
197
216
  if (this.host.quiesceBlocked())
198
217
  return;
218
+ if (!this.goalEnabled()) {
219
+ this.host.emit({
220
+ type: "notice",
221
+ level: "warning",
222
+ text: "目标模式已关闭:请先在设置「目标审查」中启用目标模式。",
223
+ textEn: "Goal mode is off: enable it under Settings → Goal review first.",
224
+ });
225
+ return;
226
+ }
199
227
  const draft = (text ?? "").trim();
200
228
  if (!draft)
201
229
  return;
@@ -272,7 +300,7 @@ export class GoalService {
272
300
  idleTimer = setTimeout(() => {
273
301
  if (!ac.signal.aborted) {
274
302
  this.wizardCancelled = true;
275
- ac.abort(new Error("目标调研超时(等待回答过久)"));
303
+ ac.abort(new Error(pick(this.lang(), "目标调研超时(等待回答过久)", "Goal survey timed out (waited too long for an answer)", "goal.wizard.idle.timeout")));
276
304
  }
277
305
  }, GoalService.WIZARD_IDLE_TIMEOUT_MS);
278
306
  idleTimer.unref?.();
@@ -289,7 +317,7 @@ export class GoalService {
289
317
  const totalTimer = setTimeout(() => {
290
318
  if (!ac.signal.aborted) {
291
319
  this.wizardCancelled = true;
292
- ac.abort(new Error("目标调研超过总时长上限"));
320
+ ac.abort(new Error(pick(this.lang(), "目标调研超过总时长上限", "Goal survey exceeded the total time limit", "goal.wizard.total.timeout")));
293
321
  }
294
322
  }, GoalService.WIZARD_MAX_TOTAL_MS);
295
323
  totalTimer.unref?.();
@@ -326,9 +354,9 @@ export class GoalService {
326
354
  const goalAsk = defineTool({
327
355
  name: "goal_ask",
328
356
  label: "Ask the user",
329
- description: "Ask the user ONE question at a time to scope down the goal. Provide a clear question and 2-4 concise options; or ask an open question. Returns the user's chosen answer.",
357
+ description: bilingual("Ask the user ONE question at a time to scope down the goal. Provide a clear question and 2-4 concise options; or ask an open question. Returns the user's chosen answer.", "一次只向用户提一个问题,以明确目标范围。给出清晰的问题和 2-4 个简洁选项;或提开放式问题。返回用户选择的答案。"),
330
358
  parameters: Type.Object({
331
- question: Type.String({ description: "The question to ask" }),
359
+ question: Type.String({ description: bilingual("The question to ask", "要问的问题") }),
332
360
  options: Type.Optional(Type.Array(Type.String())),
333
361
  }),
334
362
  // ONE question at a time. Sequential execution prevents the agent from
@@ -337,13 +365,14 @@ export class GoalService {
337
365
  // reported "调研卡住").
338
366
  executionMode: "sequential",
339
367
  execute: async (_id, params, _sig, _onUpdate, ctx) => {
368
+ const lang = this.lang();
340
369
  qStep += 1;
341
370
  if (qStep > maxSteps) {
342
371
  return {
343
372
  content: [
344
373
  {
345
374
  type: "text",
346
- text: "(达到最大提问数,请直接给出收敛后的目标文本作为最终答案)",
375
+ text: pick(lang, "(达到最大提问数,请直接给出收敛后的目标文本作为最终答案)", "(Max questions reached — stop asking and reply with the converged goal text as your final answer)", "goal.wizard.max.questions"),
347
376
  },
348
377
  ],
349
378
  details: {},
@@ -358,16 +387,23 @@ export class GoalService {
358
387
  try {
359
388
  armIdle();
360
389
  const isChoice = !!(params.options && params.options.length > 0);
361
- await this.pushWizardCard(mainSession, `🔍 第 ${qStep} 题:${params.question}${isChoice ? `【${params.options.join(" / ")}】` : ""}`, { question: params.question });
390
+ const qTitle = pick(lang, `🔍 第 ${qStep} 题:${params.question}`, `🔍 Question ${qStep}: ${params.question}`, "goal.wizard.question.title", { qStep: qStep, "params.question": params.question });
391
+ const optionsJoined = params.options.join(" / ");
392
+ const choiceSuffixZh = isChoice ? `【${optionsJoined}】` : "";
393
+ const choiceSuffixEn = isChoice ? ` [${optionsJoined}]` : "";
394
+ await this.pushWizardCard(mainSession, pick(lang, `🔍 第 ${qStep} 题:${params.question}${choiceSuffixZh}`, `🔍 Question ${qStep}: ${params.question}${choiceSuffixEn}`, "goal.wizard.question.card", {
395
+ qStep: qStep,
396
+ "params.question": params.question,
397
+ choiceSuffixZh: choiceSuffixZh,
398
+ choiceSuffixEn: choiceSuffixEn,
399
+ }), { question: params.question });
362
400
  // Resolve the pending dialog as cancelled if the wizard is aborted.
363
401
  let aborted = false;
364
402
  const onAbort = () => {
365
403
  aborted = true;
366
404
  };
367
405
  ac.signal.addEventListener("abort", onAbort, { once: true });
368
- const choose = isChoice
369
- ? ctx.ui.select(`🔍 第 ${qStep} 题:${params.question}`, params.options)
370
- : ctx.ui.input(`🔍 第 ${qStep} 题:${params.question}`);
406
+ const choose = isChoice ? ctx.ui.select(qTitle, params.options) : ctx.ui.input(qTitle);
371
407
  const ans = (await choose);
372
408
  ac.signal.removeEventListener("abort", onAbort);
373
409
  if (aborted || ac.signal.aborted) {
@@ -375,7 +411,7 @@ export class GoalService {
375
411
  content: [
376
412
  {
377
413
  type: "text",
378
- text: "(调研已取消,请不要继续提问,直接结束对话)",
414
+ text: pick(lang, "(调研已取消,请不要继续提问,直接结束对话)", "(The survey was cancelled — stop asking and end the conversation)", "goal.wizard.cancelled.stop"),
379
415
  },
380
416
  ],
381
417
  details: {},
@@ -386,30 +422,40 @@ export class GoalService {
386
422
  content: [
387
423
  {
388
424
  type: "text",
389
- text: "(用户已取消调研,请直接给出你当前收敛的目标文本作为最终答案)",
425
+ text: pick(lang, "(用户已取消调研,请直接给出你当前收敛的目标文本作为最终答案)", "(The user cancelled the survey — reply with your best-effort goal text as the final answer)", "goal.wizard.cancelled.best"),
390
426
  },
391
427
  ],
392
428
  details: {},
393
429
  };
394
430
  }
395
431
  // Record the answer in the flow too (instant append, main session idle).
396
- await this.pushWizardCard(mainSession, `↳ 您的回答:${ans}`, {
432
+ await this.pushWizardCard(mainSession, pick(lang, `↳ 您的回答:${ans}`, `↳ Your answer: ${ans}`, "goal.wizard.answer.card", { ans: ans }), {
397
433
  question: params.question,
398
434
  answer: String(ans),
399
435
  });
400
436
  return {
401
- content: [{ type: "text", text: `用户回答:${ans}` }],
437
+ content: [
438
+ {
439
+ type: "text",
440
+ text: pick(lang, `用户回答:${ans}`, `User answer: ${ans}`, "goal.wizard.answer.return", {
441
+ ans: ans,
442
+ }),
443
+ },
444
+ ],
402
445
  details: {},
403
446
  };
404
447
  }
405
448
  catch (err) {
449
+ const errMsg = err.message;
406
450
  return {
407
451
  content: [
408
452
  {
409
453
  type: "text",
410
454
  text: ac.signal.aborted
411
- ? "(调研已取消,请不要继续提问,直接结束对话)"
412
- : `提问失败:${err.message}`,
455
+ ? pick(lang, "(调研已取消,请不要继续提问,直接结束对话)", "(The survey was cancelled — stop asking and end the conversation)", "goal.wizard.cancelled.aborted")
456
+ : pick(lang, `提问失败:${errMsg}`, `Failed to ask: ${errMsg}`, "goal.wizard.ask.failed", {
457
+ errMsg: errMsg,
458
+ }),
413
459
  },
414
460
  ],
415
461
  details: {},
@@ -526,7 +572,8 @@ export class GoalService {
526
572
  // The kick-off is a user message so it appears in the flow and triggers a
527
573
  // normal turn; the finishing agent_end then runs the review loop.
528
574
  try {
529
- await mainSession.sendUserMessage(`【目标已设定】\n\n${wgoal.goal}\n\n请现在开始实现这个目标。`, {
575
+ const wizardKick = pick(this.lang(), `【目标已设定】\n\n${wgoal.goal}\n\n请现在开始实现这个目标。`, `[Goal set]\n\n${wgoal.goal}\n\nStart implementing this goal now.`, "goal.wizard.kick", { "wgoal.goal": wgoal.goal });
576
+ await mainSession.sendUserMessage(wizardKick, {
530
577
  deliverAs: mainSession.isStreaming ? "steer" : "followUp",
531
578
  });
532
579
  }
@@ -538,6 +585,8 @@ export class GoalService {
538
585
  * touching the active goal — so changes in the goal bar are remembered across
539
586
  * reloads. maxRounds 0 = unlimited. Emits goal_status so the UI stays synced. */
540
587
  async setGoalPrefs(opts) {
588
+ if (!this.goalEnabled())
589
+ return;
541
590
  const goal = this.host.activeConv().goal;
542
591
  if (opts?.reviewModel !== undefined)
543
592
  goal.reviewModel = opts.reviewModel || null;
@@ -621,7 +670,12 @@ export class GoalService {
621
670
  // Goal review hook: after the run finished normally, if a goal is
622
671
  // active (and it belonged to the ACTIVE conversation) and we're not
623
672
  // already mid-review, spawn the isolated reviewer.
624
- if (g.goal && g.conversationId === conv.id && !g.reviewing && !conv.wizardRunning && !this.host.isDisposed()) {
673
+ if (g.goal &&
674
+ g.conversationId === conv.id &&
675
+ !g.reviewing &&
676
+ !conv.wizardRunning &&
677
+ !this.host.isDisposed() &&
678
+ this.goalEnabled()) {
625
679
  void this.runGoalReview(conv);
626
680
  }
627
681
  return null;
@@ -743,7 +797,7 @@ export class GoalService {
743
797
  return;
744
798
  }
745
799
  let reviewerVerdict = "fail";
746
- let reviewerFeedback = "(审查无法完成)";
800
+ let reviewerFeedback = pick(this.lang(), "(审查无法完成)", "(The review could not be completed)", "goal.review.incomplete");
747
801
  try {
748
802
  const rmSpec = this.resolveReviewModel(g.reviewModel);
749
803
  const services = await createAgentSessionServices({
@@ -800,8 +854,9 @@ export class GoalService {
800
854
  await srv.session.dispose();
801
855
  }
802
856
  catch (err) {
857
+ const reviewErrMsg = err.message;
803
858
  reviewerVerdict = "fail";
804
- reviewerFeedback = `审查过程中出错:${err.message}`;
859
+ reviewerFeedback = pick(this.lang(), `审查过程中出错:${reviewErrMsg}`, `Error during review: ${reviewErrMsg}`, "goal.review.error", { reviewErrMsg: reviewErrMsg });
805
860
  }
806
861
  // The user may have switched chats or replaced/cleared the goal while the
807
862
  // isolated reviewer was running. Never apply a stale verdict or inject it
@@ -833,7 +888,8 @@ export class GoalService {
833
888
  // USER the outcome and hands the main agent back out of "goal mode", so a
834
889
  // follow-up instruction like "发布" is a normal request — not a confirm echo.
835
890
  try {
836
- await mainSession.sendUserMessage(`✅ 目标已达成并通过审查(第 ${round} 轮)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已解除,接下来按你的普通指令响应。)`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
891
+ const passText = pick(this.lang(), `✅ 目标已达成并通过审查(第 ${round} 轮)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已解除,接下来按你的普通指令响应。)`, `✅ Goal achieved and passed review (round ${round}).\n\nGoal: ${goalText}\n\n${feedback}\n\n(Goal mode is off — respond to further instructions normally.)`, "goal.review.pass", { round: round, goalText: goalText, feedback: feedback });
892
+ await mainSession.sendUserMessage(passText, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
837
893
  }
838
894
  catch {
839
895
  // Best-effort.
@@ -856,8 +912,11 @@ export class GoalService {
856
912
  // Inject the reviewer's feedback into the main session to revise (this IS
857
913
  // the fail review result, as an ordinary user message — no separate card).
858
914
  try {
859
- const steerText = `【目标审查:第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过】\n\n目标:${goalText}\n\n` +
860
- `审查意见:${feedback}\n\n请根据以上意见修改你的成果,使其完全满足目标。`;
915
+ const capped = budgetForCard > 0 ? budgetForCard : "不限";
916
+ const cappedEn = budgetForCard > 0 ? budgetForCard : "unlimited";
917
+ const steerText = pick(this.lang(), `【目标审查:第 ${g.round}/${capped} 轮未通过】\n\n目标:${goalText}\n\n` +
918
+ `审查意见:${feedback}\n\n请根据以上意见修改你的成果,使其完全满足目标。`, `[Goal review: round ${g.round}/${cappedEn} failed]\n\nGoal: ${goalText}\n\n` +
919
+ `Feedback: ${feedback}\n\nRevise your work based on the feedback above so it fully satisfies the goal.`, "goal.review.revise", { "g.round": g.round, capped: capped, goalText: goalText, feedback: feedback, cappedEn: cappedEn });
861
920
  await mainSession.sendUserMessage(steerText, {
862
921
  deliverAs: mainSession.isStreaming ? "steer" : "followUp",
863
922
  });
@@ -882,7 +941,9 @@ export class GoalService {
882
941
  g.statusEn = `Goal failed (${roundsEn})`;
883
942
  }
884
943
  try {
885
- await mainSession.sendUserMessage(`❌ 目标未通过审查(第 ${round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
944
+ const capped = budgetForCard > 0 ? budgetForCard : "不限";
945
+ const cappedEn = budgetForCard > 0 ? budgetForCard : "unlimited";
946
+ await mainSession.sendUserMessage(pick(this.lang(), `❌ 目标未通过审查(第 ${round}/${capped} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, `❌ Goal failed review (round ${round}/${cappedEn}).\n\nGoal: ${goalText}\n\nFeedback: ${feedback}`, "goal.review.fail", { round: round, capped: capped, goalText: goalText, feedback: feedback, cappedEn: cappedEn }), { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
886
947
  }
887
948
  catch {
888
949
  // Best-effort.