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.
- package/CHANGELOG.md +62 -0
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +751 -206
- package/dist/server/agent-service.js +1869 -85
- package/dist/server/approval-rules.js +550 -0
- package/dist/server/attachment-store.js +112 -0
- package/dist/server/attachments.js +62 -171
- package/dist/server/client-state.js +4 -0
- package/dist/server/compact-context-tool.js +125 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/dangling-tools.js +162 -0
- package/dist/server/dsh/dsh-agent-service.js +50 -60
- package/dist/server/edit-soft-tool.js +70 -5
- package/dist/server/eval-tool.js +535 -0
- package/dist/server/files-service.js +35 -7
- package/dist/server/goal-service.js +240 -68
- package/dist/server/hashline-engine.js +665 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/http-proxy.js +11 -1
- package/dist/server/index.js +157 -9
- package/dist/server/lsp-tool.js +679 -0
- package/dist/server/model-admin.js +20 -0
- package/dist/server/office-parse.js +309 -0
- package/dist/server/patch-tool.js +91 -0
- package/dist/server/plan-manager.js +104 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- package/dist/server/plugin-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +47 -11
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugins.js +745 -161
- package/dist/server/process-utils.js +12 -6
- package/dist/server/scheduler-tasks.js +6 -0
- package/dist/server/serialize.js +13 -3
- package/dist/server/settings-service.js +108 -1
- package/dist/server/subagents.js +41 -0
- package/dist/server/terminals.js +69 -14
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +214 -6
- package/dist/server/workspace-snapshot.js +113 -0
- package/package.json +3 -2
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +20 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/dark-teal.css +20 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
- package/web/dist/assets/index-BXnCQL9p.js +364 -0
- package/web/dist/assets/index-RpIGFyi_.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-18jK6al2.js +0 -364
- 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
|
-
|
|
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
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
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[
|
|
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
|
-
|
|
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 =
|
|
223
|
-
const r =
|
|
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) {
|