Files
cursor-api/static/logs.html
2025-07-27 09:04:19 +08:00

2529 lines
72 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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">从新到旧</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>