mirror of
https://github.com/wisdgod/cursor-api.git
synced 2025-10-22 06:09:25 +08:00
2529 lines
72 KiB
HTML
2529 lines
72 KiB
HTML
<!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>
|
||
« 上一页
|
||
</button>
|
||
<span id="currentPage">第 1 页</span>
|
||
<button id="nextPage" onclick="changePage(1)" disabled>
|
||
下一页 »
|
||
</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">×</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">×</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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
return escaped;
|
||
}
|
||
|
||
function escapeHtmlAndControlChars(content) {
|
||
// 先转义HTML特殊字符
|
||
let escaped = content
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
|
||
// 然后转义控制字符
|
||
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> |