cror / static /logs.html
xcolorday's picture
Upload 442 files
6b356c4 verified
Raw
History Blame Contribute Delete
73.4 kB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/x-icon" href="data:image/x-icon;," />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>请求日志查看</title>
<!-- 引入共享样式 -->
<link rel="stylesheet" href="/static/shared-styles.css" />
<script src="/static/shared.js"></script>
<style>
/* 筛选面板样式 */
.filter-panel {
background: var(--card-background);
padding: 20px;
border-radius: var(--border-radius);
margin-bottom: var(--spacing);
border: 1px solid var(--border-color);
}
.filter-section {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border-color);
}
.filter-section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.filter-section h3 {
margin: 0 0 15px 0;
color: var(--primary-color);
font-size: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 5px;
}
.filter-group label {
font-size: 13px;
color: var(--text-secondary);
}
.filter-group input[type="datetime-local"],
.filter-group input[type="text"],
.filter-group input[type="number"],
.filter-group select {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
background: var(--card-background);
color: var(--text-primary);
font-size: 14px;
}
.filter-group input:focus,
.filter-group select:focus {
border-color: var(--primary-color);
outline: none;
}
.range-input {
display: flex;
align-items: center;
gap: 10px;
}
.range-input input {
flex: 1;
}
.filter-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 20px;
}
/* 创建正确的堆叠上下文 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: var(--spacing);
}
.stat-card {
background: var(--card-background);
padding: 20px;
border-radius: var(--border-radius);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transition: all var(--transition-fast);
border: 1px solid var(--border-color);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.stat-card h4 {
margin: 0 0 8px 0;
color: var(--primary-color);
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: var(--primary-color);
margin-top: 4px;
}
.refresh-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.auto-refresh {
display: flex;
align-items: center;
gap: 8px;
background: var(--card-background);
padding: 8px 16px;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
}
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.4);
overflow-y: hidden;
}
.modal-content {
background-color: var(--card-background);
margin: 5% auto;
padding: 20px;
border-radius: var(--border-radius);
width: 90%;
max-width: 800px;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border-color);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.modal-header {
position: sticky;
top: 0;
background: var(--card-background);
z-index: 10;
padding-bottom: 15px;
border-bottom: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
}
.modal-header h3 {
margin: 0;
flex-grow: 1;
}
#conversation-content-container {
flex: 1;
overflow-y: auto;
margin: 0 -20px;
padding: 20px;
}
.close {
font-size: 24px;
font-weight: bold;
cursor: pointer;
padding: 5px 10px;
}
.close:hover {
color: var(--primary-color);
}
.info-button {
padding: 6px 12px;
font-size: 14px;
border-radius: var(--border-radius);
transition: all var(--transition-fast);
background: var(--primary-color-alpha);
color: var(--primary-color);
border: 1px solid var(--primary-color);
}
.info-button:hover {
background: var(--primary-color);
color: white;
}
.message-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
margin: 0;
border: 1px solid var(--border-color);
}
.message-table th,
.message-table td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
transition: background-color var(--transition-fast);
}
.message-table td {
word-break: break-word;
}
.message-table td:nth-child(2) {
max-width: 600px;
}
.message-table td:first-child {
width: 80px;
white-space: nowrap;
}
.usage-progress-container {
margin: 16px 0;
height: 8px;
background-color: var(--border-color);
border-radius: 4px;
overflow: hidden;
}
.usage-progress-bar {
height: 100%;
width: 0%;
transition: width 0.3s ease;
background-color: var(--primary-color);
border-radius: 4px;
}
.usage-progress-bar.low {
background-color: #4caf50;
}
.usage-progress-bar.medium {
background-color: #ff9800;
}
.usage-progress-bar.high {
background-color: #f44336;
}
/* Token 信息和对话预览的通用样式 */
.token-info-tooltip {
position: relative;
display: inline-block;
}
.token-info-tooltip .tooltip-content {
visibility: hidden;
position: absolute;
z-index: 1002;
background-color: var(--card-background);
color: var(--text-primary);
padding: 12px 15px;
border-radius: var(--border-radius);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
width: 280px;
left: 50%;
transform: translateX(-50%);
bottom: calc(100% + 8px);
opacity: 0;
transition:
opacity 0.3s,
visibility 0.3s;
text-align: left;
line-height: 1.6;
border: 1px solid var(--border-color);
pointer-events: none;
}
.token-info-tooltip:hover .tooltip-content {
visibility: visible;
opacity: 1;
}
.token-info-tooltip .tooltip-content::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -8px;
border-width: 8px;
border-style: solid;
border-color: var(--card-background) transparent transparent transparent;
}
.token-info-tooltip::after {
content: "";
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 100%;
width: 100%;
height: 10px;
background: transparent;
}
.token-info-tooltip .tooltip-info-row {
display: flex;
justify-content: space-between;
margin: 2px 0;
}
.token-info-tooltip .tooltip-info-row .label {
color: var(--text-secondary);
margin-right: 10px;
}
.token-info-tooltip .tooltip-info-row .value {
font-weight: 500;
word-break: break-word;
}
.token-info-container {
flex: 1;
overflow-y: auto;
margin: 0 -20px;
padding: 20px;
}
/* 对话预览特定样式 */
.prompt-preview .tooltip-content {
width: 320px;
max-height: 200px;
overflow-y: auto;
overflow-x: hidden;
white-space: pre-wrap;
word-break: break-word;
}
.prompt-preview .tooltip-content .message-meta {
font-size: 0.8em;
color: var(--text-secondary);
padding: 0;
margin: 0 0 4px 0;
}
.prompt-preview .tooltip-content .last-message {
font-size: 0.9em;
line-height: 1.5;
color: var(--text-primary);
margin: 0;
padding: 0;
white-space: pre-wrap;
word-break: break-word;
}
.prompt-preview .tooltip-content::-webkit-scrollbar {
width: 6px;
}
.prompt-preview .tooltip-content::-webkit-scrollbar-thumb {
background-color: var(--border-color);
border-radius: 3px;
}
.prompt-preview .tooltip-content::-webkit-scrollbar-track {
background-color: var(--card-background);
}
/* 优化表格样式 */
.table-container {
border-radius: var(--border-radius);
overflow: hidden;
border: 1px solid var(--border-color);
}
#logsTable {
position: relative;
z-index: 1;
}
#logsTable th {
position: sticky;
top: 0;
z-index: 2;
background: var(--primary-color);
white-space: nowrap;
transition: background-color 0.2s ease;
}
#logsTable td {
padding: 12px 16px;
border-bottom: 1px solid var(--border-color);
transition: background-color var(--transition-fast);
}
#logsTable tr:hover td {
background-color: var(--hover-color, rgba(0, 0, 0, 0.02));
}
.modal-actions {
display: flex;
align-items: center;
gap: 12px;
}
.danger-button {
padding: 6px 12px;
font-size: 14px;
border-radius: var(--border-radius);
background: var(--error-color-alpha);
color: var(--error-color);
border: 1px solid var(--error-color);
cursor: pointer;
transition: all var(--transition-fast);
margin-right: 8px;
}
.danger-button:hover {
background: var(--error-color);
color: white;
}
.warning-button {
padding: 6px 12px;
font-size: 14px;
border-radius: var(--border-radius);
background: rgba(255, 193, 7, 0.1);
color: #e8a400;
border: 1px solid #e8a400;
cursor: pointer;
transition: all var(--transition-fast);
margin-right: 8px;
}
.warning-button:hover {
background: #e8a400;
color: white;
}
.success-button {
padding: 6px 12px;
font-size: 14px;
border-radius: var(--border-radius);
background: rgba(76, 175, 80, 0.1);
color: #4caf50;
border: 1px solid #4caf50;
cursor: pointer;
transition: all var(--transition-fast);
margin-right: 8px;
}
.success-button:hover {
background: #4caf50;
color: white;
}
/* 图表样式 */
.chart-container {
background: var(--card-background);
padding: 20px;
border-radius: var(--border-radius);
margin-bottom: var(--spacing);
border: 1px solid var(--border-color);
height: 300px;
}
/* 添加菜单切换样式 */
.tab-menu {
display: flex;
margin-bottom: 15px;
}
.tab-button {
padding: 8px 16px;
background: var(--card-background);
color: var(--text-secondary);
border: 1px solid var(--border-color);
cursor: pointer;
transition: all var(--transition-fast);
font-size: 14px;
}
.tab-button:first-child {
border-radius: var(--border-radius) 0 0 var(--border-radius);
}
.tab-button:last-child {
border-radius: 0 var(--border-radius) var(--border-radius) 0;
}
.tab-button.active {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.delay-chart-container {
height: 200px;
margin-bottom: 20px;
}
.delay-table-container {
max-height: none;
overflow-y: visible;
}
.pagination-container {
margin-top: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.pagination-info {
flex: 1;
}
.pagination-controls {
display: flex;
gap: 10px;
align-items: center;
}
.page-jumper {
display: flex;
align-items: center;
gap: 5px;
margin-left: 15px;
}
.page-input {
width: 60px;
padding: 5px;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
text-align: center;
}
.page-input:focus {
border-color: var(--primary-color);
outline: none;
}
.page-go-btn {
padding: 5px 10px;
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
background: var(--card-background);
cursor: pointer;
transition: all var(--transition-fast);
}
.page-go-btn:hover {
background: var(--primary-color);
color: white;
border-color: var(--primary-color);
}
/* 响应式样式 */
@media (max-width: 768px) {
.filter-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.stat-card {
padding: 16px;
}
.stat-value {
font-size: 24px;
}
.modal-content {
margin: 2% auto;
width: 95%;
padding: 16px;
}
.chart-container {
height: 200px;
padding: 16px;
}
.modal-header {
flex-direction: column;
align-items: flex-start;
}
.tab-menu {
width: 100%;
}
.tab-button {
flex: 1;
}
}
</style>
</head>
<body>
<h1>请求日志查看</h1>
<div class="container">
<div class="form-group">
<label>认证令牌:</label>
<input type="password" id="authToken" placeholder="输入 AUTH_TOKEN" />
</div>
<!-- 新的筛选面板 -->
<div class="filter-panel">
<!-- 时间范围筛选 -->
<div class="filter-section">
<h3>📅 时间范围</h3>
<div class="filter-grid">
<div class="filter-group">
<label for="fromDate">开始时间</label>
<input type="datetime-local" id="fromDate" />
</div>
<div class="filter-group">
<label for="toDate">结束时间</label>
<input type="datetime-local" id="toDate" />
</div>
</div>
<div style="margin-top: 10px">
<label
style="
display: block;
margin-bottom: 5px;
font-size: 13px;
color: var(--text-secondary);
"
>快速选择</label
>
<div class="button-group" style="justify-content: flex-start">
<button
onclick="setTimeRange('1h')"
style="padding: 6px 12px; font-size: 14px"
>
最近1小时
</button>
<button
onclick="setTimeRange('24h')"
style="padding: 6px 12px; font-size: 14px"
>
最近24小时
</button>
<button
onclick="setTimeRange('7d')"
style="padding: 6px 12px; font-size: 14px"
>
最近7天
</button>
<button
onclick="setTimeRange('30d')"
style="padding: 6px 12px; font-size: 14px"
>
最近30天
</button>
</div>
</div>
</div>
<!-- 用户筛选 -->
<div class="filter-section">
<h3>👤 用户筛选</h3>
<div class="filter-grid">
<div class="filter-group">
<label for="userId">用户ID</label>
<input type="text" id="userId" placeholder="精确匹配用户ID" />
</div>
<div class="filter-group">
<label for="email">邮箱</label>
<input type="text" id="email" placeholder="支持部分匹配" />
</div>
<div class="filter-group">
<label for="membershipType">会员类型</label>
<select id="membershipType">
<option value="">全部</option>
<option value="free">免费</option>
<option value="free_trial">试用</option>
<option value="pro">专业版</option>
<option value="enterprise">企业版</option>
</select>
</div>
</div>
</div>
<!-- 模型筛选 -->
<div class="filter-section">
<h3>🤖 模型筛选</h3>
<div class="filter-grid">
<div class="filter-group">
<label for="model">模型名称</label>
<input type="text" id="model" placeholder="支持部分匹配" />
</div>
<div class="filter-group">
<label for="includeModels">包含模型</label>
<input
type="text"
id="includeModels"
placeholder="逗号分隔,如: gpt-4,claude"
/>
</div>
<div class="filter-group">
<label for="excludeModels">排除模型</label>
<input
type="text"
id="excludeModels"
placeholder="逗号分隔,如: gpt-3.5,dall-e"
/>
</div>
</div>
</div>
<!-- 性能筛选 -->
<div class="filter-section">
<h3>📊 性能筛选</h3>
<div class="filter-grid">
<div class="filter-group">
<label>耗时范围(秒)</label>
<div class="range-input">
<input
type="number"
id="minTotalTime"
placeholder="最小"
step="0.1"
min="0"
/>
<span>~</span>
<input
type="number"
id="maxTotalTime"
placeholder="最大"
step="0.1"
min="0"
/>
</div>
</div>
<div class="filter-group">
<label>Tokens范围</label>
<div class="range-input">
<input
type="number"
id="minTokens"
placeholder="最小"
min="0"
/>
<span>~</span>
<input
type="number"
id="maxTokens"
placeholder="最大"
min="0"
/>
</div>
</div>
</div>
</div>
<!-- 其他选项 -->
<div class="filter-section">
<h3>🔧 其他选项</h3>
<div class="filter-grid">
<div class="filter-group">
<label for="status">状态</label>
<select id="status">
<option value="">全部</option>
<option value="pending">处理中</option>
<option value="success">成功</option>
<option value="failure">失败</option>
</select>
</div>
<div class="filter-group">
<label for="stream">流式响应</label>
<select id="stream">
<option value="">全部</option>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div class="filter-group">
<label for="hasError">包含错误</label>
<select id="hasError">
<option value="">全部</option>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div class="filter-group">
<label for="hasChain">包含对话</label>
<select id="hasChain">
<option value="">全部</option>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div class="filter-group">
<label for="hasUsage">包含用量</label>
<select id="hasUsage">
<option value="">全部</option>
<option value="true"></option>
<option value="false"></option>
</select>
</div>
<div class="filter-group">
<label for="reverse">排序方式</label>
<select id="reverse">
<option value="false">从旧到新</option>
<option value="true" selected>从新到旧</option>
</select>
</div>
<div class="filter-group">
<label for="pageSize">每页显示</label>
<select id="pageSize">
<option value="10">10 条</option>
<option value="20" selected>20 条</option>
<option value="50">50 条</option>
<option value="100">100 条</option>
</select>
</div>
<div class="filter-group">
<label for="errorText">错误信息</label>
<input type="text" id="errorText" placeholder="支持部分匹配" />
</div>
</div>
</div>
<!-- 筛选操作按钮 -->
<div class="filter-actions">
<button onclick="resetFilters()">重置</button>
<button onclick="applyFilters()">应用筛选</button>
</div>
</div>
<div class="refresh-container">
<div class="button-group">
<button onclick="fetchLogs()">刷新日志</button>
<button onclick="exportLogs()" style="margin-left: 10px">
导出数据
</button>
</div>
<div class="auto-refresh">
<input type="checkbox" id="autoRefresh" checked />
<label for="autoRefresh">自动刷新 (60秒)</label>
</div>
</div>
</div>
<div class="container">
<div class="stats-grid">
<div class="stat-card">
<h4>总请求数</h4>
<div id="totalRequests" class="stat-value">-</div>
</div>
<div class="stat-card">
<h4>成功请求</h4>
<div id="successRequests" class="stat-value">-</div>
</div>
<div class="stat-card">
<h4>失败请求</h4>
<div id="failureRequests" class="stat-value">-</div>
</div>
<div class="stat-card">
<h4>最后更新</h4>
<div id="lastUpdate" class="stat-value">-</div>
</div>
</div>
<!-- 添加图表容器 -->
<div class="chart-container">
<canvas id="requestsChart"></canvas>
</div>
<div class="table-container">
<table id="logsTable">
<thead>
<tr>
<th>序号</th>
<th>时间</th>
<th>模型</th>
<th>Token信息</th>
<th>对话</th>
<th>用时</th>
<th>输入/输出</th>
<th>流式</th>
<th>状态</th>
<th>错误信息</th>
</tr>
</thead>
<tbody id="logsBody"></tbody>
</table>
</div>
<!-- 分页控件 -->
<div class="pagination-container">
<div class="pagination-info">
<span id="totalRecords">0</span> 条记录, 每页
<input
type="number"
id="customPageSize"
class="page-input"
min="5"
max="100"
value="20"
/>
<button class="page-go-btn" onclick="changePageSize()">应用</button>
</div>
<div class="pagination-controls">
<button id="prevPage" onclick="changePage(-1)" disabled>
&laquo; 上一页
</button>
<span id="currentPage">第 1 页</span>
<button id="nextPage" onclick="changePage(1)" disabled>
下一页 &raquo;
</button>
<div class="page-jumper">
跳转到第
<input
type="number"
id="pageNumberInput"
class="page-input"
min="1"
value="1"
/>
<button class="page-go-btn" onclick="jumpToPage()">GO</button>
</div>
</div>
</div>
</div>
<!-- Token详情模态框 -->
<div id="tokenModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>Token 详细信息</h3>
<span class="close">&times;</span>
</div>
<div class="token-info-container">
<table class="message-table">
<tbody>
<tr>
<td>Token</td>
<td id="modalToken">-</td>
</tr>
<tr>
<td>Checksum</td>
<td id="modalChecksum">-</td>
</tr>
<tr>
<td>Client Key</td>
<td id="modalClientKey">-</td>
</tr>
<tr>
<td>Config Version</td>
<td id="modalConfigVersion">-</td>
</tr>
<tr>
<td>Session ID</td>
<td id="modalSessionId">-</td>
</tr>
<tr>
<td>Proxy</td>
<td id="modalProxy">-</td>
</tr>
<tr>
<td>Timezone</td>
<td id="modalTimezone">-</td>
</tr>
<tr>
<td>Email</td>
<td id="modalEmail">-</td>
</tr>
<tr>
<td>Name</td>
<td id="modalName">-</td>
</tr>
<tr>
<td>Updated At</td>
<td id="modalUpdatedAt">-</td>
</tr>
<tr>
<td>Member Type</td>
<td id="modalMemberType">-</td>
</tr>
<tr>
<td>Payment ID</td>
<td id="modalPaymentId">-</td>
</tr>
<tr>
<td>Trial Days</td>
<td id="modalTrialDays">-</td>
</tr>
</tbody>
</table>
<div id="usageProgressContainer"></div>
</div>
</div>
</div>
<!-- 对话详情模态框 -->
<div id="conversationModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>对话详情</h3>
<div class="tab-menu">
<button id="tab-prompt" class="tab-button active">对话内容</button>
<button id="tab-think" class="tab-button">思考过程</button>
<button id="tab-delays" class="tab-button">延迟分析</button>
</div>
<span class="close">&times;</span>
</div>
<div id="conversation-content-container">
<div id="dialogContent" class="tab-content active"></div>
<div id="thinkContent" class="tab-content">
<pre id="thinkText"></pre>
</div>
<div id="delaysContent" class="tab-content">
<div class="delay-chart-container">
<canvas id="delayChart"></canvas>
</div>
<div class="delay-table-container">
<table id="delaysTable" class="message-table">
<thead>
<tr>
<th>序号</th>
<th>文本块</th>
<th>延迟时间(s)</th>
<th>速率(字符/秒)</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 引入Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<script>
// 全局变量
let currentPageIndex = 0;
let pageSize = 20;
let totalRecords = 0;
let currentFilters = {};
let refreshInterval = null;
let requestsChart = null;
// 初始化变量
let currentTokenDetails = {};
// Token信息缓存
let tokenInfoCache = {};
let allTokensLoaded = false; // 标记是否已加载所有token信息
// 更新统计信息
function updateStats(data) {
// 计算各种统计
const totalRequests = data.total || 0;
const successRequests = data.logs.filter(
(log) => log.status === "success",
).length;
const failureRequests = data.logs.filter(
(log) => log.status === "failure",
).length;
// 更新统计卡片
document.getElementById("totalRequests").textContent =
totalRequests.toLocaleString();
document.getElementById("successRequests").textContent =
successRequests.toLocaleString();
document.getElementById("failureRequests").textContent =
failureRequests.toLocaleString();
document.getElementById("lastUpdate").textContent =
new Date().toLocaleTimeString("zh-CN");
}
// 显示Token详情模态框
async function showTokenModal(tokenInfo) {
const modal = document.getElementById("tokenModal");
// 如果有key,优先从缓存获取详细信息
if (tokenInfo.key && tokenInfoCache[tokenInfo.key]) {
// 使用缓存的数据
tokenInfo = { ...tokenInfo, ...tokenInfoCache[tokenInfo.key] };
}
// 设置基础信息
document.getElementById("modalToken").textContent =
tokenInfo.primary_token || "-";
// 处理checksum
if (tokenInfo.checksum) {
document.getElementById("modalChecksum").textContent = `${
tokenInfo.checksum.first || "-"
} / ${tokenInfo.checksum.second || "-"}`;
} else {
document.getElementById("modalChecksum").textContent = "-";
}
// 设置其他字段
document.getElementById("modalClientKey").textContent =
tokenInfo.client_key || "-";
document.getElementById("modalConfigVersion").textContent =
tokenInfo.config_version || "-";
document.getElementById("modalSessionId").textContent =
tokenInfo.session_id || "-";
document.getElementById("modalProxy").textContent =
tokenInfo.proxy || "-";
document.getElementById("modalTimezone").textContent =
tokenInfo.timezone || "-";
// 处理用户信息
if (tokenInfo.user) {
document.getElementById("modalEmail").textContent =
tokenInfo.user.email || "-";
document.getElementById("modalName").textContent =
tokenInfo.user.name || "-";
document.getElementById("modalUpdatedAt").textContent = tokenInfo.user
.updated_at
? new Date(tokenInfo.user.updated_at).toLocaleString()
: "-";
} else {
document.getElementById("modalEmail").textContent = "-";
document.getElementById("modalName").textContent = "-";
document.getElementById("modalUpdatedAt").textContent = "-";
}
// 处理Stripe信息
if (tokenInfo.stripe) {
document.getElementById("modalMemberType").textContent =
formatMembershipType(tokenInfo.stripe.membership_type);
document.getElementById("modalPaymentId").textContent =
tokenInfo.stripe.payment_id || "-";
document.getElementById("modalTrialDays").textContent =
tokenInfo.stripe.days_remaining_on_trial > 0
? `${tokenInfo.stripe.days_remaining_on_trial}天`
: "-";
} else {
document.getElementById("modalMemberType").textContent = "-";
document.getElementById("modalPaymentId").textContent = "-";
document.getElementById("modalTrialDays").textContent = "-";
}
// 处理使用量信息
const container = document.getElementById("usageProgressContainer");
container.innerHTML = "";
if (tokenInfo.usage) {
const models = {
Premium: tokenInfo.usage.premium,
Standard: tokenInfo.usage.standard,
};
Object.entries(models).forEach(([modelName, modelData]) => {
if (modelData) {
const div = document.createElement("div");
div.style.marginTop = "10px";
const label = document.createElement("strong");
label.textContent = `${modelName} 使用量: `;
div.appendChild(label);
const text = document.createElement("span");
if (modelData.max_requests) {
const percentage = (
(modelData.num_requests / modelData.max_requests) *
100
).toFixed(1);
text.textContent = `${modelData.num_requests}/${modelData.max_requests} 请求 (${percentage}%), ${modelData.num_tokens} tokens`;
const progressDiv = document.createElement("div");
progressDiv.className = "usage-progress-container";
const colorClass =
percentage < 50 ? "low" : percentage < 80 ? "medium" : "high";
progressDiv.innerHTML = `<div class="usage-progress-bar ${colorClass}" style="width: ${percentage}%"></div>`;
div.appendChild(text);
div.appendChild(progressDiv);
} else {
text.textContent = `${modelData.num_requests} 请求, ${modelData.num_tokens} tokens`;
div.appendChild(text);
}
container.appendChild(div);
}
});
}
modal.style.display = "block";
}
// 格式化简单Token信息用于tooltip
function formatSimpleTokenInfo(tokenInfo) {
if (!tokenInfo) return "无Token信息";
// 如果有key,优先从缓存获取完整信息
let fullTokenInfo = tokenInfo;
if (tokenInfo.key && tokenInfoCache[tokenInfo.key]) {
fullTokenInfo = { ...tokenInfo, ...tokenInfoCache[tokenInfo.key] };
}
const rows = [];
if (fullTokenInfo.key) {
rows.push(["Key", fullTokenInfo.key]);
}
if (fullTokenInfo.user) {
rows.push(["邮箱", fullTokenInfo.user.email || "-"]);
if (fullTokenInfo.user.name) {
rows.push(["用户名", fullTokenInfo.user.name]);
}
}
if (fullTokenInfo.stripe) {
rows.push([
"会员类型",
formatMembershipType(fullTokenInfo.stripe.membership_type),
]);
}
if (fullTokenInfo.usage?.premium) {
const premium = fullTokenInfo.usage.premium;
const usage = premium.max_requests
? `${premium.num_requests}/${premium.max_requests}`
: `${premium.num_requests}`;
rows.push(["Premium使用", usage]);
}
if (rows.length === 0) {
return "无详细信息";
}
return rows
.map(
([label, value]) =>
`<div class="tooltip-info-row"><span class="label">${label}:</span><span class="value">${value}</span></div>`,
)
.join("");
}
// 设置时间范围快速选择
function setTimeRange(range) {
const now = new Date();
const toDate = now.toISOString().slice(0, 16); // YYYY-MM-DDTHH:mm
let fromDate;
switch (range) {
case "1h":
fromDate = new Date(now - 60 * 60 * 1000)
.toISOString()
.slice(0, 16);
break;
case "24h":
fromDate = new Date(now - 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 16);
break;
case "7d":
fromDate = new Date(now - 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 16);
break;
case "30d":
fromDate = new Date(now - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 16);
break;
}
document.getElementById("fromDate").value = fromDate;
document.getElementById("toDate").value = toDate;
// 自动应用筛选
applyFilters();
}
function initChart() {
const ctx = document.getElementById("requestsChart").getContext("2d");
requestsChart = new Chart(ctx, {
type: "line",
data: {
labels: [],
datasets: [
{
label: "每小时请求数",
data: [],
borderColor: "rgb(75, 192, 192)",
tension: 0.1,
fill: false,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 10,
},
},
},
plugins: {
title: {
display: true,
text: "24小时请求统计",
},
},
},
});
}
// 更新图表数据
function updateChart(data) {
if (!requestsChart) {
initChart();
}
// 按小时统计请求数量
const hourlyStats = new Map();
const now = new Date();
const past24Hours = new Date(now - 24 * 60 * 60 * 1000);
// 初始化24小时的时间段
for (let i = 0; i < 24; i++) {
const hour = new Date(now - i * 60 * 60 * 1000);
const hourKey = hour.toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "numeric",
});
hourlyStats.set(hourKey, 0);
}
// 统计每小时的请求数
data.logs.forEach((log) => {
const logTime = new Date(log.timestamp);
if (logTime >= past24Hours) {
const hourKey = logTime.toLocaleString("zh-CN", {
month: "numeric",
day: "numeric",
hour: "numeric",
});
if (hourlyStats.has(hourKey)) {
hourlyStats.set(hourKey, hourlyStats.get(hourKey) + 1);
}
}
});
// 转换为图表数据
const sortedHours = Array.from(hourlyStats.keys()).reverse();
const counts = sortedHours.map((hour) => hourlyStats.get(hour));
requestsChart.data.labels = sortedHours;
requestsChart.data.datasets[0].data = counts;
requestsChart.update();
}
async function updateTable(data) {
const tbody = document.getElementById("logsBody");
updateStats(data);
updateChart(data);
// 更新总记录数和分页信息
totalRecords = data.total;
document.getElementById("totalRecords").textContent = totalRecords;
updatePaginationControls();
// 收集所有的token keys
const tokenKeys = new Set();
data.logs.forEach((log) => {
if (log.token_info && log.token_info.key) {
tokenKeys.add(log.token_info.key);
}
});
// 如果还没有加载过所有token信息,则加载并等待完成
if (!allTokensLoaded && tokenKeys.size > 0) {
await loadAllTokenInfo(Array.from(tokenKeys));
}
tbody.innerHTML = data.logs
.map((log) => {
// 准备数据属性
let attributes = {};
// 处理 prompt 属性
if (log.chain && typeof log.chain.prompt === "string") {
try {
const promptJson = JSON.stringify(log.chain.prompt);
const escapedPrompt = escapeHtml(promptJson);
attributes.prompt = `data-prompt='${escapedPrompt}'`;
} catch (e) {
console.error(
"Failed to process prompt data:",
e,
log.chain.prompt,
);
}
}
// 处理 think 属性
if (log.chain && typeof log.chain.think === "string") {
try {
const thinkJson = JSON.stringify(log.chain.think);
const escapedThink = escapeHtml(thinkJson);
attributes.think = `data-think='${escapedThink}'`;
} catch (e) {
console.error(
"Failed to process think data:",
e,
log.chain.think,
);
}
}
// 处理 delays 属性
if (
log.chain &&
Array.isArray(log.chain.delays) &&
log.chain.delays.length === 2 &&
typeof log.chain.delays[0] === "string" &&
Array.isArray(log.chain.delays[1])
) {
try {
const delaysJson = JSON.stringify(log.chain.delays);
const escapedDelays = escapeHtml(delaysJson);
attributes.delays = `data-delays='${escapedDelays}'`;
} catch (e) {
console.error(
"Failed to stringify delays data:",
e,
log.chain.delays,
);
}
}
// 组合所有属性
const allAttributes = Object.values(attributes).join(" ");
return `<tr>
<td>${log.id}</td>
<td>${new Date(log.timestamp).toLocaleString()}</td>
<td>${log.model}</td>
<td><div class="token-info-tooltip"><button class="info-button" onclick='showTokenModal(${JSON.stringify(
log.token_info,
)})'>查看详情<div class="tooltip-content">${formatSimpleTokenInfo(
log.token_info,
)}</div></button></div></td>
<td>${
log.chain
? `<div class="token-info-tooltip prompt-preview"><button class="info-button view-conversation" ${allAttributes}>查看对话<div class="tooltip-content">${formatDialogPreview(
JSON.stringify(log.chain.prompt || ""),
)}</div></button></div>`
: "-"
}</td>
<td>${formatTiming(log.timing.total)}</td>
<td>${formatUsage(log.chain?.usage)}</td>
<td>${log.stream ? "是" : "否"}</td>
<td>${log.status}</td>
<td>${
typeof log.error === "string" ? log.error : (log.error?.error ?? "-")
}</td>
</tr>`;
})
.join("");
// 添加事件监听器
tbody.querySelectorAll(".view-conversation").forEach((button) => {
button.addEventListener("click", function () {
const prompt = this.hasAttribute("data-prompt")
? JSON.parse(this.dataset.prompt)
: "";
const think = this.hasAttribute("data-think")
? JSON.parse(this.dataset.think)
: "";
const delays = this.hasAttribute("data-delays")
? JSON.parse(this.dataset.delays)
: [];
showConversationModal(prompt, think, delays);
});
});
}
function formatTiming(total) {
return `${total.toFixed(2)}s`;
}
function formatDialogPreview(promptStr) {
try {
const messages = parsePrompt(promptStr);
if (!messages || messages.length === 0) {
return "无对话内容";
}
// 获取最后一条消息
const lastMessage = messages[messages.length - 1];
const roleLabels = {
system: "系统",
user: "用户",
assistant: "助手",
};
return `<div class="message-meta">最后一条消息 (${
roleLabels[lastMessage.role] || lastMessage.role
}):</div><div class="last-message">${lastMessage.content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/\n/g, "<br>")}</div>`;
} catch (e) {
console.error("预览对话内容失败:", e);
return "无法解析对话内容";
}
}
// 更新分页控件状态
function updatePaginationControls() {
const prevPageBtn = document.getElementById("prevPage");
const nextPageBtn = document.getElementById("nextPage");
const currentPageSpan = document.getElementById("currentPage");
const pageNumberInput = document.getElementById("pageNumberInput");
const customPageSize = document.getElementById("customPageSize");
// 计算总页数
const totalPages = Math.ceil(totalRecords / pageSize) || 1;
// 禁用或启用上一页按钮
prevPageBtn.disabled = currentPageIndex <= 0;
// 禁用或启用下一页按钮(如果当前页是最后一页或没有足够的记录)
const hasMorePages = (currentPageIndex + 1) * pageSize < totalRecords;
nextPageBtn.disabled = !hasMorePages;
// 更新当前页显示
currentPageSpan.textContent = `第 ${
currentPageIndex + 1
} 页 / 共 ${totalPages} 页`;
// 同步输入框数值
pageNumberInput.value = currentPageIndex + 1;
pageNumberInput.max = totalPages;
customPageSize.value = pageSize;
}
// 切换页码
function changePage(delta) {
currentPageIndex += delta;
if (currentPageIndex < 0) currentPageIndex = 0;
fetchLogs();
}
// 跳转到指定页
function jumpToPage() {
const pageNumberInput = document.getElementById("pageNumberInput");
const pageNumber = parseInt(pageNumberInput.value);
const totalPages = Math.ceil(totalRecords / pageSize) || 1;
if (isNaN(pageNumber) || pageNumber < 1) {
showGlobalMessage("请输入有效的页码", true);
pageNumberInput.value = currentPageIndex + 1;
return;
}
if (pageNumber > totalPages) {
showGlobalMessage(`页码超出范围,最大页码为 ${totalPages}`, true);
pageNumberInput.value = totalPages;
return;
}
currentPageIndex = pageNumber - 1;
fetchLogs();
}
// 更改每页显示数量
function changePageSize() {
const customPageSize = document.getElementById("customPageSize");
const newPageSize = parseInt(customPageSize.value);
if (isNaN(newPageSize) || newPageSize < 5 || newPageSize > 100) {
showGlobalMessage("每页显示数量必须在5-100之间", true);
customPageSize.value = pageSize;
return;
}
// 重置到第一页并使用新的pageSize
currentPageIndex = 0;
pageSize = newPageSize;
fetchLogs();
}
// 获取时间范围筛选
function getTimeRangeFilter() {
const fromDate = document.getElementById("fromDate").value;
const toDate = document.getElementById("toDate").value;
const filter = {};
if (fromDate) {
// 转换为RFC3339格式
filter.from_date = new Date(fromDate).toISOString();
}
if (toDate) {
filter.to_date = new Date(toDate).toISOString();
}
return filter;
}
// 获取用户筛选
function getUserFilter() {
const filter = {};
const userId = document.getElementById("userId").value.trim();
if (userId) filter.user_id = userId;
const email = document.getElementById("email").value.trim();
if (email) filter.email = email;
const membership = document.getElementById("membershipType").value;
if (membership) filter.membership_type = membership;
return filter;
}
// 获取模型筛选
function getModelFilter() {
const filter = {};
const model = document.getElementById("model").value.trim();
if (model) filter.model = model;
const includeModels = document
.getElementById("includeModels")
.value.trim();
if (includeModels) {
filter.include_models = includeModels
.split(",")
.map((m) => m.trim())
.filter((m) => m);
}
const excludeModels = document
.getElementById("excludeModels")
.value.trim();
if (excludeModels) {
filter.exclude_models = excludeModels
.split(",")
.map((m) => m.trim())
.filter((m) => m);
}
return filter;
}
// 获取性能筛选
function getPerformanceFilter() {
const filter = {};
const minTime = parseFloat(
document.getElementById("minTotalTime").value,
);
if (!isNaN(minTime)) filter.min_total_time = minTime;
const maxTime = parseFloat(
document.getElementById("maxTotalTime").value,
);
if (!isNaN(maxTime)) filter.max_total_time = maxTime;
const minTokens = parseInt(document.getElementById("minTokens").value);
if (!isNaN(minTokens)) filter.min_tokens = minTokens;
const maxTokens = parseInt(document.getElementById("maxTokens").value);
if (!isNaN(maxTokens)) filter.max_tokens = maxTokens;
return filter;
}
// 获取其他筛选
function getOtherFilters() {
const filter = {};
const status = document.getElementById("status").value;
if (status) filter.status = status;
const stream = document.getElementById("stream").value;
if (stream) filter.stream = stream === "true";
const hasError = document.getElementById("hasError").value;
if (hasError) filter.has_error = hasError === "true";
const hasChain = document.getElementById("hasChain").value;
if (hasChain) filter.has_chain = hasChain === "true";
const hasUsage = document.getElementById("hasUsage").value;
if (hasUsage) filter.has_usage = hasUsage === "true";
const error = document.getElementById("errorText").value.trim();
if (error) filter.error = error;
return filter;
}
// 应用筛选条件
function applyFilters() {
// 重置到第一页
currentPageIndex = 0;
// 更新每页显示数量
const pageSizeSelect = document.getElementById("pageSize");
if (pageSizeSelect) {
pageSize = parseInt(pageSizeSelect.value);
// 同步自定义输入框
document.getElementById("customPageSize").value = pageSize;
}
// 获取筛选后的数据
fetchLogs();
}
// 重置筛选条件
function resetFilters() {
// 重置所有输入框
document.getElementById("fromDate").value = "";
document.getElementById("toDate").value = "";
document.getElementById("userId").value = "";
document.getElementById("email").value = "";
document.getElementById("membershipType").value = "";
document.getElementById("model").value = "";
document.getElementById("includeModels").value = "";
document.getElementById("excludeModels").value = "";
document.getElementById("minTotalTime").value = "";
document.getElementById("maxTotalTime").value = "";
document.getElementById("minTokens").value = "";
document.getElementById("maxTokens").value = "";
document.getElementById("status").value = "";
document.getElementById("stream").value = "";
document.getElementById("hasError").value = "";
document.getElementById("hasChain").value = "";
document.getElementById("hasUsage").value = "";
document.getElementById("reverse").value = "false";
document.getElementById("pageSize").value = "20";
document.getElementById("errorText").value = "";
// 重置页码
currentPageIndex = 0;
pageSize = 20;
// 刷新数据
fetchLogs();
}
async function fetchLogs() {
// 构建查询参数
const query = {
// 分页参数
limit: pageSize,
offset: currentPageIndex * pageSize,
reverse: document.getElementById("reverse").value === "true",
// 时间范围
...getTimeRangeFilter(),
// 用户筛选
...getUserFilter(),
// 模型筛选
...getModelFilter(),
// 性能筛选
...getPerformanceFilter(),
// 其他筛选
...getOtherFilters(),
};
// 发送请求到新的API端点
const data = await makeAuthenticatedRequest("/logs/get", {
body: JSON.stringify({ query }),
});
if (data) {
updateTable(data);
showGlobalMessage("日志获取成功");
}
}
// 获取Token详细信息
async function fetchTokenDetails(tokens) {
const data = await makeAuthenticatedRequest("/logs/tokens/get", {
body: JSON.stringify(tokens),
});
if (data) {
return data.tokens;
}
return null;
}
// 批量加载所有Token信息到缓存
async function loadAllTokenInfo(tokenKeys) {
if (tokenKeys.length === 0) return;
console.log(`正在加载 ${tokenKeys.length} 个token的详细信息...`);
try {
const detailsData = await fetchTokenDetails(tokenKeys);
if (detailsData) {
// 将所有token信息存入缓存
Object.entries(detailsData).forEach(([key, info]) => {
tokenInfoCache[key] = info;
});
allTokensLoaded = true;
console.log(
`成功加载 ${Object.keys(detailsData).length} 个token的详细信息`,
);
showGlobalMessage(
`已缓存 ${Object.keys(detailsData).length} 个Token信息`,
);
}
} catch (error) {
console.error("加载Token信息失败:", error);
}
}
// 导出日志数据
async function exportLogs() {
// 获取当前筛选条件下的所有数据
const query = {
// 不限制数量,导出所有数据
limit: 10000,
offset: 0,
reverse: document.getElementById("reverse").value === "true",
// 时间范围
...getTimeRangeFilter(),
// 用户筛选
...getUserFilter(),
// 模型筛选
...getModelFilter(),
// 性能筛选
...getPerformanceFilter(),
// 其他筛选
...getOtherFilters(),
};
const data = await makeAuthenticatedRequest("/logs/get", {
method: "POST",
body: JSON.stringify({ query }),
});
if (data) {
// 创建CSV格式的数据
const csvContent = createCSVFromLogs(data.logs);
// 下载CSV文件
const blob = new Blob([csvContent], {
type: "text/csv;charset=utf-8;",
});
const link = document.createElement("a");
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `logs_${new Date().toISOString()}.csv`);
link.style.visibility = "hidden";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
showGlobalMessage(`成功导出 ${data.logs.length} 条日志记录`);
}
}
// 创建CSV内容
function createCSVFromLogs(logs) {
const headers = [
"ID",
"时间",
"模型",
"Token Key",
"用户邮箱",
"会员类型",
"耗时(秒)",
"输入Tokens",
"输出Tokens",
"流式",
"状态",
"错误信息",
];
const rows = logs.map((log) => {
const tokenInfo = log.token_info || {};
const stripe = tokenInfo.stripe || {};
const usage = log.chain?.usage || {};
const user = tokenInfo.user || {};
return [
log.id,
new Date(log.timestamp).toLocaleString(),
log.model,
tokenInfo.key || "-",
user.email || "-",
stripe.membership_type || "-",
log.timing.total.toFixed(2),
usage.input || 0,
usage.output || 0,
log.stream ? "是" : "否",
log.status,
typeof log.error === "string" ? log.error : log.error?.error || "-",
];
});
// 转换为CSV格式
const csvRows = [headers, ...rows].map((row) =>
row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(","),
);
return csvRows.join("\n");
}
// 自动刷新控制
document
.getElementById("autoRefresh")
.addEventListener("change", function (e) {
if (e.target.checked) {
refreshInterval = setInterval(fetchLogs, 60000);
} else {
clearInterval(refreshInterval);
}
});
// 页面加载完成后自动获取日志
document.addEventListener("DOMContentLoaded", () => {
const authToken = getAuthToken();
if (authToken) {
document.getElementById("authToken").value = authToken;
// 设置默认页大小
pageSize = parseInt(
document.getElementById("pageSize").value || "20",
);
document.getElementById("customPageSize").value = pageSize;
fetchLogs();
}
// 启动自动刷新
refreshInterval = setInterval(fetchLogs, 60000);
// 添加页码输入框回车键支持
document
.getElementById("pageNumberInput")
.addEventListener("keyup", function (event) {
if (event.key === "Enter") {
jumpToPage();
}
});
// 添加页面大小输入框回车键支持
document
.getElementById("customPageSize")
.addEventListener("keyup", function (event) {
if (event.key === "Enter") {
changePageSize();
}
});
});
// 初始化 token 处理
initializeTokenHandling("authToken");
// 添加清理逻辑
window.addEventListener("beforeunload", () => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
});
// 添加模态框关闭逻辑
document.querySelectorAll(".modal .close").forEach((closeBtn) => {
closeBtn.onclick = function () {
this.closest(".modal").style.display = "none";
};
});
window.onclick = function (event) {
if (event.target.classList.contains("modal")) {
event.target.style.display = "none";
}
};
/**
* 解析对话内容
* @param {string} promptStr - 原始prompt字符串
* @returns {Array<{role: string, content: string}>} 解析后的对话数组
*/
function parsePrompt(promptStr) {
if (!promptStr) return [];
// 尝试解析JSON格式的prompt
try {
const parsed = JSON.parse(promptStr);
// 如果是已解析的格式
if (Array.isArray(parsed)) {
return parsed.map((msg) => ({
role: msg.role,
content: msg.content,
}));
}
} catch (e) {
// 如果解析JSON失败,说明是原始字符串格式,使用原来的解析逻辑
}
const messages = [];
const lines = promptStr.split("\n");
let currentRole = "";
let currentContent = "";
const roleMap = {
BEGIN_SYSTEM: "system",
BEGIN_USER: "user",
BEGIN_ASSISTANT: "assistant",
};
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 检查是否是角色标记行
let foundRole = false;
for (const [marker, role] of Object.entries(roleMap)) {
if (line.includes(marker)) {
// 保存之前的消息(如果有)
if (currentRole && currentContent.trim()) {
messages.push({
role: currentRole,
content: currentContent.trim(),
});
}
// 设置新角色
currentRole = role;
currentContent = "";
foundRole = true;
break;
}
}
// 如果不是角色标记行且不是END标记行,则添加到当前内容
if (!foundRole && !line.includes("END_")) {
currentContent += line + "\n";
}
}
// 添加最后一条消息
if (currentRole && currentContent.trim()) {
messages.push({
role: currentRole,
content: currentContent.trim(),
});
}
return messages;
}
function escapeHtml(content) {
// 先转义HTML特殊字符
const escaped = content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
return escaped;
}
function escapeHtmlAndControlChars(content) {
// 先转义HTML特殊字符
let escaped = content
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
// 然后转义控制字符
escaped = escaped
.replace(/\\/g, "\\\\")
.replace(/\n/g, "\\n")
.replace(/\t/g, "\\t")
.replace(/\r/g, "\\r");
return escaped;
}
/**
* 格式化对话内容为HTML表格
* @param {Array<{role: string, content: string}>} messages - 对话消息数组
* @returns {string} HTML表格字符串
*/
function formatPromptToTable(messages) {
if (!messages || messages.length === 0) {
return "<p>无对话内容</p>";
}
const roleLabels = {
system: "系统",
user: "用户",
assistant: "助手",
};
return `<table class="message-table"><thead><tr><th>角色</th><th>内容</th></tr></thead><tbody>${messages
.map(
(msg) =>
`<tr><td>${roleLabels[msg.role] || msg.role}</td><td>${escapeHtml(
msg.content,
).replace(/\n/g, "<br>")}</td></tr>`,
)
.join("")}</tbody></table>`;
}
/**
* 显示对话详情弹窗
* @param {string} promptStr - 对话提示字符串
* @param {string} thinkStr - 思考过程字符串
* @param {Array} delaysTuple - 延迟数据数组
*/
function showConversationModal(promptStr, thinkStr, delaysTuple) {
try {
const modal = document.getElementById("conversationModal");
const dialogContent = document.getElementById("dialogContent");
const thinkContent = document.getElementById("thinkContent");
const delaysContent = document.getElementById("delaysContent");
const tabPrompt = document.getElementById("tab-prompt");
const tabThink = document.getElementById("tab-think");
const tabDelays = document.getElementById("tab-delays");
const delaysTableBody = document.querySelector("#delaysTable tbody");
const delayChartContainer = document.querySelector(
".delay-chart-container",
);
if (
!modal ||
!dialogContent ||
!thinkContent ||
!delaysContent ||
!tabPrompt ||
!tabThink ||
!tabDelays ||
!delaysTableBody ||
!delayChartContainer
) {
console.error("Modal elements not found");
return;
}
// 处理 Prompt
try {
const messages = parsePrompt(promptStr);
dialogContent.innerHTML = formatPromptToTable(messages);
} catch (e) {
console.error("解析 Prompt 数据失败:", e);
dialogContent.innerHTML = "<p>无法加载对话内容。</p>";
}
// 处理思考过程
if (thinkStr && typeof thinkStr === "string") {
document.getElementById("thinkText").textContent = thinkStr;
tabThink.style.display = "";
} else {
document.getElementById("thinkText").textContent = "无思考过程";
tabThink.style.display = "none";
}
// 处理 Delays
delaysTableBody.innerHTML = "";
delayChartContainer.innerHTML = '<canvas id="delayChart"></canvas>';
let fullText = "";
let delayPoints = [];
let chartDataPoints = [{ time: 0, chars: 0, text: "" }];
if (delaysTuple) {
if (
Array.isArray(delaysTuple) &&
delaysTuple.length === 2 &&
typeof delaysTuple[0] === "string" &&
Array.isArray(delaysTuple[1])
) {
fullText = delaysTuple[0];
delayPoints = delaysTuple[1];
} else {
console.warn("Delays data format is incorrect:", delaysTuple);
}
}
if (delayPoints.length > 0) {
const textChars = Array.from(fullText);
let currentIndex = 0;
let totalChars = 0;
let totalTime = 0;
delayPoints.forEach(([length, deltaTime], index) => {
if (
typeof length !== "number" ||
typeof deltaTime !== "number" ||
length < 0 ||
deltaTime < 0
) {
console.warn(
`Skipping invalid delay point at index ${index}:`,
[length, deltaTime],
);
return;
}
const chunkChars = textChars.slice(currentIndex, currentIndex + length);
const chunkText = chunkChars.join('');
currentIndex += length;
totalChars += length;
totalTime += deltaTime;
const rate = deltaTime > 0 ? length / deltaTime : Infinity;
const avgRate = totalTime > 0 ? totalChars / totalTime : Infinity;
const row = document.createElement("tr");
row.innerHTML = `
<td>${index + 1}</td>
<td>${escapeHtmlAndControlChars(chunkText)}</td>
<td>${deltaTime.toFixed(3)}</td>
<td>${isFinite(rate) ? rate.toFixed(1) : "N/A"} (平均: ${
isFinite(avgRate) ? avgRate.toFixed(1) : "N/A"
})</td>
`;
delaysTableBody.appendChild(row);
chartDataPoints.push({
time: totalTime,
chars: totalChars,
text: chunkText,
});
});
initDelayChart(chartDataPoints);
tabDelays.style.display = "";
} else {
delaysTableBody.innerHTML =
'<tr><td colspan="4">无延迟数据</td></tr>';
delayChartContainer.innerHTML =
'<div style="text-align: center; padding: 20px;">无延迟数据可供分析</div>';
tabDelays.style.display = "none";
}
// 设置标签页
tabPrompt.onclick = () => setActiveTab("prompt");
tabThink.onclick = () => setActiveTab("think");
tabDelays.onclick = () => setActiveTab("delays");
setActiveTab("prompt");
modal.style.display = "block";
} catch (e) {
console.error("显示对话详情失败:", e);
}
}
/**
* 设置当前活动标签页
* @param {string} tabName - 标签页名称 ('prompt' 或 'delays')
*/
function setActiveTab(tabName) {
const dialogContent = document.getElementById("dialogContent");
const thinkContent = document.getElementById("thinkContent");
const delaysContent = document.getElementById("delaysContent");
const tabPrompt = document.getElementById("tab-prompt");
const tabThink = document.getElementById("tab-think");
const tabDelays = document.getElementById("tab-delays");
if (
!dialogContent ||
!thinkContent ||
!delaysContent ||
!tabPrompt ||
!tabThink ||
!tabDelays
) {
console.error("Tab elements not found");
return;
}
if (tabName === "prompt") {
dialogContent.classList.add("active");
thinkContent.classList.remove("active");
delaysContent.classList.remove("active");
tabPrompt.classList.add("active");
tabThink.classList.remove("active");
tabDelays.classList.remove("active");
} else if (tabName === "think") {
dialogContent.classList.remove("active");
thinkContent.classList.add("active");
delaysContent.classList.remove("active");
tabPrompt.classList.remove("active");
tabThink.classList.add("active");
tabDelays.classList.remove("active");
} else {
dialogContent.classList.remove("active");
thinkContent.classList.remove("active");
delaysContent.classList.add("active");
tabPrompt.classList.remove("active");
tabThink.classList.remove("active");
tabDelays.classList.add("active");
}
}
/**
* 初始化延迟图表
* @param {Array<{time: number, chars: number, text: string}>} chartDataPoints - 包含累计时间和字符数以及块文本的数据点数组
*/
function initDelayChart(chartDataPoints) {
if (!chartDataPoints || chartDataPoints.length <= 1) {
const container = document.querySelector(".delay-chart-container");
if (container) {
container.innerHTML =
'<div style="text-align: center; padding: 20px;">延迟数据不足,无法绘制图表</div>';
}
return;
}
const ctx = document.getElementById("delayChart");
if (!ctx) {
console.error("找不到图表canvas元素");
return;
}
const existingChart = Chart.getChart(ctx);
if (existingChart) {
existingChart.destroy();
}
const maxPoints = 100;
let sampledPoints = chartDataPoints;
if (chartDataPoints.length > maxPoints) {
sampledPoints = [];
const interval = (chartDataPoints.length - 2) / (maxPoints - 2);
sampledPoints.push(chartDataPoints[0]);
for (let i = 1; i < maxPoints - 1; i++) {
const rawIndex = Math.round(i * interval);
const actualIndex = Math.min(
rawIndex + 1,
chartDataPoints.length - 2,
);
sampledPoints.push(chartDataPoints[actualIndex]);
}
sampledPoints.push(chartDataPoints[chartDataPoints.length - 1]);
}
new Chart(ctx, {
type: "line",
data: {
datasets: [
{
label: "累计输出字符数",
data: sampledPoints.map((point) => ({
x: point.time,
y: point.chars,
})),
borderColor: "rgb(75,192,192)",
backgroundColor: "rgba(75,192,192,0.2)",
tension: 0.1,
fill: true,
pointRadius: 3,
pointHoverRadius: 5,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: "字符输出进度",
},
tooltip: {
callbacks: {
title: function (context) {
const pointIndex = context[0].dataIndex;
if (pointIndex < sampledPoints.length) {
return `用时: ${sampledPoints[pointIndex].time.toFixed(
1,
)}秒`;
}
return "";
},
label: function (context) {
const pointIndex = context.dataIndex;
if (pointIndex < sampledPoints.length) {
const point = sampledPoints[pointIndex];
const prevPoint =
pointIndex > 0
? sampledPoints[pointIndex - 1]
: { time: 0, chars: 0, text: "" };
const deltaTime = point.time - prevPoint.time;
const deltaChars = point.chars - prevPoint.chars;
const currentRate =
deltaTime > 0
? (deltaChars / deltaTime).toFixed(1)
: "N/A";
const avgRate =
point.time > 0
? (point.chars / point.time).toFixed(1)
: "N/A";
const chunkText = point.text || "";
return [
`累计字符: ${point.chars}`,
`平均速率: ${avgRate} 字符/秒`,
`当前块速率: ${currentRate} 字符/秒`,
`当前块文本: ${escapeHtmlAndControlChars(
chunkText.length > 50
? chunkText.substring(0, 50) + "..."
: chunkText,
)}`,
];
}
return "";
},
},
},
},
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: "累计字符数",
},
},
x: {
type: "linear",
beginAtZero: true,
title: {
display: true,
text: "累计用时(秒)",
},
ticks: {
callback: function (value) {
return value.toFixed(1) + "s";
},
},
},
},
},
});
}
/**
* 格式化使用量信息
* @param {Object} usage - 使用量对象 { input: number, output: number }
* @returns {string} 格式化后的字符串
*/
function formatUsage(usage) {
if (!usage) return "-";
return `${usage.input} / ${usage.output}`;
}
</script>
</body>
</html>