const http = require('http'); const fs = require('fs'); const path = require('path'); const zlib = require('zlib'); const url = require('url'); const DATA_DIR = __dirname; const PORT = 7860; let convoIndex = []; let idToSource = {}; let fileCache = {}; let kpiCache = null; let kpiCacheTime = 0; function buildIndex() { const files = fs.readdirSync(DATA_DIR) .filter(f => f.startsWith('conversations-') && f.endsWith('.json')) .sort(); const entries = []; for (const fname of files) { const fpath = path.join(DATA_DIR, fname); const convos = JSON.parse(fs.readFileSync(fpath, 'utf-8')); for (const c of convos) { const cid = c.conversation_id || c.id; entries.push({ id: cid, title: c.title || '(untitled)', create_time: c.create_time, update_time: c.update_time, model: c.default_model_slug || '', _source_file: fname, }); } } entries.sort((a, b) => (b.create_time || 0) - (a.create_time || 0)); convoIndex = entries; idToSource = {}; for (const e of entries) idToSource[e.id] = e._source_file; console.error(`Indexed ${entries.length} conversations`); } function getFileConversations(sourceFile) { if (fileCache[sourceFile]) return fileCache[sourceFile]; const fpath = path.join(DATA_DIR, sourceFile); const convos = JSON.parse(fs.readFileSync(fpath, 'utf-8')); fileCache[sourceFile] = convos; return convos; } function findConversation(cid) { const sourceFile = idToSource[cid]; if (!sourceFile) return null; const convos = getFileConversations(sourceFile); return convos.find(c => (c.conversation_id || c.id) === cid) || null; } function extractMessages(conversation) { const messages = []; const mapping = conversation.mapping || {}; const current = conversation.current_node; const chain = []; let nodeId = current; while (nodeId && mapping[nodeId]) { chain.push(nodeId); nodeId = mapping[nodeId].parent; } chain.reverse(); for (const nid of chain) { const node = mapping[nid]; const msg = node.message; if (!msg) continue; const role = (msg.author && msg.author.role) || 'unknown'; const content = msg.content || {}; const parts = content.parts || []; const textParts = []; for (const p of parts) { if (typeof p === 'string') { textParts.push(p); } else if (typeof p === 'object' && p !== null) { if (p.text) { textParts.push(p.text); } else if (p.content_type === 'image_asset_pointer') { let assetId = p.asset_pointer || ''; for (const prefix of ['file-service://', 'sediment://']) { assetId = assetId.replace(prefix, ''); } textParts.push(`[image:${assetId}]`); } } } if (textParts.length === 0) continue; messages.push({ role, text: textParts.join('\n'), create_time: msg.create_time, model: (msg.metadata && msg.metadata.model_slug) || '', }); } return messages; } function computeKPIs() { const now = Date.now(); if (kpiCache && (now - kpiCacheTime) < 60000) return kpiCache; const modelCounts = {}; const dateCounts = {}; const hourCounts = {}; const weekdayCounts = {}; let earliest = null, latest = null; let totalMessages = 0, totalUser = 0, totalAssistant = 0, totalSystem = 0, totalTool = 0; let totalCharsUser = 0, totalCharsAssistant = 0, totalImages = 0; const promptLengths = [], responseLengths = []; const convoTop = []; const modelMsgStats = {}; const files = fs.readdirSync(DATA_DIR) .filter(f => f.startsWith('conversations-') && f.endsWith('.json')) .sort(); for (const fname of files) { const convos = getFileConversations(fname); for (const c of convos) { const cid = c.conversation_id || c.id; const title = c.title || '(untitled)'; const ct = c.create_time; const model = c.default_model_slug || 'unknown'; modelCounts[model] = (modelCounts[model] || 0) + 1; if (ct) { const d = new Date(ct * 1000); const dk = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}`; dateCounts[dk] = (dateCounts[dk] || 0) + 1; hourCounts[d.getHours()] = (hourCounts[d.getHours()] || 0) + 1; weekdayCounts[d.getDay()] = (weekdayCounts[d.getDay()] || 0) + 1; if (earliest === null || ct < earliest) earliest = ct; if (latest === null || ct > latest) latest = ct; } if (!modelMsgStats[model]) modelMsgStats[model] = { msgs: 0, chars: 0, convos: 0 }; modelMsgStats[model].convos++; const msgs = extractMessages(c); totalMessages += msgs.length; for (const m of msgs) { const tlen = m.text.length; if (m.text.includes('[image:')) totalImages += (m.text.match(/\[image:/g) || []).length; modelMsgStats[model].msgs++; modelMsgStats[model].chars += tlen; if (m.role === 'user') { totalUser++; totalCharsUser += tlen; promptLengths.push(tlen); } else if (m.role === 'assistant') { totalAssistant++; totalCharsAssistant += tlen; responseLengths.push(tlen); } else if (m.role === 'system') totalSystem++; else if (m.role === 'tool') totalTool++; } convoTop.push({ id: cid, title, msg_count: msgs.length, model, create_time: ct }); } } convoTop.sort((a, b) => b.msg_count - a.msg_count); const top10 = convoTop.slice(0, 10); const totalChars = totalCharsUser + totalCharsAssistant; const estTokens = Math.floor(totalChars / 4); const avgMsgs = totalMessages / convoIndex.length; const avgUserChars = totalUser ? totalCharsUser / totalUser : 0; const avgAsstChars = totalAssistant ? totalCharsAssistant / totalAssistant : 0; const bucketLabels = ['<100', '100-500', '500-1K', '1K-5K', '5K-10K', '10K+']; const promptBuckets = [0,0,0,0,0,0]; for (const pl of promptLengths) { if (pl < 100) promptBuckets[0]++; else if (pl < 500) promptBuckets[1]++; else if (pl < 1000) promptBuckets[2]++; else if (pl < 5000) promptBuckets[3]++; else if (pl < 10000) promptBuckets[4]++; else promptBuckets[5]++; } const responseBuckets = [0,0,0,0,0,0]; for (const rl of responseLengths) { if (rl < 100) responseBuckets[0]++; else if (rl < 500) responseBuckets[1]++; else if (rl < 1000) responseBuckets[2]++; else if (rl < 5000) responseBuckets[3]++; else if (rl < 10000) responseBuckets[4]++; else responseBuckets[5]++; } promptLengths.sort((a,b) => a-b); responseLengths.sort((a,b) => a-b); const medianPrompt = promptLengths.length ? promptLengths[Math.floor(promptLengths.length / 2)] : 0; const medianResponse = responseLengths.length ? responseLengths[Math.floor(responseLengths.length / 2)] : 0; const p90Prompt = promptLengths.length ? promptLengths[Math.floor(promptLengths.length * 0.9)] : 0; const p90Response = responseLengths.length ? responseLengths[Math.floor(responseLengths.length * 0.9)] : 0; // Build timeline const timeline = []; if (earliest && latest) { let cur = new Date(earliest * 1000); cur = new Date(cur.getFullYear(), cur.getMonth(), 1); const end = new Date(latest * 1000); while (cur <= end) { const key = `${cur.getFullYear()}-${String(cur.getMonth()+1).padStart(2,'0')}`; timeline.push({ month: key, count: dateCounts[key] || 0 }); cur = new Date(cur.getFullYear(), cur.getMonth() + 1, 1); } } const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const weekdayData = []; for (let i = 0; i < 7; i++) weekdayData.push({ day: dayNames[i], count: weekdayCounts[i] || 0 }); const hourData = []; for (let h = 0; h < 24; h++) hourData.push({ hour: h, count: hourCounts[h] || 0 }); const modelStats = Object.entries(modelMsgStats) .sort((a, b) => b[1].convos - a[1].convos) .map(([model, s]) => ({ model, conversations: s.convos, messages: s.msgs, chars: s.chars, avg_msgs_per_convo: Math.round(s.msgs / s.convos * 10) / 10, })); const modelDist = Object.entries(modelCounts) .sort((a, b) => b[1] - a[1]) .map(([model, count]) => ({ model, count })); const result = { total_conversations: convoIndex.length, total_messages: totalMessages, total_user_messages: totalUser, total_assistant_messages: totalAssistant, total_system_messages: totalSystem, total_tool_messages: totalTool, total_chars_user: totalCharsUser, total_chars_assistant: totalCharsAssistant, total_image_assets: totalImages, estimated_tokens: estTokens, avg_messages_per_convo: Math.round(avgMsgs * 10) / 10, avg_user_chars: Math.round(avgUserChars * 10) / 10, avg_assistant_chars: Math.round(avgAsstChars * 10) / 10, median_prompt_chars: medianPrompt, median_response_chars: medianResponse, p90_prompt_chars: p90Prompt, p90_response_chars: p90Response, prompt_length_distribution: bucketLabels.map((b, i) => ({ bucket: b, count: promptBuckets[i] })), response_length_distribution: bucketLabels.map((b, i) => ({ bucket: b, count: responseBuckets[i] })), earliest_timestamp: earliest, latest_timestamp: latest, model_distribution: modelDist, model_stats: modelStats, activity_timeline: timeline, weekday_distribution: weekdayData, hour_distribution: hourData, top_conversations: top10, }; kpiCache = result; kpiCacheTime = now; return result; } const MIME = { '.dat': 'application/octet-stream', '.json': 'application/json', '.js': 'application/javascript', '.css': 'text/css', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.pdf': 'application/pdf', '.html': 'text/html', }; function sendJson(res, data, status = 200) { const body = Buffer.from(JSON.stringify(data), 'utf-8'); res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': body.length, 'Access-Control-Allow-Origin': '*', }); res.end(body); } function sendFile(res, fpath, mimeType, supportRange = true, req) { const stat = fs.statSync(fpath); const range = req.headers.range; if (range && supportRange) { const [startStr, endStr] = range.replace('bytes=', '').split('-'); let start = parseInt(startStr) || 0; let end = endStr ? parseInt(endStr) : stat.size - 1; end = Math.min(end, stat.size - 1); const length = end - start + 1; res.writeHead(206, { 'Content-Range': `bytes ${start}-${end}/${stat.size}`, 'Content-Length': length, 'Access-Control-Allow-Origin': '*', }); fs.createReadStream(fpath, { start, end }).pipe(res); return; } res.writeHead(200, { 'Content-Type': mimeType, 'Content-Length': stat.size, 'Access-Control-Allow-Origin': '*', ...(supportRange ? { 'Accept-Ranges': 'bytes' } : {}), }); fs.createReadStream(fpath).pipe(res); } const server = http.createServer((req, res) => { const parsed = url.parse(req.url); const p = parsed.pathname; if (p === '/' || p === '/index.html') { const fpath = path.join(DATA_DIR, 'index.html'); if (fs.existsSync(fpath)) { sendFile(res, fpath, 'text/html; charset=utf-8', false, req); } else { sendJson(res, { error: 'index.html not found' }, 404); } return; } if (p === '/api/conversations') { const clean = convoIndex.map(e => { const { _source_file, ...rest } = e; return rest; }); sendJson(res, clean); return; } if (p.startsWith('/api/conversation/')) { const cid = decodeURIComponent(p.replace('/api/conversation/', '')); const conv = findConversation(cid); if (!conv) { sendJson(res, { error: 'Conversation not found' }, 404); return; } const messages = extractMessages(conv); sendJson(res, { id: cid, title: conv.title || '(untitled)', create_time: conv.create_time, model: conv.default_model_slug || '', messages, }); return; } if (p === '/api/stats') { sendJson(res, { total_conversations: convoIndex.length, data_dir: DATA_DIR }); return; } if (p === '/api/kpi') { sendJson(res, computeKPIs()); return; } // Static files const safePath = decodeURIComponent(p.replace(/^\//, '')); const fpath = path.join(DATA_DIR, safePath); const resolved = path.resolve(fpath); if (resolved.startsWith(DATA_DIR) && fs.existsSync(fpath) && fs.statSync(fpath).isFile()) { const ext = path.extname(fpath).toLowerCase(); sendFile(res, fpath, MIME[ext] || 'application/octet-stream', true, req); } else { sendJson(res, { error: 'Not found', path: p }, 404); } }); buildIndex(); server.listen(PORT, '0.0.0.0', () => { console.log(`\n ChatScope Viewer`); console.log(` Serving ${convoIndex.length} conversations`); console.log(` Open: http://localhost:${PORT}\n`); });