项目文件夹

文件
2026-01-04 10:19:02 +08:00

732 行
32 KiB
HTML

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agent Multi-Session Analyzer (Precision Date)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
slate: {
50: '#f8fafc',
100: '#f1f5f9',
200: '#e2e8f0',
300: '#cbd5e1',
400: '#94a3b8',
500: '#64748b',
600: '#475569',
700: '#334155',
800: '#1e293b',
900: '#0f172a',
},
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
},
},
fontFamily: { sans: ['Inter', 'sans-serif'], mono: ['JetBrains Mono', 'monospace'] },
},
},
};
</script>
<style>
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
body {
font-family: 'Inter', sans-serif;
}
.sidebar-item.active {
background-color: #eff6ff;
border-right: 3px solid #3b82f6;
color: #1e40af;
}
</style>
</head>
<body class="bg-slate-50 text-slate-800 antialiased h-screen flex overflow-hidden">
<!-- 左侧侧边栏 -->
<aside class="w-72 bg-white border-r border-slate-200 flex flex-col flex-shrink-0 z-20">
<div class="h-16 flex items-center px-4 border-b border-slate-200 bg-slate-50">
<svg
class="w-6 h-6 text-primary-600 mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
></path>
</svg>
<h1 class="font-bold text-slate-800 tracking-tight">Session Analyzer</h1>
</div>
<!-- 文件夹上传区 -->
<div class="p-4 border-b border-slate-200">
<label
class="flex flex-col items-center justify-center w-full h-24 border-2 border-dashed border-slate-300 rounded-lg cursor-pointer bg-slate-50 hover:bg-slate-100 hover:border-primary-400 transition-colors group"
>
<div class="flex flex-col items-center justify-center pt-2 pb-3">
<svg
class="w-6 h-6 text-slate-400 group-hover:text-primary-500 mb-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
></path>
</svg>
<p class="text-xs text-slate-500 font-medium">点击选择文件夹</p>
<p class="text-[10px] text-slate-400">支持批量读取</p>
</div>
<input id="dirInput" type="file" class="hidden" webkitdirectory directory multiple />
</label>
</div>
<!-- 会话列表 -->
<div class="flex-1 overflow-y-auto" id="sessionList">
<div class="p-8 text-center text-sm text-slate-400">
暂无会话数据<br />请上传文件夹或文件
</div>
</div>
<!-- 底部全局统计入口 -->
<div
class="p-3 border-t border-slate-200 bg-slate-50 cursor-pointer hover:bg-slate-100 transition-colors"
id="btnShowDashboard"
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-full bg-primary-100 text-primary-600 flex items-center justify-center"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
></path>
</svg>
</div>
<div>
<div class="text-xs font-bold text-slate-700">全局统计</div>
<div class="text-[10px] text-slate-500">查看汇总数据</div>
</div>
</div>
</div>
</aside>
<!-- 主内容区 -->
<main class="flex-1 flex flex-col h-full bg-white relative overflow-hidden">
<!-- Dashboard (默认显示) -->
<div
id="dashboardView"
class="absolute inset-0 z-10 bg-slate-50 overflow-y-auto p-10 flex flex-col items-center"
>
<div class="max-w-5xl w-full">
<h2 class="text-3xl font-bold text-slate-900 mb-8">分析报告概览</h2>
<!-- 核心指标 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<div class="text-slate-500 text-sm font-medium mb-1">会话总数 (Files)</div>
<div class="text-4xl font-bold text-slate-900" id="totalSessions">0</div>
</div>
<div class="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<div class="text-slate-500 text-sm font-medium mb-1">LLM 请求总数</div>
<div class="text-4xl font-bold text-blue-600" id="totalRequests">0</div>
</div>
<div class="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<div class="text-slate-500 text-sm font-medium mb-1">总 Token 消耗</div>
<div class="text-4xl font-bold text-emerald-600" id="totalTokens">0</div>
<div class="text-xs text-slate-400 mt-2">包含 Input + Output</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-10">
<!-- 模型分布统计 -->
<div class="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<h3 class="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
<svg
class="w-5 h-5 text-purple-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19.428 15.428a2 2 0 00-1.022-.547l-2.384-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z"
></path>
</svg>
模型分布 (Models)
</h3>
<div id="modelStatsContainer" class="space-y-3 max-h-80 overflow-y-auto pr-2">
<div class="text-slate-400 text-sm italic">暂无数据</div>
</div>
</div>
<!-- 每日活动统计 -->
<div class="bg-white p-6 rounded-2xl border border-slate-200 shadow-sm">
<h3 class="text-lg font-bold text-slate-800 mb-4 flex items-center gap-2">
<svg
class="w-5 h-5 text-orange-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
每日活动 (Daily Activity)
</h3>
<!-- 表头 -->
<div
class="flex text-xs font-semibold text-slate-400 border-b border-slate-100 pb-2 mb-2 px-2"
>
<div class="w-24">日期</div>
<div class="w-24 text-center">活跃会话</div>
<div class="w-20 text-center">Requests</div>
<div class="flex-1 text-right">Tokens</div>
</div>
<div id="dailyStatsContainer" class="space-y-1 max-h-80 overflow-y-auto pr-2">
<div class="text-slate-400 text-sm italic">暂无数据</div>
</div>
</div>
</div>
<h3 class="text-xl font-bold text-slate-800 mb-4">使用提示</h3>
<ul class="list-disc pl-5 space-y-2 text-slate-600 text-sm">
<li>点击左上角的区域选择包含日志的<b>文件夹</b></li>
<li>系统会自动扫描文件,并按<b>单条消息的时间戳</b>进行精准的每日统计。</li>
<li>如果一个 Session 跨越了两天,它的请求数会分别计入这两天。</li>
</ul>
</div>
</div>
<!-- 聊天详情视图 (默认隐藏) -->
<div id="chatView" class="absolute inset-0 z-0 bg-slate-50 flex flex-col hidden">
<!-- 顶部标题 -->
<header
class="h-16 bg-white border-b border-slate-200 flex items-center px-6 justify-between flex-shrink-0"
>
<div class="flex flex-col overflow-hidden">
<h2 class="font-bold text-slate-900 text-lg truncate" id="currentFileName"
>Session Name</h2
>
<div
class="flex items-center gap-3 text-xs text-slate-500"
id="currentFileStatsWrapper"
>
<!-- JS 填充详情 -->
</div>
</div>
</header>
<!-- 滚动消息区 -->
<div class="flex-1 overflow-y-auto p-6 space-y-6" id="messageList">
<!-- 消息动态插入 -->
</div>
</div>
</main>
<script>
// --- 状态管理 ---
const state = {
sessions: [],
// globalStats 增加 daily 结构
// daily: { "2023-10-01": { requests: 0, tokens: 0, activeSessionIds: Set() } }
globalStats: { sessions: 0, requests: 0, tokens: 0, models: {}, daily: {} },
currentId: null,
};
const dirInput = document.getElementById('dirInput');
const sessionListEl = document.getElementById('sessionList');
const dashboardView = document.getElementById('dashboardView');
const chatView = document.getElementById('chatView');
const btnShowDashboard = document.getElementById('btnShowDashboard');
const modelStatsContainer = document.getElementById('modelStatsContainer');
const dailyStatsContainer = document.getElementById('dailyStatsContainer');
// --- 事件监听 ---
dirInput.addEventListener('change', async (e) => {
const files = Array.from(e.target.files);
if (files.length === 0) return;
// 重置状态
state.sessions = [];
state.globalStats = { sessions: 0, requests: 0, tokens: 0, models: {}, daily: {} };
sessionListEl.innerHTML =
'<div class="p-4 text-center text-sm text-slate-500">正在解析...</div>';
await processFiles(files);
updateDashboard();
renderSidebar();
showDashboard();
});
btnShowDashboard.addEventListener('click', showDashboard);
// --- 核心逻辑 ---
async function processFiles(files) {
const validFiles = files.filter((f) => !f.name.startsWith('.'));
const promises = validFiles.map(async (file) => {
let text = '';
try {
text = await readFileAsText(file);
} catch (err) {
return null;
}
// 核心解析
const parseResult = parseSessionContent(text);
// 如果没有有效消息,跳过此文件
if (parseResult.messages.length === 0 && parseResult.stats.requests === 0) return null;
// 提取会话开始时间 (基于第一条消息)
const startTime =
parseResult.messages.length > 0 && parseResult.messages[0].timestamp
? new Date(parseResult.messages[0].timestamp)
: new Date();
return {
id: Math.random().toString(36).substr(2, 9),
fileName: file.name,
path: file.webkitRelativePath || file.name,
messages: parseResult.messages,
stats: parseResult.stats,
startTime: startTime,
};
});
const results = await Promise.all(promises);
state.sessions = results.filter((s) => s !== null);
// --- 全局统计聚合 ---
state.sessions.forEach((s) => {
// 1. 基础汇总
state.globalStats.sessions++;
state.globalStats.requests += s.stats.requests;
state.globalStats.tokens += s.stats.tokens;
// 2. 模型聚合
for (const [model, count] of Object.entries(s.stats.models)) {
if (!state.globalStats.models[model]) state.globalStats.models[model] = 0;
state.globalStats.models[model] += count;
}
// 3. 每日活动聚合 (下钻到 Message 级别)
// 此时 s.messages 已经是经过过滤(去除了 0/0 usage)的干净数据
s.messages.forEach((msg) => {
// 仅统计 Assistant 的请求
if (msg.type === 'assistant') {
// 确定该消息属于哪一天
let msgDateObj;
if (msg.timestamp) {
msgDateObj = new Date(msg.timestamp);
} else {
// 兜底:如果单条消息没时间,用 Session 开始时间
msgDateObj = s.startTime;
}
// 格式化为 YYYY-MM-DD
const dateKey = msgDateObj
.toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
.replace(/\//g, '-');
// 初始化当天的统计桶
if (!state.globalStats.daily[dateKey]) {
state.globalStats.daily[dateKey] = {
requests: 0,
tokens: 0,
activeSessionIds: new Set(), // 使用 Set 来去重统计“活跃会话数”
};
}
const dayStat = state.globalStats.daily[dateKey];
// 标记该 Session 在这一天是活跃的
dayStat.activeSessionIds.add(s.id);
// 统计逻辑 (与 parseSessionContent 保持一致)
const hasUsage = msg.message && msg.message.usage;
if (hasUsage) {
dayStat.requests++;
dayStat.tokens +=
(msg.message.usage.input_tokens || 0) + (msg.message.usage.output_tokens || 0);
} else {
// 兜底
dayStat.requests++;
}
}
});
});
}
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = reject;
reader.readAsText(file);
});
}
function parseSessionContent(content) {
const lines = content.split('\n');
const messages = [];
let requests = 0;
let tokens = 0;
const models = {};
lines.forEach((line) => {
const trimmed = line.trim();
if (!trimmed) return;
try {
const json = JSON.parse(trimmed);
let isNoise = false;
// --- 1. 过滤 Assistant 的无效 usage (0/0) ---
// 这是本次修改的核心:如果 usage 存在且全为 0,视为噪音
if (json.type === 'assistant') {
if (json.message && json.message.usage) {
const u = json.message.usage;
if (u.input_tokens === 0 && u.output_tokens === 0) {
isNoise = true;
}
}
}
// --- 2. 过滤 User 的噪音 (系统指令) ---
const rawContent = json.message?.content;
if (json.type === 'user' && typeof rawContent === 'string') {
if (
rawContent.includes('<local-command-stdout>') ||
rawContent.includes('<command-name>')
) {
isNoise = true;
}
}
// --- 3. 有效消息处理 ---
if ((json.type === 'user' || json.type === 'assistant') && !isNoise) {
messages.push(json); // 存入消息列表
// 仅针对 Assistant 进行统计
if (json.type === 'assistant') {
const hasUsage = json.message && json.message.usage;
if (hasUsage) {
requests++;
tokens +=
(json.message.usage.input_tokens || 0) +
(json.message.usage.output_tokens || 0);
} else {
// 兼容没有 usage 字段的情况,仍算作一次请求
requests++;
}
// 统计模型
let modelId = 'unknown';
if (json.message && json.message.model) modelId = json.message.model;
else if (json.model) modelId = json.model;
if (!models[modelId]) models[modelId] = 0;
models[modelId]++;
}
}
} catch (e) {}
});
return { messages, stats: { requests, tokens, models } };
}
// --- 渲染逻辑 ---
function updateDashboard() {
document.getElementById('totalSessions').textContent = state.globalStats.sessions;
document.getElementById('totalRequests').textContent = state.globalStats.requests;
const t = state.globalStats.tokens;
const tStr = t > 1000 ? (t / 1000).toFixed(1) + 'k' : t;
document.getElementById('totalTokens').textContent = tStr;
// 1. 渲染模型分布
modelStatsContainer.innerHTML = '';
const models = state.globalStats.models;
const sortedModels = Object.entries(models).sort((a, b) => b[1] - a[1]);
if (sortedModels.length === 0) {
modelStatsContainer.innerHTML =
'<div class="text-slate-400 text-sm italic">未检测到模型信息</div>';
} else {
sortedModels.forEach(([modelName, count]) => {
const percent = Math.round((count / state.globalStats.requests) * 100) || 0;
const div = document.createElement('div');
div.className =
'flex items-center gap-3 p-3 bg-slate-50 rounded-lg border border-slate-100';
div.innerHTML = `
<div class="flex-1 min-w-0">
<div class="flex justify-between items-baseline mb-1">
<span class="font-mono text-sm font-medium text-slate-700 truncate" title="${modelName}">${modelName}</span>
<span class="text-xs font-bold text-slate-900">${count}</span>
</div>
<div class="w-full bg-slate-200 h-1.5 rounded-full overflow-hidden">
<div class="bg-purple-500 h-1.5 rounded-full" style="width: ${percent}%"></div>
</div>
</div>
<span class="text-[10px] text-slate-400 w-8 text-right">${percent}%</span>
`;
modelStatsContainer.appendChild(div);
});
}
// 2. 渲染每日活动
dailyStatsContainer.innerHTML = '';
const daily = state.globalStats.daily;
// 按日期倒序 (最新的在上面)
const sortedDates = Object.keys(daily).sort().reverse();
if (sortedDates.length === 0) {
dailyStatsContainer.innerHTML =
'<div class="text-slate-400 text-sm italic">暂无日期数据</div>';
} else {
const maxReq = Math.max(...sortedDates.map((d) => daily[d].requests)) || 1;
sortedDates.forEach((date) => {
const d = daily[date];
const activeSessionCount = d.activeSessionIds.size; // 使用 Set 大小
const heatWidth = Math.max(5, Math.round((d.requests / maxReq) * 100));
const tVal = d.tokens > 1000 ? (d.tokens / 1000).toFixed(1) + 'k' : d.tokens;
const div = document.createElement('div');
div.className =
'flex items-center p-2 hover:bg-slate-50 rounded-md transition-colors text-sm border-b border-slate-50 last:border-0';
div.innerHTML = `
<div class="w-24 font-mono text-slate-600">${date}</div>
<div class="w-24 text-center text-slate-500" title="当日有消息产生的会话数">${activeSessionCount}</div>
<div class="w-20 text-center font-bold text-blue-600 relative group cursor-default">
${d.requests}
<!-- 热度条背景 -->
<div class="absolute bottom-0 left-2 right-2 h-0.5 bg-blue-100 rounded-full overflow-hidden">
<div class="bg-blue-500 h-full" style="width: ${heatWidth}%"></div>
</div>
</div>
<div class="flex-1 text-right font-mono text-emerald-600">${tVal}</div>
`;
dailyStatsContainer.appendChild(div);
});
}
}
function renderSidebar() {
sessionListEl.innerHTML = '';
const sortedSessions = [...state.sessions].sort((a, b) => b.startTime - a.startTime); // 侧边栏按时间倒序
sortedSessions.forEach((session) => {
const div = document.createElement('div');
div.className =
'sidebar-item p-3 border-b border-slate-100 cursor-pointer hover:bg-slate-50 transition-colors group';
div.dataset.id = session.id;
const displayName =
session.fileName.length > 25 ? '...' + session.fileName.slice(-22) : session.fileName;
const timeStr = session.startTime.toLocaleDateString();
div.innerHTML = `
<div class="flex justify-between items-start mb-1">
<span class="font-medium text-sm text-slate-700 group-hover:text-slate-900 truncate" title="${session.path}">${displayName}</span>
<span class="text-[10px] text-slate-400 whitespace-nowrap ml-1">${timeStr}</span>
</div>
<div class="flex items-center gap-3 text-xs text-slate-400">
<span class="flex items-center gap-1" title="消息条数">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path></svg>
${session.messages.length}
</span>
<span class="flex items-center gap-1 ${session.stats.requests > 0 ? 'text-blue-500 font-bold' : ''}" title="API请求次数">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
${session.stats.requests}
</span>
</div>
`;
div.addEventListener('click', () => loadSession(session.id));
sessionListEl.appendChild(div);
});
}
function showDashboard() {
dashboardView.classList.remove('hidden');
chatView.classList.add('hidden');
document.querySelectorAll('.sidebar-item').forEach((el) => el.classList.remove('active'));
}
function loadSession(id) {
const session = state.sessions.find((s) => s.id === id);
if (!session) return;
state.currentId = id;
dashboardView.classList.add('hidden');
chatView.classList.remove('hidden');
document.getElementById('currentFileName').textContent = session.fileName;
const t = session.stats.tokens;
const tStr = t > 1000 ? (t / 1000).toFixed(1) + 'k' : t;
let topModel = 'N/A';
let maxCount = 0;
for (const [m, c] of Object.entries(session.stats.models)) {
if (c > maxCount) {
maxCount = c;
topModel = m;
}
}
const container = document.getElementById('currentFileStatsWrapper');
container.innerHTML = `
<span class="flex items-center gap-1"><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg> Requests: ${session.stats.requests}</span>
<span class="w-px h-3 bg-slate-300"></span>
<span>Tokens: ${tStr}</span>
<span class="w-px h-3 bg-slate-300"></span>
<span class="font-mono text-[10px] bg-slate-100 px-2 py-0.5 rounded border border-slate-200" title="主要模型">${topModel}</span>
`;
document.querySelectorAll('.sidebar-item').forEach((el) => {
if (el.dataset.id === id) el.classList.add('active');
else el.classList.remove('active');
});
renderChatMessages(session.messages);
}
function renderChatMessages(messages) {
const container = document.getElementById('messageList');
container.innerHTML = '';
messages.forEach((msg) => {
const isUser = msg.type === 'user';
const dateObj = msg.timestamp ? new Date(msg.timestamp) : null;
const dateStr = dateObj
? `${dateObj.getHours().toString().padStart(2, '0')}:${dateObj.getMinutes().toString().padStart(2, '0')}:${dateObj.getSeconds().toString().padStart(2, '0')}`
: '';
let tokenInfo = '';
if (!isUser && msg.message?.usage) {
const t = msg.message.usage;
const total = (t.input_tokens || 0) + (t.output_tokens || 0);
if (total > 0)
tokenInfo = `<span class="ml-2 text-[10px] text-emerald-600 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-100 flex items-center gap-1"><svg class="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>${total}</span>`;
}
const userAvatar = `
<div class="flex-shrink-0 w-9 h-9 rounded-full bg-white flex items-center justify-center border border-slate-200 shadow-sm">
<svg class="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path></svg>
</div>`;
const aiAvatar = `
<div class="flex-shrink-0 w-9 h-9 rounded-full bg-slate-800 flex items-center justify-center shadow-md ring-2 ring-slate-50">
<svg class="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
</div>`;
const row = document.createElement('div');
row.className = `flex gap-4 ${isUser ? 'flex-row-reverse' : 'flex-row'}`;
const bubbleClass = isUser
? 'bg-white border border-slate-200 shadow-sm text-slate-800 rounded-2xl rounded-tr-sm'
: 'bg-white border border-slate-200 shadow-sm text-slate-800 rounded-2xl rounded-tl-sm';
const contentHtml = formatContent(msg);
row.innerHTML = `
${isUser ? userAvatar : aiAvatar}
<div class="flex flex-col max-w-[85%] lg:max-w-[75%] min-w-0">
<div class="flex items-center gap-2 mb-1 px-1 ${isUser ? 'justify-end' : 'justify-start'}">
<span class="text-xs font-bold text-slate-700">${isUser ? 'User' : 'Assistant'}</span>
<span class="text-[10px] text-slate-400 font-mono pt-0.5">${dateStr}</span>
${tokenInfo}
</div>
<div class="p-4 ${bubbleClass} text-sm leading-relaxed overflow-hidden hover:shadow-md transition-shadow duration-200">
${contentHtml}
</div>
</div>
`;
container.appendChild(row);
});
}
function formatContent(msg) {
const raw = msg.message?.content;
if (!raw) return '<span class="text-slate-400 italic">Empty message</span>';
let html = '';
if (typeof raw === 'string') {
html = `<div class="whitespace-pre-wrap break-words text-slate-700">${escapeHtml(raw)}</div>`;
} else if (Array.isArray(raw)) {
raw.forEach((block, idx) => {
const mt = idx > 0 ? 'mt-4' : '';
if (block.type === 'text') {
html += `<div class="whitespace-pre-wrap break-words ${mt} text-slate-700">${escapeHtml(block.text)}</div>`;
} else if (block.type === 'tool_use') {
html += `
<div class="rounded-md border border-amber-200 bg-amber-50 overflow-hidden ${mt} text-xs font-mono group">
<div class="bg-amber-100/50 px-3 py-2 border-b border-amber-200 flex items-center justify-between">
<div class="flex items-center gap-2 text-amber-800 font-bold">
<svg class="w-3.5 h-3.5 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
Tool: ${escapeHtml(block.name)}
</div>
<span class="text-amber-600/70 select-all">${escapeHtml(block.id)}</span>
</div>
<div class="p-3 bg-white overflow-x-auto">
<pre class="text-slate-600">${escapeHtml(JSON.stringify(block.input, null, 2))}</pre>
</div>
</div>`;
}
});
} else {
html = `<pre class="text-xs bg-slate-50 p-2 rounded border border-slate-200 overflow-x-auto text-slate-600">${escapeHtml(JSON.stringify(raw, null, 2))}</pre>`;
}
return html;
}
function escapeHtml(text) {
if (text == null) return '';
if (typeof text !== 'string') text = String(text);
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
</script>
</body>
</html>