@epoch-agent/view 0.23.0 → 0.26.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/README.md +15 -1
- package/dist/index.d.ts +73 -6
- package/dist/index.js +28 -12
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -18,9 +18,23 @@
|
|
|
18
18
|
| `applyStreamEvent(state, event)` | 折叠一个 `AgentEvent`,返回新的 item 列表 + 变更信息 |
|
|
19
19
|
| `diffLines(before, after)` | 行级 diff |
|
|
20
20
|
| `collapseContext(lines)` | 把连续的未改动行折叠成 `@@` 摘要 |
|
|
21
|
-
| `DIFF_CONTEXT_LINES` / `DIFF_MAX_LINES` |
|
|
21
|
+
| `DIFF_CONTEXT_LINES` / `DIFF_MAX_LINES` | 折叠与截断的阈值(后者的口径见下) |
|
|
22
22
|
| `HistoryItem*` / `ToolDisplayItem` 等类型 | item 的判别联合 |
|
|
23
23
|
|
|
24
|
+
## 行级 diff 的两个口径(2026-10-09)
|
|
25
|
+
|
|
26
|
+
| 事 | 口径 |
|
|
27
|
+
| ----------------------- | ------------------------------------------------------------------------------------------------- |
|
|
28
|
+
| `DIFF_MAX_LINES` 量什么 | **剥掉公共前后缀之后剩下的那一段**,不是整份文件。6000 行的文件改一行照画,只有中间那段真大才撞线 |
|
|
29
|
+
| 截断那一档报什么 | 只有两侧整份的 `oldLines` / `newLines`,**没有** `added` / `removed`(`DiffTruncated`) |
|
|
30
|
+
|
|
31
|
+
两条都是同一条规矩:**宁可少说,不说错的。** 以前量的是整份行数,于是「600 行改
|
|
32
|
+
两行」和「整份重写 600 行」落进同一档、一行都看不到;以前那两个数装的是文件行数,
|
|
33
|
+
而两个宿主都把它画成 `+N −M`,于是「1000 行改 2 行」显示成 `+1000 −1000`。
|
|
34
|
+
|
|
35
|
+
上限取 2000 的读数、剥前后缀的等价性、以及哪一支上有哪些字段,都在
|
|
36
|
+
[diff-lines.ts](src/diff-lines.ts) 的文件头和 `DiffTruncated` 上 —— 这里不抄第二份。
|
|
37
|
+
|
|
24
38
|
## 分界线
|
|
25
39
|
|
|
26
40
|
> ⚠️ **这里进了 React 就说明分界画错了。** 真到那一步,该拆成 `view-core` + 各自的
|
package/dist/index.d.ts
CHANGED
|
@@ -270,9 +270,22 @@ declare function applyStreamEvent(state: StreamReduceResult, event: AgentEvent,
|
|
|
270
270
|
* 旧版 TUI 把它渲染成一行「📝 补丁预览: a.ts」,两段内容直接扔了。
|
|
271
271
|
*
|
|
272
272
|
* 用经典 LCS 动态规划,**但有上限**:DP 表是 O(n·m),几千行的文件会吃掉几十 MB
|
|
273
|
-
*
|
|
273
|
+
* 并且卡住渲染线程。超限就退回「整份 N 行 → M 行」的摘要 —— 少显示细节可以接受,
|
|
274
274
|
* 界面卡死不行。
|
|
275
275
|
*
|
|
276
|
+
* ## 2026-10-09:先剥公共前后缀,上限只量「要逐行比的那一段」
|
|
277
|
+
*
|
|
278
|
+
* 在此之前判的是**整份文件的行数**(`a.length > 400 || b.length > 400`),于是
|
|
279
|
+
* 「600 行的 README 改两行」和「整份重写 600 行」落进同一档:一张
|
|
280
|
+
* 「内容超过逐行比对上限」的空卡。而前者才是补丁的常态,后者反倒少。
|
|
281
|
+
*
|
|
282
|
+
* 剥公共前后缀不改结果:公共前后缀一定可以出现在某个最优 LCS 里,所以先摘掉
|
|
283
|
+
* 它们、算完中间那段再拼回去,得到的就是完整解。收益是上限从此只量中间那一段 ——
|
|
284
|
+
* 几百行的文件改几行,要逐行比的只有几行。
|
|
285
|
+
*
|
|
286
|
+
* ⚠️ **这一处改的是 view,两个宿主跟着一起变**(`tui` / `web` 用的是同一份,
|
|
287
|
+
* 正是本文件存在的理由)—— 别在某一侧再改一遍。
|
|
288
|
+
*
|
|
276
289
|
* ## 为什么住在 view 而不是 tui / web
|
|
277
290
|
*
|
|
278
291
|
* `diffLines` 原来在 `tui/src/streaming/`、`collapseContext` 原来在
|
|
@@ -287,25 +300,79 @@ declare function applyStreamEvent(state: StreamReduceResult, event: AgentEvent,
|
|
|
287
300
|
* 固定行数并打一句「还有 N 行」;浏览器给 `max-height` + 内部滚动,一行不丢。
|
|
288
301
|
* 那不是 diff 语义,搬上来反而会让本包去关心它不该知道的终端高度。
|
|
289
302
|
*/
|
|
290
|
-
/**
|
|
291
|
-
|
|
303
|
+
/**
|
|
304
|
+
* 超过这个行数就不做逐行 diff。
|
|
305
|
+
*
|
|
306
|
+
* ⚠️ **量的是「剥掉公共前后缀之后剩下的那一段」**,不是整份文件的行数(2026-10-09
|
|
307
|
+
* 改的,判据在文件头)。
|
|
308
|
+
*
|
|
309
|
+
* 400 那个值当初是按最坏情况定的:DP 表是 O(n·m),几千行要几十 MB 并且卡住渲染
|
|
310
|
+
* 线程 —— **那句话本身没错,取值偏保守了一个数量级**。本机实测(照抄本文件的
|
|
311
|
+
* `lcsTable`,含建表和回溯):
|
|
312
|
+
*
|
|
313
|
+
* | 行数 | 小改动(10 处) | 整份重写 | DP 表 |
|
|
314
|
+
* | ---- | --------------- | -------- | ------ |
|
|
315
|
+
* | 400 | 1.9ms | 0.4ms | 0.6MB |
|
|
316
|
+
* | 600 | 1.9ms | 1.0ms | 1.4MB |
|
|
317
|
+
* | 2000 | 15.3ms | 7.8ms | 16MB |
|
|
318
|
+
* | 3000 | 34.2ms | 21.7ms | 36MB |
|
|
319
|
+
* | 5000 | 106ms | 51ms | 100MB |
|
|
320
|
+
*
|
|
321
|
+
* 取 2000:单张卡一次 `useMemo` 的预算里 15ms / 16MB 还能认。再往上(3000 起)
|
|
322
|
+
* 内存按 n² 涨,那一步该换算法而不是继续抬这个数。
|
|
323
|
+
*
|
|
324
|
+
* ⚠️ **别只看单张卡**:一屏十几张补丁卡时这个数是乘起来的。
|
|
325
|
+
*
|
|
326
|
+
* ⚠️ 和上游那个闸门对一下:core 的 `MAX_FILE_BYTES = 128KB`(注释写着约三千行)
|
|
327
|
+
* 是**服务端发不发内容**的线 —— 它比这一条宽得多。两个数**不需要相等**
|
|
328
|
+
* (发得过来不等于画得动),但知道它们差多少,才判得出「发过来却被丢掉」有多少。
|
|
329
|
+
*/
|
|
330
|
+
declare const DIFF_MAX_LINES = 2000;
|
|
292
331
|
type DiffLineKind = 'add' | 'remove' | 'context';
|
|
293
332
|
interface DiffLine {
|
|
294
333
|
kind: DiffLineKind;
|
|
295
334
|
text: string;
|
|
296
335
|
}
|
|
297
|
-
|
|
336
|
+
/** 两侧**整份**各有几行。截断那一档就靠它说「整份 N → M 行」 */
|
|
337
|
+
interface DiffSides {
|
|
338
|
+
oldLines: number;
|
|
339
|
+
newLines: number;
|
|
340
|
+
}
|
|
341
|
+
/** 逐行比过了 */
|
|
342
|
+
interface DiffAligned extends DiffSides {
|
|
343
|
+
truncated: false;
|
|
298
344
|
lines: DiffLine[];
|
|
345
|
+
/** 改动**行数**。两侧的公共前后缀是 context,不算进来 */
|
|
299
346
|
added: number;
|
|
300
347
|
removed: number;
|
|
301
|
-
/** true = 内容太大,没有逐行比,`lines` 为空 */
|
|
302
|
-
truncated: boolean;
|
|
303
348
|
}
|
|
349
|
+
/**
|
|
350
|
+
* 没逐行比 —— 只剩下两侧的整份行数。
|
|
351
|
+
*
|
|
352
|
+
* ## 这一支上**刻意没有** `added` / `removed`
|
|
353
|
+
*
|
|
354
|
+
* 2026-10-09 之前有,装的是 `b.length` / `a.length`(两侧的**整份行数**),
|
|
355
|
+
* 而两个宿主都把它画成头部的 `+N −M`:于是「1000 行的文件改 2 行」显示成
|
|
356
|
+
* `+1000 −1000`。整份重写时它碰巧对,那只是巧合 —— 一个算错的数字比没有数字
|
|
357
|
+
* 糟得多,所以它在这一支上**在结构上就不存在**,不是「算了但不画」
|
|
358
|
+
* (同 `thread/patch.tsx` 的 `PatchShape` 那条判据)。
|
|
359
|
+
*
|
|
360
|
+
* 要看这一份到底改了什么,得让 `lines` 那一支回来 —— 也就是把要逐行比的那一段
|
|
361
|
+
* 压到 {@link DIFF_MAX_LINES} 以内。
|
|
362
|
+
*/
|
|
363
|
+
interface DiffTruncated extends DiffSides {
|
|
364
|
+
truncated: true;
|
|
365
|
+
}
|
|
366
|
+
type DiffResult = DiffAligned | DiffTruncated;
|
|
304
367
|
/**
|
|
305
368
|
* 比较两段文本,产出带 add / remove / context 标记的行序列。
|
|
306
369
|
*
|
|
307
370
|
* 内容缺失(undefined)时按空串处理:新建文件只有 newContent,删除只有
|
|
308
371
|
* oldContent,两种都该显示成纯增 / 纯删而不是空 diff。
|
|
372
|
+
*
|
|
373
|
+
* 先剥公共前后缀再对中间那段跑 LCS,见文件头与 {@link DIFF_MAX_LINES}。
|
|
374
|
+
* 返回的 `lines` **永远是整份文件的**(前后缀作为 context 拼在前面和后面)——
|
|
375
|
+
* 剥只是省掉一次没必要的大表,不是「只给中间那一截」。
|
|
309
376
|
*/
|
|
310
377
|
declare function diffLines(oldContent?: string, newContent?: string): DiffResult;
|
|
311
378
|
/** 变更行前后各保留几行上下文 */
|
package/dist/index.js
CHANGED
|
@@ -122,7 +122,7 @@ function applyStreamEvent(state, event, meta) {
|
|
|
122
122
|
}
|
|
123
123
|
}
|
|
124
124
|
// src/diff-lines.ts
|
|
125
|
-
var DIFF_MAX_LINES =
|
|
125
|
+
var DIFF_MAX_LINES = 2e3;
|
|
126
126
|
function splitLines(text) {
|
|
127
127
|
if (text === "") return [];
|
|
128
128
|
const lines = text.split("\n");
|
|
@@ -146,33 +146,49 @@ function lcsTable(a, b) {
|
|
|
146
146
|
function diffLines(oldContent = "", newContent = "") {
|
|
147
147
|
const a = splitLines(oldContent);
|
|
148
148
|
const b = splitLines(newContent);
|
|
149
|
-
|
|
150
|
-
|
|
149
|
+
const sides = { oldLines: a.length, newLines: b.length };
|
|
150
|
+
const { prefix, suffix } = trimCommon(a, b);
|
|
151
|
+
const aMid = a.slice(prefix, a.length - suffix);
|
|
152
|
+
const bMid = b.slice(prefix, b.length - suffix);
|
|
153
|
+
if (aMid.length > DIFF_MAX_LINES || bMid.length > DIFF_MAX_LINES) {
|
|
154
|
+
return { ...sides, truncated: true };
|
|
151
155
|
}
|
|
152
|
-
const table = lcsTable(
|
|
156
|
+
const table = lcsTable(aMid, bMid);
|
|
153
157
|
const lines = [];
|
|
154
158
|
let added = 0;
|
|
155
159
|
let removed = 0;
|
|
160
|
+
for (let k = 0; k < prefix; k++) lines.push({ kind: "context", text: a[k] });
|
|
156
161
|
let i = 0;
|
|
157
162
|
let j = 0;
|
|
158
|
-
while (i <
|
|
159
|
-
if (
|
|
160
|
-
lines.push({ kind: "context", text:
|
|
163
|
+
while (i < aMid.length && j < bMid.length) {
|
|
164
|
+
if (aMid[i] === bMid[j]) {
|
|
165
|
+
lines.push({ kind: "context", text: aMid[i] });
|
|
161
166
|
i++;
|
|
162
167
|
j++;
|
|
163
168
|
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
164
|
-
lines.push({ kind: "remove", text:
|
|
169
|
+
lines.push({ kind: "remove", text: aMid[i] });
|
|
165
170
|
removed++;
|
|
166
171
|
i++;
|
|
167
172
|
} else {
|
|
168
|
-
lines.push({ kind: "add", text:
|
|
173
|
+
lines.push({ kind: "add", text: bMid[j] });
|
|
169
174
|
added++;
|
|
170
175
|
j++;
|
|
171
176
|
}
|
|
172
177
|
}
|
|
173
|
-
for (; i <
|
|
174
|
-
for (; j <
|
|
175
|
-
|
|
178
|
+
for (; i < aMid.length; i++, removed++) lines.push({ kind: "remove", text: aMid[i] });
|
|
179
|
+
for (; j < bMid.length; j++, added++) lines.push({ kind: "add", text: bMid[j] });
|
|
180
|
+
for (let k = suffix; k > 0; k--) lines.push({ kind: "context", text: a[a.length - k] });
|
|
181
|
+
return { ...sides, lines, added, removed, truncated: false };
|
|
182
|
+
}
|
|
183
|
+
function trimCommon(a, b) {
|
|
184
|
+
const max = Math.min(a.length, b.length);
|
|
185
|
+
let prefix = 0;
|
|
186
|
+
while (prefix < max && a[prefix] === b[prefix]) prefix += 1;
|
|
187
|
+
let suffix = 0;
|
|
188
|
+
while (suffix < max - prefix && a[a.length - 1 - suffix] === b[b.length - 1 - suffix]) {
|
|
189
|
+
suffix += 1;
|
|
190
|
+
}
|
|
191
|
+
return { prefix, suffix };
|
|
176
192
|
}
|
|
177
193
|
var DIFF_CONTEXT_LINES = 1;
|
|
178
194
|
function collapseContext(lines, context = DIFF_CONTEXT_LINES) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@epoch-agent/view",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "epoch-agent 展示层纯逻辑:AgentEvent → 可渲染 item,tui 与 web 共用",
|
|
6
6
|
"repository": {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"dist"
|
|
23
23
|
],
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@epoch-agent/protocol": "0.
|
|
25
|
+
"@epoch-agent/protocol": "0.26.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"tsup": "^8.3.6",
|