@wacry/codeks-bridge 0.0.0-stage → 0.0.1
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/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +40 -2
- package/catalog/README.md +63 -0
- package/catalog/claude-opus-5-5.json +194 -0
- package/catalog/claude-sonnet-5-5.json +194 -0
- package/catalog/deepseek-flash.json +203 -0
- package/catalog/gpt-5.6-sol.json +152 -0
- package/catalog/gpt-6-astra.json +184 -0
- package/catalog/gpt-6-luna.json +178 -0
- package/catalog/gpt-6-sol.json +182 -0
- package/catalog/gpt-6.1-sol.json +187 -0
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +42 -0
- package/dist/backends/anthropic.d.ts +8 -0
- package/dist/backends/anthropic.js +309 -0
- package/dist/backends/chat.d.ts +16 -0
- package/dist/backends/chat.js +284 -0
- package/dist/backends/deepseek.d.ts +6 -0
- package/dist/backends/deepseek.js +37 -0
- package/dist/backends/responses.d.ts +5 -0
- package/dist/backends/responses.js +205 -0
- package/dist/carrier.d.ts +22 -0
- package/dist/carrier.js +61 -0
- package/dist/catalog.d.ts +8 -0
- package/dist/catalog.js +40 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +64 -0
- package/dist/compaction-handoff.d.ts +13 -0
- package/dist/compaction-handoff.js +126 -0
- package/dist/compaction-history.d.ts +7 -0
- package/dist/compaction-history.js +20 -0
- package/dist/compaction.d.ts +20 -0
- package/dist/compaction.js +134 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +167 -0
- package/dist/errors.d.ts +43 -0
- package/dist/errors.js +141 -0
- package/dist/headers.d.ts +6 -0
- package/dist/headers.js +39 -0
- package/dist/history.d.ts +7 -0
- package/dist/history.js +85 -0
- package/dist/ids.d.ts +1 -0
- package/dist/ids.js +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/inspect/capture.d.ts +119 -0
- package/dist/inspect/capture.js +370 -0
- package/dist/inspect/http.d.ts +7 -0
- package/dist/inspect/http.js +149 -0
- package/dist/inspect/view.d.ts +239 -0
- package/dist/inspect/view.js +388 -0
- package/dist/model-catalog.d.ts +18 -0
- package/dist/model-catalog.js +16 -0
- package/dist/profiles.d.ts +6 -0
- package/dist/profiles.js +63 -0
- package/dist/prompts.d.ts +5 -0
- package/dist/prompts.js +118 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +232 -0
- package/dist/sse.d.ts +12 -0
- package/dist/sse.js +93 -0
- package/dist/tool-history.d.ts +4 -0
- package/dist/tool-history.js +72 -0
- package/dist/tools.d.ts +33 -0
- package/dist/tools.js +182 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.js +1 -0
- package/dist/upstream.d.ts +3 -0
- package/dist/upstream.js +84 -0
- package/dist/writer.d.ts +95 -0
- package/dist/writer.js +430 -0
- package/docs/backends-anthropic.md +61 -0
- package/docs/backends-deepseek-chat.md +75 -0
- package/docs/backends-responses.md +33 -0
- package/docs/compaction.md +176 -0
- package/docs/config.md +231 -0
- package/docs/design.md +93 -0
- package/docs/inspect.md +138 -0
- package/docs/reasoning.md +72 -0
- package/docs/releasing.md +50 -0
- package/docs/responses-stream.md +90 -0
- package/docs/testing.md +177 -0
- package/docs/tools.md +55 -0
- package/examples/config.json +35 -0
- package/package.json +46 -4
- package/ui/api.js +64 -0
- package/ui/app.css +375 -0
- package/ui/app.js +489 -0
- package/ui/detail.js +682 -0
- package/ui/diff.js +281 -0
- package/ui/dom.js +258 -0
- package/ui/events.js +351 -0
- package/ui/favicon.svg +1 -0
- package/ui/index.html +58 -0
- package/ui/labels.js +101 -0
- package/ui/list.js +180 -0
- package/ui/structure.js +285 -0
- package/ui/tree.js +158 -0
package/ui/detail.js
ADDED
|
@@ -0,0 +1,682 @@
|
|
|
1
|
+
// Detail of one exchange: header, the four-stage pipeline, and up to two panes
|
|
2
|
+
// (overview, a stage body in one of several view modes, annotations, diff).
|
|
3
|
+
import { h, btn, fill, copyText, download, fmtBytes, fmtDateTime, fmtDur, fmtInt, fmtTime, kvTable, oneLine, safeName, shortId, strBlock, toast, tryJson, put } from './dom.js';
|
|
4
|
+
import { jsonTree } from './tree.js';
|
|
5
|
+
import { structureView, newStructState, focusPointer } from './structure.js';
|
|
6
|
+
import { eventsView, newEventsState, finalObject, parseEvents, usageText } from './events.js';
|
|
7
|
+
import { diffView } from './diff.js';
|
|
8
|
+
import { annChip, annDetail, annIndex, callLabel, decisionClass, decisionText, END_LABEL, KIND_LABEL, KIND_TEXT, OUTCOME_LABEL, reasonText, sourceText } from './labels.js';
|
|
9
|
+
import { OUTCOME_CLASS, threadKey, byNewest } from './list.js';
|
|
10
|
+
import { exportDoc } from './api.js';
|
|
11
|
+
|
|
12
|
+
const RAW_LIMIT = 200_000;
|
|
13
|
+
const VIEW_STATES = 80;
|
|
14
|
+
const REQUEST_MODES = [['struct', '结构'], ['json', 'JSON'], ['raw', '原始'], ['headers', '头部']];
|
|
15
|
+
const RESPONSE_MODES = [['struct', '结构'], ['events', '事件'], ['json', 'JSON'], ['raw', '原始'], ['headers', '头部']];
|
|
16
|
+
|
|
17
|
+
// ---------- stage model ----------
|
|
18
|
+
|
|
19
|
+
/** Stable key per upstream call: its label, numbered when a label repeats. */
|
|
20
|
+
function callKeys(calls) {
|
|
21
|
+
const seen = new Map();
|
|
22
|
+
return (calls ?? []).map((c) => {
|
|
23
|
+
const n = (seen.get(c.label) ?? 0) + 1;
|
|
24
|
+
seen.set(c.label, n);
|
|
25
|
+
return n > 1 ? `${c.label}#${n}` : String(c.label);
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** All panes of an exchange, in pipeline order. */
|
|
30
|
+
export function stagesOf(d) {
|
|
31
|
+
const keys = callKeys(d.upstream);
|
|
32
|
+
const out = [{ key: 'overview', type: 'overview', title: '概要' },
|
|
33
|
+
{ key: 'req', type: 'request', num: '①', title: 'codeks → bridge', sub: '客户端请求', target: 'client.request', msg: d.client?.request ?? null }];
|
|
34
|
+
(d.upstream ?? []).forEach((call, i) => {
|
|
35
|
+
const label = callLabel(call.label);
|
|
36
|
+
out.push({ key: `up:${keys[i]}`, type: 'request', num: '②', title: 'bridge → 上游', sub: label, call, msg: call });
|
|
37
|
+
out.push({ key: `upres:${keys[i]}`, type: 'response', num: '③', title: '上游 → bridge', sub: label, call, resp: call.response ?? null });
|
|
38
|
+
});
|
|
39
|
+
out.push({ key: 'res', type: 'response', num: '④', title: 'bridge → codeks', sub: '客户端响应', target: 'client.response', resp: d.client?.response ?? null });
|
|
40
|
+
out.push({ key: 'ann', type: 'ann', title: `标注 ${d.annotations?.length ?? 0}` });
|
|
41
|
+
out.push({ key: 'diff', type: 'diff', title: '对比' });
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parsed = new WeakMap();
|
|
46
|
+
/** Parses a Body once: {ok, value} or {ok:false, error}. */
|
|
47
|
+
export function parseBody(body) {
|
|
48
|
+
if (!body) return { ok: false, error: '没有正文' };
|
|
49
|
+
let r = parsed.get(body);
|
|
50
|
+
if (!r) parsed.set(body, (r = tryJson(body.text)));
|
|
51
|
+
return r;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const respSig = (r) => (r ? `${r.status}|${r.end}|${r.ended_ms}|${r.body?.bytes}|${r.events?.length}|${r.error}` : 'none');
|
|
55
|
+
|
|
56
|
+
function paneSig(stage, d) {
|
|
57
|
+
if (!stage) return 'missing';
|
|
58
|
+
switch (stage.type) {
|
|
59
|
+
case 'request': return `${stage.msg?.body?.bytes}|${stage.call?.error ?? ''}|${stage.call?.response ? 1 : 0}`;
|
|
60
|
+
case 'response': return `${respSig(stage.resp)}|${stage.call?.error ?? ''}|${d.annotations?.length}`;
|
|
61
|
+
case 'diff': return 'diff';
|
|
62
|
+
default: return `${d.rev}|${respSig(d.client?.response)}|${(d.upstream ?? []).map((c) => respSig(c.response)).join(',')}|${d.annotations?.length}`;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const respClass = (r, err) => {
|
|
67
|
+
if (err || r?.error || (r?.status ?? 0) >= 400 || r?.end === 'error') return 'bad';
|
|
68
|
+
if (!r) return 'dim';
|
|
69
|
+
if (r.end === 'active') return 'info';
|
|
70
|
+
if (r.end === 'cancelled') return 'warn';
|
|
71
|
+
return 'ok';
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ---------- small renderers ----------
|
|
75
|
+
|
|
76
|
+
function valueCell(v) {
|
|
77
|
+
if (v === null || v === undefined) return h('span', { class: 'null' }, String(v));
|
|
78
|
+
if (typeof v === 'object') {
|
|
79
|
+
const s = JSON.stringify(v);
|
|
80
|
+
return s.length <= 200 ? h('span', { class: 'mono' }, s) : jsonTree(v, { auto: 1 });
|
|
81
|
+
}
|
|
82
|
+
if (typeof v === 'string' && v.length > 300) return strBlock(v);
|
|
83
|
+
return h('span', { class: `mono ${typeof v}` }, typeof v === 'string' ? v : String(v));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Generic key/value table of every field of an object, with Chinese hints for known keys. */
|
|
87
|
+
function fieldsTable(obj, hints, skip = []) {
|
|
88
|
+
const rows = [];
|
|
89
|
+
for (const [k, v] of Object.entries(obj ?? {})) {
|
|
90
|
+
if (skip.includes(k)) continue;
|
|
91
|
+
rows.push([h('span', null, hints[k] ? `${hints[k]} ` : '', h('span', { class: 'mono dim' }, k)), valueCell(v)]);
|
|
92
|
+
}
|
|
93
|
+
return kvTable(rows, 'fields-kv');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ROUTE_HINTS = {
|
|
97
|
+
alias: '别名', catalog: '目录模型', adapter: '适配器', api: '上游 API', upstream: '上游配置', base_url: 'base_url', upstream_model: '上游模型',
|
|
98
|
+
origin_id: '来源指纹', reasoning_format: 'reasoning 格式', native_compaction_format: '原生压缩格式', compaction_mode: '压缩模式',
|
|
99
|
+
native_replay: '回放原生压缩', replay: 'replay 白名单', responses_lite: 'responses_lite',
|
|
100
|
+
};
|
|
101
|
+
const SUMMARY_HINTS = {
|
|
102
|
+
id: 'ID', seq: '序号', rev: '版本', started_at: '开始', duration_ms: '耗时', active: '进行中', model: '模型别名', adapter: '适配器', api: 'API',
|
|
103
|
+
upstream: '上游配置', upstream_model: '上游模型', kind: '类型', stream: '流式', status: '状态码', outcome: '结果', error: '错误', usage: '用量',
|
|
104
|
+
thread: '线程', input_items: 'input 项数', preview: '预览', upstream_calls: '上游调用', bytes: '大小',
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
function headersTable(headers, first) {
|
|
108
|
+
const rows = first ? [...first] : [];
|
|
109
|
+
for (const [k, v] of Object.entries(headers ?? {})) {
|
|
110
|
+
const s = Array.isArray(v) ? v.join(', ') : String(v);
|
|
111
|
+
rows.push([h('span', { class: 'mono' }, k), /^\[redacted/.test(s) ? h('span', { class: 'chip warn mono' }, s) : h('span', { class: 'mono hval' }, s)]);
|
|
112
|
+
}
|
|
113
|
+
if (!rows.length) return h('div', { class: 'dim' }, '没有头部');
|
|
114
|
+
return kvTable(rows, 'headers');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rawView(text, name, isJson, ext) {
|
|
118
|
+
let full = false;
|
|
119
|
+
let pretty = false;
|
|
120
|
+
let prettyText = null;
|
|
121
|
+
const pre = h('pre', { class: 'raw' });
|
|
122
|
+
const info = h('span', { class: 'dim' });
|
|
123
|
+
const more = btn('显示全部', () => { full = true; render(); });
|
|
124
|
+
const render = () => {
|
|
125
|
+
if (pretty && prettyText == null) prettyText = JSON.stringify(JSON.parse(text), null, 2);
|
|
126
|
+
const s = pretty ? prettyText : text;
|
|
127
|
+
const cut = !full && s.length > RAW_LIMIT;
|
|
128
|
+
pre.textContent = cut ? s.slice(0, RAW_LIMIT) : s;
|
|
129
|
+
info.textContent = `${fmtInt(s.length)} 字符${cut ? ` · 仅显示前 ${fmtInt(RAW_LIMIT)}` : ''}`;
|
|
130
|
+
more.hidden = !cut;
|
|
131
|
+
};
|
|
132
|
+
const tools = h('div', { class: 'sv-tools' }, info, more,
|
|
133
|
+
isJson ? h('label', { class: 'check' }, h('input', { type: 'checkbox', onchange: (e) => { pretty = e.target.checked; render(); } }), '格式化 JSON') : null,
|
|
134
|
+
btn('复制', () => copyText(text)), btn('下载', () => download(`${name}.${ext}`, text, 'text/plain')));
|
|
135
|
+
render();
|
|
136
|
+
return h('div', { class: 'rawbox' }, tools, pre);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Annotation pointers usable in the structure view of the client response. */
|
|
140
|
+
function responsePointers(d) {
|
|
141
|
+
const resp = d.client?.response;
|
|
142
|
+
const { byPointer, byEvent } = annIndex(d.annotations, 'client.response');
|
|
143
|
+
if (!byEvent.size) return byPointer;
|
|
144
|
+
const events = parseEvents(resp) ?? [];
|
|
145
|
+
const out = new Map(byPointer);
|
|
146
|
+
for (const [i, list] of byEvent) {
|
|
147
|
+
const oi = events[i]?.json?.output_index;
|
|
148
|
+
if (oi == null) continue;
|
|
149
|
+
for (const a of list) {
|
|
150
|
+
const rest = (a.pointer ?? '').replace(/^\/item/, '');
|
|
151
|
+
const p = `/output/${oi}${rest}`;
|
|
152
|
+
out.set(p, [...(out.get(p) ?? []), a]);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------- the view ----------
|
|
159
|
+
|
|
160
|
+
export class DetailView {
|
|
161
|
+
/** ctx: { prefs, savePrefs(), getDetail(id), summaries(), summary(id), live(), select(id), refresh() } */
|
|
162
|
+
constructor(root, ctx) {
|
|
163
|
+
this.root = root;
|
|
164
|
+
this.ctx = ctx;
|
|
165
|
+
this.detail = null;
|
|
166
|
+
this.states = new Map();
|
|
167
|
+
this.diffSel = new Map();
|
|
168
|
+
this.panes = [{ key: ctx.prefs.pane ?? 'req', el: h('section', { class: 'pane' }), sig: null }];
|
|
169
|
+
this.headEl = h('div', { class: 'd-head' });
|
|
170
|
+
this.navEl = h('nav', { class: 'pipe' });
|
|
171
|
+
this.panesEl = h('div', { class: 'panes' }, this.panes[0].el);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
clear(message) {
|
|
175
|
+
this.detail = null;
|
|
176
|
+
this.root.replaceChildren(h('div', { class: 'empty' }, message));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Shows a detail; same exchange re-renders only panes whose data changed. */
|
|
180
|
+
show(d, { gone = false } = {}) {
|
|
181
|
+
const same = this.detail?.id === d.id;
|
|
182
|
+
this.detail = d;
|
|
183
|
+
this.gone = gone;
|
|
184
|
+
if (!this.root.contains(this.panesEl)) this.root.replaceChildren(this.headEl, this.navEl, this.panesEl);
|
|
185
|
+
const stages = stagesOf(d);
|
|
186
|
+
this.stages = new Map(stages.map((s) => [s.key, s]));
|
|
187
|
+
for (const p of this.panes) if (!this.stages.has(p.key)) p.key = this.fallbackKey(p.key);
|
|
188
|
+
this.renderHead(d);
|
|
189
|
+
this.renderNav(stages);
|
|
190
|
+
for (const p of this.panes) {
|
|
191
|
+
const sig = `${d.id}|${p.key}|${paneSig(this.stages.get(p.key), d)}`;
|
|
192
|
+
if (!same || sig !== p.sig) this.renderPane(p, same);
|
|
193
|
+
p.sig = sig;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
fallbackKey(key) {
|
|
198
|
+
const [kind, label] = key.split(':');
|
|
199
|
+
if (label) {
|
|
200
|
+
const base = label.replace(/#\d+$/, '');
|
|
201
|
+
const hit = [...this.stages.keys()].find((k) => k.startsWith(`${kind}:${base}`)) ?? [...this.stages.keys()].find((k) => k.startsWith(`${kind}:`));
|
|
202
|
+
if (hit) return hit;
|
|
203
|
+
return kind === 'upres' ? 'res' : 'req';
|
|
204
|
+
}
|
|
205
|
+
return 'req';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
open(key, { second = false, mode } = {}) {
|
|
209
|
+
if (!this.detail) return;
|
|
210
|
+
if (mode) this.setMode(this.stages.get(key), mode);
|
|
211
|
+
if (second) {
|
|
212
|
+
if (!this.panes[1]) {
|
|
213
|
+
this.panes.push({ key, el: h('section', { class: 'pane' }), sig: null });
|
|
214
|
+
put(this.panesEl, this.panes[1].el);
|
|
215
|
+
this.panesEl.classList.add('split');
|
|
216
|
+
}
|
|
217
|
+
this.panes[1].key = key;
|
|
218
|
+
this.panes[1].sig = null;
|
|
219
|
+
} else {
|
|
220
|
+
this.panes[0].key = key;
|
|
221
|
+
this.panes[0].sig = null;
|
|
222
|
+
this.ctx.prefs.pane = key;
|
|
223
|
+
this.ctx.savePrefs();
|
|
224
|
+
}
|
|
225
|
+
this.show(this.detail, { gone: this.gone });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
closeSecond() {
|
|
229
|
+
const p = this.panes.pop();
|
|
230
|
+
p?.el.remove();
|
|
231
|
+
this.panesEl.classList.remove('split');
|
|
232
|
+
this.renderNav(stagesOf(this.detail));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
viewState(key, mode, make) {
|
|
236
|
+
const k = `${this.detail.id}|${key}|${mode}`;
|
|
237
|
+
let st = this.states.get(k);
|
|
238
|
+
if (st) { this.states.delete(k); this.states.set(k, st); return st; }
|
|
239
|
+
st = make();
|
|
240
|
+
this.states.set(k, st);
|
|
241
|
+
if (this.states.size > VIEW_STATES) this.states.delete(this.states.keys().next().value);
|
|
242
|
+
return st;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
modeOf(stage) {
|
|
246
|
+
const modes = this.ctx.prefs.modes;
|
|
247
|
+
let m = modes[stage.type] ?? (stage.type === 'response' ? 'events' : 'struct');
|
|
248
|
+
if (m === 'events' && !stage.resp?.events) m = 'struct';
|
|
249
|
+
return m;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
setMode(stage, mode) {
|
|
253
|
+
if (stage) this.ctx.prefs.modes[stage.type] = mode;
|
|
254
|
+
this.ctx.savePrefs();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---------- header and pipeline ----------
|
|
258
|
+
|
|
259
|
+
renderHead(d) {
|
|
260
|
+
const th = d.thread ?? {};
|
|
261
|
+
const usage = d.usage ? usageText(d.usage) : '';
|
|
262
|
+
const actions = h('span', { class: 'd-actions' },
|
|
263
|
+
btn('导出此条', () => {
|
|
264
|
+
download(`codeks-bridge-${safeName(d.id)}.json`, JSON.stringify(exportDoc([d]), null, 1));
|
|
265
|
+
}, '', '下载这条记录(可在“导入”中离线查看)'),
|
|
266
|
+
btn('复制 ID', () => copyText(d.id)),
|
|
267
|
+
this.ctx.live() ? btn('刷新', () => this.ctx.refresh(), '', '重新获取这条记录') : null);
|
|
268
|
+
const threadChips = [
|
|
269
|
+
th.thread_id ? h('span', { class: 'chip sm id', title: `thread_id ${th.thread_id}` }, `线程 ${shortId(th.thread_id, 12)}`) : null,
|
|
270
|
+
th.session_id && th.session_id !== th.thread_id ? h('span', { class: 'chip sm id', title: `session_id ${th.session_id}` }, `会话 ${shortId(th.session_id, 12)}`) : null,
|
|
271
|
+
th.parent_thread_id ? h('span', { class: 'chip sm dim', title: `x-codex-parent-thread-id ${th.parent_thread_id}` }, `父线程 ${shortId(th.parent_thread_id, 12)}`) : null,
|
|
272
|
+
th.subagent ? h('span', { class: 'chip sm sub', title: 'x-openai-subagent' }, `子代理 ${th.subagent}`) : null,
|
|
273
|
+
th.window_id ? h('span', { class: 'chip sm dim', title: `x-codex-window-id ${th.window_id}` }, `窗口 ${shortId(th.window_id, 12)}`) : null,
|
|
274
|
+
];
|
|
275
|
+
fill(this.headEl,
|
|
276
|
+
h('div', { class: 'd-line' },
|
|
277
|
+
h('span', { class: 'd-seq' }, `#${d.seq ?? '?'}`),
|
|
278
|
+
h('span', { class: 'mono dim', title: 'exchange id' }, d.id),
|
|
279
|
+
h('span', { class: 'mono' }, fmtDateTime(d.started_at)),
|
|
280
|
+
h('span', { class: `chip kind k-${d.kind}` }, KIND_LABEL[d.kind] ?? d.kind),
|
|
281
|
+
h('span', { class: 'd-model' }, d.model ?? '(无模型)', d.adapter ? ` → ${d.adapter}` : '', d.upstream_model ? h('span', { class: 'dim' }, ` (${d.upstream_model})`) : null),
|
|
282
|
+
h('span', { class: `chip ${OUTCOME_CLASS[d.outcome] ?? 'dim'}` }, `${OUTCOME_LABEL[d.outcome] ?? d.outcome}${d.status != null ? ` · ${d.status}` : ''}`),
|
|
283
|
+
h('span', { class: 'mono' }, d.active ? '进行中…' : fmtDur(d.duration_ms)),
|
|
284
|
+
usage ? h('span', { class: 'mono dim', title: JSON.stringify(d.usage, null, 1) }, usage) : null,
|
|
285
|
+
this.gone ? h('span', { class: 'chip warn', title: '已从 bridge 清空或淘汰;这里显示的是浏览器缓存' }, '已从 bridge 移除') : null,
|
|
286
|
+
h('span', { class: 'grow' }), actions),
|
|
287
|
+
threadChips.some(Boolean) || d.error ? h('div', { class: 'd-line' }, threadChips, d.error ? h('span', { class: 'err' }, d.error) : null) : null);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
renderNav(stages) {
|
|
291
|
+
const sel0 = this.panes[0]?.key;
|
|
292
|
+
const sel1 = this.panes[1]?.key;
|
|
293
|
+
const card = (s, main, sub, cls = '', tip = sub) => {
|
|
294
|
+
const el = h('button', {
|
|
295
|
+
type: 'button', class: `card ${cls}${s.key === sel0 ? ' sel' : ''}${s.key === sel1 ? ' sel2' : ''}`, 'data-key': s.key,
|
|
296
|
+
title: `${tip ? `${tip}\n` : ''}Shift+点击:在右侧并排打开`,
|
|
297
|
+
onclick: (e) => this.open(s.key, { second: e.shiftKey }),
|
|
298
|
+
}, h('span', { class: 'card-main' }, main), sub ? h('span', { class: 'card-sub' }, sub) : null);
|
|
299
|
+
return el;
|
|
300
|
+
};
|
|
301
|
+
const d = this.detail;
|
|
302
|
+
const get = (k) => stages.find((s) => s.key === k);
|
|
303
|
+
const req = get('req');
|
|
304
|
+
const res = get('res');
|
|
305
|
+
const reqSub = [fmtBytes(req.msg?.body?.bytes), d.input_items != null ? `${d.input_items} 项` : null].filter(Boolean).join(' · ');
|
|
306
|
+
const respSub = (r, err) => (err ? '传输错误' : r ? [r.status ?? '–', END_LABEL[r.end] ?? r.end, r.events ? `${fmtInt(r.events.length)} 事件` : fmtBytes(r.body?.bytes)].join(' · ') : '无响应');
|
|
307
|
+
const calls = stages.filter((s) => s.key.startsWith('up:'));
|
|
308
|
+
const callBox = h('div', { class: 'pipe-calls' }, calls.length ? calls.map((s) => {
|
|
309
|
+
const r = get(`upres:${s.key.slice(3)}`);
|
|
310
|
+
return h('div', { class: 'pipe-call' },
|
|
311
|
+
h('span', { class: `call-label l-${s.call.label}` }, s.sub),
|
|
312
|
+
card(s, `② bridge → 上游`, `${s.call.method ?? ''} ${fmtBytes(s.msg?.body?.bytes)}`),
|
|
313
|
+
h('span', { class: 'arrow' }, '→'),
|
|
314
|
+
card(r, `③ 上游 → bridge`, respSub(r.resp, s.call.error), respClass(r.resp, s.call.error)));
|
|
315
|
+
}) : h('span', { class: 'dim' }, '(没有上游调用)'));
|
|
316
|
+
const annCount = d.annotations?.length ?? 0;
|
|
317
|
+
const routeSub = d.route ? `${d.route.alias ?? ''} · ${d.route.compaction_mode ?? ''}` : '无路由';
|
|
318
|
+
fill(this.navEl,
|
|
319
|
+
h('div', { class: 'pipe-side' }, card(get('overview'), '概要', null, 'mini', routeSub), card(get('diff'), '对比', null, 'mini', '与同线程上一轮或任选两条比较')),
|
|
320
|
+
card(req, '① codeks → bridge', reqSub),
|
|
321
|
+
h('span', { class: 'arrow' }, '→'), callBox, h('span', { class: 'arrow' }, '→'),
|
|
322
|
+
card(res, '④ bridge → codeks', respSub(res.resp), respClass(res.resp)),
|
|
323
|
+
h('span', { class: 'grow' }),
|
|
324
|
+
card(get('ann'), `标注 ${annCount}`, annCount ? annSummary(d.annotations) : '无', `narrow${annCount ? ' info' : ''}`),
|
|
325
|
+
this.panes[1] ? btn('✕ 关闭并排', () => this.closeSecond(), 'close2') : null);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------- panes ----------
|
|
329
|
+
|
|
330
|
+
renderPane(p, keepScroll) {
|
|
331
|
+
const body = p.el.querySelector('.pane-body');
|
|
332
|
+
const scroll = keepScroll && body ? body.scrollTop : 0;
|
|
333
|
+
const stage = this.stages.get(p.key);
|
|
334
|
+
let content;
|
|
335
|
+
let head;
|
|
336
|
+
try {
|
|
337
|
+
if (stage.type === 'overview') [head, content] = [this.paneHead(stage, p), this.overview()];
|
|
338
|
+
else if (stage.type === 'ann') [head, content] = [this.paneHead(stage, p), this.annotations()];
|
|
339
|
+
else if (stage.type === 'diff') [head, content] = [this.paneHead(stage, p), this.diffPane()];
|
|
340
|
+
else {
|
|
341
|
+
const mode = this.modeOf(stage);
|
|
342
|
+
head = this.paneHead(stage, p, mode);
|
|
343
|
+
content = this.stageBody(stage, mode);
|
|
344
|
+
}
|
|
345
|
+
} catch (e) {
|
|
346
|
+
console.error(e);
|
|
347
|
+
content = h('div', { class: 'err' }, `渲染失败:${e.message}`);
|
|
348
|
+
}
|
|
349
|
+
const nb = h('div', { class: 'pane-body' }, content);
|
|
350
|
+
p.el.replaceChildren(head ?? h('div'), nb);
|
|
351
|
+
if (scroll) nb.scrollTop = scroll;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
paneHead(stage, p, mode) {
|
|
355
|
+
const title = stage.num ? `${stage.num} ${stage.title} · ${stage.sub}` : stage.title;
|
|
356
|
+
const head = h('div', { class: 'pane-head' }, h('span', { class: 'pane-title' }, title));
|
|
357
|
+
if (mode) {
|
|
358
|
+
const modes = stage.type === 'request' ? REQUEST_MODES : RESPONSE_MODES.filter(([m]) => m !== 'events' || stage.resp?.events);
|
|
359
|
+
put(head, h('span', { class: 'tabs' }, modes.map(([m, label]) => h('button', {
|
|
360
|
+
type: 'button', class: `tab${m === mode ? ' on' : ''}`,
|
|
361
|
+
onclick: () => { this.setMode(stage, m); for (const x of this.panes) if (this.stages.get(x.key)?.type === stage.type) { x.sig = null; this.renderPane(x, false); } },
|
|
362
|
+
}, label))));
|
|
363
|
+
put(head, this.stageInfo(stage));
|
|
364
|
+
}
|
|
365
|
+
if (p === this.panes[1]) put(head, h('span', { class: 'grow' }), btn('✕', () => this.closeSecond(), '', '关闭并排'));
|
|
366
|
+
return head;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
stageInfo(stage) {
|
|
370
|
+
const out = h('span', { class: 'pane-info' });
|
|
371
|
+
const body = stage.type === 'request' ? stage.msg?.body : stage.resp?.body;
|
|
372
|
+
if (stage.type === 'request') {
|
|
373
|
+
const m = stage.msg;
|
|
374
|
+
if (m) put(out, h('span', { class: 'mono', title: m.url ?? m.path }, `${m.method ?? ''} ${oneLine(m.url ?? m.path ?? '', 80)}`));
|
|
375
|
+
if (stage.call?.started_ms != null) put(out, h('span', { class: 'dim' }, `开始 +${fmtDur(stage.call.started_ms)}`));
|
|
376
|
+
if (stage.call?.error) put(out, h('span', { class: 'chip bad' }, `传输错误:${stage.call.error}`));
|
|
377
|
+
} else {
|
|
378
|
+
const r = stage.resp;
|
|
379
|
+
if (!r) put(out, h('span', { class: 'dim' }, stage.call?.error ? `传输错误:${stage.call.error}` : '尚无响应'));
|
|
380
|
+
else {
|
|
381
|
+
put(out, h('span', { class: `chip ${respClass(r)}` }, `${r.status ?? '–'} ${r.status_text ?? ''}`.trim()),
|
|
382
|
+
h('span', { class: `chip ${r.end === 'active' ? 'info' : 'dim'}`, title: `end: ${r.end}` }, END_LABEL[r.end] ?? r.end ?? '–'),
|
|
383
|
+
r.headers_ms != null ? h('span', { class: 'dim', title: '自请求开始到响应头的时间' }, `首包 ${fmtDur(r.headers_ms)}`) : null,
|
|
384
|
+
r.ended_ms != null ? h('span', { class: 'dim', title: '自请求开始到结束的时间' }, `结束 ${fmtDur(r.ended_ms)}`) : null,
|
|
385
|
+
r.events ? h('span', { class: 'dim' }, `${fmtInt(r.events.length)} 事件`) : h('span', { class: 'dim' }, '非 SSE'),
|
|
386
|
+
r.error ? h('span', { class: 'chip bad', title: r.error }, oneLine(`错误:${r.error}`, 80)) : null);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
if (body) {
|
|
390
|
+
put(out, h('span', { class: 'dim', title: `${fmtInt(body.bytes)} 字节` }, fmtBytes(body.bytes)));
|
|
391
|
+
put(out, btn('复制正文', () => copyText(body.text)), btn('下载正文', () => download(this.fileName(stage, stage.resp?.events ? 'sse.txt' : 'json'), body.text, 'text/plain')));
|
|
392
|
+
}
|
|
393
|
+
return out;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
fileName(stage, ext) {
|
|
397
|
+
return `${safeName(this.detail.id)}-${safeName(stage.key.replace(':', '-'))}.${ext}`;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
stageBody(stage, mode) {
|
|
401
|
+
const d = this.detail;
|
|
402
|
+
const isReq = stage.type === 'request';
|
|
403
|
+
const msg = stage.msg;
|
|
404
|
+
const resp = stage.resp;
|
|
405
|
+
const body = isReq ? msg?.body : resp?.body;
|
|
406
|
+
if (!isReq && !resp) {
|
|
407
|
+
return h('div', { class: 'empty small' }, stage.call?.error ? `上游传输错误,没有响应:${stage.call.error}` : d.active ? '等待响应…' : '没有响应');
|
|
408
|
+
}
|
|
409
|
+
if (!isReq && mode === 'headers') {
|
|
410
|
+
return headersTable(resp.headers, [['状态', h('span', { class: 'mono' }, `${resp.status ?? '–'} ${resp.status_text ?? ''}`)]]);
|
|
411
|
+
}
|
|
412
|
+
if (isReq && mode === 'headers') {
|
|
413
|
+
return headersTable(msg?.headers, [['请求', h('span', { class: 'mono' }, `${msg?.method ?? ''} ${msg?.url ?? msg?.path ?? ''}`)]]);
|
|
414
|
+
}
|
|
415
|
+
if (mode === 'raw') return body ? rawView(body.text, this.fileName(stage, 'body'), parseBody(body).ok, resp?.events ? 'sse.txt' : 'json') : h('div', { class: 'dim' }, '没有正文');
|
|
416
|
+
const ann = stage.target === 'client.request' ? annIndex(d.annotations, 'client.request').byPointer
|
|
417
|
+
: stage.target === 'client.response' ? responsePointers(d) : null;
|
|
418
|
+
const chip = (a, onClick) => annChip(a, onClick);
|
|
419
|
+
if (mode === 'events' && resp?.events) {
|
|
420
|
+
const { byEvent } = annIndex(d.annotations, stage.target === 'client.response' ? 'client.response' : '-');
|
|
421
|
+
return eventsView(resp, {
|
|
422
|
+
state: this.viewState(stage.key, 'events', newEventsState), prefs: this.ctx.prefs,
|
|
423
|
+
onPrefs: () => { this.ctx.savePrefs(); for (const p of this.panes) { if (this.modeOf(this.stages.get(p.key) ?? {}) === 'events') this.renderPane(p, true); } },
|
|
424
|
+
annByEvent: byEvent, annChip: chip, annDetail,
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (mode === 'json') {
|
|
428
|
+
const st = this.viewState(stage.key, 'json', () => ({ exp: new Set() }));
|
|
429
|
+
if (resp?.events) {
|
|
430
|
+
const events = parseEvents(resp).map((e) => ({ t: e.t, event: e.event, data: e.json !== undefined ? e.json : e.data }));
|
|
431
|
+
return h('div', null, h('div', { class: 'note' }, `全部 ${fmtInt(events.length)} 个 SSE 事件(data 已解析为 JSON)`), jsonTree(events, { state: st, label: 'events', auto: 1 }));
|
|
432
|
+
}
|
|
433
|
+
const r = parseBody(body);
|
|
434
|
+
if (!r.ok) return h('div', null, h('div', { class: 'note' }, `正文不是合法 JSON:${r.error}`), strBlock(body?.text ?? ''));
|
|
435
|
+
return jsonTree(r.value, { state: st, ann, annChip: chip, label: 'body', auto: 1 });
|
|
436
|
+
}
|
|
437
|
+
// Structure view.
|
|
438
|
+
const st = this.viewState(stage.key, 'struct', newStructState);
|
|
439
|
+
if (isReq) {
|
|
440
|
+
const r = parseBody(body);
|
|
441
|
+
if (!r.ok) return h('div', null, h('div', { class: 'note' }, `正文不是合法 JSON:${r.error}`), strBlock(body?.text ?? ''));
|
|
442
|
+
return structureView(r.value, { state: st, ann, annChip: chip });
|
|
443
|
+
}
|
|
444
|
+
const fo = finalObject(resp);
|
|
445
|
+
return structureView(fo.value, { state: st, ann, annChip: chip, note: fo.note });
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------- overview ----------
|
|
449
|
+
|
|
450
|
+
overview() {
|
|
451
|
+
const d = this.detail;
|
|
452
|
+
const box = h('div', { class: 'overview' });
|
|
453
|
+
put(box, h('h4', null, '路由'), d.route ? fieldsTable(d.route, ROUTE_HINTS) : h('div', { class: 'dim' }, '没有路由信息(请求无法解析或路由失败)'));
|
|
454
|
+
put(box, h('h4', null, '时间线'), this.timeline());
|
|
455
|
+
const keys = callKeys(d.upstream);
|
|
456
|
+
if (d.upstream?.length) {
|
|
457
|
+
put(box, h('h4', null, `上游调用(${d.upstream.length})`), h('table', { class: 'calls' },
|
|
458
|
+
h('thead', null, h('tr', null, ['标签', '请求', '状态', '结束', '开始', '首包', '结束于', '请求大小', '响应大小', '错误'].map((x) => h('th', null, x)))),
|
|
459
|
+
h('tbody', null, d.upstream.map((c, i) => {
|
|
460
|
+
const r = c.response;
|
|
461
|
+
const tr = h('tr', { class: 'clickable', title: '打开该调用的上游响应', onclick: () => this.open(`upres:${keys[i]}`) },
|
|
462
|
+
h('td', null, h('span', { class: `call-label l-${c.label}` }, callLabel(c.label))),
|
|
463
|
+
h('td', { class: 'mono' }, `${c.method ?? ''} ${oneLine(c.url ?? '', 70)}`),
|
|
464
|
+
h('td', null, h('span', { class: `chip ${respClass(r, c.error)}` }, r?.status ?? '–')),
|
|
465
|
+
h('td', null, END_LABEL[r?.end] ?? r?.end ?? '–'),
|
|
466
|
+
h('td', { class: 'mono' }, c.started_ms != null ? `+${fmtDur(c.started_ms)}` : '–'),
|
|
467
|
+
h('td', { class: 'mono' }, r?.headers_ms != null ? fmtDur(r.headers_ms) : '–'),
|
|
468
|
+
h('td', { class: 'mono' }, r?.ended_ms != null ? fmtDur(r.ended_ms) : '–'),
|
|
469
|
+
h('td', { class: 'mono' }, fmtBytes(c.body?.bytes)),
|
|
470
|
+
h('td', { class: 'mono' }, fmtBytes(r?.body?.bytes)),
|
|
471
|
+
h('td', { class: 'err' }, c.error ?? r?.error ?? ''));
|
|
472
|
+
return tr;
|
|
473
|
+
}))));
|
|
474
|
+
}
|
|
475
|
+
if (d.annotations?.length) {
|
|
476
|
+
put(box, h('h4', null, '标注'), h('div', null, h('span', { class: 'chip info' }, annSummary(d.annotations)), ' ', btn('查看全部标注', () => this.open('ann'))));
|
|
477
|
+
}
|
|
478
|
+
put(box, h('h4', null, '记录字段'), fieldsTable(d, SUMMARY_HINTS, ['route', 'client', 'upstream', 'annotations']));
|
|
479
|
+
return box;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
timeline() {
|
|
483
|
+
const d = this.detail;
|
|
484
|
+
const rows = [];
|
|
485
|
+
const keys = callKeys(d.upstream);
|
|
486
|
+
const cr = d.client?.response;
|
|
487
|
+
(d.upstream ?? []).forEach((c, i) => rows.push({ label: callLabel(c.label), start: c.started_ms ?? 0, head: c.response?.headers_ms, end: c.response?.ended_ms, cls: respClass(c.response, c.error), key: `upres:${keys[i]}` }));
|
|
488
|
+
rows.push({ label: 'bridge → codeks', start: 0, head: cr?.headers_ms, end: cr?.ended_ms, cls: respClass(cr), key: 'res' });
|
|
489
|
+
const now = d.active ? Date.now() - d.started_at : 0;
|
|
490
|
+
const total = Math.max(1, d.duration_ms ?? 0, now, ...rows.map((r) => r.end ?? r.head ?? r.start));
|
|
491
|
+
const pct = (v) => `${Math.max(0, Math.min(100, (v / total) * 100)).toFixed(2)}%`;
|
|
492
|
+
return h('div', { class: 'timeline' }, rows.map((r) => {
|
|
493
|
+
const end = r.end ?? (d.active ? total : r.head ?? r.start);
|
|
494
|
+
const bar = h('span', { class: `tl-bar ${r.cls}` });
|
|
495
|
+
bar.style.left = pct(r.start);
|
|
496
|
+
bar.style.width = pct(Math.max(end - r.start, total / 400));
|
|
497
|
+
const mark = r.head != null ? h('span', { class: 'tl-head', title: `首包 ${fmtDur(r.head)}` }) : null;
|
|
498
|
+
if (mark) mark.style.left = pct(r.head);
|
|
499
|
+
return h('div', { class: 'tl-row clickable', onclick: () => this.open(r.key) },
|
|
500
|
+
h('span', { class: 'tl-label' }, r.label),
|
|
501
|
+
h('span', { class: 'tl-track' }, bar, mark),
|
|
502
|
+
h('span', { class: 'tl-num mono' }, `${fmtDur(r.start)} → ${r.head != null ? fmtDur(r.head) : '–'} → ${r.end != null ? fmtDur(r.end) : d.active ? '…' : '–'}`));
|
|
503
|
+
}), h('div', { class: 'dim tl-axis' }, `0 → ${fmtDur(total)}(开始 → 首包 → 结束,相对请求开始)`));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ---------- annotations ----------
|
|
507
|
+
|
|
508
|
+
annotations() {
|
|
509
|
+
const d = this.detail;
|
|
510
|
+
const list = d.annotations ?? [];
|
|
511
|
+
if (!list.length) return h('div', { class: 'empty small' }, '这条记录没有标注(没有 bridge 载荷的 reasoning 或压缩项)');
|
|
512
|
+
const tbody = h('tbody');
|
|
513
|
+
list.forEach((a, i) => {
|
|
514
|
+
const where = `${a.target === 'client.request' ? '① 请求' : '④ 响应'}${a.event != null ? ` 事件 #${a.event}` : ''} ${a.pointer ?? ''}`;
|
|
515
|
+
const tr = h('tr', { class: 'clickable' },
|
|
516
|
+
h('td', { class: 'mono dim' }, String(i)),
|
|
517
|
+
h('td', { class: 'mono' }, where),
|
|
518
|
+
h('td', null, KIND_TEXT[a.kind] ?? a.kind),
|
|
519
|
+
h('td', null, h('span', { class: `chip ${decisionClass(a.decision)}` }, decisionText(a.decision))),
|
|
520
|
+
h('td', null, reasonText(a), h('span', { class: 'dim mono' }, ` ${a.reason}`)),
|
|
521
|
+
h('td', null, sourceText(a.source) || '–'),
|
|
522
|
+
h('td', { class: 'mono dim' }, a.format ?? '–'),
|
|
523
|
+
h('td', null, btn('定位', () => this.locate(a), '', '在结构/事件视图中定位这一项')));
|
|
524
|
+
const detailRow = h('tr', { class: 'field-detail' }, h('td', { colSpan: 8 }));
|
|
525
|
+
tr.addEventListener('click', (e) => {
|
|
526
|
+
if (e.target.closest('button') || String(window.getSelection?.() ?? '')) return;
|
|
527
|
+
if (detailRow.isConnected) detailRow.remove();
|
|
528
|
+
else { if (!detailRow.firstChild.firstChild) put(detailRow.firstChild, annDetail(a)); tr.after(detailRow); }
|
|
529
|
+
});
|
|
530
|
+
put(tbody, tr);
|
|
531
|
+
});
|
|
532
|
+
return h('div', null,
|
|
533
|
+
h('div', { class: 'note' }, `bridge 按真实回放规则计算:${annSummary(list)}。点击行查看解码后的载荷,“定位”跳到对应项。`),
|
|
534
|
+
h('table', { class: 'anns' }, h('thead', null, h('tr', null, ['#', '位置', '类型', '决定', '原因', '来源', '格式', ''].map((x) => h('th', null, x)))), tbody));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** Opens the item or event an annotation points at, expanded and highlighted. */
|
|
538
|
+
locate(a) {
|
|
539
|
+
const isReq = a.target === 'client.request';
|
|
540
|
+
const key = isReq ? 'req' : 'res';
|
|
541
|
+
const stage = this.stages.get(key);
|
|
542
|
+
const mode = !isReq && a.event != null && stage.resp?.events ? 'events' : 'struct';
|
|
543
|
+
let selector;
|
|
544
|
+
if (mode === 'events') {
|
|
545
|
+
const st = this.viewState(key, 'events', newEventsState);
|
|
546
|
+
st.open.add(`e${a.event}`);
|
|
547
|
+
st.type = '';
|
|
548
|
+
st.q = '';
|
|
549
|
+
st.shown = Infinity;
|
|
550
|
+
selector = `[data-ev="${a.event}"]`;
|
|
551
|
+
} else {
|
|
552
|
+
const st = this.viewState(key, 'struct', newStructState);
|
|
553
|
+
st.filters.clear();
|
|
554
|
+
let pointer = a.pointer;
|
|
555
|
+
if (!isReq && a.event != null) {
|
|
556
|
+
const oi = parseEvents(stage.resp)?.[a.event]?.json?.output_index;
|
|
557
|
+
pointer = oi != null ? `/output/${oi}` : null;
|
|
558
|
+
}
|
|
559
|
+
const row = focusPointer(st, pointer);
|
|
560
|
+
selector = row ? `[data-ptr="${CSS.escape(row)}"]` : null;
|
|
561
|
+
}
|
|
562
|
+
this.open(key, { mode });
|
|
563
|
+
const pane = this.panes[0];
|
|
564
|
+
requestAnimationFrame(() => {
|
|
565
|
+
const el = selector && pane.el.querySelector(selector);
|
|
566
|
+
if (!el) { toast('没有找到对应的项'); return; }
|
|
567
|
+
el.scrollIntoView({ block: 'center' });
|
|
568
|
+
el.classList.add('flash');
|
|
569
|
+
setTimeout(() => el.classList.remove('flash'), 1800);
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------- diff ----------
|
|
574
|
+
|
|
575
|
+
/** Previous exchange in the same thread, optionally one with an upstream call of that label. */
|
|
576
|
+
previous(d, label) {
|
|
577
|
+
const key = threadKey(d);
|
|
578
|
+
if (!key) return null;
|
|
579
|
+
return [...this.ctx.summaries().values()].filter((s) => s.id !== d.id && threadKey(s) === key && byNewest(d, s) < 0 &&
|
|
580
|
+
(!label || (s.upstream_calls ?? []).some((c) => c.label === label))).sort(byNewest)[0] ?? null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
diffPane() {
|
|
584
|
+
const d = this.detail;
|
|
585
|
+
let sel = this.diffSel.get(d.id);
|
|
586
|
+
if (!sel) {
|
|
587
|
+
const prev = this.previous(d);
|
|
588
|
+
sel = { a: { id: prev?.id ?? null, stage: 'req' }, b: { id: d.id, stage: 'req' } };
|
|
589
|
+
this.diffSel.set(d.id, sel);
|
|
590
|
+
}
|
|
591
|
+
const out = h('div', { class: 'diff-result' });
|
|
592
|
+
const run = () => this.runDiff(sel, out);
|
|
593
|
+
const side = (name, s) => {
|
|
594
|
+
const exSel = h('select', { class: 'sel ex-sel' });
|
|
595
|
+
const stSel = h('select', { class: 'sel' });
|
|
596
|
+
const fillStages = () => {
|
|
597
|
+
const sum = this.ctx.summary(s.id);
|
|
598
|
+
const opts = stageOptions(sum);
|
|
599
|
+
if (!opts.some(([k]) => k === s.stage)) s.stage = opts[0]?.[0] ?? 'req';
|
|
600
|
+
stSel.replaceChildren(...opts.map(([k, label]) => h('option', { value: k, selected: k === s.stage }, label)));
|
|
601
|
+
};
|
|
602
|
+
const all = [...this.ctx.summaries().values()].sort(byNewest);
|
|
603
|
+
const key = threadKey(d);
|
|
604
|
+
const mk = (x) => h('option', { value: x.id, selected: x.id === s.id }, `#${x.seq ?? '?'} ${fmtTime(x.started_at)} ${x.model ?? '?'} ${KIND_LABEL[x.kind] ?? ''}${x.id === d.id ? '(当前)' : ''}`);
|
|
605
|
+
const same = all.filter((x) => key && threadKey(x) === key);
|
|
606
|
+
const other = all.filter((x) => !key || threadKey(x) !== key);
|
|
607
|
+
put(exSel, h('option', { value: '', selected: !s.id }, '(选择记录)'),
|
|
608
|
+
same.length ? h('optgroup', { label: '同一线程' }, same.map(mk)) : null,
|
|
609
|
+
other.length ? h('optgroup', { label: key ? '其他线程' : '全部' }, other.map(mk)) : null);
|
|
610
|
+
exSel.addEventListener('change', () => { s.id = exSel.value || null; fillStages(); run(); });
|
|
611
|
+
stSel.addEventListener('change', () => { s.stage = stSel.value; run(); });
|
|
612
|
+
fillStages();
|
|
613
|
+
return h('span', { class: 'diff-side' }, h('b', null, name), exSel, stSel);
|
|
614
|
+
};
|
|
615
|
+
const preset = (label, stage, callLbl) => btn(label, () => {
|
|
616
|
+
const prev = this.previous(d, callLbl);
|
|
617
|
+
if (!prev) { toast(callLbl ? `同一线程中没有更早的带“${callLabel(callLbl)}”的记录` : '同一线程中没有更早的记录'); return; }
|
|
618
|
+
sel.a = { id: prev.id, stage };
|
|
619
|
+
sel.b = { id: d.id, stage };
|
|
620
|
+
this.renderPane(this.panes.find((p) => p.key === 'diff') ?? this.panes[0], false);
|
|
621
|
+
});
|
|
622
|
+
const labels = [...new Set((d.upstream ?? []).map((c) => c.label))];
|
|
623
|
+
const tools = h('div', { class: 'diff-tools' },
|
|
624
|
+
side('A', sel.a), btn('⇄', () => { [sel.a, sel.b] = [sel.b, sel.a]; this.renderPane(this.panes.find((p) => p.key === 'diff') ?? this.panes[0], false); }, '', '交换 A 与 B'), side('B', sel.b));
|
|
625
|
+
const presets = h('div', { class: 'diff-tools' }, h('span', { class: 'dim' }, '快捷:'),
|
|
626
|
+
preset('上一轮 · 客户端请求', 'req'),
|
|
627
|
+
labels.map((l) => preset(`上一轮 · 上游请求·${callLabel(l)}`, `up:${l}`, l)),
|
|
628
|
+
preset('上一轮 · 客户端响应', 'res'));
|
|
629
|
+
queueMicrotask(run);
|
|
630
|
+
return h('div', { class: 'diffpane' }, tools, presets, out);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
async runDiff(sel, out) {
|
|
634
|
+
const token = (this.diffToken = (this.diffToken ?? 0) + 1);
|
|
635
|
+
if (!sel.a.id || !sel.b.id) {
|
|
636
|
+
out.replaceChildren(h('div', { class: 'empty small' }, sel.a.id || sel.b.id ? '请选择 A 与 B 两侧的记录' : '同一线程中没有更早的记录;请在上方手动选择 A。'));
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
out.replaceChildren(h('div', { class: 'dim' }, '加载并比较中…'));
|
|
640
|
+
try {
|
|
641
|
+
const [a, b] = await Promise.all([this.diffValue(sel.a), this.diffValue(sel.b)]);
|
|
642
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
643
|
+
if (token !== this.diffToken) return;
|
|
644
|
+
out.replaceChildren(h('div', { class: 'diff-legend dim' }, `A = ${a.label} B = ${b.label}`), diffView(a.value, b.value, { label: 'body' }));
|
|
645
|
+
} catch (e) {
|
|
646
|
+
if (token === this.diffToken) out.replaceChildren(h('div', { class: 'err' }, `无法比较:${e.message}`));
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async diffValue(s) {
|
|
651
|
+
const det = await this.ctx.getDetail(s.id);
|
|
652
|
+
const stage = stagesOf(det).find((x) => x.key === s.stage);
|
|
653
|
+
if (!stage) throw new Error(`记录 ${s.id} 没有阶段 ${s.stage}`);
|
|
654
|
+
const label = `#${det.seq} ${fmtTime(det.started_at)} ${det.model ?? ''} · ${stage.num} ${stage.sub}`;
|
|
655
|
+
if (stage.type === 'request') {
|
|
656
|
+
const r = parseBody(stage.msg?.body);
|
|
657
|
+
return { label, value: r.ok ? r.value : stage.msg?.body?.text ?? null };
|
|
658
|
+
}
|
|
659
|
+
return { label, value: finalObject(stage.resp)?.value ?? null };
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function stageOptions(sum) {
|
|
664
|
+
if (!sum) return [['req', '① 客户端请求'], ['res', '④ 客户端响应(最终对象)']];
|
|
665
|
+
const keys = callKeys(sum.upstream_calls ?? sum.upstream);
|
|
666
|
+
const out = [['req', '① 客户端请求']];
|
|
667
|
+
keys.forEach((k, i) => {
|
|
668
|
+
const l = callLabel((sum.upstream_calls ?? sum.upstream)[i].label);
|
|
669
|
+
out.push([`up:${k}`, `② 上游请求 · ${l}`], [`upres:${k}`, `③ 上游响应 · ${l}(最终对象)`]);
|
|
670
|
+
});
|
|
671
|
+
out.push(['res', '④ 客户端响应(最终对象)']);
|
|
672
|
+
return out;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function annSummary(list) {
|
|
676
|
+
const counts = new Map();
|
|
677
|
+
for (const a of list) {
|
|
678
|
+
const k = `${KIND_TEXT[a.kind] ?? a.kind} ${decisionText(a.decision)}`;
|
|
679
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
680
|
+
}
|
|
681
|
+
return [...counts].map(([k, n]) => `${k} ${n}`).join(' · ');
|
|
682
|
+
}
|