botmux 2.85.1 → 2.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/command-handler.d.ts.map +1 -1
- package/dist/core/command-handler.js +209 -1
- package/dist/core/command-handler.js.map +1 -1
- package/dist/core/cost-calculator.d.ts.map +1 -1
- package/dist/core/cost-calculator.js +7 -106
- package/dist/core/cost-calculator.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +240 -2
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/passthrough-commands.d.ts.map +1 -1
- package/dist/core/passthrough-commands.js +1 -1
- package/dist/core/passthrough-commands.js.map +1 -1
- package/dist/core/role-resolver.d.ts +1 -0
- package/dist/core/role-resolver.d.ts.map +1 -1
- package/dist/core/role-resolver.js +14 -0
- package/dist/core/role-resolver.js.map +1 -1
- package/dist/dashboard/web/app.d.ts.map +1 -1
- package/dist/dashboard/web/app.js +15 -4
- package/dist/dashboard/web/app.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js +116 -0
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/groups.d.ts +2 -0
- package/dist/dashboard/web/groups.d.ts.map +1 -1
- package/dist/dashboard/web/groups.js +419 -3
- package/dist/dashboard/web/groups.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +617 -3
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/insights.d.ts +2 -0
- package/dist/dashboard/web/insights.d.ts.map +1 -0
- package/dist/dashboard/web/insights.js +1523 -0
- package/dist/dashboard/web/insights.js.map +1 -0
- package/dist/dashboard/web/role-profile-match.d.ts +31 -0
- package/dist/dashboard/web/role-profile-match.d.ts.map +1 -0
- package/dist/dashboard/web/role-profile-match.js +58 -0
- package/dist/dashboard/web/role-profile-match.js.map +1 -0
- package/dist/dashboard/web/roles.d.ts +1 -0
- package/dist/dashboard/web/roles.d.ts.map +1 -1
- package/dist/dashboard/web/roles.js +520 -27
- package/dist/dashboard/web/roles.js.map +1 -1
- package/dist/dashboard/web/sessions.d.ts.map +1 -1
- package/dist/dashboard/web/sessions.js +84 -0
- package/dist/dashboard/web/sessions.js.map +1 -1
- package/dist/dashboard-web/app.js +1243 -831
- package/dist/dashboard-web/index.html +2 -1
- package/dist/dashboard-web/style.css +1085 -3
- package/dist/dashboard.js +215 -3
- package/dist/dashboard.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +34 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +34 -1
- package/dist/i18n/zh.js.map +1 -1
- package/dist/services/group-creator.d.ts +6 -0
- package/dist/services/group-creator.d.ts.map +1 -1
- package/dist/services/group-creator.js +54 -5
- package/dist/services/group-creator.js.map +1 -1
- package/dist/services/insight/antigravity-span-reader.d.ts +3 -0
- package/dist/services/insight/antigravity-span-reader.d.ts.map +1 -0
- package/dist/services/insight/antigravity-span-reader.js +249 -0
- package/dist/services/insight/antigravity-span-reader.js.map +1 -0
- package/dist/services/insight/classify.d.ts +7 -0
- package/dist/services/insight/classify.d.ts.map +1 -0
- package/dist/services/insight/classify.js +46 -0
- package/dist/services/insight/classify.js.map +1 -0
- package/dist/services/insight/claude-span-reader.d.ts +3 -0
- package/dist/services/insight/claude-span-reader.d.ts.map +1 -0
- package/dist/services/insight/claude-span-reader.js +257 -0
- package/dist/services/insight/claude-span-reader.js.map +1 -0
- package/dist/services/insight/codex-span-reader.d.ts +3 -0
- package/dist/services/insight/codex-span-reader.d.ts.map +1 -0
- package/dist/services/insight/codex-span-reader.js +290 -0
- package/dist/services/insight/codex-span-reader.js.map +1 -0
- package/dist/services/insight/intent.d.ts +5 -0
- package/dist/services/insight/intent.d.ts.map +1 -0
- package/dist/services/insight/intent.js +145 -0
- package/dist/services/insight/intent.js.map +1 -0
- package/dist/services/insight/jsonl.d.ts +10 -0
- package/dist/services/insight/jsonl.d.ts.map +1 -0
- package/dist/services/insight/jsonl.js +36 -0
- package/dist/services/insight/jsonl.js.map +1 -0
- package/dist/services/insight/prompt.d.ts +3 -0
- package/dist/services/insight/prompt.d.ts.map +1 -0
- package/dist/services/insight/prompt.js +99 -0
- package/dist/services/insight/prompt.js.map +1 -0
- package/dist/services/insight/redact.d.ts +4 -0
- package/dist/services/insight/redact.d.ts.map +1 -0
- package/dist/services/insight/redact.js +67 -0
- package/dist/services/insight/redact.js.map +1 -0
- package/dist/services/insight/report.d.ts +29 -0
- package/dist/services/insight/report.d.ts.map +1 -0
- package/dist/services/insight/report.js +1126 -0
- package/dist/services/insight/report.js.map +1 -0
- package/dist/services/insight/safe-detail.d.ts +5 -0
- package/dist/services/insight/safe-detail.d.ts.map +1 -0
- package/dist/services/insight/safe-detail.js +59 -0
- package/dist/services/insight/safe-detail.js.map +1 -0
- package/dist/services/insight/scrub.d.ts +22 -0
- package/dist/services/insight/scrub.d.ts.map +1 -0
- package/dist/services/insight/scrub.js +70 -0
- package/dist/services/insight/scrub.js.map +1 -0
- package/dist/services/insight/types.d.ts +394 -0
- package/dist/services/insight/types.d.ts.map +1 -0
- package/dist/services/insight/types.js +2 -0
- package/dist/services/insight/types.js.map +1 -0
- package/dist/services/role-profile-store.d.ts +25 -0
- package/dist/services/role-profile-store.d.ts.map +1 -0
- package/dist/services/role-profile-store.js +171 -0
- package/dist/services/role-profile-store.js.map +1 -0
- package/dist/services/transcript-resolver.d.ts +26 -0
- package/dist/services/transcript-resolver.d.ts.map +1 -0
- package/dist/services/transcript-resolver.js +111 -0
- package/dist/services/transcript-resolver.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1523 @@
|
|
|
1
|
+
import { botDisplayName, escapeHtml, loadNameMaps, relTime, stripMentionPrefix, t } from './ui.js';
|
|
2
|
+
import MarkdownIt from 'markdown-it';
|
|
3
|
+
const SEVERITY_RANK = { bad: 0, warn: 1, info: 2 };
|
|
4
|
+
function fmtInt(n) {
|
|
5
|
+
return Number.isFinite(n) ? Math.round(n).toLocaleString('en-US') : '-';
|
|
6
|
+
}
|
|
7
|
+
function fmtMs(ms) {
|
|
8
|
+
if (ms === undefined || ms === null || !Number.isFinite(ms))
|
|
9
|
+
return '-';
|
|
10
|
+
if (ms < 1000)
|
|
11
|
+
return `${Math.round(ms)}ms`;
|
|
12
|
+
if (ms < 60_000)
|
|
13
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
14
|
+
return `${Math.round(ms / 60_000)}m`;
|
|
15
|
+
}
|
|
16
|
+
function statusIcon(status) {
|
|
17
|
+
if (status === 'error')
|
|
18
|
+
return '!';
|
|
19
|
+
if (status === 'running')
|
|
20
|
+
return '~';
|
|
21
|
+
return 'OK';
|
|
22
|
+
}
|
|
23
|
+
function safeStatus(report, error) {
|
|
24
|
+
if (error)
|
|
25
|
+
return error;
|
|
26
|
+
if (!report)
|
|
27
|
+
return '-';
|
|
28
|
+
if (report.status === 'unsupported_cli')
|
|
29
|
+
return t('insights.unsupported');
|
|
30
|
+
if (report.status === 'transcript_missing')
|
|
31
|
+
return t('insights.noTranscript');
|
|
32
|
+
if (report.status === 'parse_error')
|
|
33
|
+
return t('insights.parseError');
|
|
34
|
+
return report.status;
|
|
35
|
+
}
|
|
36
|
+
function sessionTitle(s) {
|
|
37
|
+
return stripMentionPrefix(s.title ?? s.firstUserMessage ?? s.rootMessageId ?? s.sessionId);
|
|
38
|
+
}
|
|
39
|
+
function severityLabel(sev) {
|
|
40
|
+
return sev === 'bad' ? t('insights.sevBad') : sev === 'warn' ? t('insights.sevWarn') : t('insights.sevInfo');
|
|
41
|
+
}
|
|
42
|
+
function translatedOrFallback(key, fallback) {
|
|
43
|
+
const out = t(key);
|
|
44
|
+
return out === key ? fallback : out;
|
|
45
|
+
}
|
|
46
|
+
function suggestionTitle(s) {
|
|
47
|
+
return translatedOrFallback(`insights.suggestion.${s.id}.title`, s.title);
|
|
48
|
+
}
|
|
49
|
+
function suggestionAction(s) {
|
|
50
|
+
return translatedOrFallback(`insights.suggestion.${s.id}.action`, s.action);
|
|
51
|
+
}
|
|
52
|
+
function localizeEvidence(text) {
|
|
53
|
+
let m = text.match(/^(\d+) failed spans$/);
|
|
54
|
+
if (m)
|
|
55
|
+
return t('insights.evidence.failedSpans', { count: m[1] });
|
|
56
|
+
m = text.match(/^(.+) failed (\d+) times$/);
|
|
57
|
+
if (m)
|
|
58
|
+
return t('insights.evidence.toolFailedTimes', { tool: m[1], count: m[2] });
|
|
59
|
+
if (text === 'multiple tools failed')
|
|
60
|
+
return t('insights.evidence.multipleToolsFailed');
|
|
61
|
+
m = text.match(/^(.+) ran for (\d+)s$/);
|
|
62
|
+
if (m)
|
|
63
|
+
return t('insights.evidence.toolRanSeconds', { tool: m[1], seconds: m[2] });
|
|
64
|
+
m = text.match(/^read\/write ratio ([\d.]+)$/);
|
|
65
|
+
if (m)
|
|
66
|
+
return t('insights.evidence.readWriteRatio', { ratio: m[1] });
|
|
67
|
+
m = text.match(/^compactions (\d+)$/);
|
|
68
|
+
if (m)
|
|
69
|
+
return t('insights.evidence.compactions', { count: m[1] });
|
|
70
|
+
m = text.match(/^(\d+) spans analyzed$/);
|
|
71
|
+
if (m)
|
|
72
|
+
return t('insights.evidence.spansAnalyzed', { count: m[1] });
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
75
|
+
// Diagnostic reason is backend free-text (safe-projected: numbers/tools/enums). Reuse the
|
|
76
|
+
// evidence localizer on each ';'-separated clause so the zh UI doesn't surface English here;
|
|
77
|
+
// unmatched clauses fall through unchanged.
|
|
78
|
+
function localizeReason(reason) {
|
|
79
|
+
return reason
|
|
80
|
+
.split(/\s*[;;]\s*/)
|
|
81
|
+
.map(c => c.trim().replace(/[.。]\s*$/, ''))
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.map(localizeEvidence)
|
|
84
|
+
.join(';');
|
|
85
|
+
}
|
|
86
|
+
function phaseLabel(phase) {
|
|
87
|
+
const key = `insights.phase.${phase}`;
|
|
88
|
+
const out = t(key);
|
|
89
|
+
return out === key ? phase : out;
|
|
90
|
+
}
|
|
91
|
+
function phaseSlug(phase) {
|
|
92
|
+
return String(phase || 'unknown').replace(/[^a-z0-9_-]/gi, '-');
|
|
93
|
+
}
|
|
94
|
+
function phaseClass(phase) {
|
|
95
|
+
return `phase-${phaseSlug(phase)}`;
|
|
96
|
+
}
|
|
97
|
+
function reportNeedsReview(report) {
|
|
98
|
+
if (!report || report.status !== 'ok')
|
|
99
|
+
return false;
|
|
100
|
+
return report.agg.failedSpans > 0 || report.agg.slowSpans > 0 || report.suggestions.some(x => x.severity !== 'info');
|
|
101
|
+
}
|
|
102
|
+
// Project a server overview-session row into the list's record shape so the
|
|
103
|
+
// existing filter/sort/render helpers keep working unchanged.
|
|
104
|
+
function toRecord(s) {
|
|
105
|
+
return {
|
|
106
|
+
session: {
|
|
107
|
+
sessionId: s.sessionId,
|
|
108
|
+
cliId: s.cliId,
|
|
109
|
+
cliSessionId: s.cliSessionId,
|
|
110
|
+
title: s.title,
|
|
111
|
+
botName: s.botName,
|
|
112
|
+
larkAppId: s.larkAppId,
|
|
113
|
+
workingDir: s.workingDir,
|
|
114
|
+
status: s.status,
|
|
115
|
+
lastMessageAt: s.lastMessageAt,
|
|
116
|
+
},
|
|
117
|
+
report: s.report,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
function filterRecords(records, filter, q, cliSel = new Set()) {
|
|
121
|
+
const query = q.trim().toLowerCase();
|
|
122
|
+
return records.filter(rec => {
|
|
123
|
+
const s = rec.session;
|
|
124
|
+
const r = rec.report;
|
|
125
|
+
if (cliSel.size && !cliSel.has(cliIdOf(rec)))
|
|
126
|
+
return false;
|
|
127
|
+
if (filter === 'review' && !reportNeedsReview(r))
|
|
128
|
+
return false;
|
|
129
|
+
if (filter === 'failed' && !(r?.status === 'ok' && r.agg.failedSpans > 0))
|
|
130
|
+
return false;
|
|
131
|
+
if (filter === 'slow' && !(r?.status === 'ok' && r.agg.slowSpans > 0))
|
|
132
|
+
return false;
|
|
133
|
+
if (!query)
|
|
134
|
+
return true;
|
|
135
|
+
return `${sessionTitle(s)} ${botDisplayName(s)} ${s.cliId ?? ''} ${s.workingDir ?? ''} ${s.sessionId ?? ''}`.toLowerCase().includes(query);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
function cliIdOf(rec) {
|
|
139
|
+
return String(rec.session.cliId ?? 'unknown');
|
|
140
|
+
}
|
|
141
|
+
// Session-dimension CLI facet. Supported CLIs lead in a fixed order so the chip
|
|
142
|
+
// row doesn't reshuffle as sessions come and go; unknown CLIs trail alphabetically.
|
|
143
|
+
const CLI_FILTER_ORDER = ['claude-code', 'seed', 'relay', 'aiden', 'codex', 'traex', 'antigravity'];
|
|
144
|
+
function cliCounts(records) {
|
|
145
|
+
const m = new Map();
|
|
146
|
+
for (const rec of records) {
|
|
147
|
+
const id = cliIdOf(rec);
|
|
148
|
+
m.set(id, (m.get(id) ?? 0) + 1);
|
|
149
|
+
}
|
|
150
|
+
return [...m.entries()].map(([id, count]) => ({ id, count })).sort((a, b) => {
|
|
151
|
+
const ai = CLI_FILTER_ORDER.indexOf(a.id);
|
|
152
|
+
const bi = CLI_FILTER_ORDER.indexOf(b.id);
|
|
153
|
+
return (ai < 0 ? 99 : ai) - (bi < 0 ? 99 : bi) || a.id.localeCompare(b.id);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
// CLI facet chip row for the session list. Multi-select: an empty selection means
|
|
157
|
+
// "all". Counts reflect the severity+search filtered set (the CLI picks are NOT
|
|
158
|
+
// applied), so the per-CLI distribution stays visible while you drill in. Hidden
|
|
159
|
+
// when only one CLI is present.
|
|
160
|
+
function renderCliChips(records, active) {
|
|
161
|
+
const counts = cliCounts(records);
|
|
162
|
+
if (counts.length <= 1)
|
|
163
|
+
return '';
|
|
164
|
+
const chip = (key, label, n, on) => `<button type="button" class="spanchip${on ? ' on' : ''}" data-clifilter="${escapeHtml(key)}">${escapeHtml(label)} <b>${n}</b></button>`;
|
|
165
|
+
return [chip('all', t('common.all'), records.length, active.size === 0),
|
|
166
|
+
...counts.map(c => chip(c.id, c.id, c.count, active.has(c.id)))].join('');
|
|
167
|
+
}
|
|
168
|
+
function sortRecords(records) {
|
|
169
|
+
return [...records].sort((a, b) => {
|
|
170
|
+
const ar = a.report;
|
|
171
|
+
const br = b.report;
|
|
172
|
+
const aScore = (ar?.status === 'ok' ? ar.agg.failedSpans * 6 + ar.agg.slowSpans * 3 + ar.suggestions.filter(s => s.severity === 'bad').length * 5 : 0);
|
|
173
|
+
const bScore = (br?.status === 'ok' ? br.agg.failedSpans * 6 + br.agg.slowSpans * 3 + br.suggestions.filter(s => s.severity === 'bad').length * 5 : 0);
|
|
174
|
+
return bScore - aScore || Number(b.session.lastMessageAt ?? 0) - Number(a.session.lastMessageAt ?? 0);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
function renderMetric(label, value, sub = '') {
|
|
178
|
+
return `<div class="card"><div class="cv">${escapeHtml(value)}</div><div class="cl">${escapeHtml(label)}</div>${sub ? `<div class="cs">${escapeHtml(sub)}</div>` : ''}</div>`;
|
|
179
|
+
}
|
|
180
|
+
const INSIGHT_PHASES = ['research', 'edit', 'run', 'delegate', 'discuss'];
|
|
181
|
+
// The overview reflects the CURRENT filter/search: aggregate the visible records
|
|
182
|
+
// client-side so the metric cards, top-failed-tools and recommendations all move
|
|
183
|
+
// when you filter. Otherwise only the session list narrows while the prominent
|
|
184
|
+
// numbers stay frozen, and filtering feels dead (the issue 老滕 hit). Non-ok
|
|
185
|
+
// reports contribute nothing, so the unfiltered view ≈ the server aggregate.
|
|
186
|
+
function aggregateRecords(records) {
|
|
187
|
+
const agg = {
|
|
188
|
+
totalSpans: 0,
|
|
189
|
+
failedSpans: 0,
|
|
190
|
+
slowSpans: 0,
|
|
191
|
+
failByTool: {},
|
|
192
|
+
phase: {
|
|
193
|
+
research: { count: 0, ms: 0 },
|
|
194
|
+
edit: { count: 0, ms: 0 },
|
|
195
|
+
run: { count: 0, ms: 0 },
|
|
196
|
+
delegate: { count: 0, ms: 0 },
|
|
197
|
+
discuss: { count: 0, ms: 0 },
|
|
198
|
+
},
|
|
199
|
+
readWriteRatio: null,
|
|
200
|
+
compactions: 0,
|
|
201
|
+
subagentCostShare: null,
|
|
202
|
+
};
|
|
203
|
+
let analyzed = 0;
|
|
204
|
+
let rwSum = 0;
|
|
205
|
+
let rwN = 0;
|
|
206
|
+
const suggMap = new Map();
|
|
207
|
+
for (const rec of records) {
|
|
208
|
+
const r = rec.report;
|
|
209
|
+
if (!r || r.status !== 'ok')
|
|
210
|
+
continue;
|
|
211
|
+
analyzed += 1;
|
|
212
|
+
const a = r.agg;
|
|
213
|
+
agg.totalSpans += a.totalSpans;
|
|
214
|
+
agg.failedSpans += a.failedSpans;
|
|
215
|
+
agg.slowSpans += a.slowSpans;
|
|
216
|
+
agg.compactions += a.compactions;
|
|
217
|
+
for (const [tool, n] of Object.entries(a.failByTool ?? {})) {
|
|
218
|
+
agg.failByTool[tool] = (agg.failByTool[tool] ?? 0) + n;
|
|
219
|
+
}
|
|
220
|
+
for (const ph of INSIGHT_PHASES) {
|
|
221
|
+
const pv = a.phase?.[ph];
|
|
222
|
+
if (pv) {
|
|
223
|
+
agg.phase[ph].count += pv.count;
|
|
224
|
+
agg.phase[ph].ms += pv.ms;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (a.readWriteRatio !== null && Number.isFinite(a.readWriteRatio)) {
|
|
228
|
+
rwSum += a.readWriteRatio;
|
|
229
|
+
rwN += 1;
|
|
230
|
+
}
|
|
231
|
+
for (const s of r.suggestions ?? []) {
|
|
232
|
+
const e = suggMap.get(s.id);
|
|
233
|
+
if (e)
|
|
234
|
+
e.count += 1;
|
|
235
|
+
else
|
|
236
|
+
suggMap.set(s.id, { id: s.id, title: s.title, severity: s.severity, count: 1, evidence: s.evidence ?? [], action: s.action });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
agg.readWriteRatio = rwN ? Number((rwSum / rwN).toFixed(2)) : null;
|
|
240
|
+
const topFailedTools = Object.entries(agg.failByTool)
|
|
241
|
+
.map(([tool, count]) => ({ tool, count }))
|
|
242
|
+
.sort((x, y) => y.count - x.count)
|
|
243
|
+
.slice(0, 5);
|
|
244
|
+
return { totalCount: records.length, analyzedCount: analyzed, agg, topFailedTools, suggestions: [...suggMap.values()] };
|
|
245
|
+
}
|
|
246
|
+
// Overview metrics + recommendations for the CURRENTLY VISIBLE (filtered) records,
|
|
247
|
+
// so the summary tracks the active filter rather than staying global.
|
|
248
|
+
function renderOverview(d) {
|
|
249
|
+
const a = d.agg;
|
|
250
|
+
const rw = a.readWriteRatio === null ? '-' : a.readWriteRatio.toFixed(1);
|
|
251
|
+
const topTools = d.topFailedTools.slice(0, 5);
|
|
252
|
+
const topSuggestions = [...d.suggestions]
|
|
253
|
+
.sort((x, y) => SEVERITY_RANK[x.severity] - SEVERITY_RANK[y.severity] || y.count - x.count)
|
|
254
|
+
.slice(0, 6);
|
|
255
|
+
return `
|
|
256
|
+
<div class="cards insights-metrics">
|
|
257
|
+
${renderMetric(t('insights.metricSessions'), fmtInt(d.totalCount), t('insights.metricAnalyzed', { count: d.analyzedCount }))}
|
|
258
|
+
${renderMetric(t('insights.metricSpans'), fmtInt(a.totalSpans), t('insights.metricSafe'))}
|
|
259
|
+
${renderMetric(t('insights.metricFailed'), fmtInt(a.failedSpans), topTools[0] ? `${topTools[0].tool} ×${topTools[0].count}` : '')}
|
|
260
|
+
${renderMetric(t('insights.metricSlow'), fmtInt(a.slowSpans))}
|
|
261
|
+
${renderMetric(t('insights.metricRw'), rw, t('insights.metricCompactions', { count: a.compactions }))}
|
|
262
|
+
</div>
|
|
263
|
+
<div class="insights-overview-grid">
|
|
264
|
+
<section class="block recblock">
|
|
265
|
+
<h3>${escapeHtml(t('insights.recommendations'))}</h3>
|
|
266
|
+
<div class="reclist">
|
|
267
|
+
${topSuggestions.length ? topSuggestions.map(item => `
|
|
268
|
+
<div class="rec ${item.severity}">
|
|
269
|
+
<div class="rectop"><b>${escapeHtml(suggestionTitle(item))}</b><span>${escapeHtml(severityLabel(item.severity))}</span></div>
|
|
270
|
+
<div class="recev">${escapeHtml(t('insights.seenInSessions', { count: item.count }))}</div>
|
|
271
|
+
</div>`).join('') : `<p class="mut">${escapeHtml(t('insights.noRecommendations'))}</p>`}
|
|
272
|
+
</div>
|
|
273
|
+
</section>
|
|
274
|
+
<section class="block">
|
|
275
|
+
<h3>${escapeHtml(t('insights.toolFailures'))}</h3>
|
|
276
|
+
<div class="hbars">
|
|
277
|
+
${topTools.length ? topTools.map(tt => {
|
|
278
|
+
const pct = Math.max(4, Math.round((tt.count / Math.max(1, topTools[0].count)) * 100));
|
|
279
|
+
return `<div class="hbrow"><div class="hblabel">${escapeHtml(tt.tool)}</div><div class="hbtrack"><div class="hbfill" style="width:${pct}%"></div></div><div class="hbval">${fmtInt(tt.count)}</div></div>`;
|
|
280
|
+
}).join('') : `<p class="mut">${escapeHtml(t('insights.noFailures'))}</p>`}
|
|
281
|
+
</div>
|
|
282
|
+
</section>
|
|
283
|
+
</div>`;
|
|
284
|
+
}
|
|
285
|
+
function renderPhaseMix(report) {
|
|
286
|
+
const entries = Object.entries(report.agg.phase ?? {}).filter(([, v]) => v.count > 0 || v.ms > 0);
|
|
287
|
+
if (!entries.length)
|
|
288
|
+
return '';
|
|
289
|
+
return `<div class="mph">${entries.map(([phase, v]) => {
|
|
290
|
+
const weight = Math.max(1, v.ms || v.count);
|
|
291
|
+
return `<i class="${phaseClass(phase)}" style="flex:${weight}" title="${escapeHtml(`${phaseLabel(phase)} · ${v.count} · ${fmtMs(v.ms)}`)}"></i>`;
|
|
292
|
+
}).join('')}</div>`;
|
|
293
|
+
}
|
|
294
|
+
function renderSessionRows(records, selectedId) {
|
|
295
|
+
if (!records.length)
|
|
296
|
+
return `<div class="insight-empty">${escapeHtml(t('insights.empty'))}</div>`;
|
|
297
|
+
const stat = (label, val, bad = false) => `<b${bad ? ' class="bad"' : ''}>${escapeHtml(label)}<em>${escapeHtml(val)}</em></b>`;
|
|
298
|
+
return `<div class="slist">${records.map(rec => {
|
|
299
|
+
const s = rec.session;
|
|
300
|
+
const r = rec.report;
|
|
301
|
+
const ok = r?.status === 'ok';
|
|
302
|
+
const agg = r?.agg;
|
|
303
|
+
const on = s.sessionId === selectedId ? ' on' : '';
|
|
304
|
+
const review = reportNeedsReview(r) ? ' review' : '';
|
|
305
|
+
return `<button type="button" class="srow${on}${review}" data-session-id="${escapeHtml(s.sessionId)}">
|
|
306
|
+
<div class="srmain">
|
|
307
|
+
<strong>${escapeHtml(sessionTitle(s))}</strong>
|
|
308
|
+
<small>${escapeHtml(botDisplayName(s))} · ${escapeHtml(String(s.cliId ?? '-'))} · ${escapeHtml(relTime(s.lastMessageAt ?? s.spawnedAt ?? 0))}</small>
|
|
309
|
+
${ok ? renderPhaseMix(r) : ''}
|
|
310
|
+
</div>
|
|
311
|
+
${ok ? `<div class="srstats">
|
|
312
|
+
${stat(t('insights.spansShort'), fmtInt(agg.totalSpans))}
|
|
313
|
+
${stat(t('insights.failedShort'), fmtInt(agg.failedSpans), agg.failedSpans > 0)}
|
|
314
|
+
${stat(t('insights.slowShort'), fmtInt(agg.slowSpans))}
|
|
315
|
+
${stat(t('insights.rwShort'), agg.readWriteRatio !== null ? agg.readWriteRatio.toFixed(1) : '-')}
|
|
316
|
+
</div>` : `<div class="srmsg">${escapeHtml(safeStatus(r, rec.error))}</div>`}
|
|
317
|
+
</button>`;
|
|
318
|
+
}).join('')}</div>`;
|
|
319
|
+
}
|
|
320
|
+
// ── Diagnosis-driven detail view ────────────────────────────────────────────
|
|
321
|
+
// codex's detail=spans report carries everything rendered here as fail-closed safe
|
|
322
|
+
// projections (enums / tool names / numbers / basenames — never raw text):
|
|
323
|
+
// • report.recommendations[] — actionable「影响·原因·下一步」, each with evidence
|
|
324
|
+
// {spanIndexes,turnIndexes}; clicking one .hot-lights that evidence.
|
|
325
|
+
// • report.spans[].detail — the per-row 详情 drawer (safe fields + adjacent intent).
|
|
326
|
+
// • report.turnTimeline[] — ALL visible turns as an ordered event stream
|
|
327
|
+
// (read→edit→run→result) + optional owner-only prompt, so 逐轮 reads as a timeline.
|
|
328
|
+
// Localised labels for codex's safe enums + {id,params} headlines (i18n by key, fallback to key).
|
|
329
|
+
function intentLabel(kind) { const k = `insights.intent.${kind}`; const o = t(k); return o === k ? kind : o; }
|
|
330
|
+
function resultLabel(category) { const k = `insights.result.${category}`; const o = t(k); return o === k ? category : o; }
|
|
331
|
+
function tagLabel(tag) { const k = `insights.tag.${tag}`; const o = t(k); return o === k ? tag : o; }
|
|
332
|
+
function idText(ns, h) {
|
|
333
|
+
const k = `insights.${ns}.${h.id}`;
|
|
334
|
+
const o = t(k, h.params);
|
|
335
|
+
return o === k ? h.id : o;
|
|
336
|
+
}
|
|
337
|
+
const turnHeadline = (h) => idText('turnHeadline', h);
|
|
338
|
+
// inputSummary/outputSummary cross only as a fixed allow-list of structural labels (redact.ts);
|
|
339
|
+
// localize the known ones, pass anything else through (it is already safe-projected).
|
|
340
|
+
const STRUCT_KEYS = {
|
|
341
|
+
'shell command': 'insights.struct.shell',
|
|
342
|
+
'file edit': 'insights.struct.fileEdit',
|
|
343
|
+
'read/search': 'insights.struct.readSearch',
|
|
344
|
+
'agent task': 'insights.struct.agentTask',
|
|
345
|
+
'tool input': 'insights.struct.toolInput',
|
|
346
|
+
'tool result': 'insights.struct.toolResult',
|
|
347
|
+
'tool error': 'insights.struct.toolError',
|
|
348
|
+
'patch failed': 'insights.struct.patchFailed',
|
|
349
|
+
'patch applied': 'insights.struct.patchApplied',
|
|
350
|
+
};
|
|
351
|
+
function structLabel(v) {
|
|
352
|
+
if (!v)
|
|
353
|
+
return '';
|
|
354
|
+
const m = v.match(/^exit (-?\d+)$/);
|
|
355
|
+
if (m)
|
|
356
|
+
return t('insights.struct.exit', { code: m[1] });
|
|
357
|
+
return STRUCT_KEYS[v] ? translatedOrFallback(STRUCT_KEYS[v], v) : v;
|
|
358
|
+
}
|
|
359
|
+
const BAD_RESULTS = new Set(['tool_error', 'test_failed', 'typecheck_failed', 'lint_failed', 'command_failed', 'timeout', 'no_output']);
|
|
360
|
+
function spanFailed(s) {
|
|
361
|
+
return s.status === 'error' || (!!s.result && BAD_RESULTS.has(s.result.category));
|
|
362
|
+
}
|
|
363
|
+
function intentTextOf(intent, fallback) {
|
|
364
|
+
return intent && intent.kind !== 'unknown' ? intentLabel(intent.kind) : fallback;
|
|
365
|
+
}
|
|
366
|
+
function intentText(s) { return intentTextOf(s.intent, s.tool); }
|
|
367
|
+
function intentPhrase(intent) {
|
|
368
|
+
if (!intent)
|
|
369
|
+
return '';
|
|
370
|
+
return [intent.kind !== 'unknown' ? intentLabel(intent.kind) : '', intent.subject, intent.detail].filter(Boolean).join(' · ');
|
|
371
|
+
}
|
|
372
|
+
// Turn tag → one-line「怎么优化」(老滕 point 1: the page must say what to DO, not just what happened).
|
|
373
|
+
const ADVICE_TAGS = ['failure', 'retry', 'read_write_imbalance', 'slow'];
|
|
374
|
+
function turnAdvice(tags) {
|
|
375
|
+
for (const tag of ADVICE_TAGS)
|
|
376
|
+
if (tags.includes(tag)) {
|
|
377
|
+
const o = t(`insights.advice.${tag}`);
|
|
378
|
+
if (o !== `insights.advice.${tag}`)
|
|
379
|
+
return o;
|
|
380
|
+
}
|
|
381
|
+
return '';
|
|
382
|
+
}
|
|
383
|
+
// The spans/turns the focused recommendation points at — used to .hot-light evidence.
|
|
384
|
+
function focusSets(report, activeId) {
|
|
385
|
+
const spans = report.spans ?? [];
|
|
386
|
+
const rec = activeId ? (report.recommendations ?? []).find(r => r.id === activeId) ?? null : null;
|
|
387
|
+
if (!rec)
|
|
388
|
+
return { rec: null, spanIdx: new Set(), turnIdx: new Set() };
|
|
389
|
+
const span = (rec.evidence?.spanIndexes ?? []).filter(i => Number.isInteger(i) && i >= 0 && i < spans.length);
|
|
390
|
+
return { rec, spanIdx: new Set(span), turnIdx: new Set(rec.evidence?.turnIndexes ?? []) };
|
|
391
|
+
}
|
|
392
|
+
// Top of the detail: codex's actionable recommendations. Each card leads with the fix
|
|
393
|
+
// (下一步), backed by 影响 + 原因, and is a button that .hot-lights its evidence spans/turns.
|
|
394
|
+
function renderRecommendations(report, activeId) {
|
|
395
|
+
const recs = report.recommendations ?? [];
|
|
396
|
+
if (!recs.length)
|
|
397
|
+
return `<p class="mut">${escapeHtml(t('insights.noRecommendations'))}</p>`;
|
|
398
|
+
const sorted = [...recs].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
|
399
|
+
return `<div class="reclist">${sorted.map(r => {
|
|
400
|
+
const active = r.id === activeId;
|
|
401
|
+
const targeted = (r.evidence?.spanIndexes?.length ?? 0) > 0 || (r.evidence?.turnIndexes?.length ?? 0) > 0;
|
|
402
|
+
const impact = idText('impact', r.impact);
|
|
403
|
+
const why = idText('why', r.why);
|
|
404
|
+
const actions = r.nextActions.map(a => `<li>${escapeHtml(idText('action', a))}</li>`).join('');
|
|
405
|
+
const cta = targeted ? `<span class="rec-cta">${escapeHtml(active ? t('insights.diagActive') : t('insights.diagShow'))}</span>` : '';
|
|
406
|
+
return `<button type="button" class="rec ${r.severity}${targeted ? ' rec-clickable' : ''}${active ? ' active' : ''}" data-rec="${escapeHtml(r.id)}">
|
|
407
|
+
<div class="rectop"><b>${escapeHtml(idText('rec', { id: r.id, params: {} }))}</b><span>${escapeHtml(severityLabel(r.severity))}</span></div>
|
|
408
|
+
${impact ? `<div class="rec-impact">${escapeHtml(impact)}</div>` : ''}
|
|
409
|
+
${actions ? `<ul class="rec-actions">${actions}</ul>` : ''}
|
|
410
|
+
${why ? `<div class="rec-why">${escapeHtml(why)}</div>` : ''}
|
|
411
|
+
${cta}
|
|
412
|
+
</button>`;
|
|
413
|
+
}).join('')}</div>`;
|
|
414
|
+
}
|
|
415
|
+
function opGlyph(s, current) {
|
|
416
|
+
const title = `${intentText(s)}${s.intent?.subject ? ` ${s.intent.subject}` : ''}${s.result ? ` → ${resultLabel(s.result.category)}` : ''} · ${fmtMs(s.durationMs)}`;
|
|
417
|
+
return `<i class="op ph-${escapeHtml(phaseSlug(s.phase))}${spanFailed(s) ? ' bad' : ''}${current ? ' cur' : ''}" title="${escapeHtml(title)}"></i>`;
|
|
418
|
+
}
|
|
419
|
+
// One compact evidence row: turn · status · what→result · tags · duration · 详情 toggle.
|
|
420
|
+
// Far denser than the old card; the drawer (span.detail) carries the rest on demand.
|
|
421
|
+
// The whole header line is the toggle (老滕: 点单个 span 就展开,不用专门点详情按钮); the pill is a
|
|
422
|
+
// state indicator only. data-span-idx lives on the line so clicks in the open drawer don't toggle.
|
|
423
|
+
function renderSpanRow(spans, idx, hot, open, detailable = true) {
|
|
424
|
+
const s = spans[idx];
|
|
425
|
+
const subject = s.intent?.subject ? `<code class="span-subj">${escapeHtml(s.intent.subject)}</code>` : '';
|
|
426
|
+
const res = s.result;
|
|
427
|
+
const resChip = res && BAD_RESULTS.has(res.category)
|
|
428
|
+
? `<span class="span-res rc-bad">${escapeHtml(resultLabel(res.category))}${res.exitCode !== undefined ? ` · exit ${res.exitCode}` : ''}</span>`
|
|
429
|
+
: '';
|
|
430
|
+
const tags = (s.tags ?? []).filter(tg => tg !== 'normal' && tg !== 'diagnostic');
|
|
431
|
+
const tagChips = tags.map(tg => `<span class="span-tag tg-${escapeHtml(tg)}">${escapeHtml(tagLabel(tg))}</span>`).join('');
|
|
432
|
+
const detailBtn = detailable
|
|
433
|
+
? `<span class="span-detail-btn" aria-hidden="true">${escapeHtml(open ? t('insights.dCollapse') : t('insights.dDetail'))}</span>`
|
|
434
|
+
: '';
|
|
435
|
+
const lineAttrs = detailable ? ` data-span-idx="${idx}" role="button" tabindex="0" aria-expanded="${open}"` : '';
|
|
436
|
+
return `<div class="spanrow ph-${escapeHtml(phaseSlug(s.phase))}${s.status === 'error' ? ' error' : ''}${hot ? ' hot' : ''}${open ? ' open' : ''}">
|
|
437
|
+
<div class="sprow-line${detailable ? ' clickable' : ''}"${lineAttrs}>
|
|
438
|
+
<span class="span-turn" title="${escapeHtml(`${t('insights.dStart')} ${fmtMs(s.relStartMs)}`)}">#${escapeHtml(String(s.turnIndex ?? 0))}</span>
|
|
439
|
+
<span class="spanst ${escapeHtml(s.status)}">${escapeHtml(statusIcon(s.status))}</span>
|
|
440
|
+
<b class="span-what">${escapeHtml(intentText(s))}</b>${subject}
|
|
441
|
+
${resChip}
|
|
442
|
+
<span class="span-tags">${tagChips}</span>
|
|
443
|
+
<span class="span-dur">${escapeHtml(fmtMs(s.durationMs))}</span>
|
|
444
|
+
${detailBtn}
|
|
445
|
+
</div>
|
|
446
|
+
${detailable && open ? renderSpanDetail(spans, idx) : ''}
|
|
447
|
+
</div>`;
|
|
448
|
+
}
|
|
449
|
+
// Owner-only raw command/output (codex's evidence.command/output: secret-scrubbed,
|
|
450
|
+
// capped 800/2000 chars, run-class spans only). detail=spans path only — absent ⇒ nothing renders.
|
|
451
|
+
function renderTextPreview(label, p) {
|
|
452
|
+
if (!p?.text)
|
|
453
|
+
return '';
|
|
454
|
+
return `<div class="span-io"><span class="span-io-label">${escapeHtml(label)}</span><pre class="span-io-text">${escapeHtml(p.text)}${p.truncated ? '\n…' : ''}</pre></div>`;
|
|
455
|
+
}
|
|
456
|
+
// 详情 drawer for one span: codex's span.detail safe fields + raw command/output + the same-turn
|
|
457
|
+
// operation strip with this step highlighted ("what surrounded this step"). All safe projections.
|
|
458
|
+
function renderSpanDetail(spans, idx) {
|
|
459
|
+
const s = spans[idx];
|
|
460
|
+
const d = s.detail;
|
|
461
|
+
const ev = d?.evidence ?? s.evidence;
|
|
462
|
+
const io = ev ? `${renderTextPreview(t('insights.dCommand'), ev.command)}${renderTextPreview(t('insights.dCmdOutput'), ev.output)}` : '';
|
|
463
|
+
const kv = [
|
|
464
|
+
[t('insights.dPhase'), phaseLabel(s.phase)],
|
|
465
|
+
[t('insights.dStart'), fmtMs(s.relStartMs)],
|
|
466
|
+
[t('insights.dDur'), fmtMs(s.durationMs)],
|
|
467
|
+
];
|
|
468
|
+
const intent = intentPhrase(s.intent);
|
|
469
|
+
if (intent)
|
|
470
|
+
kv.push([t('insights.dIntent'), intent]);
|
|
471
|
+
if (s.result)
|
|
472
|
+
kv.push([t('insights.dResult'), `${resultLabel(s.result.category)}${s.result.exitCode !== undefined ? ` · exit ${s.result.exitCode}` : ''}`]);
|
|
473
|
+
if (s.inputSummary)
|
|
474
|
+
kv.push([t('insights.dIn'), structLabel(s.inputSummary)]);
|
|
475
|
+
if (s.outputSummary)
|
|
476
|
+
kv.push([t('insights.dOut'), structLabel(s.outputSummary)]);
|
|
477
|
+
const tags = (s.tags ?? []).filter(tg => tg !== 'normal');
|
|
478
|
+
if (tags.length)
|
|
479
|
+
kv.push([t('insights.dTags'), tags.map(tagLabel).join('、')]);
|
|
480
|
+
const prev = d?.context?.previousIntent ? intentPhrase(d.context.previousIntent) : '';
|
|
481
|
+
const next = d?.context?.nextIntent ? intentPhrase(d.context.nextIntent) : '';
|
|
482
|
+
const flank = (prev || next)
|
|
483
|
+
? `<div class="span-flank">${prev ? `<span class="sf-prev">↑ ${escapeHtml(prev)}</span>` : ''}${next ? `<span class="sf-next">↓ ${escapeHtml(next)}</span>` : ''}</div>`
|
|
484
|
+
: '';
|
|
485
|
+
const sibs = spans.map((sp, i) => ({ sp, i })).filter(x => x.sp.turnIndex === s.turnIndex).sort((a, b) => (a.sp.relStartMs ?? 0) - (b.sp.relStartMs ?? 0));
|
|
486
|
+
const strip = sibs.map(x => opGlyph(x.sp, x.i === idx)).join('');
|
|
487
|
+
return `<div class="spandetail">
|
|
488
|
+
<dl class="span-kv">${kv.map(([k, v]) => `<div><dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v)}</dd></div>`).join('')}</dl>
|
|
489
|
+
${io}
|
|
490
|
+
${flank}
|
|
491
|
+
<div class="span-ctx"><span class="span-ctx-label">${escapeHtml(t('insights.dTurnContext', { turn: s.turnIndex }))}</span><div class="opstrip">${strip}</div></div>
|
|
492
|
+
</div>`;
|
|
493
|
+
}
|
|
494
|
+
const SPAN_TAGS = ['failure', 'slow', 'retry', 'read_write_imbalance'];
|
|
495
|
+
// 文件改动 + 跑过的命令 — session-level work summary (codex's workSummary, detail=spans, owner-only).
|
|
496
|
+
// Two panels at the top of 动作 span: which files were touched (read/edit counts + line churn) and
|
|
497
|
+
// which commands ran (deduped + ×repeat + failures). Ported from the reference tool (老滕's ask).
|
|
498
|
+
function renderWorkSummary(report) {
|
|
499
|
+
const ws = report.workSummary;
|
|
500
|
+
if (!ws || (!ws.fileChanges?.length && !ws.commandsRun?.length))
|
|
501
|
+
return '';
|
|
502
|
+
const files = ws.fileChanges ?? [];
|
|
503
|
+
const cmds = ws.commandsRun ?? [];
|
|
504
|
+
const fileRows = files.length
|
|
505
|
+
? files.map(f => {
|
|
506
|
+
const stat = (f.added || f.removed)
|
|
507
|
+
? `<span class="ws-stat"><span class="ws-add">+${f.added ?? 0}</span><span class="ws-del">−${f.removed ?? 0}</span></span>`
|
|
508
|
+
: (f.edits ? `<span class="ws-stat ws-stat-edits">${escapeHtml(t('insights.wsEdits', { n: f.edits }))}</span>` : '');
|
|
509
|
+
return `<div class="ws-row"><code class="ws-path" title="${escapeHtml(f.path)}">${escapeHtml(f.path)}</code><span class="ws-meta">${escapeHtml(t('insights.wsReads', { n: f.reads }))}</span>${stat}</div>`;
|
|
510
|
+
}).join('')
|
|
511
|
+
: `<p class="mut">${escapeHtml(t('insights.wsNoFiles'))}</p>`;
|
|
512
|
+
const cmdRows = cmds.length
|
|
513
|
+
? cmds.map(c => {
|
|
514
|
+
const bad = c.failures > 0;
|
|
515
|
+
return `<div class="ws-row${bad ? ' bad' : ''}"><code class="ws-cmd" title="${escapeHtml(c.command.text)}">${escapeHtml(c.command.text)}${c.command.truncated ? '…' : ''}</code><span class="ws-meta">${c.count > 1 ? `<span class="ws-x">×${c.count}</span>` : ''}${bad ? `<span class="ws-fail">${escapeHtml(t('insights.wsFail', { n: c.failures }))}</span>` : ''}</span></div>`;
|
|
516
|
+
}).join('')
|
|
517
|
+
: `<p class="mut">${escapeHtml(t('insights.wsNoCmds'))}</p>`;
|
|
518
|
+
return `<div class="worksum">
|
|
519
|
+
<section class="ws-panel"><h4>${escapeHtml(t('insights.wsFiles', { n: files.length }))}</h4><div class="ws-list">${fileRows}</div></section>
|
|
520
|
+
<section class="ws-panel"><h4>${escapeHtml(t('insights.wsCmds', { n: cmds.length }))}</h4><div class="ws-list">${cmdRows}</div></section>
|
|
521
|
+
</div>`;
|
|
522
|
+
}
|
|
523
|
+
// 动作 span tab → compact evidence table. Tag chips filter (全部/失败/慢/…); each row expands
|
|
524
|
+
// to its 详情 drawer. Replaces the old giant cards + duplicated full timeline (老滕 point 2).
|
|
525
|
+
function renderEvidence(report, focus, spanFilter, openSpans) {
|
|
526
|
+
const spans = report.spans ?? [];
|
|
527
|
+
const work = renderWorkSummary(report);
|
|
528
|
+
if (!spans.length)
|
|
529
|
+
return `${work}<p class="mut">${escapeHtml(t('insights.noSpans'))}</p>`;
|
|
530
|
+
const order = [...spans.keys()].sort((a, b) => (spans[a].relStartMs ?? 0) - (spans[b].relStartMs ?? 0));
|
|
531
|
+
const counts = new Map();
|
|
532
|
+
for (const i of order)
|
|
533
|
+
for (const tg of spans[i].tags ?? [])
|
|
534
|
+
if (SPAN_TAGS.includes(tg))
|
|
535
|
+
counts.set(tg, (counts.get(tg) ?? 0) + 1);
|
|
536
|
+
const chip = (key, label, n) => `<button type="button" class="spanchip${key === 'all' ? '' : ` tg-${escapeHtml(key)}`}${spanFilter === key ? ' on' : ''}" data-spanfilter="${escapeHtml(key)}">${escapeHtml(label)} <b>${n}</b></button>`;
|
|
537
|
+
const chips = [chip('all', t('insights.spanAll'), order.length), ...SPAN_TAGS.filter(tg => counts.has(tg)).map(tg => chip(tg, tagLabel(tg), counts.get(tg)))].join('');
|
|
538
|
+
const visible = spanFilter === 'all' ? order : order.filter(i => spans[i].tags?.includes(spanFilter));
|
|
539
|
+
const reason = focus.rec ? `<div class="ev-reason">${escapeHtml(idText('why', focus.rec.why))}</div>` : '';
|
|
540
|
+
const rows = visible.length
|
|
541
|
+
? visible.map(i => renderSpanRow(spans, i, focus.spanIdx.has(i), openSpans.has(i))).join('')
|
|
542
|
+
: `<p class="mut">${escapeHtml(t('insights.evNoFlags'))}</p>`;
|
|
543
|
+
return `<div class="evidence">${work}${reason}<div class="spanfilter">${chips}</div><div class="spantable">${rows}</div></div>`;
|
|
544
|
+
}
|
|
545
|
+
function renderDetailShell(rec) {
|
|
546
|
+
if (!rec)
|
|
547
|
+
return `<section class="insight-detail"><p class="mut">${escapeHtml(t('insights.selectSession'))}</p></section>`;
|
|
548
|
+
const s = rec.session;
|
|
549
|
+
return `<section class="insight-detail">
|
|
550
|
+
<div class="shead">
|
|
551
|
+
<h2>${escapeHtml(sessionTitle(s))}</h2>
|
|
552
|
+
<div class="smeta">${escapeHtml(botDisplayName(s))} · ${escapeHtml(String(s.cliId ?? '-'))} · <code>${escapeHtml(String(s.sessionId ?? ''))}</code></div>
|
|
553
|
+
</div>
|
|
554
|
+
<div id="insight-detail-body"><p class="mut">${escapeHtml(t('insights.detailLoading'))}</p></div>
|
|
555
|
+
</section>`;
|
|
556
|
+
}
|
|
557
|
+
// 逐轮对账 tab → per-turn timeline. ALL visible turns (codex's report.turnTimeline), normal
|
|
558
|
+
// turns collapsed to a one-line event strip, flagged turns severity-coloured with a「怎么优化」
|
|
559
|
+
// line; expand for the full ordered event rows. Owner-only prompt 原文 (turnTimeline[].prompt,
|
|
560
|
+
// detail=spans only) renders at the turn head. Answers 老滕 point 3: all turns + a real timeline.
|
|
561
|
+
// Display-only: strip botmux-injected scaffolding (sender/mentions/reminders/quote notices)
|
|
562
|
+
// from the owner-only prompt so the actual user text shows. The report still carries the raw
|
|
563
|
+
// (truncated, credential-scrubbed) text; this is a readability projection, not a security one.
|
|
564
|
+
function cleanPromptText(raw) {
|
|
565
|
+
let s = raw
|
|
566
|
+
.replace(/<(mentions|attachments|available_bots|system-reminder|quoted_messages|sender)\b[\s\S]*?<\/\1>/g, ' ')
|
|
567
|
+
.replace(/<botmux_reminder>[\s\S]*?<\/botmux_reminder>/g, ' ')
|
|
568
|
+
.replace(/<\/?(user_message|local-command-[a-z]+)>/g, ' ')
|
|
569
|
+
.replace(/<sender\b[^>]*\/?>/g, ' ')
|
|
570
|
+
.replace(/<mention\b[^>]*\/?>/g, ' ')
|
|
571
|
+
.replace(/\[用户引用了消息[\s\S]*?\]/g, ' ')
|
|
572
|
+
.replace(/\[来自[^\]]*@mention\]/g, ' ')
|
|
573
|
+
.replace(/\[(图片|文件)\s*\d+\][^\n]*/g, ' ');
|
|
574
|
+
// Preserve newlines — markdown block structure (lists, fences, headings) depends on them.
|
|
575
|
+
// Only collapse runs of spaces/tabs and trim spaces around newlines / cap blank-line runs.
|
|
576
|
+
return s
|
|
577
|
+
.replace(/[ \t]+/g, ' ')
|
|
578
|
+
.replace(/ *\n */g, '\n')
|
|
579
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
580
|
+
.trim();
|
|
581
|
+
}
|
|
582
|
+
// Owner-only prompt 原文 rendered as Markdown (老滕: prompt 里可能有 markdown,想 prettier 展示).
|
|
583
|
+
// html:false → any literal <…> in the prompt is escaped, never raw HTML/script; validateLink
|
|
584
|
+
// whitelists http/https/mailto so a javascript:/data: link can't slip through; links open in a
|
|
585
|
+
// new tab with noopener. The output is markdown-it's own safe tag set, so innerHTML of it is safe.
|
|
586
|
+
// Parse failure ⇒ fall back to escaped plain text — never break 对账.
|
|
587
|
+
const promptMd = new MarkdownIt({ html: false, linkify: true, breaks: true });
|
|
588
|
+
promptMd.validateLink = (url) => /^(https?:|mailto:)/i.test(url.trim());
|
|
589
|
+
const _linkOpen = promptMd.renderer.rules.link_open;
|
|
590
|
+
promptMd.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
|
591
|
+
tokens[idx].attrSet('target', '_blank');
|
|
592
|
+
tokens[idx].attrSet('rel', 'noopener noreferrer nofollow');
|
|
593
|
+
return _linkOpen ? _linkOpen(tokens, idx, options, env, self) : self.renderToken(tokens, idx, options);
|
|
594
|
+
};
|
|
595
|
+
function renderPromptMarkdown(text) {
|
|
596
|
+
try {
|
|
597
|
+
const html = promptMd.render(text).trim();
|
|
598
|
+
return html || `<p>${escapeHtml(text)}</p>`;
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
return `<p>${escapeHtml(text)}</p>`;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
function turnEventGlyph(e) {
|
|
605
|
+
const bad = e.status === 'error' || (!!e.result && BAD_RESULTS.has(e.result.category));
|
|
606
|
+
const what = intentTextOf(e.intent, String(e.label.params.tool ?? e.kind));
|
|
607
|
+
const title = `${what}${e.intent?.subject ? ` ${e.intent.subject}` : ''}${e.result ? ` → ${resultLabel(e.result.category)}` : ''} · ${fmtMs(e.durationMs)}`;
|
|
608
|
+
return `<i class="op ph-${escapeHtml(phaseSlug(e.phase))}${bad ? ' bad' : ''}" title="${escapeHtml(title)}"></i>`;
|
|
609
|
+
}
|
|
610
|
+
// Prompt source attribution (codex's prompt.source: name/type/flags only, no open_id). Surfaces
|
|
611
|
+
// WHO actually sent the turn — 老滕/其他人 (👤), a bot (🤖), or an a2a forward (🤝) — answering
|
|
612
|
+
// 老滕's「区分 a2a / 其他人消息」, driven by codex's authoritative source.kind. a2a shows the
|
|
613
|
+
// specific sending agent (agentName ?? senderName); system = injected task-notification callbacks.
|
|
614
|
+
function promptSourceChip(src) {
|
|
615
|
+
if (!src?.kind)
|
|
616
|
+
return '';
|
|
617
|
+
if (src.kind === 'a2a_agent') {
|
|
618
|
+
const name = src.agentName || src.senderName;
|
|
619
|
+
return `<span class="tp-label tp-src tp-src-a2a">🤝 ${name ? `${escapeHtml(name)} · a2a` : 'a2a'}</span>`;
|
|
620
|
+
}
|
|
621
|
+
if (src.kind === 'system') {
|
|
622
|
+
return `<span class="tp-label tp-src tp-src-system">⚙️ ${escapeHtml(t('insights.senderSystem'))}</span>`;
|
|
623
|
+
}
|
|
624
|
+
const name = src.senderName ? escapeHtml(src.senderName) : '';
|
|
625
|
+
return `<span class="tp-label tp-src tp-src-user">👤 ${name || escapeHtml(t('insights.turnPrompt'))}</span>`;
|
|
626
|
+
}
|
|
627
|
+
function promptMentions(src) {
|
|
628
|
+
const ms = (src?.mentionedNames ?? []).filter(Boolean);
|
|
629
|
+
if (!ms.length)
|
|
630
|
+
return '';
|
|
631
|
+
return `<span class="tp-mentions">${escapeHtml(t('insights.srcMentions'))} ${ms.map(n => '@' + escapeHtml(n)).join(' ')}</span>`;
|
|
632
|
+
}
|
|
633
|
+
// Full-text prompt modal (老滕: 做个弹窗看全文). Bigger centred reading surface for one turn's
|
|
634
|
+
// prompt — source badge + markdown (or 原文) + truncation note. Same safe markdown path as inline.
|
|
635
|
+
function renderPromptModalInner(turnIndex, prompt, raw) {
|
|
636
|
+
const src = prompt?.source;
|
|
637
|
+
const badge = promptSourceChip(src) || `<span class="tp-label">${escapeHtml(t('insights.turnPrompt'))}</span>`;
|
|
638
|
+
const mentions = promptMentions(src);
|
|
639
|
+
const cleaned = prompt?.text ? (cleanPromptText(prompt.text) || prompt.text) : '';
|
|
640
|
+
const trunc = prompt?.truncated;
|
|
641
|
+
const bodyHtml = raw
|
|
642
|
+
? `<pre class="tp-raw modal-raw">${escapeHtml(cleaned)}${trunc ? '\n…' : ''}</pre>`
|
|
643
|
+
: `<div class="md-body modal-md">${renderPromptMarkdown(cleaned + (trunc ? ' …' : ''))}</div>`;
|
|
644
|
+
return `<div class="modal-backdrop" data-modal-close></div>
|
|
645
|
+
<div class="modal-panel" role="dialog" aria-modal="true" aria-label="${escapeHtml(t('insights.turnPrompt'))}">
|
|
646
|
+
<div class="modal-head">
|
|
647
|
+
<div class="modal-who">${badge}${mentions}<span class="modal-turnno">#${escapeHtml(String(turnIndex))}</span></div>
|
|
648
|
+
<div class="modal-acts">
|
|
649
|
+
<button type="button" class="tp-toggle" data-modal-raw>${escapeHtml(raw ? t('insights.turnPromptRendered') : t('insights.turnPromptRaw'))}</button>
|
|
650
|
+
<button type="button" class="modal-close" data-modal-close aria-label="${escapeHtml(t('insights.modalClose'))}">×</button>
|
|
651
|
+
</div>
|
|
652
|
+
</div>
|
|
653
|
+
<div class="modal-body">${bodyHtml}${trunc ? `<p class="modal-trunc mut">${escapeHtml(t('insights.promptTruncated'))}</p>` : ''}</div>
|
|
654
|
+
</div>`;
|
|
655
|
+
}
|
|
656
|
+
// One turn card: prompt 原文 + op-strip + 怎么优化 advice + expandable event rows. Each event row
|
|
657
|
+
// is detailable so its 详情 drawer carries the raw command/output too (老滕: 对账 tab 下也要看到命令和结果).
|
|
658
|
+
function renderTurnCard(report, spans, tn, focus, openTurns, openSpans, openPrompts, rawPrompts) {
|
|
659
|
+
const open = openTurns.has(tn.turnIndex);
|
|
660
|
+
const hot = focus.turnIdx.has(tn.turnIndex) ? ' hot' : '';
|
|
661
|
+
const m = tn.metrics;
|
|
662
|
+
const advice = tn.severity !== 'info' ? turnAdvice(tn.tags) : '';
|
|
663
|
+
const strip = tn.events.map(turnEventGlyph).join('');
|
|
664
|
+
// Prompt 原文: render as Markdown by default (老滕 wants prettier), clamped so a long prompt can't
|
|
665
|
+
// blow the timeline; 展开 lifts the clamp (still scroll-capped), 原文 shows the raw text instead.
|
|
666
|
+
const ptext = tn.prompt?.text ? (cleanPromptText(tn.prompt.text) || tn.prompt.text) : '';
|
|
667
|
+
const promptExpanded = openPrompts.has(tn.turnIndex);
|
|
668
|
+
const promptRaw = rawPrompts.has(tn.turnIndex);
|
|
669
|
+
const ptail = tn.prompt?.truncated ? ' …' : '';
|
|
670
|
+
const promptBody = promptRaw
|
|
671
|
+
? `<pre class="tp-raw">${escapeHtml(ptext)}${tn.prompt?.truncated ? '\n…' : ''}</pre>`
|
|
672
|
+
: `<div class="md-body">${renderPromptMarkdown(ptext + ptail)}</div>`;
|
|
673
|
+
const srcChip = promptSourceChip(tn.prompt?.source) || `<span class="tp-label">${escapeHtml(t('insights.turnPrompt'))}</span>`;
|
|
674
|
+
const mentionsHtml = promptMentions(tn.prompt?.source);
|
|
675
|
+
const promptHtml = ptext
|
|
676
|
+
? `<div class="turn-prompt">
|
|
677
|
+
${srcChip}
|
|
678
|
+
<div class="tp-body">
|
|
679
|
+
<div class="tp-md${promptExpanded ? ' expanded' : ''}">${promptBody}</div>
|
|
680
|
+
<div class="tp-actions">
|
|
681
|
+
${mentionsHtml}
|
|
682
|
+
<button type="button" class="tp-toggle" data-prompt-expand="${escapeHtml(String(tn.turnIndex))}">${escapeHtml(promptExpanded ? t('insights.turnPromptCollapse') : t('insights.turnPromptExpand'))}</button>
|
|
683
|
+
<button type="button" class="tp-toggle" data-prompt-raw="${escapeHtml(String(tn.turnIndex))}">${escapeHtml(promptRaw ? t('insights.turnPromptRendered') : t('insights.turnPromptRaw'))}</button>
|
|
684
|
+
<button type="button" class="tp-toggle" data-prompt-full="${escapeHtml(String(tn.turnIndex))}">${escapeHtml(t('insights.turnPromptFull'))}</button>
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
</div>`
|
|
688
|
+
: '';
|
|
689
|
+
const pill = (label, val, bad = false) => `<span class="tm${bad ? ' bad' : ''}"><i>${escapeHtml(label)}</i><b>${escapeHtml(val)}</b></span>`;
|
|
690
|
+
const mini = `${t('insights.mEdits')}${m.edits} ${t('insights.mRuns')}${m.runs}${m.failures ? ` · ${t('insights.mFailures')}${m.failures}` : ''} · ${fmtMs(m.durationMs)}`;
|
|
691
|
+
const detail = open
|
|
692
|
+
? `<div class="turn-detail">
|
|
693
|
+
<div class="turn-metrics">${pill(t('insights.mReads'), String(m.reads))}${pill(t('insights.mEdits'), String(m.edits))}${pill(t('insights.mRuns'), String(m.runs))}${m.failures ? pill(t('insights.mFailures'), String(m.failures), true) : ''}${pill(t('insights.mDur'), fmtMs(m.durationMs))}</div>
|
|
694
|
+
<div class="spantable">${tn.events.map(e => renderSpanRow(spans, e.spanIndex, false, openSpans.has(e.spanIndex), true)).join('')}</div>
|
|
695
|
+
</div>`
|
|
696
|
+
: '';
|
|
697
|
+
return `<div class="turnrow sev-${escapeHtml(tn.severity)}${hot}${tn.severity !== 'info' ? ' flagged' : ''}${open ? ' open' : ''}" data-turn-card="${escapeHtml(String(tn.turnIndex))}">
|
|
698
|
+
<div class="turnline">
|
|
699
|
+
<span class="turn-no">#${escapeHtml(String(tn.turnIndex))}</span>
|
|
700
|
+
<b class="turn-headline">${escapeHtml(turnHeadline(tn.headline))}</b>
|
|
701
|
+
<div class="opstrip turn-strip">${strip}</div>
|
|
702
|
+
<span class="turn-mini">${escapeHtml(mini)}</span>
|
|
703
|
+
<button type="button" class="turn-expand-btn" data-turn="${escapeHtml(String(tn.turnIndex))}" aria-expanded="${open}">${escapeHtml(open ? t('insights.turnCollapse') : t('insights.turnExpand', { count: tn.events.length }))}</button>
|
|
704
|
+
</div>
|
|
705
|
+
${promptHtml}
|
|
706
|
+
${advice ? `<div class="turn-advice">${escapeHtml(advice)}</div>` : ''}
|
|
707
|
+
${detail}
|
|
708
|
+
</div>`;
|
|
709
|
+
}
|
|
710
|
+
function turnSenderKind(tn) {
|
|
711
|
+
return tn.prompt?.source?.kind ?? 'user';
|
|
712
|
+
}
|
|
713
|
+
// 逐轮对账 tab → per-turn timeline. 发起人 filter narrows first (全部 = 对话 = user+a2a; 系统 is an
|
|
714
|
+
// opt-in, since task-notification callbacks aren't real conversation), then two orderings: 正常排序
|
|
715
|
+
// (by turnIndex) and 按建议分类 (turns grouped under the recommendation that cites them).
|
|
716
|
+
function renderTurnEfficiency(report, focus, openTurns, openSpans, ledgerSort, openPrompts, rawPrompts, ledgerSender) {
|
|
717
|
+
const spans = report.spans ?? [];
|
|
718
|
+
const allTurns = report.turnTimeline ?? [];
|
|
719
|
+
if (!allTurns.length)
|
|
720
|
+
return `<p class="mut">${escapeHtml(t('insights.noSpans'))}</p>`;
|
|
721
|
+
const recs = report.recommendations ?? [];
|
|
722
|
+
const canGroup = recs.some(r => (r.evidence?.turnIndexes?.length ?? 0) > 0);
|
|
723
|
+
// 发起人 filter chips — only show a kind that actually occurs; emoji mirror the prompt badges.
|
|
724
|
+
// 全部 = conversation (user + a2a), system excluded by default; 系统 chip is muted opt-in.
|
|
725
|
+
const senderCount = { user: 0, a2a_agent: 0, system: 0 };
|
|
726
|
+
for (const tn of allTurns)
|
|
727
|
+
senderCount[turnSenderKind(tn)]++;
|
|
728
|
+
const senderChip = (key, label, n, extra = '') => `<button type="button" class="spanchip${extra}${ledgerSender === key ? ' on' : ''}" data-ledgersender="${key}">${escapeHtml(label)} <b>${n}</b></button>`;
|
|
729
|
+
const senderChips = [
|
|
730
|
+
senderChip('all', t('insights.spanAll'), senderCount.user + senderCount.a2a_agent),
|
|
731
|
+
...(senderCount.user ? [senderChip('user', `👤 ${t('insights.senderHuman')}`, senderCount.user)] : []),
|
|
732
|
+
...(senderCount.a2a_agent ? [senderChip('a2a_agent', `🤝 ${t('insights.senderA2A')}`, senderCount.a2a_agent)] : []),
|
|
733
|
+
...(senderCount.system ? [senderChip('system', `⚙️ ${t('insights.senderSystem')}`, senderCount.system, ' spanchip-sys')] : []),
|
|
734
|
+
].join('');
|
|
735
|
+
const senderFilter = `<div class="spanfilter ledgersender">${senderChips}</div>`;
|
|
736
|
+
const turns = ledgerSender === 'all'
|
|
737
|
+
? allTurns.filter(tn => turnSenderKind(tn) !== 'system')
|
|
738
|
+
: allTurns.filter(tn => turnSenderKind(tn) === ledgerSender);
|
|
739
|
+
const flagged = turns.filter(tn => tn.severity !== 'info').length;
|
|
740
|
+
const sortChip = (key, label) => `<button type="button" class="spanchip${ledgerSort === key ? ' on' : ''}" data-ledgersort="${key}">${escapeHtml(label)}</button>`;
|
|
741
|
+
const toggle = canGroup ? `<div class="spanfilter ledgersort">${sortChip('normal', t('insights.ledgerNormal'))}${sortChip('grouped', t('insights.ledgerGrouped'))}</div>` : '';
|
|
742
|
+
const controls = `${senderFilter}${toggle}`;
|
|
743
|
+
const summary = `<p class="turn-sum mut">${escapeHtml(t('insights.turnSummary', { total: turns.length, flagged }))}</p>`;
|
|
744
|
+
const note = report.meta?.capped ? `<p class="turn-hidden mut">${escapeHtml(t('insights.turnsCapped', { shown: String(report.meta.spansReturned ?? spans.length), total: String(report.meta.spansTotal ?? spans.length) }))}</p>` : '';
|
|
745
|
+
const card = (tn) => renderTurnCard(report, spans, tn, focus, openTurns, openSpans, openPrompts, rawPrompts);
|
|
746
|
+
if (!turns.length)
|
|
747
|
+
return `<div class="turnlist">${controls}<p class="mut">${escapeHtml(t('insights.evNoFlags'))}</p></div>`;
|
|
748
|
+
if (ledgerSort === 'grouped' && canGroup) {
|
|
749
|
+
const sortedRecs = [...recs].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
|
|
750
|
+
const byIndex = new Map(turns.map(tn => [tn.turnIndex, tn]));
|
|
751
|
+
const assigned = new Map();
|
|
752
|
+
for (const r of sortedRecs)
|
|
753
|
+
for (const ti of r.evidence?.turnIndexes ?? [])
|
|
754
|
+
if (byIndex.has(ti) && !assigned.has(ti))
|
|
755
|
+
assigned.set(ti, r.id);
|
|
756
|
+
const groupHead = (sev, title, n) => `<div class="turn-group-head sev-${escapeHtml(sev)}"><b>${escapeHtml(title)}</b><span>${escapeHtml(t('insights.ledgerGroupCount', { count: n }))}</span></div>`;
|
|
757
|
+
const blocks = [];
|
|
758
|
+
for (const r of sortedRecs) {
|
|
759
|
+
const ts = turns.filter(tn => assigned.get(tn.turnIndex) === r.id).sort((a, b) => a.turnIndex - b.turnIndex);
|
|
760
|
+
if (ts.length)
|
|
761
|
+
blocks.push(`<div class="turn-group">${groupHead(r.severity, idText('rec', { id: r.id, params: {} }), ts.length)}${ts.map(card).join('')}</div>`);
|
|
762
|
+
}
|
|
763
|
+
const other = turns.filter(tn => !assigned.has(tn.turnIndex)).sort((a, b) => a.turnIndex - b.turnIndex);
|
|
764
|
+
if (other.length)
|
|
765
|
+
blocks.push(`<div class="turn-group">${groupHead('info', t('insights.ledgerOther'), other.length)}${other.map(card).join('')}</div>`);
|
|
766
|
+
return `<div class="turnlist">${summary}${controls}${blocks.join('')}${note}</div>`;
|
|
767
|
+
}
|
|
768
|
+
const ordered = [...turns].sort((a, b) => a.turnIndex - b.turnIndex);
|
|
769
|
+
return `<div class="turnlist">${summary}${controls}${ordered.map(card).join('')}${note}</div>`;
|
|
770
|
+
}
|
|
771
|
+
function groupConvo(messages) {
|
|
772
|
+
const units = [];
|
|
773
|
+
for (const m of messages) {
|
|
774
|
+
if (m.role === 'agent') {
|
|
775
|
+
const last = units[units.length - 1];
|
|
776
|
+
if (last && last.kind === 'ops' && last.turnIndex === m.turnIndex)
|
|
777
|
+
last.msgs.push(m);
|
|
778
|
+
else
|
|
779
|
+
units.push({ kind: 'ops', turnIndex: m.turnIndex, msgs: [m] });
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
units.push({ kind: 'prompt', msg: m });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return units;
|
|
786
|
+
}
|
|
787
|
+
// Recommendation badge(s) for a turn — links a chat turn to the optimisation findings that cite it.
|
|
788
|
+
function convoRecBadges(turnIndex, recByTurn) {
|
|
789
|
+
const ids = recByTurn.get(turnIndex);
|
|
790
|
+
if (!ids?.length)
|
|
791
|
+
return '';
|
|
792
|
+
return ids.map(id => `<span class="cbub-rec" title="${escapeHtml(idText('rec', { id, params: {} }))}">💡 ${escapeHtml(idText('rec', { id, params: {} }))}</span>`).join('');
|
|
793
|
+
}
|
|
794
|
+
function renderConvoPrompt(m, recByTurn) {
|
|
795
|
+
const side = m.role === 'user' ? 'right' : 'left';
|
|
796
|
+
const badge = promptSourceChip(m.source) || `<span class="tp-label">${escapeHtml(t('insights.turnPrompt'))}</span>`;
|
|
797
|
+
const text = m.text ? (cleanPromptText(m.text) || m.text) : '';
|
|
798
|
+
const sevCls = m.severity && m.severity !== 'info' ? ` sev-${escapeHtml(m.severity)}` : '';
|
|
799
|
+
return `<div class="cbub cbub-${side} role-${escapeHtml(m.role)}${sevCls}">
|
|
800
|
+
<div class="cbub-head">${badge}${promptMentions(m.source)}<span class="cbub-turn">#${escapeHtml(String(m.turnIndex))}</span>${convoRecBadges(m.turnIndex, recByTurn)}</div>
|
|
801
|
+
<div class="cbub-body"><div class="md-body">${text ? renderPromptMarkdown(text + (m.truncated ? ' …' : '')) : `<p class="mut">${escapeHtml(t('insights.replayNoText'))}</p>`}</div></div>
|
|
802
|
+
${m.truncated ? `<div class="cbub-foot"><button type="button" class="tp-toggle" data-convo-full="${escapeHtml(String(m.turnIndex))}">${escapeHtml(t('insights.turnPromptFull'))}</button></div>` : ''}
|
|
803
|
+
</div>`;
|
|
804
|
+
}
|
|
805
|
+
function renderConvoOpRow(m, open) {
|
|
806
|
+
const e = m.event;
|
|
807
|
+
if (!e)
|
|
808
|
+
return '';
|
|
809
|
+
const bad = e.status === 'error' || (!!e.result && BAD_RESULTS.has(e.result.category));
|
|
810
|
+
const subj = e.intent?.subject ? `<code class="span-subj">${escapeHtml(e.intent.subject)}</code>` : '';
|
|
811
|
+
const what = intentTextOf(e.intent, String(e.label?.params?.tool ?? e.kind));
|
|
812
|
+
const res = e.result && BAD_RESULTS.has(e.result.category)
|
|
813
|
+
? `<span class="span-res rc-bad">${escapeHtml(resultLabel(e.result.category))}${e.result.exitCode !== undefined ? ` · exit ${e.result.exitCode}` : ''}</span>`
|
|
814
|
+
: '';
|
|
815
|
+
const ev = e.evidence;
|
|
816
|
+
const expandable = !!(ev?.command?.text || ev?.output?.text);
|
|
817
|
+
const io = open && ev ? `${renderTextPreview(t('insights.dCommand'), ev.command)}${renderTextPreview(t('insights.dCmdOutput'), ev.output)}` : '';
|
|
818
|
+
const tags = (m.tags ?? []).filter(tg => tg !== 'normal' && tg !== 'diagnostic');
|
|
819
|
+
const tagChips = tags.map(tg => `<span class="span-tag tg-${escapeHtml(tg)}">${escapeHtml(tagLabel(tg))}</span>`).join('');
|
|
820
|
+
return `<div class="cop${bad ? ' bad' : ''}${open ? ' open' : ''}">
|
|
821
|
+
<div class="cop-line${expandable ? ' clickable' : ''}"${expandable ? ` data-convo-op="${escapeHtml(m.id)}" role="button" tabindex="0"` : ''}>
|
|
822
|
+
<i class="op ph-${escapeHtml(phaseSlug(e.phase))}${bad ? ' bad' : ''}"></i>
|
|
823
|
+
<b class="span-what">${escapeHtml(what)}</b>${subj}${res}
|
|
824
|
+
<span class="span-tags">${tagChips}</span>
|
|
825
|
+
<span class="span-dur">${escapeHtml(fmtMs(e.durationMs))}</span>
|
|
826
|
+
${expandable ? `<span class="span-detail-btn" aria-hidden="true">${escapeHtml(open ? t('insights.dCollapse') : t('insights.dDetail'))}</span>` : ''}
|
|
827
|
+
</div>
|
|
828
|
+
${io ? `<div class="spandetail">${io}</div>` : ''}
|
|
829
|
+
</div>`;
|
|
830
|
+
}
|
|
831
|
+
function renderConvoOps(unit, openOps, recByTurn) {
|
|
832
|
+
const worst = unit.msgs.some(m => m.severity === 'bad') ? ' sev-bad' : unit.msgs.some(m => m.severity === 'warn') ? ' sev-warn' : '';
|
|
833
|
+
const rows = unit.msgs.map(m => renderConvoOpRow(m, openOps.has(m.id))).join('');
|
|
834
|
+
return `<div class="cbub cbub-left role-agent cbub-ops${worst}">
|
|
835
|
+
<div class="cbub-head"><span class="tp-label tp-src tp-src-system">🤖 ${escapeHtml(t('insights.replayAgent'))}</span><span class="cbub-turn">#${escapeHtml(String(unit.turnIndex))}</span><span class="cbub-opcount">${escapeHtml(t('insights.replayOps', { count: unit.msgs.length }))}</span>${convoRecBadges(unit.turnIndex, recByTurn)}</div>
|
|
836
|
+
<div class="cbub-ops-list">${rows}</div>
|
|
837
|
+
</div>`;
|
|
838
|
+
}
|
|
839
|
+
function renderConvoThread(convo, recByTurn) {
|
|
840
|
+
if (!convo.messages.length) {
|
|
841
|
+
return convo.loading ? `<p class="mut">${escapeHtml(t('insights.detailLoading'))}</p>` : `<p class="mut">${escapeHtml(t('insights.replayEmpty'))}</p>`;
|
|
842
|
+
}
|
|
843
|
+
const units = groupConvo(convo.messages);
|
|
844
|
+
const bubbles = units.map(u => u.kind === 'prompt' ? renderConvoPrompt(u.msg, recByTurn) : renderConvoOps(u, convo.openOps, recByTurn)).join('');
|
|
845
|
+
const more = convo.hasMore
|
|
846
|
+
? `<div class="convo-more"><button type="button" class="primary convo-loadmore"${convo.loading ? ' disabled' : ''}>${escapeHtml(convo.loading ? t('insights.detailLoading') : t('insights.replayLoadMore', { shown: convo.messages.length, total: convo.total }))}</button></div>`
|
|
847
|
+
: `<p class="convo-more mut">${escapeHtml(t('insights.replayAllLoaded', { total: convo.total }))}</p>`;
|
|
848
|
+
return `${bubbles}${more}`;
|
|
849
|
+
}
|
|
850
|
+
const CONVO_ROLES = [
|
|
851
|
+
{ key: 'all', label: 'spanAll' },
|
|
852
|
+
{ key: 'user', label: 'senderHuman' },
|
|
853
|
+
{ key: 'a2a_agent', label: 'senderA2A' },
|
|
854
|
+
{ key: 'system', label: 'senderSystem' },
|
|
855
|
+
];
|
|
856
|
+
const CONVO_TAGS = [
|
|
857
|
+
{ key: 'all', label: 'spanAll' },
|
|
858
|
+
{ key: 'failure', label: 'tag.failure' },
|
|
859
|
+
{ key: 'slow', label: 'tag.slow' },
|
|
860
|
+
];
|
|
861
|
+
function renderConvo(convo, recByTurn) {
|
|
862
|
+
const roleChips = CONVO_ROLES.map(r => `<button type="button" class="spanchip${convo.role === r.key ? ' on' : ''}" data-convo-role="${r.key}">${escapeHtml(r.label.includes('.') ? tagLabel(r.label.split('.')[1]) : t('insights.' + r.label))}</button>`).join('');
|
|
863
|
+
const tagChips = CONVO_TAGS.map(tg => `<button type="button" class="spanchip${convo.tag === tg.key ? ' on' : ''}" data-convo-tag="${tg.key}">${escapeHtml(tg.label.includes('.') ? tagLabel(tg.label.split('.')[1]) : t('insights.' + tg.label))}</button>`).join('');
|
|
864
|
+
return `<div class="convo">
|
|
865
|
+
<div class="convo-controls">
|
|
866
|
+
<input type="search" class="convo-search" placeholder="${escapeHtml(t('insights.replaySearch'))}" value="${escapeHtml(convo.q)}">
|
|
867
|
+
<div class="convo-filters">
|
|
868
|
+
<div class="spanfilter convo-rolefilter"><span class="convo-flabel">${escapeHtml(t('insights.replayBy'))}</span>${roleChips}</div>
|
|
869
|
+
<div class="spanfilter convo-tagfilter"><span class="convo-flabel">${escapeHtml(t('insights.replayState'))}</span>${tagChips}</div>
|
|
870
|
+
</div>
|
|
871
|
+
</div>
|
|
872
|
+
<div class="convothread">${renderConvoThread(convo, recByTurn)}</div>
|
|
873
|
+
</div>`;
|
|
874
|
+
}
|
|
875
|
+
// A turn has no single phase; pick the one its events spent the most time in (a +1 floor so
|
|
876
|
+
// zero-duration events still vote), so the rail node color reflects what the turn mostly did.
|
|
877
|
+
function turnMainPhase(tn) {
|
|
878
|
+
const w = new Map();
|
|
879
|
+
for (const e of tn.events ?? []) {
|
|
880
|
+
if (!e.phase)
|
|
881
|
+
continue;
|
|
882
|
+
w.set(e.phase, (w.get(e.phase) ?? 0) + (e.durationMs ?? 0) + 1);
|
|
883
|
+
}
|
|
884
|
+
let best = 'discuss', max = -1;
|
|
885
|
+
for (const [p, d] of w)
|
|
886
|
+
if (d > max) {
|
|
887
|
+
max = d;
|
|
888
|
+
best = p;
|
|
889
|
+
}
|
|
890
|
+
return best;
|
|
891
|
+
}
|
|
892
|
+
// 会话轨迹 — session-trace mini-map. One clickable node per turn, colored by its dominant phase,
|
|
893
|
+
// badged for failures/slow/recommendation hits, dimmed when a recommendation focus is active (so the
|
|
894
|
+
// cited turns pop). Clicking a node jumps to that turn in the 逐轮对账 ledger. Per codex: no turn-level
|
|
895
|
+
// compaction marker (only a session-level count exists — don't fake per-turn compaction points).
|
|
896
|
+
function renderTurnRail(report, focus, recByTurn) {
|
|
897
|
+
// Serial main line — MUST be in turn order. turnTimeline arrives unsorted (the ledger sorts it too),
|
|
898
|
+
// so sort by turnIndex here or the rail reads as random (老滕: 顺序乱).
|
|
899
|
+
const turns = [...(report.turnTimeline ?? [])].sort((a, b) => a.turnIndex - b.turnIndex);
|
|
900
|
+
if (turns.length < 2)
|
|
901
|
+
return '';
|
|
902
|
+
const focused = focus.turnIdx.size > 0;
|
|
903
|
+
const items = turns.map(tn => {
|
|
904
|
+
const m = tn.metrics;
|
|
905
|
+
const phase = turnMainPhase(tn);
|
|
906
|
+
const fail = (tn.tags ?? []).includes('failure') || (tn.events ?? []).some(e => e.status === 'error');
|
|
907
|
+
const slow = (tn.tags ?? []).includes('slow');
|
|
908
|
+
const recHit = recByTurn.has(tn.turnIndex);
|
|
909
|
+
const hot = focus.turnIdx.has(tn.turnIndex);
|
|
910
|
+
const cls = ['railnode', phaseClass(phase), hot ? 'hot' : '', (focused && !hot) ? 'dim' : ''].filter(Boolean).join(' ');
|
|
911
|
+
const tip = `#${tn.turnIndex} · ${phaseLabel(phase)} · ${t('insights.mReads')}${m.reads} ${t('insights.mEdits')}${m.edits} ${t('insights.mRuns')}${m.runs}${m.failures ? ` · ${t('insights.mFailures')}${m.failures}` : ''} · ${fmtMs(m.durationMs)}`;
|
|
912
|
+
const badges = `${fail ? '<i class="rb rb-fail"></i>' : ''}${slow ? '<i class="rb rb-slow"></i>' : ''}${recHit ? '<i class="rb rb-rec"></i>' : ''}`;
|
|
913
|
+
const node = `<button type="button" class="${cls}" data-rail-turn="${escapeHtml(String(tn.turnIndex))}" data-tip="${escapeHtml(tip)}"><span>${escapeHtml(String(tn.turnIndex))}</span>${badges}</button>`;
|
|
914
|
+
// 委派分支: one teal dot per subagent (delegate event) the turn spawned, so the serial main line
|
|
915
|
+
// shows where work branched off — mirrors the reference's 串行主线 + 委派分支.
|
|
916
|
+
const subs = (tn.events ?? []).filter(e => e.kind === 'delegate').length;
|
|
917
|
+
const branch = subs ? `<span class="railbranch" data-tip="${escapeHtml(`#${tn.turnIndex} · ${t('insights.railSubagents', { n: subs })}`)}">${'<i class="rbr-sub"></i>'.repeat(Math.min(subs, 4))}</span>` : '';
|
|
918
|
+
return node + branch;
|
|
919
|
+
}).join('');
|
|
920
|
+
const legend = [
|
|
921
|
+
...['research', 'edit', 'run', 'delegate', 'discuss'].map(p => `<span class="rl-item"><i class="${phaseClass(p)}"></i>${escapeHtml(phaseLabel(p))}</span>`),
|
|
922
|
+
`<span class="rl-item rl-sep"><i class="rbr-sub"></i>${escapeHtml(t('insights.railSubagent'))}</span>`,
|
|
923
|
+
].join('');
|
|
924
|
+
return `<section class="block turnrail-block">
|
|
925
|
+
<div class="turnrail-head"><h3>${escapeHtml(t('insights.turnRail'))}</h3><span class="turnrail-legend">${legend}</span></div>
|
|
926
|
+
<div class="turnrail">${items}</div>
|
|
927
|
+
</section>`;
|
|
928
|
+
}
|
|
929
|
+
// 工作时序 — work-timeline Gantt. Each span is a bar positioned by relStartMs and sized by
|
|
930
|
+
// durationMs over the session's real elapsed span, colored by phase, failures/slow highlighted.
|
|
931
|
+
// Idle gaps show as empty track, so it's clear where the wall-clock actually went. Clicking a bar
|
|
932
|
+
// opens that span in the 动作 span tab.
|
|
933
|
+
function renderWorkGantt(report) {
|
|
934
|
+
const timed = (report.spans ?? []).map((s, i) => ({ s, i, start: s.relStartMs ?? 0, dur: Math.max(s.durationMs ?? 0, 0) }))
|
|
935
|
+
.filter(x => Number.isFinite(x.start)).sort((a, b) => a.start - b.start);
|
|
936
|
+
if (timed.length < 2)
|
|
937
|
+
return '';
|
|
938
|
+
// Pack bars by time order, each width ∝ its duration — we deliberately drop real-time idle gaps
|
|
939
|
+
// (a session left open for days would otherwise squish all the active work to the left). This reads
|
|
940
|
+
// as "where the active wall-clock actually went": a 28s Bash is a fat bar, instant reads are slivers.
|
|
941
|
+
const active = Math.max(timed.reduce((sum, x) => sum + x.dur, 0), 1);
|
|
942
|
+
let cursor = 0;
|
|
943
|
+
const bars = timed.map(x => {
|
|
944
|
+
const left = cursor / active * 100;
|
|
945
|
+
const width = Math.max(x.dur / active * 100, 0.3);
|
|
946
|
+
cursor += x.dur;
|
|
947
|
+
const fail = x.s.status === 'error' || (x.s.tags ?? []).includes('failure');
|
|
948
|
+
const slow = (x.s.tags ?? []).includes('slow');
|
|
949
|
+
const cls = ['gbar', phaseClass(x.s.phase), fail ? 'gbar-fail' : '', slow ? 'gbar-slow' : ''].filter(Boolean).join(' ');
|
|
950
|
+
const st = x.s.status === 'error' ? ` · ${tagLabel('failure')}` : '';
|
|
951
|
+
const tip = `#${x.i} · ${x.s.tool} · ${phaseLabel(x.s.phase)} · ${fmtMs(x.s.durationMs)}${st}`;
|
|
952
|
+
return `<button type="button" class="${cls}" style="left:${left.toFixed(3)}%;width:${width.toFixed(3)}%" data-gantt-span="${x.i}" data-tip="${escapeHtml(tip)}"></button>`;
|
|
953
|
+
}).join('');
|
|
954
|
+
const realSpan = (timed[timed.length - 1].start + timed[timed.length - 1].dur) - timed[0].start;
|
|
955
|
+
return `<section class="block gantt-block">
|
|
956
|
+
<div class="turnrail-head"><h3>${escapeHtml(t('insights.gantt'))}</h3><span class="gantt-cap">${escapeHtml(t('insights.ganttCaption', { span: timed.length, dur: fmtMs(realSpan), active: fmtMs(active) }))}</span></div>
|
|
957
|
+
<div class="gantt"><div class="gtrack">${bars}</div></div>
|
|
958
|
+
</section>`;
|
|
959
|
+
}
|
|
960
|
+
// 上下文曲线 — context-pressure line. Plots codex's per-turn `context.contextTokens` (input + cache
|
|
961
|
+
// read + cache create = the size pushed into the model that turn) so the climb-then-drop at a
|
|
962
|
+
// compaction is visible from the curve shape. Backend-optional: only CLIs with usage carry context,
|
|
963
|
+
// and only on detail=spans — so this whole block hides when there are <2 points. No explicit
|
|
964
|
+
// turn-level compaction markers (codex: only a session-level count exists, don't fake per-turn ones).
|
|
965
|
+
function renderContextCurve(report) {
|
|
966
|
+
const pts = (report.turnTimeline ?? [])
|
|
967
|
+
.map(tn => (tn.context && Number.isFinite(tn.context.contextTokens)) ? { turn: tn.turnIndex, v: tn.context.contextTokens } : null)
|
|
968
|
+
.filter((p) => p !== null)
|
|
969
|
+
.sort((a, b) => a.turn - b.turn);
|
|
970
|
+
if (pts.length < 2)
|
|
971
|
+
return '';
|
|
972
|
+
const max = Math.max(...pts.map(p => p.v), 1);
|
|
973
|
+
const W = 100, H = 40, n = pts.length;
|
|
974
|
+
const xs = (i) => (i / (n - 1)) * W;
|
|
975
|
+
const ys = (v) => H - 1 - (v / max) * (H - 2);
|
|
976
|
+
const line = pts.map((p, i) => `${xs(i).toFixed(2)},${ys(p.v).toFixed(2)}`).join(' ');
|
|
977
|
+
const area = `0,${H} ${line} ${W},${H}`;
|
|
978
|
+
// Invisible full-height hover bands per point — hovering anywhere in a turn's x-slice shows that
|
|
979
|
+
// turn's exact context tokens via the shared tooltip (老滕: hover 要有具体数值).
|
|
980
|
+
const band = W / n;
|
|
981
|
+
const hits = pts.map((p, i) => `<rect class="ctxhit" x="${Math.max(0, xs(i) - band / 2).toFixed(2)}" y="0" width="${band.toFixed(2)}" height="${H}" data-tip="${escapeHtml(`${t('insights.ctxTurn', { n: p.turn })} · ${fmtInt(p.v)} tok`)}"></rect>`).join('');
|
|
982
|
+
const mid = fmtInt(Math.round(max / 2));
|
|
983
|
+
return `<section class="block ctxcurve-block">
|
|
984
|
+
<div class="turnrail-head"><h3>${escapeHtml(t('insights.ctxCurve'))}</h3><span class="gantt-cap">${escapeHtml(t('insights.ctxCaption', { peak: fmtInt(max), turns: pts.length }))}</span></div>
|
|
985
|
+
<div class="ctxchart">
|
|
986
|
+
<div class="ctxyaxis"><span>${escapeHtml(fmtInt(max))}</span><span>${escapeHtml(mid)}</span><span>0 tok</span></div>
|
|
987
|
+
<div class="ctxplot">
|
|
988
|
+
<svg class="ctxcurve" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="${escapeHtml(t('insights.ctxCurve'))}">
|
|
989
|
+
<polygon class="ctxarea" points="${area}"/>
|
|
990
|
+
<polyline class="ctxline" points="${line}"/>
|
|
991
|
+
${hits}
|
|
992
|
+
</svg>
|
|
993
|
+
<div class="ctxxaxis"><span>${escapeHtml(t('insights.ctxTurn', { n: pts[0].turn }))}</span><span>${escapeHtml(t('insights.ctxTurn', { n: pts[pts.length - 1].turn }))}</span></div>
|
|
994
|
+
</div>
|
|
995
|
+
</div>
|
|
996
|
+
</section>`;
|
|
997
|
+
}
|
|
998
|
+
// Shared hover tooltip: any [data-tip] element inside a bound host (rail / gantt / context curve)
|
|
999
|
+
// shows its text in the persistent #insight-tip box, tracking the cursor. Native `title` is too slow
|
|
1000
|
+
// and unreliable on the thin bars (老滕: 工作时序 hover 没提示).
|
|
1001
|
+
function bindTip(host, tipEl) {
|
|
1002
|
+
host.addEventListener('mousemove', e => {
|
|
1003
|
+
const el = e.target.closest('[data-tip]');
|
|
1004
|
+
if (!el) {
|
|
1005
|
+
tipEl.hidden = true;
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
tipEl.textContent = el.getAttribute('data-tip') || '';
|
|
1009
|
+
tipEl.hidden = false;
|
|
1010
|
+
const pad = 14;
|
|
1011
|
+
const r = tipEl.getBoundingClientRect();
|
|
1012
|
+
let x = e.clientX + pad, y = e.clientY + pad;
|
|
1013
|
+
if (x + r.width > window.innerWidth)
|
|
1014
|
+
x = e.clientX - r.width - pad;
|
|
1015
|
+
if (y + r.height > window.innerHeight)
|
|
1016
|
+
y = e.clientY - r.height - pad;
|
|
1017
|
+
tipEl.style.left = `${Math.max(4, x)}px`;
|
|
1018
|
+
tipEl.style.top = `${Math.max(4, y)}px`;
|
|
1019
|
+
});
|
|
1020
|
+
host.addEventListener('mouseleave', () => { tipEl.hidden = true; });
|
|
1021
|
+
}
|
|
1022
|
+
function renderDetailBody(report, view) {
|
|
1023
|
+
if (report.status !== 'ok') {
|
|
1024
|
+
return `<p class="mut">${escapeHtml(safeStatus(report))}</p>`;
|
|
1025
|
+
}
|
|
1026
|
+
const a = report.agg;
|
|
1027
|
+
const focus = focusSets(report, view.activeId);
|
|
1028
|
+
const recByTurn = new Map();
|
|
1029
|
+
for (const r of report.recommendations ?? [])
|
|
1030
|
+
for (const ti of r.evidence?.turnIndexes ?? []) {
|
|
1031
|
+
const arr = recByTurn.get(ti) ?? [];
|
|
1032
|
+
if (!arr.includes(r.id))
|
|
1033
|
+
arr.push(r.id);
|
|
1034
|
+
recByTurn.set(ti, arr);
|
|
1035
|
+
}
|
|
1036
|
+
const meta = [
|
|
1037
|
+
report.meta?.asOf ? t('sessions.insightAsOf', { asOf: String(report.meta.asOf) }) : '',
|
|
1038
|
+
report.meta?.partial ? t('sessions.insightPartial') : '',
|
|
1039
|
+
report.meta?.capped ? t('sessions.insightCapped', { shown: String(report.meta.spansReturned ?? report.spans?.length ?? 0), total: String(report.meta.spansTotal ?? report.spans?.length ?? 0) }) : '',
|
|
1040
|
+
].filter(Boolean).join(' · ');
|
|
1041
|
+
const spanCount = report.spans?.length ?? 0;
|
|
1042
|
+
const turnTotal = report.turnTimeline?.length ?? 0;
|
|
1043
|
+
return `
|
|
1044
|
+
<div class="cards insight-detail-metrics">
|
|
1045
|
+
${renderMetric(t('insights.metricSpans'), fmtInt(a.totalSpans))}
|
|
1046
|
+
${renderMetric(t('insights.metricFailed'), fmtInt(a.failedSpans))}
|
|
1047
|
+
${renderMetric(t('insights.metricSlow'), fmtInt(a.slowSpans))}
|
|
1048
|
+
${renderMetric(t('insights.metricRw'), a.readWriteRatio === null ? '-' : a.readWriteRatio.toFixed(1))}
|
|
1049
|
+
</div>
|
|
1050
|
+
${meta ? `<p class="insight-meta">${escapeHtml(meta)}</p>` : ''}
|
|
1051
|
+
<section class="block recblock">
|
|
1052
|
+
<h3>${escapeHtml(t('insights.recommendations'))}</h3>
|
|
1053
|
+
${renderRecommendations(report, view.activeId)}
|
|
1054
|
+
</section>
|
|
1055
|
+
${renderTurnRail(report, focus, recByTurn)}
|
|
1056
|
+
${renderWorkGantt(report)}
|
|
1057
|
+
${renderContextCurve(report)}
|
|
1058
|
+
<div class="detailtabs">
|
|
1059
|
+
<div class="detailtabbar" role="tablist" aria-label="${escapeHtml(t('insights.detailTabs'))}">
|
|
1060
|
+
<button type="button" role="tab" data-tab="spans" class="${view.tab === 'spans' ? 'on' : ''}">${escapeHtml(t('insights.trace'))} <b>${spanCount}</b></button>
|
|
1061
|
+
<button type="button" role="tab" data-tab="ledger" class="${view.tab === 'ledger' ? 'on' : ''}">${escapeHtml(t('insights.ledger'))} <b>${turnTotal}</b></button>
|
|
1062
|
+
<button type="button" role="tab" data-tab="convo" class="${view.tab === 'convo' ? 'on' : ''}">${escapeHtml(t('insights.replay'))}</button>
|
|
1063
|
+
</div>
|
|
1064
|
+
<div class="detailtabbody">
|
|
1065
|
+
<div class="insight-tab-panel" data-panel="spans"${view.tab === 'spans' ? '' : ' hidden'}>${renderEvidence(report, focus, view.spanFilter, view.openSpans)}</div>
|
|
1066
|
+
<div class="insight-tab-panel" data-panel="ledger"${view.tab === 'ledger' ? '' : ' hidden'}>${renderTurnEfficiency(report, focus, view.openTurns, view.openSpans, view.ledgerSort, view.openPrompts, view.rawPrompts, view.ledgerSender)}</div>
|
|
1067
|
+
<div class="insight-tab-panel" data-panel="convo"${view.tab === 'convo' ? '' : ' hidden'}>${renderConvo(view.convo, recByTurn)}</div>
|
|
1068
|
+
</div>
|
|
1069
|
+
</div>`;
|
|
1070
|
+
}
|
|
1071
|
+
async function fetchDetail(sessionId) {
|
|
1072
|
+
const r = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/insight?detail=spans`, { cache: 'no-store' });
|
|
1073
|
+
const d = await r.json().catch(() => ({}));
|
|
1074
|
+
if (!r.ok || d?.ok === false)
|
|
1075
|
+
throw new Error(String(d?.error ?? r.status));
|
|
1076
|
+
return d.report;
|
|
1077
|
+
}
|
|
1078
|
+
export function renderInsightsPage(root) {
|
|
1079
|
+
let overviewData = null;
|
|
1080
|
+
let records = [];
|
|
1081
|
+
let filter = 'all';
|
|
1082
|
+
const cliFilter = new Set();
|
|
1083
|
+
let q = '';
|
|
1084
|
+
let selectedId = null;
|
|
1085
|
+
let activeRec = null;
|
|
1086
|
+
let detailReport = null;
|
|
1087
|
+
let detailTab = 'spans';
|
|
1088
|
+
let spanFilter = 'all';
|
|
1089
|
+
let openSpans = new Set();
|
|
1090
|
+
let openTurns = new Set();
|
|
1091
|
+
let ledgerSort = 'normal';
|
|
1092
|
+
let ledgerSender = 'all';
|
|
1093
|
+
let openPrompts = new Set();
|
|
1094
|
+
let rawPrompts = new Set();
|
|
1095
|
+
const newConvo = () => ({ messages: [], total: 0, hasMore: false, nextOffset: 0, loading: false, q: '', role: 'all', tag: 'all', openOps: new Set() });
|
|
1096
|
+
let convo = newConvo();
|
|
1097
|
+
let modalTurn = null;
|
|
1098
|
+
let modalRaw = false;
|
|
1099
|
+
let modalPrompt = null;
|
|
1100
|
+
let modalReq = 0;
|
|
1101
|
+
let disposed = false;
|
|
1102
|
+
root.innerHTML = `
|
|
1103
|
+
<section class="page insights-page">
|
|
1104
|
+
<div class="page-heading">
|
|
1105
|
+
<div>
|
|
1106
|
+
<p class="eyebrow">${escapeHtml(t('nav.insights'))}</p>
|
|
1107
|
+
<h1>${escapeHtml(t('insights.title'))}</h1>
|
|
1108
|
+
<p>${escapeHtml(t('insights.subtitle'))}</p>
|
|
1109
|
+
</div>
|
|
1110
|
+
<button type="button" id="insight-refresh" class="primary">${escapeHtml(t('insights.refresh'))}</button>
|
|
1111
|
+
</div>
|
|
1112
|
+
<form id="insight-filters" class="filters insights-filters">
|
|
1113
|
+
<input type="search" name="q" placeholder="${escapeHtml(t('insights.search'))}">
|
|
1114
|
+
<div class="segmented" role="group" aria-label="${escapeHtml(t('insights.filter'))}">
|
|
1115
|
+
<button type="button" data-filter="all">${escapeHtml(t('insights.filterAll'))}</button>
|
|
1116
|
+
<button type="button" data-filter="review">${escapeHtml(t('insights.filterReview'))}</button>
|
|
1117
|
+
<button type="button" data-filter="failed">${escapeHtml(t('insights.filterFailed'))}</button>
|
|
1118
|
+
<button type="button" data-filter="slow">${escapeHtml(t('insights.filterSlow'))}</button>
|
|
1119
|
+
</div>
|
|
1120
|
+
<div id="insight-cli-filter" class="spanfilter cli-filter" role="group" aria-label="${escapeHtml(t('insights.filter'))}"></div>
|
|
1121
|
+
</form>
|
|
1122
|
+
<div id="insight-status" class="insight-page-status"></div>
|
|
1123
|
+
<div id="insight-overview"></div>
|
|
1124
|
+
<div class="insight-workbench">
|
|
1125
|
+
<section class="insight-list-panel">
|
|
1126
|
+
<div class="insight-list-head"><h3>${escapeHtml(t('insights.sessions'))}</h3><span id="insight-list-subtitle"></span></div>
|
|
1127
|
+
<div id="insight-list"></div>
|
|
1128
|
+
</section>
|
|
1129
|
+
<div id="insight-detail">${renderDetailShell(undefined)}</div>
|
|
1130
|
+
</div>
|
|
1131
|
+
<div id="insight-modal" class="insight-modal" hidden></div>
|
|
1132
|
+
<div id="insight-tip" class="ins-tip" role="tooltip" hidden></div>
|
|
1133
|
+
</section>`;
|
|
1134
|
+
const status = root.querySelector('#insight-status');
|
|
1135
|
+
const overviewEl = root.querySelector('#insight-overview');
|
|
1136
|
+
const list = root.querySelector('#insight-list');
|
|
1137
|
+
const listSubtitle = root.querySelector('#insight-list-subtitle');
|
|
1138
|
+
const detail = root.querySelector('#insight-detail');
|
|
1139
|
+
const search = root.querySelector('input[name=q]');
|
|
1140
|
+
const refreshBtn = root.querySelector('#insight-refresh');
|
|
1141
|
+
const filterButtons = [...root.querySelectorAll('[data-filter]')];
|
|
1142
|
+
const cliFilterEl = root.querySelector('#insight-cli-filter');
|
|
1143
|
+
function currentRows() {
|
|
1144
|
+
return sortRecords(filterRecords(records, filter, q, cliFilter));
|
|
1145
|
+
}
|
|
1146
|
+
function paint() {
|
|
1147
|
+
if (disposed)
|
|
1148
|
+
return;
|
|
1149
|
+
// Faceted CLI chips reflect the severity+search set; drop stale CLI picks that
|
|
1150
|
+
// the other filters have emptied out so the list never gets stuck on nothing.
|
|
1151
|
+
const cliBase = filterRecords(records, filter, q);
|
|
1152
|
+
const present = new Set(cliBase.map(cliIdOf));
|
|
1153
|
+
for (const id of [...cliFilter])
|
|
1154
|
+
if (!present.has(id))
|
|
1155
|
+
cliFilter.delete(id);
|
|
1156
|
+
cliFilterEl.innerHTML = renderCliChips(cliBase, cliFilter);
|
|
1157
|
+
const rows = currentRows();
|
|
1158
|
+
filterButtons.forEach(btn => btn.classList.toggle('active', btn.dataset.filter === filter));
|
|
1159
|
+
overviewEl.innerHTML = overviewData ? renderOverview(aggregateRecords(rows)) : '';
|
|
1160
|
+
listSubtitle.textContent = t('insights.listCount', { shown: rows.length, total: records.length });
|
|
1161
|
+
list.innerHTML = renderSessionRows(rows, selectedId);
|
|
1162
|
+
const selected = rows.find(r => r.session.sessionId === selectedId) ?? records.find(r => r.session.sessionId === selectedId);
|
|
1163
|
+
if (!selectedId || !selected)
|
|
1164
|
+
detail.innerHTML = renderDetailShell(undefined);
|
|
1165
|
+
for (const btn of list.querySelectorAll('[data-session-id]')) {
|
|
1166
|
+
btn.onclick = () => void selectSession(btn.dataset.sessionId ?? '');
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
// Re-render the detail body in place (no refetch) after a focus/filter change.
|
|
1170
|
+
function paintDetailBody() {
|
|
1171
|
+
const body = detail.querySelector('#insight-detail-body');
|
|
1172
|
+
if (!body || !detailReport)
|
|
1173
|
+
return;
|
|
1174
|
+
// A full innerHTML replace recreates the scroll container, snapping the in-panel list back to
|
|
1175
|
+
// the top — which read as a page refresh (老滕). Preserve the active tab's list scroll (and
|
|
1176
|
+
// window scroll) across the re-render so toggling a 详情 drawer feels in-place.
|
|
1177
|
+
const sel = `.insight-tab-panel[data-panel="${detailTab}"] .spantable, .insight-tab-panel[data-panel="${detailTab}"] .turnlist, .insight-tab-panel[data-panel="${detailTab}"] .convothread`;
|
|
1178
|
+
const prevTop = body.querySelector(sel)?.scrollTop ?? 0;
|
|
1179
|
+
const winY = window.scrollY;
|
|
1180
|
+
body.innerHTML = renderDetailBody(detailReport, { activeId: activeRec, tab: detailTab, spanFilter, openSpans, openTurns, ledgerSort, openPrompts, rawPrompts, ledgerSender, convo });
|
|
1181
|
+
wireDetailBody(body);
|
|
1182
|
+
const next = body.querySelector(sel);
|
|
1183
|
+
if (next)
|
|
1184
|
+
next.scrollTop = prevTop;
|
|
1185
|
+
if (window.scrollY !== winY)
|
|
1186
|
+
window.scrollTo({ top: winY });
|
|
1187
|
+
}
|
|
1188
|
+
// 对话回放: fetch one page of the paginated conversation. reset=true replaces (filter/search
|
|
1189
|
+
// change), false appends (load-more). Filters/search go through codex's q/role/tag params.
|
|
1190
|
+
async function loadConvo(reset) {
|
|
1191
|
+
if (!selectedId || convo.loading)
|
|
1192
|
+
return;
|
|
1193
|
+
if (reset) {
|
|
1194
|
+
convo.messages = [];
|
|
1195
|
+
convo.nextOffset = 0;
|
|
1196
|
+
convo.hasMore = false;
|
|
1197
|
+
}
|
|
1198
|
+
convo.loading = true;
|
|
1199
|
+
if (detailTab === 'convo')
|
|
1200
|
+
paintDetailBody();
|
|
1201
|
+
const params = new URLSearchParams({ detail: 'conversation', offset: String(convo.nextOffset), limit: '40' });
|
|
1202
|
+
if (convo.q)
|
|
1203
|
+
params.set('q', convo.q);
|
|
1204
|
+
if (convo.role !== 'all')
|
|
1205
|
+
params.set('role', convo.role);
|
|
1206
|
+
if (convo.tag !== 'all')
|
|
1207
|
+
params.set('tag', convo.tag);
|
|
1208
|
+
const sid = selectedId;
|
|
1209
|
+
try {
|
|
1210
|
+
const r = await fetch(`/api/sessions/${encodeURIComponent(sid)}/insight?${params.toString()}`, { cache: 'no-store' });
|
|
1211
|
+
const d = await r.json().catch(() => ({}));
|
|
1212
|
+
if (disposed || sid !== selectedId)
|
|
1213
|
+
return;
|
|
1214
|
+
const c = d?.conversation;
|
|
1215
|
+
if (c) {
|
|
1216
|
+
convo.messages = reset ? (c.messages ?? []) : [...convo.messages, ...(c.messages ?? [])];
|
|
1217
|
+
convo.total = c.total ?? convo.messages.length;
|
|
1218
|
+
convo.hasMore = !!c.hasMore;
|
|
1219
|
+
convo.nextOffset = c.nextOffset ?? (convo.nextOffset + (c.messages?.length ?? 0));
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
catch { /* leave what we have */ }
|
|
1223
|
+
convo.loading = false;
|
|
1224
|
+
if (detailTab === 'convo')
|
|
1225
|
+
paintDetailBody();
|
|
1226
|
+
}
|
|
1227
|
+
// Full-text prompt modal — lives in the persistent page shell (#insight-modal), not in the
|
|
1228
|
+
// re-rendered detail body. Prompt text is fetched full on demand (老滕: 弹窗别截断) via the
|
|
1229
|
+
// per-turn endpoint, since the bulk timeline only carries a 400-char preview.
|
|
1230
|
+
function paintModal() {
|
|
1231
|
+
const m = root.querySelector('#insight-modal');
|
|
1232
|
+
if (!m)
|
|
1233
|
+
return;
|
|
1234
|
+
if (modalTurn === null) {
|
|
1235
|
+
m.hidden = true;
|
|
1236
|
+
m.innerHTML = '';
|
|
1237
|
+
document.body.classList.remove('insight-modal-open');
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
m.hidden = false;
|
|
1241
|
+
document.body.classList.add('insight-modal-open');
|
|
1242
|
+
m.innerHTML = modalPrompt
|
|
1243
|
+
? renderPromptModalInner(modalTurn, modalPrompt, modalRaw)
|
|
1244
|
+
: `<div class="modal-backdrop" data-modal-close></div><div class="modal-panel"><div class="modal-body"><p class="mut">${escapeHtml(t('insights.detailLoading'))}</p></div></div>`;
|
|
1245
|
+
m.querySelectorAll('[data-modal-close]').forEach(el => el.addEventListener('click', closeModal));
|
|
1246
|
+
m.querySelector('[data-modal-raw]')?.addEventListener('click', () => { modalRaw = !modalRaw; paintModal(); });
|
|
1247
|
+
}
|
|
1248
|
+
function closeModal() { modalTurn = null; modalRaw = false; modalPrompt = null; paintModal(); }
|
|
1249
|
+
async function openModal(turnIndex) {
|
|
1250
|
+
if (!selectedId)
|
|
1251
|
+
return;
|
|
1252
|
+
modalTurn = turnIndex;
|
|
1253
|
+
modalRaw = false;
|
|
1254
|
+
modalPrompt = null;
|
|
1255
|
+
const req = ++modalReq;
|
|
1256
|
+
const sid = selectedId;
|
|
1257
|
+
paintModal();
|
|
1258
|
+
try {
|
|
1259
|
+
const r = await fetch(`/api/sessions/${encodeURIComponent(sid)}/insight/turn/${turnIndex}?offset=0&limit=40000`, { cache: 'no-store' });
|
|
1260
|
+
const d = await r.json().catch(() => ({}));
|
|
1261
|
+
if (req !== modalReq || disposed)
|
|
1262
|
+
return;
|
|
1263
|
+
modalPrompt = d?.turn?.prompt ?? { text: '', truncated: false };
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
if (req !== modalReq)
|
|
1267
|
+
return;
|
|
1268
|
+
modalPrompt = { text: t('insights.unavailable'), truncated: false };
|
|
1269
|
+
}
|
|
1270
|
+
paintModal();
|
|
1271
|
+
}
|
|
1272
|
+
function wireDetailBody(body) {
|
|
1273
|
+
const bar = body.querySelector('.detailtabbar');
|
|
1274
|
+
bar?.addEventListener('click', e => {
|
|
1275
|
+
const btn = e.target.closest('button[data-tab]');
|
|
1276
|
+
if (!btn)
|
|
1277
|
+
return;
|
|
1278
|
+
detailTab = btn.dataset.tab || 'spans';
|
|
1279
|
+
bar.querySelectorAll('button[data-tab]').forEach(b => b.classList.toggle('on', b.dataset.tab === detailTab));
|
|
1280
|
+
body.querySelectorAll('.insight-tab-panel').forEach(p => { p.hidden = p.dataset.panel !== detailTab; });
|
|
1281
|
+
// 对话回放: load the first page lazily on first open.
|
|
1282
|
+
if (detailTab === 'convo' && !convo.messages.length && !convo.loading)
|
|
1283
|
+
void loadConvo(true);
|
|
1284
|
+
});
|
|
1285
|
+
// Recommendation cards .hot-light their evidence; clicking the active one clears.
|
|
1286
|
+
for (const btn of body.querySelectorAll('.reclist [data-rec]')) {
|
|
1287
|
+
btn.addEventListener('click', () => {
|
|
1288
|
+
const id = btn.dataset.rec || null;
|
|
1289
|
+
activeRec = activeRec === id ? null : id;
|
|
1290
|
+
paintDetailBody();
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
// 会话轨迹 nodes jump to that turn in the ledger: switch tab, expand it, scroll it into view.
|
|
1294
|
+
// Reset to linear sort + a sender filter that keeps the target turn visible (system turns are
|
|
1295
|
+
// hidden under the default 全部) so the jump never lands on a turn the ledger filtered away.
|
|
1296
|
+
for (const btn of body.querySelectorAll('.turnrail [data-rail-turn]')) {
|
|
1297
|
+
btn.addEventListener('click', () => {
|
|
1298
|
+
const i = Number(btn.dataset.railTurn);
|
|
1299
|
+
const tn = detailReport?.turnTimeline?.find(t => t.turnIndex === i);
|
|
1300
|
+
detailTab = 'ledger';
|
|
1301
|
+
ledgerSort = 'normal';
|
|
1302
|
+
if (tn && turnSenderKind(tn) === 'system')
|
|
1303
|
+
ledgerSender = 'system';
|
|
1304
|
+
openTurns.add(i);
|
|
1305
|
+
paintDetailBody();
|
|
1306
|
+
detail.querySelector(`.insight-tab-panel[data-panel="ledger"] [data-turn-card="${i}"]`)
|
|
1307
|
+
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1308
|
+
});
|
|
1309
|
+
}
|
|
1310
|
+
// 工作时序 bars open the span in the 动作 span tab (clearing any span filter that would hide it).
|
|
1311
|
+
for (const btn of body.querySelectorAll('.gantt [data-gantt-span]')) {
|
|
1312
|
+
btn.addEventListener('click', () => {
|
|
1313
|
+
const i = Number(btn.dataset.ganttSpan);
|
|
1314
|
+
detailTab = 'spans';
|
|
1315
|
+
spanFilter = 'all';
|
|
1316
|
+
openSpans.add(i);
|
|
1317
|
+
paintDetailBody();
|
|
1318
|
+
detail.querySelector(`.insight-tab-panel[data-panel="spans"] .sprow-line[data-span-idx="${i}"]`)
|
|
1319
|
+
?.closest('.spanrow')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
// Shared hover tooltips for the session-trace rail / work-timeline / context curve.
|
|
1323
|
+
const tipEl = root.querySelector('#insight-tip');
|
|
1324
|
+
if (tipEl)
|
|
1325
|
+
for (const host of body.querySelectorAll('.turnrail, .gtrack, .ctxcurve'))
|
|
1326
|
+
bindTip(host, tipEl);
|
|
1327
|
+
// span tab filter chips (全部/失败/慢/…).
|
|
1328
|
+
for (const btn of body.querySelectorAll('.spanfilter [data-spanfilter]')) {
|
|
1329
|
+
btn.addEventListener('click', () => { spanFilter = btn.dataset.spanfilter || 'all'; paintDetailBody(); });
|
|
1330
|
+
}
|
|
1331
|
+
// 详情 drawer toggles — the whole span header line is the target (老滕: 点 span 行即展开).
|
|
1332
|
+
for (const el of body.querySelectorAll('.sprow-line[data-span-idx]')) {
|
|
1333
|
+
const toggle = () => {
|
|
1334
|
+
const i = Number(el.dataset.spanIdx);
|
|
1335
|
+
if (openSpans.has(i))
|
|
1336
|
+
openSpans.delete(i);
|
|
1337
|
+
else
|
|
1338
|
+
openSpans.add(i);
|
|
1339
|
+
paintDetailBody();
|
|
1340
|
+
};
|
|
1341
|
+
el.addEventListener('click', toggle);
|
|
1342
|
+
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') {
|
|
1343
|
+
e.preventDefault();
|
|
1344
|
+
toggle();
|
|
1345
|
+
} });
|
|
1346
|
+
}
|
|
1347
|
+
// Per-turn expand/collapse in the timeline.
|
|
1348
|
+
for (const btn of body.querySelectorAll('.turn-expand-btn[data-turn]')) {
|
|
1349
|
+
btn.addEventListener('click', () => {
|
|
1350
|
+
const i = Number(btn.dataset.turn);
|
|
1351
|
+
if (openTurns.has(i))
|
|
1352
|
+
openTurns.delete(i);
|
|
1353
|
+
else
|
|
1354
|
+
openTurns.add(i);
|
|
1355
|
+
paintDetailBody();
|
|
1356
|
+
});
|
|
1357
|
+
}
|
|
1358
|
+
// 逐轮对账 sort toggle (正常排序 / 按建议分类).
|
|
1359
|
+
for (const btn of body.querySelectorAll('.ledgersort [data-ledgersort]')) {
|
|
1360
|
+
btn.addEventListener('click', () => { ledgerSort = btn.dataset.ledgersort === 'grouped' ? 'grouped' : 'normal'; paintDetailBody(); });
|
|
1361
|
+
}
|
|
1362
|
+
// 逐轮对账 发起人 filter (全部 / 人类 / a2a / 其他).
|
|
1363
|
+
for (const btn of body.querySelectorAll('.ledgersender [data-ledgersender]')) {
|
|
1364
|
+
btn.addEventListener('click', () => { ledgerSender = btn.dataset.ledgersender || 'all'; paintDetailBody(); });
|
|
1365
|
+
}
|
|
1366
|
+
// Prompt 展开/收起 + 渲染/原文 toggles per turn.
|
|
1367
|
+
for (const btn of body.querySelectorAll('.tp-toggle[data-prompt-expand]')) {
|
|
1368
|
+
btn.addEventListener('click', () => {
|
|
1369
|
+
const i = Number(btn.dataset.promptExpand);
|
|
1370
|
+
if (openPrompts.has(i))
|
|
1371
|
+
openPrompts.delete(i);
|
|
1372
|
+
else
|
|
1373
|
+
openPrompts.add(i);
|
|
1374
|
+
paintDetailBody();
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
for (const btn of body.querySelectorAll('.tp-toggle[data-prompt-raw]')) {
|
|
1378
|
+
btn.addEventListener('click', () => {
|
|
1379
|
+
const i = Number(btn.dataset.promptRaw);
|
|
1380
|
+
if (rawPrompts.has(i))
|
|
1381
|
+
rawPrompts.delete(i);
|
|
1382
|
+
else
|
|
1383
|
+
rawPrompts.add(i);
|
|
1384
|
+
paintDetailBody();
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
// Prompt 全文弹窗 (老滕: 做个弹窗看全文) — fetches the full text on demand.
|
|
1388
|
+
for (const btn of body.querySelectorAll('.tp-toggle[data-prompt-full]')) {
|
|
1389
|
+
btn.addEventListener('click', () => void openModal(Number(btn.dataset.promptFull)));
|
|
1390
|
+
}
|
|
1391
|
+
// 对话回放 controls + bubbles.
|
|
1392
|
+
const convoSearch = body.querySelector('.convo-search');
|
|
1393
|
+
convoSearch?.addEventListener('keydown', e => {
|
|
1394
|
+
if (e.key !== 'Enter')
|
|
1395
|
+
return;
|
|
1396
|
+
e.preventDefault();
|
|
1397
|
+
const v = convoSearch.value.trim();
|
|
1398
|
+
if (v === convo.q)
|
|
1399
|
+
return;
|
|
1400
|
+
convo.q = v;
|
|
1401
|
+
void loadConvo(true);
|
|
1402
|
+
});
|
|
1403
|
+
for (const btn of body.querySelectorAll('.convo-rolefilter [data-convo-role]')) {
|
|
1404
|
+
btn.addEventListener('click', () => { const k = btn.dataset.convoRole || 'all'; if (k === convo.role)
|
|
1405
|
+
return; convo.role = k; void loadConvo(true); });
|
|
1406
|
+
}
|
|
1407
|
+
for (const btn of body.querySelectorAll('.convo-tagfilter [data-convo-tag]')) {
|
|
1408
|
+
btn.addEventListener('click', () => { const k = btn.dataset.convoTag || 'all'; if (k === convo.tag)
|
|
1409
|
+
return; convo.tag = k; void loadConvo(true); });
|
|
1410
|
+
}
|
|
1411
|
+
body.querySelector('.convo-loadmore')?.addEventListener('click', () => void loadConvo(false));
|
|
1412
|
+
for (const btn of body.querySelectorAll('.cbub [data-convo-full]')) {
|
|
1413
|
+
btn.addEventListener('click', () => void openModal(Number(btn.dataset.convoFull)));
|
|
1414
|
+
}
|
|
1415
|
+
for (const el of body.querySelectorAll('.cop-line[data-convo-op]')) {
|
|
1416
|
+
const toggle = () => { const id = el.dataset.convoOp; if (convo.openOps.has(id))
|
|
1417
|
+
convo.openOps.delete(id);
|
|
1418
|
+
else
|
|
1419
|
+
convo.openOps.add(id); paintDetailBody(); };
|
|
1420
|
+
el.addEventListener('click', toggle);
|
|
1421
|
+
el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') {
|
|
1422
|
+
e.preventDefault();
|
|
1423
|
+
toggle();
|
|
1424
|
+
} });
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
async function selectSession(sessionId) {
|
|
1428
|
+
selectedId = sessionId;
|
|
1429
|
+
activeRec = null;
|
|
1430
|
+
detailTab = 'spans';
|
|
1431
|
+
spanFilter = 'all';
|
|
1432
|
+
openSpans = new Set();
|
|
1433
|
+
openTurns = new Set();
|
|
1434
|
+
ledgerSort = 'normal';
|
|
1435
|
+
ledgerSender = 'all';
|
|
1436
|
+
openPrompts = new Set();
|
|
1437
|
+
rawPrompts = new Set();
|
|
1438
|
+
convo = newConvo();
|
|
1439
|
+
modalTurn = null;
|
|
1440
|
+
modalRaw = false;
|
|
1441
|
+
modalPrompt = null;
|
|
1442
|
+
paintModal();
|
|
1443
|
+
detailReport = null;
|
|
1444
|
+
const rec = records.find(r => r.session.sessionId === sessionId);
|
|
1445
|
+
detail.innerHTML = renderDetailShell(rec);
|
|
1446
|
+
for (const btn of list.querySelectorAll('[data-session-id]')) {
|
|
1447
|
+
btn.classList.toggle('on', btn.dataset.sessionId === sessionId);
|
|
1448
|
+
}
|
|
1449
|
+
const body = detail.querySelector('#insight-detail-body');
|
|
1450
|
+
if (!body)
|
|
1451
|
+
return;
|
|
1452
|
+
try {
|
|
1453
|
+
const report = await fetchDetail(sessionId);
|
|
1454
|
+
if (!disposed && report) {
|
|
1455
|
+
detailReport = report;
|
|
1456
|
+
paintDetailBody();
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
catch (e) {
|
|
1460
|
+
if (!disposed)
|
|
1461
|
+
body.innerHTML = `<p class="mut">${escapeHtml(String(e))}</p>`;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
async function refresh() {
|
|
1465
|
+
refreshBtn.disabled = true;
|
|
1466
|
+
status.textContent = t('insights.loading');
|
|
1467
|
+
try {
|
|
1468
|
+
const r = await fetch('/api/insights/summary?limit=200', { cache: 'no-store' });
|
|
1469
|
+
const d = await r.json().catch(() => ({}));
|
|
1470
|
+
if (!r.ok || d?.ok === false || !d.overview) {
|
|
1471
|
+
overviewData = null;
|
|
1472
|
+
records = [];
|
|
1473
|
+
status.textContent = `${t('insights.unavailable')}: ${String(d?.error ?? r.status)}`;
|
|
1474
|
+
}
|
|
1475
|
+
else {
|
|
1476
|
+
overviewData = d.overview;
|
|
1477
|
+
records = overviewData.sessions.map(toRecord);
|
|
1478
|
+
status.textContent = t('insights.loaded', { count: overviewData.meta.analyzedSessions });
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
catch (e) {
|
|
1482
|
+
overviewData = null;
|
|
1483
|
+
records = [];
|
|
1484
|
+
status.textContent = `${t('insights.unavailable')}: ${String(e)}`;
|
|
1485
|
+
}
|
|
1486
|
+
if (disposed)
|
|
1487
|
+
return;
|
|
1488
|
+
if (selectedId && !records.some(r => r.session.sessionId === selectedId))
|
|
1489
|
+
selectedId = null;
|
|
1490
|
+
paint();
|
|
1491
|
+
refreshBtn.disabled = false;
|
|
1492
|
+
}
|
|
1493
|
+
search.oninput = () => { q = search.value; paint(); };
|
|
1494
|
+
refreshBtn.onclick = () => void refresh();
|
|
1495
|
+
for (const btn of filterButtons) {
|
|
1496
|
+
btn.onclick = () => {
|
|
1497
|
+
filter = btn.dataset.filter || 'all';
|
|
1498
|
+
paint();
|
|
1499
|
+
};
|
|
1500
|
+
}
|
|
1501
|
+
cliFilterEl.addEventListener('click', e => {
|
|
1502
|
+
const btn = e.target.closest('[data-clifilter]');
|
|
1503
|
+
if (!btn)
|
|
1504
|
+
return;
|
|
1505
|
+
const key = btn.dataset.clifilter || 'all';
|
|
1506
|
+
if (key === 'all')
|
|
1507
|
+
cliFilter.clear();
|
|
1508
|
+
else if (cliFilter.has(key))
|
|
1509
|
+
cliFilter.delete(key);
|
|
1510
|
+
else
|
|
1511
|
+
cliFilter.add(key);
|
|
1512
|
+
paint();
|
|
1513
|
+
});
|
|
1514
|
+
// Esc closes the full-text prompt modal.
|
|
1515
|
+
const onKey = (e) => { if (e.key === 'Escape' && modalTurn !== null)
|
|
1516
|
+
closeModal(); };
|
|
1517
|
+
document.addEventListener('keydown', onKey);
|
|
1518
|
+
void loadNameMaps().then(() => { if (!disposed)
|
|
1519
|
+
paint(); });
|
|
1520
|
+
void refresh();
|
|
1521
|
+
return () => { disposed = true; document.removeEventListener('keydown', onKey); document.body.classList.remove('insight-modal-open'); };
|
|
1522
|
+
}
|
|
1523
|
+
//# sourceMappingURL=insights.js.map
|