Spaces:
Paused
Paused
| 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`); | |
| }); | |