chat-viewer / server.js
josephrw's picture
Upload server.js with huggingface_hub
b5ecbfb verified
Raw
History Blame Contribute Delete
13.1 kB
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`);
});