pi-web-ui 0.94.0 → 0.95.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 (65) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +5 -6
  3. package/README.zh-CN.md +4 -5
  4. package/bin/pi-web-ui.mjs +751 -206
  5. package/dist/server/agent-service.js +1869 -85
  6. package/dist/server/approval-rules.js +550 -0
  7. package/dist/server/attachment-store.js +112 -0
  8. package/dist/server/attachments.js +62 -171
  9. package/dist/server/client-state.js +4 -0
  10. package/dist/server/compact-context-tool.js +125 -0
  11. package/dist/server/context-budget.js +317 -0
  12. package/dist/server/dangling-tools.js +162 -0
  13. package/dist/server/dsh/dsh-agent-service.js +50 -60
  14. package/dist/server/edit-soft-tool.js +70 -5
  15. package/dist/server/eval-tool.js +535 -0
  16. package/dist/server/files-service.js +35 -7
  17. package/dist/server/goal-service.js +240 -68
  18. package/dist/server/hashline-engine.js +665 -0
  19. package/dist/server/host-metrics.js +26 -2
  20. package/dist/server/http-proxy.js +11 -1
  21. package/dist/server/index.js +157 -9
  22. package/dist/server/lsp-tool.js +679 -0
  23. package/dist/server/model-admin.js +20 -0
  24. package/dist/server/office-parse.js +309 -0
  25. package/dist/server/patch-tool.js +91 -0
  26. package/dist/server/plan-manager.js +104 -0
  27. package/dist/server/plugin-api-catalog.js +296 -0
  28. package/dist/server/plugin-install-spec.js +196 -0
  29. package/dist/server/plugin-installer.js +73 -0
  30. package/dist/server/plugin-manifest-validate.js +305 -0
  31. package/dist/server/plugin-project.js +47 -11
  32. package/dist/server/plugin-tool-guard.js +120 -0
  33. package/dist/server/plugins.js +745 -161
  34. package/dist/server/process-utils.js +12 -6
  35. package/dist/server/scheduler-tasks.js +6 -0
  36. package/dist/server/serialize.js +13 -3
  37. package/dist/server/settings-service.js +108 -1
  38. package/dist/server/subagents.js +41 -0
  39. package/dist/server/terminals.js +69 -14
  40. package/dist/server/tool-approval.js +84 -0
  41. package/dist/server/tool-manager.js +214 -6
  42. package/dist/server/workspace-snapshot.js +113 -0
  43. package/package.json +3 -2
  44. package/plugin-sdk/README.md +25 -0
  45. package/plugin-sdk/index.d.ts +31 -38
  46. package/plugin-sdk/index.mjs +35 -19
  47. package/plugins/catalog.json +20 -0
  48. package/themes/ayu-light.css +6 -6
  49. package/themes/catppuccin-latte.css +6 -6
  50. package/themes/claude-code-dark.css +144 -0
  51. package/themes/codex.css +6 -6
  52. package/themes/dark-teal.css +20 -6
  53. package/themes/everforest-light.css +6 -6
  54. package/themes/geist.css +6 -6
  55. package/themes/gruvbox-light.css +6 -6
  56. package/themes/kanagawa-lotus.css +6 -6
  57. package/themes/rose-pine-dawn.css +6 -6
  58. package/themes/solarized-light.css +6 -6
  59. package/themes/vs-code-dark.css +146 -0
  60. package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
  61. package/web/dist/assets/index-BXnCQL9p.js +364 -0
  62. package/web/dist/assets/index-RpIGFyi_.css +1 -0
  63. package/web/dist/index.html +2 -2
  64. package/web/dist/assets/index-18jK6al2.js +0 -364
  65. package/web/dist/assets/index-B3S9MxnN.css +0 -1
