@migor/cli 0.9.0 → 0.9.2
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 +106 -1
- package/dist/AGENTS.md +23 -1
- package/dist/dev-runner.js +32 -1
- package/dist/dev.js +8 -0
- package/dist/inspector/fromTrace.js +12 -2
- package/dist/inspector-page.html +364 -23
- package/dist/inspector-sink.js +19 -0
- package/dist/inspector.js +139 -7
- package/dist/markdown.js +243 -0
- package/dist/panel-logic.js +167 -0
- package/dist/templates/package.json +2 -2
- package/package.json +1 -1
package/dist/inspector.js
CHANGED
|
@@ -60,6 +60,68 @@ function validateTrace(t) {
|
|
|
60
60
|
}
|
|
61
61
|
return null;
|
|
62
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* 入站校验:**增量记账事件**的形状(`POST /ingest-event`,① 实时右栏)。
|
|
65
|
+
*
|
|
66
|
+
* 为什么卡得比 `validateTrace` 还细:面板拿这些字段**直接建树**,而坏帧的报错现场在
|
|
67
|
+
* 浏览器里(最难查的地方)。缺一个 `spanId` 的表现是「树上少一个节点」而不是任何报错 ——
|
|
68
|
+
* 所以宁可在这里响亮拒(400 会被 runner 的 event sink 静默吞掉,但至少不会污染面板)。
|
|
69
|
+
*/
|
|
70
|
+
function validateTraceEvent(e) {
|
|
71
|
+
if (!e || typeof e !== 'object' || Array.isArray(e))
|
|
72
|
+
return '事件必须是对象';
|
|
73
|
+
const ev = e;
|
|
74
|
+
if (typeof ev.seq !== 'number' || !Number.isFinite(ev.seq))
|
|
75
|
+
return 'seq 必须是有穷 number';
|
|
76
|
+
switch (ev.type) {
|
|
77
|
+
case 'span.begin': {
|
|
78
|
+
const s = ev.span;
|
|
79
|
+
if (!s || typeof s !== 'object')
|
|
80
|
+
return 'span.begin 缺 span';
|
|
81
|
+
if (typeof s.spanId !== 'string' || s.spanId.length === 0)
|
|
82
|
+
return 'span.begin 的 span.spanId 缺失';
|
|
83
|
+
if (typeof s.traceId !== 'string' || s.traceId.length === 0) {
|
|
84
|
+
return 'span.begin 的 span.traceId 缺失';
|
|
85
|
+
}
|
|
86
|
+
if (typeof s.name !== 'string')
|
|
87
|
+
return 'span.begin 的 span.name 缺失';
|
|
88
|
+
if (typeof s.startedAt !== 'number' || !Number.isFinite(s.startedAt)) {
|
|
89
|
+
return 'span.begin 的 span.startedAt 非法';
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
case 'span.end': {
|
|
94
|
+
if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
|
|
95
|
+
return 'span.end 缺 spanId';
|
|
96
|
+
if (typeof ev.endedAt !== 'number' || !Number.isFinite(ev.endedAt))
|
|
97
|
+
return 'span.end 的 endedAt 非法';
|
|
98
|
+
if (typeof ev.status !== 'string')
|
|
99
|
+
return 'span.end 的 status 缺失';
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
case 'span.event': {
|
|
103
|
+
if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
|
|
104
|
+
return 'span.event 缺 spanId';
|
|
105
|
+
if (!ev.event || typeof ev.event.name !== 'string')
|
|
106
|
+
return 'span.event 缺 event.name';
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
case 'span.attribute': {
|
|
110
|
+
if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
|
|
111
|
+
return 'span.attribute 缺 spanId';
|
|
112
|
+
if (typeof ev.key !== 'string' || ev.key.length === 0)
|
|
113
|
+
return 'span.attribute 缺 key';
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
case 'span.link': {
|
|
117
|
+
if (typeof ev.spanId !== 'string' || ev.spanId.length === 0)
|
|
118
|
+
return 'span.link 缺 spanId';
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
default:
|
|
122
|
+
return `不认识的事件类型:${String(ev.type)}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
63
125
|
/** 防 DNS rebinding:面板只服务本机,Host 不是 localhost/127.0.0.1/[::1] 的一律拒 */
|
|
64
126
|
function isLocalHostHeader(host) {
|
|
65
127
|
if (!host)
|
|
@@ -142,6 +204,11 @@ const MAX_RUNS = 50;
|
|
|
142
204
|
* (见 `rememberNote`),所以它有自己的一条淘汰线。
|
|
143
205
|
*/
|
|
144
206
|
const MAX_NOTES = MAX_RUNS;
|
|
207
|
+
/**
|
|
208
|
+
* `GET /api/fs` 一次最多回多少个**文件**名(目录不设限:一个位置要列几百个目录,本身就
|
|
209
|
+
* 说明位置选错了)。超限回 `filesTruncated: true` —— 截断必须**明示**,不许静默少给。
|
|
210
|
+
*/
|
|
211
|
+
const FILE_LIMIT = 200;
|
|
145
212
|
/** 静态资源目录(构建期由 scripts/copy-assets.mjs 就位) */
|
|
146
213
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
147
214
|
const ASSETS = join(HERE, 'inspector');
|
|
@@ -159,6 +226,9 @@ const STATIC = new Map([
|
|
|
159
226
|
['summary.js', join(ASSETS, 'summary.js')],
|
|
160
227
|
['trace-view.css', join(ASSETS, 'trace-view.css')],
|
|
161
228
|
['panel-logic.js', join(HERE, 'panel-logic.js')],
|
|
229
|
+
// 面板的 Markdown 解析器(零依赖、按构造不产生 HTML —— 见 src/markdown.ts 的头注)。
|
|
230
|
+
// 与 panel-logic.js 同处 dist 根:两个都是 CLI 自己编译出来的模块,单测直接 import 它们。
|
|
231
|
+
['markdown.js', join(HERE, 'markdown.js')],
|
|
162
232
|
]);
|
|
163
233
|
/** 没有 dev 钩子时的状态(面板据此隐藏输入条) */
|
|
164
234
|
function unavailableDevState() {
|
|
@@ -352,6 +422,42 @@ export function startInspector(opts = {}) {
|
|
|
352
422
|
json(res, 200, { ok: true });
|
|
353
423
|
return;
|
|
354
424
|
}
|
|
425
|
+
/**
|
|
426
|
+
* **在飞 run 的增量记账事件**(① 实时右栏):`POST /ingest-event`。
|
|
427
|
+
*
|
|
428
|
+
* 与 `POST /ingest` 的分工是刻意的两条缝(框架侧同名区分):
|
|
429
|
+
* - 这条 = **此刻看到**:高频、逐笔、不落库、**不保证送达**;
|
|
430
|
+
* - 那条 = **最终账**:收尾一次、进 run 列表、被 `/api/runs` 读。
|
|
431
|
+
*
|
|
432
|
+
* 所以这里**只广播**、不建 run 记录:面板拿它把树「长」出来,收尾那份整棵 trace
|
|
433
|
+
* 回来时覆盖(`open()` 的渲染永远以收尾那份为准)。
|
|
434
|
+
*
|
|
435
|
+
* 202 而不是 200:已经广播出去了,但面板收没收到不归这里管(SSE 是单向流)。
|
|
436
|
+
* 没有 dev 钩子时回 503 —— 与 `/run` / `/run/abort` 同一口径(没有面板消费它)。
|
|
437
|
+
*/
|
|
438
|
+
if (req.method === 'POST' && path === '/ingest-event') {
|
|
439
|
+
if (!opts.dev) {
|
|
440
|
+
json(res, 503, { error: '这个 inspector 不是 agentia dev 起的(没有 runner)' });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const body = await readBody(req);
|
|
444
|
+
let ev;
|
|
445
|
+
try {
|
|
446
|
+
ev = JSON.parse(body);
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
json(res, 400, { error: 'body 不是合法 JSON' });
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
const problem = validateTraceEvent(ev);
|
|
453
|
+
if (problem) {
|
|
454
|
+
json(res, 400, { error: problem });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
opts.dev.traceEvent(ev);
|
|
458
|
+
json(res, 202, { ok: true });
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
355
461
|
if (req.method === 'GET' && path === '/api/runs') {
|
|
356
462
|
json(res, 200, order
|
|
357
463
|
.slice()
|
|
@@ -374,14 +480,25 @@ export function startInspector(opts = {}) {
|
|
|
374
480
|
return;
|
|
375
481
|
}
|
|
376
482
|
/**
|
|
377
|
-
* 目录浏览器:`GET /api/fs?path=<绝对路径>` →
|
|
483
|
+
* 目录浏览器:`GET /api/fs?path=<绝对路径>` →
|
|
484
|
+
* `{ path, parent, dirs, dotDirs, files, filesTruncated }`。
|
|
378
485
|
*
|
|
379
486
|
* 为什么需要它:浏览器**拿不到**用户选的文件夹的绝对路径 —— `<input webkitdirectory>`
|
|
380
487
|
* 只给相对路径,File System Access API 只给 handle.name。而 dev 环要的是绝对路径
|
|
381
488
|
* (工具按它解析)。⇒ 要么让用户手打路径,要么服务端列目录。后者才是「工作目录是
|
|
382
489
|
* 主控件」(D3/D7)该有的手感。
|
|
383
490
|
*
|
|
384
|
-
*
|
|
491
|
+
* 三条口径(2026-09-22 复核后改,前两条是实测出来的缺口):
|
|
492
|
+
* - **文件也列**(`files`,只给名字、不读内容):面板据此提供「选这个文件」——
|
|
493
|
+
* 工作目录取它所在目录、prompt 空时填文件名。只列目录时,「让 agent 看某个文件」
|
|
494
|
+
* 只能靠用户自己把文件名打进 prompt。
|
|
495
|
+
* - **隐藏目录单列**(`dotDirs`,不在 `dirs` 里):以前一批过滤掉,于是
|
|
496
|
+
* `~/xxx/.yyy` 这类目录**根本点不进去**(`..` 只能退到它上面,进不去)。
|
|
497
|
+
* 单列是为了不把 `.git` / `.cache` 混进正常浏览,同时保证**可达**。
|
|
498
|
+
* - `files` 有上限(`FILE_LIMIT`)并回 `filesTruncated`:一次列几千个文件的名
|
|
499
|
+
* 没有意义,但**必须明示**截断(本仓纪律:不许静默少给)。
|
|
500
|
+
*
|
|
501
|
+
* 只列名字、**不读文件内容**;且只在 dev 钩子在场时开放(只读面板不暴露文件树)。
|
|
385
502
|
* 越界不是威胁(本机 dev 工具,token 已经挡住了其它进程),但**不存在 / 不是目录**
|
|
386
503
|
* 要响亮报错 —— 静默回退到项目根会让 agent 对着错的目录乱写(D5)。
|
|
387
504
|
*/
|
|
@@ -408,11 +525,26 @@ export function startInspector(opts = {}) {
|
|
|
408
525
|
json(res, 400, { error: `读不了这个目录:${e.message}` });
|
|
409
526
|
return;
|
|
410
527
|
}
|
|
411
|
-
const dirs =
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
528
|
+
const dirs = [];
|
|
529
|
+
const dotDirs = [];
|
|
530
|
+
const files = [];
|
|
531
|
+
for (const e of entries) {
|
|
532
|
+
if (isDirLikeEntry(join(abs, e.name), e)) {
|
|
533
|
+
(e.name.startsWith('.') ? dotDirs : dirs).push(e.name);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
// 读不了内容的条目(管道 / socket / 权限)也当文件列出来 —— 列名字不需要读它
|
|
537
|
+
if (!e.name.startsWith('.'))
|
|
538
|
+
files.push(e.name);
|
|
539
|
+
}
|
|
540
|
+
json(res, 200, {
|
|
541
|
+
path: abs,
|
|
542
|
+
parent: dirname(abs),
|
|
543
|
+
dirs: dirs.sort(),
|
|
544
|
+
dotDirs: dotDirs.sort(),
|
|
545
|
+
files: files.sort().slice(0, FILE_LIMIT),
|
|
546
|
+
filesTruncated: files.length > FILE_LIMIT,
|
|
547
|
+
});
|
|
416
548
|
return;
|
|
417
549
|
}
|
|
418
550
|
if (req.method === 'POST' && path === '/run') {
|
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板的 **Markdown 最小渲染器** —— 纯逻辑、零 DOM、零依赖。
|
|
3
|
+
*
|
|
4
|
+
* 为什么自己写而不引 `marked` / `markdown-it`:
|
|
5
|
+
* - **零运行时依赖是本 CLI 的硬承诺**(`package.json` 里没有 `dependencies`)。为了面板把一份
|
|
6
|
+
* 解析器 + 一份消毒器(DOMPurify 之类)塞进 dist,等于在 dev 链路里引入两个第三方供应链面,
|
|
7
|
+
* 而它们的产物**没法被本仓的单测验证**(我们只能验「我写的那部分」)。
|
|
8
|
+
* - 面板只需要**模型正文**这一种输入可读:标题 / 粗体 / 斜体 / 行内码 / 围栏码 / 列表 /
|
|
9
|
+
* 引用 / 分隔线 / 链接。这套子集手写不到 200 行,且**每条规则都能配一条单测**。
|
|
10
|
+
* - 最要紧的是**它按构造不产生 HTML**:返回值是一棵 token 树(`MdBlock` / `MdInline`),
|
|
11
|
+
* 没有任何「原样透传的 HTML」这一类。页面的渲染器只准用 `createElement` + `textContent`
|
|
12
|
+
* 把它变成 DOM ⇒ **模型输出里的 `<script>` 只会是字面文本**,不存在「忘了转义」这条路径。
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ 本文件**不许碰 DOM**(不出现 `document` / `window`)—— 它要在 Node 里被直接 import 做单测。
|
|
15
|
+
*
|
|
16
|
+
* 支持的子集(刻意不做的都在「不做」里):
|
|
17
|
+
* - 块:`#` 标题(1–6 级)、围栏码块(``` / ~~~,带语言标注)、无序/有序列表(**一层**)、
|
|
18
|
+
* 引用(合并成一段正文)、`---` 分隔线、段落
|
|
19
|
+
* - 行内:行内码、`**粗**`、`*斜*`、`[文字](链接)`、`<https://…>` 自动链接
|
|
20
|
+
*
|
|
21
|
+
* 不做(都是刻意的,不是漏):
|
|
22
|
+
* - **不做 HTML 透传**(安全前提,见上)
|
|
23
|
+
* - 不做表格 / 任务列表 / 嵌套列表 / 脚注 / 数学 —— 面板是调试视图,不是文档站
|
|
24
|
+
* - 不做 setext 标题(`===` 下划线式)—— 与分隔线歧义大、收益小
|
|
25
|
+
* - 不做图片(`![]()`)—— 面板**不去外网取图**:一次 run 的正文不该触发网络请求
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* 链接协议白名单。**这是安全边界,不是风格选择**:
|
|
29
|
+
* 模型输出里的 `[点我](javascript:alert(1))` 若被渲染成可点链接,面板就成了 XSS 的跳板
|
|
30
|
+
* (`href` 是唯一一处「我们主动写进属性」的值 —— 文本全走 `textContent` 是安全的,
|
|
31
|
+
* 属性不是)。不合法 ⇒ 这一条**整个降级成字面文本**(连 `[]()` 都照原样显示),
|
|
32
|
+
* 使用者一眼看得出「这里有个链接我没渲染」,而不是看到一个空链接或点不动的链接。
|
|
33
|
+
*/
|
|
34
|
+
export function isSafeHref(href) {
|
|
35
|
+
// 允许 http/https/mailto;**不允许** javascript: / data: / vbscript: / file: 等
|
|
36
|
+
return /^(https?:|mailto:)/i.test(href.trim());
|
|
37
|
+
}
|
|
38
|
+
const FENCE = /^\s{0,3}(`{3,}|~{3,})\s*([^\s`]*)/;
|
|
39
|
+
const HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
40
|
+
const HR = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/;
|
|
41
|
+
const QUOTE = /^\s{0,3}>\s?(.*)$/;
|
|
42
|
+
const UL = /^(\s*)([-*+])\s+(.*)$/;
|
|
43
|
+
const OL = /^(\s*)(\d{1,9})[.)]\s+(.*)$/;
|
|
44
|
+
const BLANK = /^\s*$/;
|
|
45
|
+
/**
|
|
46
|
+
* 把一段 Markdown 文本解析成块序列。
|
|
47
|
+
*
|
|
48
|
+
* 三条与「真 Markdown」不同但**刻意**的口径:
|
|
49
|
+
* - 缩进 ≥2 空格的续行归上一条列表项(列表只解析**一层**:更深一层当正文,不递归建树);
|
|
50
|
+
* - 未闭合的围栏码块吃掉到文末(**不静默丢内容** —— 模型被截断时最常见的就是这个形态);
|
|
51
|
+
* - 引用(`>`)合并成**一段正文**(面板里引用几乎总是模型在引日志,逐行建块只会更碎)。
|
|
52
|
+
*/
|
|
53
|
+
export function parseMarkdown(text) {
|
|
54
|
+
const src = String(text ?? '').replace(/\r\n?/g, '\n');
|
|
55
|
+
const lines = src.split('\n');
|
|
56
|
+
const blocks = [];
|
|
57
|
+
let i = 0;
|
|
58
|
+
let para = [];
|
|
59
|
+
const flushPara = () => {
|
|
60
|
+
if (para.length === 0)
|
|
61
|
+
return;
|
|
62
|
+
const joined = para.join('\n').trim();
|
|
63
|
+
para = [];
|
|
64
|
+
if (joined)
|
|
65
|
+
blocks.push({ type: 'p', inline: parseInline(joined, 0) });
|
|
66
|
+
};
|
|
67
|
+
while (i < lines.length) {
|
|
68
|
+
const line = lines[i];
|
|
69
|
+
// —— 围栏码块 ——
|
|
70
|
+
const fence = FENCE.exec(line);
|
|
71
|
+
if (fence) {
|
|
72
|
+
flushPara();
|
|
73
|
+
const marker = fence[1][0];
|
|
74
|
+
const min = fence[1].length;
|
|
75
|
+
const lang = fence[2] ?? '';
|
|
76
|
+
const body = [];
|
|
77
|
+
i++;
|
|
78
|
+
while (i < lines.length) {
|
|
79
|
+
const cur = lines[i];
|
|
80
|
+
const close = new RegExp(`^\\s{0,3}${marker === '`' ? '`' : '~'}{${min},}\\s*$`);
|
|
81
|
+
if (close.test(cur)) {
|
|
82
|
+
i++;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
body.push(cur);
|
|
86
|
+
i++;
|
|
87
|
+
}
|
|
88
|
+
blocks.push({ type: 'code', lang, text: body.join('\n') });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
// —— 空行:段落到此为止 ——
|
|
92
|
+
if (BLANK.test(line)) {
|
|
93
|
+
flushPara();
|
|
94
|
+
i++;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// —— 分隔线(必须在列表之前判:`---` 也是无序列表的标记) ——
|
|
98
|
+
if (HR.test(line)) {
|
|
99
|
+
flushPara();
|
|
100
|
+
blocks.push({ type: 'hr' });
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// —— 标题 ——
|
|
105
|
+
const h = HEADING.exec(line);
|
|
106
|
+
if (h) {
|
|
107
|
+
flushPara();
|
|
108
|
+
blocks.push({
|
|
109
|
+
type: 'h',
|
|
110
|
+
level: h[1].length,
|
|
111
|
+
inline: parseInline(h[2].trim(), 0),
|
|
112
|
+
});
|
|
113
|
+
i++;
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
// —— 引用:连续 `>` 行合成一段 ——
|
|
117
|
+
if (QUOTE.test(line)) {
|
|
118
|
+
flushPara();
|
|
119
|
+
const quoted = [];
|
|
120
|
+
while (i < lines.length && QUOTE.test(lines[i])) {
|
|
121
|
+
quoted.push(QUOTE.exec(lines[i])[1]);
|
|
122
|
+
i++;
|
|
123
|
+
}
|
|
124
|
+
const joined = quoted.join('\n').trim();
|
|
125
|
+
blocks.push({ type: 'quote', inline: parseInline(joined, 0) });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// —— 列表(无序 / 有序,各收一段连续的) ——
|
|
129
|
+
const ul = UL.exec(line);
|
|
130
|
+
const ol = olAt(line);
|
|
131
|
+
if (ul || ol) {
|
|
132
|
+
flushPara();
|
|
133
|
+
const ordered = ol !== null;
|
|
134
|
+
const items = [];
|
|
135
|
+
while (i < lines.length) {
|
|
136
|
+
const cur = lines[i];
|
|
137
|
+
const m = ordered ? olAt(cur) : UL.exec(cur);
|
|
138
|
+
if (m) {
|
|
139
|
+
items.push(parseInline(m[3].trim(), 0));
|
|
140
|
+
i++;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
// 续行:缩进 ≥2 且不是新的一条 ⇒ 并进上一项
|
|
144
|
+
if (items.length > 0 && /^\s{2,}\S/.test(cur) && !BLANK.test(cur)) {
|
|
145
|
+
const last = items[items.length - 1];
|
|
146
|
+
last.push(...parseInline(cur.trim(), 0));
|
|
147
|
+
i++;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
blocks.push({ type: 'list', ordered, items });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// —— 其余:段落(连续非空行合并,行内换行保留) ——
|
|
156
|
+
para.push(line.trim());
|
|
157
|
+
i++;
|
|
158
|
+
}
|
|
159
|
+
flushPara();
|
|
160
|
+
return blocks;
|
|
161
|
+
}
|
|
162
|
+
/** 有序列表标记(`1.` / `1)`),返回与 UL 同形的三组:缩进 / 标记 / 正文 */
|
|
163
|
+
function olAt(line) {
|
|
164
|
+
const m = OL.exec(line);
|
|
165
|
+
return m ? m : null;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 行内解析。`depth` 是**递归上限**:`**a *b* c**` 这类嵌套只展开到第 3 层,
|
|
169
|
+
* 再深就按字面留着 —— 递归无上限时,一条构造出来的正文就能把面板卡死。
|
|
170
|
+
*/
|
|
171
|
+
export function parseInline(text, depth) {
|
|
172
|
+
const out = [];
|
|
173
|
+
let buf = '';
|
|
174
|
+
let i = 0;
|
|
175
|
+
const pushText = () => {
|
|
176
|
+
if (buf)
|
|
177
|
+
out.push({ type: 'text', text: buf });
|
|
178
|
+
buf = '';
|
|
179
|
+
};
|
|
180
|
+
while (i < text.length) {
|
|
181
|
+
const c = text[i];
|
|
182
|
+
// 行内码:`...`(不跨行;两端各去掉一个空格 —— 与 CommonMark 同款)
|
|
183
|
+
if (c === '`') {
|
|
184
|
+
const end = text.indexOf('`', i + 1);
|
|
185
|
+
if (end > i + 1) {
|
|
186
|
+
pushText();
|
|
187
|
+
let inner = text.slice(i + 1, end);
|
|
188
|
+
if (inner.startsWith(' ') && inner.endsWith(' ') && inner.trim())
|
|
189
|
+
inner = inner.slice(1, -1);
|
|
190
|
+
out.push({ type: 'code', text: inner });
|
|
191
|
+
i = end + 1;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// 链接:[文字](地址) —— 地址不过白名单就整条降级成字面文本(见 isSafeHref)
|
|
196
|
+
if (c === '[') {
|
|
197
|
+
const m = /^\[([^\]]*)\]\(([^)\s]*)\)/.exec(text.slice(i));
|
|
198
|
+
if (m) {
|
|
199
|
+
const raw = text.slice(i, i + m[0].length);
|
|
200
|
+
const href = m[2].trim();
|
|
201
|
+
if (isSafeHref(href)) {
|
|
202
|
+
pushText();
|
|
203
|
+
out.push({ type: 'link', href, text: m[1].trim() });
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
buf += raw; // 字面保留(连 []() 一起)—— 让使用者看见「这里有个链接我没渲染」
|
|
207
|
+
}
|
|
208
|
+
i += m[0].length;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// 自动链接:<https://…>(裸的 `<` 一律当文本 —— 这正是「不做 HTML 透传」的落点)
|
|
213
|
+
if (c === '<') {
|
|
214
|
+
const m = /^<(https?:\/\/[^\s>]+)>/.exec(text.slice(i));
|
|
215
|
+
if (m) {
|
|
216
|
+
pushText();
|
|
217
|
+
out.push({ type: 'link', href: m[1], text: m[1] });
|
|
218
|
+
i += m[0].length;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// 强调:**粗** / *斜*(`__` / `_` 同义)
|
|
223
|
+
if (c === '*' || c === '_') {
|
|
224
|
+
const strong = text.startsWith(c + c, i);
|
|
225
|
+
const mark = strong ? c + c : c;
|
|
226
|
+
const end = text.indexOf(mark, i + mark.length);
|
|
227
|
+
if (end > i + mark.length) {
|
|
228
|
+
const inner = text.slice(i + mark.length, end);
|
|
229
|
+
if (inner.trim() && !inner.startsWith(' ') && !inner.endsWith(' ')) {
|
|
230
|
+
pushText();
|
|
231
|
+
const children = depth < 3 ? parseInline(inner, depth + 1) : [{ type: 'text', text: inner }];
|
|
232
|
+
out.push(strong ? { type: 'strong', children } : { type: 'em', children });
|
|
233
|
+
i = end + mark.length;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
buf += c;
|
|
239
|
+
i++;
|
|
240
|
+
}
|
|
241
|
+
pushText();
|
|
242
|
+
return out;
|
|
243
|
+
}
|
package/dist/panel-logic.js
CHANGED
|
@@ -246,3 +246,170 @@ export function shortenPath(abs, opts = {}) {
|
|
|
246
246
|
return `.${abs.slice(cwd.length)}`;
|
|
247
247
|
return abs;
|
|
248
248
|
}
|
|
249
|
+
/** 空累加器:`state.live` 的初值,也是 e2e 折回的起点 */
|
|
250
|
+
export function emptyTraceAccumulator() {
|
|
251
|
+
return { lastSeq: 0, traceId: '', spans: [] };
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* 应用一条增量记账事件。返回**它有没有被用上**(false = 丢了)。
|
|
255
|
+
*
|
|
256
|
+
* 丢的三种情形都是刻意的、**都不抛**:
|
|
257
|
+
* - `seq` 不前进(重复投递、迟到的旧帧)—— SSE 不保证送达,但送达的只能应用一次;
|
|
258
|
+
* - 指向**没见过的 `spanId`**(面板连上得晚,那个 `span.begin` 没收到)—— 绝不能凭空建
|
|
259
|
+
* 节点:那会造出一棵缺了上半截的假树。收尾那份会补齐;
|
|
260
|
+
* - 形状不认识(框架将来加了新事件类型)—— 静默放过,别让整个右栏停摆。
|
|
261
|
+
*/
|
|
262
|
+
export function applyTraceEvent(acc, ev) {
|
|
263
|
+
// 先卡 seq:它同时挡住「重复投递」与「乱序的旧帧」
|
|
264
|
+
if (!ev || typeof ev.seq !== 'number' || ev.seq <= acc.lastSeq)
|
|
265
|
+
return false;
|
|
266
|
+
const find = (spanId) => acc.spans.find((s) => s.spanId === spanId);
|
|
267
|
+
switch (ev.type) {
|
|
268
|
+
case 'span.begin': {
|
|
269
|
+
const src = ev.span;
|
|
270
|
+
// 拷一份:折回不该持有(更不该改写)协议对象 —— 面板会重画很多次
|
|
271
|
+
const copy = {
|
|
272
|
+
...src,
|
|
273
|
+
attributes: { ...src.attributes },
|
|
274
|
+
events: (src.events ?? []).map((e) => ({ ...e })),
|
|
275
|
+
};
|
|
276
|
+
const seen = find(src.spanId);
|
|
277
|
+
// 同一个 `spanId` 再来一次 ⇒ **原地刷新**(重放幂等),不再挂一个同 id 的节点
|
|
278
|
+
if (seen)
|
|
279
|
+
Object.assign(seen, copy);
|
|
280
|
+
else {
|
|
281
|
+
acc.spans.push(copy);
|
|
282
|
+
if (!acc.traceId)
|
|
283
|
+
acc.traceId = src.traceId;
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
}
|
|
287
|
+
case 'span.end': {
|
|
288
|
+
const s = find(ev.spanId);
|
|
289
|
+
if (!s)
|
|
290
|
+
return false;
|
|
291
|
+
s.endedAt = ev.endedAt;
|
|
292
|
+
s.status = ev.status;
|
|
293
|
+
if (ev.error)
|
|
294
|
+
s.error = { ...ev.error };
|
|
295
|
+
if (ev.usage)
|
|
296
|
+
s.usage = { ...ev.usage };
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
case 'span.event': {
|
|
300
|
+
const s = find(ev.spanId);
|
|
301
|
+
if (!s)
|
|
302
|
+
return false;
|
|
303
|
+
s.events.push({ ...ev.event });
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
case 'span.attribute': {
|
|
307
|
+
const s = find(ev.spanId);
|
|
308
|
+
if (!s)
|
|
309
|
+
return false;
|
|
310
|
+
s.attributes = { ...s.attributes, [ev.key]: ev.value };
|
|
311
|
+
break;
|
|
312
|
+
}
|
|
313
|
+
case 'span.link': {
|
|
314
|
+
const s = find(ev.spanId);
|
|
315
|
+
if (!s)
|
|
316
|
+
return false;
|
|
317
|
+
s.links = [...(s.links ?? []), ev.link];
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
default:
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
acc.lastSeq = ev.seq;
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* 当前已知的这棵树(交给 `playTrace` 画)。返回的是**副本** —— 渲染层拿它排序 / 展开,
|
|
328
|
+
* 不该回头改到累加器(下一次重画还要用同一份账)。
|
|
329
|
+
*
|
|
330
|
+
* `status` 只区分**在飞**与**已收尾**:根 span 还没有 `endedAt` 就是「在飞」,渲染层据此
|
|
331
|
+
* **不收尾**(否则根会被画成「已在某一刻完成」,那是个假事实)。收尾后取根自己的状态
|
|
332
|
+
* (`ok` / `error`),与框架口径一致。
|
|
333
|
+
*/
|
|
334
|
+
export function partialTrace(acc) {
|
|
335
|
+
const spans = acc.spans.map((s) => ({
|
|
336
|
+
...s,
|
|
337
|
+
attributes: { ...s.attributes },
|
|
338
|
+
events: s.events.map((e) => ({ ...e })),
|
|
339
|
+
}));
|
|
340
|
+
const root = spans.find((s) => s.parentSpanId === null);
|
|
341
|
+
const status = root && root.endedAt === undefined ? 'running' : (root?.status ?? 'running');
|
|
342
|
+
return { traceId: acc.traceId, spans, status };
|
|
343
|
+
}
|
|
344
|
+
// ---------- ② 回复正文的归属 ----------
|
|
345
|
+
/**
|
|
346
|
+
* 「`open(id)` 该不该**保留**屏幕上这条回复」。
|
|
347
|
+
*
|
|
348
|
+
* 背景:`run-done` 把回复正文写上去之后,面板会**自动 open 刚跑完的那一轮** ——
|
|
349
|
+
* 而 `open()` 原本无条件清掉回复区,于是那句回复在十几毫秒后被自己擦掉(看起来像「没回复」)。
|
|
350
|
+
*
|
|
351
|
+
* 口径与 `open()` 的原意图对齐:只有「现在打开的就是这条回复的主人」才保留;其余一律清
|
|
352
|
+
* (打开一条历史 run 时若还挂着**别人**的回复,那是张冠李戴 —— 比空白更糟)。
|
|
353
|
+
* `replyFor` 为 null(还没跑过 / 已清)**不等于**「谁都对」:默认清。
|
|
354
|
+
*/
|
|
355
|
+
export function replyBelongsTo(openId, replyFor) {
|
|
356
|
+
if (!openId || !replyFor)
|
|
357
|
+
return false;
|
|
358
|
+
return openId === replyFor;
|
|
359
|
+
}
|
|
360
|
+
// ---------- ③ 目录浏览 / 选文件 ----------
|
|
361
|
+
/**
|
|
362
|
+
* 点「浏览…」时该打开哪个目录:**总是**按输入框的值。
|
|
363
|
+
*
|
|
364
|
+
* 旧实现把它当**开关**(面板开着时再点一次就关掉),于是「在输入框敲了目标路径 → 点浏览…」
|
|
365
|
+
* 这个最自然的动作反而是把面板关掉,用户就以为「选不了别的目录」。空值才回落到缺省工作目录;
|
|
366
|
+
* 首尾空白不该让路径读错。
|
|
367
|
+
*/
|
|
368
|
+
export function browseTarget(value, defaultDir) {
|
|
369
|
+
const v = value.trim();
|
|
370
|
+
return v.length > 0 ? v : defaultDir;
|
|
371
|
+
}
|
|
372
|
+
/**
|
|
373
|
+
* 点一个**文件**之后的 prompt 该是什么。
|
|
374
|
+
*
|
|
375
|
+
* 口径:**只在 prompt 为空时**填文件名(省一次打字);用户已经写好的话**一个字都不动** ——
|
|
376
|
+
* 悄悄改写用户输入比少填一次糟得多。`filled` 是给面板回话用的(提示文案不同)。
|
|
377
|
+
* 工作目录的切换(取该文件**所在的**目录)由调用方做,不在这个纯判定里。
|
|
378
|
+
*/
|
|
379
|
+
export function promptAfterFilePick(current, name) {
|
|
380
|
+
if (current.trim().length > 0)
|
|
381
|
+
return { prompt: current, filled: false };
|
|
382
|
+
return { prompt: name, filled: true };
|
|
383
|
+
}
|
|
384
|
+
// ---------- ③ 消息折叠:长正文默认收起,短的不加控件 ----------
|
|
385
|
+
/**
|
|
386
|
+
* 超过这么多行就默认折叠。12 行是「一屏大致读得完」的量级 —— 面板右栏在 1223px 下正文行高
|
|
387
|
+
* 约 18px,12 行 ≈ 216px,正好是不把调用树挤出去的上限。
|
|
388
|
+
*/
|
|
389
|
+
export const COLLAPSE_MAX_LINES = 12;
|
|
390
|
+
/** 单行长正文的兜底阈值:一行 2000 字符(模型不换行时很常见)同样该收起来 */
|
|
391
|
+
export const COLLAPSE_MAX_CHARS = 1200;
|
|
392
|
+
/**
|
|
393
|
+
* 「这段正文要不要折叠」。**判定是纯逻辑** —— 渲染层只管按结果挂控件,
|
|
394
|
+
* 不自己量高度(量高度依赖布局与字体,单测里量不出来,等于把规则藏进浏览器)。
|
|
395
|
+
*
|
|
396
|
+
* 为什么不按像素量:像素阈值在字体/缩放/窄屏下会漂,同一个回复在不同机器上折叠与否都不一样;
|
|
397
|
+
* 行数 + 字符数是**输入本身的属性**,可复现、可断言。
|
|
398
|
+
*
|
|
399
|
+
* `hint` 是给展开控件用的文案(面板拿它当按钮标题,再加个 caret);不折叠时是空串 ——
|
|
400
|
+
* **短消息上挂一个「展开」是噪声**,所以控件只在真的长时出现。
|
|
401
|
+
*/
|
|
402
|
+
export function collapseDecision(text, opts = {}) {
|
|
403
|
+
const t = String(text ?? '');
|
|
404
|
+
const lines = t.length === 0 ? 0 : t.split('\n').length;
|
|
405
|
+
const chars = t.length;
|
|
406
|
+
const maxLines = opts.maxLines ?? COLLAPSE_MAX_LINES;
|
|
407
|
+
const maxChars = opts.maxChars ?? COLLAPSE_MAX_CHARS;
|
|
408
|
+
const collapsed = lines > maxLines || chars > maxChars;
|
|
409
|
+
const hint = collapsed
|
|
410
|
+
? lines > 1
|
|
411
|
+
? `展开全文(共 ${lines} 行)`
|
|
412
|
+
: `展开全文(共 ${chars} 字符)`
|
|
413
|
+
: '';
|
|
414
|
+
return { collapsed, lines, chars, hint };
|
|
415
|
+
}
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@migor/agentia": "^0.9.
|
|
12
|
+
"@migor/agentia": "^0.9.2"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"@migor/cli": "^0.9.
|
|
15
|
+
"@migor/cli": "^0.9.2",
|
|
16
16
|
"tsx": "^4.19.0",
|
|
17
17
|
"typescript": "^7.0.2",
|
|
18
18
|
"@types/node": "^22.0.0"
|