Repository navigation
Expand file tree
/
Copy pathapp.js
More file actions
2290 lines (2083 loc) · 90.5 KB
/
Copy pathapp.js
File metadata and controls
2290 lines (2083 loc) · 90.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* JevLoop · 界面
*
* 两个视图,**同一串事件**:
*
* 对话 你说了什么、它答了什么。过程默认折成一行
* 轨迹 每一次判定、每一次工具调用、每一次生成,一张卡一张卡铺开
*
* 分开不是为了藏东西,是因为它们回答的问题不同。对话回答「它做成了吗」,
* 轨迹回答「它凭什么这么判断」。切过去看不需要重新跑 —— 事件全在内存里,
* 轨迹是**回放**出来的,重跑就是为同一个答案付两次钱。
*
* 视觉照搬自 DeepSeek Harness(MIT,Copyright © 2026 DeepSeek):
* 只有用户消息有气泡,助手正文落在背景上,过程折叠成一行。
*
* 主张只有一条:**橙色(模型生成)必须稀少且扎眼。**
*
* ## 待拆
*
* 开头的「两个视图,同一串事件」是一句话,但文件内部是十三节:
* DOM 小工具 / 状态 / 视图切换 / 渲染:对话 / 滚动 / 跳转轨 /
* 渲染:轨迹 / 右栏:记账 / 事件分发 / 运行 / DECISION.md 面板 /
* 主题 / 会话。
*
* 接缝就是这些节,而且**三条链几乎不交叉**:`渲染:对话`、`渲染:轨迹`
* 各自闭包(都只读同一份事件数组),`事件分发 + 运行` 是另一块。
* 拆的时候按这三块走,不按行数。
*
* `滚动` 和 `跳转轨` 是后加的:两者都只操作 `.stream` 一个元素,
* 既不碰事件流也不碰渲染,合起来是一块独立的「视口控制」——
* 要摘的话先摘它。
*/
import { renderMarkdown, createStreamRenderer } from './markdown.js'
// ═══════════════════════════════════════════════════════════
// DOM 小工具(不引框架:这个界面只有两种交互,画卡片和切视图)
// ═══════════════════════════════════════════════════════════
/** 上下文账目的一句话。两个调用点共用 —— 分两处写必然分叉 */
function contextBits(e) {
const bits = [`${e.rawChars} → ${e.keptChars} 字符`]
if (e.prunedCount > 0) bits.push(`剪中间 ${e.prunedCount} 条`)
if (e.foldedCount > 0) bits.push(`折叠 ${e.foldedCount} 条`)
if (e.overRetain) bits.push('⚠ 压完仍超目标')
return bits
}
/** 上文(多轮问答)的账目。和 `contextBits` 是一对,但**单位是轮**,不是步 */
function conversationBits(e) {
const bits = [`${e.rawTurns} → ${e.keptTurns} 轮`, `${e.rawChars} → ${e.keptChars} 字符`]
if (e.foldedTurns > 0) bits.push(`折叠 ${e.foldedTurns} 轮`)
if (e.overRetain) bits.push('⚠ 压完仍超目标')
return bits
}
/**
* 一次生成调用的 token 账。
*
* 三个数回答不同的问题,所以**并排显示**:
*
* 估 N 我们按字符启发式估的「我们发出去的那部分」
* 入 M provider 报的输入(含生成器内部的 system prompt)
* 出 K provider 报的输出
*
* `入/出` 是 0 的时候**不说 0,说「未报」** —— 脚本生成器不报 usage,
* 那是「没量到」而不是「量到了 0」(§8.10)。把两者显示成同一个东西
* 会让人以为一次调用真的没花 token。
*/
function tokenText(e) {
const reported = e.inputTokens > 0 || e.outputTokens > 0
if (!reported) return `估 ${e.estimatedInputTokens} tok · 用量未报`
return `估 ${e.estimatedInputTokens} / 入 ${e.inputTokens} / 出 ${e.outputTokens} tok`
}
function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag)
for (const [k, v] of Object.entries(attrs)) {
if (v == null || v === false) continue
if (k === 'class') el.className = v
else if (k === 'text') el.textContent = v
else if (k.startsWith('on')) el.addEventListener(k.slice(2), v)
else el.setAttribute(k, v)
}
for (const c of children.flat(Infinity)) {
if (c == null || c === false) continue
el.append(c instanceof Node ? c : document.createTextNode(String(c)))
}
return el
}
/**
* 概率的显示。
*
* **四舍五入绝不能跨过 0 和 1 这两个端点。** 先前的写法是
* `n >= 0.995 ? toFixed(0) : toFixed(1)`,于是 0.995 画成 `100%`、
* 0.004 画成 `0%` —— 把一个**不确定**的判定画成了**确定**的。
* 这个界面的全部主张就是「判定带概率」,所以端点必须保住:
* 只有恰好等于 1 才配显示 100%。
*/
const pct = (n) => {
if (n >= 1) return '100%'
if (n <= 0) return '0%'
const s = (n * 100).toFixed(1)
return s === '100.0' ? '>99.9%' : s === '0.0' ? '<0.1%' : `${s}%`
}
const ms = (n) => `${Math.round(n)}ms`
// ═══════════════════════════════════════════════════════════
// 状态
// ═══════════════════════════════════════════════════════════
const state = {
decisions: 0,
models: 0,
tools: 0,
rules: 0,
running: false,
/**
* 最后一次 `generate` 事件。
*
* 留着它是因为 **provider 报的 token 真值只在那上面**,而记账面板要
* 把它和我们估的构成并排显示 —— 那个差值是校准启发式的唯一依据
* (见 `context.ts` 的 `priceGenerateRequest`)。
*/
lastGenerate: null,
/** 登记过的工作区。空数组 = 一个都没登记,这时服务端用它自己的演示目录 */
workspaces: [],
/** 会话列表(摘要,不含正文 —— 见 `SessionStore` 的模块头) */
sessions: [],
/** 「其他 N 个会话」那一组展开着吗 */
showOthers: false,
}
/**
* 对话记录。
*
* 每一轮 = 用户一句话 + 一次运行。事件留在轮里,所以切到轨迹
* 可以直接回放,不需要重跑。
*/
const turns = []
let current = null
const $ = (id) => document.getElementById(id)
const chat = $('chat')
function updateTally() {
$('t-decide').textContent = state.decisions
$('t-model').textContent = state.models
$('t-tool').textContent = state.tools
$('t-rules').textContent = state.rules
}
// ═══════════════════════════════════════════════════════════
// 视图切换
// ═══════════════════════════════════════════════════════════
const VIEWS = ['chat', 'trace', 'spec']
function selectView(id) {
for (const v of VIEWS) {
const on = v === id
$(`tab-${v}`).setAttribute('aria-selected', String(on))
$(`view-${v}`).hidden = !on
}
try {
localStorage.setItem('jl-view', id)
} catch {
/* 隐私模式下 localStorage 会抛,忽略即可 —— 视图选择不是关键功能 */
}
}
for (const v of VIEWS) $(`tab-${v}`).addEventListener('click', () => selectView(v))
// ═══════════════════════════════════════════════════════════
// 渲染:对话
// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════
// 滚动:跟着最新走,但不跟用户抢
// ═══════════════════════════════════════════════════════════
const stream = $('stream')
/**
* 多靠近底部才算「在底部」。
*
* 这个判断是整段滚动逻辑的核心 —— 没有它,两条路都是错的:
* 每来一行就滚到底,你往回翻第 3 步时页面自己在动,翻不上去;
* 完全不自动滚,长任务就得手动追着拖。
*
* 所以规则是:**你离开底部我就停手,你回到底部我接着跟。**
*
* 阈值不取 0,是因为「底部」在像素上永远差一两像素(行高取整、
* 平滑滚动还没停稳)。精确比较会把它误判成「用户滚走了」而永久停手 ——
* 表现就是自动滚动彻底失效,且看不出原因。
*/
const STICK_GAP_PX = 32
let stick = true
function nearBottom() {
return stream.scrollHeight - stream.scrollTop - stream.clientHeight <= STICK_GAP_PX
}
/**
* 跟着尾巴滚 —— 只在用户没有主动离开时。
*
* 用 `scrollTop = scrollHeight`,**不用 `scrollIntoView`**。后者有两个毛病,
* 合起来正好就是「页面完全不动」:
*
* 1. 它会一路滚动**所有**可滚动的祖先,包括页面本身;
* 2. 连续调用时,后一次平滑动画会取消前一次 —— 每 390ms 来一行,
* 上一段动画还没走完就被取消,于是永远停在原地。
*/
function followTail() {
if (!stick) return
stream.scrollTop = stream.scrollHeight
syncToBottom()
}
function syncToBottom() {
$('to-bottom').hidden = nearBottom()
}
/** 正在做程序化的平滑滚动。期间不接受滚动事件的判决。 */
let smoothJump = false
/**
* 回到底部(点按钮时)。这里用平滑滚动,所以需要一个标志位:
* 动画途中每一帧都「不靠近底部」,不加标志的话每一帧都会把 stick
* 判成 false —— 按钮自己把自己的自动跟随关掉了。
*/
function backToBottom() {
stick = true
smoothJump = true
stream.scrollTo({ top: stream.scrollHeight, behavior: 'smooth' })
clearTimeout(backToBottom.timer)
backToBottom.timer = setTimeout(() => {
smoothJump = false
stick = nearBottom()
syncToBottom()
syncActiveMark()
}, 400)
}
stream.addEventListener('scroll', () => {
if (smoothJump) return
stick = nearBottom()
syncToBottom()
syncActiveMark()
})
$('to-bottom').addEventListener('click', backToBottom)
// ═══════════════════════════════════════════════════════════
// 跳转轨:每一次输入一个刻度
// ═══════════════════════════════════════════════════════════
/**
* 屏幕右边那条轨道,一个刻度 = 一次用户输入。
*
* 它解决的是「往回翻的成本随长度增长」:跑十几步之后想回到「我最早说的是
* 什么」要滚很久,而且滚的过程没有落点。
*
* 刻度按**内容里的位置比例**摆,不是等距排 —— 等距只能说明「有 5 次输入」,
* 按比例才能说明「那 5 次分别在多深的地方」。
*/
const turnMarks = []
let railEls = []
function syncTurnRail() {
$('turn-rail').hidden = turnMarks.length < 2
if (turnMarks.length < 2) return
const scroller = $('turn-rail-scroller')
// 刻度只在数量变化时重建。位置每次都要重算 —— 内容变长,比例就变了。
if (railEls.length !== turnMarks.length) {
railEls = turnMarks.map((mark, i) => {
const el = h('button', {
class: 'turn-mark',
type: 'button',
title: mark.text.length > 60 ? `${mark.text.slice(0, 60)}…` : mark.text,
'aria-label': `第 ${i + 1} 次输入`,
})
el.addEventListener('click', () => scrollToTurn(i))
return el
})
scroller.replaceChildren(...railEls)
}
const total = stream.scrollHeight || 1
const top = stream.getBoundingClientRect().top
for (const [i, el] of railEls.entries()) {
// 视口坐标 + 已滚距离 = 内容坐标
const y = turnMarks[i].el.getBoundingClientRect().top - top + stream.scrollTop
el.style.top = `${(Math.min(1, Math.max(0, y / total)) * 100).toFixed(3)}%`
}
syncActiveMark()
}
/**
* 跳到第 i 次输入。
*
* 同样不用 `scrollIntoView` —— 它连整个页面一起滚。这里要动的只有
* `.stream` 一个元素,所以按「目标相对滚动视口的偏移」算差值。
*/
function scrollToTurn(i) {
const mark = turnMarks[i]
if (!mark) return
const delta = mark.el.getBoundingClientRect().top - stream.getBoundingClientRect().top
// 手动跳转意味着用户不是要看最新的,把自动跟随关掉
stick = false
smoothJump = true
stream.scrollTo({ top: Math.max(0, stream.scrollTop + delta - 12), behavior: 'smooth' })
clearTimeout(scrollToTurn.timer)
scrollToTurn.timer = setTimeout(() => {
smoothJump = false
stick = nearBottom()
syncToBottom()
syncActiveMark()
}, 400)
}
/** 高亮「现在读到的是哪一次输入」:最后一个已经越过视口顶部的刻度 */
function syncActiveMark() {
if (!railEls.length) return
const top = stream.getBoundingClientRect().top
let active = 0
for (const [i, mark] of turnMarks.entries()) {
if (mark.el.getBoundingClientRect().top - top <= 16) active = i
}
for (const [i, el] of railEls.entries()) el.classList.toggle('active', i === active)
}
function userTurn(text) {
const el = h('div', { class: 'msg-user' }, h('div', { class: 'bubble' }, text))
chat.append(el)
turnMarks.push({ el, text })
syncTurnRail()
followTail()
}
/**
* 一次运行是「一次模型调用 + 很多次判定」,所以**过程本身就是内容**。
*
* 每一行事件追加到列表里,不覆盖 —— 覆盖的话你只看得见最后一个,
* 而「判定 13 次、模型 1 次」这件事恰恰只有铺开才看得见。
*
* 列表末尾永远有一行运行指示器,**它的秒数在走**。这是「慢」和「断」
* 的分界线:一次判定在 hosted Jev 上要 ~390ms,所以几秒没动静是正常的,
* 但秒数一直涨而不出下一行,就说明真的卡住了。
*/
function assistantTurn() {
const list = h('div', { class: 'process-list' })
const running = h('div', { class: 'running-row' }, h('span', { class: 'pulse' }), '开始…')
const answer = h('div', { class: 'answer' }, '')
const foot = h('div', { class: 'msg-foot' })
// 「上一次的输出作废了,正在重写」——和 `running` 一样是**实时专有**的,
// 历史里没有它(那时没有东西被擦掉,没什么可解释的)。
const notice = h('div', { class: 'answer-notice' })
notice.hidden = true
const el = h('div', { class: 'msg-assistant' }, list, running, notice, answer, foot)
chat.append(el)
followTail()
return {
el,
list,
running,
notice,
answer,
foot,
rows: 0,
finished: false,
stopped: false,
// 回答是**边生成边长**的:`draft` 是到目前为止收到的全文,
// `stream` 只重画尾部(见 `markdown.js` 的 `createStreamRenderer`)。
draft: '',
stream: createStreamRenderer(answer),
}
}
/** 事件 → 过程里的一行 */
function processRow(e, dur) {
const meta = EVENT_META[e.type]
if (!meta) return null
const detail = []
switch (e.type) {
case 'decision': {
detail.push(h('span', { class: 'hit' }, `→ ${e.action}`))
/*
★ **所有答案都显示,不是只显示第一个。**
以前只取 `Object.entries(answers)[0]`,而策略可能看的是**第二个**。
实测(2026-09-21,查「输出被收回」时撞见的):
loop.canDeliver → revise · deliverable=真 68.0%
那一行读起来自相矛盾 —— 68% 的把握说「可以交付」,动作却是「修订」。
真相在没被显示的那个答案上:`unsupported=0.70`,而策略是
`prob:unsupported >= 0.5 → revise` 排在 `deliverable` 前面。
`gradeRisk`(risk + needs_auth)同理,两个都在,只显示一个就会漏掉
真正决定动作的那个。
答案最多两三个,全铺开也不会长到读不下。
*/
for (const [qid, a] of Object.entries(e.answers ?? {})) {
if (a.type === 'choice') detail.push(` · ${qid}=${a.choice} ${pct(a.probabilities?.[a.choice] ?? 0)}`)
else if (a.type === 'noul') detail.push(` · ${qid}=${a.noul >= 0.5 ? '真' : '假'} ${pct(a.noul)}`)
else if (a.type === 'score') detail.push(` · ${qid}=档位 ${a.score}`)
}
break
}
case 'tool:call':
detail.push(e.input ? `${e.tool}(${e.input})` : `${e.tool}()`)
break
case 'tool:result':
detail.push(`${String(e.output ?? '').length} 字符`)
break
case 'generate':
detail.push(`${e.kind} · ${tokenText(e)} · 唯一贵的一步`)
break
case 'authorize':
detail.push(`${e.tool} · ${e.approved ? '已授权' : '已拒绝'} · ${e.reason ?? ''}`)
break
case 'audit':
detail.push(`${e.record?.tool ?? ''} · risk ${e.record?.risk ?? '?'}`)
break
case 'context': {
// 压了什么必须写清楚 —— 否则「这次答得不全」会被归因到模型身上
detail.push(contextBits(e).join(' · '))
break
}
case 'conversation': {
// 同理:上文被折过而没说,模型答不出「那个文件」时会被归因到它自己身上
detail.push(conversationBits(e).join(' · '))
break
}
default:
break
}
return h(
'div',
{ class: `prow ${meta.cls}` },
h('span', { class: 'pdot' }),
h('span', { class: 'pkind' }, meta.label),
h('span', { class: 'pname' }, e.id ?? e.tool ?? ''),
h('span', { class: 'pdetail' }, ...detail),
h('span', { class: 'ptime' }, dur && dur.measured ? ms(dur.ms) : ''),
)
}
/**
* 「比平时久」的界线,**按阶段分开**。
*
* ★ 一个数管三段是错的:托管 Jev 一次判定约 **390ms**(§8.11),而一次
* 生成 **2 秒起、几十秒都正常**(生成器自己的超时是 60 秒)。用同一个
* 6 秒,既会把正常的生成报成「比平时久」,又对真卡住的判定太宽容。
*
* 数字的来源:判定 6 秒 ≈ 托管 Jev 的 15 倍;生成 30 秒 ≈ 实测常见的
* 10 倍,同时留在 60 秒超时之内 —— 超过它多半是真出问题了,但**还可能
* 正常返回**,所以文案是「比平时久」而不是「失败」。
*/
const STALL_AFTER_MS = { decide: 6000, tool: 6000, generate: 30000 }
let ticker = null
/**
* 运行指示器:显示「现在在等什么」+ 已经等了多久。
*
* 没有这个,一次 37 秒的运行看起来和挂掉没有区别 —— 而它其实一直在跑,
* 只是每次判定之间隔着几百毫秒的静默。
*/
function startTicker() {
stopTicker()
ticker = setInterval(() => {
if (!current || current.finished || current.stopped) return
const waited = Date.now() - (current.lastEventAt ?? Date.now())
const secs = (waited / 1000).toFixed(0)
const stalled = waited >= (STALL_AFTER_MS[current.phaseKind] ?? 6000)
current.running.className = `running-row${stalled ? ' stalled' : ''}`
current.running.replaceChildren(
h('span', { class: 'pulse' }),
`${current.waitingOn} · 已等待 ${secs}s${stalled ? '(比平时久)' : ''}`,
)
}, 500)
}
function stopTicker() {
if (ticker !== null) {
clearInterval(ticker)
ticker = null
}
}
/**
* 换「现在在干什么」,并把秒数从头数起。
*
* **只有「开始」信号该调它** —— `phase` 事件和 `tool:call`。其余事件都是
* 做完之后才发的,拿它们改标签就是原来那个 bug(生成期间显示「正在判定」)。
*/
function setPhase(text, kind) {
if (!current) return
current.waitingOn = text
current.phaseKind = kind
current.lastEventAt = Date.now()
if (!current.stopped) {
current.running.className = 'running-row'
current.running.replaceChildren(h('span', { class: 'pulse' }), `${text} · 已等待 0s`)
}
}
/**
* 又来了一条事件 —— **只把秒数从头数起,不动标签**。
*
* 两件事分开是因为它们回答不同的问题:标签是「在做什么」(由开始信号决定),
* 秒数是「上一次有动静是多久以前」(任何事件都算)。
*/
function touch() {
if (current) current.lastEventAt = Date.now()
}
/** `phase` 事件 → 那句话。判定节点 id 不进去 —— 轨迹里有,这里加只会变吵 */
function phaseText(e) {
return e.kind === 'generate' ? '正在生成回答' : '正在判定'
}
/**
* 「判定 : 模型」的显示文本。**两个调用点共用这一个函数。**
*
* 以前消息页脚(`finishAssistant`)和右侧统计栏(`renderEndStats`)各写了一份,
* 而且**已经不一致**:`decisions` 缺失时一个渲染 `? : 0`、另一个渲染 `— : 0` ——
* 同一次运行、同屏可见,两种文本。旁边那段「不在这里重算,两处实现会分叉」
* 的注释防住的是**除法**,没防住**格式化**,而分叉恰恰发生在格式化上。
*
* `ratio === null` 是服务端的「没有模型调用」(见 `MeterStats.ratio`)——
* 以前那里是 `Infinity`,而 JSON 把它变成 `null`,于是前端那条
* `!Number.isFinite` 分支**永远不可达**,显示成 `?` 而不是它想显示的 `N : 0`。
* 恰恰是「跑了 N 次判定、一次模型调用都没成」这个最需要看清的情形。
*/
function ratioText(s) {
const r = s?.ratio
if (r === null) return `${s?.decisions ?? '?'} : 0`
if (typeof r !== 'number' || !Number.isFinite(r)) return '?'
return `${r.toFixed(1)} : 1`
}
/**
* 这一轮被交付闸门要求修订过吗?有就返回那句话,没有返回 `null`。
*
* ★ 判据是**持久化的事件**:修订那一次的 `generate` 事件 kind 是
* `generate/revise …`,它进日志(增量不进,见 `src/events.ts` 的
* `GenerateDelta`)。所以只有它能同时对**实时那一轮**和**刷新之后重放的
* 那一轮**说同一句话 —— 拿增量判就会「实时看得见、刷新就没了」。
*/
function reviseNoteOf(events) {
const revised = (events ?? []).some(
(e) => e.type === 'generate' && String(e.kind ?? '').startsWith('generate/revise'),
)
return revised ? '这次回答被交付闸门要求修订过一次,上面是修订后的版本' : null
}
function finishAssistant(text, stats, halt) {
if (!current) return
current.finished = true
stopTicker()
current.el.removeChild(current.running)
/*
★ **回答按 markdown 渲染,不再当纯文本。**
以前这里是 `textContent = text` —— 于是模型输出的 `##`、`**`、代码围栏
全都以**字面**出现,一段正常的总结是一屏带井号和星号的原文
(实测 2026-09-21,用户报的)。
渲染器在 `markdown.js`:手写(不引依赖)、**全程构造 DOM 不用
`innerHTML`**(渲染的是模型生成的文本,即不可信输入)。
*/
current.answer.replaceChildren(text ? renderMarkdown(text) : '(没有回答)')
// 说明行定稿:实时那一轮用闸门给的原话(流式时写的),重放那一轮只能
// 从事件里推出来 —— 两者到这里统一成同一句
const note = reviseNoteOf(current.events)
current.notice.hidden = note === null
if (note) current.notice.replaceChildren(h('span', { class: 'mark' }, '⟲'), note)
const s = stats ?? {}
const ratio = ratioText(s)
current.foot.className = `msg-foot${halt === 'error' ? ' failed' : ''}`
const gateKeys = Object.keys(current.gates ?? {}).sort()
current.foot.replaceChildren(
h('span', {}, `${s.decisions ?? '?'} 判定`),
h('span', {}, `${s.modelCalls ?? '?'} 模型`),
h('span', {}, `${ratio} 判定:模型`),
h('span', {}, `停于 ${halt}`),
// 只在**真的有覆盖**时才出现这一项 —— 默认门限是常态,常态不用标注
...(gateKeys.length > 0
? [h('span', { class: 'gated', title: gateKeys.map((k) => `${k}=${current.gates[k]}`).join(', ') }, `门限覆盖 ${gateKeys.length} 项`)]
: []),
)
followTail()
}
// ═══════════════════════════════════════════════════════════
// 渲染:轨迹
//
// 结构照搬 DSH 的 ui-trajectory:顶部时间轴 + 事件账本 + 详情面板。
//
// 时间轴横轴是**真实耗时**,三条泳道是判定 / 工具 / 模型。它一眼要说的
// 就是那个主张:蓝的一堆、橙的只有一个,而橙的那个最宽。
//
// 账本一行一个事件,30px。点一行,右边显示它的全部内容 ——
// 判定卡里那些概率条搬到了那里,因为一行 30px 放不下四个选项,
// 而概率恰恰是这个界面最不该省略的东西。
// ═══════════════════════════════════════════════════════════
/** 泳道号。顺序和 .plot-labels 里的三行标签一一对应 */
const LANE = { decide: 0, tool: 1, model: 2 }
/**
* 事件 → 它在轨迹里的样子。
*
* 时间轴和账本共用这张表,所以两边的分类永远不会不一致 ——
* 分两处写的话,加一种事件时必然只改一处。
*/
const EVENT_META = {
decision: { label: '判定', cls: 'decide', lane: LANE.decide },
// 授权单独一类 —— 它是**停下来等人**,不是一次判定,也不该像出错
authorize: { label: '授权', cls: 'authorize', lane: LANE.decide },
audit: { label: '审计', cls: 'audit', lane: LANE.decide },
// 上下文账目是**代码做的决定**(预算是纯代码),所以归「规则」那一档的灰
context: { label: '预算', cls: 'audit', lane: LANE.decide },
// 上文折叠同理,也是纯代码 —— 但**单位不同**(轮 vs 步),所以徽章分开写
conversation: { label: '上文', cls: 'audit', lane: LANE.decide },
// 工具那两行用等宽字体:它们的内容是命令和输出,不是句子
'tool:call': { label: '工具', cls: 'tool', lane: LANE.tool, mono: true },
'tool:result': { label: '工具结果', cls: 'tool', lane: LANE.tool, mono: true },
generate: { label: '模型', cls: 'model', lane: LANE.model },
}
/**
* 图例的说明文字 —— 按**类**写,不按事件写。
*
* 同一档颜色下可能有好几种徽章(灰色那一档就是「审计」和「预算」),
* 它们在图里长得一模一样,所以在图例里也该并排出现、共用一句说明。
*/
const LEGEND_NOTES = {
decide: { text: '一次前向就出答案,不生成文本' },
audit: { text: '代码做的决定,不过模型' },
tool: { text: '唯一有真实副作用的地方' },
model: { text: '唯一贵的一步,整条轨迹上最宽的那一条', strong: true },
authorize: { text: '停下来等人' },
}
/** 图例的排列顺序。不在这里的类会被追加到末尾并标出来,不会被丢掉。 */
const LEGEND_ORDER = ['decide', 'audit', 'tool', 'model', 'authorize']
/**
* 图例是 EVENT_META 的**第三个**消费者(另两个是时间轴和账本)。
*
* 上一版图例是手写的 HTML,于是和轨迹漂移了个彻底:轨迹早就是 19px 的
* 文字徽章,图例还在画 3px 色块,连「规则」那一档的类名(`.swatch.rule`)
* 在轨迹侧都不存在。三样全错,却没有任何东西会报错。
*
* 从同一张表生成就没有这个问题:加一种事件,图例自己会多出一行。
*/
function renderLegend() {
const byCls = new Map()
for (const meta of Object.values(EVENT_META)) {
if (!byCls.has(meta.cls)) byCls.set(meta.cls, [])
byCls.get(meta.cls).push(meta.label)
}
// EVENT_META 里出现了、但 LEGEND_ORDER 没收录的类,追加到末尾。
// 说明文字缺失时报「还没写说明」,**不静默少一行** —— 少一行是看不出来的。
const extra = [...byCls.keys()].filter((cls) => !LEGEND_ORDER.includes(cls))
const rows = []
for (const cls of [...LEGEND_ORDER, ...extra]) {
const labels = byCls.get(cls)
if (!labels) continue
const note = LEGEND_NOTES[cls]
rows.push(
h(
'div',
{ class: 'legend-row' },
h(
'span',
{ class: 'legend-badges' },
...labels.map((label) => h('span', { class: `kind-tag ${cls}` }, label)),
),
h('span', {}, note?.strong ? h('b', {}, note.text) : (note?.text ?? '(还没写说明)')),
),
)
}
$('legend-rows').replaceChildren(...rows)
}
/**
* 事件自己报的耗时。
*
* 时间轴按它排布。没有耗时的那些(工具发起、授权、审计)给一个标称宽度 ——
* 它们在时间轴上只是"发生过"的记号,不是可测量的区间。
* 标称值不参与总时长计算,否则会把它撑大,让真正贵的那个显得没那么宽。
*/
function durationOf(e) {
switch (e.type) {
case 'decision':
return { ms: e.latencyMs ?? 0, measured: true }
case 'tool:result':
return { ms: e.ms ?? 0, measured: true }
case 'generate':
return { ms: e.latencyMs ?? 0, measured: true }
default:
return { ms: 0, measured: false }
}
}
/** 一行里「内容」列写什么。返回一串节点,让调用方决定怎么排版 */
function contentOf(e) {
const mono = (t) => h('span', { class: 'mono' }, t)
const dim = (t) => h('span', { class: 'dim' }, t)
switch (e.type) {
case 'decision': {
const picked = Object.entries(e.answers ?? {})[0]
const bits = [h('span', { class: 'hit' }, e.action)]
if (picked) {
const [qid, a] = picked
if (a.type === 'choice') {
bits.push(dim(' · '), mono(qid), ' = ', mono(a.choice), dim(` ${pct(a.probabilities?.[a.choice] ?? 0)}`))
} else if (a.type === 'noul') {
bits.push(dim(' · '), mono(qid), ' ', dim(a.noul >= 0.5 ? '真' : '假'), dim(` ${pct(a.noul)}`))
} else if (a.type === 'score') {
bits.push(dim(' · '), mono(qid), ' ', dim(`档位 ${a.score}`))
}
}
return bits
}
case 'tool:call':
return [mono(e.tool), dim('('), dim(e.input || '无输入'), dim(')')]
case 'tool:result': {
const body = String(e.output ?? '')
const first = body.split('\n')[0].slice(0, 120)
return [dim(`${body.length} 字符 · ${ms(e.ms)} · `), first]
}
case 'generate':
return [
h('span', { class: 'warn' }, e.kind),
dim(` · ${ms(e.latencyMs)} · ${tokenText(e)} · 整个 loop 里唯一贵的一步`),
]
case 'authorize':
return [e.approved ? '已授权' : '已拒绝', dim(` · ${e.tool} · ${e.reason}`)]
case 'audit':
return [mono(e.record?.tool ?? ''), dim(` 记了审计留痕 · risk ${e.record?.risk ?? '?'}`)]
case 'context': {
const bits = contextBits(e)
if (e.overRetain) {
return [h('span', { class: 'warn' }, '⚠ 上下文压不到目标线'), dim(' · ' + bits.join(' · '))]
}
return [dim('工具证据被预算压过 · '), bits.join(' · ')]
}
case 'conversation': {
const bits = conversationBits(e)
if (e.overRetain) {
return [h('span', { class: 'warn' }, '⚠ 上文压不到目标线'), dim(' · ' + bits.join(' · '))]
}
return [dim('上文被折叠过 · '), bits.join(' · ')]
}
default:
return [e.type]
}
}
// ── 账本 ──────────────────────────────────────────────────
const trajBody = $('traj-body')
const plotLanes = $('plot-lanes')
const plotEmpty = $('plot-empty')
const detailTitle = $('detail-title')
const detailLocation = $('detail-location')
const detailTabs = $('detail-tabs')
const detailBody = $('detail-body')
/** 时间轴上的条。`start`/`dur` 是累计毫秒 */
const spans = []
let clock = 0
let selected = null
let rowsAdded = 0
function addSpan(e, dur) {
const meta = EVENT_META[e.type]
if (!meta) return
// 有耗时的按真实区间排;没有的给 1ms 标称宽度,且不推进时钟
const width = dur.measured ? Math.max(dur.ms, 1) : 1
spans.push({ lane: meta.lane, cls: meta.cls, start: clock, dur: width, event: e })
if (dur.measured) clock += dur.ms
renderPlot()
}
/**
* 重画时间轴。
*
* 每次事件都整体重画:一次运行几十个条,重画比维护增量便宜得多,
* 而且总时长还在长 —— 百分比定位必须跟着总长走。
*/
function renderPlot() {
if (spans.length === 0) {
plotEmpty.hidden = false
plotLanes.replaceChildren()
return
}
plotEmpty.hidden = true
const total = clock > 0 ? clock : 1
plotLanes.replaceChildren(
...spans.map((s) => {
const el = h('div', {
class: `span ${s.cls}`,
style: `--lane:${s.lane};--left:${(s.start / total) * 100}%;--width:${Math.max(0.4, (s.dur / total) * 100)}%`,
title: `${EVENT_META[s.event.type]?.label ?? s.event.type} · ${Math.round(s.dur)}ms`,
})
return el
}),
)
}
function addRow(e, dur, step) {
const meta = EVENT_META[e.type]
if (!meta) return
// 步骤边界:行顶一条 2px 细线(CSS 的 ::before),左上角一个 8px 的步骤号徽章。
// 第一次见到某个 step 时打上标记 —— DSH 的 data-turn-start 是同一个做法。
const stepStart = e.type === 'decision' && step && step !== lastStep
if (stepStart) lastStep = step
const tr = h('tr', {
tabindex: '0',
...(stepStart ? { 'data-step-start': 'true' } : {}),
...(meta.mono ? { 'data-mono': 'true' } : {}),
})
// 事件列:36px 左留白里放步骤号,76px 的槽里右对齐一个文字徽章
tr.append(
h(
'td',
{ class: 'col-event' },
stepStart ? h('span', { class: 'step-label' }, `step ${step}`) : null,
h(
'div',
{ class: 'event-inner' },
h('span', { class: 'kind-slot' }, h('span', { class: `kind-tag ${meta.cls}` }, meta.label)),
e.id || e.tool ? h('span', { class: 'event-id' }, e.id ?? e.tool) : null,
),
),
h('td', { class: 'content-cell' }, h('span', { class: 'content-text' }, ...contentOf(e))),
)
const select = () => selectRow(tr, e)
tr.addEventListener('click', select)
tr.addEventListener('keydown', (ev) => {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault()
select()
}
})
trajBody.append(tr)
rowsAdded++
}
function selectRow(tr, e) {
if (selected) selected.removeAttribute('aria-selected')
tr.setAttribute('aria-selected', 'true')
selected = tr
showDetail(e)
}
// ── 详情 ──────────────────────────────────────────────────
/** 右侧详情有两个 tab:概览看结论,决策帧看模型当时看到了什么 */
const DETAIL_TABS = ['概览', '决策帧']
let detailEvent = null
let detailTab = 0
function showDetail(e) {
detailEvent = e
detailTab = 0
const meta = EVENT_META[e.type]
detailTitle.textContent = `${meta?.label ?? e.type}${e.id ? ` ${e.id}` : ''}`
detailLocation.textContent = e.tool ?? e.kind ?? ''
detailTabs.replaceChildren(
...DETAIL_TABS.map((name, i) =>
h(
'button',
{
class: 'detail-tab',
role: 'tab',
'aria-selected': String(i === detailTab),
onclick: () => {
detailTab = i
for (const [j, b] of [...detailTabs.children].entries()) {
b.setAttribute('aria-selected', String(j === i))
}
renderDetail()
},
},
name,
),
),
)
renderDetail()
}
const field = (k, v) => h('div', { class: 'field' }, h('div', { class: 'field-k' }, k), h('div', { class: 'field-v' }, v))
function renderDetail() {
const e = detailEvent
if (!e) return
if (detailTab === 1) {
// 决策帧:真正发给模型的东西。判定质量的上限由这一帧决定
detailBody.replaceChildren(
e.state
? field('发给模型的 state', h('pre', {}, JSON.stringify(e.state, null, 2)))
: h('div', { class: 'detail-empty' }, '这个事件没有决策帧'),
)
return
}
const kids = []
if (e.reason) kids.push(field('为什么是这个动作', e.reason))
if (e.latencyMs != null) kids.push(field('判定耗时', ms(e.latencyMs)))
// 每个问题:选项 + 概率条。这是这个界面最不该省略的东西
for (const [qid, q] of Object.entries(e.questions ?? {})) {
const a = e.answers?.[qid]
if (!a) continue
const qa = h('div', { class: 'qa' })
qa.append(h('div', { class: 'q-instruction' }, h('span', { class: 'q-id' }, qid), q.instructions ?? ''))
if (a.type === 'choice') {
const opts = Object.entries(a.probabilities ?? {}).sort((x, y) => y[1] - x[1])
for (const [name, p] of opts) {
qa.append(
h(
'div',
{ class: `opt${name === a.choice ? ' picked' : ''}` },
h('span', { class: 'opt-name', title: q.criteria?.[name] ?? '' }, name),
h('span', { class: 'opt-track' }, h('span', { class: 'opt-fill', style: `width:${Math.max(1, p * 100)}%` })),
h('span', { class: 'opt-pct' }, pct(p)),
),
)
}
} else if (a.type === 'noul') {
qa.append(
h(
'div',
{ class: 'noul-bar' },
h('span', { class: 'noul-false', style: `flex:${Math.max(0.001, 1 - a.noul)}` }),
h('span', { class: 'noul-true', style: `flex:${Math.max(0.001, a.noul)}` }),
),
h(
'div',
{ class: 'noul-legend' },
h('span', {}, `假 ${pct(1 - a.noul)}`),
h('span', {}, `真 ${pct(a.noul)}`),
),
)
} else if (a.type === 'score') {
const legend = a.legend ?? {}
for (const [k, p] of Object.entries(a.probabilities ?? {})) {
qa.append(
h(
'div',
{ class: 'opt' },
h('span', { class: 'opt-name', title: legend[k] ?? '' }, `${k} ${(legend[k] ?? '').slice(0, 8)}`),
h('span', { class: 'opt-track' }, h('span', { class: 'opt-fill', style: `width:${Math.max(1, p * 100)}%` })),
h('span', { class: 'opt-pct' }, pct(p)),
),
)
}
qa.append(h('div', { class: 'scale' }, `期望档位 ${a.score}`))
}
kids.push(qa)
}
if (e.type === 'tool:call') kids.push(field('输入', h('pre', {}, e.input || '(无输入)')))
if (e.type === 'tool:result') {
kids.push(field('耗时', ms(e.ms)))
kids.push(field('输出', h('pre', {}, String(e.output ?? ''))))
}
if (e.type === 'generate') {
kids.push(field('耗时', ms(e.latencyMs)))
kids.push(field('我们估的输入', `${e.estimatedInputTokens} token(上文 + 当前任务 + 证据)`))
if (e.inputTokens > 0 || e.outputTokens > 0) {
kids.push(field('provider 报的输入', `${e.inputTokens} token(含生成器内部的 system prompt)`))
kids.push(field('provider 报的输出', `${e.outputTokens} token`))
// 差值是 system prompt + 启发式偏差的和 —— 分开说,别假装能算清
const gap = e.inputTokens - e.estimatedInputTokens