purewhiter--mobilegym
5563 行
205 KiB
HTML
5563 行
205 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Run Explorer - 轨迹数据可视化</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||
<style>
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
:root {
|
||
--bg: #f8fafc;
|
||
--bg-card: #ffffff;
|
||
--bg-hover: #f1f5f9;
|
||
--bg-active: #e0f2fe;
|
||
--border: #e2e8f0;
|
||
--border-active: #0ea5e9;
|
||
--text: #0f172a;
|
||
--text-secondary: #64748b;
|
||
--text-muted: #94a3b8;
|
||
--primary: #0ea5e9;
|
||
--primary-light: #e0f2fe;
|
||
--primary-dark: #0284c7;
|
||
--success: #10b981;
|
||
--success-light: #d1fae5;
|
||
--error: #ef4444;
|
||
--error-light: #fee2e2;
|
||
--warning: #f59e0b;
|
||
--warning-light: #fef3c7;
|
||
--purple: #8b5cf6;
|
||
--purple-light: #ede9fe;
|
||
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
|
||
--shadow: 0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1);
|
||
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1);
|
||
--radius: 12px;
|
||
--radius-sm: 8px;
|
||
--font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||
}
|
||
|
||
body {
|
||
font-family: var(--font);
|
||
background: linear-gradient(135deg, #f0f9ff 0%, #f8fafc 50%, #faf5ff 100%);
|
||
color: var(--text);
|
||
min-height: 100vh;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ========== Layout ========== */
|
||
.app {
|
||
display: grid;
|
||
grid-template-rows: auto 1fr;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ========== Header ========== */
|
||
header {
|
||
background: var(--bg-card);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 16px 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 20px;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.logo {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
|
||
.logo-icon {
|
||
width: 40px;
|
||
height: 40px;
|
||
background: linear-gradient(135deg, var(--primary) 0%, var(--purple) 100%);
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-size: 20px;
|
||
}
|
||
|
||
.logo h1 {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
background: linear-gradient(135deg, var(--primary) 0%, var(--purple) 100%);
|
||
-webkit-background-clip: text;
|
||
-webkit-text-fill-color: transparent;
|
||
background-clip: text;
|
||
}
|
||
|
||
.logo p {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.btn {
|
||
padding: 10px 18px;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.btn-primary {
|
||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||
color: white;
|
||
box-shadow: 0 2px 4px rgba(14, 165, 233, 0.3);
|
||
}
|
||
|
||
.btn-primary:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: 0 4px 8px rgba(14, 165, 233, 0.4);
|
||
}
|
||
|
||
.btn-secondary {
|
||
background: var(--bg-card);
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.btn-secondary:hover {
|
||
background: var(--bg-hover);
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.btn:disabled {
|
||
opacity: 0.5;
|
||
cursor: not-allowed;
|
||
transform: none !important;
|
||
}
|
||
|
||
/* ========== Main Content ========== */
|
||
main {
|
||
display: grid;
|
||
grid-template-columns: 380px 1fr;
|
||
min-height: 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ========== Sidebar ========== */
|
||
.sidebar {
|
||
background: var(--bg-card);
|
||
border-right: 1px solid var(--border);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.sidebar-header h2 {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
margin-bottom: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.sidebar-header h2::before {
|
||
content: '';
|
||
width: 4px;
|
||
height: 16px;
|
||
background: linear-gradient(180deg, var(--primary) 0%, var(--purple) 100%);
|
||
border-radius: 2px;
|
||
}
|
||
|
||
.run-select-wrap {
|
||
position: relative;
|
||
}
|
||
|
||
.run-select-wrap select {
|
||
width: 100%;
|
||
padding: 12px 16px;
|
||
padding-right: 40px;
|
||
background: var(--bg);
|
||
border: 2px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
appearance: none;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.run-select-wrap select:hover {
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.run-select-wrap select:focus {
|
||
outline: none;
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px var(--primary-light);
|
||
}
|
||
|
||
.run-select-wrap::after {
|
||
content: '▼';
|
||
position: absolute;
|
||
right: 14px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
font-size: 10px;
|
||
color: var(--text-muted);
|
||
pointer-events: none;
|
||
}
|
||
|
||
/* Filters */
|
||
.filters {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--bg);
|
||
}
|
||
|
||
.filter-group {
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.filter-group-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.filter-selects {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 6px;
|
||
}
|
||
|
||
.filter-selects.single {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.filter-select {
|
||
width: 100%;
|
||
padding: 7px 28px 7px 10px;
|
||
background: var(--bg-card);
|
||
border: 1.5px solid var(--border);
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
appearance: none;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
background-image: url("data:image/svg+xml,%3Csvg width='10' height='6' viewBox='0 0 10 6' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1L5 5L9 1' stroke='%2394a3b8' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat;
|
||
background-position: right 8px center;
|
||
}
|
||
|
||
.filter-select:hover {
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.filter-select:focus {
|
||
outline: none;
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 2px var(--primary-light);
|
||
}
|
||
|
||
.filter-select.active-filter {
|
||
border-color: var(--primary);
|
||
background-color: var(--primary-light);
|
||
}
|
||
|
||
.active-filters-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.active-filter-tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 3px 8px;
|
||
background: var(--primary-light);
|
||
color: var(--primary-dark);
|
||
border-radius: 12px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
border: 1px solid var(--primary);
|
||
}
|
||
|
||
.active-filter-tag .remove-filter {
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
opacity: 0.6;
|
||
transition: opacity 0.15s;
|
||
}
|
||
|
||
.active-filter-tag .remove-filter:hover {
|
||
opacity: 1;
|
||
}
|
||
|
||
.filter-reset-btn {
|
||
padding: 3px 8px;
|
||
background: var(--bg-card);
|
||
color: var(--text-muted);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
}
|
||
|
||
.filter-reset-btn:hover {
|
||
background: var(--error-light);
|
||
color: var(--error);
|
||
border-color: var(--error);
|
||
}
|
||
|
||
.search-box {
|
||
position: relative;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.search-box input {
|
||
width: 100%;
|
||
padding: 10px 14px 10px 40px;
|
||
background: var(--bg-card);
|
||
border: 2px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
font-size: 14px;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.search-box input:focus {
|
||
outline: none;
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px var(--primary-light);
|
||
}
|
||
|
||
.search-box::before {
|
||
content: '🔍';
|
||
position: absolute;
|
||
left: 14px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
font-size: 14px;
|
||
}
|
||
|
||
.filter-chips {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 12px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
border: 2px solid transparent;
|
||
}
|
||
|
||
.chip input {
|
||
display: none;
|
||
}
|
||
|
||
.chip.success {
|
||
background: var(--success-light);
|
||
color: var(--success);
|
||
}
|
||
|
||
.chip.success.active {
|
||
border-color: var(--success);
|
||
}
|
||
|
||
.chip.failed {
|
||
background: var(--error-light);
|
||
color: var(--error);
|
||
}
|
||
|
||
.chip.failed.active {
|
||
border-color: var(--error);
|
||
}
|
||
|
||
.chip.error {
|
||
background: var(--warning-light);
|
||
color: var(--warning);
|
||
}
|
||
|
||
.chip.error.active {
|
||
border-color: var(--warning);
|
||
}
|
||
|
||
.chip.sideEffect {
|
||
background: #fff7ed;
|
||
color: #f97316;
|
||
}
|
||
|
||
.chip.sideEffect.active {
|
||
border-color: #f97316;
|
||
}
|
||
|
||
.chip.overdue {
|
||
background: #ede9fe;
|
||
color: #7c3aed;
|
||
}
|
||
|
||
.chip.overdue.active {
|
||
border-color: #7c3aed;
|
||
}
|
||
|
||
.chip.falseComplete {
|
||
background: #fce7f3;
|
||
color: #be185d;
|
||
}
|
||
|
||
.chip.falseComplete.active {
|
||
border-color: #be185d;
|
||
}
|
||
|
||
/* Task List */
|
||
.task-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 12px;
|
||
}
|
||
|
||
.task-item {
|
||
padding: 14px 16px;
|
||
background: var(--bg);
|
||
border: 2px solid transparent;
|
||
border-radius: var(--radius);
|
||
margin-bottom: 8px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.task-item:hover {
|
||
background: var(--bg-hover);
|
||
border-color: var(--border);
|
||
transform: translateX(4px);
|
||
}
|
||
|
||
.task-item.active {
|
||
background: var(--primary-light);
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.task-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.status-badge {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.status-badge.success { background: var(--success); box-shadow: 0 0 8px var(--success); }
|
||
.status-badge.failed { background: var(--error); box-shadow: 0 0 8px var(--error); }
|
||
.status-badge.error { background: var(--warning); box-shadow: 0 0 8px var(--warning); }
|
||
.status-badge.unknown { background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%); box-shadow: 0 0 8px #9ca3af; }
|
||
|
||
.task-title {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
flex: 1;
|
||
}
|
||
|
||
.task-meta {
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
font-family: var(--mono);
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.task-count {
|
||
padding: 2px 0;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
border-top: 1px solid var(--border);
|
||
margin-top: 8px;
|
||
padding-top: 12px;
|
||
}
|
||
|
||
/* ========== Content Area ========== */
|
||
.content {
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: var(--bg);
|
||
}
|
||
|
||
/* Tabs */
|
||
.tabs {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 16px 24px;
|
||
background: var(--bg-card);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.tab {
|
||
padding: 10px 20px;
|
||
background: none;
|
||
border: none;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
position: relative;
|
||
}
|
||
|
||
.tab:hover {
|
||
color: var(--text);
|
||
background: var(--bg-hover);
|
||
}
|
||
|
||
.tab.active {
|
||
color: var(--primary);
|
||
background: var(--primary-light);
|
||
}
|
||
|
||
.tab.active::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: -16px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 40px;
|
||
height: 3px;
|
||
background: var(--primary);
|
||
border-radius: 3px;
|
||
}
|
||
|
||
/* Tab Content */
|
||
.tab-content {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 24px;
|
||
}
|
||
|
||
.panel {
|
||
display: none;
|
||
animation: fadeIn 0.3s ease;
|
||
}
|
||
|
||
.panel.active {
|
||
display: block;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from { opacity: 0; transform: translateY(10px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
/* ========== Cards ========== */
|
||
.card {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 24px;
|
||
margin-bottom: 20px;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
margin-bottom: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
|
||
.card-title .icon {
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: var(--radius-sm);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 16px;
|
||
}
|
||
|
||
.card-title .icon.blue { background: var(--primary-light); }
|
||
.card-title .icon.green { background: var(--success-light); }
|
||
.card-title .icon.purple { background: var(--purple-light); }
|
||
|
||
/* Stats Grid */
|
||
.stats-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 16px;
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.stats-grid .stat-card {
|
||
flex: 1 1 auto;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.stat-card {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 20px;
|
||
text-align: center;
|
||
box-shadow: var(--shadow-sm);
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.stat-card:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.stat-card.highlight {
|
||
background: linear-gradient(135deg, var(--primary) 0%, var(--purple) 100%);
|
||
color: white;
|
||
}
|
||
|
||
.stat-card.highlight .stat-label {
|
||
color: rgba(255,255,255,0.8);
|
||
}
|
||
|
||
.stat-value {
|
||
font-size: 36px;
|
||
font-weight: 800;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.stat-value.success { color: var(--success); }
|
||
.stat-value.error { color: var(--error); }
|
||
|
||
.stat-label {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
/* Info Grid */
|
||
.info-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.info-item {
|
||
padding: 12px 16px;
|
||
background: var(--bg);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.info-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.info-value {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
font-family: var(--mono);
|
||
word-break: break-all;
|
||
}
|
||
|
||
/* Judge Section */
|
||
.judge-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 12px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.judge-stat {
|
||
padding: 16px;
|
||
background: var(--bg);
|
||
border-radius: var(--radius-sm);
|
||
text-align: center;
|
||
}
|
||
|
||
.judge-stat-value {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.judge-stat-value.pass { color: var(--success); }
|
||
.judge-stat-value.fail { color: var(--error); }
|
||
|
||
.judge-stat-label {
|
||
font-size: 12px;
|
||
color: var(--text-muted);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.judge-item {
|
||
padding: 16px;
|
||
background: var(--bg);
|
||
border-radius: var(--radius-sm);
|
||
margin-bottom: 10px;
|
||
border-left: 4px solid var(--border);
|
||
}
|
||
|
||
.judge-item.passed { border-left-color: var(--success); background: var(--success-light); }
|
||
.judge-item.failed { border-left-color: var(--error); background: var(--error-light); }
|
||
|
||
.judge-field {
|
||
font-family: var(--mono);
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.judge-values {
|
||
display: flex;
|
||
gap: 24px;
|
||
flex-wrap: wrap;
|
||
font-size: 12px;
|
||
font-family: var(--mono);
|
||
}
|
||
|
||
.judge-values span {
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* Warnings */
|
||
.warnings-card {
|
||
background: var(--warning-light);
|
||
border-color: var(--warning);
|
||
}
|
||
|
||
.warning-item {
|
||
font-size: 12px;
|
||
font-family: var(--mono);
|
||
color: #92400e;
|
||
padding: 8px 12px;
|
||
background: rgba(255,255,255,0.5);
|
||
border-radius: var(--radius-sm);
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
/* Collapsible */
|
||
details {
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
margin-bottom: 16px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
details summary {
|
||
padding: 14px 18px;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
font-size: 14px;
|
||
background: var(--bg-card);
|
||
user-select: none;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
details summary:hover {
|
||
background: var(--bg-hover);
|
||
}
|
||
|
||
details summary::before {
|
||
content: '▶';
|
||
font-size: 10px;
|
||
color: var(--text-muted);
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
details[open] summary::before {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
details .content {
|
||
padding: 16px;
|
||
background: var(--bg);
|
||
}
|
||
|
||
.code {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 16px;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
max-height: 400px;
|
||
overflow: auto;
|
||
}
|
||
|
||
/* ========== Trajectory ========== */
|
||
.trajectory-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.trajectory-header h3 {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.trajectory-nav {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.nav-btn {
|
||
width: 36px;
|
||
height: 36px;
|
||
border: 1px solid var(--border);
|
||
background: var(--bg-card);
|
||
border-radius: var(--radius-sm);
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.nav-btn:hover {
|
||
background: var(--primary-light);
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.trajectory-strip {
|
||
display: flex;
|
||
gap: 12px;
|
||
overflow-x: auto;
|
||
padding: 16px;
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
margin-bottom: 24px;
|
||
}
|
||
|
||
.thumb {
|
||
flex-shrink: 0;
|
||
width: 90px;
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
border: 3px solid transparent;
|
||
transition: all 0.2s;
|
||
position: relative;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.thumb:hover {
|
||
transform: scale(1.05);
|
||
border-color: var(--purple);
|
||
}
|
||
|
||
.thumb.active {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 3px var(--primary-light);
|
||
}
|
||
|
||
.thumb img {
|
||
display: block;
|
||
width: 90px;
|
||
height: 160px;
|
||
object-fit: cover;
|
||
}
|
||
|
||
.thumb-label {
|
||
position: absolute;
|
||
bottom: 6px;
|
||
left: 6px;
|
||
background: rgba(0,0,0,0.75);
|
||
color: white;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
padding: 3px 8px;
|
||
border-radius: 4px;
|
||
font-family: var(--mono);
|
||
}
|
||
|
||
.step-card {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
margin-bottom: 20px;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.step-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 20px;
|
||
background: linear-gradient(135deg, var(--primary-light) 0%, var(--purple-light) 100%);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.step-number {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
.step-action {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--purple);
|
||
background: white;
|
||
padding: 6px 12px;
|
||
border-radius: 20px;
|
||
font-family: var(--mono);
|
||
}
|
||
|
||
.step-body {
|
||
padding: 20px;
|
||
display: grid;
|
||
grid-template-columns: auto 1fr;
|
||
gap: 24px;
|
||
}
|
||
|
||
.step-images {
|
||
display: flex;
|
||
gap: 12px;
|
||
}
|
||
|
||
.step-img-wrap {
|
||
position: relative;
|
||
}
|
||
|
||
.step-img {
|
||
width: 220px;
|
||
border-radius: var(--radius);
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
border: 2px solid var(--border);
|
||
}
|
||
|
||
.step-img:hover {
|
||
transform: scale(1.02);
|
||
border-color: var(--primary);
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
|
||
.img-label {
|
||
position: absolute;
|
||
bottom: 10px;
|
||
left: 10px;
|
||
background: rgba(0,0,0,0.75);
|
||
color: white;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
padding: 4px 10px;
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.step-text {
|
||
min-width: 0;
|
||
}
|
||
|
||
.step-thought {
|
||
font-size: 14px;
|
||
line-height: 1.7;
|
||
background: var(--bg);
|
||
padding: 16px;
|
||
border-radius: var(--radius-sm);
|
||
white-space: pre-wrap;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.step-route {
|
||
font-size: 12px;
|
||
font-family: var(--mono);
|
||
color: var(--purple);
|
||
}
|
||
|
||
.step-meta-info {
|
||
margin-top: 14px;
|
||
padding: 12px 16px;
|
||
background: var(--purple-light);
|
||
border-radius: var(--radius-sm);
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 12px;
|
||
}
|
||
|
||
.step-action-data {
|
||
font-size: 12px;
|
||
font-family: var(--mono);
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
/* Response Section */
|
||
.response-section {
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.response-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
background: linear-gradient(135deg, var(--primary-light) 0%, var(--purple-light) 100%);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.response-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
.response-file {
|
||
font-size: 11px;
|
||
font-family: var(--mono);
|
||
color: var(--text-muted);
|
||
background: white;
|
||
padding: 3px 8px;
|
||
border-radius: 4px;
|
||
}
|
||
|
||
.response-content {
|
||
padding: 16px;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.response-pre {
|
||
font-family: var(--mono);
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
margin: 0;
|
||
color: var(--text);
|
||
}
|
||
|
||
.loading-text {
|
||
color: var(--text-muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.no-response {
|
||
color: var(--text-muted);
|
||
font-size: 13px;
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Prompt/Chat Section */
|
||
.prompt-section {
|
||
margin-top: 16px;
|
||
background: var(--bg);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
}
|
||
|
||
.prompt-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 12px 16px;
|
||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
|
||
.prompt-header:hover {
|
||
background: linear-gradient(135deg, #fde68a 0%, #fcd34d 100%);
|
||
}
|
||
|
||
.prompt-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #92400e;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.prompt-toggle {
|
||
font-size: 10px;
|
||
color: #92400e;
|
||
transition: transform 0.2s;
|
||
}
|
||
|
||
.prompt-section.expanded .prompt-toggle {
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
.prompt-content {
|
||
display: none;
|
||
max-height: 600px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.prompt-section.expanded .prompt-content {
|
||
display: block;
|
||
}
|
||
|
||
.chat-messages {
|
||
padding: 16px;
|
||
}
|
||
|
||
.chat-message {
|
||
margin-bottom: 16px;
|
||
padding: 14px 16px;
|
||
border-radius: var(--radius-sm);
|
||
position: relative;
|
||
}
|
||
|
||
.chat-message:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.chat-message.system {
|
||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
|
||
border-left: 4px solid var(--primary);
|
||
}
|
||
|
||
.chat-message.user {
|
||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
||
border-left: 4px solid var(--success);
|
||
}
|
||
|
||
.chat-message.assistant {
|
||
background: linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%);
|
||
border-left: 4px solid var(--purple);
|
||
}
|
||
|
||
.chat-role {
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-bottom: 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
.chat-message.system .chat-role { color: var(--primary-dark); }
|
||
.chat-message.user .chat-role { color: var(--success); }
|
||
.chat-message.assistant .chat-role { color: var(--purple); }
|
||
|
||
.chat-text {
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
color: var(--text);
|
||
}
|
||
|
||
/* Collapsible system message */
|
||
.chat-message.system .chat-text {
|
||
max-height: 150px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.chat-message.system .chat-text.expanded {
|
||
max-height: none;
|
||
}
|
||
|
||
.chat-message.system .chat-text:not(.expanded)::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 40px;
|
||
background: linear-gradient(transparent, #e0f2fe);
|
||
}
|
||
|
||
.expand-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
padding: 4px 10px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--primary-dark);
|
||
background: rgba(14, 165, 233, 0.1);
|
||
border: none;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.expand-btn:hover {
|
||
background: rgba(14, 165, 233, 0.2);
|
||
}
|
||
|
||
.chat-image-placeholder {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 12px;
|
||
background: rgba(0,0,0,0.05);
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
margin: 4px 0;
|
||
}
|
||
|
||
.chat-content-item {
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.chat-content-item:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
/* Full History Button */
|
||
.full-history-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 8px 14px;
|
||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||
border: 1px solid #f59e0b;
|
||
border-radius: var(--radius-sm);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: #92400e;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
.full-history-btn:hover {
|
||
background: linear-gradient(135deg, #fde68a 0%, #fcd34d 100%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
/* Prompt Modal */
|
||
.prompt-modal {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.8);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 9998;
|
||
backdrop-filter: blur(5px);
|
||
}
|
||
|
||
.prompt-modal.open {
|
||
display: flex;
|
||
}
|
||
|
||
.prompt-modal-content {
|
||
background: var(--bg-card);
|
||
border-radius: var(--radius);
|
||
width: 90%;
|
||
max-width: 900px;
|
||
max-height: 90vh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
box-shadow: var(--shadow-lg);
|
||
}
|
||
|
||
.prompt-modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 20px;
|
||
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.prompt-modal-title {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: #92400e;
|
||
}
|
||
|
||
.prompt-modal-close {
|
||
width: 32px;
|
||
height: 32px;
|
||
border: none;
|
||
background: rgba(0,0,0,0.1);
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
font-size: 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.prompt-modal-close:hover {
|
||
background: rgba(0,0,0,0.2);
|
||
}
|
||
|
||
.prompt-modal-body {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 20px;
|
||
}
|
||
|
||
.prompt-stats {
|
||
display: flex;
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
padding: 12px 16px;
|
||
background: var(--bg);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.prompt-stat {
|
||
font-size: 12px;
|
||
color: var(--text-secondary);
|
||
}
|
||
|
||
.prompt-stat strong {
|
||
color: var(--text);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Return Value */
|
||
.return-value {
|
||
margin-top: 14px;
|
||
padding: 14px 16px;
|
||
background: var(--success-light);
|
||
border: 1px solid var(--success);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
.return-label {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--success);
|
||
text-transform: uppercase;
|
||
margin-bottom: 6px;
|
||
}
|
||
|
||
.return-content {
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
color: var(--text);
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
/* ========== All Images Grid ========== */
|
||
.images-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.images-header h3 {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.images-count {
|
||
font-size: 14px;
|
||
color: var(--text-muted);
|
||
background: var(--bg-card);
|
||
padding: 6px 14px;
|
||
border-radius: 20px;
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.images-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||
gap: 16px;
|
||
}
|
||
|
||
.image-card {
|
||
background: var(--bg-card);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.image-card:hover {
|
||
transform: translateY(-4px);
|
||
box-shadow: var(--shadow-lg);
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.image-card img {
|
||
width: 100%;
|
||
display: block;
|
||
}
|
||
|
||
.image-card-info {
|
||
padding: 12px 14px;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
border-top: 1px solid var(--border);
|
||
background: var(--bg);
|
||
}
|
||
|
||
/* ========== Lightbox ========== */
|
||
.lightbox {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.9);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 9999;
|
||
backdrop-filter: blur(10px);
|
||
}
|
||
|
||
.lightbox.open {
|
||
display: flex;
|
||
}
|
||
|
||
.lightbox img {
|
||
max-width: 92%;
|
||
max-height: 92%;
|
||
border-radius: var(--radius);
|
||
box-shadow: 0 25px 50px rgba(0,0,0,0.5);
|
||
}
|
||
|
||
.lightbox-close {
|
||
position: absolute;
|
||
top: 20px;
|
||
right: 20px;
|
||
width: 48px;
|
||
height: 48px;
|
||
background: rgba(255,255,255,0.1);
|
||
border: none;
|
||
border-radius: 50%;
|
||
color: white;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.lightbox-close:hover {
|
||
background: rgba(255,255,255,0.2);
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.lightbox-nav {
|
||
position: absolute;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 56px;
|
||
height: 56px;
|
||
background: rgba(255,255,255,0.1);
|
||
border: none;
|
||
border-radius: 50%;
|
||
color: white;
|
||
font-size: 28px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.lightbox-nav:hover {
|
||
background: rgba(255,255,255,0.2);
|
||
transform: translateY(-50%) scale(1.1);
|
||
}
|
||
|
||
.lightbox-prev { left: 24px; }
|
||
.lightbox-next { right: 24px; }
|
||
|
||
.lightbox-info {
|
||
position: absolute;
|
||
bottom: 24px;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
background: rgba(0,0,0,0.7);
|
||
color: white;
|
||
padding: 10px 24px;
|
||
border-radius: 30px;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* ========== Empty State ========== */
|
||
.empty {
|
||
text-align: center;
|
||
padding: 60px 30px;
|
||
}
|
||
|
||
.empty-icon {
|
||
width: 80px;
|
||
height: 80px;
|
||
margin: 0 auto 20px;
|
||
background: var(--bg);
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 36px;
|
||
}
|
||
|
||
.empty h3 {
|
||
font-size: 18px;
|
||
color: var(--text);
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.empty p {
|
||
font-size: 14px;
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* ========== Badge ========== */
|
||
.badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 14px;
|
||
border-radius: 20px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.badge.success {
|
||
background: var(--success-light);
|
||
color: var(--success);
|
||
}
|
||
|
||
.badge.failed {
|
||
background: var(--error-light);
|
||
color: var(--error);
|
||
}
|
||
|
||
/* ========== Pass@K Stats ========== */
|
||
.pass-k-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.pass-k-item {
|
||
text-align: center;
|
||
padding: 16px 12px;
|
||
background: linear-gradient(135deg, var(--primary-light) 0%, var(--purple-light) 100%);
|
||
border-radius: var(--radius-sm);
|
||
border: 1px solid var(--border);
|
||
}
|
||
|
||
.pass-k-label {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.pass-k-value {
|
||
font-size: 24px;
|
||
font-weight: 800;
|
||
color: var(--primary-dark);
|
||
}
|
||
|
||
/* ========== Per Task Pass@K Table ========== */
|
||
.task-pass-k-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.task-pass-k-table th,
|
||
.task-pass-k-table td {
|
||
padding: 12px 14px;
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.task-pass-k-table th {
|
||
background: var(--bg);
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
font-size: 11px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
position: sticky;
|
||
top: 0;
|
||
}
|
||
|
||
.task-pass-k-table tbody tr:hover {
|
||
background: var(--bg-hover);
|
||
}
|
||
|
||
.task-pass-k-table .task-name-cell {
|
||
font-weight: 600;
|
||
font-family: var(--mono);
|
||
max-width: 200px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.pass-k-cell {
|
||
text-align: center;
|
||
font-family: var(--mono);
|
||
}
|
||
|
||
.pass-k-cell.full {
|
||
color: var(--success);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.pass-k-cell.zero {
|
||
color: var(--error);
|
||
}
|
||
|
||
.pass-k-cell.partial {
|
||
color: var(--warning);
|
||
}
|
||
|
||
.success-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-width: 120px;
|
||
}
|
||
|
||
.success-bar-track {
|
||
width: 60px;
|
||
min-width: 60px;
|
||
height: 8px;
|
||
background: var(--bg);
|
||
border-radius: 4px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.success-bar-fill {
|
||
height: 100%;
|
||
background: linear-gradient(90deg, var(--success) 0%, #059669 100%);
|
||
border-radius: 4px;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
.success-bar-label {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
min-width: 55px;
|
||
}
|
||
|
||
/* ========== Distribution Charts ========== */
|
||
.distribution-container {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||
gap: 20px;
|
||
}
|
||
|
||
.bar-chart {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.bar-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.bar-label {
|
||
min-width: 100px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
color: var(--text-secondary);
|
||
font-family: var(--mono);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.bar-track {
|
||
flex: 1;
|
||
height: 24px;
|
||
background: var(--bg);
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
position: relative;
|
||
}
|
||
|
||
.bar-fill {
|
||
height: 100%;
|
||
border-radius: 6px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
padding-right: 8px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: white;
|
||
min-width: fit-content;
|
||
}
|
||
|
||
.bar-fill.primary {
|
||
background: linear-gradient(90deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||
}
|
||
|
||
.bar-fill.success {
|
||
background: linear-gradient(90deg, var(--success) 0%, #059669 100%);
|
||
}
|
||
|
||
.bar-fill.purple {
|
||
background: linear-gradient(90deg, var(--warning) 0%, #d97706 100%);
|
||
}
|
||
|
||
.bar-fill.warning {
|
||
background: linear-gradient(90deg, var(--warning) 0%, #d97706 100%);
|
||
}
|
||
|
||
.bar-value {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
min-width: 50px;
|
||
text-align: right;
|
||
}
|
||
|
||
/* ========== Trial Info ========== */
|
||
.trial-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
||
gap: 8px;
|
||
}
|
||
|
||
.trial-item {
|
||
text-align: center;
|
||
padding: 10px 8px;
|
||
background: var(--bg);
|
||
border-radius: var(--radius-sm);
|
||
border: 2px solid transparent;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.trial-item:hover {
|
||
transform: scale(1.05);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
|
||
.trial-item.success {
|
||
background: var(--success-light);
|
||
}
|
||
|
||
.trial-item.success:hover {
|
||
border-color: var(--success);
|
||
}
|
||
|
||
.trial-item.failed {
|
||
background: var(--error-light);
|
||
}
|
||
|
||
.trial-item.failed:hover {
|
||
border-color: var(--error);
|
||
}
|
||
|
||
.trial-item.error {
|
||
background: var(--warning-light);
|
||
}
|
||
|
||
.trial-item.error:hover {
|
||
border-color: var(--warning);
|
||
}
|
||
|
||
.trial-item.unknown {
|
||
background: #f3f4f6;
|
||
}
|
||
|
||
.trial-item.unknown:hover {
|
||
border-color: #6b7280;
|
||
}
|
||
|
||
.trial-item.current {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 2px var(--primary-light);
|
||
}
|
||
|
||
.trial-num {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: var(--text-muted);
|
||
margin-bottom: 2px;
|
||
}
|
||
|
||
.trial-status {
|
||
font-size: 16px;
|
||
}
|
||
|
||
/* ========== Table Container ========== */
|
||
.table-container {
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
|
||
/* ========== Task Stats Table ========== */
|
||
.task-stats-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.task-stats-table th,
|
||
.task-stats-table td {
|
||
padding: 14px 16px;
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.task-stats-table th {
|
||
background: var(--bg);
|
||
font-weight: 600;
|
||
color: var(--text-secondary);
|
||
font-size: 12px;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 1;
|
||
}
|
||
|
||
.task-stats-table tbody tr:hover {
|
||
background: var(--bg-hover);
|
||
}
|
||
|
||
.task-stats-table .task-name-cell {
|
||
font-family: var(--mono);
|
||
max-width: 220px;
|
||
}
|
||
|
||
.task-stats-table .center-cell {
|
||
text-align: center;
|
||
font-family: var(--mono);
|
||
}
|
||
|
||
.task-stats-table .actions-cell {
|
||
font-size: 11px;
|
||
color: var(--text-secondary);
|
||
font-family: var(--mono);
|
||
}
|
||
|
||
.success-rate-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
min-width: 80px;
|
||
}
|
||
|
||
.success-rate-value {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.success-rate-bar {
|
||
height: 6px;
|
||
background: var(--bg);
|
||
border-radius: 3px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.success-rate-fill {
|
||
height: 100%;
|
||
border-radius: 3px;
|
||
transition: width 0.3s ease;
|
||
}
|
||
|
||
/* ========== Task Group (for repeat_n > 1) ========== */
|
||
.task-group {
|
||
background: var(--bg);
|
||
border: 2px solid transparent;
|
||
border-radius: var(--radius);
|
||
margin-bottom: 8px;
|
||
overflow: hidden;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.task-group:hover {
|
||
background: var(--bg-hover);
|
||
border-color: var(--border);
|
||
}
|
||
|
||
.task-group.active {
|
||
background: var(--primary-light);
|
||
border-color: var(--primary);
|
||
}
|
||
|
||
.task-group-header {
|
||
padding: 12px 14px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.trial-indicators {
|
||
display: flex;
|
||
gap: 4px;
|
||
padding: 0 14px 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.trial-indicator {
|
||
position: relative;
|
||
width: 24px;
|
||
height: 24px;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: all 0.15s;
|
||
border: 2px solid transparent;
|
||
}
|
||
|
||
.trial-indicator.success {
|
||
background: var(--success-light);
|
||
color: var(--success);
|
||
}
|
||
|
||
.trial-indicator.failed {
|
||
background: var(--error-light);
|
||
color: var(--error);
|
||
}
|
||
|
||
.trial-indicator.error {
|
||
background: var(--warning-light);
|
||
color: var(--warning);
|
||
}
|
||
|
||
.trial-indicator.unknown {
|
||
background: #f3f4f6;
|
||
color: #6b7280;
|
||
}
|
||
|
||
.trial-indicator-tags {
|
||
position: absolute;
|
||
bottom: -2px;
|
||
right: -2px;
|
||
display: flex;
|
||
gap: 1px;
|
||
font-size: 8px;
|
||
line-height: 1;
|
||
pointer-events: none;
|
||
}
|
||
|
||
.trial-indicator:hover {
|
||
transform: scale(1.1);
|
||
}
|
||
|
||
.trial-indicator.active {
|
||
border-color: var(--primary);
|
||
box-shadow: 0 0 0 2px var(--primary-light);
|
||
}
|
||
|
||
.status-badge.partial {
|
||
background: var(--warning);
|
||
box-shadow: 0 0 8px var(--warning);
|
||
}
|
||
|
||
/* ========== Scrollbar ========== */
|
||
::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: var(--bg);
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: var(--border);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb:hover {
|
||
background: var(--text-muted);
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="app">
|
||
<header>
|
||
<div class="logo">
|
||
<div class="logo-icon">📊</div>
|
||
<div>
|
||
<h1>Run Explorer</h1>
|
||
<p>轨迹数据可视化工具</p>
|
||
</div>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button class="btn btn-secondary" id="btnRefresh" onclick="loadRunsViaHttp()">
|
||
<span>🔄</span> 刷新
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main>
|
||
<aside class="sidebar">
|
||
<div class="sidebar-header">
|
||
<h2>选择运行记录</h2>
|
||
<div class="run-select-wrap">
|
||
<select id="runSelect" disabled>
|
||
<option value="">加载中...</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filters">
|
||
<div class="search-box">
|
||
<input type="text" id="taskFilter" placeholder="搜索任务..." disabled>
|
||
</div>
|
||
<div id="activeFiltersBar" class="active-filters-bar" style="display: none;"></div>
|
||
<div class="filter-group">
|
||
<div class="filter-selects single">
|
||
<select id="fSuite" class="filter-select" disabled>
|
||
<option value="">全部 Suite</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="filter-group">
|
||
<div class="filter-selects">
|
||
<select id="fDifficulty" class="filter-select" disabled>
|
||
<option value="">难度</option>
|
||
</select>
|
||
<select id="fScope" class="filter-select" disabled>
|
||
<option value="">范围</option>
|
||
</select>
|
||
<select id="fObjective" class="filter-select" disabled>
|
||
<option value="">目标</option>
|
||
</select>
|
||
<select id="fComposition" class="filter-select" disabled>
|
||
<option value="">组合</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="filter-chips">
|
||
<label class="chip success active" title="完美成功:目标达成 + 无副作用 + 主动 COMPLETE">
|
||
<input type="checkbox" id="fSuccess" checked> ✓ 成功
|
||
</label>
|
||
<label class="chip failed active" title="非完美成功(含副作用、超步数、早终止、目标未达成等)">
|
||
<input type="checkbox" id="fFailed" checked> ✗ 失败
|
||
</label>
|
||
<label class="chip error active" title="execution.error / judge_error:系统/判定异常">
|
||
<input type="checkbox" id="fError" checked> ⚠ 错误
|
||
</label>
|
||
</div>
|
||
<div class="filter-chips" style="margin-top: 6px;">
|
||
<label class="chip sideEffect" title="勾选后仅显示带副作用的 trial(USE: judge.clean=false)">
|
||
<input type="checkbox" id="fSideEffect"> 🔧 副作用
|
||
</label>
|
||
<label class="chip overdue" title="勾选后仅显示超步数的 trial(OT: truncated=true 且 judge.success=true)">
|
||
<input type="checkbox" id="fOverdue"> ⏱ 超步数
|
||
</label>
|
||
<label class="chip falseComplete" title="勾选后仅显示 false-complete 的 trial(FC: stop_reason=COMPLETE 但 is_success=false)">
|
||
<input type="checkbox" id="fFalseComplete"> 🚫 错误完成
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="task-list" id="taskList">
|
||
<div class="empty">
|
||
<div class="empty-icon">📋</div>
|
||
<h3>加载中...</h3>
|
||
<p>正在加载任务列表</p>
|
||
</div>
|
||
</div>
|
||
</aside>
|
||
|
||
<div class="content">
|
||
<div class="tabs">
|
||
<button class="tab active" data-tab="overview">📊 概览</button>
|
||
<button class="tab" data-tab="detail">📝 任务详情</button>
|
||
<button class="tab" data-tab="trajectory">🎯 轨迹查看</button>
|
||
<button class="tab" data-tab="all-images">🖼️ 全部截图</button>
|
||
</div>
|
||
|
||
<div class="tab-content">
|
||
<!-- Overview Panel -->
|
||
<div class="panel active" id="panel-overview">
|
||
<div class="empty" id="overviewEmpty">
|
||
<div class="empty-icon">📊</div>
|
||
<h3>加载中...</h3>
|
||
<p>正在从服务器加载运行记录</p>
|
||
</div>
|
||
<div id="overviewContent" style="display: none;"></div>
|
||
</div>
|
||
|
||
<!-- Detail Panel -->
|
||
<div class="panel" id="panel-detail">
|
||
<div class="empty" id="detailEmpty">
|
||
<div class="empty-icon">📝</div>
|
||
<h3>选择任务</h3>
|
||
<p>从左侧列表选择一个任务来查看详情</p>
|
||
</div>
|
||
<div id="detailContent" style="display: none;"></div>
|
||
</div>
|
||
|
||
<!-- Trajectory Panel -->
|
||
<div class="panel" id="panel-trajectory">
|
||
<div class="empty" id="trajectoryEmpty">
|
||
<div class="empty-icon">🎯</div>
|
||
<h3>选择任务</h3>
|
||
<p>从左侧列表选择一个任务来查看轨迹</p>
|
||
</div>
|
||
<div id="trajectoryContent" style="display: none;"></div>
|
||
</div>
|
||
|
||
<!-- All Images Panel -->
|
||
<div class="panel" id="panel-all-images">
|
||
<div class="empty" id="allImagesEmpty">
|
||
<div class="empty-icon">🖼️</div>
|
||
<h3>选择任务</h3>
|
||
<p>从左侧列表选择一个任务来查看所有截图</p>
|
||
</div>
|
||
<div id="allImagesContent" style="display: none;"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<!-- Lightbox -->
|
||
<div class="lightbox" id="lightbox">
|
||
<button class="lightbox-close" onclick="closeLightbox()">×</button>
|
||
<button class="lightbox-nav lightbox-prev" onclick="navigateLightbox(-1)">‹</button>
|
||
<img id="lightboxImg" src="" alt="">
|
||
<button class="lightbox-nav lightbox-next" onclick="navigateLightbox(1)">›</button>
|
||
<div class="lightbox-info" id="lightboxInfo"></div>
|
||
</div>
|
||
|
||
<!-- Prompt Modal -->
|
||
<div class="prompt-modal" id="promptModal">
|
||
<div class="prompt-modal-content">
|
||
<div class="prompt-modal-header">
|
||
<span class="prompt-modal-title">📝 完整对话历史</span>
|
||
<button class="prompt-modal-close" onclick="closePromptModal()">×</button>
|
||
</div>
|
||
<div class="prompt-modal-body" id="promptModalBody">
|
||
<!-- Content will be injected here -->
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// =========== Helpers ===========
|
||
const el = id => document.getElementById(id);
|
||
const API_BASE = '/api/runs';
|
||
|
||
async function fetchJson(urlPath, timeout = 30000) {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||
try {
|
||
const res = await fetch(API_BASE + urlPath, { signal: controller.signal });
|
||
clearTimeout(timeoutId);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.json();
|
||
} catch (e) {
|
||
clearTimeout(timeoutId);
|
||
if (e.name === 'AbortError') throw new Error('Request timeout');
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
async function fetchText(urlPath, timeout = 30000) {
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||
try {
|
||
const res = await fetch(API_BASE + urlPath, { signal: controller.signal });
|
||
clearTimeout(timeoutId);
|
||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
return res.text();
|
||
} catch (e) {
|
||
clearTimeout(timeoutId);
|
||
if (e.name === 'AbortError') throw new Error('Request timeout');
|
||
throw e;
|
||
}
|
||
}
|
||
|
||
function imageUrl(urlPath) {
|
||
return API_BASE + urlPath;
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
if (!s) return '';
|
||
const div = document.createElement('div');
|
||
div.textContent = s;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function formatValue(v) {
|
||
if (v === null) return 'null';
|
||
if (v === undefined) return 'undefined';
|
||
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
||
if (typeof v === 'string') return v.length > 120 ? `"${v.slice(0, 120)}..."` : `"${v}"`;
|
||
if (typeof v === 'object') return JSON.stringify(v).slice(0, 120);
|
||
return String(v);
|
||
}
|
||
|
||
function formatTime(t) {
|
||
if (!t) return '-';
|
||
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
|
||
}
|
||
|
||
function debounce(fn, ms = 150) {
|
||
let t = null;
|
||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||
}
|
||
|
||
// =========== Taxonomy Filter Logic ===========
|
||
|
||
// Populate suite + taxonomy filter dropdowns from loaded results
|
||
function populateFilterOptions() {
|
||
const allRes = state.allResults || [];
|
||
const suites = new Set();
|
||
const difficulties = new Set();
|
||
const scopes = new Set();
|
||
const objectives = new Set();
|
||
const compositions = new Set();
|
||
|
||
for (const r of allRes) {
|
||
if (r.suite) suites.add(r.suite);
|
||
if (r.difficulty) difficulties.add(r.difficulty);
|
||
if (r.scope) scopes.add(r.scope);
|
||
if (r.objective) objectives.add(r.objective);
|
||
if (r.composition) compositions.add(r.composition);
|
||
}
|
||
|
||
const fillSelect = (id, defaultLabel, values, sortFn) => {
|
||
const sel = el(id);
|
||
const curVal = sel.value;
|
||
sel.innerHTML = `<option value="">${defaultLabel}</option>`;
|
||
const sorted = [...values].sort(sortFn || ((a, b) => a.localeCompare(b)));
|
||
sorted.forEach(v => sel.appendChild(new Option(v, v)));
|
||
sel.disabled = sorted.length === 0;
|
||
if (curVal && sorted.includes(curVal)) sel.value = curVal;
|
||
};
|
||
|
||
fillSelect('fSuite', `全部 Suite (${suites.size})`, suites);
|
||
fillSelect('fDifficulty', '难度', difficulties);
|
||
fillSelect('fScope', '范围', scopes);
|
||
fillSelect('fObjective', '目标', objectives);
|
||
fillSelect('fComposition', '组合', compositions);
|
||
|
||
updateActiveFiltersBar();
|
||
}
|
||
|
||
// Get the taxonomy dimensions for a particular task (from its first result)
|
||
function getResultTaxonomy(taskId) {
|
||
const trials = state.resultsByTask?.get(taskId);
|
||
if (!trials || trials.length === 0) return null;
|
||
const r = trials[0];
|
||
return {
|
||
suite: r.suite || null,
|
||
difficulty: r.difficulty || null,
|
||
scope: r.scope || null,
|
||
objective: r.objective || null,
|
||
composition: r.composition || null,
|
||
};
|
||
}
|
||
|
||
// Check if a task passes all taxonomy filters
|
||
function taskPassesTaxonomyFilters(taskId) {
|
||
const fSuite = el('fSuite').value;
|
||
const fDifficulty = el('fDifficulty').value;
|
||
const fScope = el('fScope').value;
|
||
const fObjective = el('fObjective').value;
|
||
const fComposition = el('fComposition').value;
|
||
|
||
// No filter active = pass
|
||
if (!fSuite && !fDifficulty && !fScope && !fObjective && !fComposition) return true;
|
||
|
||
const tax = getResultTaxonomy(taskId);
|
||
if (!tax) return false;
|
||
|
||
if (fSuite && tax.suite !== fSuite) return false;
|
||
if (fDifficulty && tax.difficulty !== fDifficulty) return false;
|
||
if (fScope && tax.scope !== fScope) return false;
|
||
if (fObjective && tax.objective !== fObjective) return false;
|
||
if (fComposition && tax.composition !== fComposition) return false;
|
||
return true;
|
||
}
|
||
|
||
// Get results filtered by all taxonomy filters
|
||
function getFilteredResults() {
|
||
const allRes = state.allResults || [];
|
||
const fSuite = el('fSuite').value;
|
||
const fDifficulty = el('fDifficulty').value;
|
||
const fScope = el('fScope').value;
|
||
const fObjective = el('fObjective').value;
|
||
const fComposition = el('fComposition').value;
|
||
|
||
if (!fSuite && !fDifficulty && !fScope && !fObjective && !fComposition) return allRes;
|
||
|
||
return allRes.filter(r => {
|
||
if (fSuite && r.suite !== fSuite) return false;
|
||
if (fDifficulty && r.difficulty !== fDifficulty) return false;
|
||
if (fScope && r.scope !== fScope) return false;
|
||
if (fObjective && r.objective !== fObjective) return false;
|
||
if (fComposition && r.composition !== fComposition) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
// Check if any taxonomy filter is active
|
||
function hasTaxonomyFilters() {
|
||
return !!(el('fSuite').value || el('fDifficulty').value || el('fScope').value ||
|
||
el('fObjective').value || el('fComposition').value);
|
||
}
|
||
|
||
// Render the active filters tags bar
|
||
function updateActiveFiltersBar() {
|
||
const bar = el('activeFiltersBar');
|
||
const filters = [
|
||
{ id: 'fSuite', label: 'Suite' },
|
||
{ id: 'fDifficulty', label: '难度' },
|
||
{ id: 'fScope', label: '范围' },
|
||
{ id: 'fObjective', label: '目标' },
|
||
{ id: 'fComposition', label: '组合' },
|
||
];
|
||
const active = filters.filter(f => el(f.id).value);
|
||
|
||
if (active.length === 0) {
|
||
bar.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
bar.style.display = 'flex';
|
||
bar.innerHTML = active.map(f =>
|
||
`<span class="active-filter-tag">${f.label}: ${el(f.id).value} <span class="remove-filter" onclick="el('${f.id}').value=''; onTaxonomyFilterChange()">×</span></span>`
|
||
).join('') + `<button class="filter-reset-btn" onclick="resetTaxonomyFilters()">清除全部</button>`;
|
||
|
||
// Update select highlight
|
||
filters.forEach(f => {
|
||
el(f.id).classList.toggle('active-filter', !!el(f.id).value);
|
||
});
|
||
}
|
||
|
||
function resetTaxonomyFilters() {
|
||
['fSuite', 'fDifficulty', 'fScope', 'fObjective', 'fComposition'].forEach(id => { el(id).value = ''; });
|
||
onTaxonomyFilterChange();
|
||
}
|
||
|
||
function onTaxonomyFilterChange() {
|
||
updateActiveFiltersBar();
|
||
renderTaskList();
|
||
renderOverview();
|
||
}
|
||
|
||
// Render chat messages from prompt array
|
||
function renderChatMessages(messages, uniquePrefix = '') {
|
||
if (!messages || !Array.isArray(messages)) return '<div class="no-response">无对话数据</div>';
|
||
|
||
return messages.map((msg, msgIdx) => {
|
||
const role = msg.role || 'unknown';
|
||
const roleIcon = role === 'system' ? '⚙️' : role === 'user' ? '👤' : role === 'assistant' ? '🤖' : '❓';
|
||
const roleName = role === 'system' ? 'System' : role === 'user' ? 'User' : role === 'assistant' ? 'Assistant' : role;
|
||
const textId = `${uniquePrefix}_msg_${msgIdx}`;
|
||
|
||
let contentHtml = '';
|
||
let isLongSystemMsg = false;
|
||
|
||
if (typeof msg.content === 'string') {
|
||
// Simple string content
|
||
isLongSystemMsg = role === 'system' && msg.content.length > 500;
|
||
contentHtml = `<div class="chat-text" id="${textId}">${escapeHtml(msg.content)}</div>`;
|
||
if (isLongSystemMsg) {
|
||
contentHtml += `<button class="expand-btn" onclick="toggleSystemMsg('${textId}', this)">▼ 展开完整内容</button>`;
|
||
}
|
||
} else if (Array.isArray(msg.content)) {
|
||
// Array of content items (multimodal)
|
||
contentHtml = msg.content.map(item => {
|
||
if (item.type === 'text') {
|
||
return `<div class="chat-content-item chat-text">${escapeHtml(item.text || '')}</div>`;
|
||
} else if (item.type === 'image_url') {
|
||
// Show placeholder for image (data stripped)
|
||
const url = item.image_url?.url || '';
|
||
const isStripped = url.includes('STRIPPED') || url.startsWith('[');
|
||
return `<div class="chat-content-item">
|
||
<div class="chat-image-placeholder">
|
||
🖼️ ${isStripped ? '图片 (数据已省略)' : '图片'}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
return '';
|
||
}).join('');
|
||
}
|
||
|
||
return `
|
||
<div class="chat-message ${role}">
|
||
<div class="chat-role">${roleIcon} ${roleName}</div>
|
||
${contentHtml}
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function toggleSystemMsg(textId, btn) {
|
||
const textEl = document.getElementById(textId);
|
||
if (!textEl) return;
|
||
|
||
if (textEl.classList.contains('expanded')) {
|
||
textEl.classList.remove('expanded');
|
||
btn.innerHTML = '▼ 展开完整内容';
|
||
} else {
|
||
textEl.classList.add('expanded');
|
||
btn.innerHTML = '▲ 收起';
|
||
}
|
||
}
|
||
|
||
// Get prompt stats
|
||
function getPromptStats(messages) {
|
||
if (!messages || !Array.isArray(messages)) return { total: 0, system: 0, user: 0, assistant: 0 };
|
||
return {
|
||
total: messages.length,
|
||
system: messages.filter(m => m.role === 'system').length,
|
||
user: messages.filter(m => m.role === 'user').length,
|
||
assistant: messages.filter(m => m.role === 'assistant').length
|
||
};
|
||
}
|
||
|
||
// =========== State ===========
|
||
const state = {
|
||
runsList: [],
|
||
runDirName: '',
|
||
meta: null,
|
||
summary: null,
|
||
results: new Map(),
|
||
allResults: [],
|
||
resultsByTask: new Map(),
|
||
tasks: [],
|
||
taskGroups: new Map(),
|
||
selectedTask: null,
|
||
trajectory: [],
|
||
selectedStepIdx: 0,
|
||
mediaCache: new Map(),
|
||
actionTypesCache: null,
|
||
perTaskActionTypes: {},
|
||
actionTypesLoading: false,
|
||
isLoading: false, // Prevent duplicate operations
|
||
};
|
||
|
||
let lightboxImages = [];
|
||
let lightboxIndex = 0;
|
||
|
||
// =========== Data Loading ===========
|
||
|
||
async function loadRunsViaHttp() {
|
||
state.runDirName = '';
|
||
|
||
try {
|
||
const data = await fetchJson('');
|
||
state.runsList = data.runs || [];
|
||
} catch (e) {
|
||
console.error('Failed to load runs:', e);
|
||
state.runsList = [];
|
||
}
|
||
|
||
await refreshRunsDropdown();
|
||
}
|
||
|
||
async function refreshRunsDropdown() {
|
||
const sel = el('runSelect');
|
||
sel.innerHTML = '';
|
||
|
||
if (state.runsList.length === 0) {
|
||
sel.disabled = true;
|
||
sel.innerHTML = '<option value="">runs 目录下没有子目录</option>';
|
||
return;
|
||
}
|
||
|
||
sel.disabled = false;
|
||
sel.innerHTML = '<option value="">选择一个 run...</option>';
|
||
state.runsList.forEach(name => sel.appendChild(new Option(name, name)));
|
||
|
||
// Auto select first
|
||
if (!state.runDirName && state.runsList.length > 0) {
|
||
sel.value = state.runsList[0];
|
||
await loadRunDir(state.runsList[0]);
|
||
}
|
||
}
|
||
|
||
async function loadRunDir(runName) {
|
||
if (state.isLoading) return;
|
||
state.isLoading = true;
|
||
|
||
try {
|
||
// Show loading state
|
||
el('overviewEmpty').innerHTML = `
|
||
<div class="empty-icon">⏳</div>
|
||
<h3>加载中...</h3>
|
||
<p>正在加载 ${runName}</p>
|
||
`;
|
||
el('overviewEmpty').style.display = 'block';
|
||
el('overviewContent').style.display = 'none';
|
||
|
||
state.runDirName = runName;
|
||
state.selectedTask = null;
|
||
state.trajectory = [];
|
||
state.selectedStepIdx = 0;
|
||
state.actionTypesCache = null;
|
||
state.perTaskActionTypes = {};
|
||
state.actionTypesLoading = false;
|
||
clearMediaCache();
|
||
|
||
// Load meta
|
||
try {
|
||
state.meta = await fetchJson(`/${runName}/meta.json`);
|
||
} catch { state.meta = null; }
|
||
|
||
// Load summary
|
||
try {
|
||
state.summary = await fetchJson(`/${runName}/summary.json`);
|
||
} catch { state.summary = null; }
|
||
|
||
// Load results.jsonl
|
||
state.results = new Map();
|
||
state.allResults = [];
|
||
state.resultsByTask = new Map();
|
||
try {
|
||
const text = await fetchText(`/${runName}/results.jsonl`);
|
||
for (const line of text.split('\n')) {
|
||
if (!line.trim()) continue;
|
||
try {
|
||
const obj = JSON.parse(line);
|
||
if (obj.id) {
|
||
obj.task_id = obj.id;
|
||
state.allResults.push(obj);
|
||
|
||
if (!state.resultsByTask.has(obj.id)) {
|
||
state.resultsByTask.set(obj.id, []);
|
||
}
|
||
state.resultsByTask.get(obj.id).push(obj);
|
||
|
||
const key = obj.trial_id !== undefined ? `${obj.id}_trial_${obj.trial_id}` : obj.id;
|
||
state.results.set(key, obj);
|
||
|
||
if (!state.results.has(obj.id)) {
|
||
state.results.set(obj.id, obj);
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
for (const [taskId, trials] of state.resultsByTask) {
|
||
trials.sort((a, b) => (a.trial_id ?? 0) - (b.trial_id ?? 0));
|
||
}
|
||
} catch {}
|
||
|
||
// Load tasks - parse from directory names
|
||
state.tasks = [];
|
||
state.taskGroups = new Map();
|
||
try {
|
||
const trajData = await fetchJson(`/${runName}/trajectory`);
|
||
const taskDirs = trajData.tasks || [];
|
||
|
||
// Helper: convert dir name to result id format
|
||
// e.g., wechat_OpenNewFriends_i0 -> wechat.OpenNewFriends_i0
|
||
// Only replace the first underscore (between app and task name) with a dot
|
||
const dirNameToResultId = (name) => {
|
||
// Find the FIRST underscore followed by an uppercase letter — that's the suite/task boundary
|
||
// e.g. x1_X1_SendDm -> x1.X1_SendDm, tencent_meeting_Check -> tencent_meeting.Check
|
||
const match = name.match(/^(.*?)_([A-Z].*)$/);
|
||
if (match) {
|
||
return `${match[1]}.${match[2]}`;
|
||
}
|
||
return name;
|
||
};
|
||
|
||
for (const dirName of taskDirs) {
|
||
const trialMatch = dirName.match(/^(.+)_t(\d+)$/);
|
||
let baseTaskId, trialId;
|
||
|
||
if (trialMatch) {
|
||
baseTaskId = dirNameToResultId(trialMatch[1]);
|
||
trialId = parseInt(trialMatch[2]);
|
||
} else {
|
||
baseTaskId = dirNameToResultId(dirName);
|
||
trialId = 0;
|
||
}
|
||
|
||
const resultData = state.resultsByTask.get(baseTaskId)?.[0];
|
||
const task_name = resultData?.task_name || '';
|
||
|
||
const taskEntry = {
|
||
dirName,
|
||
task_id: baseTaskId,
|
||
baseTaskId,
|
||
trialId,
|
||
task_name,
|
||
meta: null
|
||
};
|
||
|
||
state.tasks.push(taskEntry);
|
||
|
||
if (!state.taskGroups.has(baseTaskId)) {
|
||
state.taskGroups.set(baseTaskId, []);
|
||
}
|
||
state.taskGroups.get(baseTaskId).push(taskEntry);
|
||
}
|
||
|
||
state.tasks.sort((a, b) => {
|
||
const cmp = a.baseTaskId.localeCompare(b.baseTaskId, 'zh-CN');
|
||
if (cmp !== 0) return cmp;
|
||
return (a.trialId ?? 0) - (b.trialId ?? 0);
|
||
});
|
||
|
||
for (const [taskId, trials] of state.taskGroups) {
|
||
trials.sort((a, b) => (a.trialId ?? 0) - (b.trialId ?? 0));
|
||
}
|
||
} catch {}
|
||
|
||
el('taskFilter').disabled = false;
|
||
populateFilterOptions();
|
||
renderAll();
|
||
|
||
// 自动加载动作统计(后台异步,不阻塞界面)
|
||
loadActionTypes();
|
||
|
||
// Select first task without blocking
|
||
if (state.tasks.length > 0) {
|
||
state.isLoading = false; // Reset before selectTask
|
||
selectTask(state.tasks[0]); // Don't await
|
||
return;
|
||
}
|
||
} catch (e) {
|
||
console.error('loadRunDir error:', e);
|
||
el('overviewEmpty').innerHTML = `
|
||
<div class="empty-icon">❌</div>
|
||
<h3>加载失败</h3>
|
||
<p style="color: var(--error);">${e.message || '未知错误'}</p>
|
||
<button class="btn btn-secondary" onclick="loadRunDir('${runName}')" style="margin-top: 12px;">
|
||
重试
|
||
</button>
|
||
`;
|
||
} finally {
|
||
state.isLoading = false;
|
||
}
|
||
}
|
||
|
||
async function loadTrajectory(task) {
|
||
state.trajectory = [];
|
||
state.selectedStepIdx = 0;
|
||
clearMediaCache();
|
||
|
||
try {
|
||
state.trajectory = await fetchJson(`/${state.runDirName}/trajectory/${task.dirName}/trajectory.json`);
|
||
if (!Array.isArray(state.trajectory)) state.trajectory = [];
|
||
} catch { state.trajectory = []; }
|
||
}
|
||
|
||
function clearMediaCache() {
|
||
// Simply clear the cache - URLs are from API, not blob URLs
|
||
state.mediaCache = new Map();
|
||
}
|
||
|
||
async function getMedia(idx) {
|
||
if (state.mediaCache.has(idx)) return state.mediaCache.get(idx);
|
||
const step = state.trajectory[idx];
|
||
const media = { rawUrl: '', annotUrl: '', responseText: '', prompt: null };
|
||
|
||
if (step && state.selectedTask) {
|
||
const taskBasePath = `/${state.runDirName}/trajectory/${state.selectedTask.dirName}`;
|
||
|
||
if (step.screenshot) {
|
||
media.rawUrl = imageUrl(`${taskBasePath}/${step.screenshot}`);
|
||
}
|
||
if (step.screenshot_annotated) {
|
||
media.annotUrl = imageUrl(`${taskBasePath}/${step.screenshot_annotated}`);
|
||
}
|
||
// Load model response text
|
||
if (step.model_response_path) {
|
||
try {
|
||
media.responseText = await fetchText(`${taskBasePath}/${step.model_response_path}`);
|
||
} catch {}
|
||
}
|
||
// Load prompt JSON
|
||
const stepNum = step.step ?? (idx + 1);
|
||
const promptFileName = `step_${String(stepNum).padStart(3, '0')}_prompt.json`;
|
||
try {
|
||
media.prompt = await fetchJson(`${taskBasePath}/${promptFileName}`);
|
||
} catch {}
|
||
}
|
||
|
||
state.mediaCache.set(idx, media);
|
||
return media;
|
||
}
|
||
|
||
// =========== Task Selection ===========
|
||
async function selectTask(task) {
|
||
console.log('selectTask called:', task?.dirName, 'isLoading:', state.isLoading);
|
||
if (state.isLoading) {
|
||
console.log('selectTask blocked by isLoading');
|
||
return;
|
||
}
|
||
if (state.selectedTask?.dirName === task.dirName) {
|
||
console.log('selectTask skipped - same task');
|
||
return;
|
||
}
|
||
|
||
state.isLoading = true;
|
||
console.log('selectTask starting for:', task?.dirName);
|
||
try {
|
||
state.selectedTask = task;
|
||
renderTaskList();
|
||
|
||
el('trajectoryContent').innerHTML = `
|
||
<div class="empty" style="padding: 40px;">
|
||
<div class="empty-icon">⏳</div>
|
||
<h3>加载轨迹...</h3>
|
||
</div>
|
||
`;
|
||
el('trajectoryContent').style.display = 'block';
|
||
el('trajectoryEmpty').style.display = 'none';
|
||
|
||
await loadTrajectory(task);
|
||
console.log('selectTask trajectory loaded');
|
||
|
||
renderDetail();
|
||
renderTrajectory();
|
||
renderAllImages();
|
||
console.log('selectTask completed');
|
||
} catch (e) {
|
||
console.error('selectTask error:', e);
|
||
} finally {
|
||
state.isLoading = false;
|
||
console.log('selectTask isLoading reset to false');
|
||
}
|
||
}
|
||
|
||
function getTaskStatus(task) {
|
||
return getTaskStatusForTrial(task);
|
||
}
|
||
|
||
// Get all trials for a specific task
|
||
function getTrialsForTask(taskId) {
|
||
return state.resultsByTask?.get(taskId) || [];
|
||
}
|
||
|
||
// Select a specific trial for viewing
|
||
async function selectTrial(taskId, trialIdx) {
|
||
if (state.isLoading) return;
|
||
|
||
const taskGroup = state.taskGroups.get(taskId);
|
||
if (taskGroup && trialIdx >= 0 && trialIdx < taskGroup.length) {
|
||
const task = taskGroup[trialIdx];
|
||
state.isLoading = true;
|
||
try {
|
||
state.selectedTask = task;
|
||
renderTaskList();
|
||
await loadTrajectory(task);
|
||
renderDetail();
|
||
renderTrajectory();
|
||
renderAllImages();
|
||
} catch (e) {
|
||
console.error('selectTrial error:', e);
|
||
} finally {
|
||
state.isLoading = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
// =========== Rendering ===========
|
||
function renderAll() {
|
||
renderOverview();
|
||
renderTaskList();
|
||
renderDetail();
|
||
renderTrajectory();
|
||
renderAllImages();
|
||
}
|
||
|
||
function renderOverview() {
|
||
if (!state.runDirName) {
|
||
el('overviewEmpty').style.display = 'block';
|
||
el('overviewContent').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
el('overviewEmpty').style.display = 'none';
|
||
el('overviewContent').style.display = 'block';
|
||
|
||
const sum = state.summary || {};
|
||
const meta = state.meta || {};
|
||
const successRate = ((sum.success_rate ?? 0) * 100).toFixed(1);
|
||
const hasPassK = sum.pass_at_k && Object.keys(sum.pass_at_k).length > 0;
|
||
const hasRepeat = (sum.repeat_n ?? meta.repeat_n ?? 1) > 1;
|
||
|
||
// Get filtered results for overview stats
|
||
const filteredRes = getFilteredResults();
|
||
const isFiltered = hasTaxonomyFilters();
|
||
const filterBadge = isFiltered ? ` <span style="font-size: 11px; font-weight: 500; color: var(--primary); background: var(--primary-light); padding: 2px 8px; border-radius: 10px; margin-left: 8px;">已筛选: ${filteredRes.length} 条</span>` : '';
|
||
|
||
// Calculate additional statistics (with filtered data)
|
||
const statsData = calculateDetailedStats(isFiltered ? filteredRes : undefined);
|
||
|
||
// Pre-calculate per-trial stats for header display (with filtered data)
|
||
const preTrialStats = calculatePerTrialStatsQuick(isFiltered ? filteredRes : undefined);
|
||
|
||
// 使用重新计算的值(基于 clean && finished)而不是 summary.json 中的旧值
|
||
const actualSuccess = preTrialStats ? preTrialStats.perfectSuccess : (sum.success ?? 0);
|
||
const actualTotal = preTrialStats ? preTrialStats.totalResults : (sum.total_tasks ?? sum.total ?? state.tasks.length);
|
||
const actualError = preTrialStats ? preTrialStats.errorCount : 0;
|
||
const actualValid = actualTotal - actualError;
|
||
const actualFailed = actualValid - actualSuccess;
|
||
const actualSuccessRate = actualValid > 0 ? (actualSuccess / actualValid * 100).toFixed(1) : '0.0';
|
||
|
||
const filteredEpisodes = isFiltered ? filteredRes.length : (sum.total_episodes ?? '-');
|
||
|
||
let html = `
|
||
${filterBadge}
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value">${actualTotal}</div>
|
||
<div class="stat-label">任务数</div>
|
||
</div>
|
||
${hasRepeat ? `
|
||
<div class="stat-card">
|
||
<div class="stat-value">${filteredEpisodes}</div>
|
||
<div class="stat-label">总运行次数</div>
|
||
</div>
|
||
` : ''}
|
||
<div class="stat-card">
|
||
<div class="stat-value success">${actualSuccess}</div>
|
||
<div class="stat-label">成功</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value error">${actualFailed}</div>
|
||
<div class="stat-label">失败</div>
|
||
</div>
|
||
${actualError > 0 ? `
|
||
<div class="stat-card" style="border-left: 4px solid var(--warning);" title="评判/执行阶段内部错误,非 Agent 行为导致">
|
||
<div class="stat-value" style="color: var(--warning);">${actualError}</div>
|
||
<div class="stat-label">错误</div>
|
||
</div>
|
||
` : ''}
|
||
<div class="stat-card highlight">
|
||
<div class="stat-value">${actualSuccessRate}%</div>
|
||
<div class="stat-label">成功率</div>
|
||
<div style="font-size: 10px; color: var(--text-muted); margin-top: 2px;">${actualSuccess}/${actualValid}</div>
|
||
</div>
|
||
${preTrialStats ? `
|
||
<div class="stat-card" style="border-left: 4px solid #60a5fa;" title="目标达成率 = judge.success=true 的比例(排除 error,不考虑副作用和是否主动终止)">
|
||
<div class="stat-value" style="color: #60a5fa;">${(preTrialStats.goalSuccessRate * 100).toFixed(1)}%</div>
|
||
<div class="stat-label">目标达成率</div>
|
||
<div style="font-size: 10px; color: var(--text-muted); margin-top: 2px;">${preTrialStats.totalGoalSuccess}/${preTrialStats.validResults}</div>
|
||
</div>
|
||
` : ''}
|
||
${hasRepeat && preTrialStats ? `
|
||
<div class="stat-card" style="border-left: 4px solid var(--success);">
|
||
<div class="stat-value success">${preTrialStats.tasksWithSuccess}</div>
|
||
<div class="stat-label">有成功的任务</div>
|
||
</div>
|
||
<div class="stat-card" style="border-left: 4px solid var(--error);">
|
||
<div class="stat-value error">${preTrialStats.tasksAllFailed}</div>
|
||
<div class="stat-label">全部失败的任务</div>
|
||
</div>
|
||
<div class="stat-card" style="border-left: 4px solid var(--primary);">
|
||
<div class="stat-value" style="color: var(--primary);">${preTrialStats.tasksAllSuccess}</div>
|
||
<div class="stat-label">全部成功的任务</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
|
||
// 论文 §3.5 评估指标(PR / FC / OT / USE)
|
||
if (preTrialStats) {
|
||
const pr = (preTrialStats.progressRate * 100).toFixed(1);
|
||
const fc = (preTrialStats.falseCompleteRate * 100).toFixed(1);
|
||
const otr = (preTrialStats.overdueRate * 100).toFixed(1);
|
||
const use = (preTrialStats.sideEffectRate * 100).toFixed(1);
|
||
|
||
html += `
|
||
<div class="card" style="border-left: 4px solid var(--purple);">
|
||
<div class="card-title">
|
||
<div class="icon purple">📐</div>
|
||
多维评估指标
|
||
<span style="font-size: 11px; font-weight: 400; color: var(--text-muted); margin-left: auto;">论文 §3.5 评估协议</span>
|
||
</div>
|
||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 16px;">
|
||
<div style="padding: 16px; border-radius: var(--radius-sm); text-align: center; background: linear-gradient(135deg, var(--primary-light) 0%, var(--purple-light) 100%); border: 1px solid var(--border);">
|
||
<div style="font-size: 24px; font-weight: 800; color: var(--primary-dark);">${pr}%</div>
|
||
<div style="font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-top: 4px;">Progress Rate</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">平均目标完成进度</div>
|
||
</div>
|
||
<div style="padding: 16px; border-radius: var(--radius-sm); text-align: center; background: ${preTrialStats.falseCompleteCount > 0 ? 'var(--error-light)' : 'var(--success-light)'}; border: 1px solid var(--border);">
|
||
<div style="font-size: 24px; font-weight: 800; color: ${preTrialStats.falseCompleteCount > 0 ? 'var(--error)' : 'var(--success)'};">${fc}%</div>
|
||
<div style="font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-top: 4px;">False Complete</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">声称完成但未真正成功 (${preTrialStats.falseCompleteCount})</div>
|
||
</div>
|
||
<div style="padding: 16px; border-radius: var(--radius-sm); text-align: center; background: ${preTrialStats.overdueCount > 0 ? 'var(--warning-light)' : 'var(--success-light)'}; border: 1px solid var(--border);">
|
||
<div style="font-size: 24px; font-weight: 800; color: ${preTrialStats.overdueCount > 0 ? 'var(--warning)' : 'var(--success)'};">${otr}%</div>
|
||
<div style="font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-top: 4px;">Overdue Term.</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">达成目标却未主动结束 (${preTrialStats.overdueCount})</div>
|
||
</div>
|
||
<div style="padding: 16px; border-radius: var(--radius-sm); text-align: center; background: ${preTrialStats.uncleanCount > 0 ? '#fff7ed' : 'var(--success-light)'}; border: 1px solid var(--border);">
|
||
<div style="font-size: 24px; font-weight: 800; color: ${preTrialStats.uncleanCount > 0 ? '#f97316' : 'var(--success)'};">${use}%</div>
|
||
<div style="font-size: 12px; font-weight: 600; color: var(--text-secondary); margin-top: 4px;">Side Effects</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">意外状态变更 (${preTrialStats.uncleanCount})</div>
|
||
</div>
|
||
</div>
|
||
<div style="padding: 12px 16px; background: var(--bg); border-radius: var(--radius-sm); font-size: 12px; color: var(--text-secondary);">
|
||
<div style="margin-bottom: 6px;"><strong style="color: var(--text);">📌 指标定义(论文 §3.5):</strong></div>
|
||
<div style="display: grid; gap: 3px;">
|
||
<div><strong>PR (Progress Rate)</strong>:平均 check_goals 完成进度,mean(passed_checks / total_checks)</div>
|
||
<div><strong>FC (False Complete)</strong>:Agent 声明完成但 episode 未 fully successful (stop_reason=COMPLETE 且 is_success=false)</div>
|
||
<div><strong>OT (Overdue Termination)</strong>:达成 goal state 但未主动 COMPLETE,被 step budget / loop detection 截断 (truncated=true 且 judge.success=true)</div>
|
||
<div><strong>USE (Side Effects)</strong>:产生非预期状态变更 (judge.clean=false),分母为全体 episode,与 SR/FC/OT 互不互斥</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 目标达成分析(如果有目标达成但不是完美成功的情况)
|
||
if (preTrialStats?.totalGoalSuccess > preTrialStats?.perfectSuccess) {
|
||
const goalSuccessPct = (preTrialStats.goalSuccessRate * 100).toFixed(1);
|
||
const successRatePct = successRate;
|
||
|
||
// 计算各类别占目标达成的比例
|
||
const total = preTrialStats.totalGoalSuccess;
|
||
const perfectPct = total > 0 ? (preTrialStats.perfectSuccess / total * 100).toFixed(1) : 0;
|
||
const unawarePct = total > 0 ? (preTrialStats.passedButUnaware / total * 100).toFixed(1) : 0;
|
||
const sideEffectFinishedPct = total > 0 ? (preTrialStats.sideEffectFinished / total * 100).toFixed(1) : 0;
|
||
const sideEffectUnawarePct = total > 0 ? (preTrialStats.sideEffectUnaware / total * 100).toFixed(1) : 0;
|
||
|
||
html += `
|
||
<div class="card" style="border-left: 4px solid #60a5fa;">
|
||
<div class="card-title">
|
||
<div class="icon" style="background: #dbeafe;">📊</div>
|
||
目标达成分析
|
||
</div>
|
||
<div style="margin-bottom: 16px;">
|
||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 8px;">
|
||
<span style="font-size: 24px; font-weight: 800; color: #60a5fa;">${preTrialStats.totalGoalSuccess}</span>
|
||
<span style="color: var(--text-secondary);">次目标达成 (${goalSuccessPct}%)</span>
|
||
</div>
|
||
<div style="display: flex; height: 24px; border-radius: 4px; overflow: hidden; background: var(--bg);">
|
||
${preTrialStats.perfectSuccess > 0 ? `<div style="width: ${perfectPct}%; background: var(--success); display: flex; align-items: center; justify-content: center; color: white; font-size: 11px; font-weight: 600;" title="完美成功: ${preTrialStats.perfectSuccess}">✓</div>` : ''}
|
||
${preTrialStats.passedButUnaware > 0 ? `<div style="width: ${unawarePct}%; background: var(--warning); display: flex; align-items: center; justify-content: center; color: white; font-size: 11px; font-weight: 600;" title="达成但未意识: ${preTrialStats.passedButUnaware}">⚠</div>` : ''}
|
||
${preTrialStats.sideEffectFinished > 0 ? `<div style="width: ${sideEffectFinishedPct}%; background: #f97316; display: flex; align-items: center; justify-content: center; color: white; font-size: 11px; font-weight: 600;" title="有副作用(主动终止): ${preTrialStats.sideEffectFinished}">🔧</div>` : ''}
|
||
${preTrialStats.sideEffectUnaware > 0 ? `<div style="width: ${sideEffectUnawarePct}%; background: #c2410c; display: flex; align-items: center; justify-content: center; color: white; font-size: 11px; font-weight: 600;" title="有副作用(未终止): ${preTrialStats.sideEffectUnaware}">⚠🔧</div>` : ''}
|
||
</div>
|
||
</div>
|
||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 16px;">
|
||
<div style="text-align: center; padding: 12px; background: var(--success-light); border-radius: var(--radius-sm); border: 2px solid var(--success);">
|
||
<div style="font-size: 20px; font-weight: 800; color: var(--success);">${preTrialStats.perfectSuccess}</div>
|
||
<div style="font-size: 11px; color: var(--text-secondary);">✓ 完美成功</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">${perfectPct}%</div>
|
||
</div>
|
||
${preTrialStats.passedButUnaware > 0 ? `
|
||
<div style="text-align: center; padding: 12px; background: var(--warning-light); border-radius: var(--radius-sm); border: 2px solid var(--warning);">
|
||
<div style="font-size: 20px; font-weight: 800; color: var(--warning);">${preTrialStats.passedButUnaware}</div>
|
||
<div style="font-size: 11px; color: var(--text-secondary);">⚠️ 未意识</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">${unawarePct}%</div>
|
||
</div>
|
||
` : ''}
|
||
${preTrialStats.sideEffectFinished > 0 ? `
|
||
<div style="text-align: center; padding: 12px; background: #fff7ed; border-radius: var(--radius-sm); border: 2px solid #f97316;">
|
||
<div style="font-size: 20px; font-weight: 800; color: #f97316;">${preTrialStats.sideEffectFinished}</div>
|
||
<div style="font-size: 11px; color: var(--text-secondary);">🔧 有副作用</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">${sideEffectFinishedPct}%</div>
|
||
</div>
|
||
` : ''}
|
||
${preTrialStats.sideEffectUnaware > 0 ? `
|
||
<div style="text-align: center; padding: 12px; background: #fed7aa; border-radius: var(--radius-sm); border: 2px solid #c2410c;">
|
||
<div style="font-size: 20px; font-weight: 800; color: #c2410c;">${preTrialStats.sideEffectUnaware}</div>
|
||
<div style="font-size: 11px; color: var(--text-secondary);">⚠️🔧 双重问题</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">${sideEffectUnawarePct}%</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div style="padding: 12px 16px; background: var(--bg); border-radius: var(--radius-sm); font-size: 12px; color: var(--text-secondary);">
|
||
<div style="margin-bottom: 8px;"><strong style="color: var(--text);">📌 指标说明:</strong></div>
|
||
<div style="display: grid; gap: 4px;">
|
||
<div><span style="color: var(--success);">✓ 完美成功</span>:目标达成 + 无副作用 + 主动终止</div>
|
||
<div><span style="color: var(--warning);">⚠️ 未意识</span>:目标达成 + 无副作用,但未主动终止</div>
|
||
<div><span style="color: #f97316;">🔧 有副作用</span>:目标达成 + 主动终止,但有意外状态变化</div>
|
||
<div><span style="color: #c2410c;">⚠️🔧 双重问题</span>:目标达成,但有副作用且未主动终止</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Taxonomy Distribution (Difficulty / Scope / Objective / Composition breakdown)
|
||
(() => {
|
||
const allRes = filteredRes;
|
||
const hasTaxonomy = allRes.some(r => r.difficulty || r.scope);
|
||
if (hasTaxonomy) {
|
||
const buildBreakdown = (dimKey, label, colorMap) => {
|
||
const groups = {};
|
||
allRes.forEach(r => {
|
||
const val = r[dimKey];
|
||
if (!val) return;
|
||
if (!groups[val]) groups[val] = { total: 0, success: 0, goalSuccess: 0 };
|
||
groups[val].total++;
|
||
if (r.is_success) groups[val].success++;
|
||
if (r.judge?.success) groups[val].goalSuccess++;
|
||
});
|
||
if (Object.keys(groups).length === 0) return '';
|
||
const sorted = Object.entries(groups).sort(([a], [b]) => a.localeCompare(b));
|
||
return `
|
||
<div style="margin-bottom: 16px;">
|
||
<div style="font-size: 13px; font-weight: 700; color: var(--text); margin-bottom: 8px;">${label}</div>
|
||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px;">
|
||
${sorted.map(([k, v]) => {
|
||
const color = colorMap[k] || '#64748b';
|
||
const sr = v.total > 0 ? (v.success / v.total * 100).toFixed(0) : 0;
|
||
return `
|
||
<div style="padding: 10px; border-radius: var(--radius-sm); background: ${color}10; border: 1px solid ${color}30; text-align: center;">
|
||
<div style="font-size: 12px; font-weight: 700; color: ${color}; margin-bottom: 4px;">${k}</div>
|
||
<div style="font-size: 18px; font-weight: 800; color: ${color};">${sr}%</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">${v.success}/${v.total}</div>
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
</div>`;
|
||
};
|
||
const dc = { L1: '#10b981', L2: '#3b82f6', L3: '#f59e0b', L4: '#ef4444' };
|
||
const sc = { S1: '#10b981', S2: '#3b82f6', S3: '#8b5cf6' };
|
||
const oc = { operate: '#3b82f6', query: '#10b981', hybrid: '#f59e0b', vague: '#94a3b8', safety: '#ef4444' };
|
||
const cc = { atomic: '#94a3b8', sequential: '#3b82f6', transfer: '#10b981', deep_dive: '#8b5cf6' };
|
||
html += `
|
||
<div class="card" style="border-left: 4px solid var(--primary);">
|
||
<div class="card-title">
|
||
<div class="icon blue">🏷️</div>
|
||
按分类维度统计
|
||
</div>
|
||
${buildBreakdown('difficulty', '📊 按难度 (Difficulty)', dc)}
|
||
${buildBreakdown('scope', '📐 按范围 (Scope)', sc)}
|
||
${buildBreakdown('objective', '🎯 按目标 (Objective)', oc)}
|
||
${buildBreakdown('composition', '🧩 按组合 (Composition)', cc)}
|
||
</div>
|
||
`;
|
||
}
|
||
})();
|
||
|
||
// 运行配置 (放在最上面)
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon blue">⚙️</div>
|
||
运行配置
|
||
</div>
|
||
<div class="info-grid">
|
||
${[
|
||
['开始时间', formatTime(meta.start_time)],
|
||
['Agent', meta.agent],
|
||
['模型', meta.model_name],
|
||
['坐标空间', meta.coord_space],
|
||
['最大步数', meta.max_steps],
|
||
['重复次数', meta.repeat_n ?? 1],
|
||
['应用', meta.app || (meta.apps?.join(', '))],
|
||
['环境URL', meta.env_url],
|
||
['并行度', meta.parallel],
|
||
['隔离模式', meta.isolation],
|
||
['截图缩放', meta.screenshot_scale],
|
||
['Pass@K 值', meta.pass_k?.join(', ')],
|
||
].filter(([k, v]) => v != null && v !== '').map(([k, v]) => `
|
||
<div class="info-item">
|
||
<div class="info-label">${k}</div>
|
||
<div class="info-value">${v ?? '-'}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<details>
|
||
<summary>查看完整 meta.json</summary>
|
||
<div class="content">
|
||
<div class="code">${JSON.stringify(meta, null, 2)}</div>
|
||
</div>
|
||
</details>
|
||
`;
|
||
|
||
// Pass@K Statistics (if available)
|
||
const hasFilteredPassK = isFiltered && preTrialStats?.passAtKSuccess && Object.keys(preTrialStats.passAtKSuccess).length > 0;
|
||
if (hasPassK || hasFilteredPassK) {
|
||
const passKSource = hasFilteredPassK ? preTrialStats.passAtKSuccess : sum.pass_at_k;
|
||
const passKKeys = Object.keys(passKSource).sort((a, b) => parseInt(a) - parseInt(b));
|
||
const hasPassedPassK = preTrialStats?.passAtKPassed && Object.keys(preTrialStats.passAtKPassed).length > 0;
|
||
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon purple">🎯</div>
|
||
Pass@K 统计 ${hasRepeat ? `(每任务重复 ${sum.repeat_n ?? meta.repeat_n} 次)` : ''}${hasFilteredPassK ? ' <span style="font-size: 11px; color: var(--primary);">(已筛选)</span>' : ''}
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 16px; margin-bottom: 12px; font-size: 12px;">
|
||
<span style="display: flex; align-items: center; gap: 4px;">
|
||
<span style="width: 12px; height: 12px; background: var(--success); border-radius: 2px;"></span>
|
||
任务成功 (success+clean+finished)
|
||
</span>
|
||
<span style="display: flex; align-items: center; gap: 4px;">
|
||
<span style="width: 12px; height: 12px; background: var(--warning); border-radius: 2px;"></span>
|
||
目标达成 (judge.success)
|
||
</span>
|
||
</div>
|
||
|
||
<div class="pass-k-grid">
|
||
${passKKeys.map(k => {
|
||
const successVal = ((passKSource[k] ?? 0) * 100).toFixed(1);
|
||
const passedVal = hasPassedPassK ? ((preTrialStats.passAtKPassed[k] ?? 0) * 100).toFixed(1) : successVal;
|
||
const hasGap = hasPassedPassK && parseFloat(passedVal) > parseFloat(successVal);
|
||
return `
|
||
<div class="pass-k-item" style="position: relative;">
|
||
<div class="pass-k-label">Pass@${k}</div>
|
||
<div style="display: flex; flex-direction: column; gap: 4px; align-items: center;">
|
||
${hasGap ? `<div style="font-size: 16px; font-weight: 700; color: var(--warning);">${passedVal}%</div>` : ''}
|
||
<div class="pass-k-value" style="color: var(--success); ${hasGap ? 'font-size: 14px;' : ''}">${successVal}%</div>
|
||
</div>
|
||
${hasGap ? `<div style="font-size: 10px; color: var(--warning); margin-top: 2px;">+${(parseFloat(passedVal) - parseFloat(successVal)).toFixed(1)}%</div>` : ''}
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 8px;">
|
||
💡 Pass@K 表示在 K 次尝试中至少成功/达成一次的概率
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Per-Trial Statistics and Task Success Summary
|
||
if (hasRepeat && statsData.perTrialStats) {
|
||
const pts = statsData.perTrialStats;
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon green">📊</div>
|
||
分 Trial 统计
|
||
</div>
|
||
|
||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 20px;">
|
||
<div class="info-item" style="text-align: center; padding: 16px;">
|
||
<div class="info-label">有成功的任务</div>
|
||
<div style="font-size: 28px; font-weight: 800; color: var(--success);">${pts.tasksWithSuccess}</div>
|
||
<div style="font-size: 12px; color: var(--text-muted);">至少成功一次</div>
|
||
</div>
|
||
<div class="info-item" style="text-align: center; padding: 16px;">
|
||
<div class="info-label">全部失败的任务</div>
|
||
<div style="font-size: 28px; font-weight: 800; color: var(--error);">${pts.tasksAllFailed}</div>
|
||
<div style="font-size: 12px; color: var(--text-muted);">所有 trial 都失败</div>
|
||
</div>
|
||
<div class="info-item" style="text-align: center; padding: 16px;">
|
||
<div class="info-label">全部成功的任务</div>
|
||
<div style="font-size: 28px; font-weight: 800; color: var(--primary);">${pts.tasksAllSuccess}</div>
|
||
<div style="font-size: 12px; color: var(--text-muted);">所有 trial 都成功</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: 16px; margin-bottom: 12px; font-size: 12px;">
|
||
<span class="info-label" style="margin: 0;">各 Trial 统计</span>
|
||
<span style="display: flex; align-items: center; gap: 4px;">
|
||
<span style="width: 12px; height: 12px; background: var(--warning); border-radius: 2px;"></span>
|
||
目标达成率
|
||
</span>
|
||
<span style="display: flex; align-items: center; gap: 4px;">
|
||
<span style="width: 12px; height: 12px; background: var(--success); border-radius: 2px;"></span>
|
||
任务成功率
|
||
</span>
|
||
</div>
|
||
<div class="bar-chart">
|
||
${pts.trialSuccessRates.map((rate, idx) => {
|
||
const successPct = (rate * 100).toFixed(1);
|
||
const passedRate = pts.trialPassedRates?.[idx] ?? rate;
|
||
const passedPct = (passedRate * 100).toFixed(1);
|
||
const passedCount = pts.trialPassedCounts?.[idx] ?? pts.trialSuccessCounts[idx];
|
||
const hasGap = passedRate > rate;
|
||
return `
|
||
<div class="bar-item">
|
||
<div class="bar-label">Trial ${idx}</div>
|
||
<div class="bar-track" style="position: relative;">
|
||
<!-- 目标达成率(紫色底层) -->
|
||
<div class="bar-fill purple" style="width: ${Math.max(parseFloat(passedPct), 3)}%; position: absolute; left: 0; top: 0; height: 100%; opacity: ${hasGap ? 1 : 0.3};">
|
||
${hasGap ? `<span style="position: absolute; right: 8px; color: white; font-size: 11px;">${passedCount}/${pts.totalTasks}</span>` : ''}
|
||
</div>
|
||
<!-- 任务成功率(绿色顶层) -->
|
||
<div class="bar-fill success" style="width: ${Math.max(parseFloat(successPct), 3)}%; position: relative; z-index: 1;">
|
||
${pts.trialSuccessCounts[idx]}/${pts.totalTasks}
|
||
</div>
|
||
</div>
|
||
<div class="bar-value" style="min-width: 90px;">
|
||
${hasGap ? `<span style="color: var(--warning);">${passedCount}/${pts.totalTasks}</span> / ` : ''}
|
||
<span style="color: var(--success);">${pts.trialSuccessCounts[idx]}/${pts.totalTasks}</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
|
||
${pts.trialSuccessRates.length > 1 ? `
|
||
<div style="margin-top: 16px; padding: 12px; background: var(--bg); border-radius: var(--radius-sm);">
|
||
<div style="display: flex; flex-wrap: wrap; gap: 12px; font-size: 12px;">
|
||
<span style="color: var(--success);">成功率: <strong>${(pts.trialSuccessRates.reduce((a,b) => a+b, 0) / pts.trialSuccessRates.length * 100).toFixed(1)}%</strong> (平均)</span>
|
||
<span style="color: var(--warning);">达成率: <strong>${(pts.trialPassedRates.reduce((a,b) => a+b, 0) / pts.trialPassedRates.length * 100).toFixed(1)}%</strong> (平均)</span>
|
||
<span>差距: <strong style="color: var(--warning);">${((pts.trialPassedRates.reduce((a,b) => a+b, 0) - pts.trialSuccessRates.reduce((a,b) => a+b, 0)) / pts.trialPassedRates.length * 100).toFixed(1)}%</strong></span>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 运行总结
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon green">📈</div>
|
||
运行总结
|
||
</div>
|
||
<div class="info-grid">
|
||
${[
|
||
['结束时间', formatTime(sum.end_time)],
|
||
['总运行时间', sum.start_time && sum.end_time ? formatDuration(new Date(sum.end_time) - new Date(sum.start_time)) : null],
|
||
['平均步数', sum.avg_steps?.toFixed(2)],
|
||
['平均耗时', sum.avg_runtime_s ? `${sum.avg_runtime_s.toFixed(1)}s` : null],
|
||
['错误任务数', sum.error],
|
||
].filter(([k, v]) => v != null).map(([k, v]) => `
|
||
<div class="info-item">
|
||
<div class="info-label">${k}</div>
|
||
<div class="info-value">${v ?? '-'}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Steps Statistics
|
||
if (statsData.stepsStats) {
|
||
const ss = statsData.stepsStats;
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon blue">📊</div>
|
||
步数统计
|
||
</div>
|
||
<div class="info-grid">
|
||
${[
|
||
['最小步数', ss.min],
|
||
['最大步数', ss.max],
|
||
['平均步数', ss.avg.toFixed(2)],
|
||
['中位数', ss.median],
|
||
['标准差', ss.std.toFixed(2)],
|
||
].map(([k, v]) => `
|
||
<div class="info-item">
|
||
<div class="info-label">${k}</div>
|
||
<div class="info-value">${v}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
${ss.distribution ? `
|
||
<div style="margin-top: 16px;">
|
||
<div class="info-label" style="margin-bottom: 12px;">步数分布</div>
|
||
<div class="bar-chart">
|
||
${Object.entries(ss.distribution).map(([range, count]) => {
|
||
const pct = (count / ss.total * 100).toFixed(1);
|
||
return `
|
||
<div class="bar-item">
|
||
<div class="bar-label">${range} 步</div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill primary" style="width: ${Math.max(pct, 2)}%">${count}</div>
|
||
</div>
|
||
<div class="bar-value">${pct}%</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Action Type Distribution (only show when data is loaded)
|
||
if (state.actionTypesCache && Object.keys(state.actionTypesCache).length > 0) {
|
||
const totalActions = Object.values(state.actionTypesCache).reduce((a, b) => a + b, 0);
|
||
const sortedActions = Object.entries(state.actionTypesCache).sort((a, b) => b[1] - a[1]);
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon purple">🎬</div>
|
||
动作类型分布 (共 ${totalActions} 个动作)
|
||
</div>
|
||
<div class="bar-chart">
|
||
${sortedActions.map(([action, count], idx) => {
|
||
const pct = (count / totalActions * 100).toFixed(1);
|
||
const colors = ['primary', 'success', 'purple', 'warning'];
|
||
const color = colors[idx % colors.length];
|
||
return `
|
||
<div class="bar-item">
|
||
<div class="bar-label">${action}</div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill ${color}" style="width: ${Math.max(pct, 3)}%">${count}</div>
|
||
</div>
|
||
<div class="bar-value">${pct}%</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Per-App Success Rate
|
||
if (statsData.perApp && Object.keys(statsData.perApp).length > 0) {
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon green">📱</div>
|
||
分应用成功率
|
||
</div>
|
||
<div class="bar-chart">
|
||
${Object.entries(statsData.perApp).map(([app, data]) => {
|
||
const pct = (data.successRate * 100).toFixed(1);
|
||
return `
|
||
<div class="bar-item">
|
||
<div class="bar-label" title="${app}">${app}</div>
|
||
<div class="bar-track">
|
||
<div class="bar-fill success" style="width: ${Math.max(pct, 2)}%">${data.success}/${data.total}</div>
|
||
</div>
|
||
<div class="bar-value">${pct}%</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Task Complexity Analysis
|
||
if (statsData.complexity && statsData.complexity.length > 0) {
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon blue">🧩</div>
|
||
任务复杂度分析 (按步数)
|
||
</div>
|
||
<div class="info-grid">
|
||
${statsData.complexity.map(c => `
|
||
<div class="info-item">
|
||
<div class="info-label">${c.level}</div>
|
||
<div class="info-value">${c.count} 任务 (${c.successRate.toFixed(1)}% 成功)</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Per-Task Statistics Table (detailed view with actions and Pass@K)
|
||
if (statsData.perTaskStats && Object.keys(statsData.perTaskStats).length > 0) {
|
||
const sortedTasks = Object.entries(statsData.perTaskStats)
|
||
.sort((a, b) => b[1].successRate - a[1].successRate);
|
||
|
||
const hasActionData = state.actionTypesCache && Object.keys(state.actionTypesCache).length > 0;
|
||
|
||
// 检查是否有 Pass@K 数据
|
||
const hasPassK = sum.per_task_pass_k && Object.keys(sum.per_task_pass_k).length > 0;
|
||
let passKKeys = [];
|
||
if (hasPassK) {
|
||
passKKeys = Object.keys(Object.values(sum.per_task_pass_k)[0] || {})
|
||
.filter(k => k.startsWith('pass@'))
|
||
.sort((a, b) => parseInt(a.split('@')[1]) - parseInt(b.split('@')[1]));
|
||
}
|
||
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title" style="justify-content: space-between;">
|
||
<div style="display: flex; align-items: center; gap: 10px;">
|
||
<div class="icon purple">🏷️</div>
|
||
按任务类型统计
|
||
</div>
|
||
${!hasActionData && !state.actionTypesLoading ? `
|
||
<button class="btn btn-secondary" onclick="loadActionTypes()" style="font-size: 12px; padding: 6px 12px;">
|
||
📊 加载动作统计
|
||
</button>
|
||
` : ''}
|
||
${state.actionTypesLoading ? `
|
||
<span style="font-size: 12px; color: var(--primary);">⏳ 加载中...</span>
|
||
` : ''}
|
||
</div>
|
||
<div class="table-container" style="max-height: 500px;">
|
||
<table class="task-stats-table">
|
||
<thead>
|
||
<tr>
|
||
<th>任务类型</th>
|
||
<th title="任务成功率 (success && clean && finished)">
|
||
<span style="color: var(--success);">✓</span> 成功率
|
||
</th>
|
||
<th title="目标达成率 (judge.success=true)">
|
||
<span style="color: var(--warning);">🎯</span> 达成率
|
||
</th>
|
||
<th>成功/达成/总数</th>
|
||
<th title="Progress Rate: 平均 check_goals 完成进度">PR</th>
|
||
${hasPassK ? passKKeys.map(k => `<th style="text-align: center;">${k}</th>`).join('') : ''}
|
||
<th>平均步数</th>
|
||
<th>主要动作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${sortedTasks.map(([taskName, data]) => {
|
||
const successPct = (data.successRate * 100).toFixed(0);
|
||
const goalSuccessPct = ((data.goalSuccessRate ?? data.successRate) * 100).toFixed(0);
|
||
const hasGap = (data.goalSuccessRate ?? 0) > data.successRate;
|
||
const successBarColor = data.successRate >= 0.8 ? 'var(--success)' :
|
||
data.successRate >= 0.5 ? 'var(--warning)' : 'var(--error)';
|
||
const goalSuccessBarColor = (data.goalSuccessRate ?? 0) >= 0.8 ? 'var(--warning)' :
|
||
(data.goalSuccessRate ?? 0) >= 0.5 ? 'var(--purple)' : 'var(--purple)';
|
||
// Re-calculate actions with latest data
|
||
const actionCounts = getActionCountsForTask(taskName);
|
||
const topActions = Object.entries(actionCounts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 3)
|
||
.map(([type, count]) => `${type}:${count}`)
|
||
.join(', ');
|
||
|
||
// 获取该任务的 Pass@K 数据
|
||
const taskPassK = hasPassK ? sum.per_task_pass_k[taskName] : null;
|
||
const passKCells = hasPassK ? passKKeys.map(k => {
|
||
const val = taskPassK?.[k] ?? 0;
|
||
const pct = (val * 100).toFixed(0);
|
||
let cellClass = 'pass-k-cell';
|
||
if (val >= 1) cellClass += ' full';
|
||
else if (val === 0) cellClass += ' zero';
|
||
else cellClass += ' partial';
|
||
return `<td class="${cellClass}">${pct}%</td>`;
|
||
}).join('') : '';
|
||
|
||
return `
|
||
<tr>
|
||
<td class="task-name-cell" title="${taskName}">
|
||
<strong>${taskName.split('.').pop() || taskName}</strong>
|
||
</td>
|
||
<td>
|
||
<div class="success-rate-cell">
|
||
<span class="success-rate-value" style="color: ${successBarColor}">${successPct}%</span>
|
||
<div class="success-rate-bar">
|
||
<div class="success-rate-fill" style="width: ${successPct}%; background: ${successBarColor}"></div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="success-rate-cell">
|
||
<span class="success-rate-value" style="color: var(--purple);">${goalSuccessPct}%</span>
|
||
<div class="success-rate-bar">
|
||
<div class="success-rate-fill" style="width: ${goalSuccessPct}%; background: var(--purple);"></div>
|
||
</div>
|
||
${hasGap ? `<span style="font-size: 10px; color: var(--warning);">+${(parseFloat(goalSuccessPct) - parseFloat(successPct)).toFixed(0)}%</span>` : ''}
|
||
</div>
|
||
</td>
|
||
<td class="center-cell">
|
||
<span style="color: var(--success);">${data.success}</span> /
|
||
<span style="color: var(--purple);">${data.goalSuccess ?? data.success}</span> /
|
||
${data.total}
|
||
</td>
|
||
<td class="center-cell" style="font-family: var(--mono); color: ${(data.progressRate ?? 0) >= 0.8 ? 'var(--success)' : (data.progressRate ?? 0) >= 0.5 ? 'var(--warning)' : 'var(--error)'};">${((data.progressRate ?? 0) * 100).toFixed(0)}%</td>
|
||
${passKCells}
|
||
<td class="center-cell">${data.avgSteps.toFixed(1)}</td>
|
||
<td class="actions-cell">${topActions || (hasActionData ? '-' : '<span style="color: var(--text-muted);">点击加载</span>')}</td>
|
||
</tr>
|
||
`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// 收集各类任务列表(基于新规则重新计算)
|
||
const realSuccessTasks = []; // success && clean && finished (完美成功)
|
||
const passedButUnawareTasks = []; // success && clean && !finished (未意识)
|
||
const sideEffectFinishedTasks = []; // success && !clean && finished (有副作用)
|
||
const sideEffectUnawareTasks = []; // success && !clean && !finished (双重问题)
|
||
const realFailedTasks = []; // !success (真正失败)
|
||
const resultsToCheck = filteredRes;
|
||
for (const r of resultsToCheck) {
|
||
const success = r.judge?.success === true;
|
||
const clean = r.judge?.clean === true;
|
||
const finished = r.execution?.finished === true;
|
||
const taskId = r.task_id || r.id;
|
||
const trialId = r.trial_id;
|
||
const label = trialId !== undefined ? `${taskId} (T${trialId})` : taskId;
|
||
|
||
if (success && clean && finished) {
|
||
realSuccessTasks.push(label);
|
||
} else if (success && clean && !finished) {
|
||
passedButUnawareTasks.push(label);
|
||
} else if (success && !clean && finished) {
|
||
sideEffectFinishedTasks.push(label);
|
||
} else if (success && !clean && !finished) {
|
||
sideEffectUnawareTasks.push(label);
|
||
} else {
|
||
realFailedTasks.push(label);
|
||
}
|
||
}
|
||
|
||
// Success/Failed task lists (collapsible) - 使用新规则重新计算的列表
|
||
html += `
|
||
<details ${realSuccessTasks.length && realSuccessTasks.length <= 20 ? 'open' : ''}>
|
||
<summary style="color: var(--success);">✓ 成功任务列表 (${realSuccessTasks.length} 个)</summary>
|
||
<div class="content">
|
||
<p style="font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;">
|
||
这些任务目标达成(judge.success=true)、无副作用(judge.clean=true)且主动完成(stop_reason=COMPLETE)。
|
||
</p>
|
||
${realSuccessTasks.length ? `
|
||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||
${realSuccessTasks.map(t => `<span class="badge success">${t}</span>`).join('')}
|
||
</div>
|
||
` : '<p style="color: var(--text-muted);">无成功任务</p>'}
|
||
</div>
|
||
</details>
|
||
|
||
${passedButUnawareTasks.length > 0 ? `
|
||
<details ${passedButUnawareTasks.length <= 20 ? 'open' : ''}>
|
||
<summary style="color: var(--warning);">⚠️ 达成但未意识任务列表 (${passedButUnawareTasks.length} 个)</summary>
|
||
<div class="content">
|
||
<p style="font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;">
|
||
这些任务的目标已达成(judge.success=true)且无副作用(judge.clean=true),但模型没有主动以 COMPLETE 结束任务(stop_reason≠COMPLETE),最终被判定为失败。
|
||
</p>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||
${passedButUnawareTasks.map(t => `<span class="badge" style="background: var(--warning-light); color: var(--warning);">${t}</span>`).join('')}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
` : ''}
|
||
|
||
${sideEffectFinishedTasks.length > 0 ? `
|
||
<details ${sideEffectFinishedTasks.length <= 20 ? 'open' : ''}>
|
||
<summary style="color: #f97316;">🔧 达成但有副作用任务列表 (${sideEffectFinishedTasks.length} 个)</summary>
|
||
<div class="content">
|
||
<p style="font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;">
|
||
这些任务的目标已达成(judge.success=true)且模型主动完成(stop_reason=COMPLETE),但产生了意外的副作用(judge.clean=false),最终被判定为失败。
|
||
</p>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||
${sideEffectFinishedTasks.map(t => `<span class="badge" style="background: #fff7ed; color: #f97316;">${t}</span>`).join('')}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
` : ''}
|
||
|
||
${sideEffectUnawareTasks.length > 0 ? `
|
||
<details ${sideEffectUnawareTasks.length <= 20 ? 'open' : ''}>
|
||
<summary style="color: #c2410c;">⚠️🔧 双重问题任务列表 (${sideEffectUnawareTasks.length} 个)</summary>
|
||
<div class="content">
|
||
<p style="font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;">
|
||
这些任务的目标已达成(judge.success=true),但既有副作用(judge.clean=false)又未以 COMPLETE 结束(stop_reason≠COMPLETE),最终被判定为失败。
|
||
</p>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||
${sideEffectUnawareTasks.map(t => `<span class="badge" style="background: #fed7aa; color: #c2410c;">${t}</span>`).join('')}
|
||
</div>
|
||
</div>
|
||
</details>
|
||
` : ''}
|
||
|
||
<details ${realFailedTasks.length && realFailedTasks.length <= 20 ? 'open' : ''}>
|
||
<summary style="color: var(--error);">✗ 失败任务列表 (${realFailedTasks.length} 个)</summary>
|
||
<div class="content">
|
||
<p style="font-size: 13px; color: var(--text-secondary); margin-bottom: 12px;">
|
||
这些任务目标未达成(judge.success=false)。
|
||
</p>
|
||
${realFailedTasks.length ? `
|
||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||
${realFailedTasks.map(t => `<span class="badge failed">${t}</span>`).join('')}
|
||
</div>
|
||
` : '<p style="color: var(--text-muted);">无失败任务</p>'}
|
||
</div>
|
||
</details>
|
||
|
||
<details>
|
||
<summary>查看完整 summary.json</summary>
|
||
<div class="content">
|
||
<div class="code">${JSON.stringify(sum, null, 2)}</div>
|
||
</div>
|
||
</details>
|
||
`;
|
||
|
||
el('overviewContent').innerHTML = html;
|
||
}
|
||
|
||
// Calculate detailed statistics from results
|
||
function calculateDetailedStats(filteredResults) {
|
||
const stats = {
|
||
stepsStats: null,
|
||
actionTypes: {},
|
||
perApp: {},
|
||
complexity: [],
|
||
taskTypes: {},
|
||
perTaskStats: {},
|
||
perTrialStats: null
|
||
};
|
||
|
||
if (state.results.size === 0 && state.allResults.length === 0) return stats;
|
||
|
||
// Use filteredResults if provided, otherwise fallback to allResults
|
||
const resultsToAnalyze = filteredResults || (state.allResults.length > 0 ? state.allResults : Array.from(state.results.values()));
|
||
|
||
// Collect steps data
|
||
const stepsArr = [];
|
||
const tasksBySteps = { simple: [], medium: [], complex: [] };
|
||
|
||
// Per-task detailed stats collection
|
||
const perTaskData = {};
|
||
|
||
for (const r of resultsToAnalyze) {
|
||
const taskId = r.task_id || r.id;
|
||
const steps = r.execution?.steps;
|
||
if (typeof steps === 'number' && steps > 0) {
|
||
stepsArr.push(steps);
|
||
|
||
// Categorize by complexity
|
||
if (steps <= 5) tasksBySteps.simple.push(r);
|
||
else if (steps <= 12) tasksBySteps.medium.push(r);
|
||
else tasksBySteps.complex.push(r);
|
||
}
|
||
|
||
// Per-app stats
|
||
const appMatch = taskId?.match(/^([^.]+)\./);
|
||
if (appMatch) {
|
||
const app = appMatch[1];
|
||
if (!stats.perApp[app]) {
|
||
stats.perApp[app] = { total: 0, success: 0, successRate: 0 };
|
||
}
|
||
stats.perApp[app].total++;
|
||
if (isRealSuccess(r)) stats.perApp[app].success++;
|
||
}
|
||
|
||
// Task type stats (extract verb from task name like ReadMyWxid -> Read)
|
||
const taskTypeMatch = taskId?.match(/^[^.]+\.([A-Z][a-z]+)/);
|
||
if (taskTypeMatch) {
|
||
const taskType = taskTypeMatch[1];
|
||
if (!stats.taskTypes[taskType]) {
|
||
stats.taskTypes[taskType] = { total: 0, success: 0 };
|
||
}
|
||
stats.taskTypes[taskType].total++;
|
||
if (isRealSuccess(r)) stats.taskTypes[taskType].success++;
|
||
}
|
||
|
||
// Per-task detailed stats
|
||
if (taskId) {
|
||
if (!perTaskData[taskId]) {
|
||
perTaskData[taskId] = {
|
||
total: 0,
|
||
success: 0,
|
||
goalSuccess: 0,
|
||
errorCount: 0,
|
||
stepsArr: [],
|
||
actionCounts: {},
|
||
progressSum: 0,
|
||
falseCompleteCount: 0,
|
||
overdueCount: 0,
|
||
};
|
||
}
|
||
const td = perTaskData[taskId];
|
||
td.total++;
|
||
const realSuccess = isRealSuccess(r);
|
||
if (realSuccess) td.success++;
|
||
const goalOk = r.judge?.success === true;
|
||
if (goalOk) td.goalSuccess++;
|
||
td.progressSum += (r.progress ?? r.judge?.progress ?? (goalOk ? 1.0 : 0.0));
|
||
const isErr = r.is_error || r.execution?.error || r.judge?.judge_error
|
||
|| r.judge?.issues?.some(i => 'error' in i);
|
||
if (isErr) td.errorCount++;
|
||
// FC: stop_reason=COMPLETE 且 episode 未 fully successful (论文 §3.5)。
|
||
// 新字段 false_complete 存在时优先使用;历史 run 没有该字段时按 raw fields 重算,
|
||
// 避免旧 premature_termination 口径漏掉「goal 达成但有副作用」一类。
|
||
const fcFromRaw = (r.execution?.stop_reason === 'COMPLETE' && !realSuccess);
|
||
const fcFlag = r.false_complete === undefined ? fcFromRaw : r.false_complete === true;
|
||
if (!isErr && fcFlag) td.falseCompleteCount++;
|
||
// OT: 统一按 raw fields 重算 (truncated && goal_success),与 overview /
|
||
// getTaskIndicators / METRICS_RECALC_NOTES 对齐。旧 run 中 overdue_termination
|
||
// 字段曾表示「所有 truncated」,不可信,必须忽略。
|
||
if (r.execution?.truncated === true && goalOk) td.overdueCount++;
|
||
if (typeof steps === 'number') {
|
||
td.stepsArr.push(steps);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Calculate per-app success rates
|
||
for (const app of Object.keys(stats.perApp)) {
|
||
const data = stats.perApp[app];
|
||
data.successRate = data.total > 0 ? data.success / data.total : 0;
|
||
}
|
||
|
||
// Calculate per-task stats with action types from trajectories
|
||
for (const [taskId, data] of Object.entries(perTaskData)) {
|
||
const avgSteps = data.stepsArr.length > 0
|
||
? data.stepsArr.reduce((a, b) => a + b, 0) / data.stepsArr.length
|
||
: 0;
|
||
|
||
// Get action counts from cached action types or trajectory data
|
||
const actionCounts = getActionCountsForTask(taskId);
|
||
const topActions = Object.entries(actionCounts)
|
||
.sort((a, b) => b[1] - a[1])
|
||
.slice(0, 3)
|
||
.map(([type, count]) => ({ type, count }));
|
||
|
||
const validCount = data.total - (data.errorCount || 0);
|
||
stats.perTaskStats[taskId] = {
|
||
total: data.total,
|
||
success: data.success,
|
||
goalSuccess: data.goalSuccess,
|
||
errorCount: data.errorCount || 0,
|
||
successRate: validCount > 0 ? data.success / validCount : 0,
|
||
goalSuccessRate: validCount > 0 ? data.goalSuccess / validCount : 0,
|
||
progressRate: data.total > 0 ? data.progressSum / data.total : 0,
|
||
falseCompleteRate: data.total > 0 ? data.falseCompleteCount / data.total : 0,
|
||
overdueRate: data.total > 0 ? data.overdueCount / data.total : 0,
|
||
avgSteps,
|
||
topActions
|
||
};
|
||
}
|
||
|
||
// Per-Trial Statistics (for repeat_n > 1)
|
||
const meta = state.meta || {};
|
||
const sum = state.summary || {};
|
||
const repeatN = sum.repeat_n ?? meta.repeat_n ?? 1;
|
||
|
||
if (repeatN > 1) {
|
||
// Re-group resultsToAnalyze by taskId for filtered per-trial stats
|
||
const filteredByTask = new Map();
|
||
for (const r of resultsToAnalyze) {
|
||
const id = r.id || r.task_id;
|
||
if (!id) continue;
|
||
if (!filteredByTask.has(id)) filteredByTask.set(id, []);
|
||
filteredByTask.get(id).push(r);
|
||
}
|
||
if (filteredByTask.size > 0) {
|
||
const trialSuccessCounts = new Array(repeatN).fill(0);
|
||
const trialPassedCounts = new Array(repeatN).fill(0); // 目标达成数
|
||
let tasksWithSuccess = 0;
|
||
let tasksAllFailed = 0;
|
||
let tasksAllSuccess = 0;
|
||
let validTaskCount = 0; // 排除全 error 的任务
|
||
const totalTasks = filteredByTask.size;
|
||
|
||
for (const [taskId, trials] of filteredByTask) {
|
||
const errorCount = trials.filter(t =>
|
||
t.is_error || t.execution?.error || t.judge?.judge_error
|
||
|| t.judge?.issues?.some(i => 'error' in i)).length;
|
||
const validN = trials.length - errorCount;
|
||
const successCount = trials.filter(t => isRealSuccess(t)).length;
|
||
|
||
if (validN > 0) {
|
||
validTaskCount++;
|
||
// Count tasks by success pattern
|
||
if (successCount > 0) tasksWithSuccess++;
|
||
if (successCount === 0) tasksAllFailed++;
|
||
if (successCount === validN) tasksAllSuccess++;
|
||
}
|
||
|
||
// Count success and passed per trial index
|
||
for (const trial of trials) {
|
||
const trialIdx = trial.trial_id ?? 0;
|
||
if (trialIdx < repeatN) {
|
||
if (isRealSuccess(trial)) {
|
||
trialSuccessCounts[trialIdx]++;
|
||
}
|
||
// 目标达成:judge.success=true
|
||
if (trial.judge?.success === true) {
|
||
trialPassedCounts[trialIdx]++;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const trialSuccessRates = trialSuccessCounts.map(count =>
|
||
validTaskCount > 0 ? count / validTaskCount : 0
|
||
);
|
||
|
||
const trialPassedRates = trialPassedCounts.map(count =>
|
||
validTaskCount > 0 ? count / validTaskCount : 0
|
||
);
|
||
|
||
stats.perTrialStats = {
|
||
totalTasks: validTaskCount,
|
||
repeatN,
|
||
trialSuccessCounts,
|
||
trialSuccessRates,
|
||
trialPassedCounts,
|
||
trialPassedRates,
|
||
tasksWithSuccess,
|
||
tasksAllFailed,
|
||
tasksAllSuccess
|
||
};
|
||
} // end if filteredByTask.size > 0
|
||
} // end if repeatN > 1
|
||
|
||
// Steps statistics
|
||
if (stepsArr.length > 0) {
|
||
stepsArr.sort((a, b) => a - b);
|
||
const sum = stepsArr.reduce((a, b) => a + b, 0);
|
||
const avg = sum / stepsArr.length;
|
||
const median = stepsArr.length % 2 === 0
|
||
? (stepsArr[stepsArr.length / 2 - 1] + stepsArr[stepsArr.length / 2]) / 2
|
||
: stepsArr[Math.floor(stepsArr.length / 2)];
|
||
const variance = stepsArr.reduce((acc, v) => acc + Math.pow(v - avg, 2), 0) / stepsArr.length;
|
||
const std = Math.sqrt(variance);
|
||
|
||
// Distribution buckets
|
||
const distribution = {};
|
||
const buckets = [[1, 5], [6, 10], [11, 15], [16, 20], [21, Infinity]];
|
||
for (const [min, max] of buckets) {
|
||
const label = max === Infinity ? `${min}+` : `${min}-${max}`;
|
||
distribution[label] = stepsArr.filter(s => s >= min && s <= max).length;
|
||
}
|
||
|
||
stats.stepsStats = {
|
||
min: stepsArr[0],
|
||
max: stepsArr[stepsArr.length - 1],
|
||
avg,
|
||
median,
|
||
std,
|
||
total: stepsArr.length,
|
||
distribution
|
||
};
|
||
}
|
||
|
||
// Complexity analysis
|
||
const complexityLevels = [
|
||
{ level: '简单 (1-5步)', tasks: tasksBySteps.simple },
|
||
{ level: '中等 (6-12步)', tasks: tasksBySteps.medium },
|
||
{ level: '复杂 (13+步)', tasks: tasksBySteps.complex }
|
||
];
|
||
|
||
stats.complexity = complexityLevels.map(c => ({
|
||
level: c.level,
|
||
count: c.tasks.length,
|
||
successRate: c.tasks.length > 0 ?
|
||
c.tasks.filter(t => isRealSuccess(t)).length / c.tasks.length * 100 : 0
|
||
})).filter(c => c.count > 0);
|
||
|
||
// Action types will be collected async when available
|
||
// They're stored in state.actionTypesCache after loadActionTypes() is called
|
||
|
||
return stats;
|
||
}
|
||
|
||
function formatDuration(ms) {
|
||
if (!ms || ms < 0) return '-';
|
||
const seconds = Math.floor(ms / 1000);
|
||
const minutes = Math.floor(seconds / 60);
|
||
const hours = Math.floor(minutes / 60);
|
||
|
||
if (hours > 0) {
|
||
return `${hours}小时 ${minutes % 60}分钟`;
|
||
} else if (minutes > 0) {
|
||
return `${minutes}分钟 ${seconds % 60}秒`;
|
||
} else {
|
||
return `${seconds}秒`;
|
||
}
|
||
}
|
||
|
||
function calculateStd(arr) {
|
||
if (!arr || arr.length === 0) return 0;
|
||
const avg = arr.reduce((a, b) => a + b, 0) / arr.length;
|
||
const variance = arr.reduce((acc, v) => acc + Math.pow(v - avg, 2), 0) / arr.length;
|
||
return Math.sqrt(variance);
|
||
}
|
||
|
||
// Pass@K 无偏估计器计算
|
||
function calculatePassAtK(n, c, k) {
|
||
if (n <= 0 || k <= 0) return 0;
|
||
if (c >= n) return 1; // 全部成功
|
||
if (k > n) k = n;
|
||
if (n - c < k) return 1; // 失败数小于 k,必然至少成功一次
|
||
|
||
// Pass@k = 1 - C(n-c, k) / C(n, k)
|
||
// = 1 - product((n-c-i)/(n-i) for i in 0..k-1)
|
||
let prob = 1;
|
||
for (let i = 0; i < k; i++) {
|
||
prob *= (n - c - i) / (n - i);
|
||
}
|
||
return 1 - prob;
|
||
}
|
||
|
||
// Quick calculation for header stats
|
||
function calculatePerTrialStatsQuick(filteredResults) {
|
||
// When filteredResults provided, re-group by taskId
|
||
const taskMap = filteredResults ? new Map() : state.resultsByTask;
|
||
if (filteredResults) {
|
||
for (const r of filteredResults) {
|
||
const id = r.id || r.task_id;
|
||
if (!id) continue;
|
||
if (!taskMap.has(id)) taskMap.set(id, []);
|
||
taskMap.get(id).push(r);
|
||
}
|
||
}
|
||
if (taskMap.size === 0) return null;
|
||
|
||
let tasksWithSuccess = 0;
|
||
let tasksAllFailed = 0;
|
||
let tasksAllSuccess = 0;
|
||
|
||
// 收集每个任务的成功数和达成数,用于计算 Pass@K
|
||
const taskSuccessCounts = [];
|
||
const taskPassedCounts = [];
|
||
|
||
for (const [taskId, trials] of taskMap) {
|
||
// 排除 error episodes
|
||
const errorTrials = trials.filter(t =>
|
||
t.is_error || t.execution?.error || t.judge?.judge_error
|
||
|| t.judge?.issues?.some(i => 'error' in i));
|
||
const validN = trials.length - errorTrials.length;
|
||
// 使用新规则计算成功数
|
||
const successCount = trials.filter(t => isRealSuccess(t)).length;
|
||
// 目标达成数:judge.success=true(不考虑 clean)
|
||
const goalSuccessCount = trials.filter(t => t.judge?.success === true).length;
|
||
|
||
taskSuccessCounts.push({ n: validN, c: successCount });
|
||
taskPassedCounts.push({ n: validN, c: goalSuccessCount });
|
||
|
||
if (successCount > 0) tasksWithSuccess++;
|
||
if (successCount === 0 && validN > 0) tasksAllFailed++;
|
||
if (successCount === validN && validN > 0) tasksAllSuccess++;
|
||
}
|
||
|
||
// 统计目标达成后的各种情况
|
||
const resultsToAnalyze = filteredResults || (state.allResults.length > 0 ? state.allResults : Array.from(state.results.values()));
|
||
let totalResults = resultsToAnalyze.length;
|
||
|
||
// 目标达成相关统计 (基于 judge.success)
|
||
let totalGoalSuccess = 0; // judge.success=true 的总数
|
||
let perfectSuccess = 0; // is_success=true (success + clean + stop_reason=COMPLETE)
|
||
let passedButUnaware = 0; // success=true, clean=true, stop_reason≠COMPLETE (达成但未以 COMPLETE 结束)
|
||
let sideEffectFinished = 0; // success=true, clean=false, stop_reason=COMPLETE (达成但有副作用,主动完成)
|
||
let sideEffectUnaware = 0; // success=true, clean=false, stop_reason≠COMPLETE (达成但有副作用,未以 COMPLETE 结束)
|
||
|
||
// 旧的 passed 统计 (基于 judge.passed = success AND clean),保留用于兼容
|
||
let totalPassed = 0;
|
||
|
||
// 论文 §3.5 聚合指标
|
||
let progressSum = 0; // 用于计算 Progress Rate (PR)
|
||
let falseCompleteCount = 0; // FC: stop_reason=COMPLETE 但 episode 未 fully successful
|
||
let overdueCount = 0; // OT: truncated=true 且 goal_success(达成目标却未主动结束)
|
||
let uncleanCount = 0; // USE: judge.clean=false(独立诊断,与 SR/FC/OT 不互斥)
|
||
let errorCount = 0; // Judge/Exec errors (not agent's fault)
|
||
|
||
for (const r of resultsToAnalyze) {
|
||
const success = r.judge?.success === true;
|
||
const clean = r.judge?.clean === true;
|
||
const finished = r.execution?.finished === true;
|
||
const isCompleted = r.execution?.stop_reason === 'COMPLETE';
|
||
const realSuccess = isRealSuccess(r);
|
||
|
||
if (success) {
|
||
totalGoalSuccess++;
|
||
|
||
if (clean && isCompleted) {
|
||
perfectSuccess++; // is_success = true
|
||
} else if (clean && !isCompleted) {
|
||
passedButUnaware++; // 达成但未以 COMPLETE 结束
|
||
} else if (!clean && isCompleted) {
|
||
sideEffectFinished++; // 达成但有副作用(主动完成)
|
||
} else {
|
||
sideEffectUnaware++; // 达成但有副作用(未以 COMPLETE 结束)
|
||
}
|
||
}
|
||
|
||
if (r.judge?.passed === true) {
|
||
totalPassed++;
|
||
}
|
||
|
||
// PR: 收集 progress(优先用顶层字段,回退到 judge.progress)
|
||
const prog = r.progress ?? r.judge?.progress ?? (success ? 1.0 : 0.0);
|
||
progressSum += prog;
|
||
|
||
// Error detection (exec or judge)
|
||
const isError = r.is_error || r.execution?.error || r.judge?.judge_error
|
||
|| r.judge?.issues?.some(i => 'error' in i);
|
||
if (isError) errorCount++;
|
||
|
||
// FC (论文 §3.5): stop_reason=COMPLETE 但 episode 未 fully successful。
|
||
// 新字段 false_complete 存在时优先使用;历史 run 没有该字段时按 raw fields 重算。
|
||
const fcFromRaw = isCompleted && !realSuccess;
|
||
const fcFlag = r.false_complete === undefined ? fcFromRaw : r.false_complete === true;
|
||
if (!isError && fcFlag) {
|
||
falseCompleteCount++;
|
||
}
|
||
|
||
// OT (论文 §3.5): 达成 goal 但未主动 COMPLETE,被 step budget / loop 截断。
|
||
// NOTES 提示老 run 的 overdue_termination 字段语义曾经是「所有 truncation」,
|
||
// 不可信,统一按 raw fields 重算 (truncated=true 且 judge.success=true)。
|
||
if (r.execution?.truncated === true && success) {
|
||
overdueCount++;
|
||
}
|
||
|
||
// USE: 有意外副作用(分母为全体 episode,与 SR/FC/OT 不互斥)
|
||
if (!clean && r.judge != null) {
|
||
uncleanCount++;
|
||
}
|
||
}
|
||
|
||
// 有效 episode 数(排除 error)
|
||
const validResults = totalResults - errorCount;
|
||
// 目标达成率 = goal_success / valid (基于 judge.success,排除 error)
|
||
const goalSuccessRate = validResults > 0 ? totalGoalSuccess / validResults : 0;
|
||
// 旧的 passed 率 (基于 judge.passed = success AND clean),保留用于兼容
|
||
const passedRate = validResults > 0 ? totalPassed / validResults : 0;
|
||
|
||
// 论文 §3.5 聚合指标
|
||
const progressRate = totalResults > 0 ? progressSum / totalResults : 0;
|
||
const falseCompleteRate = totalResults > 0 ? falseCompleteCount / totalResults : 0;
|
||
const overdueRate = totalResults > 0 ? overdueCount / totalResults : 0;
|
||
const sideEffectRate = totalResults > 0 ? uncleanCount / totalResults : 0;
|
||
|
||
// 计算基于 judge.success 的 Pass@K(目标达成)
|
||
const meta = state.meta || {};
|
||
const sum = state.summary || {};
|
||
const passKValues = meta.pass_k || [1, 2, 4, 8];
|
||
// 只有 n > 0 的任务才计入 Pass@K 平均
|
||
const validPassedTasks = taskPassedCounts.filter(t => t.n > 0);
|
||
const validSuccessTasks = taskSuccessCounts.filter(t => t.n > 0);
|
||
const numPassedTasks = validPassedTasks.length || 1;
|
||
const numSuccessTasks = validSuccessTasks.length || 1;
|
||
|
||
const passAtKPassed = {}; // 基于 judge.success 的 Pass@K(目标达成)
|
||
const passAtKSuccess = {}; // 基于 isRealSuccess 的 Pass@K(完美成功)
|
||
for (const k of passKValues) {
|
||
passAtKPassed[k] = validPassedTasks.reduce((acc, { n, c }) => acc + calculatePassAtK(n, c, k), 0) / numPassedTasks;
|
||
passAtKSuccess[k] = validSuccessTasks.reduce((acc, { n, c }) => acc + calculatePassAtK(n, c, k), 0) / numSuccessTasks;
|
||
}
|
||
|
||
return {
|
||
tasksWithSuccess, tasksAllFailed, tasksAllSuccess,
|
||
// 目标达成分解
|
||
totalGoalSuccess, goalSuccessRate,
|
||
perfectSuccess, passedButUnaware, sideEffectFinished, sideEffectUnaware,
|
||
// 旧的 passed 统计
|
||
totalPassed, totalResults, passedRate, passAtKPassed, passAtKSuccess,
|
||
// 论文 §3.5 聚合指标
|
||
progressRate, falseCompleteRate, falseCompleteCount, overdueRate, overdueCount, sideEffectRate, uncleanCount,
|
||
errorCount, validResults,
|
||
};
|
||
}
|
||
|
||
// Load action types from all trajectories
|
||
async function loadActionTypes() {
|
||
if (state.actionTypesLoading) return;
|
||
|
||
state.actionTypesLoading = true;
|
||
state.actionTypesCache = {};
|
||
state.perTaskActionTypes = {}; // Store per-task action types
|
||
renderOverview();
|
||
|
||
try {
|
||
for (const task of state.tasks) {
|
||
try {
|
||
const trajectory = await fetchJson(`/${state.runDirName}/trajectory/${task.dirName}/trajectory.json`);
|
||
const baseTaskId = task.baseTaskId || task.task_id;
|
||
|
||
if (!state.perTaskActionTypes[baseTaskId]) {
|
||
state.perTaskActionTypes[baseTaskId] = {};
|
||
}
|
||
|
||
if (Array.isArray(trajectory)) {
|
||
for (const step of trajectory) {
|
||
const actionType = step.action_type || 'UNKNOWN';
|
||
// Global action types
|
||
state.actionTypesCache[actionType] = (state.actionTypesCache[actionType] || 0) + 1;
|
||
// Per-task action types
|
||
state.perTaskActionTypes[baseTaskId][actionType] =
|
||
(state.perTaskActionTypes[baseTaskId][actionType] || 0) + 1;
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load action types:', e);
|
||
}
|
||
|
||
state.actionTypesLoading = false;
|
||
renderOverview();
|
||
}
|
||
|
||
// Get action counts for a specific task
|
||
function getActionCountsForTask(taskId) {
|
||
if (state.perTaskActionTypes && state.perTaskActionTypes[taskId]) {
|
||
return state.perTaskActionTypes[taskId];
|
||
}
|
||
return {};
|
||
}
|
||
|
||
function renderTaskList() {
|
||
const wrap = el('taskList');
|
||
|
||
if (!state.runDirName) {
|
||
wrap.innerHTML = `
|
||
<div class="empty">
|
||
<div class="empty-icon">📋</div>
|
||
<h3>暂无数据</h3>
|
||
<p>正在加载...</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const filter = el('taskFilter').value.toLowerCase();
|
||
const showSuccess = el('fSuccess').checked;
|
||
const showFailed = el('fFailed').checked;
|
||
const showError = el('fError').checked;
|
||
const filterSideEffect = el('fSideEffect').checked;
|
||
const filterOverdue = el('fOverdue').checked;
|
||
const filterFalseComplete = el('fFalseComplete').checked;
|
||
|
||
// Update chip active states
|
||
document.querySelectorAll('.chip').forEach(chip => {
|
||
const input = chip.querySelector('input');
|
||
chip.classList.toggle('active', input.checked);
|
||
});
|
||
|
||
// 主结果白名单 + 问题标签正向 OR 过滤:
|
||
// - 标签全不选:不按标签过滤
|
||
// - 勾选标签:trial 命中任意一个已选标签即可保留
|
||
function trialPasses(t) {
|
||
const status = getTaskStatusForTrial(t);
|
||
if (status === 'success' && !showSuccess) return false;
|
||
if (status === 'failed' && !showFailed) return false;
|
||
if (status === 'error' && !showError) return false;
|
||
if (status === 'unknown' && !showError) return false;
|
||
|
||
const tags = getTaskIndicators(t);
|
||
const selectedIssueTags = [];
|
||
if (filterSideEffect) selectedIssueTags.push('sideEffect');
|
||
if (filterOverdue) selectedIssueTags.push('overdue');
|
||
if (filterFalseComplete) selectedIssueTags.push('falseComplete');
|
||
if (selectedIssueTags.length > 0 && !selectedIssueTags.some(tag => tags.includes(tag))) {
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
const sum = state.summary || {};
|
||
const meta = state.meta || {};
|
||
const hasPassK = sum.per_task_pass_k && Object.keys(sum.per_task_pass_k).length > 0;
|
||
const repeatN = sum.repeat_n ?? meta.repeat_n ?? 1;
|
||
|
||
// Determine if we should show grouped view (when repeat_n > 1)
|
||
const showGrouped = repeatN > 1 && state.taskGroups.size > 0;
|
||
|
||
let html = '';
|
||
|
||
if (showGrouped) {
|
||
// Grouped view - show tasks with their trials
|
||
const filteredGroups = [];
|
||
|
||
for (const [baseTaskId, trials] of state.taskGroups) {
|
||
// Taxonomy filter check
|
||
if (!taskPassesTaxonomyFilters(baseTaskId)) continue;
|
||
// Text filter check
|
||
const text = `${baseTaskId} ${trials[0]?.task_name || ''}`.toLowerCase();
|
||
if (filter && !text.includes(filter)) continue;
|
||
|
||
const visibleTrials = trials.filter(trialPasses);
|
||
if (visibleTrials.length === 0) continue;
|
||
|
||
filteredGroups.push({ baseTaskId, visibleTrials });
|
||
}
|
||
|
||
if (filteredGroups.length === 0) {
|
||
wrap.innerHTML = `
|
||
<div class="empty">
|
||
<div class="empty-icon">🔍</div>
|
||
<h3>未找到任务</h3>
|
||
<p>尝试调整筛选条件</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
const visibleTrialCount = filteredGroups.reduce((acc, { visibleTrials }) => acc + visibleTrials.length, 0);
|
||
|
||
html = filteredGroups.map(({ baseTaskId, visibleTrials }) => {
|
||
const taskPassK = sum.per_task_pass_k?.[baseTaskId];
|
||
const successCount = visibleTrials.filter(t => getTaskStatusForTrial(t) === 'success').length;
|
||
// 目标达成数:judge.success === true(无论是否完美)
|
||
const passedCount = visibleTrials.filter(t => getResultForTask(t)?.judge?.success === true).length;
|
||
const totalTrials = visibleTrials.length;
|
||
const isAnyActive = visibleTrials.some(t => state.selectedTask?.dirName === t.dirName);
|
||
const taskName = visibleTrials[0]?.task_name || baseTaskId;
|
||
const hasPassedButUnaware = passedCount > successCount;
|
||
|
||
// Pass@k info (只显示 K>1 的情况,Pass@1 和成功率重复)
|
||
let passKHtml = '';
|
||
if (taskPassK) {
|
||
// 不显示 Pass@1,因为和成功率重复
|
||
}
|
||
|
||
// Trial indicators
|
||
const trialIndicators = visibleTrials.map((t, idx) => {
|
||
const status = getTaskStatusForTrial(t);
|
||
const tags = getTaskIndicators(t);
|
||
const isActive = state.selectedTask?.dirName === t.dirName;
|
||
let statusText, statusIcon;
|
||
switch (status) {
|
||
case 'success': statusText = '完美成功'; statusIcon = '✓'; break;
|
||
case 'error': statusText = '错误'; statusIcon = '⚠'; break;
|
||
case 'unknown': statusText = '未知'; statusIcon = '?'; break;
|
||
default: statusText = '失败'; statusIcon = '✗';
|
||
}
|
||
const tagText = tags.length ? ' · ' + tags.map(k => INDICATOR_LABELS[k].text).join('/') : '';
|
||
const tagBadges = tags.length
|
||
? `<span class="trial-indicator-tags">${tags.map(k => INDICATOR_LABELS[k].icon).join('')}</span>`
|
||
: '';
|
||
return `<div class="trial-indicator ${status} ${isActive ? 'active' : ''}"
|
||
data-dir="${t.dirName}"
|
||
title="Trial ${idx}: ${statusText}${tagText}">
|
||
${statusIcon}${tagBadges}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const r0 = getResultForTask(visibleTrials[0]);
|
||
const diff0 = r0?.difficulty;
|
||
const diffColorMap0 = { L1: '#10b981', L2: '#3b82f6', L3: '#f59e0b', L4: '#ef4444' };
|
||
const diffBadge0 = diff0 ? `<span style="padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; background: ${diffColorMap0[diff0] || '#64748b'}20; color: ${diffColorMap0[diff0] || '#64748b'}; flex-shrink: 0;">${diff0}</span>` : '';
|
||
|
||
return `
|
||
<div class="task-group ${isAnyActive ? 'active' : ''}" data-base-id="${baseTaskId}">
|
||
<div class="task-group-header" data-dir="${visibleTrials[0].dirName}">
|
||
<div class="task-header">
|
||
<div class="status-badge ${successCount === totalTrials ? 'success' : successCount > 0 ? 'partial' : 'failed'}"></div>
|
||
<div class="task-title" title="${taskName}">${taskName}</div>
|
||
${diffBadge0}
|
||
</div>
|
||
<div class="task-meta">
|
||
<span title="任务成功数 (success && clean && finished)" style="color: var(--success);">✓ ${successCount}/${totalTrials}</span>
|
||
${hasPassedButUnaware ? `<span title="目标达成数 (judge.success=true)" style="color: var(--warning);">🎯 ${passedCount}/${totalTrials}</span>` : ''}
|
||
</div>
|
||
</div>
|
||
<div class="trial-indicators">
|
||
${trialIndicators}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
html += `<div class="task-count">共 ${filteredGroups.length} 个任务,${visibleTrialCount} 次运行</div>`;
|
||
} else {
|
||
// Simple view - single trial per task
|
||
const filtered = state.tasks.filter(t => {
|
||
// Taxonomy filter
|
||
if (!taskPassesTaxonomyFilters(t.baseTaskId || t.task_id)) return false;
|
||
// Text filter
|
||
const text = `${t.task_id} ${t.task_name} ${t.dirName}`.toLowerCase();
|
||
if (filter && !text.includes(filter)) return false;
|
||
return trialPasses(t);
|
||
});
|
||
|
||
if (filtered.length === 0) {
|
||
wrap.innerHTML = `
|
||
<div class="empty">
|
||
<div class="empty-icon">🔍</div>
|
||
<h3>未找到任务</h3>
|
||
<p>尝试调整筛选条件</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
html = filtered.map(t => {
|
||
const status = getTaskStatusForTrial(t);
|
||
const r = getResultForTask(t);
|
||
const steps = r?.execution?.steps ?? '-';
|
||
const runtime = r?.execution?.runtime_s?.toFixed(1) ?? '-';
|
||
const isActive = state.selectedTask?.dirName === t.dirName;
|
||
const diff = r?.difficulty;
|
||
const diffColorMap = { L1: '#10b981', L2: '#3b82f6', L3: '#f59e0b', L4: '#ef4444' };
|
||
const diffBadge = diff ? `<span style="padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; background: ${diffColorMap[diff] || '#64748b'}20; color: ${diffColorMap[diff] || '#64748b'};">${diff}</span>` : '';
|
||
|
||
return `
|
||
<div class="task-item ${isActive ? 'active' : ''}" data-dir="${t.dirName}">
|
||
<div class="task-header">
|
||
<div class="status-badge ${status}"></div>
|
||
<div class="task-title" title="${t.task_name || t.task_id}">${t.task_name || t.task_id}</div>
|
||
${diffBadge}
|
||
</div>
|
||
<div class="task-meta">
|
||
<span>${steps} 步</span>
|
||
<span>${runtime}s</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
html += `<div class="task-count">共 ${filtered.length} 个任务</div>`;
|
||
}
|
||
|
||
wrap.innerHTML = html;
|
||
|
||
// Bind click for task items (simple view)
|
||
wrap.querySelectorAll('.task-item').forEach(item => {
|
||
item.onclick = function() {
|
||
const dirName = this.dataset.dir;
|
||
console.log('task-item clicked:', dirName);
|
||
const task = state.tasks.find(t => t.dirName === dirName);
|
||
if (task) selectTask(task);
|
||
};
|
||
});
|
||
|
||
// Bind click for task group headers (grouped view)
|
||
wrap.querySelectorAll('.task-group-header').forEach(header => {
|
||
header.onclick = function() {
|
||
const dirName = this.dataset.dir;
|
||
console.log('task-group-header clicked:', dirName);
|
||
const task = state.tasks.find(t => t.dirName === dirName);
|
||
if (task) selectTask(task);
|
||
};
|
||
});
|
||
|
||
// Bind click for trial indicators
|
||
wrap.querySelectorAll('.trial-indicator').forEach(indicator => {
|
||
indicator.onclick = function(e) {
|
||
e.stopPropagation();
|
||
const dirName = this.dataset.dir;
|
||
console.log('trial-indicator clicked:', dirName);
|
||
const task = state.tasks.find(t => t.dirName === dirName);
|
||
if (task) selectTask(task);
|
||
};
|
||
});
|
||
}
|
||
|
||
// 辅助函数:判断是否为真正的成功
|
||
// 优先使用后端 is_success(已包含 ABORT 排除逻辑),回退到前端计算
|
||
function isRealSuccess(r) {
|
||
if (!r) return false;
|
||
if (r.is_success !== undefined) return r.is_success === true;
|
||
return r.judge?.success === true && r.judge?.clean === true
|
||
&& r.execution?.stop_reason === 'COMPLETE';
|
||
}
|
||
|
||
// Get task primary outcome for a specific trial.
|
||
// 三类互斥:success(完美成功)/ error(系统/判定异常)/ failed(其余一切,含副作用、超步数、错误完成、ABORT 等)。
|
||
// 副作用、超步数、错误完成 等正交细节由 getTaskIndicators 返回。
|
||
function getTaskStatusForTrial(task) {
|
||
const r = getResultForTask(task);
|
||
if (!r) return 'unknown';
|
||
|
||
const goalSuccess = r.judge?.success === true;
|
||
const clean = r.judge?.clean === true;
|
||
const isCompleted = r.execution?.stop_reason === 'COMPLETE';
|
||
const isAbort = r.execution?.stop_reason === 'ABORT';
|
||
const hasSysError = r.execution?.error || r.is_error || r.judge?.judge_error
|
||
|| r.judge?.issues?.some(i => 'error' in i);
|
||
|
||
if (!isAbort && goalSuccess && clean && isCompleted) return 'success';
|
||
if (hasSysError) return 'error';
|
||
return 'failed';
|
||
}
|
||
|
||
// 任务的正交问题指标,与 overview 三指标 (FC / OT / USE) 对齐(论文 §3.5)。
|
||
function getTaskIndicators(task) {
|
||
const r = getResultForTask(task);
|
||
if (!r) return [];
|
||
const tags = [];
|
||
const hasSysError = r.execution?.error || r.is_error || r.judge?.judge_error
|
||
|| r.judge?.issues?.some(i => 'error' in i);
|
||
if (r.judge?.clean === false) tags.push('sideEffect');
|
||
// OT: 重算 (truncated && goal_success),避免老 run 中 overdue_termination 字段语义漂移。
|
||
const goalSuccess = r.judge?.success === true;
|
||
if (r.execution?.truncated === true && goalSuccess) tags.push('overdue');
|
||
// FC: stop_reason=COMPLETE 且 episode 未 fully successful。
|
||
// 新字段 false_complete 存在时优先使用;历史 run 没有该字段时按 raw fields 重算。
|
||
const realSuccess = isRealSuccess(r);
|
||
const fcFromRaw = r.execution?.stop_reason === 'COMPLETE' && !realSuccess;
|
||
const fcFlag = r.false_complete === undefined ? fcFromRaw : r.false_complete === true;
|
||
if (!hasSysError && fcFlag) {
|
||
tags.push('falseComplete');
|
||
}
|
||
return tags;
|
||
}
|
||
|
||
const INDICATOR_LABELS = {
|
||
sideEffect: { icon: '🔧', text: '副作用' },
|
||
overdue: { icon: '⏱', text: '超步数' },
|
||
falseComplete: { icon: '🚫', text: '错误完成' },
|
||
};
|
||
|
||
// Get result for a specific task/trial
|
||
function getResultForTask(task) {
|
||
// Try to find by task_id + trial_id first
|
||
const trialKey = `${task.baseTaskId || task.task_id}_trial_${task.trialId ?? 0}`;
|
||
let r = state.results.get(trialKey);
|
||
if (r) return r;
|
||
|
||
// Normalize task_id: convert wechat_OpenNewFriends_i0 to wechat.OpenNewFriends_i0
|
||
// and vice versa for matching
|
||
const normalizeId = (id) => {
|
||
if (!id) return id;
|
||
// Pattern: app_TaskName_i0 -> app.TaskName_i0
|
||
const match = id.match(/^([a-z]+)_([A-Z].*)$/);
|
||
if (match) {
|
||
return `${match[1]}.${match[2]}`;
|
||
}
|
||
return id;
|
||
};
|
||
|
||
const taskIdNormalized = normalizeId(task.task_id);
|
||
const baseTaskIdNormalized = normalizeId(task.baseTaskId);
|
||
|
||
// Fallback to looking through allResults with normalized IDs
|
||
r = state.allResults.find(res => {
|
||
const resIdNormalized = normalizeId(res.id);
|
||
const resTaskIdNormalized = normalizeId(res.task_id);
|
||
|
||
const idMatches =
|
||
res.task_id === task.baseTaskId ||
|
||
res.task_id === task.task_id ||
|
||
res.id === task.task_id ||
|
||
resIdNormalized === taskIdNormalized ||
|
||
resIdNormalized === baseTaskIdNormalized ||
|
||
resTaskIdNormalized === taskIdNormalized ||
|
||
resTaskIdNormalized === baseTaskIdNormalized;
|
||
|
||
const trialMatches =
|
||
res.trial_id === task.trialId ||
|
||
(res.trial_id === undefined && task.trialId === 0) ||
|
||
(res.trial_id === 0 && task.trialId === undefined);
|
||
|
||
return idMatches && trialMatches;
|
||
});
|
||
|
||
if (r) return r;
|
||
|
||
// Final fallback
|
||
return state.results.get(task.task_id) || state.results.get(task.baseTaskId);
|
||
}
|
||
|
||
function renderDetail() {
|
||
if (!state.selectedTask) {
|
||
el('detailEmpty').style.display = 'block';
|
||
el('detailContent').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
el('detailEmpty').style.display = 'none';
|
||
el('detailContent').style.display = 'block';
|
||
|
||
const task = state.selectedTask;
|
||
const r = getResultForTask(task);
|
||
const isSuccess = isRealSuccess(r);
|
||
const sum = state.summary || {};
|
||
const meta = state.meta || {};
|
||
const taskPassK = sum.per_task_pass_k?.[task.baseTaskId || task.task_id];
|
||
const repeatN = sum.repeat_n ?? meta.repeat_n ?? 1;
|
||
|
||
const trialLabel = repeatN > 1 ? ` (Trial ${task.trialId ?? 0})` : '';
|
||
|
||
// 检查是否是"达成但未意识"的情况(success && clean && !finished)
|
||
const isPassedButUnaware = r?.judge?.success === true && r?.judge?.clean === true && r?.execution?.finished === false;
|
||
|
||
let html = `
|
||
<div class="card">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;">
|
||
<div>
|
||
<h2 style="font-size: 20px; font-weight: 700; margin-bottom: 4px;">
|
||
${task.task_name || task.task_id}${trialLabel}
|
||
</h2>
|
||
<p style="font-size: 13px; color: var(--text-muted); font-family: var(--mono);">
|
||
${task.baseTaskId || task.task_id}
|
||
${repeatN > 1 ? `<span style="color: var(--primary);"> • Trial ${task.trialId ?? 0}</span>` : ''}
|
||
</p>
|
||
</div>
|
||
<span class="badge ${isSuccess ? 'success' : isPassedButUnaware ? '' : 'failed'}" style="font-size: 14px; padding: 8px 18px; ${isPassedButUnaware ? 'background: var(--warning-light); color: var(--warning);' : ''}">
|
||
${isSuccess ? '✓ 成功' : isPassedButUnaware ? '! 达成但未意识' : '✗ 失败'}
|
||
</span>
|
||
</div>
|
||
${isPassedButUnaware ? `
|
||
<div style="margin-top: 12px; padding: 12px 16px; background: var(--warning-light); border: 1px solid var(--warning); border-radius: var(--radius-sm);">
|
||
<div style="display: flex; align-items: center; gap: 8px; color: var(--warning); font-weight: 600; margin-bottom: 4px;">
|
||
<span>⚠️</span> 任务目标已达成,但模型未意识到完成
|
||
</div>
|
||
<div style="font-size: 13px; color: var(--text-secondary);">
|
||
judge.success=true 且 judge.clean=true 表示任务目标已达成且无副作用,但 stop_reason=${r?.execution?.stop_reason || 'MAX_STEPS'}(非 COMPLETE),表示模型没有主动以 COMPLETE 结束任务。
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
|
||
// Pass@K for this task
|
||
if (taskPassK) {
|
||
const passKKeys = Object.keys(taskPassK).filter(k => k.startsWith('pass@')).sort((a, b) => {
|
||
return parseInt(a.split('@')[1]) - parseInt(b.split('@')[1]);
|
||
});
|
||
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon purple">🎯</div>
|
||
任务 Pass@K 统计
|
||
</div>
|
||
<div class="info-grid">
|
||
<div class="info-item">
|
||
<div class="info-label">试验次数</div>
|
||
<div class="info-value">${taskPassK.trials}</div>
|
||
</div>
|
||
<div class="info-item">
|
||
<div class="info-label">成功次数</div>
|
||
<div class="info-value">${taskPassK.successes}</div>
|
||
</div>
|
||
<div class="info-item">
|
||
<div class="info-label">成功率</div>
|
||
<div class="info-value">${(taskPassK.successes / taskPassK.trials * 100).toFixed(1)}%</div>
|
||
</div>
|
||
</div>
|
||
<div class="pass-k-grid" style="margin-top: 16px;">
|
||
${passKKeys.map(k => {
|
||
const val = (taskPassK[k] * 100).toFixed(1);
|
||
return `
|
||
<div class="pass-k-item">
|
||
<div class="pass-k-label">${k}</div>
|
||
<div class="pass-k-value">${val}%</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Show individual trial results - get from task groups for trajectory access
|
||
const taskGroup = state.taskGroups.get(task.baseTaskId || task.task_id) || [];
|
||
const baseTaskId = task.baseTaskId || task.task_id;
|
||
|
||
if (taskGroup.length > 1) {
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon green">📊</div>
|
||
分 Trial 结果 (${taskGroup.length} 次运行)
|
||
</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; font-size: 11px;">
|
||
<span style="display: flex; align-items: center; gap: 4px;">
|
||
<span style="width: 10px; height: 10px; background: var(--success); border-radius: 2px;"></span>
|
||
完美成功
|
||
</span>
|
||
<span style="display: flex; align-items: center; gap: 4px;">
|
||
<span style="width: 10px; height: 10px; background: var(--error); border-radius: 2px;"></span>
|
||
失败
|
||
</span>
|
||
<span style="display: flex; align-items: center; gap: 4px;">
|
||
<span style="width: 10px; height: 10px; background: var(--warning); border-radius: 2px;"></span>
|
||
错误
|
||
</span>
|
||
<span style="color: var(--text-muted);">指标:🔧 副作用 / ⏱ 超步数 / 🚫 错误完成</span>
|
||
</div>
|
||
<div class="trial-grid">
|
||
${taskGroup.map((t, idx) => {
|
||
const trialResult = getResultForTask(t);
|
||
const status = getTaskStatusForTrial(t);
|
||
const tags = getTaskIndicators(t);
|
||
const isCurrentTrial = task.dirName === t.dirName;
|
||
|
||
let statusEmoji, statusText;
|
||
switch (status) {
|
||
case 'success': statusEmoji = '✅'; statusText = '完美成功'; break;
|
||
case 'error': statusEmoji = '⚠️'; statusText = '错误'; break;
|
||
case 'unknown': statusEmoji = '?'; statusText = '未知'; break;
|
||
default: statusEmoji = '❌'; statusText = '失败';
|
||
}
|
||
const tagSuffix = tags.length ? ' · ' + tags.map(k => INDICATOR_LABELS[k].text).join('/') : '';
|
||
const tagIcons = tags.map(k => INDICATOR_LABELS[k].icon).join('');
|
||
|
||
return `
|
||
<div class="trial-item ${status} ${isCurrentTrial ? 'current' : ''}"
|
||
onclick="selectTrial('${baseTaskId}', ${idx})"
|
||
title="Trial ${idx}: ${statusText}${tagSuffix} 步数: ${trialResult?.execution?.steps ?? '-'} 耗时: ${trialResult?.execution?.runtime_s?.toFixed(1) ?? '-'}s${isCurrentTrial ? ' (当前查看)' : ''}">
|
||
<div class="trial-num">Trial ${idx}</div>
|
||
<div class="trial-status">${statusEmoji}${tagIcons ? `<span style="font-size: 10px; margin-left: 2px;">${tagIcons}</span>` : ''}</div>
|
||
${isCurrentTrial ? '<div style="font-size: 10px; color: var(--primary);">当前</div>' : ''}
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
<div style="margin-top: 16px; font-size: 12px; color: var(--text-muted);">
|
||
💡 点击查看具体 Trial 的轨迹和详细信息
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Trial statistics summary - use results from resultsByTask
|
||
const trialResults = getTrialsForTask(baseTaskId);
|
||
if (trialResults.length > 0) {
|
||
const successTrials = trialResults.filter(t => isRealSuccess(t));
|
||
const goalSuccessTrials = trialResults.filter(t => t.judge?.success === true);
|
||
const passedButUnawareTrials = goalSuccessTrials.filter(t => t.judge?.clean === true && t.execution?.finished !== true);
|
||
const sideEffectTrials = goalSuccessTrials.filter(t => t.judge?.clean !== true && t.execution?.finished === true);
|
||
const doubleIssueTrials = goalSuccessTrials.filter(t => t.judge?.clean !== true && t.execution?.finished !== true);
|
||
const avgSteps = trialResults.reduce((sum, t) => sum + (t.execution?.steps || 0), 0) / trialResults.length;
|
||
const avgTime = trialResults.reduce((sum, t) => sum + (t.execution?.runtime_s || 0), 0) / trialResults.length;
|
||
|
||
// Calculate step variance
|
||
const stepsArr = trialResults.map(t => t.execution?.steps || 0);
|
||
const minSteps = Math.min(...stepsArr);
|
||
const maxSteps = Math.max(...stepsArr);
|
||
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon blue">📈</div>
|
||
Trial 统计汇总
|
||
</div>
|
||
<div class="info-grid">
|
||
<div class="info-item">
|
||
<div class="info-label" style="color: var(--success);">✓ 任务成功</div>
|
||
<div class="info-value" style="color: var(--success);">${successTrials.length}</div>
|
||
</div>
|
||
<div class="info-item">
|
||
<div class="info-label" style="color: #60a5fa;">🎯 目标达成</div>
|
||
<div class="info-value" style="color: #60a5fa;">${goalSuccessTrials.length}</div>
|
||
</div>
|
||
${passedButUnawareTrials.length > 0 ? `
|
||
<div class="info-item">
|
||
<div class="info-label" style="color: var(--warning);">⚠️ 未意识</div>
|
||
<div class="info-value" style="color: var(--warning);">${passedButUnawareTrials.length}</div>
|
||
</div>
|
||
` : ''}
|
||
${sideEffectTrials.length > 0 ? `
|
||
<div class="info-item">
|
||
<div class="info-label" style="color: #f97316;">🔧 有副作用</div>
|
||
<div class="info-value" style="color: #f97316;">${sideEffectTrials.length}</div>
|
||
</div>
|
||
` : ''}
|
||
${doubleIssueTrials.length > 0 ? `
|
||
<div class="info-item">
|
||
<div class="info-label" style="color: #c2410c;">⚠🔧 双重问题</div>
|
||
<div class="info-value" style="color: #c2410c;">${doubleIssueTrials.length}</div>
|
||
</div>
|
||
` : ''}
|
||
<div class="info-item">
|
||
<div class="info-label">失败 Trial</div>
|
||
<div class="info-value" style="color: var(--error);">${trialResults.filter(t => !t.judge?.success).length}</div>
|
||
</div>
|
||
<div class="info-item">
|
||
<div class="info-label">平均步数</div>
|
||
<div class="info-value">${avgSteps.toFixed(1)}</div>
|
||
</div>
|
||
<div class="info-item">
|
||
<div class="info-label">步数范围</div>
|
||
<div class="info-value">${minSteps} - ${maxSteps}</div>
|
||
</div>
|
||
<div class="info-item">
|
||
<div class="info-label">平均耗时</div>
|
||
<div class="info-value">${avgTime.toFixed(1)}s</div>
|
||
</div>
|
||
</div>
|
||
${successTrials.length > 0 ? `
|
||
<div style="margin-top: 16px;">
|
||
<div class="info-label" style="margin-bottom: 8px; color: var(--success);">✓ 成功 Trial 详情</div>
|
||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||
${successTrials.map((t, i) => `
|
||
<span class="badge success" title="Trial ${t.trial_id ?? i}">
|
||
T${t.trial_id ?? i}: ${t.execution?.steps ?? '-'} 步
|
||
</span>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
${passedButUnawareTrials.length > 0 ? `
|
||
<div style="margin-top: 16px;">
|
||
<div class="info-label" style="margin-bottom: 8px; color: var(--warning);">⚠️ 未意识 Trial 详情</div>
|
||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||
${passedButUnawareTrials.map((t, i) => `
|
||
<span class="badge" style="background: var(--warning-light); color: var(--warning);" title="Trial ${t.trial_id ?? i}: 目标达成但未主动终止 (${t.execution?.stop_reason || 'MAX_STEPS'})">
|
||
T${t.trial_id ?? i}: ${t.execution?.steps ?? '-'} 步
|
||
</span>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
${sideEffectTrials.length > 0 ? `
|
||
<div style="margin-top: 16px;">
|
||
<div class="info-label" style="margin-bottom: 8px; color: #f97316;">🔧 有副作用 Trial 详情</div>
|
||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||
${sideEffectTrials.map((t, i) => `
|
||
<span class="badge" style="background: #fff7ed; color: #f97316;" title="Trial ${t.trial_id ?? i}: 目标达成但有副作用 (warnings: ${t.judge?.warnings?.length ?? 0})">
|
||
T${t.trial_id ?? i}: ${t.execution?.steps ?? '-'} 步
|
||
</span>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
${doubleIssueTrials.length > 0 ? `
|
||
<div style="margin-top: 16px;">
|
||
<div class="info-label" style="margin-bottom: 8px; color: #c2410c;">⚠🔧 双重问题 Trial 详情</div>
|
||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||
${doubleIssueTrials.map((t, i) => `
|
||
<span class="badge" style="background: #fed7aa; color: #c2410c;" title="Trial ${t.trial_id ?? i}: 有副作用且未主动终止">
|
||
T${t.trial_id ?? i}: ${t.execution?.steps ?? '-'} 步
|
||
</span>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
${trialResults.filter(t => !t.judge?.success).length > 0 ? `
|
||
<div style="margin-top: 16px;">
|
||
<div class="info-label" style="margin-bottom: 8px;">✗ 失败 Trial 详情</div>
|
||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||
${trialResults.filter(t => !t.judge?.success).map((t, i) => `
|
||
<span class="badge failed" title="Trial ${t.trial_id ?? i}: 目标未达成 (${t.execution?.stop_reason || ''})">
|
||
T${t.trial_id ?? i}: ${t.execution?.steps ?? '-'} 步
|
||
</span>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
}
|
||
|
||
if (r) {
|
||
const ex = r.execution || {};
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon blue">📋</div>
|
||
执行信息 ${taskPassK ? '(当前选中的 Trial)' : ''}
|
||
</div>
|
||
<div class="info-grid">
|
||
${[
|
||
['任务ID', r.id],
|
||
['Suite', r.suite],
|
||
['涉及App', r.apps?.join(', ')],
|
||
['Trial ID', r.trial_id ?? '-'],
|
||
['步数', ex.steps],
|
||
['完成状态', ex.finished ? '✓ 已完成' : '✗ 未完成'],
|
||
['截断', ex.truncated ? '是' : '否'],
|
||
['停止原因', ex.stop_reason],
|
||
['提交答案', ex.agent_answer ? '✓ 已提交' : null],
|
||
['运行时间', ex.runtime_s?.toFixed(2) + 's'],
|
||
['False Complete', (() => {
|
||
const hasSysError = r.execution?.error || r.is_error || r.judge?.judge_error
|
||
|| r.judge?.issues?.some(i => 'error' in i);
|
||
// 新字段优先;缺失时按 raw fields 重算(论文 §3.5)。
|
||
const fcFromRaw = ex.stop_reason === 'COMPLETE' && !isRealSuccess(r) && !hasSysError;
|
||
const fc = r.false_complete === undefined ? fcFromRaw : r.false_complete === true;
|
||
return fc ? '⚠ 是(Agent 声称完成但未 fully successful)' : null;
|
||
})()],
|
||
['Overdue Termination', (r.execution?.truncated === true && r.judge?.success === true)
|
||
? '⚠ 是(达成目标却被 step budget / loop 截断)'
|
||
: null],
|
||
['开始时间', formatTime(r.start_time)],
|
||
['结束时间', formatTime(r.end_time)],
|
||
].filter(([k, v]) => v != null && v !== '').map(([k, v]) => `
|
||
<div class="info-item">
|
||
<div class="info-label">${k}</div>
|
||
<div class="info-value">${v ?? '-'}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Task Taxonomy Card
|
||
if (r.difficulty || r.scope || r.objective || r.composition || r.capabilities?.length) {
|
||
const diffColors = { L1: '#10b981', L2: '#3b82f6', L3: '#f59e0b', L4: '#ef4444' };
|
||
const scopeColors = { S1: '#10b981', S2: '#3b82f6', S3: '#8b5cf6' };
|
||
const objColors = { operate: '#3b82f6', query: '#10b981', hybrid: '#f59e0b', vague: '#94a3b8', safety: '#ef4444' };
|
||
const compColors = { atomic: '#94a3b8', sequential: '#3b82f6', transfer: '#10b981', deep_dive: '#8b5cf6' };
|
||
const capColors = { nav: '#6366f1', search: '#0ea5e9', query: '#10b981', reasoning: '#f59e0b', create: '#8b5cf6', transfer: '#ec4899', social: '#f97316', finance: '#14b8a6' };
|
||
|
||
const makeBadge = (label, value, colorMap) => {
|
||
const color = colorMap?.[value] || '#64748b';
|
||
return `<span style="display: inline-flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: 6px; font-size: 12px; font-weight: 600; background: ${color}18; color: ${color}; border: 1px solid ${color}40;">${label ? `<span style="font-weight: 400; opacity: 0.7;">${label}</span> ` : ''}${value}</span>`;
|
||
};
|
||
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon purple">🏷️</div>
|
||
任务分类
|
||
</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">
|
||
${r.difficulty ? makeBadge('难度', r.difficulty, diffColors) : ''}
|
||
${r.scope ? makeBadge('范围', r.scope, scopeColors) : ''}
|
||
${r.objective ? makeBadge('目标', r.objective, objColors) : ''}
|
||
${r.composition ? makeBadge('组合', r.composition, compColors) : ''}
|
||
</div>
|
||
${r.capabilities?.length ? `
|
||
<div style="margin-top: 12px;">
|
||
<div class="info-label" style="margin-bottom: 6px;">所需能力</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 6px;">
|
||
${r.capabilities.map(c => makeBadge('', c, capColors)).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (ex.agent_answer || ex.agent_message) {
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon purple">💬</div>
|
||
Agent 输出
|
||
</div>
|
||
${ex.agent_answer ? `
|
||
<div style="margin-bottom: 12px;">
|
||
<div class="info-label">📝 提交的答案 (ANSWER)</div>
|
||
<div class="step-thought" style="border-left: 3px solid var(--primary); padding-left: 12px;">${escapeHtml(ex.agent_answer)}</div>
|
||
</div>
|
||
` : ''}
|
||
${ex.agent_message ? `
|
||
<div>
|
||
<div class="info-label">💬 终止说明 (COMPLETE/ABORT)</div>
|
||
<div class="step-thought">${escapeHtml(ex.agent_message)}</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Judge
|
||
if (r.judge) {
|
||
const j = r.judge;
|
||
const isGoalSuccess = j.success === true;
|
||
const isClean = j.clean === true;
|
||
const isCompleted = r.execution?.stop_reason === 'COMPLETE';
|
||
const isTaskSuccess = isRealSuccess(r); // 与顶部徽章同源,后端 is_success 为准
|
||
|
||
// 判断失败原因类别
|
||
let failureCategory = null;
|
||
if (isGoalSuccess && !isTaskSuccess) {
|
||
if (isClean && !isCompleted) failureCategory = 'unaware'; // 未意识
|
||
else if (!isClean && isCompleted) failureCategory = 'sideEffect'; // 有副作用
|
||
else if (!isClean && !isCompleted) failureCategory = 'double'; // 双重问题
|
||
}
|
||
|
||
html += `
|
||
<div class="card">
|
||
<div class="card-title">
|
||
<div class="icon green">⚖️</div>
|
||
评判结果
|
||
</div>
|
||
|
||
<!-- 最终结果 -->
|
||
<div style="padding: 16px; border-radius: var(--radius-sm); text-align: center; margin-bottom: 20px; ${isTaskSuccess ? 'background: var(--success-light); border: 2px solid var(--success);' : 'background: var(--error-light); border: 2px solid var(--error);'}">
|
||
<div style="font-size: 28px; font-weight: 800; color: ${isTaskSuccess ? 'var(--success)' : 'var(--error)'};">${isTaskSuccess ? '✓ 任务成功' : '✗ 任务失败'}</div>
|
||
<div style="font-size: 12px; color: var(--text-muted); margin-top: 4px;">is_success = stop_reason==COMPLETE AND judge.success AND judge.clean</div>
|
||
</div>
|
||
|
||
<!-- 三个独立条件 -->
|
||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px;">
|
||
<div style="padding: 12px; border-radius: var(--radius-sm); text-align: center; ${isGoalSuccess ? 'background: #dbeafe; border: 2px solid #60a5fa;' : 'background: var(--error-light); border: 2px solid var(--error);'}">
|
||
<div style="font-size: 20px; font-weight: 800; color: ${isGoalSuccess ? '#60a5fa' : 'var(--error)'};">${isGoalSuccess ? '✓' : '✗'}</div>
|
||
<div style="font-size: 12px; font-weight: 600; margin-top: 4px;">目标达成</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">judge.success</div>
|
||
</div>
|
||
<div style="padding: 12px; border-radius: var(--radius-sm); text-align: center; ${isClean ? 'background: #fef3c7; border: 2px solid #f59e0b;' : 'background: #fff7ed; border: 2px solid #f97316;'}">
|
||
<div style="font-size: 20px; font-weight: 800; color: ${isClean ? '#f59e0b' : '#f97316'};">${isClean ? '✓' : '✗'}</div>
|
||
<div style="font-size: 12px; font-weight: 600; margin-top: 4px;">无副作用</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">judge.clean</div>
|
||
</div>
|
||
<div style="padding: 12px; border-radius: var(--radius-sm); text-align: center; ${isCompleted ? 'background: var(--success-light); border: 2px solid var(--success);' : 'background: var(--warning-light); border: 2px solid var(--warning);'}">
|
||
<div style="font-size: 20px; font-weight: 800; color: ${isCompleted ? 'var(--success)' : 'var(--warning)'};">${isCompleted ? '✓' : '✗'}</div>
|
||
<div style="font-size: 12px; font-weight: 600; margin-top: 4px;">主动完成</div>
|
||
<div style="font-size: 10px; color: var(--text-muted);">stop_reason === COMPLETE</div>
|
||
</div>
|
||
</div>
|
||
|
||
${j.progress !== undefined && j.progress < 1.0 ? `
|
||
<div style="margin-bottom: 20px;">
|
||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 6px;">
|
||
<span style="font-size: 13px; font-weight: 600; color: var(--text-secondary);">目标完成进度</span>
|
||
<span style="font-size: 16px; font-weight: 800; color: ${j.progress >= 0.8 ? 'var(--success)' : j.progress >= 0.5 ? 'var(--warning)' : 'var(--error)'};">${(j.progress * 100).toFixed(0)}%</span>
|
||
</div>
|
||
<div style="height: 8px; background: var(--bg); border-radius: 4px; overflow: hidden;">
|
||
<div style="width: ${j.progress * 100}%; height: 100%; background: ${j.progress >= 0.8 ? 'var(--success)' : j.progress >= 0.5 ? 'var(--warning)' : 'var(--error)'}; border-radius: 4px; transition: width 0.3s;"></div>
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
${failureCategory === 'unaware' ? `
|
||
<div style="padding: 12px 16px; background: var(--warning-light); border: 1px solid var(--warning); border-radius: var(--radius-sm); margin-bottom: 20px;">
|
||
<div style="display: flex; align-items: center; gap: 8px; color: var(--warning); font-weight: 600;">
|
||
<span>⚠️</span> 达成但未意识
|
||
</div>
|
||
<div style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">
|
||
目标已达成且无副作用,但模型没有意识到完成,未主动调用 COMPLETE 终止任务。
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
${failureCategory === 'sideEffect' ? `
|
||
<div style="padding: 12px 16px; background: #fff7ed; border: 1px solid #f97316; border-radius: var(--radius-sm); margin-bottom: 20px;">
|
||
<div style="display: flex; align-items: center; gap: 8px; color: #f97316; font-weight: 600;">
|
||
<span>🔧</span> 达成但有副作用
|
||
</div>
|
||
<div style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">
|
||
目标已达成且主动终止,但执行过程中产生了意外的状态变化(见下方 warnings)。
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
${failureCategory === 'double' ? `
|
||
<div style="padding: 12px 16px; background: #fed7aa; border: 1px solid #c2410c; border-radius: var(--radius-sm); margin-bottom: 20px;">
|
||
<div style="display: flex; align-items: center; gap: 8px; color: #c2410c; font-weight: 600;">
|
||
<span>⚠️🔧</span> 双重问题
|
||
</div>
|
||
<div style="font-size: 12px; color: var(--text-secondary); margin-top: 4px;">
|
||
目标已达成,但既有副作用又未主动终止。
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
`;
|
||
|
||
if (j.issues?.length) {
|
||
html += '<h4 style="font-size: 14px; margin: 20px 0 12px;">检查项</h4>';
|
||
j.issues.forEach(issue => {
|
||
if (issue.field !== undefined) {
|
||
html += `
|
||
<div class="judge-item ${issue.passed ? 'passed' : 'failed'}">
|
||
<div class="judge-field">${escapeHtml(issue.field)}</div>
|
||
<div class="judge-values">
|
||
<div><span>期望:</span> ${formatValue(issue.expected)}</div>
|
||
<div><span>实际:</span> ${formatValue(issue.actual)}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (issue.reason) {
|
||
html += `
|
||
<div class="judge-item failed">
|
||
<div class="judge-field" style="color: var(--error);">原因</div>
|
||
<div class="judge-values">
|
||
<div>${escapeHtml(String(issue.reason))}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else if (issue.error) {
|
||
html += `
|
||
<div class="judge-item failed" style="border-left-color: var(--warning);">
|
||
<div class="judge-field" style="color: var(--warning);">错误</div>
|
||
<div class="judge-values">
|
||
<div>${escapeHtml(String(issue.error))}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
html += `
|
||
<div class="judge-item failed">
|
||
<div class="judge-values">
|
||
<div>${formatValue(issue)}</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
});
|
||
}
|
||
html += '</div>';
|
||
|
||
if (j.warnings?.length) {
|
||
html += `
|
||
<div class="card warnings-card">
|
||
<div class="card-title">⚠️ 警告 (${j.warnings.length})</div>
|
||
${j.warnings.map(w => `
|
||
<div class="warning-item">${w.field}: ${formatValue(w.before)} → ${formatValue(w.after)}</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
html += `
|
||
<details>
|
||
<summary>查看完整 result JSON</summary>
|
||
<div class="content">
|
||
<div class="code">${JSON.stringify(r, null, 2)}</div>
|
||
</div>
|
||
</details>
|
||
`;
|
||
} else {
|
||
html += `
|
||
<div class="card">
|
||
<p style="color: var(--text-muted); text-align: center; padding: 20px;">
|
||
results.jsonl 中未找到该任务的结果
|
||
</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
el('detailContent').innerHTML = html;
|
||
}
|
||
|
||
function renderTrajectory() {
|
||
if (!state.selectedTask) {
|
||
el('trajectoryEmpty').style.display = 'block';
|
||
el('trajectoryContent').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
el('trajectoryEmpty').style.display = 'none';
|
||
el('trajectoryContent').style.display = 'block';
|
||
|
||
if (state.trajectory.length === 0) {
|
||
el('trajectoryContent').innerHTML = `
|
||
<div class="empty">
|
||
<div class="empty-icon">🎯</div>
|
||
<h3>无轨迹数据</h3>
|
||
<p>该任务没有轨迹记录</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Calculate action type distribution for this task
|
||
const taskActionTypes = {};
|
||
for (const step of state.trajectory) {
|
||
const actionType = step.action_type || 'UNKNOWN';
|
||
taskActionTypes[actionType] = (taskActionTypes[actionType] || 0) + 1;
|
||
}
|
||
const totalTaskActions = Object.values(taskActionTypes).reduce((a, b) => a + b, 0);
|
||
const sortedTaskActions = Object.entries(taskActionTypes).sort((a, b) => b[1] - a[1]);
|
||
|
||
let html = `
|
||
<div class="card" style="margin-bottom: 20px;">
|
||
<div class="card-title">
|
||
<div class="icon purple">🎬</div>
|
||
本任务动作分布 (${totalTaskActions} 步)
|
||
</div>
|
||
<div style="display: flex; flex-wrap: wrap; gap: 10px;">
|
||
${sortedTaskActions.map(([action, count]) => {
|
||
const pct = (count / totalTaskActions * 100).toFixed(0);
|
||
return `
|
||
<div style="background: var(--bg); padding: 8px 14px; border-radius: 20px; font-size: 12px;">
|
||
<strong>${action}</strong>: ${count} (${pct}%)
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
// Check if this is a multi-trial run
|
||
const sum = state.summary || {};
|
||
const meta = state.meta || {};
|
||
const repeatN = sum.repeat_n ?? meta.repeat_n ?? 1;
|
||
const trialInfo = repeatN > 1 ? ` - Trial ${state.selectedTask.trialId ?? 0}` : '';
|
||
const taskResult = getResultForTask(state.selectedTask);
|
||
|
||
// 计算详细状态
|
||
const goalSuccess = taskResult?.judge?.success === true;
|
||
const clean = taskResult?.judge?.clean === true;
|
||
const finished = taskResult?.execution?.finished === true;
|
||
|
||
let resultStatus, resultStatusColor;
|
||
if (goalSuccess && clean && finished) {
|
||
resultStatus = '✅ 完美成功'; resultStatusColor = 'var(--success)';
|
||
} else if (goalSuccess && clean && !finished) {
|
||
resultStatus = '⚠️ 未意识'; resultStatusColor = 'var(--warning)';
|
||
} else if (goalSuccess && !clean && finished) {
|
||
resultStatus = '🔧 有副作用'; resultStatusColor = '#f97316';
|
||
} else if (goalSuccess && !clean && !finished) {
|
||
resultStatus = '⚠️🔧 双重问题'; resultStatusColor = '#c2410c';
|
||
} else {
|
||
resultStatus = '❌ 失败'; resultStatusColor = 'var(--error)';
|
||
}
|
||
|
||
html += `
|
||
<div class="card" style="margin-bottom: 20px; padding: 16px;">
|
||
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px;">
|
||
<div>
|
||
<h3 style="font-size: 18px; font-weight: 700; margin-bottom: 4px;">
|
||
${state.selectedTask.task_name || state.selectedTask.task_id}${trialInfo}
|
||
</h3>
|
||
<p style="font-size: 12px; color: var(--text-muted);">
|
||
<span style="color: ${resultStatusColor};">${resultStatus}</span> • ${state.trajectory.length} 步 • ${taskResult?.execution?.runtime_s?.toFixed(1) ?? '-'}s
|
||
</p>
|
||
</div>
|
||
${repeatN > 1 ? `
|
||
<div style="display: flex; gap: 6px;">
|
||
${(state.taskGroups.get(state.selectedTask.baseTaskId || state.selectedTask.task_id) || []).map((t, idx) => {
|
||
const isCurrent = t.dirName === state.selectedTask.dirName;
|
||
const status = getTaskStatusForTrial(t);
|
||
const tags = getTaskIndicators(t);
|
||
const titleMap = { success: '完美成功', error: '错误', unknown: '未知' };
|
||
const statusTitle = titleMap[status] || '失败';
|
||
const tagSuffix = tags.length ? ' · ' + tags.map(k => INDICATOR_LABELS[k].text).join('/') : '';
|
||
const tagBadges = tags.length
|
||
? `<span class="trial-indicator-tags">${tags.map(k => INDICATOR_LABELS[k].icon).join('')}</span>`
|
||
: '';
|
||
return `<div class="trial-indicator ${status} ${isCurrent ? 'active' : ''}"
|
||
onclick="selectTrial('${state.selectedTask.baseTaskId || state.selectedTask.task_id}', ${idx})"
|
||
title="Trial ${idx}: ${statusTitle}${tagSuffix}">
|
||
${idx}${tagBadges}
|
||
</div>`;
|
||
}).join('')}
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
html += `
|
||
<div class="trajectory-header">
|
||
<h3>轨迹步骤 (${state.trajectory.length} 步)</h3>
|
||
<div style="display: flex; align-items: center;">
|
||
<button class="full-history-btn" onclick="showFullHistory()">
|
||
📝 查看完整对话历史
|
||
</button>
|
||
<div class="trajectory-nav">
|
||
<button class="nav-btn" onclick="navigateStep(-1)">‹</button>
|
||
<button class="nav-btn" onclick="navigateStep(1)">›</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="trajectory-strip" id="trajStrip">
|
||
${state.trajectory.map((step, idx) => `
|
||
<div class="thumb ${state.selectedStepIdx === idx ? 'active' : ''}" data-idx="${idx}">
|
||
<img id="thumb-${idx}" alt="Step ${step.step ?? idx}" loading="lazy">
|
||
<div class="thumb-label">#${step.step ?? idx}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
|
||
<div id="stepsContainer">
|
||
${state.trajectory.map((step, idx) => {
|
||
const hasAnnot = !!step.screenshot_annotated;
|
||
const hasResponse = !!step.model_response_path;
|
||
return `
|
||
<div class="step-card" id="step-${idx}">
|
||
<div class="step-header">
|
||
<span class="step-number">Step ${step.step ?? idx}</span>
|
||
<span class="step-action">${step.action_type ?? 'UNKNOWN'}</span>
|
||
</div>
|
||
<div class="step-body">
|
||
<div class="step-images">
|
||
<div class="step-img-wrap">
|
||
<img class="step-img" id="stepImg-${idx}-raw" alt="原图" data-idx="${idx}" data-type="raw">
|
||
<div class="img-label">原图</div>
|
||
</div>
|
||
${hasAnnot ? `
|
||
<div class="step-img-wrap">
|
||
<img class="step-img" id="stepImg-${idx}-annot" alt="标注" data-idx="${idx}" data-type="annot">
|
||
<div class="img-label">标注</div>
|
||
</div>
|
||
` : ''}
|
||
</div>
|
||
<div class="step-text">
|
||
${hasResponse ? `
|
||
<div class="response-section">
|
||
<div class="response-header">
|
||
<span class="response-title">📄 模型完整输出</span>
|
||
<span class="response-file">${step.model_response_path}</span>
|
||
</div>
|
||
<div class="response-content" id="response-${idx}">
|
||
<div class="loading-text">加载中...</div>
|
||
</div>
|
||
</div>
|
||
` : `
|
||
<div class="step-thought">${escapeHtml(step.thought || '(无思考内容)')}</div>
|
||
`}
|
||
<div class="step-meta-info">
|
||
<div class="step-route">
|
||
📍 ${step.route?.app || ''}${step.route?.path || ''}
|
||
</div>
|
||
<div class="step-action-data">
|
||
🎯 ${step.action_type || 'UNKNOWN'}
|
||
${step.action_data?.point ? `: 点击 (${step.action_data.point.join(', ')})` : ''}
|
||
${step.action_data?.start && step.action_data?.end ? `: 滑动 (${step.action_data.start.join(',')}) → (${step.action_data.end.join(',')})` : ''}
|
||
${step.action_data?.text ? `: 输入 "${step.action_data.text}"` : ''}
|
||
${step.action_data?.value && step.action_type === 'ANSWER' ? `: 答案 "${step.action_data.value}"` : ''}
|
||
${step.action_data?.return ? `: 返回值` : ''}
|
||
</div>
|
||
</div>
|
||
${step.action_data?.return ? `
|
||
<div class="return-value">
|
||
<div class="return-label">返回值:</div>
|
||
<div class="return-content">${escapeHtml(String(step.action_data.return))}</div>
|
||
</div>
|
||
` : ''}
|
||
<div class="prompt-section" id="prompt-section-${idx}">
|
||
<div class="prompt-header" onclick="togglePromptSection(${idx})">
|
||
<span class="prompt-title">📝 Prompt (点击展开)</span>
|
||
<span class="prompt-toggle">▶</span>
|
||
</div>
|
||
<div class="prompt-content" id="prompt-content-${idx}">
|
||
<div class="loading-text">加载中...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
|
||
el('trajectoryContent').innerHTML = html;
|
||
|
||
// Load images, response text, and prompt
|
||
state.trajectory.forEach(async (step, idx) => {
|
||
try {
|
||
const media = await getMedia(idx);
|
||
const thumbImg = document.querySelector(`#trajStrip .thumb[data-idx="${idx}"] img`);
|
||
if (thumbImg) thumbImg.src = media.annotUrl || media.rawUrl || '';
|
||
|
||
const rawImg = el(`stepImg-${idx}-raw`);
|
||
if (rawImg && media.rawUrl) rawImg.src = media.rawUrl;
|
||
|
||
const annotImg = el(`stepImg-${idx}-annot`);
|
||
if (annotImg && media.annotUrl) annotImg.src = media.annotUrl;
|
||
|
||
// Load response text
|
||
const responseEl = el(`response-${idx}`);
|
||
if (responseEl && media.responseText) {
|
||
responseEl.innerHTML = `<pre class="response-pre">${escapeHtml(media.responseText)}</pre>`;
|
||
} else if (responseEl) {
|
||
responseEl.innerHTML = `<div class="no-response">无响应内容</div>`;
|
||
}
|
||
|
||
// Load prompt
|
||
const promptContentEl = el(`prompt-content-${idx}`);
|
||
if (promptContentEl) {
|
||
if (media.prompt && Array.isArray(media.prompt)) {
|
||
const stats = getPromptStats(media.prompt);
|
||
promptContentEl.innerHTML = `
|
||
<div class="prompt-stats">
|
||
<span class="prompt-stat">共 <strong>${stats.total}</strong> 条消息</span>
|
||
<span class="prompt-stat">System: <strong>${stats.system}</strong></span>
|
||
<span class="prompt-stat">User: <strong>${stats.user}</strong></span>
|
||
<span class="prompt-stat">Assistant: <strong>${stats.assistant}</strong></span>
|
||
</div>
|
||
<div class="chat-messages">
|
||
${renderChatMessages(media.prompt, `step_${idx}`)}
|
||
</div>
|
||
`;
|
||
// Update header text
|
||
const promptSection = el(`prompt-section-${idx}`);
|
||
if (promptSection) {
|
||
const titleEl = promptSection.querySelector('.prompt-title');
|
||
if (titleEl) titleEl.textContent = `📝 Prompt (${stats.total} 条消息)`;
|
||
}
|
||
} else {
|
||
promptContentEl.innerHTML = `<div class="no-response" style="padding: 16px;">无 Prompt 数据</div>`;
|
||
const promptSection = el(`prompt-section-${idx}`);
|
||
if (promptSection) {
|
||
const titleEl = promptSection.querySelector('.prompt-title');
|
||
if (titleEl) titleEl.textContent = `📝 Prompt (无数据)`;
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error(`Failed to load media for step ${idx}:`, e);
|
||
const responseEl = el(`response-${idx}`);
|
||
if (responseEl) {
|
||
responseEl.innerHTML = `<div class="no-response" style="color: var(--error);">加载失败: ${e.message}</div>`;
|
||
}
|
||
}
|
||
});
|
||
|
||
// Bind thumb clicks
|
||
document.querySelectorAll('#trajStrip .thumb').forEach(thumb => {
|
||
thumb.addEventListener('click', () => {
|
||
const idx = parseInt(thumb.dataset.idx);
|
||
state.selectedStepIdx = idx;
|
||
document.querySelectorAll('#trajStrip .thumb').forEach(t => t.classList.remove('active'));
|
||
thumb.classList.add('active');
|
||
document.getElementById(`step-${idx}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
});
|
||
|
||
// Bind image clicks for lightbox
|
||
document.querySelectorAll('.step-img').forEach(img => {
|
||
img.addEventListener('click', async () => {
|
||
const idx = parseInt(img.dataset.idx);
|
||
const type = img.dataset.type;
|
||
await openLightboxForStep(idx, type);
|
||
});
|
||
});
|
||
}
|
||
|
||
function navigateStep(delta) {
|
||
const newIdx = state.selectedStepIdx + delta;
|
||
if (newIdx >= 0 && newIdx < state.trajectory.length) {
|
||
state.selectedStepIdx = newIdx;
|
||
document.querySelectorAll('#trajStrip .thumb').forEach((t, i) => {
|
||
t.classList.toggle('active', i === newIdx);
|
||
});
|
||
document.getElementById(`step-${newIdx}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
}
|
||
|
||
async function renderAllImages() {
|
||
if (!state.selectedTask) {
|
||
el('allImagesEmpty').style.display = 'block';
|
||
el('allImagesContent').style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
el('allImagesEmpty').style.display = 'none';
|
||
el('allImagesContent').style.display = 'block';
|
||
|
||
if (state.trajectory.length === 0) {
|
||
el('allImagesContent').innerHTML = `
|
||
<div class="empty">
|
||
<div class="empty-icon">🖼️</div>
|
||
<h3>无截图</h3>
|
||
<p>该任务没有截图记录</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
// Build image list
|
||
lightboxImages = [];
|
||
for (let i = 0; i < state.trajectory.length; i++) {
|
||
const step = state.trajectory[i];
|
||
const media = await getMedia(i);
|
||
if (media.rawUrl) {
|
||
lightboxImages.push({ url: media.rawUrl, label: `Step ${step.step ?? i} - 原图`, stepIdx: i, type: 'raw' });
|
||
}
|
||
if (media.annotUrl) {
|
||
lightboxImages.push({ url: media.annotUrl, label: `Step ${step.step ?? i} - 标注`, stepIdx: i, type: 'annot' });
|
||
}
|
||
}
|
||
|
||
// Check if multi-trial
|
||
const sum = state.summary || {};
|
||
const meta = state.meta || {};
|
||
const repeatN = sum.repeat_n ?? meta.repeat_n ?? 1;
|
||
const trialInfo = repeatN > 1 ? ` (Trial ${state.selectedTask.trialId ?? 0})` : '';
|
||
|
||
let html = `
|
||
<div class="images-header">
|
||
<h3>全部截图${trialInfo}</h3>
|
||
<span class="images-count">${lightboxImages.length} 张</span>
|
||
</div>
|
||
<div class="images-grid">
|
||
${lightboxImages.map((img, idx) => `
|
||
<div class="image-card" data-idx="${idx}">
|
||
<img src="${img.url}" alt="${img.label}" loading="lazy">
|
||
<div class="image-card-info">${img.label}</div>
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
`;
|
||
|
||
el('allImagesContent').innerHTML = html;
|
||
|
||
// Bind click
|
||
document.querySelectorAll('#allImagesContent .image-card').forEach(card => {
|
||
card.addEventListener('click', () => {
|
||
lightboxIndex = parseInt(card.dataset.idx);
|
||
showLightbox();
|
||
});
|
||
});
|
||
}
|
||
|
||
// =========== Prompt Functions ===========
|
||
function togglePromptSection(idx) {
|
||
const section = el(`prompt-section-${idx}`);
|
||
if (section) {
|
||
section.classList.toggle('expanded');
|
||
}
|
||
}
|
||
|
||
async function showFullHistory() {
|
||
// Use the last step's prompt which contains the full conversation history
|
||
const lastIdx = state.trajectory.length - 1;
|
||
if (lastIdx < 0) {
|
||
el('promptModalBody').innerHTML = '<div class="no-response" style="padding: 20px; text-align: center;">无轨迹数据</div>';
|
||
el('promptModal').classList.add('open');
|
||
return;
|
||
}
|
||
|
||
const media = await getMedia(lastIdx);
|
||
if (media.prompt && Array.isArray(media.prompt)) {
|
||
const stats = getPromptStats(media.prompt);
|
||
const lastStep = state.trajectory[lastIdx];
|
||
el('promptModalBody').innerHTML = `
|
||
<div class="prompt-stats">
|
||
<span class="prompt-stat">来源: <strong>Step ${lastStep.step ?? lastIdx}</strong> (最后一步)</span>
|
||
<span class="prompt-stat">共 <strong>${stats.total}</strong> 条消息</span>
|
||
<span class="prompt-stat">System: <strong>${stats.system}</strong></span>
|
||
<span class="prompt-stat">User: <strong>${stats.user}</strong></span>
|
||
<span class="prompt-stat">Assistant: <strong>${stats.assistant}</strong></span>
|
||
</div>
|
||
<div class="chat-messages">
|
||
${renderChatMessages(media.prompt, 'modal')}
|
||
</div>
|
||
`;
|
||
} else {
|
||
el('promptModalBody').innerHTML = '<div class="no-response" style="padding: 20px; text-align: center;">无 Prompt 数据</div>';
|
||
}
|
||
|
||
el('promptModal').classList.add('open');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function closePromptModal() {
|
||
el('promptModal').classList.remove('open');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
// =========== Lightbox ===========
|
||
async function openLightboxForStep(stepIdx, type) {
|
||
lightboxImages = [];
|
||
for (let i = 0; i < state.trajectory.length; i++) {
|
||
const step = state.trajectory[i];
|
||
const media = await getMedia(i);
|
||
if (media.rawUrl) {
|
||
lightboxImages.push({ url: media.rawUrl, label: `Step ${step.step ?? i} - 原图`, stepIdx: i, type: 'raw' });
|
||
}
|
||
if (media.annotUrl) {
|
||
lightboxImages.push({ url: media.annotUrl, label: `Step ${step.step ?? i} - 标注`, stepIdx: i, type: 'annot' });
|
||
}
|
||
}
|
||
|
||
lightboxIndex = lightboxImages.findIndex(img => img.stepIdx === stepIdx && img.type === type);
|
||
if (lightboxIndex === -1) lightboxIndex = 0;
|
||
|
||
showLightbox();
|
||
}
|
||
|
||
function showLightbox() {
|
||
if (lightboxImages.length === 0) return;
|
||
const img = lightboxImages[lightboxIndex];
|
||
el('lightboxImg').src = img.url;
|
||
el('lightboxInfo').textContent = `${img.label} (${lightboxIndex + 1}/${lightboxImages.length})`;
|
||
el('lightbox').classList.add('open');
|
||
document.body.style.overflow = 'hidden';
|
||
}
|
||
|
||
function closeLightbox() {
|
||
el('lightbox').classList.remove('open');
|
||
document.body.style.overflow = '';
|
||
}
|
||
|
||
function navigateLightbox(delta) {
|
||
lightboxIndex = (lightboxIndex + delta + lightboxImages.length) % lightboxImages.length;
|
||
showLightbox();
|
||
}
|
||
|
||
// =========== Event Handlers ===========
|
||
el('runSelect').addEventListener('change', (e) => {
|
||
const v = e.target.value;
|
||
if (!v) return;
|
||
if (state.isLoading) return;
|
||
loadRunDir(v); // Don't await, let it run async
|
||
});
|
||
|
||
el('taskFilter').addEventListener('input', debounce(() => renderTaskList()));
|
||
['fSuccess', 'fFailed', 'fError', 'fSideEffect', 'fOverdue', 'fFalseComplete'].forEach(id => {
|
||
el(id).addEventListener('change', () => renderTaskList());
|
||
});
|
||
|
||
// Taxonomy filter event listeners
|
||
['fSuite', 'fDifficulty', 'fScope', 'fObjective', 'fComposition'].forEach(id => {
|
||
el(id).addEventListener('change', () => onTaxonomyFilterChange());
|
||
});
|
||
|
||
// Tabs
|
||
document.querySelectorAll('.tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
el(`panel-${tab.dataset.tab}`).classList.add('active');
|
||
});
|
||
});
|
||
|
||
// Lightbox keyboard
|
||
document.addEventListener('keydown', (e) => {
|
||
if (el('promptModal').classList.contains('open')) {
|
||
if (e.key === 'Escape') closePromptModal();
|
||
return;
|
||
}
|
||
if (!el('lightbox').classList.contains('open')) return;
|
||
if (e.key === 'Escape') closeLightbox();
|
||
if (e.key === 'ArrowLeft') navigateLightbox(-1);
|
||
if (e.key === 'ArrowRight') navigateLightbox(1);
|
||
});
|
||
|
||
el('lightbox').addEventListener('click', (e) => {
|
||
if (e.target === el('lightbox')) closeLightbox();
|
||
});
|
||
|
||
el('promptModal').addEventListener('click', (e) => {
|
||
if (e.target === el('promptModal')) closePromptModal();
|
||
});
|
||
|
||
// Initialize app
|
||
loadRunsViaHttp();
|
||
</script>
|
||
</body>
|
||
</html>
|