opencode-rag-plugin 1.18.2 → 1.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ReadMe.md +1 -1
- package/dist/content/reader.d.ts +2 -1
- package/dist/content/reader.js +9 -15
- package/dist/core/config.d.ts +28 -2
- package/dist/core/config.js +13 -1
- package/dist/core/exclude.d.ts +4 -0
- package/dist/core/exclude.js +45 -0
- package/dist/core/interfaces.d.ts +6 -0
- package/dist/embedder/factory.d.ts +10 -3
- package/dist/embedder/factory.js +38 -9
- package/dist/indexer/pipeline.js +24 -3
- package/dist/indexer/watch.js +4 -3
- package/dist/indexer.d.ts +1 -0
- package/dist/indexer.js +1 -0
- package/dist/plugin.js +15 -1
- package/dist/quirks/quirk-store.d.ts +15 -0
- package/dist/quirks/quirk-store.js +27 -0
- package/dist/vectorstore/lancedb.d.ts +33 -0
- package/dist/vectorstore/lancedb.js +169 -80
- package/dist/web/api.d.ts +2 -1
- package/dist/web/api.js +219 -3
- package/dist/web/pca.d.ts +7 -0
- package/dist/web/pca.js +87 -0
- package/dist/web/server.js +23 -8
- package/dist/web/static.d.ts +7 -2
- package/dist/web/static.js +31 -7
- package/dist/web/ui/assets/index-CJBvt6e0.js +3 -0
- package/dist/web/ui/assets/index-CKdp79Tw.css +1 -0
- package/dist/web/ui/assets/vendor-Dy7HKFCY.js +1 -0
- package/dist/web/ui/index.html +9 -1632
- package/package.json +10 -4
- package/dist/web/ui/app.css +0 -1
- package/dist/web/ui/github-dark.css +0 -118
- package/dist/web/ui/highlight.min.js +0 -1213
package/dist/web/ui/index.html
CHANGED
|
@@ -1,1638 +1,15 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en" class="dark">
|
|
3
3
|
<head>
|
|
4
|
-
<meta charset="UTF-8"
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0"
|
|
6
|
-
<title>OpenCodeRAG
|
|
7
|
-
<link rel="
|
|
8
|
-
<
|
|
9
|
-
<
|
|
10
|
-
<
|
|
11
|
-
.chart-svg text { font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
|
|
12
|
-
.chart-svg rect { transition: opacity 0.15s; }
|
|
13
|
-
.chart-svg rect:hover { opacity: 0.85; }
|
|
14
|
-
.chart-svg circle { transition: r 0.15s; }
|
|
15
|
-
.chart-svg circle:hover { r: 5; }
|
|
16
|
-
</style>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>OpenCodeRAG</title>
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🔎</text></svg>">
|
|
8
|
+
<script type="module" crossorigin src="/ui/assets/index-CJBvt6e0.js"></script>
|
|
9
|
+
<link rel="modulepreload" crossorigin href="/ui/assets/vendor-Dy7HKFCY.js">
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/ui/assets/index-CKdp79Tw.css">
|
|
17
11
|
</head>
|
|
18
|
-
<body class="h-screen flex flex-col overflow-hidden">
|
|
19
|
-
|
|
20
|
-
<header class="flex items-center gap-4 px-4 py-2 bg-slate-800 border-b border-slate-700 shrink-0">
|
|
21
|
-
<h1 class="text-lg font-bold text-brand-400">OpenCodeRAG</h1>
|
|
22
|
-
<nav class="flex gap-1">
|
|
23
|
-
<button id="nav-dashboard" class="nav-btn active px-3 py-1 rounded text-sm font-medium transition">Dashboard</button>
|
|
24
|
-
<button id="nav-chunks" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Chunks</button>
|
|
25
|
-
<button id="nav-files" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Files</button>
|
|
26
|
-
<button id="nav-evaluate" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Evaluate</button>
|
|
27
|
-
<button id="nav-quirks" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Quirks</button>
|
|
28
|
-
</nav>
|
|
29
|
-
<div class="flex-1"></div>
|
|
30
|
-
<div class="relative">
|
|
31
|
-
<input id="global-search" type="text" placeholder="Search chunks..."
|
|
32
|
-
class="w-64 px-3 py-1.5 bg-slate-900 border border-slate-600 rounded text-sm focus:outline-none focus:border-brand-400">
|
|
33
|
-
<div id="search-results" class="hidden absolute top-full mt-1 right-0 w-[480px] max-h-[400px] overflow-y-auto bg-slate-800 border border-slate-600 rounded shadow-xl z-50"></div>
|
|
34
|
-
</div>
|
|
35
|
-
</header>
|
|
36
|
-
|
|
37
|
-
<div class="flex flex-1 overflow-hidden">
|
|
38
|
-
<!-- Sidebar: File Tree -->
|
|
39
|
-
<aside id="sidebar" class="w-64 bg-slate-850 border-r border-slate-700 overflow-y-auto scrollbar-thin shrink-0" style="background:#0f172a;">
|
|
40
|
-
<div class="p-3">
|
|
41
|
-
<div class="flex items-center justify-between mb-2">
|
|
42
|
-
<h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Files</h2>
|
|
43
|
-
<span id="file-count" class="text-xs text-slate-500">0</span>
|
|
44
|
-
</div>
|
|
45
|
-
<input id="file-filter" type="text" placeholder="Filter files..."
|
|
46
|
-
class="w-full px-2 py-1 bg-slate-900 border border-slate-700 rounded text-xs mb-2 focus:outline-none focus:border-brand-400">
|
|
47
|
-
<div id="file-tree" class="text-sm"></div>
|
|
48
|
-
</div>
|
|
49
|
-
</aside>
|
|
50
|
-
|
|
51
|
-
<!-- Main Content -->
|
|
52
|
-
<main id="main-content" class="flex-1 overflow-hidden p-4">
|
|
53
|
-
<div id="view-dashboard" class="h-full overflow-y-auto scrollbar-thin"></div>
|
|
54
|
-
<div id="view-chunks" class="hidden h-full flex gap-4">
|
|
55
|
-
<div id="chunks-master" class="w-1/2 overflow-y-auto scrollbar-thin pr-1 border-r border-slate-700"></div>
|
|
56
|
-
<div id="chunks-detail" class="w-1/2 overflow-y-auto scrollbar-thin pl-1"></div>
|
|
57
|
-
</div>
|
|
58
|
-
<div id="view-files" class="h-full overflow-y-auto scrollbar-thin"></div>
|
|
59
|
-
<div id="view-evaluate" class="hidden h-full flex flex-col"></div>
|
|
60
|
-
<div id="view-quirks" class="hidden h-full overflow-y-auto scrollbar-thin"></div>
|
|
61
|
-
</main>
|
|
62
|
-
</div>
|
|
63
|
-
|
|
64
|
-
<script>
|
|
65
|
-
const API = {
|
|
66
|
-
async stats() { const r = await fetch('/api/stats'); return r.json(); },
|
|
67
|
-
async files() { const r = await fetch('/api/files'); return r.json(); },
|
|
68
|
-
async chunks(offset=0, limit=50, lang='', file='') {
|
|
69
|
-
const p = new URLSearchParams({offset, limit});
|
|
70
|
-
if (lang) p.set('lang', lang);
|
|
71
|
-
if (file) p.set('file', file);
|
|
72
|
-
const r = await fetch('/api/chunks?' + p); return r.json();
|
|
73
|
-
},
|
|
74
|
-
async chunk(id) { const r = await fetch('/api/chunks/' + encodeURIComponent(id)); return r.json(); },
|
|
75
|
-
async search(q, topK=20) {
|
|
76
|
-
const p = new URLSearchParams({q, topK});
|
|
77
|
-
const r = await fetch('/api/search?' + p); return r.json();
|
|
78
|
-
},
|
|
79
|
-
async compare(ids) {
|
|
80
|
-
const p = new URLSearchParams({ids: ids.join(',')});
|
|
81
|
-
const r = await fetch('/api/compare?' + p); return r.json();
|
|
82
|
-
},
|
|
83
|
-
async evalSessions() { const r = await fetch('/api/eval/sessions'); return r.json(); },
|
|
84
|
-
async evalSession(id) { const r = await fetch('/api/eval/sessions/' + encodeURIComponent(id)); return r.json(); },
|
|
85
|
-
async evalDeleteSession(id) { const r = await fetch('/api/eval/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); return r.json(); },
|
|
86
|
-
async evalCompare(a, b) { const p = new URLSearchParams({a, b}); const r = await fetch('/api/eval/compare?' + p); return r.json(); },
|
|
87
|
-
async evalAnalysis(id) { const r = await fetch('/api/eval/sessions/' + encodeURIComponent(id) + '/analysis'); return r.json(); },
|
|
88
|
-
async evalTokenCompare(a, b) { const p = new URLSearchParams({a, b}); const r = await fetch('/api/eval/token-compare?' + p); return r.json(); },
|
|
89
|
-
async evalProjectSavings(params) { const r = await fetch('/api/eval/project-savings', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(params) }); return r.json(); },
|
|
90
|
-
async quirks() { const r = await fetch('/api/quirks'); return r.json(); },
|
|
91
|
-
async quirkLint() { const r = await fetch('/api/quirks/lint'); return r.json(); },
|
|
92
|
-
async deleteQuirk(id) { const r = await fetch('/api/quirks/' + encodeURIComponent(id), { method: 'DELETE' }); return r.json(); },
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const state = {
|
|
96
|
-
currentView: 'dashboard',
|
|
97
|
-
selectedFile: null,
|
|
98
|
-
selectedLang: null,
|
|
99
|
-
selectedChunkId: null,
|
|
100
|
-
chunkOffset: 0,
|
|
101
|
-
chunkLimit: 50,
|
|
102
|
-
|
|
103
|
-
collapsedDirs: new Set(),
|
|
104
|
-
stats: null,
|
|
105
|
-
files: [],
|
|
106
|
-
currentChunks: [],
|
|
107
|
-
evalSelectedSessions: new Set(),
|
|
108
|
-
evalSessionDetail: null,
|
|
109
|
-
|
|
110
|
-
quirkTypeFilter: null,
|
|
111
|
-
};
|
|
112
|
-
|
|
113
|
-
function $(sel) { return document.querySelector(sel); }
|
|
114
|
-
function $$(sel) { return document.querySelectorAll(sel); }
|
|
115
|
-
|
|
116
|
-
function showView(name) {
|
|
117
|
-
state.currentView = name;
|
|
118
|
-
$$('#main-content > div').forEach(d => d.classList.add('hidden'));
|
|
119
|
-
const el = $(`#view-${name}`);
|
|
120
|
-
if (el) el.classList.remove('hidden');
|
|
121
|
-
$$('.nav-btn').forEach(b => b.classList.remove('active'));
|
|
122
|
-
const btn = $(`#nav-${name}`);
|
|
123
|
-
if (btn) btn.classList.add('active');
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function langColor(lang) {
|
|
127
|
-
const map = {
|
|
128
|
-
typescript: 'text-blue-400', javascript: 'text-yellow-400', python: 'text-green-400',
|
|
129
|
-
java: 'text-red-400', go: 'text-cyan-400', rust: 'text-orange-400', ruby: 'text-pink-400',
|
|
130
|
-
csharp: 'text-purple-400', cpp: 'text-indigo-400', c: 'text-gray-400', markdown: 'text-gray-300',
|
|
131
|
-
html: 'text-orange-300', css: 'text-blue-300', json: 'text-yellow-300', kotlin: 'text-purple-300',
|
|
132
|
-
swift: 'text-orange-400', tex: 'text-emerald-400', sql: 'text-cyan-300'
|
|
133
|
-
};
|
|
134
|
-
return map[lang] || 'text-slate-400';
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function langBadge(lang) {
|
|
138
|
-
return `<span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono ${langColor(lang)} bg-slate-800">${lang}</span>`;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function quirkTypeColor(quirkType) {
|
|
142
|
-
const map = {
|
|
143
|
-
gotcha: 'text-amber-400',
|
|
144
|
-
preference: 'text-emerald-400',
|
|
145
|
-
decision: 'text-sky-400',
|
|
146
|
-
'environment-constraint': 'text-rose-400',
|
|
147
|
-
};
|
|
148
|
-
return map[quirkType] || 'text-slate-400';
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function quirkTypeBadge(quirkType) {
|
|
152
|
-
const label = quirkType || 'general';
|
|
153
|
-
return `<span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono ${quirkTypeColor(quirkType)} bg-slate-800">${label}</span>`;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function truncate(s, len=80) {
|
|
157
|
-
if (!s) return '';
|
|
158
|
-
return s.length > len ? s.slice(0, len) + '...' : s;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function escapeHtml(s) {
|
|
162
|
-
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function highlightCode(el) {
|
|
166
|
-
el.querySelectorAll('pre code').forEach(b => {
|
|
167
|
-
b.className = b.className.replace(/language-\w+/g, '');
|
|
168
|
-
hljs.highlightElement(b);
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── SVG Chart Helpers ──────────────────────────────────────────────
|
|
173
|
-
|
|
174
|
-
const CHART_COLORS = {
|
|
175
|
-
input: '#3b82f6', // blue-500
|
|
176
|
-
output: '#a855f7', // purple-500
|
|
177
|
-
rag: '#06b6d4', // cyan-500
|
|
178
|
-
reasoning: '#f59e0b', // amber-500
|
|
179
|
-
cache: '#64748b', // slate-500
|
|
180
|
-
cost: '#22c55e', // green-500
|
|
181
|
-
time: '#f97316', // orange-500
|
|
182
|
-
sessionA: '#3b82f6',
|
|
183
|
-
sessionB: '#06b6d4',
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
function svgAttr(attrs) {
|
|
187
|
-
return Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(' ');
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function svgRect(x, y, w, h, fill, extra = '') {
|
|
191
|
-
return `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}" ${extra}/>`;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function svgText(x, y, text, extra = '') {
|
|
195
|
-
return `<text x="${x}" y="${y}" ${extra}>${escapeHtml(String(text))}</text>`;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function svgLine(x1, y1, x2, y2, extra = '') {
|
|
199
|
-
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" ${extra}/>`;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function svgCircle(cx, cy, r, extra = '') {
|
|
203
|
-
return `<circle cx="${cx}" cy="${cy}" r="${r}" ${extra}/>`;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
function svgPath(d, extra = '') {
|
|
207
|
-
return `<path d="${d}" ${extra}/>`;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
function svgDonutArc(cx, cy, r, startAngle, endAngle, fill, innerR) {
|
|
211
|
-
if (endAngle - startAngle >= 359.99) {
|
|
212
|
-
return svgCircle(cx, cy, r, `fill="none" stroke="${fill}" stroke-width="${r - (innerR || r * 0.6)}"`) +
|
|
213
|
-
svgCircle(cx, cy, innerR || r * 0.6, `fill="#0f172a"`);
|
|
214
|
-
}
|
|
215
|
-
const toRad = a => (a - 90) * Math.PI / 180;
|
|
216
|
-
const x1 = cx + r * Math.cos(toRad(startAngle));
|
|
217
|
-
const y1 = cy + r * Math.sin(toRad(startAngle));
|
|
218
|
-
const x2 = cx + r * Math.cos(toRad(endAngle));
|
|
219
|
-
const y2 = cy + r * Math.sin(toRad(endAngle));
|
|
220
|
-
const ix1 = cx + (innerR || r * 0.6) * Math.cos(toRad(endAngle));
|
|
221
|
-
const iy1 = cy + (innerR || r * 0.6) * Math.sin(toRad(endAngle));
|
|
222
|
-
const ix2 = cx + (innerR || r * 0.6) * Math.cos(toRad(startAngle));
|
|
223
|
-
const iy2 = cy + (innerR || r * 0.6) * Math.sin(toRad(startAngle));
|
|
224
|
-
const large = endAngle - startAngle > 180 ? 1 : 0;
|
|
225
|
-
const d = `M${x1},${y1} A${r},${r} 0 ${large} 1 ${x2},${y2} L${ix1},${iy1} A${innerR || r * 0.6},${innerR || r * 0.6} 0 ${large} 0 ${ix2},${iy2} Z`;
|
|
226
|
-
return svgPath(d, `fill="${fill}"`);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function chartLegend(items) {
|
|
230
|
-
return `<div class="flex flex-wrap gap-3 mt-2">${items.map(([label, color]) =>
|
|
231
|
-
`<div class="flex items-center gap-1.5 text-xs text-slate-400">
|
|
232
|
-
<span class="inline-block w-3 h-3 rounded-sm" style="background:${color}"></span>${escapeHtml(label)}
|
|
233
|
-
</div>`
|
|
234
|
-
).join('')}</div>`;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// ── Vertical Bar Chart: Session Comparison ─────────────────────────
|
|
238
|
-
|
|
239
|
-
function renderComparisonBars(a, b) {
|
|
240
|
-
const aName = truncate(a.title || a.sessionID.slice(0, 8), 14);
|
|
241
|
-
const bName = truncate(b.title || b.sessionID.slice(0, 8), 14);
|
|
242
|
-
|
|
243
|
-
const aIn = a.totalTokens.input + a.totalTokens.cacheRead;
|
|
244
|
-
const bIn = b.totalTokens.input + b.totalTokens.cacheRead;
|
|
245
|
-
const aTotalTokens = aIn + a.totalTokens.output + a.totalTokens.reasoning;
|
|
246
|
-
const bTotalTokens = bIn + b.totalTokens.output + b.totalTokens.reasoning;
|
|
247
|
-
|
|
248
|
-
const aRagToolCount = (a.toolCallCounts?.search_semantic || 0) + (a.toolCallCounts?.get_file_skeleton || 0) + (a.toolCallCounts?.find_usages || 0);
|
|
249
|
-
const bRagToolCount = (b.toolCallCounts?.search_semantic || 0) + (b.toolCallCounts?.get_file_skeleton || 0) + (b.toolCallCounts?.find_usages || 0);
|
|
250
|
-
|
|
251
|
-
const metrics = [
|
|
252
|
-
{ label: 'Total Tokens', aVal: aTotalTokens, bVal: bTotalTokens, color: CHART_COLORS.input, stacked: [
|
|
253
|
-
{ label: 'Input', color: CHART_COLORS.input, aFn: () => aIn - a.ragContextTokens, bFn: () => bIn - b.ragContextTokens },
|
|
254
|
-
{ label: 'RAG Ctx', color: CHART_COLORS.rag, aFn: () => a.ragContextTokens, bFn: () => b.ragContextTokens },
|
|
255
|
-
{ label: 'Output', color: CHART_COLORS.output, aFn: () => a.totalTokens.output, bFn: () => b.totalTokens.output },
|
|
256
|
-
{ label: 'Reasoning', color: CHART_COLORS.reasoning, aFn: () => a.totalTokens.reasoning, bFn: () => b.totalTokens.reasoning },
|
|
257
|
-
]},
|
|
258
|
-
{ label: 'Cost', aVal: a.totalCost, bVal: b.totalCost, color: CHART_COLORS.cost, format: formatCost },
|
|
259
|
-
{ label: 'Avg Response', aVal: a.avgResponseTimeMs || 0, bVal: b.avgResponseTimeMs || 0, color: CHART_COLORS.time, format: formatMs },
|
|
260
|
-
{ label: 'Messages', aVal: a.messageCount, bVal: b.messageCount, color: '#94a3b8' },
|
|
261
|
-
{ label: 'RAG Injections', aVal: a.ragContextCount, bVal: b.ragContextCount, color: CHART_COLORS.rag },
|
|
262
|
-
{ label: 'RAG Tool Calls', aVal: aRagToolCount, bVal: bRagToolCount, color: CHART_COLORS.rag },
|
|
263
|
-
];
|
|
264
|
-
|
|
265
|
-
const n = metrics.length;
|
|
266
|
-
const svgW = 700, svgH = 260;
|
|
267
|
-
const padL = 10, padR = 20, padTop = 30, padBot = 50;
|
|
268
|
-
const chartW = svgW - padL - padR;
|
|
269
|
-
const chartH = svgH - padTop - padBot;
|
|
270
|
-
const groupW = chartW / n;
|
|
271
|
-
const barW = Math.min(groupW * 0.3, 40);
|
|
272
|
-
const barGap = 4;
|
|
273
|
-
const baseline = padTop + chartH;
|
|
274
|
-
|
|
275
|
-
const maxGlobal = Math.max(...metrics.map(m => Math.max(m.aVal, m.bVal)), 1);
|
|
276
|
-
|
|
277
|
-
const gridLines = [0.25, 0.5, 0.75, 1].map(pct => {
|
|
278
|
-
const y = baseline - chartH * pct;
|
|
279
|
-
return svgLine(padL, y, svgW - padR, y, 'stroke="#1e293b" stroke-width="1"');
|
|
280
|
-
}).join('');
|
|
281
|
-
|
|
282
|
-
const axisLine = svgLine(padL, baseline, svgW - padR, baseline, 'stroke="#334155" stroke-width="1"');
|
|
283
|
-
|
|
284
|
-
const barGroups = metrics.map((m, i) => {
|
|
285
|
-
const cx = padL + groupW * i + groupW / 2;
|
|
286
|
-
const aBarX = cx - barW - barGap / 2;
|
|
287
|
-
const bBarX = cx + barGap / 2;
|
|
288
|
-
const scale = chartH / maxGlobal;
|
|
289
|
-
const aH = m.aVal * scale;
|
|
290
|
-
const bH = m.bVal * scale;
|
|
291
|
-
|
|
292
|
-
const fmt = m.format || formatTokens;
|
|
293
|
-
const aLabel = fmt(m.aVal);
|
|
294
|
-
const bLabel = fmt(m.bVal);
|
|
295
|
-
|
|
296
|
-
let bars;
|
|
297
|
-
if (m.stacked) {
|
|
298
|
-
const stackScale = chartH / maxGlobal;
|
|
299
|
-
let aStackY = baseline, bStackY = baseline;
|
|
300
|
-
const aStackBars = m.stacked.filter(s => s.aFn() > 0).map(s => {
|
|
301
|
-
const val = s.aFn();
|
|
302
|
-
const h = val * stackScale;
|
|
303
|
-
aStackY -= h;
|
|
304
|
-
return svgRect(aBarX, aStackY, barW, h, s.color, `rx="2"`);
|
|
305
|
-
}).join('');
|
|
306
|
-
const bStackBars = m.stacked.filter(s => s.bFn() > 0).map(s => {
|
|
307
|
-
const val = s.bFn();
|
|
308
|
-
const h = val * stackScale;
|
|
309
|
-
bStackY -= h;
|
|
310
|
-
return svgRect(bBarX, bStackY, barW, h, s.color, `rx="2"`);
|
|
311
|
-
}).join('');
|
|
312
|
-
bars = aStackBars + bStackBars;
|
|
313
|
-
} else {
|
|
314
|
-
bars = svgRect(aBarX, baseline - aH, barW, aH, m.color, `rx="2"`) +
|
|
315
|
-
svgRect(bBarX, baseline - bH, barW, bH, m.color === CHART_COLORS.cost ? '#16a34a' : m.color === CHART_COLORS.time ? '#ea580c' : CHART_COLORS.sessionB, `rx="2" opacity="0.7"`);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return `
|
|
319
|
-
${bars}
|
|
320
|
-
<text x="${cx}" y="${baseline + 14}" text-anchor="middle" fill="#94a3b8" font-size="9" font-family="inherit">${escapeHtml(m.label)}</text>
|
|
321
|
-
${m.aVal > 0 ? svgText(aBarX + barW / 2, baseline - aH - 4, aLabel, 'text-anchor="middle" fill="#94a3b8" font-size="8" font-family="inherit"') : ''}
|
|
322
|
-
${m.bVal > 0 ? svgText(bBarX + barW / 2, baseline - bH - 4, bLabel, 'text-anchor="middle" fill="#94a3b8" font-size="8" font-family="inherit"') : ''}
|
|
323
|
-
`;
|
|
324
|
-
}).join('');
|
|
325
|
-
|
|
326
|
-
return `
|
|
327
|
-
<div class="bg-slate-900 rounded-lg border border-slate-700 p-4 mb-4">
|
|
328
|
-
<h4 class="text-sm font-semibold text-white mb-3">Session Comparison</h4>
|
|
329
|
-
<svg width="100%" viewBox="0 0 ${svgW} ${svgH}" class="chart-svg">
|
|
330
|
-
${gridLines}${axisLine}${barGroups}
|
|
331
|
-
</svg>
|
|
332
|
-
<div class="flex gap-6 mt-1 justify-center">
|
|
333
|
-
${chartLegend([[aName, CHART_COLORS.sessionA], [bName, CHART_COLORS.sessionB]])}
|
|
334
|
-
</div>
|
|
335
|
-
</div>`;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// ── Donut Chart: Token Composition (Session Detail) ────────────────
|
|
339
|
-
|
|
340
|
-
function renderTokenDonut(s) {
|
|
341
|
-
const totalIn = s.totalTokens.input + s.totalTokens.cacheRead;
|
|
342
|
-
const segments = [
|
|
343
|
-
{ label: 'Input (non-RAG)', value: totalIn - s.ragContextTokens, color: CHART_COLORS.input },
|
|
344
|
-
{ label: 'RAG Context', value: s.ragContextTokens, color: CHART_COLORS.rag },
|
|
345
|
-
{ label: 'Output', value: s.totalTokens.output, color: CHART_COLORS.output },
|
|
346
|
-
{ label: 'Reasoning', value: s.totalTokens.reasoning, color: CHART_COLORS.reasoning },
|
|
347
|
-
{ label: 'Cache Read', value: s.totalTokens.cacheRead, color: CHART_COLORS.cache },
|
|
348
|
-
].filter(s => s.value > 0);
|
|
349
|
-
|
|
350
|
-
const total = segments.reduce((sum, s) => sum + s.value, 0) || 1;
|
|
351
|
-
const cx = 90, cy = 90, r = 75, innerR = 48;
|
|
352
|
-
|
|
353
|
-
let angle = 0;
|
|
354
|
-
const arcs = segments.map(seg => {
|
|
355
|
-
const sweep = (seg.value / total) * 360;
|
|
356
|
-
const arc = svgDonutArc(cx, cy, r, angle, angle + sweep, seg.color, innerR);
|
|
357
|
-
angle += sweep;
|
|
358
|
-
return arc;
|
|
359
|
-
}).join('');
|
|
360
|
-
|
|
361
|
-
const pctLabel = total >= 1000000 ? (total / 1000000).toFixed(1) + 'M' : total >= 1000 ? (total / 1000).toFixed(1) + 'k' : String(total);
|
|
362
|
-
|
|
363
|
-
return `
|
|
364
|
-
<div class="flex items-center gap-6 mb-4">
|
|
365
|
-
<div class="shrink-0">
|
|
366
|
-
<svg width="180" height="180" viewBox="0 0 180 180" class="chart-svg">
|
|
367
|
-
${arcs}
|
|
368
|
-
<text x="${cx}" y="${cy - 4}" text-anchor="middle" fill="white" font-size="16" font-weight="bold" font-family="inherit">${pctLabel}</text>
|
|
369
|
-
<text x="${cx}" y="${cy + 12}" text-anchor="middle" fill="#64748b" font-size="9" font-family="inherit">tokens</text>
|
|
370
|
-
</svg>
|
|
371
|
-
</div>
|
|
372
|
-
<div class="flex-1">
|
|
373
|
-
${chartLegend(segments.map(s => [`${s.label}: ${formatTokens(s.value)} (${((s.value / total) * 100).toFixed(1)}%)`, s.color]))}
|
|
374
|
-
</div>
|
|
375
|
-
</div>`;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
async function renderDashboard() {
|
|
380
|
-
const [stats, files] = await Promise.all([API.stats(), API.files()]);
|
|
381
|
-
state.stats = stats;
|
|
382
|
-
state.files = files;
|
|
383
|
-
renderFileTree(files);
|
|
384
|
-
|
|
385
|
-
const topLangs = stats.languages.slice(0, 8);
|
|
386
|
-
const maxCount = topLangs[0]?.count || 1;
|
|
387
|
-
|
|
388
|
-
const el = $('#view-dashboard');
|
|
389
|
-
el.innerHTML = `
|
|
390
|
-
<div class="grid grid-cols-4 gap-4 mb-6">
|
|
391
|
-
<div class="kpi-card p-4">
|
|
392
|
-
<div class="text-slate-400 text-xs mb-1">Total Chunks</div>
|
|
393
|
-
<div class="text-3xl font-bold text-white">${stats.totalChunks.toLocaleString()}</div>
|
|
394
|
-
</div>
|
|
395
|
-
<div class="kpi-card p-4">
|
|
396
|
-
<div class="text-slate-400 text-xs mb-1">Total Files</div>
|
|
397
|
-
<div class="text-3xl font-bold text-white">${stats.totalFiles.toLocaleString()}</div>
|
|
398
|
-
</div>
|
|
399
|
-
<div class="kpi-card p-4">
|
|
400
|
-
<div class="text-slate-400 text-xs mb-1">Languages</div>
|
|
401
|
-
<div class="text-3xl font-bold text-white">${stats.languages.length}</div>
|
|
402
|
-
</div>
|
|
403
|
-
<div class="kpi-card p-4">
|
|
404
|
-
<div class="text-slate-400 text-xs mb-1">Avg Chunks/File</div>
|
|
405
|
-
<div class="text-3xl font-bold text-white">${stats.totalFiles > 0 ? (stats.totalChunks / stats.totalFiles).toFixed(1) : '0'}</div>
|
|
406
|
-
</div>
|
|
407
|
-
</div>
|
|
408
|
-
<div class="kpi-card p-4">
|
|
409
|
-
<h3 class="text-sm font-semibold text-slate-300 mb-3">Language Distribution</h3>
|
|
410
|
-
<div class="space-y-2">
|
|
411
|
-
${topLangs.map(l => `
|
|
412
|
-
<div class="flex items-center gap-3">
|
|
413
|
-
<span class="w-24 text-xs text-slate-400 text-right">${l.language}</span>
|
|
414
|
-
<div class="flex-1 bg-slate-800 rounded-full h-5 overflow-hidden">
|
|
415
|
-
<div class="h-full rounded-full bg-brand-500 flex items-center pl-2"
|
|
416
|
-
style="width:${Math.max(8, (l.count / maxCount) * 100)}%">
|
|
417
|
-
<span class="text-xs font-medium text-white">${l.count}</span>
|
|
418
|
-
</div>
|
|
419
|
-
</div>
|
|
420
|
-
<span class="text-xs text-slate-500 w-12 text-right">${((l.count / stats.totalChunks) * 100).toFixed(0)}%</span>
|
|
421
|
-
</div>
|
|
422
|
-
`).join('')}
|
|
423
|
-
</div>
|
|
424
|
-
</div>
|
|
425
|
-
`;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
async function renderChunks() {
|
|
429
|
-
const data = await API.chunks(state.chunkOffset, state.chunkLimit, state.selectedLang || '', state.selectedFile || '');
|
|
430
|
-
state.currentChunks = data.chunks;
|
|
431
|
-
|
|
432
|
-
const totalPages = Math.ceil(data.total / state.chunkLimit);
|
|
433
|
-
const currentPage = Math.floor(state.chunkOffset / state.chunkLimit) + 1;
|
|
434
|
-
|
|
435
|
-
const master = $('#chunks-master');
|
|
436
|
-
const detail = $('#chunks-detail');
|
|
437
|
-
|
|
438
|
-
master.innerHTML = `
|
|
439
|
-
<div class="flex items-center gap-3 mb-3">
|
|
440
|
-
<h2 class="text-lg font-semibold text-white">Chunks</h2>
|
|
441
|
-
<span class="text-sm text-slate-400">${data.total} total</span>
|
|
442
|
-
${state.selectedLang ? `<span class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300">${state.selectedLang} <button id="clear-lang" class="ml-1 text-slate-500 hover:text-white">×</button></span>` : ''}
|
|
443
|
-
${state.selectedFile ? `<span class="text-xs px-2 py-1 rounded bg-slate-700 text-slate-300">${truncate(state.selectedFile, 30)} <button id="clear-file" class="ml-1 text-slate-500 hover:text-white">×</button></span>` : ''}
|
|
444
|
-
</div>
|
|
445
|
-
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
446
|
-
<table class="w-full text-sm">
|
|
447
|
-
<thead>
|
|
448
|
-
<tr class="bg-slate-800 text-slate-400 text-xs">
|
|
449
|
-
<th class="px-3 py-2 text-left">File</th>
|
|
450
|
-
<th class="px-3 py-2 text-left w-20">Lang</th>
|
|
451
|
-
<th class="px-3 py-2 text-left">Description</th>
|
|
452
|
-
</tr>
|
|
453
|
-
</thead>
|
|
454
|
-
<tbody>
|
|
455
|
-
${data.chunks.map((c, i) => {
|
|
456
|
-
const id = c.id || `chunk-${i}`;
|
|
457
|
-
const selected = state.selectedChunkId === id ? ' selected' : '';
|
|
458
|
-
return `
|
|
459
|
-
<tr class="chunk-row${selected} border-t border-slate-800 cursor-pointer" data-id="${id}" data-index="${i}">
|
|
460
|
-
<td class="px-3 py-2 text-yellow-400 font-mono text-xs">${truncate(c.filePath, 40)}:${c.startLine}-${c.endLine}</td>
|
|
461
|
-
<td class="px-3 py-2">${langBadge(c.language)}</td>
|
|
462
|
-
<td class="px-3 py-2 text-slate-400 text-xs">${truncate(c.description || '', 50)}</td>
|
|
463
|
-
</tr>`;
|
|
464
|
-
}).join('')}
|
|
465
|
-
</tbody>
|
|
466
|
-
</table>
|
|
467
|
-
</div>
|
|
468
|
-
<div class="flex items-center justify-between mt-3">
|
|
469
|
-
<button id="prev-page" class="px-3 py-1 bg-slate-700 rounded text-sm hover:bg-slate-600 disabled:opacity-50" ${currentPage <= 1 ? 'disabled' : ''}>Previous</button>
|
|
470
|
-
<span class="text-sm text-slate-400">Page ${currentPage} of ${totalPages}</span>
|
|
471
|
-
<button id="next-page" class="px-3 py-1 bg-slate-700 rounded text-sm hover:bg-slate-600 disabled:opacity-50" ${currentPage >= totalPages ? 'disabled' : ''}>Next</button>
|
|
472
|
-
</div>
|
|
473
|
-
`;
|
|
474
|
-
|
|
475
|
-
// Row click -> select and show detail
|
|
476
|
-
$$('.chunk-row').forEach(row => {
|
|
477
|
-
row.addEventListener('click', (e) => {
|
|
478
|
-
const id = row.dataset.id;
|
|
479
|
-
const idx = parseInt(row.dataset.index, 10);
|
|
480
|
-
if (!id) return;
|
|
481
|
-
state.selectedChunkId = id;
|
|
482
|
-
$$('.chunk-row').forEach(r => r.classList.remove('selected'));
|
|
483
|
-
row.classList.add('selected');
|
|
484
|
-
renderChunkDetail(data.chunks[idx]);
|
|
485
|
-
});
|
|
486
|
-
});
|
|
487
|
-
|
|
488
|
-
const prevBtn = $('#prev-page');
|
|
489
|
-
const nextBtn = $('#next-page');
|
|
490
|
-
if (prevBtn) prevBtn.addEventListener('click', () => { state.chunkOffset = Math.max(0, state.chunkOffset - state.chunkLimit); renderChunks(); });
|
|
491
|
-
if (nextBtn) nextBtn.addEventListener('click', () => { state.chunkOffset += state.chunkLimit; renderChunks(); });
|
|
492
|
-
|
|
493
|
-
const clearLang = $('#clear-lang');
|
|
494
|
-
const clearFile = $('#clear-file');
|
|
495
|
-
if (clearLang) clearLang.addEventListener('click', () => { state.selectedLang = null; state.chunkOffset = 0; renderChunks(); });
|
|
496
|
-
if (clearFile) clearFile.addEventListener('click', () => { state.selectedFile = null; state.chunkOffset = 0; renderChunks(); });
|
|
497
|
-
|
|
498
|
-
// Auto-select first chunk if none selected or current selection not in page
|
|
499
|
-
if (data.chunks.length > 0) {
|
|
500
|
-
const inPage = data.chunks.some(c => c.id === state.selectedChunkId);
|
|
501
|
-
if (!inPage) {
|
|
502
|
-
state.selectedChunkId = data.chunks[0].id;
|
|
503
|
-
renderChunkDetail(data.chunks[0]);
|
|
504
|
-
$$('.chunk-row').forEach(r => r.classList.remove('selected'));
|
|
505
|
-
const first = $('.chunk-row');
|
|
506
|
-
if (first) first.classList.add('selected');
|
|
507
|
-
}
|
|
508
|
-
} else {
|
|
509
|
-
detail.innerHTML = `
|
|
510
|
-
<div class="flex items-center justify-center h-full text-slate-500">
|
|
511
|
-
<div class="text-center">
|
|
512
|
-
<div class="text-4xl mb-2">🔍</div>
|
|
513
|
-
<div>No chunks found</div>
|
|
514
|
-
</div>
|
|
515
|
-
</div>`;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
function renderChunkDetail(c) {
|
|
520
|
-
const detail = $('#chunks-detail');
|
|
521
|
-
if (!detail) return;
|
|
522
|
-
|
|
523
|
-
const isImage = c.language === 'image';
|
|
524
|
-
|
|
525
|
-
let imageHtml = '';
|
|
526
|
-
if (isImage) {
|
|
527
|
-
const imgUrl = `/api/file?path=${encodeURIComponent(c.filePath)}`;
|
|
528
|
-
imageHtml = `
|
|
529
|
-
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden mb-3">
|
|
530
|
-
<div class="px-3 py-1.5 bg-slate-800 border-b border-slate-700 flex items-center justify-between">
|
|
531
|
-
<span class="text-xs text-slate-400">Image Preview</span>
|
|
532
|
-
</div>
|
|
533
|
-
<div class="p-3 flex items-center justify-center bg-slate-950" id="image-container-${c.id}">
|
|
534
|
-
<img src="${imgUrl}" alt="${escapeHtml(c.filePath)}" class="max-w-full max-h-[60vh] object-contain rounded"
|
|
535
|
-
onerror="this.closest('.bg-slate-950').innerHTML='<span class=\\'text-slate-500 text-sm\\'>Image not available</span>'">
|
|
536
|
-
</div>
|
|
537
|
-
</div>`;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
detail.innerHTML = `
|
|
541
|
-
<div class="mb-3">
|
|
542
|
-
<div class="flex items-center gap-3 mb-1">
|
|
543
|
-
<span class="text-yellow-400 font-mono text-sm">${c.filePath}</span>
|
|
544
|
-
</div>
|
|
545
|
-
<div class="flex items-center gap-3 text-sm text-slate-400">
|
|
546
|
-
<span>Lines ${c.startLine}-${c.endLine}</span>
|
|
547
|
-
${langBadge(c.language)}
|
|
548
|
-
${c.id ? `<span class="text-xs text-slate-600 font-mono">${c.id}</span>` : ''}
|
|
549
|
-
</div>
|
|
550
|
-
</div>
|
|
551
|
-
${c.description ? `
|
|
552
|
-
<div class="kpi-card p-3 mb-3">
|
|
553
|
-
<h3 class="text-xs font-semibold text-slate-400 mb-1">Description</h3>
|
|
554
|
-
<p class="text-sm text-slate-300">${c.description}</p>
|
|
555
|
-
</div>` : ''}
|
|
556
|
-
${imageHtml}
|
|
557
|
-
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
558
|
-
<div class="px-3 py-1.5 bg-slate-800 border-b border-slate-700 flex items-center justify-between">
|
|
559
|
-
<span class="text-xs text-slate-400">${isImage ? 'Vision Analysis' : 'Source Code'}</span>
|
|
560
|
-
<button class="copy-btn text-xs text-slate-500 hover:text-white">Copy</button>
|
|
561
|
-
</div>
|
|
562
|
-
<pre class="p-3 overflow-x-auto text-sm max-h-[calc(100vh-220px)]"><code class="language-${isImage ? 'text' : c.language}">${escapeHtml(c.content || '')}</code></pre>
|
|
563
|
-
</div>
|
|
564
|
-
`;
|
|
565
|
-
|
|
566
|
-
detail.querySelectorAll('.copy-btn').forEach(btn => {
|
|
567
|
-
btn.addEventListener('click', () => {
|
|
568
|
-
navigator.clipboard.writeText(c.content || '');
|
|
569
|
-
btn.textContent = 'Copied!';
|
|
570
|
-
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
|
571
|
-
});
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
highlightCode(detail);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
function renderFileTree(files) {
|
|
578
|
-
const tree = $('#file-tree');
|
|
579
|
-
const filter = $('#file-filter')?.value?.toLowerCase() || '';
|
|
580
|
-
|
|
581
|
-
const filtered = filter
|
|
582
|
-
? files.filter(f => f.filePath.toLowerCase().includes(filter))
|
|
583
|
-
: files;
|
|
584
|
-
|
|
585
|
-
$('#file-count').textContent = filtered.length;
|
|
586
|
-
|
|
587
|
-
const dirs = {};
|
|
588
|
-
for (const f of filtered) {
|
|
589
|
-
const parts = f.filePath.split('/');
|
|
590
|
-
let current = dirs;
|
|
591
|
-
for (let i = 0; i < parts.length - 1; i++) {
|
|
592
|
-
if (!current[parts[i]]) current[parts[i]] = {};
|
|
593
|
-
current = current[parts[i]];
|
|
594
|
-
}
|
|
595
|
-
if (!current.__files) current.__files = [];
|
|
596
|
-
current.__files.push(f);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function renderDir(obj, depth=0, parentPath='') {
|
|
600
|
-
let html = '';
|
|
601
|
-
const entries = Object.entries(obj).filter(([k]) => k !== '__files').sort(([a], [b]) => a.localeCompare(b));
|
|
602
|
-
const files = obj.__files || [];
|
|
603
|
-
|
|
604
|
-
for (const [name, children] of entries) {
|
|
605
|
-
const dirPath = parentPath ? `${parentPath}/${name}` : name;
|
|
606
|
-
const childCount = countFiles(children);
|
|
607
|
-
const isCollapsed = state.collapsedDirs.has(dirPath);
|
|
608
|
-
const arrow = isCollapsed ? '▸' : '▾';
|
|
609
|
-
html += `<div class="file-item flex items-center gap-1 py-0.5 px-2 rounded cursor-pointer text-slate-400 hover:text-white" style="padding-left:${depth * 12 + 8}px" data-dir-key="${dirPath}">
|
|
610
|
-
<span class="text-xs">${arrow}</span>
|
|
611
|
-
<span class="text-xs">📁</span>
|
|
612
|
-
<span class="text-xs">${name}</span>
|
|
613
|
-
<span class="text-xs text-slate-600 ml-auto">${childCount}</span>
|
|
614
|
-
</div>`;
|
|
615
|
-
const hidden = isCollapsed ? ' style="display:none"' : '';
|
|
616
|
-
html += `<div class="dir-children" data-dir-parent="${dirPath}"${hidden}>`;
|
|
617
|
-
html += renderDir(children, depth + 1, dirPath);
|
|
618
|
-
html += '</div>';
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
for (const f of files) {
|
|
622
|
-
const name = f.filePath.split('/').pop();
|
|
623
|
-
const active = state.selectedFile === f.filePath ? 'active text-white' : 'text-slate-400 hover:text-white';
|
|
624
|
-
html += `<div class="file-item flex items-center gap-1 py-0.5 px-2 rounded cursor-pointer ${active}" style="padding-left:${depth * 12 + 8}px" data-file="${f.filePath}">
|
|
625
|
-
<span class="text-xs ${langColor(f.language)}">◆</span>
|
|
626
|
-
<span class="text-xs truncate">${name}</span>
|
|
627
|
-
<span class="text-xs text-slate-600 ml-auto">${f.chunkCount}</span>
|
|
628
|
-
</div>`;
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
return html;
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
function countFiles(obj) {
|
|
635
|
-
let count = (obj.__files || []).length;
|
|
636
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
637
|
-
if (k !== '__files') count += countFiles(v);
|
|
638
|
-
}
|
|
639
|
-
return count;
|
|
640
|
-
}
|
|
641
|
-
|
|
642
|
-
tree.innerHTML = renderDir(dirs);
|
|
643
|
-
|
|
644
|
-
$$('.file-item[data-dir-key]').forEach(el => {
|
|
645
|
-
el.addEventListener('click', () => {
|
|
646
|
-
const dirKey = el.dataset.dirKey;
|
|
647
|
-
if (state.collapsedDirs.has(dirKey)) {
|
|
648
|
-
state.collapsedDirs.delete(dirKey);
|
|
649
|
-
} else {
|
|
650
|
-
state.collapsedDirs.add(dirKey);
|
|
651
|
-
}
|
|
652
|
-
renderFileTree(files);
|
|
653
|
-
});
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
$$('.file-item[data-file]').forEach(el => {
|
|
657
|
-
el.addEventListener('click', () => {
|
|
658
|
-
state.selectedFile = el.dataset.file;
|
|
659
|
-
state.selectedChunkId = null;
|
|
660
|
-
showView('chunks');
|
|
661
|
-
state.chunkOffset = 0;
|
|
662
|
-
renderChunks();
|
|
663
|
-
renderFileTree(files);
|
|
664
|
-
});
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
async function renderFiles() {
|
|
669
|
-
if (!state.files.length) {
|
|
670
|
-
const files = await API.files();
|
|
671
|
-
state.files = files;
|
|
672
|
-
renderFileTree(files);
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
const el = $('#view-files');
|
|
676
|
-
const filter = $('#file-filter')?.value?.toLowerCase() || '';
|
|
677
|
-
const filtered = filter
|
|
678
|
-
? state.files.filter(f => f.filePath.toLowerCase().includes(filter))
|
|
679
|
-
: state.files;
|
|
680
|
-
|
|
681
|
-
const totalChunks = filtered.reduce((s, f) => s + f.chunkCount, 0);
|
|
682
|
-
|
|
683
|
-
el.innerHTML = `
|
|
684
|
-
<div class="mb-4">
|
|
685
|
-
<div class="flex items-center gap-3 mb-3">
|
|
686
|
-
<h2 class="text-lg font-semibold text-white">Files</h2>
|
|
687
|
-
<span class="text-sm text-slate-400">${filtered.length} files, ${totalChunks} chunks</span>
|
|
688
|
-
</div>
|
|
689
|
-
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
690
|
-
<table class="w-full text-sm">
|
|
691
|
-
<thead>
|
|
692
|
-
<tr class="bg-slate-800 text-slate-400 text-xs">
|
|
693
|
-
<th class="px-3 py-2 text-left">File</th>
|
|
694
|
-
<th class="px-3 py-2 text-left w-28">Language</th>
|
|
695
|
-
<th class="px-3 py-2 text-left w-20">Chunks</th>
|
|
696
|
-
<th class="px-3 py-2 text-left w-20"></th>
|
|
697
|
-
</tr>
|
|
698
|
-
</thead>
|
|
699
|
-
<tbody>
|
|
700
|
-
${filtered.map(f => `
|
|
701
|
-
<tr class="border-t border-slate-800 hover:bg-slate-800 cursor-pointer file-row" data-file="${f.filePath}">
|
|
702
|
-
<td class="px-3 py-2 text-yellow-400 font-mono text-xs">${f.filePath}</td>
|
|
703
|
-
<td class="px-3 py-2">${langBadge(f.language)}</td>
|
|
704
|
-
<td class="px-3 py-2 text-slate-300">${f.chunkCount}</td>
|
|
705
|
-
<td class="px-3 py-2 text-slate-500 text-xs">
|
|
706
|
-
<button class="hover:text-white" data-action="view-chunks" data-file="${f.filePath}">View chunks</button>
|
|
707
|
-
</td>
|
|
708
|
-
</tr>
|
|
709
|
-
`).join('')}
|
|
710
|
-
</tbody>
|
|
711
|
-
</table>
|
|
712
|
-
</div>
|
|
713
|
-
</div>
|
|
714
|
-
`;
|
|
715
|
-
|
|
716
|
-
$$('.file-row').forEach(row => {
|
|
717
|
-
row.addEventListener('click', (e) => {
|
|
718
|
-
if (e.target.dataset.action === 'view-chunks') {
|
|
719
|
-
e.stopPropagation();
|
|
720
|
-
}
|
|
721
|
-
state.selectedFile = row.dataset.file;
|
|
722
|
-
state.selectedChunkId = null;
|
|
723
|
-
state.chunkOffset = 0;
|
|
724
|
-
showView('chunks');
|
|
725
|
-
renderChunks();
|
|
726
|
-
renderFileTree(state.files);
|
|
727
|
-
});
|
|
728
|
-
});
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
async function selectChunkFromSearch(id) {
|
|
734
|
-
showView('chunks');
|
|
735
|
-
state.selectedChunkId = id;
|
|
736
|
-
state.chunkOffset = 0;
|
|
737
|
-
state.selectedLang = null;
|
|
738
|
-
state.selectedFile = null;
|
|
739
|
-
await renderChunks();
|
|
740
|
-
|
|
741
|
-
// Scroll to selected row and render detail
|
|
742
|
-
const row = $(`.chunk-row[data-id="${id}"]`);
|
|
743
|
-
if (row) {
|
|
744
|
-
$$('.chunk-row').forEach(r => r.classList.remove('selected'));
|
|
745
|
-
row.classList.add('selected');
|
|
746
|
-
row.scrollIntoView({ block: 'center' });
|
|
747
|
-
const idx = parseInt(row.dataset.index, 10);
|
|
748
|
-
if (!isNaN(idx) && state.currentChunks[idx]) {
|
|
749
|
-
renderChunkDetail(state.currentChunks[idx]);
|
|
750
|
-
}
|
|
751
|
-
} else {
|
|
752
|
-
// Chunk not on this page, fetch it directly
|
|
753
|
-
const chunk = await API.chunk(id);
|
|
754
|
-
if (chunk && !chunk.error) {
|
|
755
|
-
renderChunkDetail(chunk);
|
|
756
|
-
}
|
|
757
|
-
}
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
// Global search
|
|
761
|
-
let searchTimeout;
|
|
762
|
-
$('#global-search')?.addEventListener('input', (e) => {
|
|
763
|
-
clearTimeout(searchTimeout);
|
|
764
|
-
const q = e.target.value.trim();
|
|
765
|
-
const box = $('#search-results');
|
|
766
|
-
|
|
767
|
-
if (!q) { box.classList.add('hidden'); return; }
|
|
768
|
-
|
|
769
|
-
searchTimeout = setTimeout(async () => {
|
|
770
|
-
const data = await API.search(q, 10);
|
|
771
|
-
if (!data.results?.length) {
|
|
772
|
-
box.innerHTML = '<div class="p-3 text-sm text-slate-500">No results</div>';
|
|
773
|
-
} else {
|
|
774
|
-
box.innerHTML = data.results.map(r => `
|
|
775
|
-
<div class="search-result p-2 hover:bg-slate-700 cursor-pointer border-b border-slate-700 last:border-0" data-id="${r.chunk.id}">
|
|
776
|
-
<div class="flex items-center gap-2 text-xs">
|
|
777
|
-
<span class="text-yellow-400 font-mono">${truncate(r.chunk.filePath, 35)}:${r.chunk.startLine}-${r.chunk.endLine}</span>
|
|
778
|
-
${langBadge(r.chunk.language)}
|
|
779
|
-
<span class="ml-auto text-slate-500">${r.score}</span>
|
|
780
|
-
</div>
|
|
781
|
-
<div class="text-xs text-slate-400 mt-1 truncate">${truncate(r.chunk.content || '', 80)}</div>
|
|
782
|
-
</div>
|
|
783
|
-
`).join('');
|
|
784
|
-
}
|
|
785
|
-
box.classList.remove('hidden');
|
|
786
|
-
|
|
787
|
-
$$('.search-result').forEach(el => {
|
|
788
|
-
el.addEventListener('click', () => {
|
|
789
|
-
selectChunkFromSearch(el.dataset.id);
|
|
790
|
-
box.classList.add('hidden');
|
|
791
|
-
$('#global-search').value = '';
|
|
792
|
-
});
|
|
793
|
-
});
|
|
794
|
-
}, 300);
|
|
795
|
-
});
|
|
796
|
-
|
|
797
|
-
document.addEventListener('click', (e) => {
|
|
798
|
-
if (!e.target.closest('#global-search') && !e.target.closest('#search-results')) {
|
|
799
|
-
$('#search-results')?.classList.add('hidden');
|
|
800
|
-
}
|
|
801
|
-
});
|
|
802
|
-
|
|
803
|
-
// File filter
|
|
804
|
-
$('#file-filter')?.addEventListener('input', () => {
|
|
805
|
-
renderFileTree(state.files);
|
|
806
|
-
if (state.currentView === 'files') renderFiles();
|
|
807
|
-
});
|
|
808
|
-
|
|
809
|
-
// Nav buttons
|
|
810
|
-
$('#nav-dashboard')?.addEventListener('click', () => { showView('dashboard'); renderDashboard(); });
|
|
811
|
-
$('#nav-chunks')?.addEventListener('click', () => { showView('chunks'); state.chunkOffset = 0; state.selectedChunkId = null; renderChunks(); });
|
|
812
|
-
$('#nav-files')?.addEventListener('click', () => {
|
|
813
|
-
showView('files');
|
|
814
|
-
renderFiles();
|
|
815
|
-
});
|
|
816
|
-
|
|
817
|
-
$('#nav-evaluate')?.addEventListener('click', () => { showView('evaluate'); renderEvaluate(); });
|
|
818
|
-
|
|
819
|
-
$('#nav-quirks')?.addEventListener('click', () => { showView('quirks'); renderQuirks(); });
|
|
820
|
-
|
|
821
|
-
// ── Evaluate View ──────────────────────────────────────────────────
|
|
822
|
-
|
|
823
|
-
function formatTokens(n) {
|
|
824
|
-
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
825
|
-
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
|
826
|
-
return String(n);
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
function formatCost(n) {
|
|
830
|
-
if (n === 0) return '$0.00';
|
|
831
|
-
if (n < 0.01) return '$' + n.toFixed(4);
|
|
832
|
-
return '$' + n.toFixed(2);
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
function formatMs(ms) {
|
|
836
|
-
if (ms >= 60000) return (ms / 60000).toFixed(1) + 'm';
|
|
837
|
-
if (ms >= 1000) return (ms / 1000).toFixed(1) + 's';
|
|
838
|
-
return ms + 'ms';
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
function formatTimestamp(ts) {
|
|
842
|
-
if (!ts) return '-';
|
|
843
|
-
const d = new Date(ts);
|
|
844
|
-
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
function deltaStr(val, suffix) {
|
|
848
|
-
if (val === 0) return '<span class="text-slate-500">-</span>';
|
|
849
|
-
const sign = val > 0 ? '+' : '';
|
|
850
|
-
const color = val > 0 ? 'text-red-400' : 'text-green-400';
|
|
851
|
-
return `<span class="${color}">${sign}${typeof val === 'number' ? (suffix === '$' ? formatCost(val) : suffix === '%' ? val.toFixed(1) + '%' : formatTokens(val)) : val}</span>`;
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
async function renderEvaluate() {
|
|
855
|
-
const el = $('#view-evaluate');
|
|
856
|
-
el.innerHTML = '<div class="text-center py-10 text-slate-500">Loading sessions...</div>';
|
|
857
|
-
|
|
858
|
-
const data = await API.evalSessions();
|
|
859
|
-
const sessions = data.sessions || [];
|
|
860
|
-
|
|
861
|
-
if (sessions.length === 0) {
|
|
862
|
-
el.innerHTML = `
|
|
863
|
-
<div class="text-center flex-1 flex items-center justify-center">
|
|
864
|
-
<div>
|
|
865
|
-
<div class="text-4xl mb-3">📊</div>
|
|
866
|
-
<h2 class="text-lg font-semibold text-slate-400 mb-2">No sessions recorded yet</h2>
|
|
867
|
-
<p class="text-sm text-slate-500 max-w-md mx-auto">
|
|
868
|
-
Sessions are logged automatically when the OpenCodeRAG plugin is active.
|
|
869
|
-
Use OpenCode normally — every conversation is captured here with token usage, timing, and RAG context data.
|
|
870
|
-
</p>
|
|
871
|
-
</div>
|
|
872
|
-
</div>`;
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
const selected = state.evalSelectedSessions;
|
|
877
|
-
|
|
878
|
-
el.innerHTML = `
|
|
879
|
-
<div id="eval-top" class="h-1/2 overflow-y-auto scrollbar-thin border-b border-slate-700">
|
|
880
|
-
<div class="flex items-center gap-4 mb-4">
|
|
881
|
-
<h2 class="text-lg font-semibold text-white">Evaluate</h2>
|
|
882
|
-
<span class="text-sm text-slate-400">${sessions.length} session${sessions.length === 1 ? '' : 's'}</span>
|
|
883
|
-
<div class="flex-1"></div>
|
|
884
|
-
<button id="eval-compare-btn" class="px-3 py-1 bg-brand-600 rounded text-sm font-medium hover:bg-brand-500 disabled:opacity-50 disabled:cursor-not-allowed" ${selected.size === 2 ? '' : 'disabled'}>Compare Selected (2)</button>
|
|
885
|
-
<button id="eval-select-all" class="px-2 py-1 bg-slate-700 rounded text-xs hover:bg-slate-600">Select All</button>
|
|
886
|
-
<button id="eval-clear-sel" class="px-2 py-1 bg-slate-700 rounded text-xs hover:bg-slate-600">Clear</button>
|
|
887
|
-
</div>
|
|
888
|
-
|
|
889
|
-
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
890
|
-
<table class="w-full text-sm">
|
|
891
|
-
<thead>
|
|
892
|
-
<tr class="bg-slate-800 text-slate-400 text-xs">
|
|
893
|
-
<th class="px-3 py-2 text-left w-8"></th>
|
|
894
|
-
<th class="px-3 py-2 text-left">Session</th>
|
|
895
|
-
<th class="px-3 py-2 text-left w-36">Last Activity</th>
|
|
896
|
-
<th class="px-3 py-2 text-right w-24">Messages</th>
|
|
897
|
-
<th class="px-3 py-2 text-right w-28">Input Tokens</th>
|
|
898
|
-
<th class="px-3 py-2 text-right w-28">Output Tokens</th>
|
|
899
|
-
<th class="px-3 py-2 text-right w-20">Cost</th>
|
|
900
|
-
<th class="px-3 py-2 text-right w-24">RAG Calls</th>
|
|
901
|
-
<th class="px-3 py-2 text-right w-28">RAG Tokens</th>
|
|
902
|
-
<th class="px-3 py-2 text-left w-28">Model</th>
|
|
903
|
-
<th class="px-3 py-2 text-left w-16"></th>
|
|
904
|
-
</tr>
|
|
905
|
-
</thead>
|
|
906
|
-
<tbody>
|
|
907
|
-
${sessions.map(s => {
|
|
908
|
-
const checked = selected.has(s.sessionID) ? 'checked' : '';
|
|
909
|
-
const title = s.title || s.sessionID.slice(0, 8);
|
|
910
|
-
const totalIn = s.totalTokens.input + s.totalTokens.cacheRead;
|
|
911
|
-
return `
|
|
912
|
-
<tr class="border-t border-slate-800 hover:bg-slate-800 cursor-pointer eval-row" data-id="${s.sessionID}">
|
|
913
|
-
<td class="px-3 py-2"><input type="checkbox" class="eval-check rounded" data-id="${s.sessionID}" ${checked}></td>
|
|
914
|
-
<td class="px-3 py-2 text-yellow-400 font-mono text-xs">${escapeHtml(truncate(title, 40))}</td>
|
|
915
|
-
<td class="px-3 py-2 text-slate-400 text-xs">${formatTimestamp(s.lastEventAt)}</td>
|
|
916
|
-
<td class="px-3 py-2 text-right text-slate-300">${s.messageCount}</td>
|
|
917
|
-
<td class="px-3 py-2 text-right text-slate-300">${formatTokens(totalIn)}</td>
|
|
918
|
-
<td class="px-3 py-2 text-right text-slate-300">${formatTokens(s.totalTokens.output)}</td>
|
|
919
|
-
<td class="px-3 py-2 text-right text-slate-300">${formatCost(s.totalCost)}</td>
|
|
920
|
-
<td class="px-3 py-2 text-right text-cyan-400">${s.ragContextCount}</td>
|
|
921
|
-
<td class="px-3 py-2 text-right text-cyan-400">${s.ragContextTokens > 0 ? formatTokens(s.ragContextTokens) : '-'}</td>
|
|
922
|
-
<td class="px-3 py-2 text-slate-400 text-xs">${s.models.length > 0 ? escapeHtml(s.models[0]) : '-'}</td>
|
|
923
|
-
<td class="px-3 py-2"><button class="eval-delete text-slate-600 hover:text-red-400 text-xs" data-id="${s.sessionID}" title="Delete">🗑</button></td>
|
|
924
|
-
</tr>`;
|
|
925
|
-
}).join('')}
|
|
926
|
-
</tbody>
|
|
927
|
-
</table>
|
|
928
|
-
</div>
|
|
929
|
-
</div>
|
|
930
|
-
|
|
931
|
-
<div id="eval-bottom" class="h-1/2 overflow-y-auto scrollbar-thin">
|
|
932
|
-
<div id="eval-compare-area"></div>
|
|
933
|
-
<div id="eval-detail-area"></div>
|
|
934
|
-
|
|
935
|
-
<div id="eval-projection-area" class="mt-4">
|
|
936
|
-
<details class="group">
|
|
937
|
-
<summary class="text-sm font-semibold text-slate-400 cursor-pointer hover:text-white select-none mb-2">What-If Projection</summary>
|
|
938
|
-
<div class="bg-slate-900 rounded-lg border border-slate-700 p-4">
|
|
939
|
-
<div class="grid grid-cols-5 gap-4 mb-4">
|
|
940
|
-
<div>
|
|
941
|
-
<label class="block text-xs text-slate-500 mb-1">Avg Chunk Size (chars)</label>
|
|
942
|
-
<input type="range" id="proj-chunk-size" min="200" max="2000" step="100" value="600" class="w-full">
|
|
943
|
-
<div class="text-xs text-slate-400 text-center mt-1" id="proj-chunk-size-val">600</div>
|
|
944
|
-
</div>
|
|
945
|
-
<div>
|
|
946
|
-
<label class="block text-xs text-slate-500 mb-1">Chunks/Query</label>
|
|
947
|
-
<input type="range" id="proj-chunks-query" min="1" max="10" step="1" value="3" class="w-full">
|
|
948
|
-
<div class="text-xs text-slate-400 text-center mt-1" id="proj-chunks-query-val">3</div>
|
|
949
|
-
</div>
|
|
950
|
-
<div>
|
|
951
|
-
<label class="block text-xs text-slate-500 mb-1">Reads/Query (no RAG)</label>
|
|
952
|
-
<input type="range" id="proj-reads-without" min="0" max="10" step="0.5" value="2.5" class="w-full">
|
|
953
|
-
<div class="text-xs text-slate-400 text-center mt-1" id="proj-reads-without-val">2.5</div>
|
|
954
|
-
</div>
|
|
955
|
-
<div>
|
|
956
|
-
<label class="block text-xs text-slate-500 mb-1">Reads/Query (with RAG)</label>
|
|
957
|
-
<input type="range" id="proj-reads-with" min="0" max="10" step="0.5" value="0.5" class="w-full">
|
|
958
|
-
<div class="text-xs text-slate-400 text-center mt-1" id="proj-reads-with-val">0.5</div>
|
|
959
|
-
</div>
|
|
960
|
-
<div>
|
|
961
|
-
<label class="block text-xs text-slate-500 mb-1">Query Count</label>
|
|
962
|
-
<input type="range" id="proj-query-count" min="1" max="100" step="1" value="10" class="w-full">
|
|
963
|
-
<div class="text-xs text-slate-400 text-center mt-1" id="proj-query-count-val">10</div>
|
|
964
|
-
</div>
|
|
965
|
-
</div>
|
|
966
|
-
<div class="grid grid-cols-4 gap-3">
|
|
967
|
-
<div class="kpi-card p-3 text-center">
|
|
968
|
-
<div class="text-slate-500 text-xs mb-1">RAG Overhead</div>
|
|
969
|
-
<div class="text-lg font-bold text-yellow-400" id="proj-overhead">-</div>
|
|
970
|
-
</div>
|
|
971
|
-
<div class="kpi-card p-3 text-center">
|
|
972
|
-
<div class="text-slate-500 text-xs mb-1">Reads Saved</div>
|
|
973
|
-
<div class="text-lg font-bold text-green-400" id="proj-saved">-</div>
|
|
974
|
-
</div>
|
|
975
|
-
<div class="kpi-card p-3 text-center">
|
|
976
|
-
<div class="text-slate-500 text-xs mb-1">Net Savings</div>
|
|
977
|
-
<div class="text-lg font-bold" id="proj-net">-</div>
|
|
978
|
-
</div>
|
|
979
|
-
<div class="kpi-card p-3 text-center">
|
|
980
|
-
<div class="text-slate-500 text-xs mb-1">Verdict</div>
|
|
981
|
-
<div class="text-lg font-bold" id="proj-verdict">-</div>
|
|
982
|
-
</div>
|
|
983
|
-
</div>
|
|
984
|
-
</div>
|
|
985
|
-
</details>
|
|
986
|
-
</div>
|
|
987
|
-
</div>
|
|
988
|
-
`;
|
|
989
|
-
|
|
990
|
-
// Row click -> show detail
|
|
991
|
-
$$('.eval-row').forEach(row => {
|
|
992
|
-
row.addEventListener('click', (e) => {
|
|
993
|
-
if (e.target.type === 'checkbox' || e.target.closest('.eval-delete')) return;
|
|
994
|
-
const id = row.dataset.id;
|
|
995
|
-
renderEvalDetail(id);
|
|
996
|
-
});
|
|
997
|
-
});
|
|
998
|
-
|
|
999
|
-
// Checkboxes
|
|
1000
|
-
$$('.eval-check').forEach(cb => {
|
|
1001
|
-
cb.addEventListener('change', (e) => {
|
|
1002
|
-
const id = e.target.dataset.id;
|
|
1003
|
-
if (e.target.checked) selected.add(id);
|
|
1004
|
-
else selected.delete(id);
|
|
1005
|
-
updateEvalCompareBtn();
|
|
1006
|
-
});
|
|
1007
|
-
});
|
|
1008
|
-
|
|
1009
|
-
// Delete buttons
|
|
1010
|
-
$$('.eval-delete').forEach(btn => {
|
|
1011
|
-
btn.addEventListener('click', async (e) => {
|
|
1012
|
-
e.stopPropagation();
|
|
1013
|
-
const id = btn.dataset.id;
|
|
1014
|
-
await API.evalDeleteSession(id);
|
|
1015
|
-
selected.delete(id);
|
|
1016
|
-
renderEvaluate();
|
|
1017
|
-
});
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
// Compare button
|
|
1021
|
-
$('#eval-compare-btn')?.addEventListener('click', () => {
|
|
1022
|
-
const ids = [...selected];
|
|
1023
|
-
console.log('[Compare] clicked, selected ids:', ids);
|
|
1024
|
-
if (ids.length === 2) renderEvalComparison(ids[0], ids[1]);
|
|
1025
|
-
});
|
|
1026
|
-
|
|
1027
|
-
// Select all / clear
|
|
1028
|
-
$('#eval-select-all')?.addEventListener('click', () => {
|
|
1029
|
-
sessions.forEach(s => selected.add(s.sessionID));
|
|
1030
|
-
$$('.eval-check').forEach(cb => cb.checked = true);
|
|
1031
|
-
updateEvalCompareBtn();
|
|
1032
|
-
});
|
|
1033
|
-
$('#eval-clear-sel')?.addEventListener('click', () => {
|
|
1034
|
-
selected.clear();
|
|
1035
|
-
$$('.eval-check').forEach(cb => cb.checked = false);
|
|
1036
|
-
updateEvalCompareBtn();
|
|
1037
|
-
});
|
|
1038
|
-
|
|
1039
|
-
function updateEvalCompareBtn() {
|
|
1040
|
-
const btn = $('#eval-compare-btn');
|
|
1041
|
-
if (btn) {
|
|
1042
|
-
btn.disabled = selected.size !== 2;
|
|
1043
|
-
btn.textContent = `Compare Selected (${selected.size})`;
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
// What-If Projection sliders
|
|
1048
|
-
let projDebounce = null;
|
|
1049
|
-
const projSliders = [
|
|
1050
|
-
{ id: 'proj-chunk-size', valId: 'proj-chunk-size-val' },
|
|
1051
|
-
{ id: 'proj-chunks-query', valId: 'proj-chunks-query-val' },
|
|
1052
|
-
{ id: 'proj-reads-without', valId: 'proj-reads-without-val' },
|
|
1053
|
-
{ id: 'proj-reads-with', valId: 'proj-reads-with-val' },
|
|
1054
|
-
{ id: 'proj-query-count', valId: 'proj-query-count-val' },
|
|
1055
|
-
];
|
|
1056
|
-
projSliders.forEach(({ id, valId }) => {
|
|
1057
|
-
const slider = document.getElementById(id);
|
|
1058
|
-
const label = document.getElementById(valId);
|
|
1059
|
-
if (!slider || !label) return;
|
|
1060
|
-
slider.addEventListener('input', () => {
|
|
1061
|
-
label.textContent = slider.value;
|
|
1062
|
-
if (projDebounce) clearTimeout(projDebounce);
|
|
1063
|
-
projDebounce = setTimeout(updateProjection, 200);
|
|
1064
|
-
});
|
|
1065
|
-
});
|
|
1066
|
-
updateProjection();
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
async function updateProjection() {
|
|
1070
|
-
const params = {
|
|
1071
|
-
avgChunkSize: Number(document.getElementById('proj-chunk-size')?.value ?? 600),
|
|
1072
|
-
avgChunksPerQuery: Number(document.getElementById('proj-chunks-query')?.value ?? 3),
|
|
1073
|
-
avgReadsPerQueryWithoutRAG: Number(document.getElementById('proj-reads-without')?.value ?? 2.5),
|
|
1074
|
-
avgReadsPerQueryWithRAG: Number(document.getElementById('proj-reads-with')?.value ?? 0.5),
|
|
1075
|
-
queryCount: Number(document.getElementById('proj-query-count')?.value ?? 10),
|
|
1076
|
-
};
|
|
1077
|
-
|
|
1078
|
-
const data = await API.evalProjectSavings(params);
|
|
1079
|
-
const p = data?.projection;
|
|
1080
|
-
if (!p) return;
|
|
1081
|
-
|
|
1082
|
-
const overheadEl = document.getElementById('proj-overhead');
|
|
1083
|
-
const savedEl = document.getElementById('proj-saved');
|
|
1084
|
-
const netEl = document.getElementById('proj-net');
|
|
1085
|
-
const verdictEl = document.getElementById('proj-verdict');
|
|
1086
|
-
|
|
1087
|
-
if (overheadEl) overheadEl.textContent = formatTokens(p.ragOverheadTokens);
|
|
1088
|
-
if (savedEl) savedEl.textContent = formatTokens(p.savedReadTokens);
|
|
1089
|
-
if (netEl) {
|
|
1090
|
-
netEl.textContent = (p.netSavings > 0 ? '+' : '') + formatTokens(p.netSavings);
|
|
1091
|
-
netEl.className = `text-lg font-bold ${p.netSavings > 0 ? 'text-green-400' : p.netSavings < 0 ? 'text-red-400' : 'text-slate-400'}`;
|
|
1092
|
-
}
|
|
1093
|
-
if (verdictEl) {
|
|
1094
|
-
verdictEl.textContent = p.isPositive ? 'SAVES' : 'COSTS';
|
|
1095
|
-
verdictEl.className = `text-lg font-bold ${p.isPositive ? 'text-green-400' : 'text-red-400'}`;
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
async function renderEvalDetail(sessionId) {
|
|
1100
|
-
const area = $('#eval-detail-area');
|
|
1101
|
-
if (!area) return;
|
|
1102
|
-
|
|
1103
|
-
area.innerHTML = '<div class="text-center py-4 text-slate-500">Loading...</div>';
|
|
1104
|
-
|
|
1105
|
-
const data = await API.evalSession(sessionId);
|
|
1106
|
-
if (!data || data.error) {
|
|
1107
|
-
area.innerHTML = '<div class="text-center py-4 text-red-400">Session not found</div>';
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
const { events, summary: s } = data;
|
|
1112
|
-
const totalIn = s.totalTokens.input + s.totalTokens.cacheRead;
|
|
1113
|
-
const totalAll = totalIn + s.totalTokens.output + s.totalTokens.reasoning;
|
|
1114
|
-
|
|
1115
|
-
area.innerHTML = `
|
|
1116
|
-
<div class="mb-4">
|
|
1117
|
-
<div class="flex items-center gap-3 mb-3">
|
|
1118
|
-
<h3 class="text-base font-semibold text-white">Session Detail</h3>
|
|
1119
|
-
<span class="text-xs font-mono text-slate-500">${s.sessionID}</span>
|
|
1120
|
-
</div>
|
|
1121
|
-
|
|
1122
|
-
<div class="grid grid-cols-5 gap-3 mb-4">
|
|
1123
|
-
<div class="kpi-card p-3">
|
|
1124
|
-
<div class="text-slate-400 text-xs mb-1">Total Tokens</div>
|
|
1125
|
-
<div class="text-xl font-bold text-white">${formatTokens(totalAll)}</div>
|
|
1126
|
-
</div>
|
|
1127
|
-
<div class="kpi-card p-3">
|
|
1128
|
-
<div class="text-slate-400 text-xs mb-1">Input Tokens</div>
|
|
1129
|
-
<div class="text-xl font-bold text-white">${formatTokens(totalIn)}</div>
|
|
1130
|
-
</div>
|
|
1131
|
-
<div class="kpi-card p-3">
|
|
1132
|
-
<div class="text-slate-400 text-xs mb-1">Output Tokens</div>
|
|
1133
|
-
<div class="text-xl font-bold text-white">${formatTokens(s.totalTokens.output)}</div>
|
|
1134
|
-
</div>
|
|
1135
|
-
<div class="kpi-card p-3">
|
|
1136
|
-
<div class="text-slate-400 text-xs mb-1">Cost</div>
|
|
1137
|
-
<div class="text-xl font-bold text-white">${formatCost(s.totalCost)}</div>
|
|
1138
|
-
</div>
|
|
1139
|
-
<div class="kpi-card p-3">
|
|
1140
|
-
<div class="text-slate-400 text-xs mb-1">RAG Context Tokens</div>
|
|
1141
|
-
<div class="text-xl font-bold text-cyan-400">${s.ragContextTokens > 0 ? formatTokens(s.ragContextTokens) : '0'}</div>
|
|
1142
|
-
</div>
|
|
1143
|
-
</div>
|
|
1144
|
-
|
|
1145
|
-
${renderTokenDonut(s)}
|
|
1146
|
-
|
|
1147
|
-
<div class="grid grid-cols-4 gap-3 mb-4">
|
|
1148
|
-
<div class="kpi-card p-3">
|
|
1149
|
-
<div class="text-slate-400 text-xs mb-1">Messages</div>
|
|
1150
|
-
<div class="text-lg font-bold text-white">${s.messageCount}</div>
|
|
1151
|
-
</div>
|
|
1152
|
-
<div class="kpi-card p-3">
|
|
1153
|
-
<div class="text-slate-400 text-xs mb-1">Steps</div>
|
|
1154
|
-
<div class="text-lg font-bold text-white">${s.totalSteps}</div>
|
|
1155
|
-
</div>
|
|
1156
|
-
<div class="kpi-card p-3">
|
|
1157
|
-
<div class="text-slate-400 text-xs mb-1">RAG Injections</div>
|
|
1158
|
-
<div class="text-lg font-bold text-cyan-400">${s.ragContextCount}</div>
|
|
1159
|
-
</div>
|
|
1160
|
-
<div class="kpi-card p-3">
|
|
1161
|
-
<div class="text-slate-400 text-xs mb-1">Avg Response</div>
|
|
1162
|
-
<div class="text-lg font-bold text-white">${s.avgResponseTimeMs ? formatMs(s.avgResponseTimeMs) : '-'}</div>
|
|
1163
|
-
</div>
|
|
1164
|
-
</div>
|
|
1165
|
-
|
|
1166
|
-
${s.models.length > 0 ? `
|
|
1167
|
-
<div class="kpi-card p-3 mb-4">
|
|
1168
|
-
<h4 class="text-xs font-semibold text-slate-400 mb-2">Models Used</h4>
|
|
1169
|
-
<div class="flex flex-wrap gap-2">
|
|
1170
|
-
${s.models.map(m => `<span class="inline-block px-2 py-1 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700">${escapeHtml(m)}</span>`).join('')}
|
|
1171
|
-
</div>
|
|
1172
|
-
</div>` : ''}
|
|
1173
|
-
</div>
|
|
1174
|
-
|
|
1175
|
-
<div id="token-analysis-area" class="mb-4"></div>
|
|
1176
|
-
|
|
1177
|
-
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
1178
|
-
<div class="px-3 py-2 bg-slate-800 border-b border-slate-700">
|
|
1179
|
-
<span class="text-xs font-semibold text-slate-400">Event Timeline</span>
|
|
1180
|
-
<span class="text-xs text-slate-600 ml-2">${events.length} events</span>
|
|
1181
|
-
</div>
|
|
1182
|
-
<div class="max-h-[500px] overflow-y-auto scrollbar-thin">
|
|
1183
|
-
${events.map(ev => renderTimelineEvent(ev)).join('')}
|
|
1184
|
-
</div>
|
|
1185
|
-
</div>
|
|
1186
|
-
`;
|
|
1187
|
-
|
|
1188
|
-
renderTokenAnalysis(sessionId);
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
async function renderTokenAnalysis(sessionId) {
|
|
1192
|
-
const area = $('#token-analysis-area');
|
|
1193
|
-
if (!area) return;
|
|
1194
|
-
|
|
1195
|
-
const data = await API.evalAnalysis(sessionId);
|
|
1196
|
-
if (!data || data.error || !data.analysis) {
|
|
1197
|
-
area.innerHTML = '';
|
|
1198
|
-
return;
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
const a = data.analysis;
|
|
1202
|
-
const e = a.estimates;
|
|
1203
|
-
const savingsColor = e.netSavings > 0 ? 'text-green-400' : e.netSavings < 0 ? 'text-red-400' : 'text-slate-400';
|
|
1204
|
-
const savingsSign = e.netSavings > 0 ? '+' : '';
|
|
1205
|
-
|
|
1206
|
-
area.innerHTML = `
|
|
1207
|
-
<div class="bg-slate-900 rounded-lg border border-slate-700 p-4 mb-4">
|
|
1208
|
-
<h4 class="text-sm font-semibold text-white mb-3">Token Analysis</h4>
|
|
1209
|
-
|
|
1210
|
-
<div class="grid grid-cols-3 gap-3 mb-4">
|
|
1211
|
-
<div class="kpi-card p-3">
|
|
1212
|
-
<div class="text-slate-400 text-xs mb-1">Est. Tokens Without RAG</div>
|
|
1213
|
-
<div class="text-xl font-bold text-white">${formatTokens(e.tokensWithoutRAG)}</div>
|
|
1214
|
-
</div>
|
|
1215
|
-
<div class="kpi-card p-3">
|
|
1216
|
-
<div class="text-slate-400 text-xs mb-1">Tokens With RAG (actual)</div>
|
|
1217
|
-
<div class="text-xl font-bold text-white">${formatTokens(e.tokensWithRAG)}</div>
|
|
1218
|
-
</div>
|
|
1219
|
-
<div class="kpi-card p-3">
|
|
1220
|
-
<div class="text-slate-400 text-xs mb-1">Net Savings</div>
|
|
1221
|
-
<div class="text-xl font-bold ${savingsColor}">${savingsSign}${formatTokens(e.netSavings)}</div>
|
|
1222
|
-
<div class="text-xs ${savingsColor}">${savingsSign}${e.percentSavings}%</div>
|
|
1223
|
-
</div>
|
|
1224
|
-
</div>
|
|
1225
|
-
|
|
1226
|
-
<div class="grid grid-cols-4 gap-3 mb-4 text-xs">
|
|
1227
|
-
<div class="text-slate-400">
|
|
1228
|
-
<span class="block text-slate-500">RAG Context</span>
|
|
1229
|
-
<span class="text-white font-semibold">${formatTokens(a.totals.ragContextTokens)}</span>
|
|
1230
|
-
</div>
|
|
1231
|
-
<div class="text-slate-400">
|
|
1232
|
-
<span class="block text-slate-500">System Guidance</span>
|
|
1233
|
-
<span class="text-white font-semibold">${formatTokens(a.totals.systemGuidanceTokens)}</span>
|
|
1234
|
-
</div>
|
|
1235
|
-
<div class="text-slate-400">
|
|
1236
|
-
<span class="block text-slate-500">Read Tool Calls</span>
|
|
1237
|
-
<span class="text-white font-semibold">${a.totals.readToolCalls}</span>
|
|
1238
|
-
</div>
|
|
1239
|
-
<div class="text-slate-400">
|
|
1240
|
-
<span class="block text-slate-500">RAG Tool Calls</span>
|
|
1241
|
-
<span class="text-cyan-400 font-semibold">${a.totals.ragToolCalls}</span>
|
|
1242
|
-
</div>
|
|
1243
|
-
</div>
|
|
1244
|
-
|
|
1245
|
-
${a.breakdowns.length > 0 ? `
|
|
1246
|
-
<details class="group">
|
|
1247
|
-
<summary class="text-xs font-semibold text-slate-400 cursor-pointer hover:text-white select-none">Per-Query Breakdown (${a.breakdowns.length} queries)</summary>
|
|
1248
|
-
<div class="mt-2 overflow-x-auto">
|
|
1249
|
-
<table class="w-full text-xs">
|
|
1250
|
-
<thead>
|
|
1251
|
-
<tr class="text-slate-500 border-b border-slate-800">
|
|
1252
|
-
<th class="px-2 py-1 text-left">#</th>
|
|
1253
|
-
<th class="px-2 py-1 text-right">Input</th>
|
|
1254
|
-
<th class="px-2 py-1 text-right">Output</th>
|
|
1255
|
-
<th class="px-2 py-1 text-right">RAG Ctx</th>
|
|
1256
|
-
<th class="px-2 py-1 text-right">Chunks</th>
|
|
1257
|
-
<th class="px-2 py-1 text-right">Reads</th>
|
|
1258
|
-
<th class="px-2 py-1 text-right">RAG Tools</th>
|
|
1259
|
-
<th class="px-2 py-1 text-right">Score</th>
|
|
1260
|
-
<th class="px-2 py-1 text-right">Time</th>
|
|
1261
|
-
</tr>
|
|
1262
|
-
</thead>
|
|
1263
|
-
<tbody>
|
|
1264
|
-
${a.breakdowns.map((b, i) => {
|
|
1265
|
-
const hasRag = b.ragContextTokens > 0;
|
|
1266
|
-
const borderCls = hasRag ? 'border-l-2 border-l-cyan-600' : '';
|
|
1267
|
-
return `<tr class="border-b border-slate-800/50 ${borderCls}">
|
|
1268
|
-
<td class="px-2 py-1 text-slate-500">${i + 1}</td>
|
|
1269
|
-
<td class="px-2 py-1 text-right text-slate-300">${formatTokens(b.inputTokens)}</td>
|
|
1270
|
-
<td class="px-2 py-1 text-right text-slate-300">${formatTokens(b.outputTokens)}</td>
|
|
1271
|
-
<td class="px-2 py-1 text-right ${hasRag ? 'text-cyan-400' : 'text-slate-500'}">${hasRag ? formatTokens(b.ragContextTokens) : '-'}</td>
|
|
1272
|
-
<td class="px-2 py-1 text-right text-slate-400">${hasRag ? b.ragChunkCount : '-'}</td>
|
|
1273
|
-
<td class="px-2 py-1 text-right text-slate-400">${b.readToolCalls}</td>
|
|
1274
|
-
<td class="px-2 py-1 text-right ${b.ragToolCalls > 0 ? 'text-cyan-400' : 'text-slate-500'}">${b.ragToolCalls || '-'}</td>
|
|
1275
|
-
<td class="px-2 py-1 text-right text-slate-400">${b.ragTopScore > 0 ? b.ragTopScore.toFixed(2) : '-'}</td>
|
|
1276
|
-
<td class="px-2 py-1 text-right text-slate-500">${b.responseTimeMs > 0 ? formatMs(b.responseTimeMs) : '-'}</td>
|
|
1277
|
-
</tr>`;
|
|
1278
|
-
}).join('')}
|
|
1279
|
-
</tbody>
|
|
1280
|
-
</table>
|
|
1281
|
-
</div>
|
|
1282
|
-
</details>` : ''}
|
|
1283
|
-
</div>
|
|
1284
|
-
`;
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
function renderTimelineEvent(ev) {
|
|
1288
|
-
const time = formatTimestamp(ev.ts);
|
|
1289
|
-
|
|
1290
|
-
if (ev.event === 'message' && ev.role === 'assistant') {
|
|
1291
|
-
const tokens = ev.tokens;
|
|
1292
|
-
const tokenStr = tokens ? `in=${formatTokens(tokens.input)} out=${formatTokens(tokens.output)}` : '';
|
|
1293
|
-
const costStr = ev.cost != null && ev.cost > 0 ? formatCost(ev.cost) : '';
|
|
1294
|
-
const errorStr = ev.errorName ? `<span class="text-red-400">[${escapeHtml(ev.errorName)}]</span> ` : '';
|
|
1295
|
-
const timing = ev.timeCreated && ev.timeCompleted ? formatMs(ev.timeCompleted - ev.timeCreated) : '';
|
|
1296
|
-
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
1297
|
-
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
1298
|
-
<span class="text-purple-400 w-20 shrink-0">assistant</span>
|
|
1299
|
-
<span class="text-slate-400">${errorStr}${tokenStr}${costStr ? ' <span class="text-green-400">' + costStr + '</span>' : ''}${timing ? ' <span class="text-slate-500">' + timing + '</span>' : ''}</span>
|
|
1300
|
-
</div>`;
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
if (ev.event === 'rag.context') {
|
|
1304
|
-
const injected = ev.ragInjected ? 'injected' : 'no match';
|
|
1305
|
-
const color = ev.ragInjected ? 'text-cyan-400' : 'text-slate-500';
|
|
1306
|
-
const details = ev.ragInjected ? `${ev.ragChunkCount} chunks, ${formatTokens(ev.ragContextTokens || 0)} tokens, ${formatMs(ev.ragRetrievalTimeMs || 0)}` : '';
|
|
1307
|
-
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
1308
|
-
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
1309
|
-
<span class="${color} w-20 shrink-0">rag</span>
|
|
1310
|
-
<span class="text-slate-400">${injected} ${details}</span>
|
|
1311
|
-
</div>`;
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
if (ev.event === 'tool') {
|
|
1315
|
-
const status = ev.toolStatus || '';
|
|
1316
|
-
const color = status === 'completed' ? 'text-green-400' : status === 'error' ? 'text-red-400' : 'text-yellow-400';
|
|
1317
|
-
const isRag = ['search_semantic','get_file_skeleton','find_usages','read'].includes(ev.tool);
|
|
1318
|
-
const dur = ev.toolDurationMs != null ? formatMs(ev.toolDurationMs) : '';
|
|
1319
|
-
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
1320
|
-
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
1321
|
-
<span class="${color} w-20 shrink-0">${status}</span>
|
|
1322
|
-
<span class="${isRag ? 'text-cyan-300' : 'text-slate-400'}">${escapeHtml(ev.tool || '')}</span>
|
|
1323
|
-
${dur ? `<span class="text-slate-500 ml-auto">${dur}</span>` : ''}
|
|
1324
|
-
</div>`;
|
|
1325
|
-
}
|
|
1326
|
-
|
|
1327
|
-
if (ev.event === 'step') {
|
|
1328
|
-
const tokens = ev.stepTokens;
|
|
1329
|
-
const tokenStr = tokens ? `in=${formatTokens(tokens.input)} out=${formatTokens(tokens.output)}` : '';
|
|
1330
|
-
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
1331
|
-
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
1332
|
-
<span class="text-blue-400 w-20 shrink-0">step</span>
|
|
1333
|
-
<span class="text-slate-400">${ev.stepReason || ''} ${tokenStr}</span>
|
|
1334
|
-
</div>`;
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
|
-
if (ev.event === 'session.created') {
|
|
1338
|
-
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
1339
|
-
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
1340
|
-
<span class="text-green-400 w-20 shrink-0">created</span>
|
|
1341
|
-
<span class="text-slate-400">${escapeHtml(ev.sessionTitle || ev.sessionID)}</span>
|
|
1342
|
-
</div>`;
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
if (ev.event === 'session.status') {
|
|
1346
|
-
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
1347
|
-
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
1348
|
-
<span class="text-slate-500 w-20 shrink-0">status</span>
|
|
1349
|
-
<span class="text-slate-500">${escapeHtml(ev.sessionStatus || '')}</span>
|
|
1350
|
-
</div>`;
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
return '';
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
async function renderEvalComparison(idA, idB) {
|
|
1357
|
-
const area = $('#eval-compare-area');
|
|
1358
|
-
if (!area) return;
|
|
1359
|
-
|
|
1360
|
-
area.innerHTML = '<div class="text-center py-4 text-slate-500">Loading comparison...</div>';
|
|
1361
|
-
|
|
1362
|
-
let basicData, tokenData;
|
|
1363
|
-
try {
|
|
1364
|
-
[basicData, tokenData] = await Promise.all([
|
|
1365
|
-
API.evalCompare(idA, idB),
|
|
1366
|
-
API.evalTokenCompare(idA, idB),
|
|
1367
|
-
]);
|
|
1368
|
-
} catch (err) {
|
|
1369
|
-
area.innerHTML = `<div class="text-center py-4 text-red-400">Comparison failed: ${escapeHtml(String(err))}</div>`;
|
|
1370
|
-
return;
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
if (!basicData || basicData.error) {
|
|
1374
|
-
area.innerHTML = `<div class="text-center py-4 text-red-400">${escapeHtml(basicData?.error || 'Comparison failed')}</div>`;
|
|
1375
|
-
return;
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
try {
|
|
1379
|
-
const { sessionA: a, sessionB: b, delta } = basicData;
|
|
1380
|
-
const aTotalIn = a.totalTokens.input + a.totalTokens.cacheRead;
|
|
1381
|
-
const bTotalIn = b.totalTokens.input + b.totalTokens.cacheRead;
|
|
1382
|
-
const aTotal = aTotalIn + a.totalTokens.output + a.totalTokens.reasoning;
|
|
1383
|
-
const bTotal = bTotalIn + b.totalTokens.output + b.totalTokens.reasoning;
|
|
1384
|
-
|
|
1385
|
-
function pctDelta(aVal, bVal) {
|
|
1386
|
-
if (aVal === 0) return bVal === 0 ? 0 : 100;
|
|
1387
|
-
return ((bVal - aVal) / aVal) * 100;
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
const rows = [
|
|
1391
|
-
{ label: 'Total Tokens', a: aTotal, b: bTotal, fmt: formatTokens, pct: true },
|
|
1392
|
-
{ label: 'Input Tokens', a: aTotalIn, b: bTotalIn, fmt: formatTokens, pct: true },
|
|
1393
|
-
{ label: 'Output Tokens', a: a.totalTokens.output, b: b.totalTokens.output, fmt: formatTokens, pct: true },
|
|
1394
|
-
{ label: 'Reasoning Tokens', a: a.totalTokens.reasoning, b: b.totalTokens.reasoning, fmt: formatTokens, pct: true },
|
|
1395
|
-
{ label: 'Cache Read', a: a.totalTokens.cacheRead, b: b.totalTokens.cacheRead, fmt: formatTokens, pct: false },
|
|
1396
|
-
{ label: 'Cost', a: a.totalCost, b: b.totalCost, fmt: formatCost, pct: true },
|
|
1397
|
-
{ label: 'Messages', a: a.messageCount, b: b.messageCount, fmt: String, pct: false },
|
|
1398
|
-
{ label: 'Steps', a: a.totalSteps, b: b.totalSteps, fmt: String, pct: false },
|
|
1399
|
-
{ label: 'RAG Injections', a: a.ragContextCount, b: b.ragContextCount, fmt: String, pct: false },
|
|
1400
|
-
{ label: 'RAG Context Tokens', a: a.ragContextTokens, b: b.ragContextTokens, fmt: formatTokens, pct: false },
|
|
1401
|
-
{ label: 'RAG Tool Calls', a: a.ragToolCalls, b: b.ragToolCalls, fmt: String, pct: false },
|
|
1402
|
-
{ label: 'Avg Response Time', a: a.avgResponseTimeMs || 0, b: b.avgResponseTimeMs || 0, fmt: formatMs, pct: true },
|
|
1403
|
-
];
|
|
1404
|
-
|
|
1405
|
-
// Add token analysis rows if available
|
|
1406
|
-
const tc = tokenData?.comparison;
|
|
1407
|
-
if (tc) {
|
|
1408
|
-
rows.push(
|
|
1409
|
-
{ label: 'Read Tool Calls', a: a.ragToolCalls || 0, b: b.ragToolCalls || 0, fmt: String, pct: false },
|
|
1410
|
-
);
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
area.innerHTML = `
|
|
1414
|
-
<div class="mt-6 mb-4">
|
|
1415
|
-
<div class="flex items-center gap-3 mb-3">
|
|
1416
|
-
<h3 class="text-base font-semibold text-white">Comparison</h3>
|
|
1417
|
-
<button id="eval-close-compare" class="text-xs text-slate-500 hover:text-white ml-auto">Close</button>
|
|
1418
|
-
</div>
|
|
1419
|
-
|
|
1420
|
-
${tc ? `
|
|
1421
|
-
<div class="p-3 rounded-lg mb-4 ${tc.delta.inputTokens < 0 ? 'bg-green-900/20 border border-green-800' : tc.delta.inputTokens > 0 ? 'bg-red-900/20 border border-red-800' : 'bg-slate-800 border border-slate-700'}">
|
|
1422
|
-
<div class="text-sm font-semibold ${tc.delta.inputTokens < 0 ? 'text-green-300' : tc.delta.inputTokens > 0 ? 'text-red-300' : 'text-slate-300'}">${escapeHtml(tc.verdict)}</div>
|
|
1423
|
-
${tokenData?.ragOn?.estimates ? `
|
|
1424
|
-
<div class="text-xs text-slate-400 mt-1">
|
|
1425
|
-
Token savings estimate: ${tokenData.ragOn.estimates.netSavings > 0 ? '+' : ''}${formatTokens(tokenData.ragOn.estimates.netSavings)} (${tokenData.ragOn.estimates.percentSavings}%)
|
|
1426
|
-
for RAG-on session
|
|
1427
|
-
</div>` : ''}
|
|
1428
|
-
</div>` : ''}
|
|
1429
|
-
|
|
1430
|
-
${renderComparisonBars(a, b)}
|
|
1431
|
-
|
|
1432
|
-
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
1433
|
-
<table class="w-full text-sm">
|
|
1434
|
-
<thead>
|
|
1435
|
-
<tr class="bg-slate-800 text-slate-400 text-xs">
|
|
1436
|
-
<th class="px-4 py-2 text-left">Metric</th>
|
|
1437
|
-
<th class="px-4 py-2 text-right w-32">${escapeHtml(truncate(a.title || a.sessionID.slice(0, 8), 20))}</th>
|
|
1438
|
-
<th class="px-4 py-2 text-right w-32">${escapeHtml(truncate(b.title || b.sessionID.slice(0, 8), 20))}</th>
|
|
1439
|
-
<th class="px-4 py-2 text-right w-28">Delta</th>
|
|
1440
|
-
<th class="px-4 py-2 text-right w-20">%</th>
|
|
1441
|
-
</tr>
|
|
1442
|
-
</thead>
|
|
1443
|
-
<tbody>
|
|
1444
|
-
${rows.map(r => {
|
|
1445
|
-
const d = r.b - r.a;
|
|
1446
|
-
const pct = r.pct ? pctDelta(r.a, r.b) : null;
|
|
1447
|
-
const dColor = d > 0 ? 'text-red-400' : d < 0 ? 'text-green-400' : 'text-slate-500';
|
|
1448
|
-
const dSign = d > 0 ? '+' : '';
|
|
1449
|
-
return `
|
|
1450
|
-
<tr class="border-t border-slate-800">
|
|
1451
|
-
<td class="px-4 py-2 text-slate-300">${r.label}</td>
|
|
1452
|
-
<td class="px-4 py-2 text-right text-slate-400">${r.fmt(r.a)}</td>
|
|
1453
|
-
<td class="px-4 py-2 text-right text-slate-400">${r.fmt(r.b)}</td>
|
|
1454
|
-
<td class="px-4 py-2 text-right ${dColor}">${d === 0 ? '-' : dSign + r.fmt(Math.abs(d))}</td>
|
|
1455
|
-
<td class="px-4 py-2 text-right ${dColor}">${pct === null ? '-' : (pct > 0 ? '+' : '') + pct.toFixed(1) + '%'}</td>
|
|
1456
|
-
</tr>`;
|
|
1457
|
-
}).join('')}
|
|
1458
|
-
</tbody>
|
|
1459
|
-
</table>
|
|
1460
|
-
</div>
|
|
1461
|
-
|
|
1462
|
-
${delta.ragContextTokens !== 0 ? `
|
|
1463
|
-
<div class="mt-3 p-3 rounded-lg ${delta.ragContextTokens > 0 ? 'bg-cyan-900/20 border border-cyan-800' : 'bg-green-900/20 border border-green-800'}">
|
|
1464
|
-
<div class="text-xs ${delta.ragContextTokens > 0 ? 'text-cyan-300' : 'text-green-300'}">
|
|
1465
|
-
${delta.ragContextTokens > 0
|
|
1466
|
-
? `RAG added ${formatTokens(delta.ragContextTokens)} context tokens (${delta.ragContextCount} injections). The LLM processed ${formatTokens(delta.inputTokens)} more input tokens with RAG enabled.`
|
|
1467
|
-
: `RAG reduced context by ${formatTokens(Math.abs(delta.ragContextTokens))} tokens. The LLM processed ${formatTokens(Math.abs(delta.inputTokens))} fewer input tokens.`}
|
|
1468
|
-
</div>
|
|
1469
|
-
</div>` : ''}
|
|
1470
|
-
|
|
1471
|
-
${tokenData?.ragOn?.estimates ? `
|
|
1472
|
-
<div class="grid grid-cols-2 gap-3 mt-3">
|
|
1473
|
-
<div class="kpi-card p-3">
|
|
1474
|
-
<div class="text-slate-400 text-xs mb-1">RAG-On Session Savings</div>
|
|
1475
|
-
<div class="text-lg font-bold ${tokenData.ragOn.estimates.netSavings > 0 ? 'text-green-400' : 'text-red-400'}">${tokenData.ragOn.estimates.netSavings > 0 ? '+' : ''}${formatTokens(tokenData.ragOn.estimates.netSavings)}</div>
|
|
1476
|
-
<div class="text-xs text-slate-500">est. ${tokenData.ragOn.estimates.percentSavings}% ${tokenData.ragOn.estimates.netSavings > 0 ? 'saved' : 'overhead'}</div>
|
|
1477
|
-
</div>
|
|
1478
|
-
<div class="kpi-card p-3">
|
|
1479
|
-
<div class="text-slate-400 text-xs mb-1">RAG-Off Session Savings</div>
|
|
1480
|
-
<div class="text-lg font-bold ${tokenData.ragOff.estimates.netSavings > 0 ? 'text-green-400' : 'text-red-400'}">${tokenData.ragOff.estimates.netSavings > 0 ? '+' : ''}${formatTokens(tokenData.ragOff.estimates.netSavings)}</div>
|
|
1481
|
-
<div class="text-xs text-slate-500">est. ${tokenData.ragOff.estimates.percentSavings}% ${tokenData.ragOff.estimates.netSavings > 0 ? 'saved' : 'overhead'}</div>
|
|
1482
|
-
</div>
|
|
1483
|
-
</div>` : ''}
|
|
1484
|
-
</div>
|
|
1485
|
-
`;
|
|
1486
|
-
|
|
1487
|
-
$('#eval-close-compare')?.addEventListener('click', () => {
|
|
1488
|
-
area.innerHTML = '';
|
|
1489
|
-
});
|
|
1490
|
-
} catch (err) {
|
|
1491
|
-
area.innerHTML = `<div class="text-center py-4 text-red-400">Comparison rendering failed: ${escapeHtml(String(err))}</div>`;
|
|
1492
|
-
}
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
// ── Quirks View ────────────────────────────────────────────────────
|
|
1496
|
-
|
|
1497
|
-
function confidenceColor(c) {
|
|
1498
|
-
if (c >= 0.75) return 'text-green-400';
|
|
1499
|
-
if (c >= 0.5) return 'text-amber-400';
|
|
1500
|
-
return 'text-red-400';
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
function formatQuirkDate(iso) {
|
|
1504
|
-
if (!iso) return '-';
|
|
1505
|
-
const d = new Date(iso);
|
|
1506
|
-
if (isNaN(d.getTime())) return '-';
|
|
1507
|
-
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
1508
|
-
}
|
|
1509
|
-
|
|
1510
|
-
async function renderQuirks() {
|
|
1511
|
-
const el = $('#view-quirks');
|
|
1512
|
-
if (!el) return;
|
|
1513
|
-
el.innerHTML = '<div class="text-center py-10 text-slate-500">Loading quirks...</div>';
|
|
1514
|
-
|
|
1515
|
-
let data;
|
|
1516
|
-
try {
|
|
1517
|
-
data = await API.quirks();
|
|
1518
|
-
} catch (err) {
|
|
1519
|
-
el.innerHTML = `<div class="text-center py-10 text-red-400">Failed to load quirks: ${escapeHtml(String(err))}</div>`;
|
|
1520
|
-
return;
|
|
1521
|
-
}
|
|
1522
|
-
|
|
1523
|
-
const quirks = data.quirks || [];
|
|
1524
|
-
const types = [...new Set(quirks.map(q => q.quirkType || 'general'))].sort();
|
|
1525
|
-
const filter = state.quirkTypeFilter;
|
|
1526
|
-
|
|
1527
|
-
const filtered = filter ? quirks.filter(q => (q.quirkType || 'general') === filter) : quirks;
|
|
1528
|
-
|
|
1529
|
-
if (quirks.length === 0) {
|
|
1530
|
-
el.innerHTML = `
|
|
1531
|
-
<div class="text-center flex-1 flex items-center justify-center py-20">
|
|
1532
|
-
<div>
|
|
1533
|
-
<div class="text-4xl mb-3">💡</div>
|
|
1534
|
-
<h2 class="text-lg font-semibold text-slate-400 mb-2">No quirks stored yet</h2>
|
|
1535
|
-
<p class="text-sm text-slate-500 max-w-md mx-auto">
|
|
1536
|
-
Quirks are experiential memories (gotchas, preferences, decisions, environment constraints)
|
|
1537
|
-
recorded by the agent via the <code>add_quirk</code> tool. They will appear here once captured.
|
|
1538
|
-
</p>
|
|
1539
|
-
</div>
|
|
1540
|
-
</div>`;
|
|
1541
|
-
return;
|
|
1542
|
-
}
|
|
1543
|
-
|
|
1544
|
-
el.innerHTML = `
|
|
1545
|
-
<div class="flex items-center gap-3 mb-4">
|
|
1546
|
-
<h2 class="text-lg font-semibold text-white">Quirks</h2>
|
|
1547
|
-
<span class="text-sm text-slate-400">${quirks.length} total${filter ? `, ${filtered.length} shown` : ''}</span>
|
|
1548
|
-
<div class="flex-1"></div>
|
|
1549
|
-
<div class="flex items-center gap-2">
|
|
1550
|
-
<button class="quirk-filter px-2 py-1 rounded text-xs ${!filter ? 'bg-brand-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'}" data-type="">All</button>
|
|
1551
|
-
${types.map(t => `
|
|
1552
|
-
<button class="quirk-filter px-2 py-1 rounded text-xs ${filter === t ? 'bg-brand-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'}" data-type="${escapeHtml(t)}">${escapeHtml(t)}</button>
|
|
1553
|
-
`).join('')}
|
|
1554
|
-
</div>
|
|
1555
|
-
<button id="quirk-lint-btn" class="px-3 py-1 bg-slate-700 rounded text-sm hover:bg-slate-600">Lint</button>
|
|
1556
|
-
</div>
|
|
1557
|
-
<div id="quirk-lint-area" class="hidden mb-4"></div>
|
|
1558
|
-
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
|
1559
|
-
${filtered.map(q => `
|
|
1560
|
-
<div class="bg-slate-900 rounded-lg border border-slate-700 p-3 flex flex-col" data-id="${escapeHtml(q.id)}">
|
|
1561
|
-
<div class="flex items-center gap-2 mb-2">
|
|
1562
|
-
${quirkTypeBadge(q.quirkType)}
|
|
1563
|
-
<span class="text-xs text-slate-500">confidence <span class="${confidenceColor(q.confidence)}">${((q.confidence ?? 1) * 100).toFixed(0)}%</span></span>
|
|
1564
|
-
<span class="text-xs text-slate-600 ml-auto">${formatQuirkDate(q.lastObserved)}</span>
|
|
1565
|
-
</div>
|
|
1566
|
-
<p class="text-sm text-slate-200 whitespace-pre-wrap flex-1">${escapeHtml(q.content)}</p>
|
|
1567
|
-
${q.tags && q.tags.length ? `
|
|
1568
|
-
<div class="flex flex-wrap gap-1 mt-2">
|
|
1569
|
-
${q.tags.map(t => `<span class="inline-block px-1.5 py-0.5 rounded text-xs bg-slate-800 text-slate-400">#${escapeHtml(t)}</span>`).join('')}
|
|
1570
|
-
</div>` : ''}
|
|
1571
|
-
${q.sourceRef ? `<div class="text-xs text-slate-600 mt-2 font-mono">src: ${escapeHtml(q.sourceRef)}</div>` : ''}
|
|
1572
|
-
<div class="flex items-center justify-between mt-2 pt-2 border-t border-slate-800">
|
|
1573
|
-
<span class="text-xs text-slate-600 font-mono">${escapeHtml(q.id)}</span>
|
|
1574
|
-
<button class="quirk-delete text-xs text-slate-600 hover:text-red-400" data-id="${escapeHtml(q.id)}">Delete</button>
|
|
1575
|
-
</div>
|
|
1576
|
-
</div>
|
|
1577
|
-
`).join('')}
|
|
1578
|
-
</div>
|
|
1579
|
-
`;
|
|
1580
|
-
|
|
1581
|
-
$$('.quirk-filter').forEach(btn => {
|
|
1582
|
-
btn.addEventListener('click', () => {
|
|
1583
|
-
state.quirkTypeFilter = btn.dataset.type || null;
|
|
1584
|
-
renderQuirks();
|
|
1585
|
-
});
|
|
1586
|
-
});
|
|
1587
|
-
|
|
1588
|
-
$$('.quirk-delete').forEach(btn => {
|
|
1589
|
-
btn.addEventListener('click', async (e) => {
|
|
1590
|
-
e.stopPropagation();
|
|
1591
|
-
const id = btn.dataset.id;
|
|
1592
|
-
if (!confirm('Delete this quirk? This cannot be undone.')) return;
|
|
1593
|
-
btn.textContent = 'Deleting...';
|
|
1594
|
-
try {
|
|
1595
|
-
await API.deleteQuirk(id);
|
|
1596
|
-
renderQuirks();
|
|
1597
|
-
} catch (err) {
|
|
1598
|
-
alert('Failed to delete quirk: ' + String(err));
|
|
1599
|
-
renderQuirks();
|
|
1600
|
-
}
|
|
1601
|
-
});
|
|
1602
|
-
});
|
|
1603
|
-
|
|
1604
|
-
$('#quirk-lint-btn')?.addEventListener('click', async () => {
|
|
1605
|
-
const area = $('#quirk-lint-area');
|
|
1606
|
-
if (!area) return;
|
|
1607
|
-
if (!area.classList.contains('hidden')) {
|
|
1608
|
-
area.classList.add('hidden');
|
|
1609
|
-
area.innerHTML = '';
|
|
1610
|
-
return;
|
|
1611
|
-
}
|
|
1612
|
-
area.classList.remove('hidden');
|
|
1613
|
-
area.innerHTML = '<div class="text-center py-3 text-slate-500">Running lint...</div>';
|
|
1614
|
-
try {
|
|
1615
|
-
const res = await API.quirkLint();
|
|
1616
|
-
const issues = res.issues || [];
|
|
1617
|
-
if (issues.length === 0) {
|
|
1618
|
-
area.innerHTML = `<div class="bg-green-900/20 border border-green-800 rounded-lg p-3 text-sm text-green-300">All quirks look healthy. No issues found.</div>`;
|
|
1619
|
-
} else {
|
|
1620
|
-
area.innerHTML = `
|
|
1621
|
-
<div class="bg-slate-900 rounded-lg border border-amber-800 p-3">
|
|
1622
|
-
<div class="text-sm font-semibold text-amber-300 mb-2">${issues.length} issue(s) found</div>
|
|
1623
|
-
<ul class="space-y-1 text-xs text-slate-300 list-disc list-inside">
|
|
1624
|
-
${issues.map(i => `<li>${escapeHtml(i)}</li>`).join('')}
|
|
1625
|
-
</ul>
|
|
1626
|
-
</div>`;
|
|
1627
|
-
}
|
|
1628
|
-
} catch (err) {
|
|
1629
|
-
area.innerHTML = `<div class="bg-red-900/20 border border-red-800 rounded-lg p-3 text-sm text-red-300">Lint failed: ${escapeHtml(String(err))}</div>`;
|
|
1630
|
-
}
|
|
1631
|
-
});
|
|
1632
|
-
}
|
|
1633
|
-
|
|
1634
|
-
// Init
|
|
1635
|
-
renderDashboard();
|
|
1636
|
-
</script>
|
|
12
|
+
<body class="h-screen flex flex-col overflow-hidden bg-slate-900 text-slate-200">
|
|
13
|
+
<div id="app"></div>
|
|
1637
14
|
</body>
|
|
1638
15
|
</html>
|