@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-page.html
CHANGED
|
@@ -43,15 +43,46 @@
|
|
|
43
43
|
}
|
|
44
44
|
#banner.err { border-color: var(--err); background: #1d1416; color: var(--err); }
|
|
45
45
|
|
|
46
|
+
/* 应用外壳:**整页不滚**,滚动交给各自的栏。
|
|
47
|
+
为什么:内容一长就整页滚的话,「我正在看调用树」与「我在读回复」会互相把对方推走,
|
|
48
|
+
而且 footer 还粘在底部盖住内容 —— 三个滚动条(页面 / 树 / 展开的行)叠在一起最难用。 */
|
|
49
|
+
html, body { height: 100%; }
|
|
50
|
+
body { overflow: hidden; display: flex; flex-direction: column; }
|
|
51
|
+
header, #banner, footer { flex: none; }
|
|
46
52
|
main {
|
|
53
|
+
flex: 1 1 auto; min-height: 0;
|
|
47
54
|
display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 14px;
|
|
48
55
|
padding: 14px 20px;
|
|
49
56
|
}
|
|
50
57
|
aside, section {
|
|
51
58
|
background: var(--card); border: 1px solid var(--border); border-radius: 6px;
|
|
59
|
+
min-height: 0;
|
|
60
|
+
}
|
|
61
|
+
/* 左栏:页签/清空条固定,列表自己滚 */
|
|
62
|
+
aside { display: flex; flex-direction: column; overflow: hidden; }
|
|
63
|
+
#tabs, #chat-bar { flex: none; }
|
|
64
|
+
#runs, #chat { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
|
65
|
+
/* 右栏分层:`#trace` 是**唯一**无界增长的部分(它随 span 数长)⇒ 只有它 flex 撑开并滚;
|
|
66
|
+
回复 / 能力排行 / 用量固定在下方,各自有上限(回复很长时在**它自己那块**里滚,
|
|
67
|
+
而不是把树推出屏幕 —— 这与「看树」和「读回复」两个动作各自独立是一致的)。 */
|
|
68
|
+
section {
|
|
69
|
+
padding: 14px; font-family: var(--mono); font-size: 12px;
|
|
70
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
71
|
+
}
|
|
72
|
+
#ttl { flex: none; }
|
|
73
|
+
#trace { flex: 1 1 auto; min-height: 140px; overflow: auto; }
|
|
74
|
+
#summary, #reply, .usage { flex: none; max-height: 38vh; overflow: auto; }
|
|
75
|
+
/* 窄屏:两栏并排已经太窄 ⇒ 回到单列 + 整页滚动(此时「整页滚」才是唯一合理的形态) */
|
|
76
|
+
@media (max-width: 860px) {
|
|
77
|
+
html, body { height: auto; }
|
|
78
|
+
body { overflow: visible; }
|
|
79
|
+
main { grid-template-columns: minmax(0, 1fr); }
|
|
80
|
+
aside { overflow: visible; }
|
|
81
|
+
section { overflow: visible; }
|
|
82
|
+
#runs, #chat, #trace { overflow: visible; }
|
|
83
|
+
#trace { min-height: 320px; }
|
|
84
|
+
#summary, #reply, .usage { max-height: none; }
|
|
52
85
|
}
|
|
53
|
-
aside { overflow: hidden auto; max-height: calc(100vh - 200px); }
|
|
54
|
-
section { padding: 14px; min-height: 240px; font-family: var(--mono); font-size: 12px; overflow: auto; }
|
|
55
86
|
.run {
|
|
56
87
|
display: block; width: 100%; text-align: left; cursor: pointer;
|
|
57
88
|
background: none; border: 0; border-bottom: 1px solid var(--border);
|
|
@@ -102,10 +133,50 @@
|
|
|
102
133
|
|
|
103
134
|
#reply {
|
|
104
135
|
margin: 14px 0 0; padding: 10px; border: 1px solid var(--border); border-radius: 4px;
|
|
105
|
-
background: #0e0e10;
|
|
136
|
+
background: #0e0e10; word-break: break-word; color: var(--muted);
|
|
106
137
|
}
|
|
107
138
|
#reply[hidden] { display: none; }
|
|
108
139
|
|
|
140
|
+
/* —— 模型正文:Markdown 渲染 ——
|
|
141
|
+
`.md` 里所有元素都是 createElement 造出来的(解析器不产生 HTML,渲染器不用 innerHTML),
|
|
142
|
+
所以这里可以有排版规则,但没有一处「把字符串塞进 DOM」。 */
|
|
143
|
+
.md { white-space: normal; word-break: break-word; line-height: 1.6; }
|
|
144
|
+
.md-p { margin: 0 0 6px; }
|
|
145
|
+
.md-p:last-child, .md-list:last-child, .md-pre:last-child { margin-bottom: 0; }
|
|
146
|
+
.md-h { margin: 10px 0 4px; font-size: 12.5px; color: var(--text); }
|
|
147
|
+
.md-h:first-child { margin-top: 0; }
|
|
148
|
+
.md-list { margin: 0 0 6px; padding-left: 18px; }
|
|
149
|
+
.md-list li { margin: 1px 0; }
|
|
150
|
+
.md-pre {
|
|
151
|
+
position: relative; margin: 0 0 6px; padding: 8px; background: #0e0e10;
|
|
152
|
+
border: 1px solid var(--border); border-radius: 4px; overflow: auto;
|
|
153
|
+
}
|
|
154
|
+
.md-pre code { font-family: var(--mono); font-size: 11.5px; white-space: pre; }
|
|
155
|
+
.md-lang { position: absolute; top: 3px; right: 7px; color: var(--faint); font-size: 9.5px; }
|
|
156
|
+
.md-c { background: #1b1b1f; border-radius: 3px; padding: 0 3px; font-family: var(--mono); }
|
|
157
|
+
.md blockquote { margin: 0 0 6px; padding-left: 9px; border-left: 2px solid var(--border); color: var(--muted); }
|
|
158
|
+
.md a { color: var(--ice); }
|
|
159
|
+
.md hr { border: 0; border-top: 1px solid var(--border); margin: 8px 0; }
|
|
160
|
+
/* 正文用无衬线正文栈(模型输出是散文,等宽栈读它更累);码块与行内码仍是等宽。
|
|
161
|
+
挂在 `.md-wrap`(渲染器的包装节点)而不是 `#reply .md, .turn .md`:单一类选择器
|
|
162
|
+
既不用管特异性排序,也让「哪块是模型正文」只由一处决定。 */
|
|
163
|
+
.md-wrap {
|
|
164
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif;
|
|
165
|
+
font-size: 12px;
|
|
166
|
+
}
|
|
167
|
+
/* 折叠态:卡高度 + 底部渐隐(渐隐是「下面还有」的视觉提示;用 mask 所以不影响选中复制) */
|
|
168
|
+
.md-clamped {
|
|
169
|
+
max-height: 216px; overflow: hidden;
|
|
170
|
+
-webkit-mask-image: linear-gradient(180deg, #000 78%, transparent 100%);
|
|
171
|
+
mask-image: linear-gradient(180deg, #000 78%, transparent 100%);
|
|
172
|
+
}
|
|
173
|
+
/* 展开控件:正文**下方**左对齐(长正文里没人会去右上角找一个 10px 的箭头) */
|
|
174
|
+
.md-toggle {
|
|
175
|
+
margin-top: 6px; background: var(--card); border: 1px solid var(--border); border-radius: 4px;
|
|
176
|
+
color: var(--text); padding: 4px 9px; font: inherit; font-size: 11px; cursor: pointer;
|
|
177
|
+
}
|
|
178
|
+
.md-toggle:hover { border-color: var(--ice); }
|
|
179
|
+
|
|
109
180
|
/* 输入条:固定在底部、横跨两栏、分两行(四个控件挤一行会太窄) */
|
|
110
181
|
footer {
|
|
111
182
|
position: sticky; bottom: 0; margin-top: 4px;
|
|
@@ -151,6 +222,11 @@
|
|
|
151
222
|
.fs-head span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--faint); }
|
|
152
223
|
.fs-item { display: block; width: 100%; text-align: left; background: none; border: 0; color: var(--text); padding: 4px 6px; border-radius: 4px; font: inherit; font-family: var(--mono); font-size: 12px; cursor: pointer; }
|
|
153
224
|
.fs-item:hover { background: #202024; }
|
|
225
|
+
/* 隐藏目录:可达但不可眼(旧实现直接过滤掉,于是 .y 这类目录根本点不进去) */
|
|
226
|
+
.fs-dot { color: var(--faint); }
|
|
227
|
+
/* 文件行:点它 = 选它所在的目录(不是选文件本身当工作目录 —— 工作目录只能是目录) */
|
|
228
|
+
.fs-file { color: var(--text); }
|
|
229
|
+
.fs-sep { padding: 8px 6px 2px; margin-top: 6px; border-top: 1px solid var(--border); font-family: var(--mono); font-size: 10.5px; color: var(--faint); }
|
|
154
230
|
.fs-foot { padding-top: 8px; border-top: 1px solid var(--border); margin-top: 6px; }
|
|
155
231
|
.mini { background: var(--card); border: 1px solid var(--border); border-radius: 4px; color: var(--text); padding: 5px 9px; font: inherit; font-size: 11.5px; cursor: pointer; }
|
|
156
232
|
.mini:hover { border-color: var(--ice); }
|
|
@@ -227,22 +303,33 @@
|
|
|
227
303
|
<span id="fs-path"></span>
|
|
228
304
|
</div>
|
|
229
305
|
<div id="fs-list"></div>
|
|
230
|
-
<div class="fs-foot"
|
|
306
|
+
<div class="fs-foot">
|
|
307
|
+
<button class="mini" id="fs-use" type="button">选这个目录</button>
|
|
308
|
+
<span class="hint"> 点文件名 = 选它所在的目录(prompt 为空时填文件名)</span>
|
|
309
|
+
</div>
|
|
231
310
|
</div>
|
|
232
311
|
</footer>
|
|
233
312
|
|
|
234
313
|
<script type="module">
|
|
235
314
|
import { createTraceView, playTrace, summarizeTrace, renderSummary } from './index.js';
|
|
315
|
+
import { parseMarkdown } from './markdown.js';
|
|
236
316
|
import {
|
|
237
317
|
HISTORY_DRAFT,
|
|
318
|
+
applyTraceEvent,
|
|
319
|
+
browseTarget,
|
|
238
320
|
capabilityBadge,
|
|
321
|
+
collapseDecision,
|
|
322
|
+
emptyTraceAccumulator,
|
|
239
323
|
historyStep,
|
|
240
324
|
historyText,
|
|
241
325
|
mergeConversation,
|
|
242
326
|
multiTurnDefault,
|
|
243
327
|
multiTurnLabel,
|
|
244
328
|
normalizeToolSources,
|
|
329
|
+
partialTrace,
|
|
330
|
+
promptAfterFilePick,
|
|
245
331
|
pushPromptHistory,
|
|
332
|
+
replyBelongsTo,
|
|
246
333
|
runDoneNotice,
|
|
247
334
|
runIsFailure,
|
|
248
335
|
shortenPath,
|
|
@@ -298,6 +385,17 @@
|
|
|
298
385
|
list: [],
|
|
299
386
|
current: null,
|
|
300
387
|
reply: '',
|
|
388
|
+
/**
|
|
389
|
+
* 这段回复是**哪个 traceId** 的(`run-done` 带回来的)。`open()` 靠它判断
|
|
390
|
+
* 「现在打开的就是这条回复的主人」⇒ 不清。见 panel-logic 的 `replyBelongsTo`。
|
|
391
|
+
*/
|
|
392
|
+
replyFor: null,
|
|
393
|
+
/**
|
|
394
|
+
* 在飞 run 的增量 trace 累加器(null = 没有在飞的 run)。
|
|
395
|
+
* 收到 `trace-event` 就折回并重画;收尾那份整棵 trace 回来时把它清掉
|
|
396
|
+
* (**收尾那份永远覆盖在飞的这份**,缺的 span 由它补齐)。
|
|
397
|
+
*/
|
|
398
|
+
live: null,
|
|
301
399
|
notice: '', // 面板自己发的操作反馈(已接受 / 重启了 runner / 出错)
|
|
302
400
|
noticeErr: false,
|
|
303
401
|
};
|
|
@@ -462,6 +560,123 @@
|
|
|
462
560
|
}
|
|
463
561
|
}
|
|
464
562
|
|
|
563
|
+
// ---------- 模型正文的渲染(Markdown + 长了就折叠) ----------
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 展开态**活在渲染之外**(与 trace-view 的 `expanded` 同一个理由):面板每次全量重画,
|
|
567
|
+
* 状态留在 DOM 里就会被下一次重画合上 —— 而「刚点开的那段自己收起来了」是最烦人的那种 bug。
|
|
568
|
+
* 键要**跨重画稳定**:回复用 traceId、对话轮用下标(会话是只追加的,下标稳定)。
|
|
569
|
+
*/
|
|
570
|
+
const expandedMsgs = new Set();
|
|
571
|
+
|
|
572
|
+
/** 行内 token → DOM。只走 createElement / textContent —— 见 markdown.ts 的安全前提 */
|
|
573
|
+
function appendInline(parent, inline) {
|
|
574
|
+
for (const n of inline) {
|
|
575
|
+
if (n.type === 'text') {
|
|
576
|
+
parent.appendChild(document.createTextNode(n.text));
|
|
577
|
+
} else if (n.type === 'code') {
|
|
578
|
+
const c = document.createElement('code');
|
|
579
|
+
c.className = 'md-c';
|
|
580
|
+
c.textContent = n.text;
|
|
581
|
+
parent.appendChild(c);
|
|
582
|
+
} else if (n.type === 'link') {
|
|
583
|
+
// href 已在解析层过白名单(isSafeHref);target/rel 防止把这面板当跳板
|
|
584
|
+
const a = document.createElement('a');
|
|
585
|
+
a.href = n.href;
|
|
586
|
+
a.textContent = n.text;
|
|
587
|
+
a.target = '_blank';
|
|
588
|
+
a.rel = 'noopener noreferrer';
|
|
589
|
+
parent.appendChild(a);
|
|
590
|
+
} else {
|
|
591
|
+
const w = document.createElement(n.type === 'strong' ? 'strong' : 'em');
|
|
592
|
+
appendInline(w, n.children);
|
|
593
|
+
parent.appendChild(w);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** Markdown → DOM。块级只认 markdown.ts 的那六种;这里**没有** innerHTML —— 一个都没有 */
|
|
599
|
+
function renderMarkdownInto(el, text) {
|
|
600
|
+
for (const b of parseMarkdown(text)) {
|
|
601
|
+
if (b.type === 'hr') {
|
|
602
|
+
el.appendChild(document.createElement('hr'));
|
|
603
|
+
} else if (b.type === 'code') {
|
|
604
|
+
const pre = document.createElement('pre');
|
|
605
|
+
pre.className = 'md-pre';
|
|
606
|
+
if (b.lang) {
|
|
607
|
+
const tag = document.createElement('span');
|
|
608
|
+
tag.className = 'md-lang';
|
|
609
|
+
tag.textContent = b.lang;
|
|
610
|
+
pre.appendChild(tag);
|
|
611
|
+
}
|
|
612
|
+
const code = document.createElement('code');
|
|
613
|
+
code.textContent = b.text;
|
|
614
|
+
pre.appendChild(code);
|
|
615
|
+
el.appendChild(pre);
|
|
616
|
+
} else if (b.type === 'list') {
|
|
617
|
+
const list = document.createElement(b.ordered ? 'ol' : 'ul');
|
|
618
|
+
list.className = 'md-list';
|
|
619
|
+
for (const item of b.items) {
|
|
620
|
+
const li = document.createElement('li');
|
|
621
|
+
appendInline(li, item);
|
|
622
|
+
list.appendChild(li);
|
|
623
|
+
}
|
|
624
|
+
el.appendChild(list);
|
|
625
|
+
} else if (b.type === 'quote') {
|
|
626
|
+
const q = document.createElement('blockquote');
|
|
627
|
+
appendInline(q, b.inline);
|
|
628
|
+
el.appendChild(q);
|
|
629
|
+
} else {
|
|
630
|
+
const isH = b.type === 'h';
|
|
631
|
+
const node = document.createElement(isH ? `h${Math.min(6, Math.max(1, b.level))}` : 'p');
|
|
632
|
+
node.className = isH ? 'md-h' : 'md-p';
|
|
633
|
+
appendInline(node, b.inline);
|
|
634
|
+
el.appendChild(node);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* 一段**模型正文**(最终回复 / 助手轮)→ 填进 `wrap`。
|
|
641
|
+
*
|
|
642
|
+
* 折叠控件放在正文**下方、左对齐**,不在行尾悬 caret:trace-view 那边 caret 靠右是因为
|
|
643
|
+
* 行是 nowrap 的表格状布局,「右端」本来就是「这一行结束了」的位置;正文块不是那个形态,
|
|
644
|
+
* 一个 10px 的箭头挂在右上角没人会去找。判定走 `collapseDecision`(纯逻辑、带单测)。
|
|
645
|
+
*/
|
|
646
|
+
function renderMessage(wrap, text, key) {
|
|
647
|
+
wrap.classList.add('md-wrap');
|
|
648
|
+
const body = document.createElement('div');
|
|
649
|
+
body.className = 'md';
|
|
650
|
+
const d = collapseDecision(text);
|
|
651
|
+
const open = expandedMsgs.has(key);
|
|
652
|
+
if (d.collapsed && !open) body.classList.add('md-clamped');
|
|
653
|
+
renderMarkdownInto(body, text);
|
|
654
|
+
wrap.appendChild(body);
|
|
655
|
+
if (!d.collapsed) return; // 短正文不挂控件(挂一个「展开」是噪声)
|
|
656
|
+
const btn = document.createElement('button');
|
|
657
|
+
btn.type = 'button';
|
|
658
|
+
btn.className = 'md-toggle';
|
|
659
|
+
btn.textContent = open ? '收起 ▴' : `${d.hint} ▾`;
|
|
660
|
+
btn.addEventListener('click', () => {
|
|
661
|
+
if (expandedMsgs.has(key)) expandedMsgs.delete(key);
|
|
662
|
+
else expandedMsgs.add(key);
|
|
663
|
+
renderChat();
|
|
664
|
+
renderReply();
|
|
665
|
+
});
|
|
666
|
+
wrap.appendChild(btn);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** 右栏底部那块回复正文。key 带 traceId ⇒ 换一轮就是一个新的折叠态 */
|
|
670
|
+
function renderReply() {
|
|
671
|
+
replyEl.replaceChildren();
|
|
672
|
+
if (!state.reply) {
|
|
673
|
+
replyEl.hidden = true;
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
replyEl.hidden = false;
|
|
677
|
+
renderMessage(replyEl, state.reply, `reply:${state.replyFor ?? ''}`);
|
|
678
|
+
}
|
|
679
|
+
|
|
465
680
|
// ---------- 对话流(只在多轮开 + 有会话文件时出现) ----------
|
|
466
681
|
function renderChat() {
|
|
467
682
|
const dev = state.dev;
|
|
@@ -489,7 +704,10 @@
|
|
|
489
704
|
d.textContent = '会话文件还是空的。跑一次多轮 run 就有了。';
|
|
490
705
|
chatEl.appendChild(d);
|
|
491
706
|
}
|
|
707
|
+
// 下标当折叠态的键:会话只追加 ⇒ 下标跨重画稳定
|
|
708
|
+
let ti = -1;
|
|
492
709
|
for (const t of turns) {
|
|
710
|
+
ti++;
|
|
493
711
|
const box = document.createElement('div');
|
|
494
712
|
box.className = 'turn' + (t.failed ? ' failed' : t.aborted ? ' aborted' : '');
|
|
495
713
|
const who = document.createElement('div');
|
|
@@ -505,7 +723,10 @@
|
|
|
505
723
|
who2.textContent = 'assistant';
|
|
506
724
|
const a = document.createElement('div');
|
|
507
725
|
a.className = 'msg';
|
|
508
|
-
|
|
726
|
+
// 助手轮是**模型正文** ⇒ Markdown 渲染 + 长了折叠。
|
|
727
|
+
// 用户轮保持字面(`u.textContent = t.user` 上面那两行没动)—— 那是「我打的字」,
|
|
728
|
+
// 把它按 Markdown 重排会让面板看起来像改过我的输入。
|
|
729
|
+
renderMessage(a, t.assistant, `turn:${ti}`);
|
|
509
730
|
box.append(who2, a);
|
|
510
731
|
} else if (t.failed) {
|
|
511
732
|
// 失败轮**标失败**,不假装那轮没发生(它没回写会话文件,靠 run 列表 join 出来)
|
|
@@ -536,6 +757,51 @@
|
|
|
536
757
|
renderChat();
|
|
537
758
|
}
|
|
538
759
|
|
|
760
|
+
// ---------- 在飞 run 的实时右栏(①) ----------
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* run 开跑:把右栏切成「这一轮的空树」。
|
|
764
|
+
*
|
|
765
|
+
* 为什么必须在 run-start 就切(而不是等第一条 span):不切的话右栏留着**上一轮**那棵树,
|
|
766
|
+
* 一次十几秒的 run 期间它看起来就是「这次的结果」。同理清掉上一轮的回复 / 排行 / 用量 ——
|
|
767
|
+
* 它们都是**上一轮**的账,与在飞的这一轮无关(历史 run 在左栏列表里随时能点回来)。
|
|
768
|
+
*/
|
|
769
|
+
function beginLiveRender() {
|
|
770
|
+
state.live = emptyTraceAccumulator();
|
|
771
|
+
state.reply = '';
|
|
772
|
+
state.replyFor = null;
|
|
773
|
+
renderReply();
|
|
774
|
+
summaryEl.replaceChildren();
|
|
775
|
+
usageEl.hidden = true;
|
|
776
|
+
ttlEl.textContent = '';
|
|
777
|
+
const ph = document.createElement('div');
|
|
778
|
+
ph.className = 'tr-empty';
|
|
779
|
+
ph.textContent = '// 这一轮在飞 —— span 会一个个长出来(收尾时用整棵 trace 覆盖)';
|
|
780
|
+
traceEl.replaceChildren(ph);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* 增量事件 → 折回 → 重画整棵树。
|
|
785
|
+
*
|
|
786
|
+
* `playTrace` 是**幂等全量重画**(自己拆好树再画),所以这里不需要「局部更新」那套:
|
|
787
|
+
* 每次把当前已知的 spans 交回去就行。用 rAF 合帧是必要的 —— 一次 run 可能有几百条
|
|
788
|
+
* 记账事件,逐条重绘是白烧 CPU(而人眼只看得见一帧)。
|
|
789
|
+
*/
|
|
790
|
+
let liveFrame = 0;
|
|
791
|
+
function renderLive() {
|
|
792
|
+
if (liveFrame) return;
|
|
793
|
+
liveFrame = requestAnimationFrame(() => {
|
|
794
|
+
liveFrame = 0;
|
|
795
|
+
if (!state.live) return;
|
|
796
|
+
const t = partialTrace(state.live);
|
|
797
|
+
// 空树不交给 playTrace(它对空 spans 直接返回 false,右栏会留着占位那行 —— 那正是我们要的)
|
|
798
|
+
if (t.spans.length === 0) return;
|
|
799
|
+
if (!playTrace(view, t)) return;
|
|
800
|
+
ttlEl.textContent = `${t.traceId}(在飞)`;
|
|
801
|
+
usageEl.hidden = false;
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
539
805
|
// ---------- 拉数据 ----------
|
|
540
806
|
async function refreshDev() {
|
|
541
807
|
try {
|
|
@@ -546,6 +812,9 @@
|
|
|
546
812
|
}
|
|
547
813
|
// 会话 id 由 dev 环持有(清空对话会换它)—— 面板不自己造,只跟着走
|
|
548
814
|
state.sessionId = state.dev?.sessionId ?? '';
|
|
815
|
+
// 页面**在 run 在飞时**才加载(刷新 / 新开标签)⇒ 补一个累加器,好接住后续的增量帧。
|
|
816
|
+
// 之前那些 span 收不到(SSE 不重放)—— 它们会随收尾那份整棵 trace 一起补齐。
|
|
817
|
+
if (state.dev?.running && !state.live) state.live = emptyTraceAccumulator();
|
|
549
818
|
renderControls();
|
|
550
819
|
renderChat();
|
|
551
820
|
renderBanner();
|
|
@@ -590,10 +859,16 @@
|
|
|
590
859
|
playTrace(view, trace);
|
|
591
860
|
summaryEl.innerHTML = renderSummary(summarizeTrace(trace));
|
|
592
861
|
// 回复正文只对**刚跑完的那一轮**有意义(trace 上没有它 —— 它是 run-done 事件带的)
|
|
593
|
-
// ⇒
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
862
|
+
// ⇒ 打开**别的** run 时必须清掉,否则它挂在别人的 trace 底下(张冠李戴)。
|
|
863
|
+
//
|
|
864
|
+
// ⚠️ 但不能**无条件**清:`run-done` 与「这条 trace 的收尾帧」几乎同毫秒到达,
|
|
865
|
+
// 而这条 handler 的链更长(refresh → refreshSession → renderChat → open)——
|
|
866
|
+
// 无条件清会把刚写上去的那句回复擦掉(端到端实测 17 ms,现象是「跑完了但没回复」)。
|
|
867
|
+
if (!replyBelongsTo(id, state.replyFor)) {
|
|
868
|
+
state.reply = '';
|
|
869
|
+
state.replyFor = null;
|
|
870
|
+
renderReply();
|
|
871
|
+
}
|
|
597
872
|
renderList();
|
|
598
873
|
}
|
|
599
874
|
|
|
@@ -602,10 +877,20 @@
|
|
|
602
877
|
if (state.list.length && !state.current) await open(state.list[0].traceId);
|
|
603
878
|
}
|
|
604
879
|
|
|
605
|
-
// ---------- 目录浏览 ----------
|
|
880
|
+
// ---------- 目录浏览 / 选文件(③) ----------
|
|
606
881
|
// 浏览器拿不到用户选的文件夹的绝对路径(webkitdirectory 只给相对路径、
|
|
607
882
|
// File System Access 只给 handle.name),而 dev 环要的是绝对路径 ⇒ 服务端列目录。
|
|
883
|
+
//
|
|
884
|
+
// 三个控件语义(2026-09-22 复核后定,前两条是实测出来的缺口):
|
|
885
|
+
// - 点**目录名** ⇒ 进去;点 `..` ⇒ 上一层;「选这个目录」⇒ 把当前位置当工作目录。
|
|
886
|
+
// - 点**文件名** ⇒ 工作目录 = 它**所在的**目录(就是当前这一层),prompt 为空时填文件名。
|
|
887
|
+
// 只列目录的旧形态下,「让 agent 看某个文件」只能靠用户自己把名字打进 prompt。
|
|
888
|
+
// - 隐藏目录(`.git` / `~/x/.y`)单列在正常目录之后 —— 旧实现整批过滤掉,
|
|
889
|
+
// 于是那些目录**根本点不进去**(`..` 只能退到它上面,进不去)。
|
|
608
890
|
let fsPath = '';
|
|
891
|
+
function closeFs() {
|
|
892
|
+
fsEl.hidden = true;
|
|
893
|
+
}
|
|
609
894
|
async function openFs(at) {
|
|
610
895
|
const res = await fetch('/api/fs?path=' + encodeURIComponent(at));
|
|
611
896
|
if (!res.ok) {
|
|
@@ -617,30 +902,67 @@
|
|
|
617
902
|
fsPath = body.path;
|
|
618
903
|
fsPathEl.textContent = body.path;
|
|
619
904
|
fsListEl.replaceChildren();
|
|
620
|
-
|
|
905
|
+
|
|
906
|
+
const row = (text, cls, title, onClick) => {
|
|
621
907
|
const b = document.createElement('button');
|
|
622
908
|
b.type = 'button';
|
|
623
|
-
b.className =
|
|
624
|
-
b.textContent =
|
|
625
|
-
|
|
909
|
+
b.className = cls;
|
|
910
|
+
b.textContent = text;
|
|
911
|
+
if (title) b.title = title;
|
|
912
|
+
b.addEventListener('click', onClick);
|
|
626
913
|
fsListEl.appendChild(b);
|
|
914
|
+
};
|
|
915
|
+
const enterDir = (name) => () => void openFs(`${fsPath}/${name}`);
|
|
916
|
+
/** 选一个文件:工作目录取**当前这一层**(它所在的目录),prompt 只在空时被填 */
|
|
917
|
+
const useFile = (name) => () => {
|
|
918
|
+
workdirEl.value = fsPath;
|
|
919
|
+
state.workdir = fsPath;
|
|
920
|
+
const picked = promptAfterFilePick(promptEl.value, name);
|
|
921
|
+
if (picked.filled) promptEl.value = picked.prompt;
|
|
922
|
+
closeFs();
|
|
923
|
+
setNotice(
|
|
924
|
+
picked.filled
|
|
925
|
+
? `工作目录:${short(fsPath)}(prompt 已填「${name}」)`
|
|
926
|
+
: `工作目录:${short(fsPath)}(prompt 里已有内容,一个字没动)`,
|
|
927
|
+
false,
|
|
928
|
+
);
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
for (const name of body.dirs) row(name + '/', 'fs-item', '', enterDir(name));
|
|
932
|
+
for (const name of body.dotDirs ?? []) {
|
|
933
|
+
row(name + '/', 'fs-item fs-dot', '隐藏目录', enterDir(name));
|
|
934
|
+
}
|
|
935
|
+
const files = body.files ?? [];
|
|
936
|
+
if (files.length > 0) {
|
|
937
|
+
const sep = document.createElement('div');
|
|
938
|
+
sep.className = 'fs-sep';
|
|
939
|
+
sep.textContent = '文件';
|
|
940
|
+
fsListEl.appendChild(sep);
|
|
941
|
+
for (const name of files) {
|
|
942
|
+
row(name, 'fs-item fs-file', '选这个文件:工作目录设成它所在的目录,prompt 为空时填文件名', useFile(name));
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
if (body.filesTruncated) {
|
|
946
|
+
const more = document.createElement('div');
|
|
947
|
+
more.className = 'hint';
|
|
948
|
+
more.textContent = `(文件只列了前 ${files.length} 个)`;
|
|
949
|
+
fsListEl.appendChild(more);
|
|
627
950
|
}
|
|
628
951
|
fsEl.hidden = false;
|
|
629
952
|
fsEl.dataset.parent = body.parent;
|
|
630
953
|
}
|
|
631
|
-
$('fs-up').addEventListener('click', () => openFs(fsEl.dataset.parent || fsPath));
|
|
954
|
+
$('fs-up').addEventListener('click', () => void openFs(fsEl.dataset.parent || fsPath));
|
|
632
955
|
$('fs-use').addEventListener('click', () => {
|
|
633
956
|
workdirEl.value = fsPath;
|
|
634
957
|
state.workdir = fsPath;
|
|
635
|
-
|
|
958
|
+
closeFs();
|
|
636
959
|
setNotice(`工作目录:${short(fsPath)}`, false);
|
|
637
960
|
});
|
|
961
|
+
// ⚠️ 这里**不是**开关:旧实现「面板开着时再点一次把它关掉」,于是
|
|
962
|
+
// 「在输入框敲了目标路径 → 点浏览…」这个最自然的动作是把面板关掉,
|
|
963
|
+
// 用户就以为「选不了别的目录」。现在点它**总是**按当前输入打开。
|
|
638
964
|
$('browse').addEventListener('click', () => {
|
|
639
|
-
|
|
640
|
-
fsEl.hidden = true;
|
|
641
|
-
return;
|
|
642
|
-
}
|
|
643
|
-
void openFs(workdirEl.value.trim() || (state.dev ? state.dev.defaultWorkdir : '.'));
|
|
965
|
+
void openFs(browseTarget(workdirEl.value, state.dev ? state.dev.defaultWorkdir : '.'));
|
|
644
966
|
});
|
|
645
967
|
|
|
646
968
|
// ---------- 控件接线(只做「取值 → 调 panel-logic → 写回」) ----------
|
|
@@ -654,6 +976,9 @@
|
|
|
654
976
|
});
|
|
655
977
|
document.addEventListener('click', (e) => {
|
|
656
978
|
if (capsEl.open && !capsEl.contains(e.target)) capsEl.open = false;
|
|
979
|
+
// 目录浏览器:点面板外面收起。`浏览…` 不再是开关之后,这是主要的收起方式
|
|
980
|
+
// (按钮自己那一支要排除,否则点它就会「开完立刻被这里收掉」)。
|
|
981
|
+
if (!fsEl.hidden && !fsEl.contains(e.target) && !$('browse').contains(e.target)) closeFs();
|
|
657
982
|
});
|
|
658
983
|
|
|
659
984
|
promptEl.addEventListener('keydown', (e) => {
|
|
@@ -758,6 +1083,9 @@
|
|
|
758
1083
|
const nameEl = document.createElement('b');
|
|
759
1084
|
nameEl.textContent = s.name;
|
|
760
1085
|
liveEl.append(nameEl, ` · ${fmtMs(s.ms)}`);
|
|
1086
|
+
// 收尾那份整棵 trace 到了 ⇒ 在飞的那份**作废**:`open()` 画的是权威版本,
|
|
1087
|
+
// 而且此后不该再让迟到的增量帧把它重画成残缺的一棵(见 dev-protocol 的 DevEvent)
|
|
1088
|
+
state.live = null;
|
|
761
1089
|
await refresh();
|
|
762
1090
|
await refreshSession();
|
|
763
1091
|
renderChat();
|
|
@@ -767,6 +1095,8 @@
|
|
|
767
1095
|
const ev = JSON.parse(m.data);
|
|
768
1096
|
if (ev.kind === 'run-start') {
|
|
769
1097
|
liveEl.textContent = '正在跑…';
|
|
1098
|
+
// 右栏从这一轮的空树开始(① 实时):不这么做的话它留着上一轮那棵树
|
|
1099
|
+
beginLiveRender();
|
|
770
1100
|
setNotice(
|
|
771
1101
|
`运行中:${short(ev.workdir)} · ${ev.toolSources === null ? '全量' : ev.toolSources.join(',')}` +
|
|
772
1102
|
`${ev.multiTurn ? ' · 多轮' : ''}`,
|
|
@@ -774,10 +1104,18 @@
|
|
|
774
1104
|
);
|
|
775
1105
|
// run-start 是「确实开跑了」的权威信号 ⇒ 中止按钮在这里出现(不看面板自己的乐观状态)
|
|
776
1106
|
await refreshDev();
|
|
1107
|
+
} else if (ev.kind === 'trace-event') {
|
|
1108
|
+
// 在飞的一笔记账(①):折回 + 合帧重画。`state.live` 为空说明收尾那份已经到了
|
|
1109
|
+
// (或这一轮不是面板起的)⇒ 丢掉 —— 权威版本已经在屏幕上,别用残缺的覆盖它。
|
|
1110
|
+
if (state.live && applyTraceEvent(state.live, ev.event)) renderLive();
|
|
777
1111
|
} else if (ev.kind === 'run-done') {
|
|
778
1112
|
state.reply = ev.finalText || '';
|
|
779
|
-
|
|
780
|
-
|
|
1113
|
+
// 记下这条回复的**主人**:紧随其后的收尾帧会 open() 同一条 traceId,
|
|
1114
|
+
// 那时不能把回复当「别人的」擦掉(见 panel-logic 的 replyBelongsTo)
|
|
1115
|
+
state.replyFor = ev.traceId;
|
|
1116
|
+
state.live = null;
|
|
1117
|
+
// Markdown 渲染 + 长了折叠(渲染层不认识正文语义,只按 token 建节点)
|
|
1118
|
+
renderReply();
|
|
781
1119
|
// 判别顺序(先 stopReason 后 ok)是**语义**,抽在 panel-logic 里并带单测 ——
|
|
782
1120
|
// 中止的 run `ok` 也是 false(引擎刻意给 aborted 带结构化 error),
|
|
783
1121
|
// 顺序反了就会把「我按了中止」显示成「run 失败」。
|
|
@@ -789,6 +1127,8 @@
|
|
|
789
1127
|
renderChat();
|
|
790
1128
|
} else if (ev.kind === 'runner-restart') {
|
|
791
1129
|
setNotice(`重启 runner:${ev.reason}`, false);
|
|
1130
|
+
// 重启前的在飞 run 已经不存在了(它的 trace 会丢,面板在别处明说)⇒ 别留一棵半截树
|
|
1131
|
+
state.live = null;
|
|
792
1132
|
// 重启期间菜单是过期的(旧 runner 的能力)—— 先刷一次,等 runner-ready 再刷一次。
|
|
793
1133
|
await refreshDev();
|
|
794
1134
|
} else if (ev.kind === 'runner-ready') {
|
|
@@ -797,6 +1137,7 @@
|
|
|
797
1137
|
await refreshDev();
|
|
798
1138
|
} else if (ev.kind === 'runner-error') {
|
|
799
1139
|
setNotice(ev.message, true);
|
|
1140
|
+
state.live = null;
|
|
800
1141
|
await refreshDev();
|
|
801
1142
|
}
|
|
802
1143
|
});
|
package/dist/inspector-sink.js
CHANGED
|
@@ -15,3 +15,22 @@ export function createInspectSink(opts) {
|
|
|
15
15
|
},
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
|
+
export function createInspectEventSink(opts) {
|
|
19
|
+
const url = `http://${opts.host ?? '127.0.0.1'}:${opts.port}/ingest-event`;
|
|
20
|
+
const headers = { 'content-type': 'application/json' };
|
|
21
|
+
if (opts.token)
|
|
22
|
+
headers['x-agentia-token'] = opts.token;
|
|
23
|
+
let chain = Promise.resolve();
|
|
24
|
+
return {
|
|
25
|
+
send(event) {
|
|
26
|
+
chain = chain.then(async () => {
|
|
27
|
+
try {
|
|
28
|
+
await fetch(url, { method: 'POST', headers, body: JSON.stringify(event) });
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* 面板未开 / 已关 / 连接被拒:静默(观测失败不击穿 dev) */
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|