opencode-rag-plugin 1.7.1 → 1.8.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 +22 -10
- package/dist/cli.js +47 -1
- package/dist/cli.js.map +1 -1
- package/dist/core/config.d.ts +5 -0
- package/dist/core/config.js +8 -0
- package/dist/core/config.js.map +1 -1
- package/dist/eval/index.d.ts +5 -0
- package/dist/eval/index.js +4 -0
- package/dist/eval/index.js.map +1 -0
- package/dist/eval/session-logger.d.ts +16 -0
- package/dist/eval/session-logger.js +124 -0
- package/dist/eval/session-logger.js.map +1 -0
- package/dist/eval/storage.d.ts +12 -0
- package/dist/eval/storage.js +184 -0
- package/dist/eval/storage.js.map +1 -0
- package/dist/eval/types.d.ts +89 -0
- package/dist/eval/types.js +17 -0
- package/dist/eval/types.js.map +1 -0
- package/dist/plugin.js +37 -14
- package/dist/plugin.js.map +1 -1
- package/dist/retriever/keyword-index.js +2 -0
- package/dist/retriever/keyword-index.js.map +1 -1
- package/dist/tui.js +310 -0
- package/dist/tui.js.map +1 -1
- package/dist/vectorstore/lancedb.d.ts +2 -0
- package/dist/vectorstore/lancedb.js +2 -0
- package/dist/vectorstore/lancedb.js.map +1 -1
- package/dist/web/api.d.ts +4 -0
- package/dist/web/api.js +197 -0
- package/dist/web/api.js.map +1 -0
- package/dist/web/server.d.ts +5 -0
- package/dist/web/server.js +41 -0
- package/dist/web/server.js.map +1 -0
- package/dist/web/static.d.ts +1 -0
- package/dist/web/static.js +13 -0
- package/dist/web/static.js.map +1 -0
- package/dist/web/ui/index.html +1078 -0
- package/package.json +6 -2
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>OpenCodeRAG - Vector Database Explorer</title>
|
|
7
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
8
|
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
|
|
9
|
+
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
|
10
|
+
<script>
|
|
11
|
+
tailwind.config = {
|
|
12
|
+
darkMode: 'class',
|
|
13
|
+
theme: {
|
|
14
|
+
extend: {
|
|
15
|
+
colors: {
|
|
16
|
+
brand: { 400: '#22d3ee', 500: '#06b6d4', 600: '#0891b2' }
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
<style>
|
|
23
|
+
body { background: #0f172a; color: #e2e8f0; }
|
|
24
|
+
.scrollbar-thin::-webkit-scrollbar { width: 6px; }
|
|
25
|
+
.scrollbar-thin::-webkit-scrollbar-track { background: #1e293b; }
|
|
26
|
+
.scrollbar-thin::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
|
|
27
|
+
.chunk-row { border-left: 2px solid transparent; }
|
|
28
|
+
.chunk-row:hover { background: #1e293b; }
|
|
29
|
+
.chunk-row.selected { background: #1e293b; border-left-color: #06b6d4; }
|
|
30
|
+
.file-item:hover { background: #1e293b; }
|
|
31
|
+
.file-item.active { background: #1e293b; border-left: 2px solid #06b6d4; }
|
|
32
|
+
.kpi-card { background: #1e293b; border: 1px solid #334155; border-radius: 0.75rem; }
|
|
33
|
+
.nav-btn.active { background: #06b6d4; color: #0f172a; }
|
|
34
|
+
pre code.hljs { background: transparent; padding: 0; }
|
|
35
|
+
</style>
|
|
36
|
+
</head>
|
|
37
|
+
<body class="h-screen flex flex-col overflow-hidden">
|
|
38
|
+
<!-- Top Bar -->
|
|
39
|
+
<header class="flex items-center gap-4 px-4 py-2 bg-slate-800 border-b border-slate-700 shrink-0">
|
|
40
|
+
<h1 class="text-lg font-bold text-brand-400">OpenCodeRAG</h1>
|
|
41
|
+
<nav class="flex gap-1">
|
|
42
|
+
<button id="nav-dashboard" class="nav-btn active px-3 py-1 rounded text-sm font-medium transition">Dashboard</button>
|
|
43
|
+
<button id="nav-chunks" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Chunks</button>
|
|
44
|
+
<button id="nav-files" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Files</button>
|
|
45
|
+
<button id="nav-compare" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Compare</button>
|
|
46
|
+
<button id="nav-evaluate" class="nav-btn px-3 py-1 rounded text-sm font-medium hover:bg-slate-700 transition">Evaluate</button>
|
|
47
|
+
</nav>
|
|
48
|
+
<div class="flex-1"></div>
|
|
49
|
+
<div class="relative">
|
|
50
|
+
<input id="global-search" type="text" placeholder="Search chunks..."
|
|
51
|
+
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">
|
|
52
|
+
<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>
|
|
53
|
+
</div>
|
|
54
|
+
</header>
|
|
55
|
+
|
|
56
|
+
<div class="flex flex-1 overflow-hidden">
|
|
57
|
+
<!-- Sidebar: File Tree -->
|
|
58
|
+
<aside id="sidebar" class="w-64 bg-slate-850 border-r border-slate-700 overflow-y-auto scrollbar-thin shrink-0" style="background:#0f172a;">
|
|
59
|
+
<div class="p-3">
|
|
60
|
+
<div class="flex items-center justify-between mb-2">
|
|
61
|
+
<h2 class="text-xs font-semibold text-slate-400 uppercase tracking-wide">Files</h2>
|
|
62
|
+
<span id="file-count" class="text-xs text-slate-500">0</span>
|
|
63
|
+
</div>
|
|
64
|
+
<input id="file-filter" type="text" placeholder="Filter files..."
|
|
65
|
+
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">
|
|
66
|
+
<div id="file-tree" class="text-sm"></div>
|
|
67
|
+
</div>
|
|
68
|
+
</aside>
|
|
69
|
+
|
|
70
|
+
<!-- Main Content -->
|
|
71
|
+
<main id="main-content" class="flex-1 overflow-hidden p-4">
|
|
72
|
+
<div id="view-dashboard" class="h-full overflow-y-auto scrollbar-thin"></div>
|
|
73
|
+
<div id="view-chunks" class="hidden h-full flex gap-4">
|
|
74
|
+
<div id="chunks-master" class="w-1/2 overflow-y-auto scrollbar-thin pr-1 border-r border-slate-700"></div>
|
|
75
|
+
<div id="chunks-detail" class="w-1/2 overflow-y-auto scrollbar-thin pl-1"></div>
|
|
76
|
+
</div>
|
|
77
|
+
<div id="view-files" class="h-full overflow-y-auto scrollbar-thin"></div>
|
|
78
|
+
<div id="view-compare" class="h-full overflow-y-auto scrollbar-thin"></div>
|
|
79
|
+
<div id="view-evaluate" class="hidden h-full overflow-y-auto scrollbar-thin"></div>
|
|
80
|
+
</main>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<script>
|
|
84
|
+
const API = {
|
|
85
|
+
async stats() { const r = await fetch('/api/stats'); return r.json(); },
|
|
86
|
+
async files() { const r = await fetch('/api/files'); return r.json(); },
|
|
87
|
+
async chunks(offset=0, limit=50, lang='', file='') {
|
|
88
|
+
const p = new URLSearchParams({offset, limit});
|
|
89
|
+
if (lang) p.set('lang', lang);
|
|
90
|
+
if (file) p.set('file', file);
|
|
91
|
+
const r = await fetch('/api/chunks?' + p); return r.json();
|
|
92
|
+
},
|
|
93
|
+
async chunk(id) { const r = await fetch('/api/chunks/' + encodeURIComponent(id)); return r.json(); },
|
|
94
|
+
async search(q, topK=20) {
|
|
95
|
+
const p = new URLSearchParams({q, topK});
|
|
96
|
+
const r = await fetch('/api/search?' + p); return r.json();
|
|
97
|
+
},
|
|
98
|
+
async compare(ids) {
|
|
99
|
+
const p = new URLSearchParams({ids: ids.join(',')});
|
|
100
|
+
const r = await fetch('/api/compare?' + p); return r.json();
|
|
101
|
+
},
|
|
102
|
+
async evalSessions() { const r = await fetch('/api/eval/sessions'); return r.json(); },
|
|
103
|
+
async evalSession(id) { const r = await fetch('/api/eval/sessions/' + encodeURIComponent(id)); return r.json(); },
|
|
104
|
+
async evalDeleteSession(id) { const r = await fetch('/api/eval/sessions/' + encodeURIComponent(id), { method: 'DELETE' }); return r.json(); },
|
|
105
|
+
async evalCompare(a, b) { const p = new URLSearchParams({a, b}); const r = await fetch('/api/eval/compare?' + p); return r.json(); },
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const state = {
|
|
109
|
+
currentView: 'dashboard',
|
|
110
|
+
selectedFile: null,
|
|
111
|
+
selectedLang: null,
|
|
112
|
+
selectedChunkId: null,
|
|
113
|
+
chunkOffset: 0,
|
|
114
|
+
chunkLimit: 50,
|
|
115
|
+
compareIds: new Set(),
|
|
116
|
+
collapsedDirs: new Set(),
|
|
117
|
+
stats: null,
|
|
118
|
+
files: [],
|
|
119
|
+
currentChunks: [],
|
|
120
|
+
evalSelectedSessions: new Set(),
|
|
121
|
+
evalSessionDetail: null,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function $(sel) { return document.querySelector(sel); }
|
|
125
|
+
function $$(sel) { return document.querySelectorAll(sel); }
|
|
126
|
+
|
|
127
|
+
function showView(name) {
|
|
128
|
+
state.currentView = name;
|
|
129
|
+
$$('#main-content > div').forEach(d => d.classList.add('hidden'));
|
|
130
|
+
const el = $(`#view-${name}`);
|
|
131
|
+
if (el) el.classList.remove('hidden');
|
|
132
|
+
$$('.nav-btn').forEach(b => b.classList.remove('active'));
|
|
133
|
+
const btn = $(`#nav-${name}`);
|
|
134
|
+
if (btn) btn.classList.add('active');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function langColor(lang) {
|
|
138
|
+
const map = {
|
|
139
|
+
typescript: 'text-blue-400', javascript: 'text-yellow-400', python: 'text-green-400',
|
|
140
|
+
java: 'text-red-400', go: 'text-cyan-400', rust: 'text-orange-400', ruby: 'text-pink-400',
|
|
141
|
+
csharp: 'text-purple-400', cpp: 'text-indigo-400', c: 'text-gray-400', markdown: 'text-gray-300',
|
|
142
|
+
html: 'text-orange-300', css: 'text-blue-300', json: 'text-yellow-300', kotlin: 'text-purple-300',
|
|
143
|
+
swift: 'text-orange-400', tex: 'text-emerald-400', sql: 'text-cyan-300'
|
|
144
|
+
};
|
|
145
|
+
return map[lang] || 'text-slate-400';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function langBadge(lang) {
|
|
149
|
+
return `<span class="inline-block px-1.5 py-0.5 rounded text-xs font-mono ${langColor(lang)} bg-slate-800">${lang}</span>`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function truncate(s, len=80) {
|
|
153
|
+
if (!s) return '';
|
|
154
|
+
return s.length > len ? s.slice(0, len) + '...' : s;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function escapeHtml(s) {
|
|
158
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function highlightCode(el) {
|
|
162
|
+
el.querySelectorAll('pre code').forEach(b => {
|
|
163
|
+
b.className = b.className.replace(/language-\w+/g, '');
|
|
164
|
+
hljs.highlightElement(b);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function renderDashboard() {
|
|
169
|
+
const [stats, files] = await Promise.all([API.stats(), API.files()]);
|
|
170
|
+
state.stats = stats;
|
|
171
|
+
state.files = files;
|
|
172
|
+
renderFileTree(files);
|
|
173
|
+
|
|
174
|
+
const topLangs = stats.languages.slice(0, 8);
|
|
175
|
+
const maxCount = topLangs[0]?.count || 1;
|
|
176
|
+
|
|
177
|
+
const el = $('#view-dashboard');
|
|
178
|
+
el.innerHTML = `
|
|
179
|
+
<div class="grid grid-cols-4 gap-4 mb-6">
|
|
180
|
+
<div class="kpi-card p-4">
|
|
181
|
+
<div class="text-slate-400 text-xs mb-1">Total Chunks</div>
|
|
182
|
+
<div class="text-3xl font-bold text-white">${stats.totalChunks.toLocaleString()}</div>
|
|
183
|
+
</div>
|
|
184
|
+
<div class="kpi-card p-4">
|
|
185
|
+
<div class="text-slate-400 text-xs mb-1">Total Files</div>
|
|
186
|
+
<div class="text-3xl font-bold text-white">${stats.totalFiles.toLocaleString()}</div>
|
|
187
|
+
</div>
|
|
188
|
+
<div class="kpi-card p-4">
|
|
189
|
+
<div class="text-slate-400 text-xs mb-1">Languages</div>
|
|
190
|
+
<div class="text-3xl font-bold text-white">${stats.languages.length}</div>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="kpi-card p-4">
|
|
193
|
+
<div class="text-slate-400 text-xs mb-1">Avg Chunks/File</div>
|
|
194
|
+
<div class="text-3xl font-bold text-white">${stats.totalFiles > 0 ? (stats.totalChunks / stats.totalFiles).toFixed(1) : '0'}</div>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
<div class="kpi-card p-4">
|
|
198
|
+
<h3 class="text-sm font-semibold text-slate-300 mb-3">Language Distribution</h3>
|
|
199
|
+
<div class="space-y-2">
|
|
200
|
+
${topLangs.map(l => `
|
|
201
|
+
<div class="flex items-center gap-3">
|
|
202
|
+
<span class="w-24 text-xs text-slate-400 text-right">${l.language}</span>
|
|
203
|
+
<div class="flex-1 bg-slate-800 rounded-full h-5 overflow-hidden">
|
|
204
|
+
<div class="h-full rounded-full bg-brand-500 flex items-center pl-2"
|
|
205
|
+
style="width:${Math.max(8, (l.count / maxCount) * 100)}%">
|
|
206
|
+
<span class="text-xs font-medium text-white">${l.count}</span>
|
|
207
|
+
</div>
|
|
208
|
+
</div>
|
|
209
|
+
<span class="text-xs text-slate-500 w-12 text-right">${((l.count / stats.totalChunks) * 100).toFixed(0)}%</span>
|
|
210
|
+
</div>
|
|
211
|
+
`).join('')}
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function renderChunks() {
|
|
218
|
+
const data = await API.chunks(state.chunkOffset, state.chunkLimit, state.selectedLang || '', state.selectedFile || '');
|
|
219
|
+
state.currentChunks = data.chunks;
|
|
220
|
+
|
|
221
|
+
const totalPages = Math.ceil(data.total / state.chunkLimit);
|
|
222
|
+
const currentPage = Math.floor(state.chunkOffset / state.chunkLimit) + 1;
|
|
223
|
+
|
|
224
|
+
const master = $('#chunks-master');
|
|
225
|
+
const detail = $('#chunks-detail');
|
|
226
|
+
|
|
227
|
+
master.innerHTML = `
|
|
228
|
+
<div class="flex items-center gap-3 mb-3">
|
|
229
|
+
<h2 class="text-lg font-semibold text-white">Chunks</h2>
|
|
230
|
+
<span class="text-sm text-slate-400">${data.total} total</span>
|
|
231
|
+
${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>` : ''}
|
|
232
|
+
${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>` : ''}
|
|
233
|
+
</div>
|
|
234
|
+
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
235
|
+
<table class="w-full text-sm">
|
|
236
|
+
<thead>
|
|
237
|
+
<tr class="bg-slate-800 text-slate-400 text-xs">
|
|
238
|
+
<th class="px-3 py-2 text-left w-8"></th>
|
|
239
|
+
<th class="px-3 py-2 text-left">File</th>
|
|
240
|
+
<th class="px-3 py-2 text-left w-20">Lang</th>
|
|
241
|
+
<th class="px-3 py-2 text-left">Description</th>
|
|
242
|
+
</tr>
|
|
243
|
+
</thead>
|
|
244
|
+
<tbody>
|
|
245
|
+
${data.chunks.map((c, i) => {
|
|
246
|
+
const id = c.id || `chunk-${i}`;
|
|
247
|
+
const checked = state.compareIds.has(id) ? 'checked' : '';
|
|
248
|
+
const selected = state.selectedChunkId === id ? ' selected' : '';
|
|
249
|
+
return `
|
|
250
|
+
<tr class="chunk-row${selected} border-t border-slate-800 cursor-pointer" data-id="${id}" data-index="${i}">
|
|
251
|
+
<td class="px-3 py-2"><input type="checkbox" class="compare-check rounded" data-id="${id}" ${checked}></td>
|
|
252
|
+
<td class="px-3 py-2 text-yellow-400 font-mono text-xs">${truncate(c.filePath, 40)}:${c.startLine}-${c.endLine}</td>
|
|
253
|
+
<td class="px-3 py-2">${langBadge(c.language)}</td>
|
|
254
|
+
<td class="px-3 py-2 text-slate-400 text-xs">${truncate(c.description || '', 50)}</td>
|
|
255
|
+
</tr>`;
|
|
256
|
+
}).join('')}
|
|
257
|
+
</tbody>
|
|
258
|
+
</table>
|
|
259
|
+
</div>
|
|
260
|
+
<div class="flex items-center justify-between mt-3">
|
|
261
|
+
<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>
|
|
262
|
+
<span class="text-sm text-slate-400">Page ${currentPage} of ${totalPages}</span>
|
|
263
|
+
<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>
|
|
264
|
+
</div>
|
|
265
|
+
`;
|
|
266
|
+
|
|
267
|
+
// Row click -> select and show detail
|
|
268
|
+
$$('.chunk-row').forEach(row => {
|
|
269
|
+
row.addEventListener('click', (e) => {
|
|
270
|
+
if (e.target.type === 'checkbox') return;
|
|
271
|
+
const id = row.dataset.id;
|
|
272
|
+
const idx = parseInt(row.dataset.index, 10);
|
|
273
|
+
if (!id) return;
|
|
274
|
+
state.selectedChunkId = id;
|
|
275
|
+
$$('.chunk-row').forEach(r => r.classList.remove('selected'));
|
|
276
|
+
row.classList.add('selected');
|
|
277
|
+
renderChunkDetail(data.chunks[idx]);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
$$('.compare-check').forEach(cb => {
|
|
282
|
+
cb.addEventListener('change', (e) => {
|
|
283
|
+
const id = e.target.dataset.id;
|
|
284
|
+
if (e.target.checked) state.compareIds.add(id);
|
|
285
|
+
else state.compareIds.delete(id);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const prevBtn = $('#prev-page');
|
|
290
|
+
const nextBtn = $('#next-page');
|
|
291
|
+
if (prevBtn) prevBtn.addEventListener('click', () => { state.chunkOffset = Math.max(0, state.chunkOffset - state.chunkLimit); renderChunks(); });
|
|
292
|
+
if (nextBtn) nextBtn.addEventListener('click', () => { state.chunkOffset += state.chunkLimit; renderChunks(); });
|
|
293
|
+
|
|
294
|
+
const clearLang = $('#clear-lang');
|
|
295
|
+
const clearFile = $('#clear-file');
|
|
296
|
+
if (clearLang) clearLang.addEventListener('click', () => { state.selectedLang = null; state.chunkOffset = 0; renderChunks(); });
|
|
297
|
+
if (clearFile) clearFile.addEventListener('click', () => { state.selectedFile = null; state.chunkOffset = 0; renderChunks(); });
|
|
298
|
+
|
|
299
|
+
// Auto-select first chunk if none selected or current selection not in page
|
|
300
|
+
if (data.chunks.length > 0) {
|
|
301
|
+
const inPage = data.chunks.some(c => c.id === state.selectedChunkId);
|
|
302
|
+
if (!inPage) {
|
|
303
|
+
state.selectedChunkId = data.chunks[0].id;
|
|
304
|
+
renderChunkDetail(data.chunks[0]);
|
|
305
|
+
$$('.chunk-row').forEach(r => r.classList.remove('selected'));
|
|
306
|
+
const first = $('.chunk-row');
|
|
307
|
+
if (first) first.classList.add('selected');
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
detail.innerHTML = `
|
|
311
|
+
<div class="flex items-center justify-center h-full text-slate-500">
|
|
312
|
+
<div class="text-center">
|
|
313
|
+
<div class="text-4xl mb-2">🔍</div>
|
|
314
|
+
<div>No chunks found</div>
|
|
315
|
+
</div>
|
|
316
|
+
</div>`;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function renderChunkDetail(c) {
|
|
321
|
+
const detail = $('#chunks-detail');
|
|
322
|
+
if (!detail) return;
|
|
323
|
+
|
|
324
|
+
detail.innerHTML = `
|
|
325
|
+
<div class="mb-3">
|
|
326
|
+
<div class="flex items-center gap-3 mb-1">
|
|
327
|
+
<span class="text-yellow-400 font-mono text-sm">${c.filePath}</span>
|
|
328
|
+
</div>
|
|
329
|
+
<div class="flex items-center gap-3 text-sm text-slate-400">
|
|
330
|
+
<span>Lines ${c.startLine}-${c.endLine}</span>
|
|
331
|
+
${langBadge(c.language)}
|
|
332
|
+
${c.id ? `<span class="text-xs text-slate-600 font-mono">${c.id}</span>` : ''}
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
${c.description ? `
|
|
336
|
+
<div class="kpi-card p-3 mb-3">
|
|
337
|
+
<h3 class="text-xs font-semibold text-slate-400 mb-1">Description</h3>
|
|
338
|
+
<p class="text-sm text-slate-300">${c.description}</p>
|
|
339
|
+
</div>` : ''}
|
|
340
|
+
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
341
|
+
<div class="px-3 py-1.5 bg-slate-800 border-b border-slate-700 flex items-center justify-between">
|
|
342
|
+
<span class="text-xs text-slate-400">Source Code</span>
|
|
343
|
+
<button class="copy-btn text-xs text-slate-500 hover:text-white">Copy</button>
|
|
344
|
+
</div>
|
|
345
|
+
<pre class="p-3 overflow-x-auto text-sm max-h-[calc(100vh-220px)]"><code class="language-${c.language}">${escapeHtml(c.content || '')}</code></pre>
|
|
346
|
+
</div>
|
|
347
|
+
`;
|
|
348
|
+
|
|
349
|
+
detail.querySelectorAll('.copy-btn').forEach(btn => {
|
|
350
|
+
btn.addEventListener('click', () => {
|
|
351
|
+
navigator.clipboard.writeText(c.content || '');
|
|
352
|
+
btn.textContent = 'Copied!';
|
|
353
|
+
setTimeout(() => { btn.textContent = 'Copy'; }, 1500);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
highlightCode(detail);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function renderFileTree(files) {
|
|
361
|
+
const tree = $('#file-tree');
|
|
362
|
+
const filter = $('#file-filter')?.value?.toLowerCase() || '';
|
|
363
|
+
|
|
364
|
+
const filtered = filter
|
|
365
|
+
? files.filter(f => f.filePath.toLowerCase().includes(filter))
|
|
366
|
+
: files;
|
|
367
|
+
|
|
368
|
+
$('#file-count').textContent = filtered.length;
|
|
369
|
+
|
|
370
|
+
const dirs = {};
|
|
371
|
+
for (const f of filtered) {
|
|
372
|
+
const parts = f.filePath.split('/');
|
|
373
|
+
let current = dirs;
|
|
374
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
375
|
+
if (!current[parts[i]]) current[parts[i]] = {};
|
|
376
|
+
current = current[parts[i]];
|
|
377
|
+
}
|
|
378
|
+
if (!current.__files) current.__files = [];
|
|
379
|
+
current.__files.push(f);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function renderDir(obj, depth=0, parentPath='') {
|
|
383
|
+
let html = '';
|
|
384
|
+
const entries = Object.entries(obj).filter(([k]) => k !== '__files').sort(([a], [b]) => a.localeCompare(b));
|
|
385
|
+
const files = obj.__files || [];
|
|
386
|
+
|
|
387
|
+
for (const [name, children] of entries) {
|
|
388
|
+
const dirPath = parentPath ? `${parentPath}/${name}` : name;
|
|
389
|
+
const childCount = countFiles(children);
|
|
390
|
+
const isCollapsed = state.collapsedDirs.has(dirPath);
|
|
391
|
+
const arrow = isCollapsed ? '▸' : '▾';
|
|
392
|
+
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}">
|
|
393
|
+
<span class="text-xs">${arrow}</span>
|
|
394
|
+
<span class="text-xs">📁</span>
|
|
395
|
+
<span class="text-xs">${name}</span>
|
|
396
|
+
<span class="text-xs text-slate-600 ml-auto">${childCount}</span>
|
|
397
|
+
</div>`;
|
|
398
|
+
const hidden = isCollapsed ? ' style="display:none"' : '';
|
|
399
|
+
html += `<div class="dir-children" data-dir-parent="${dirPath}"${hidden}>`;
|
|
400
|
+
html += renderDir(children, depth + 1, dirPath);
|
|
401
|
+
html += '</div>';
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (const f of files) {
|
|
405
|
+
const name = f.filePath.split('/').pop();
|
|
406
|
+
const active = state.selectedFile === f.filePath ? 'active text-white' : 'text-slate-400 hover:text-white';
|
|
407
|
+
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}">
|
|
408
|
+
<span class="text-xs ${langColor(f.language)}">◆</span>
|
|
409
|
+
<span class="text-xs truncate">${name}</span>
|
|
410
|
+
<span class="text-xs text-slate-600 ml-auto">${f.chunkCount}</span>
|
|
411
|
+
</div>`;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return html;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function countFiles(obj) {
|
|
418
|
+
let count = (obj.__files || []).length;
|
|
419
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
420
|
+
if (k !== '__files') count += countFiles(v);
|
|
421
|
+
}
|
|
422
|
+
return count;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
tree.innerHTML = renderDir(dirs);
|
|
426
|
+
|
|
427
|
+
$$('.file-item[data-dir-key]').forEach(el => {
|
|
428
|
+
el.addEventListener('click', () => {
|
|
429
|
+
const dirKey = el.dataset.dirKey;
|
|
430
|
+
if (state.collapsedDirs.has(dirKey)) {
|
|
431
|
+
state.collapsedDirs.delete(dirKey);
|
|
432
|
+
} else {
|
|
433
|
+
state.collapsedDirs.add(dirKey);
|
|
434
|
+
}
|
|
435
|
+
renderFileTree(files);
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
$$('.file-item[data-file]').forEach(el => {
|
|
440
|
+
el.addEventListener('click', () => {
|
|
441
|
+
state.selectedFile = el.dataset.file;
|
|
442
|
+
state.selectedChunkId = null;
|
|
443
|
+
showView('chunks');
|
|
444
|
+
state.chunkOffset = 0;
|
|
445
|
+
renderChunks();
|
|
446
|
+
renderFileTree(files);
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
async function renderFiles() {
|
|
452
|
+
if (!state.files.length) {
|
|
453
|
+
const files = await API.files();
|
|
454
|
+
state.files = files;
|
|
455
|
+
renderFileTree(files);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const el = $('#view-files');
|
|
459
|
+
const filter = $('#file-filter')?.value?.toLowerCase() || '';
|
|
460
|
+
const filtered = filter
|
|
461
|
+
? state.files.filter(f => f.filePath.toLowerCase().includes(filter))
|
|
462
|
+
: state.files;
|
|
463
|
+
|
|
464
|
+
const totalChunks = filtered.reduce((s, f) => s + f.chunkCount, 0);
|
|
465
|
+
|
|
466
|
+
el.innerHTML = `
|
|
467
|
+
<div class="mb-4">
|
|
468
|
+
<div class="flex items-center gap-3 mb-3">
|
|
469
|
+
<h2 class="text-lg font-semibold text-white">Files</h2>
|
|
470
|
+
<span class="text-sm text-slate-400">${filtered.length} files, ${totalChunks} chunks</span>
|
|
471
|
+
</div>
|
|
472
|
+
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
473
|
+
<table class="w-full text-sm">
|
|
474
|
+
<thead>
|
|
475
|
+
<tr class="bg-slate-800 text-slate-400 text-xs">
|
|
476
|
+
<th class="px-3 py-2 text-left">File</th>
|
|
477
|
+
<th class="px-3 py-2 text-left w-28">Language</th>
|
|
478
|
+
<th class="px-3 py-2 text-left w-20">Chunks</th>
|
|
479
|
+
<th class="px-3 py-2 text-left w-20"></th>
|
|
480
|
+
</tr>
|
|
481
|
+
</thead>
|
|
482
|
+
<tbody>
|
|
483
|
+
${filtered.map(f => `
|
|
484
|
+
<tr class="border-t border-slate-800 hover:bg-slate-800 cursor-pointer file-row" data-file="${f.filePath}">
|
|
485
|
+
<td class="px-3 py-2 text-yellow-400 font-mono text-xs">${f.filePath}</td>
|
|
486
|
+
<td class="px-3 py-2">${langBadge(f.language)}</td>
|
|
487
|
+
<td class="px-3 py-2 text-slate-300">${f.chunkCount}</td>
|
|
488
|
+
<td class="px-3 py-2 text-slate-500 text-xs">
|
|
489
|
+
<button class="hover:text-white" data-action="view-chunks" data-file="${f.filePath}">View chunks</button>
|
|
490
|
+
</td>
|
|
491
|
+
</tr>
|
|
492
|
+
`).join('')}
|
|
493
|
+
</tbody>
|
|
494
|
+
</table>
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
`;
|
|
498
|
+
|
|
499
|
+
$$('.file-row').forEach(row => {
|
|
500
|
+
row.addEventListener('click', (e) => {
|
|
501
|
+
if (e.target.dataset.action === 'view-chunks') {
|
|
502
|
+
e.stopPropagation();
|
|
503
|
+
}
|
|
504
|
+
state.selectedFile = row.dataset.file;
|
|
505
|
+
state.selectedChunkId = null;
|
|
506
|
+
state.chunkOffset = 0;
|
|
507
|
+
showView('chunks');
|
|
508
|
+
renderChunks();
|
|
509
|
+
renderFileTree(state.files);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function renderCompare() {
|
|
515
|
+
const el = $('#view-compare');
|
|
516
|
+
const ids = [...state.compareIds];
|
|
517
|
+
|
|
518
|
+
if (ids.length === 0) {
|
|
519
|
+
el.innerHTML = `
|
|
520
|
+
<div class="text-center py-20">
|
|
521
|
+
<h2 class="text-lg font-semibold text-slate-400 mb-2">No chunks selected</h2>
|
|
522
|
+
<p class="text-sm text-slate-500">Go to the Chunks view, select 2-3 chunks using checkboxes, then come back here.</p>
|
|
523
|
+
</div>`;
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (ids.length > 3) {
|
|
528
|
+
el.innerHTML = `
|
|
529
|
+
<div class="text-center py-20">
|
|
530
|
+
<h2 class="text-lg font-semibold text-slate-400 mb-2">Too many selected</h2>
|
|
531
|
+
<p class="text-sm text-slate-500">Select at most 3 chunks to compare. Currently selected: ${ids.length}</p>
|
|
532
|
+
</div>`;
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const data = await API.compare(ids);
|
|
537
|
+
|
|
538
|
+
el.innerHTML = `
|
|
539
|
+
<div class="flex items-center gap-4 mb-4">
|
|
540
|
+
<h2 class="text-lg font-semibold text-white">Compare</h2>
|
|
541
|
+
<span class="text-sm text-slate-400">${data.chunks.length} chunks</span>
|
|
542
|
+
<button id="clear-compare" class="text-xs text-slate-500 hover:text-white">Clear selection</button>
|
|
543
|
+
</div>
|
|
544
|
+
<div class="grid gap-4" style="grid-template-columns: repeat(${data.chunks.length}, 1fr)">
|
|
545
|
+
${data.chunks.map(c => `
|
|
546
|
+
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
547
|
+
<div class="px-3 py-2 bg-slate-800 border-b border-slate-700">
|
|
548
|
+
<div class="text-xs text-yellow-400 font-mono">${c.filePath}:${c.startLine}-${c.endLine}</div>
|
|
549
|
+
<div class="flex items-center gap-2 mt-1">${langBadge(c.language)}</div>
|
|
550
|
+
</div>
|
|
551
|
+
${c.description ? `<div class="px-3 py-2 text-xs text-slate-400 border-b border-slate-800">${c.description}</div>` : ''}
|
|
552
|
+
<pre class="p-3 overflow-x-auto text-xs max-h-[600px] overflow-y-auto"><code class="language-${c.language}">${escapeHtml(c.content || '')}</code></pre>
|
|
553
|
+
</div>
|
|
554
|
+
`).join('')}
|
|
555
|
+
</div>`;
|
|
556
|
+
|
|
557
|
+
el.querySelectorAll('pre code').forEach(b => hljs.highlightElement(b));
|
|
558
|
+
$('#clear-compare')?.addEventListener('click', () => { state.compareIds.clear(); renderCompare(); });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async function selectChunkFromSearch(id) {
|
|
562
|
+
showView('chunks');
|
|
563
|
+
state.selectedChunkId = id;
|
|
564
|
+
state.chunkOffset = 0;
|
|
565
|
+
state.selectedLang = null;
|
|
566
|
+
state.selectedFile = null;
|
|
567
|
+
await renderChunks();
|
|
568
|
+
|
|
569
|
+
// Scroll to selected row and render detail
|
|
570
|
+
const row = $(`.chunk-row[data-id="${id}"]`);
|
|
571
|
+
if (row) {
|
|
572
|
+
$$('.chunk-row').forEach(r => r.classList.remove('selected'));
|
|
573
|
+
row.classList.add('selected');
|
|
574
|
+
row.scrollIntoView({ block: 'center' });
|
|
575
|
+
const idx = parseInt(row.dataset.index, 10);
|
|
576
|
+
if (!isNaN(idx) && state.currentChunks[idx]) {
|
|
577
|
+
renderChunkDetail(state.currentChunks[idx]);
|
|
578
|
+
}
|
|
579
|
+
} else {
|
|
580
|
+
// Chunk not on this page, fetch it directly
|
|
581
|
+
const chunk = await API.chunk(id);
|
|
582
|
+
if (chunk && !chunk.error) {
|
|
583
|
+
renderChunkDetail(chunk);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Global search
|
|
589
|
+
let searchTimeout;
|
|
590
|
+
$('#global-search')?.addEventListener('input', (e) => {
|
|
591
|
+
clearTimeout(searchTimeout);
|
|
592
|
+
const q = e.target.value.trim();
|
|
593
|
+
const box = $('#search-results');
|
|
594
|
+
|
|
595
|
+
if (!q) { box.classList.add('hidden'); return; }
|
|
596
|
+
|
|
597
|
+
searchTimeout = setTimeout(async () => {
|
|
598
|
+
const data = await API.search(q, 10);
|
|
599
|
+
if (!data.results?.length) {
|
|
600
|
+
box.innerHTML = '<div class="p-3 text-sm text-slate-500">No results</div>';
|
|
601
|
+
} else {
|
|
602
|
+
box.innerHTML = data.results.map(r => `
|
|
603
|
+
<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}">
|
|
604
|
+
<div class="flex items-center gap-2 text-xs">
|
|
605
|
+
<span class="text-yellow-400 font-mono">${truncate(r.chunk.filePath, 35)}:${r.chunk.startLine}-${r.chunk.endLine}</span>
|
|
606
|
+
${langBadge(r.chunk.language)}
|
|
607
|
+
<span class="ml-auto text-slate-500">${r.score}</span>
|
|
608
|
+
</div>
|
|
609
|
+
<div class="text-xs text-slate-400 mt-1 truncate">${truncate(r.chunk.content || '', 80)}</div>
|
|
610
|
+
</div>
|
|
611
|
+
`).join('');
|
|
612
|
+
}
|
|
613
|
+
box.classList.remove('hidden');
|
|
614
|
+
|
|
615
|
+
$$('.search-result').forEach(el => {
|
|
616
|
+
el.addEventListener('click', () => {
|
|
617
|
+
selectChunkFromSearch(el.dataset.id);
|
|
618
|
+
box.classList.add('hidden');
|
|
619
|
+
$('#global-search').value = '';
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
}, 300);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
document.addEventListener('click', (e) => {
|
|
626
|
+
if (!e.target.closest('#global-search') && !e.target.closest('#search-results')) {
|
|
627
|
+
$('#search-results')?.classList.add('hidden');
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// File filter
|
|
632
|
+
$('#file-filter')?.addEventListener('input', () => {
|
|
633
|
+
renderFileTree(state.files);
|
|
634
|
+
if (state.currentView === 'files') renderFiles();
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
// Nav buttons
|
|
638
|
+
$('#nav-dashboard')?.addEventListener('click', () => { showView('dashboard'); renderDashboard(); });
|
|
639
|
+
$('#nav-chunks')?.addEventListener('click', () => { showView('chunks'); state.chunkOffset = 0; state.selectedChunkId = null; renderChunks(); });
|
|
640
|
+
$('#nav-files')?.addEventListener('click', () => {
|
|
641
|
+
showView('files');
|
|
642
|
+
renderFiles();
|
|
643
|
+
});
|
|
644
|
+
$('#nav-compare')?.addEventListener('click', () => { showView('compare'); renderCompare(); });
|
|
645
|
+
$('#nav-evaluate')?.addEventListener('click', () => { showView('evaluate'); renderEvaluate(); });
|
|
646
|
+
|
|
647
|
+
// ── Evaluate View ──────────────────────────────────────────────────
|
|
648
|
+
|
|
649
|
+
function formatTokens(n) {
|
|
650
|
+
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
651
|
+
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
|
652
|
+
return String(n);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
function formatCost(n) {
|
|
656
|
+
if (n === 0) return '$0.00';
|
|
657
|
+
if (n < 0.01) return '$' + n.toFixed(4);
|
|
658
|
+
return '$' + n.toFixed(2);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function formatMs(ms) {
|
|
662
|
+
if (ms >= 60000) return (ms / 60000).toFixed(1) + 'm';
|
|
663
|
+
if (ms >= 1000) return (ms / 1000).toFixed(1) + 's';
|
|
664
|
+
return ms + 'ms';
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
function formatTimestamp(ts) {
|
|
668
|
+
if (!ts) return '-';
|
|
669
|
+
const d = new Date(ts);
|
|
670
|
+
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
function deltaStr(val, suffix) {
|
|
674
|
+
if (val === 0) return '<span class="text-slate-500">-</span>';
|
|
675
|
+
const sign = val > 0 ? '+' : '';
|
|
676
|
+
const color = val > 0 ? 'text-red-400' : 'text-green-400';
|
|
677
|
+
return `<span class="${color}">${sign}${typeof val === 'number' ? (suffix === '$' ? formatCost(val) : suffix === '%' ? val.toFixed(1) + '%' : formatTokens(val)) : val}</span>`;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
async function renderEvaluate() {
|
|
681
|
+
const el = $('#view-evaluate');
|
|
682
|
+
el.innerHTML = '<div class="text-center py-10 text-slate-500">Loading sessions...</div>';
|
|
683
|
+
|
|
684
|
+
const data = await API.evalSessions();
|
|
685
|
+
const sessions = data.sessions || [];
|
|
686
|
+
|
|
687
|
+
if (sessions.length === 0) {
|
|
688
|
+
el.innerHTML = `
|
|
689
|
+
<div class="text-center py-20">
|
|
690
|
+
<div class="text-4xl mb-3">📊</div>
|
|
691
|
+
<h2 class="text-lg font-semibold text-slate-400 mb-2">No sessions recorded yet</h2>
|
|
692
|
+
<p class="text-sm text-slate-500 max-w-md mx-auto">
|
|
693
|
+
Sessions are logged automatically when the OpenCodeRAG plugin is active.
|
|
694
|
+
Use OpenCode normally — every conversation is captured here with token usage, timing, and RAG context data.
|
|
695
|
+
</p>
|
|
696
|
+
</div>`;
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const selected = state.evalSelectedSessions;
|
|
701
|
+
|
|
702
|
+
el.innerHTML = `
|
|
703
|
+
<div class="flex items-center gap-4 mb-4">
|
|
704
|
+
<h2 class="text-lg font-semibold text-white">Evaluate</h2>
|
|
705
|
+
<span class="text-sm text-slate-400">${sessions.length} session${sessions.length === 1 ? '' : 's'}</span>
|
|
706
|
+
<div class="flex-1"></div>
|
|
707
|
+
<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>
|
|
708
|
+
<button id="eval-select-all" class="px-2 py-1 bg-slate-700 rounded text-xs hover:bg-slate-600">Select All</button>
|
|
709
|
+
<button id="eval-clear-sel" class="px-2 py-1 bg-slate-700 rounded text-xs hover:bg-slate-600">Clear</button>
|
|
710
|
+
</div>
|
|
711
|
+
|
|
712
|
+
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden mb-6">
|
|
713
|
+
<table class="w-full text-sm">
|
|
714
|
+
<thead>
|
|
715
|
+
<tr class="bg-slate-800 text-slate-400 text-xs">
|
|
716
|
+
<th class="px-3 py-2 text-left w-8"></th>
|
|
717
|
+
<th class="px-3 py-2 text-left">Session</th>
|
|
718
|
+
<th class="px-3 py-2 text-left w-36">Last Activity</th>
|
|
719
|
+
<th class="px-3 py-2 text-right w-24">Messages</th>
|
|
720
|
+
<th class="px-3 py-2 text-right w-28">Input Tokens</th>
|
|
721
|
+
<th class="px-3 py-2 text-right w-28">Output Tokens</th>
|
|
722
|
+
<th class="px-3 py-2 text-right w-20">Cost</th>
|
|
723
|
+
<th class="px-3 py-2 text-right w-24">RAG Calls</th>
|
|
724
|
+
<th class="px-3 py-2 text-right w-28">RAG Tokens</th>
|
|
725
|
+
<th class="px-3 py-2 text-left w-28">Model</th>
|
|
726
|
+
<th class="px-3 py-2 text-left w-16"></th>
|
|
727
|
+
</tr>
|
|
728
|
+
</thead>
|
|
729
|
+
<tbody>
|
|
730
|
+
${sessions.map(s => {
|
|
731
|
+
const checked = selected.has(s.sessionID) ? 'checked' : '';
|
|
732
|
+
const title = s.title || s.sessionID.slice(0, 8);
|
|
733
|
+
const totalIn = s.totalTokens.input + s.totalTokens.cacheRead;
|
|
734
|
+
return `
|
|
735
|
+
<tr class="border-t border-slate-800 hover:bg-slate-800 cursor-pointer eval-row" data-id="${s.sessionID}">
|
|
736
|
+
<td class="px-3 py-2"><input type="checkbox" class="eval-check rounded" data-id="${s.sessionID}" ${checked}></td>
|
|
737
|
+
<td class="px-3 py-2 text-yellow-400 font-mono text-xs">${escapeHtml(truncate(title, 40))}</td>
|
|
738
|
+
<td class="px-3 py-2 text-slate-400 text-xs">${formatTimestamp(s.lastEventAt)}</td>
|
|
739
|
+
<td class="px-3 py-2 text-right text-slate-300">${s.messageCount}</td>
|
|
740
|
+
<td class="px-3 py-2 text-right text-slate-300">${formatTokens(totalIn)}</td>
|
|
741
|
+
<td class="px-3 py-2 text-right text-slate-300">${formatTokens(s.totalTokens.output)}</td>
|
|
742
|
+
<td class="px-3 py-2 text-right text-slate-300">${formatCost(s.totalCost)}</td>
|
|
743
|
+
<td class="px-3 py-2 text-right text-cyan-400">${s.ragContextCount}</td>
|
|
744
|
+
<td class="px-3 py-2 text-right text-cyan-400">${s.ragContextTokens > 0 ? formatTokens(s.ragContextTokens) : '-'}</td>
|
|
745
|
+
<td class="px-3 py-2 text-slate-400 text-xs">${s.models.length > 0 ? escapeHtml(s.models[0]) : '-'}</td>
|
|
746
|
+
<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>
|
|
747
|
+
</tr>`;
|
|
748
|
+
}).join('')}
|
|
749
|
+
</tbody>
|
|
750
|
+
</table>
|
|
751
|
+
</div>
|
|
752
|
+
|
|
753
|
+
<div id="eval-detail-area"></div>
|
|
754
|
+
<div id="eval-compare-area"></div>
|
|
755
|
+
`;
|
|
756
|
+
|
|
757
|
+
// Row click -> show detail
|
|
758
|
+
$$('.eval-row').forEach(row => {
|
|
759
|
+
row.addEventListener('click', (e) => {
|
|
760
|
+
if (e.target.type === 'checkbox' || e.target.closest('.eval-delete')) return;
|
|
761
|
+
const id = row.dataset.id;
|
|
762
|
+
renderEvalDetail(id);
|
|
763
|
+
});
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
// Checkboxes
|
|
767
|
+
$$('.eval-check').forEach(cb => {
|
|
768
|
+
cb.addEventListener('change', (e) => {
|
|
769
|
+
const id = e.target.dataset.id;
|
|
770
|
+
if (e.target.checked) selected.add(id);
|
|
771
|
+
else selected.delete(id);
|
|
772
|
+
updateEvalCompareBtn();
|
|
773
|
+
});
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
// Delete buttons
|
|
777
|
+
$$('.eval-delete').forEach(btn => {
|
|
778
|
+
btn.addEventListener('click', async (e) => {
|
|
779
|
+
e.stopPropagation();
|
|
780
|
+
const id = btn.dataset.id;
|
|
781
|
+
await API.evalDeleteSession(id);
|
|
782
|
+
selected.delete(id);
|
|
783
|
+
renderEvaluate();
|
|
784
|
+
});
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
// Compare button
|
|
788
|
+
$('#eval-compare-btn')?.addEventListener('click', () => {
|
|
789
|
+
const ids = [...selected];
|
|
790
|
+
if (ids.length === 2) renderEvalComparison(ids[0], ids[1]);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// Select all / clear
|
|
794
|
+
$('#eval-select-all')?.addEventListener('click', () => {
|
|
795
|
+
sessions.forEach(s => selected.add(s.sessionID));
|
|
796
|
+
$$('.eval-check').forEach(cb => cb.checked = true);
|
|
797
|
+
updateEvalCompareBtn();
|
|
798
|
+
});
|
|
799
|
+
$('#eval-clear-sel')?.addEventListener('click', () => {
|
|
800
|
+
selected.clear();
|
|
801
|
+
$$('.eval-check').forEach(cb => cb.checked = false);
|
|
802
|
+
updateEvalCompareBtn();
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
function updateEvalCompareBtn() {
|
|
806
|
+
const btn = $('#eval-compare-btn');
|
|
807
|
+
if (btn) {
|
|
808
|
+
btn.disabled = selected.size !== 2;
|
|
809
|
+
btn.textContent = `Compare Selected (${selected.size})`;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
async function renderEvalDetail(sessionId) {
|
|
815
|
+
const area = $('#eval-detail-area');
|
|
816
|
+
if (!area) return;
|
|
817
|
+
|
|
818
|
+
area.innerHTML = '<div class="text-center py-4 text-slate-500">Loading...</div>';
|
|
819
|
+
|
|
820
|
+
const data = await API.evalSession(sessionId);
|
|
821
|
+
if (!data || data.error) {
|
|
822
|
+
area.innerHTML = '<div class="text-center py-4 text-red-400">Session not found</div>';
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const { events, summary: s } = data;
|
|
827
|
+
const totalIn = s.totalTokens.input + s.totalTokens.cacheRead;
|
|
828
|
+
const totalAll = totalIn + s.totalTokens.output + s.totalTokens.reasoning;
|
|
829
|
+
|
|
830
|
+
const toolEntries = Object.entries(s.toolCallCounts).sort((a, b) => b[1] - a[1]);
|
|
831
|
+
|
|
832
|
+
area.innerHTML = `
|
|
833
|
+
<div class="mb-4">
|
|
834
|
+
<div class="flex items-center gap-3 mb-3">
|
|
835
|
+
<h3 class="text-base font-semibold text-white">Session Detail</h3>
|
|
836
|
+
<span class="text-xs font-mono text-slate-500">${s.sessionID}</span>
|
|
837
|
+
</div>
|
|
838
|
+
|
|
839
|
+
<div class="grid grid-cols-5 gap-3 mb-4">
|
|
840
|
+
<div class="kpi-card p-3">
|
|
841
|
+
<div class="text-slate-400 text-xs mb-1">Total Tokens</div>
|
|
842
|
+
<div class="text-xl font-bold text-white">${formatTokens(totalAll)}</div>
|
|
843
|
+
</div>
|
|
844
|
+
<div class="kpi-card p-3">
|
|
845
|
+
<div class="text-slate-400 text-xs mb-1">Input Tokens</div>
|
|
846
|
+
<div class="text-xl font-bold text-white">${formatTokens(totalIn)}</div>
|
|
847
|
+
</div>
|
|
848
|
+
<div class="kpi-card p-3">
|
|
849
|
+
<div class="text-slate-400 text-xs mb-1">Output Tokens</div>
|
|
850
|
+
<div class="text-xl font-bold text-white">${formatTokens(s.totalTokens.output)}</div>
|
|
851
|
+
</div>
|
|
852
|
+
<div class="kpi-card p-3">
|
|
853
|
+
<div class="text-slate-400 text-xs mb-1">Cost</div>
|
|
854
|
+
<div class="text-xl font-bold text-white">${formatCost(s.totalCost)}</div>
|
|
855
|
+
</div>
|
|
856
|
+
<div class="kpi-card p-3">
|
|
857
|
+
<div class="text-slate-400 text-xs mb-1">RAG Context Tokens</div>
|
|
858
|
+
<div class="text-xl font-bold text-cyan-400">${s.ragContextTokens > 0 ? formatTokens(s.ragContextTokens) : '0'}</div>
|
|
859
|
+
</div>
|
|
860
|
+
</div>
|
|
861
|
+
|
|
862
|
+
<div class="grid grid-cols-4 gap-3 mb-4">
|
|
863
|
+
<div class="kpi-card p-3">
|
|
864
|
+
<div class="text-slate-400 text-xs mb-1">Messages</div>
|
|
865
|
+
<div class="text-lg font-bold text-white">${s.messageCount}</div>
|
|
866
|
+
</div>
|
|
867
|
+
<div class="kpi-card p-3">
|
|
868
|
+
<div class="text-slate-400 text-xs mb-1">Steps</div>
|
|
869
|
+
<div class="text-lg font-bold text-white">${s.totalSteps}</div>
|
|
870
|
+
</div>
|
|
871
|
+
<div class="kpi-card p-3">
|
|
872
|
+
<div class="text-slate-400 text-xs mb-1">RAG Injections</div>
|
|
873
|
+
<div class="text-lg font-bold text-cyan-400">${s.ragContextCount}</div>
|
|
874
|
+
</div>
|
|
875
|
+
<div class="kpi-card p-3">
|
|
876
|
+
<div class="text-slate-400 text-xs mb-1">Avg Response</div>
|
|
877
|
+
<div class="text-lg font-bold text-white">${s.avgResponseTimeMs ? formatMs(s.avgResponseTimeMs) : '-'}</div>
|
|
878
|
+
</div>
|
|
879
|
+
</div>
|
|
880
|
+
|
|
881
|
+
${toolEntries.length > 0 ? `
|
|
882
|
+
<div class="kpi-card p-3 mb-4">
|
|
883
|
+
<h4 class="text-xs font-semibold text-slate-400 mb-2">Tool Calls</h4>
|
|
884
|
+
<div class="flex flex-wrap gap-2">
|
|
885
|
+
${toolEntries.map(([name, count]) => {
|
|
886
|
+
const isRag = ['opencode-rag-context','search_semantic','get_file_skeleton','find_usages','read'].includes(name);
|
|
887
|
+
const cls = isRag ? 'bg-cyan-900/50 text-cyan-300 border-cyan-700' : 'bg-slate-800 text-slate-400 border-slate-700';
|
|
888
|
+
return `<span class="inline-flex items-center gap-1 px-2 py-1 rounded text-xs border ${cls}">${escapeHtml(name)} <strong>${count}</strong></span>`;
|
|
889
|
+
}).join('')}
|
|
890
|
+
</div>
|
|
891
|
+
</div>` : ''}
|
|
892
|
+
|
|
893
|
+
${s.models.length > 0 ? `
|
|
894
|
+
<div class="kpi-card p-3 mb-4">
|
|
895
|
+
<h4 class="text-xs font-semibold text-slate-400 mb-2">Models Used</h4>
|
|
896
|
+
<div class="flex flex-wrap gap-2">
|
|
897
|
+
${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('')}
|
|
898
|
+
</div>
|
|
899
|
+
</div>` : ''}
|
|
900
|
+
</div>
|
|
901
|
+
|
|
902
|
+
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
903
|
+
<div class="px-3 py-2 bg-slate-800 border-b border-slate-700">
|
|
904
|
+
<span class="text-xs font-semibold text-slate-400">Event Timeline</span>
|
|
905
|
+
<span class="text-xs text-slate-600 ml-2">${events.length} events</span>
|
|
906
|
+
</div>
|
|
907
|
+
<div class="max-h-[500px] overflow-y-auto scrollbar-thin">
|
|
908
|
+
${events.map(ev => renderTimelineEvent(ev)).join('')}
|
|
909
|
+
</div>
|
|
910
|
+
</div>
|
|
911
|
+
`;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function renderTimelineEvent(ev) {
|
|
915
|
+
const time = formatTimestamp(ev.ts);
|
|
916
|
+
|
|
917
|
+
if (ev.event === 'message' && ev.role === 'assistant') {
|
|
918
|
+
const tokens = ev.tokens;
|
|
919
|
+
const tokenStr = tokens ? `in=${formatTokens(tokens.input)} out=${formatTokens(tokens.output)}` : '';
|
|
920
|
+
const costStr = ev.cost != null && ev.cost > 0 ? formatCost(ev.cost) : '';
|
|
921
|
+
const errorStr = ev.errorName ? `<span class="text-red-400">[${escapeHtml(ev.errorName)}]</span> ` : '';
|
|
922
|
+
const timing = ev.timeCreated && ev.timeCompleted ? formatMs(ev.timeCompleted - ev.timeCreated) : '';
|
|
923
|
+
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
924
|
+
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
925
|
+
<span class="text-purple-400 w-20 shrink-0">assistant</span>
|
|
926
|
+
<span class="text-slate-400">${errorStr}${tokenStr}${costStr ? ' <span class="text-green-400">' + costStr + '</span>' : ''}${timing ? ' <span class="text-slate-500">' + timing + '</span>' : ''}</span>
|
|
927
|
+
</div>`;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
if (ev.event === 'rag.context') {
|
|
931
|
+
const injected = ev.ragInjected ? 'injected' : 'no match';
|
|
932
|
+
const color = ev.ragInjected ? 'text-cyan-400' : 'text-slate-500';
|
|
933
|
+
const details = ev.ragInjected ? `${ev.ragChunkCount} chunks, ${formatTokens(ev.ragContextTokens || 0)} tokens, ${formatMs(ev.ragRetrievalTimeMs || 0)}` : '';
|
|
934
|
+
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
935
|
+
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
936
|
+
<span class="${color} w-20 shrink-0">rag</span>
|
|
937
|
+
<span class="text-slate-400">${injected} ${details}</span>
|
|
938
|
+
</div>`;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
if (ev.event === 'tool') {
|
|
942
|
+
const status = ev.toolStatus || '';
|
|
943
|
+
const color = status === 'completed' ? 'text-green-400' : status === 'error' ? 'text-red-400' : 'text-yellow-400';
|
|
944
|
+
const isRag = ['opencode-rag-context','search_semantic','get_file_skeleton','find_usages','read'].includes(ev.tool);
|
|
945
|
+
const dur = ev.toolDurationMs != null ? formatMs(ev.toolDurationMs) : '';
|
|
946
|
+
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
947
|
+
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
948
|
+
<span class="${color} w-20 shrink-0">${status}</span>
|
|
949
|
+
<span class="${isRag ? 'text-cyan-300' : 'text-slate-400'}">${escapeHtml(ev.tool || '')}</span>
|
|
950
|
+
${dur ? `<span class="text-slate-500 ml-auto">${dur}</span>` : ''}
|
|
951
|
+
</div>`;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (ev.event === 'step') {
|
|
955
|
+
const tokens = ev.stepTokens;
|
|
956
|
+
const tokenStr = tokens ? `in=${formatTokens(tokens.input)} out=${formatTokens(tokens.output)}` : '';
|
|
957
|
+
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
958
|
+
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
959
|
+
<span class="text-blue-400 w-20 shrink-0">step</span>
|
|
960
|
+
<span class="text-slate-400">${ev.stepReason || ''} ${tokenStr}</span>
|
|
961
|
+
</div>`;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
if (ev.event === 'session.created') {
|
|
965
|
+
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
966
|
+
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
967
|
+
<span class="text-green-400 w-20 shrink-0">created</span>
|
|
968
|
+
<span class="text-slate-400">${escapeHtml(ev.sessionTitle || ev.sessionID)}</span>
|
|
969
|
+
</div>`;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (ev.event === 'session.status') {
|
|
973
|
+
return `<div class="px-3 py-1.5 border-b border-slate-800 flex items-center gap-2 text-xs">
|
|
974
|
+
<span class="text-slate-600 w-16 shrink-0">${time.split(' ')[1] || time}</span>
|
|
975
|
+
<span class="text-slate-500 w-20 shrink-0">status</span>
|
|
976
|
+
<span class="text-slate-500">${escapeHtml(ev.sessionStatus || '')}</span>
|
|
977
|
+
</div>`;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
return '';
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
async function renderEvalComparison(idA, idB) {
|
|
984
|
+
const area = $('#eval-compare-area');
|
|
985
|
+
if (!area) return;
|
|
986
|
+
|
|
987
|
+
area.innerHTML = '<div class="text-center py-4 text-slate-500">Loading comparison...</div>';
|
|
988
|
+
|
|
989
|
+
const data = await API.evalCompare(idA, idB);
|
|
990
|
+
if (!data || data.error) {
|
|
991
|
+
area.innerHTML = `<div class="text-center py-4 text-red-400">${escapeHtml(data?.error || 'Comparison failed')}</div>`;
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const { sessionA: a, sessionB: b, delta } = data;
|
|
996
|
+
const aTotalIn = a.totalTokens.input + a.totalTokens.cacheRead;
|
|
997
|
+
const bTotalIn = b.totalTokens.input + b.totalTokens.cacheRead;
|
|
998
|
+
const aTotal = aTotalIn + a.totalTokens.output + a.totalTokens.reasoning;
|
|
999
|
+
const bTotal = bTotalIn + b.totalTokens.output + b.totalTokens.reasoning;
|
|
1000
|
+
|
|
1001
|
+
function pctDelta(aVal, bVal) {
|
|
1002
|
+
if (aVal === 0) return bVal === 0 ? 0 : 100;
|
|
1003
|
+
return ((bVal - aVal) / aVal) * 100;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
const rows = [
|
|
1007
|
+
{ label: 'Total Tokens', a: aTotal, b: bTotal, fmt: formatTokens, pct: true },
|
|
1008
|
+
{ label: 'Input Tokens', a: aTotalIn, b: bTotalIn, fmt: formatTokens, pct: true },
|
|
1009
|
+
{ label: 'Output Tokens', a: a.totalTokens.output, b: b.totalTokens.output, fmt: formatTokens, pct: true },
|
|
1010
|
+
{ label: 'Reasoning Tokens', a: a.totalTokens.reasoning, b: b.totalTokens.reasoning, fmt: formatTokens, pct: true },
|
|
1011
|
+
{ label: 'Cache Read', a: a.totalTokens.cacheRead, b: b.totalTokens.cacheRead, fmt: formatTokens, pct: false },
|
|
1012
|
+
{ label: 'Cost', a: a.totalCost, b: b.totalCost, fmt: formatCost, pct: true },
|
|
1013
|
+
{ label: 'Messages', a: a.messageCount, b: b.messageCount, fmt: String, pct: false },
|
|
1014
|
+
{ label: 'Steps', a: a.totalSteps, b: b.totalSteps, fmt: String, pct: false },
|
|
1015
|
+
{ label: 'RAG Injections', a: a.ragContextCount, b: b.ragContextCount, fmt: String, pct: false },
|
|
1016
|
+
{ label: 'RAG Context Tokens', a: a.ragContextTokens, b: b.ragContextTokens, fmt: formatTokens, pct: false },
|
|
1017
|
+
{ label: 'RAG Tool Calls', a: a.ragToolCalls, b: b.ragToolCalls, fmt: String, pct: false },
|
|
1018
|
+
{ label: 'Avg Response Time', a: a.avgResponseTimeMs || 0, b: b.avgResponseTimeMs || 0, fmt: formatMs, pct: true },
|
|
1019
|
+
];
|
|
1020
|
+
|
|
1021
|
+
area.innerHTML = `
|
|
1022
|
+
<div class="mt-6 mb-4">
|
|
1023
|
+
<div class="flex items-center gap-3 mb-3">
|
|
1024
|
+
<h3 class="text-base font-semibold text-white">Comparison</h3>
|
|
1025
|
+
<button id="eval-close-compare" class="text-xs text-slate-500 hover:text-white ml-auto">Close</button>
|
|
1026
|
+
</div>
|
|
1027
|
+
|
|
1028
|
+
<div class="bg-slate-900 rounded-lg border border-slate-700 overflow-hidden">
|
|
1029
|
+
<table class="w-full text-sm">
|
|
1030
|
+
<thead>
|
|
1031
|
+
<tr class="bg-slate-800 text-slate-400 text-xs">
|
|
1032
|
+
<th class="px-4 py-2 text-left">Metric</th>
|
|
1033
|
+
<th class="px-4 py-2 text-right w-32">${escapeHtml(truncate(a.title || a.sessionID.slice(0, 8), 20))}</th>
|
|
1034
|
+
<th class="px-4 py-2 text-right w-32">${escapeHtml(truncate(b.title || b.sessionID.slice(0, 8), 20))}</th>
|
|
1035
|
+
<th class="px-4 py-2 text-right w-28">Delta</th>
|
|
1036
|
+
<th class="px-4 py-2 text-right w-20">%</th>
|
|
1037
|
+
</tr>
|
|
1038
|
+
</thead>
|
|
1039
|
+
<tbody>
|
|
1040
|
+
${rows.map(r => {
|
|
1041
|
+
const d = r.b - r.a;
|
|
1042
|
+
const pct = r.pct ? pctDelta(r.a, r.b) : null;
|
|
1043
|
+
const dColor = d > 0 ? 'text-red-400' : d < 0 ? 'text-green-400' : 'text-slate-500';
|
|
1044
|
+
const dSign = d > 0 ? '+' : '';
|
|
1045
|
+
return `
|
|
1046
|
+
<tr class="border-t border-slate-800">
|
|
1047
|
+
<td class="px-4 py-2 text-slate-300">${r.label}</td>
|
|
1048
|
+
<td class="px-4 py-2 text-right text-slate-400">${r.fmt(r.a)}</td>
|
|
1049
|
+
<td class="px-4 py-2 text-right text-slate-400">${r.fmt(r.b)}</td>
|
|
1050
|
+
<td class="px-4 py-2 text-right ${dColor}">${d === 0 ? '-' : dSign + r.fmt(Math.abs(d))}</td>
|
|
1051
|
+
<td class="px-4 py-2 text-right ${dColor}">${pct === null ? '-' : (pct > 0 ? '+' : '') + pct.toFixed(1) + '%'}</td>
|
|
1052
|
+
</tr>`;
|
|
1053
|
+
}).join('')}
|
|
1054
|
+
</tbody>
|
|
1055
|
+
</table>
|
|
1056
|
+
</div>
|
|
1057
|
+
|
|
1058
|
+
${delta.ragContextTokens !== 0 ? `
|
|
1059
|
+
<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'}">
|
|
1060
|
+
<div class="text-xs ${delta.ragContextTokens > 0 ? 'text-cyan-300' : 'text-green-300'}">
|
|
1061
|
+
${delta.ragContextTokens > 0
|
|
1062
|
+
? `RAG added ${formatTokens(delta.ragContextTokens)} context tokens (${delta.ragContextCount} injections). The LLM processed ${formatTokens(delta.inputTokens)} more input tokens with RAG enabled.`
|
|
1063
|
+
: `RAG reduced context by ${formatTokens(Math.abs(delta.ragContextTokens))} tokens. The LLM processed ${formatTokens(Math.abs(delta.inputTokens))} fewer input tokens.`}
|
|
1064
|
+
</div>
|
|
1065
|
+
</div>` : ''}
|
|
1066
|
+
</div>
|
|
1067
|
+
`;
|
|
1068
|
+
|
|
1069
|
+
$('#eval-close-compare')?.addEventListener('click', () => {
|
|
1070
|
+
area.innerHTML = '';
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Init
|
|
1075
|
+
renderDashboard();
|
|
1076
|
+
</script>
|
|
1077
|
+
</body>
|
|
1078
|
+
</html>
|