@@ -124,9 +124,11 @@ const DEFAULT_SETTINGS = {
124
124
  terminalToolsEnabled: false,
125
125
  terminalBash: false,
126
126
  terminalBashIdleMs: 15_000,
127
+ terminalBashMaxForegroundMs: 60_000,
127
128
  toolWatchdogTimeoutMs: 20 * 60_000,
128
129
  editSoftEnabled: false,
129
130
  questionnaireEnabled: true,
131
+ toolApprovalEnabled: true,
130
132
  goalModeEnabled: true,
131
133
  parallelReminderEnabled: true,
132
134
  thinkingWrap: false,
@@ -271,9 +273,11 @@ export class DshClientSession {
271
273
  terminalToolsEnabled: savedSettings.terminalToolsEnabled,
272
274
  terminalBash: savedSettings.terminalBash,
273
275
  terminalBashIdleMs: savedSettings.terminalBashIdleMs,
276
+ terminalBashMaxForegroundMs: savedSettings.terminalBashMaxForegroundMs ?? 60_000,
274
277
  toolWatchdogTimeoutMs: savedSettings.toolWatchdogTimeoutMs ?? 20 * 60_000,
275
278
  editSoftEnabled: savedSettings.editSoftEnabled,
276
279
  questionnaireEnabled: savedSettings.questionnaireEnabled ?? true,
280
+ toolApprovalEnabled: savedSettings.toolApprovalEnabled ?? true,
277
281
  goalModeEnabled: savedSettings.goalModeEnabled ?? true,
278
282
  parallelReminderEnabled: savedSettings.parallelReminderEnabled ?? true,
279
283
  thinkingWrap: savedSettings.thinkingWrap,
@@ -1803,62 +1807,46 @@ export class DshClientSession {
1803
1807
  }
1804
1808
  }
1805
1809
  else if (resolved) {
1806
- if (a.mode === "inline" || a.mode === undefined) {
1807
- // 内联文本(小文件直接读内容)。
1810
+ // 一律只给路径引用:不再把文件内容注入 prompt(小文件也一样),模型用
1811
+ // 自己的读取工具按需读。行范围模式带上行号提示。
1812
+ // 工作区图片文件仍走 attachment store → 真 image 块(模型可看图)。
1813
+ const refText = pick(lang, `\n[文件引用: ${resolved.rel}]`, `\n[File reference: ${resolved.rel}]`, "dsh.attach.file.ref", {
1814
+ "resolved.rel": resolved.rel,
1815
+ }) +
1816
+ (a.mode === "lines" && a.lines
1817
+ ? pick(lang, `(第 ${a.lines.start}-${a.lines.end} 行)`, ` (lines ${a.lines.start}-${a.lines.end})`)
1818
+ : "");
1819
+ let isImage = false;
1820
+ try {
1821
+ const st = statSync(resolved.abs);
1822
+ isImage = st.isFile() && st.size > 0 && st.size <= 512 * 1024 && previewKind(resolved.abs) === "image";
1823
+ }
1824
+ catch {
1825
+ /* 不存在/不可读 → 照样给路径引用,读取工具会自己报错 */
1826
+ }
1827
+ if (isImage) {
1808
1828
  try {
1809
- const st = statSync(resolved.abs);
1810
- if (st.size <= 512 * 1024) {
1811
- const buf = readFileSync(resolved.abs);
1812
- const kind = previewKind(resolved.abs);
1813
- if (kind === "image") {
1814
- // 工作区图片文件 → attachment store → 真 image 块。
1815
- try {
1816
- const ext = (resolved.rel.match(/\.([a-z0-9]+)$/iu)?.[1] ?? "png").toLowerCase();
1817
- const mediaType = ext === "jpg" || ext === "jpeg"
1818
- ? "image/jpeg"
1819
- : ext === "webp"
1820
- ? "image/webp"
1821
- : ext === "gif"
1822
- ? "image/gif"
1823
- : "image/png";
1824
- const saved = await this.runtime.attachmentSave(mediaType, buf.toString("base64"), resolved.rel);
1825
- blocks.push({ type: "image", attachment: saved.ref });
1826
- }
1827
- catch {
1828
- blocks.push({
1829
- type: "text",
1830
- text: pick(lang, `\n[图片附件: ${resolved.rel}]`, `\n[Image attachment: ${resolved.rel}]`, "dsh.attach.image.ref", { "resolved.rel": resolved.rel }),
1831
- });
1832
- }
1833
- }
1834
- else {
1835
- const enc = this.decodeText(buf);
1836
- const capped = enc.length > 100_000 ? `${enc.slice(0, 100_000)}\n… [truncated]` : enc;
1837
- blocks.push({
1838
- type: "text",
1839
- text: `\n<file path="${resolved.rel}">\n${capped}\n</file>`,
1840
- });
1841
- }
1842
- }
1843
- else {
1844
- blocks.push({
1845
- type: "text",
1846
- text: pick(lang, `\n[文件引用: ${resolved.rel}(大文件,请用读取工具查看)]`, `\n[File reference: ${resolved.rel} (large file, use the read tool to view it)]`, "dsh.attach.file.large", { "resolved.rel": resolved.rel }),
1847
- });
1848
- }
1829
+ const buf = readFileSync(resolved.abs);
1830
+ const ext = (resolved.rel.match(/\.([a-z0-9]+)$/iu)?.[1] ?? "png").toLowerCase();
1831
+ const mediaType = ext === "jpg" || ext === "jpeg"
1832
+ ? "image/jpeg"
1833
+ : ext === "webp"
1834
+ ? "image/webp"
1835
+ : ext === "gif"
1836
+ ? "image/gif"
1837
+ : "image/png";
1838
+ const saved = await this.runtime.attachmentSave(mediaType, buf.toString("base64"), resolved.rel);
1839
+ blocks.push({ type: "image", attachment: saved.ref });
1849
1840
  }
1850
1841
  catch {
1851
1842
  blocks.push({
1852
1843
  type: "text",
1853
- text: pick(lang, `\n[文件引用: ${resolved.rel}]`, `\n[File reference: ${resolved.rel}]`, "dsh.attach.file.ref.fallback", { "resolved.rel": resolved.rel }),
1844
+ text: pick(lang, `\n[图片附件: ${resolved.rel}]`, `\n[Image attachment: ${resolved.rel}]`, "dsh.attach.image.ref", { "resolved.rel": resolved.rel }),
1854
1845
  });
1855
1846
  }
1856
1847
  }
1857
1848
  else {
1858
- blocks.push({
1859
- type: "text",
1860
- text: pick(lang, `\n[文件引用: ${resolved.rel}]`, `\n[File reference: ${resolved.rel}]`, "dsh.attach.file.ref", { "resolved.rel": resolved.rel }),
1861
- });
1849
+ blocks.push({ type: "text", text: refText });
1862
1850
  }
1863
1851
  }
1864
1852
  else if (a.name) {
@@ -1870,19 +1858,6 @@ export class DshClientSession {
1870
1858
  }
1871
1859
  return blocks;
1872
1860
  }
1873
- decodeText(buf) {
1874
- try {
1875
- return new TextDecoder("utf-8", { fatal: true }).decode(buf);
1876
- }
1877
- catch {
1878
- try {
1879
- return new TextDecoder("gbk").decode(buf);
1880
- }
1881
- catch {
1882
- return buf.toString("latin1");
1883
- }
1884
- }
1885
- }
1886
1861
  /** 中止:kill 运行时进程树(所有 conversation 的运行停止)→ 自动重启保持可用。 */
1887
1862
  async abort() {
1888
1863
  if (!this.runtime.alive)
@@ -2624,6 +2599,7 @@ export class DshClientSession {
2624
2599
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2625
2600
  terminalBash: this.settings.terminalBash,
2626
2601
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2602
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
2627
2603
  toolWatchdogTimeoutMs: this.settings.toolWatchdogTimeoutMs,
2628
2604
  // DSH 引擎无 customTool 注册面(工具来自 shipped preset),read 目录覆盖面不存在。
2629
2605
  readDirEnabled: true,
@@ -2634,6 +2610,10 @@ export class DshClientSession {
2634
2610
  softCapTokens: 0,
2635
2611
  softCapByModel: {},
2636
2612
  questionnaireEnabled: this.settings.questionnaireEnabled,
2613
+ toolApprovalEnabled: this.settings.toolApprovalEnabled !== false,
2614
+ // DSH 无审批弹窗,策略恒为空(保协议完整)。
2615
+ approvalPolicy: { allowAll: false, categories: [] },
2616
+ approvalRules: [],
2637
2617
  goalModeEnabled: this.settings.goalModeEnabled,
2638
2618
  parallelReminderEnabled: this.settings.parallelReminderEnabled,
2639
2619
  thinkingWrap: this.settings.thinkingWrap,
@@ -2695,12 +2675,16 @@ export class DshClientSession {
2695
2675
  this.settings.terminalBash = partial.terminalBash;
2696
2676
  if (partial.terminalBashIdleMs !== undefined)
2697
2677
  this.settings.terminalBashIdleMs = partial.terminalBashIdleMs;
2678
+ if (partial.terminalBashMaxForegroundMs !== undefined)
2679
+ this.settings.terminalBashMaxForegroundMs = partial.terminalBashMaxForegroundMs;
2698
2680
  if (partial.toolWatchdogTimeoutMs !== undefined)
2699
2681
  this.settings.toolWatchdogTimeoutMs = normalizeToolWatchdogTimeoutMs(partial.toolWatchdogTimeoutMs);
2700
2682
  if (partial.editSoftEnabled !== undefined)
2701
2683
  this.settings.editSoftEnabled = partial.editSoftEnabled;
2702
2684
  if (partial.questionnaireEnabled !== undefined)
2703
2685
  this.settings.questionnaireEnabled = partial.questionnaireEnabled;
2686
+ if (partial.toolApprovalEnabled !== undefined)
2687
+ this.settings.toolApprovalEnabled = partial.toolApprovalEnabled;
2704
2688
  if (partial.goalModeEnabled !== undefined)
2705
2689
  this.settings.goalModeEnabled = partial.goalModeEnabled;
2706
2690
  if (partial.parallelReminderEnabled !== undefined)
@@ -2735,6 +2719,7 @@ export class DshClientSession {
2735
2719
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2736
2720
  terminalBash: this.settings.terminalBash,
2737
2721
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2722
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
2738
2723
  editSoftEnabled: this.settings.editSoftEnabled,
2739
2724
  // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2740
2725
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
@@ -2742,6 +2727,7 @@ export class DshClientSession {
2742
2727
  softCapTokens: 0,
2743
2728
  softCapByModel: {},
2744
2729
  questionnaireEnabled: this.settings.questionnaireEnabled,
2730
+ toolApprovalEnabled: this.settings.toolApprovalEnabled !== false,
2745
2731
  goalModeEnabled: this.settings.goalModeEnabled,
2746
2732
  parallelReminderEnabled: this.settings.parallelReminderEnabled,
2747
2733
  thinkingWrap: this.settings.thinkingWrap,
@@ -3059,6 +3045,7 @@ export class DshClientSession {
3059
3045
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
3060
3046
  terminalBash: this.settings.terminalBash,
3061
3047
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
3048
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
3062
3049
  editSoftEnabled: this.settings.editSoftEnabled,
3063
3050
  // DSH 无独立重试配置,预设沿用默认值。
3064
3051
  // DSH 无 skill 全文注入概念,给空保预设类型完整。
@@ -3099,6 +3086,8 @@ export class DshClientSession {
3099
3086
  this.settings.terminalToolsEnabled = preset.terminalToolsEnabled;
3100
3087
  this.settings.terminalBash = preset.terminalBash;
3101
3088
  this.settings.terminalBashIdleMs = preset.terminalBashIdleMs;
3089
+ this.settings.terminalBashMaxForegroundMs =
3090
+ preset.terminalBashMaxForegroundMs ?? this.settings.terminalBashMaxForegroundMs;
3102
3091
  this.settings.editSoftEnabled = preset.editSoftEnabled ?? this.settings.editSoftEnabled;
3103
3092
  this.settings.reviewPrompt = preset.reviewPrompt ?? "";
3104
3093
  this.stateStore.saveSettings(this.clientId, {
@@ -3109,6 +3098,7 @@ export class DshClientSession {
3109
3098
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
3110
3099
  terminalBash: this.settings.terminalBash,
3111
3100
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
3101
+ terminalBashMaxForegroundMs: this.settings.terminalBashMaxForegroundMs,
3112
3102
  editSoftEnabled: this.settings.editSoftEnabled,
3113
3103
  thinkingWrap: this.settings.thinkingWrap,
3114
3104
  toolsWrap: this.settings.toolsWrap,
@@ -12,7 +12,11 @@
12
12
  * 在文件里找一段连续行,其核心序列与 oldText 完全一致(仅唯一匹配才写)。
13
13
  * - 命中后按「整行替换」写入 newText **原样**(AI 给的缩进就是最终缩进),
14
14
  * 只做必要的行尾换行平衡。
15
- * - 若不支持片段(oldText 不是完整行)会在严格匹配阶段返回错误提示。
15
+ * - 若不支持片段(oldText 不是完整行)会返回错误提示:
16
+ * • 多行 oldText 跨行但首/尾未对齐行边界 → 直接拒绝(否则会吃掉行首/行尾残留、
17
+ * 写出粘连内容);
18
+ * • 宽松阶段整块对不上、且首/尾行只是某行的一部分 → 报「片段」错而非笼统的「找不到」;
19
+ * • 单行片段(如 `b();`)不改变行结构,仍照旧支持。
16
20
  *
17
21
  * 开关:设置面板「编辑」页 `editSoftEnabled`(默认关)。关闭时该工具从活跃集移除。
18
22
  */
@@ -142,6 +146,50 @@ export function oldTextCores(oldTextLF) {
142
146
  parts.pop();
143
147
  return parts.map((p) => p.trim());
144
148
  }
149
+ /**
150
+ * 判断 oldText 的首行/末行是否只是「片段」(某文件行的一部分)。
151
+ * 宽松匹配要求每行都是完整行;当整块对不上时,若首/末行的核心确实出现在文件的
152
+ * **行中部**(既不在行首也不在行尾),则极可能是模型漏抄了行首/行尾。
153
+ *
154
+ * 仅在「整块核心序列一次都没命中」后才调用,因此只作诊断,不影响正常匹配。
155
+ */
156
+ export function fragmentLineEnds(cores, units) {
157
+ const isMidLineFragment = (core) => {
158
+ if (core === "")
159
+ return false;
160
+ // 先看有没有任意一行与它整行相同:有 → 不是片段(只是缩进/上下文不对)。
161
+ if (units.some((u) => u.core === core))
162
+ return false;
163
+ // 再看它是否出现在某行的中部(前后都还有内容)→ 是片段。
164
+ return units.some((u) => {
165
+ const idx = u.raw.indexOf(core);
166
+ return idx > 0 && idx + core.length < u.raw.length;
167
+ });
168
+ };
169
+ const ends = [];
170
+ if (cores.length > 0 && isMidLineFragment(cores[0]))
171
+ ends.push(0);
172
+ if (cores.length > 1 && isMidLineFragment(cores[cores.length - 1]))
173
+ ends.push(1);
174
+ return ends;
175
+ }
176
+ /**
177
+ * 判断一段**精确命中**是否为「跨行但未对齐整行」的非法片段。
178
+ *
179
+ * 只包含单行的 oldText(如 `const x = 1; `)是安全的:替换不会改动行结构。
180
+ * 但若 oldText 跨越了换行、且首/尾没落在行边界(既不在行首/行尾,也没包含整行),
181
+ * 直接子串替换会吃掉行首/行尾的残留,写出粘连内容——例如在
182
+ * `foo(a);\nfoo(b);` 上把 `a);\nfoo(` 换成 `z();` 会得到 `foo(z();b);`。
183
+ * 这几乎总是模型漏抄行首/行尾所致,因此报错而不静默写坏。
184
+ */
185
+ export function isMisalignedMultilineFragment(content, start, oldTextLF) {
186
+ if (!oldTextLF.includes("\n"))
187
+ return false; // 单行片段:不影响行结构,允许
188
+ const end = start + oldTextLF.length;
189
+ const startsAtLineStart = start === 0 || content[start - 1] === "\n";
190
+ const endsAtLineBoundary = end === content.length || content[end] === "\n" || content[end - 1] === "\n";
191
+ return !(startsAtLineStart && endsAtLineBoundary);
192
+ }
145
193
  /** NOT_FOUND / NOT_UNIQUE 用异常类型区分(与内置 edit 报错风格一致)。 */
146
194
  class SoftEditMatchError extends Error {
147
195
  constructor(message) {
@@ -158,7 +206,12 @@ function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIn
158
206
  }
159
207
  // 1) 精确子串匹配(等价普通 edit,支持片段)
160
208
  const exactIdx = normalizedContent.indexOf(oldTextLF);
161
- if (exactIdx !== -1) {
209
+ // 非法片段防御:多行 oldText 若未对齐整行边界,精确子串替换会吃掉行首/行尾
210
+ // 残留、写出粘连内容(如 `foo(a);\nfoo(b);` 把 `a);\nfoo(` 换成 `z();` →
211
+ // `foo(z();b);`)。这种命中一律不走精确路径,改为交给下面的「整行宽松匹配」;
212
+ // 只有当整行也匹配不上时才报错拒绝(避免误伤「首行省略缩进」等合法情况)。
213
+ const exactMisaligned = exactIdx !== -1 && isMisalignedMultilineFragment(normalizedContent, exactIdx, oldTextLF);
214
+ if (exactIdx !== -1 && !exactMisaligned) {
162
215
  // 唯一性:精确匹配出现多次 → 报错(模型应提供更多上下文)
163
216
  const occurrences = normalizedContent.split(oldTextLF).length - 1;
164
217
  if (occurrences > 1) {
@@ -186,6 +239,14 @@ function locateReplacement(normalizedContent, oldTextLF, newTextLF, path, editIn
186
239
  starts.push(i);
187
240
  }
188
241
  if (starts.length === 0) {
242
+ // 非法片段诊断:宽松匹配要求 oldText 的每一行都是**完整行**。若首/尾行只是
243
+ // 某文件行的一部分(模型漏抄了行首/行尾,例如只给了 `a);` 而非 `foo(a);`),
244
+ // 逐行核心永远对不上;这里给出针对性提示,而不是笼统的「找不到」。
245
+ // exactMisaligned 说明精确子串能命中、但跨行且未对齐整行(会写出粘连内容),
246
+ // 而整行匹配又对不上 —— 这种命中绝对不能走精确路径。
247
+ if (exactMisaligned || fragmentLineEnds(cores, units).length > 0) {
248
+ throw new SoftEditMatchError(pick(lang, `在 ${path} 中找不到该文本:edits[${editIndex}].oldText 跨越多行但首/尾没有落在行边界上(首行或末行只是文件中某行的一部分),而本工具按**整行**匹配。请让 oldText 的每一行都是完整行——行首缩进可以省略,但行内容必须完整。`, `Could not find the text in ${path}: edits[${editIndex}].oldText spans multiple lines but its start/end do not fall on line boundaries (its first or last line is only part of a file line), and this tool matches whole lines. Make every line of oldText a complete line — leading indentation may be omitted, but the line content must be complete.`, "editsoft.fragment.not.supported", { path, editIndex }));
249
+ }
189
250
  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 }));
190
251
  }
191
252
  if (starts.length > 1) {
@@ -217,10 +278,14 @@ export function applySoftEdits(normalizedContent, edits, path, lang = "en") {
217
278
  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 }));
218
279
  }
219
280
  }
220
- // 逆序应用,保持左侧偏移稳定
281
+ // 逆序应用,保持左侧偏移稳定。
282
+ // 关键:必须按**位置升序**(前面的 `sorted`)再逆序应用,不能按 edits 的传入
283
+ // 顺序逆序——模型若把靠后的 edit 写在前面,未排序的逆序应用会让后面的替换先
284
+ // 改变长度,前面的偏移随即串位,写出错乱内容(历史 bug:protocol.ts /
285
+ // use-chat.ts / ChatInput.tsx 被写坏)。内置 edit 同样先按 matchIndex 排序,此为对齐语义。
221
286
  let result = normalizedContent;
222
- for (let i = replacements.length - 1; i >= 0; i--) {
223
- const r = replacements[i];
287
+ for (let i = sorted.length - 1; i >= 0; i--) {
288
+ const r = sorted[i];
224
289
  result = result.slice(0, r.start) + r.insertText + result.slice(r.end);
225
290
  }
226
291
  if (result === normalizedContent) {