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.
- package/dist/server/agent-service.js +157 -50
- package/dist/server/attachments.js +7 -2
- package/dist/server/client-state.js +27 -0
- package/dist/server/dsh/dsh-agent-service.js +139 -38
- package/dist/server/dsh/dsh-client.js +9 -8
- package/dist/server/dsh/dsh-sessions.js +4 -3
- 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 +76 -18
- package/dist/server/locales.js +55 -1
- 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 +42 -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 +20 -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 +7 -2
- package/dist/server/vision-bridge.js +34 -12
- package/package.json +2 -1
- package/web/dist/assets/{TerminalPanel-BQ5NTB9Y.js → TerminalPanel-Cj8zsjx-.js} +1 -1
- package/web/dist/assets/index-DCOcsPFm.js +334 -0
- package/web/dist/assets/{index-C_I-6Zul.css → index-jH2Bb-0X.css} +1 -1
- package/web/dist/assets/{markdown-DOsihKaR.js → markdown-Cpo0pNcR.js} +1 -1
- package/web/dist/assets/{react-DIP6JKYk.js → react-CtudoG1_.js} +1 -1
- package/web/dist/index.html +4 -4
- 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({
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
: `提问失败:${
|
|
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
|
-
|
|
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 &&
|
|
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 = `审查过程中出错:${
|
|
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
|
-
|
|
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
|
|
860
|
-
|
|
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
|
-
|
|
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.
|