Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Slyfox</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=IBM+Plex+Mono&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet" /> | |
| <link rel="stylesheet" href="/linear-theme.css" /> | |
| <link rel="stylesheet" href="/slyfox-theme.css" /> | |
| <style> | |
| * { box-sizing: border-box; } | |
| html, body { height: 100%; margin: 0; } | |
| body { | |
| background: var(--bg); | |
| color: var(--fg); | |
| font-family: var(--font-inter-variable); | |
| font-size: var(--text-body); | |
| line-height: var(--leading-body); | |
| letter-spacing: var(--tracking-body); | |
| } | |
| /* Sidebar */ | |
| aside { | |
| width: 260px; | |
| flex-shrink: 0; | |
| background: var(--sidebar-bg); | |
| color: var(--sidebar-fg); | |
| border-right: 1px solid var(--border); | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| } | |
| .sidebar-header { | |
| padding: 12px; | |
| } | |
| .new-chat { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| width: 100%; | |
| background: transparent; | |
| border: 0; | |
| color: inherit; | |
| font: inherit; | |
| font-weight: 500; | |
| padding: 8px 12px; | |
| border-radius: 10px; | |
| cursor: pointer; | |
| text-align: left; | |
| } | |
| .new-chat:hover { background: var(--sidebar-hover); } | |
| .new-chat svg { width: 16px; height: 16px; } | |
| .chat-list { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 4px 8px 16px; | |
| } | |
| .chat-item { | |
| display: block; | |
| width: 100%; | |
| text-align: left; | |
| background: transparent; | |
| border: 0; | |
| color: inherit; | |
| font: inherit; | |
| padding: 8px 10px; | |
| border-radius: 8px; | |
| cursor: pointer; | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| } | |
| .chat-item:hover { background: var(--sidebar-hover); } | |
| .chat-item.active { background: var(--sidebar-active); } | |
| /* Main column */ | |
| .main-wrap { | |
| flex: 1; | |
| display: flex; | |
| justify-content: center; | |
| min-width: 0; | |
| } | |
| main { | |
| width: 100%; | |
| max-width: 1100px; | |
| display: flex; | |
| flex-direction: column; | |
| height: 100vh; | |
| } | |
| .msg { | |
| max-width: 80%; | |
| padding: 10px 14px; | |
| border-radius: 14px; | |
| white-space: pre-wrap; | |
| word-wrap: break-word; | |
| } | |
| .msg.user { | |
| align-self: flex-end; | |
| background: var(--bubble-user); | |
| color: var(--bubble-user-fg); | |
| border-bottom-right-radius: 4px; | |
| } | |
| .msg.bot { | |
| align-self: flex-start; | |
| background: var(--bubble-bot); | |
| color: var(--bubble-bot-fg); | |
| border-bottom-left-radius: 4px; | |
| } | |
| /* Composer */ | |
| form { | |
| padding: 12px 16px 18px; | |
| background: var(--bg); | |
| } | |
| .composer { | |
| --composer-pad-x: 2px; | |
| border: 1px solid var(--border); | |
| border-radius: 22px; | |
| background: var(--composer-bg); | |
| padding: 14px 16px; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| box-shadow: 0 1px 2px rgba(0,0,0,0.04); | |
| } | |
| .composer:focus-within { border-color: #c7c7cc; } | |
| #input { | |
| flex: 1; | |
| min-width: 0; | |
| resize: none; | |
| font: inherit; | |
| color: inherit; | |
| background: transparent; | |
| border: 0; | |
| outline: none; | |
| padding: 4px var(--composer-pad-x); | |
| max-height: 240px; | |
| min-height: calc(1.45em + 8px); | |
| line-height: 1.45; | |
| white-space: pre-wrap; | |
| overflow-wrap: anywhere; | |
| } | |
| #input.multiline { | |
| min-height: calc(2 * 1.45em + 8px); | |
| } | |
| #input::placeholder { color: var(--muted); } | |
| #send { | |
| flex-shrink: 0; | |
| background: var(--send-bg); | |
| color: var(--send-fg); | |
| width: 32px; | |
| height: 32px; | |
| border-radius: 50%; | |
| border: 0; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| cursor: pointer; | |
| padding: 0; | |
| } | |
| #send:disabled { opacity: 0.45; cursor: not-allowed; } | |
| #send svg { width: 18px; height: 18px; } | |
| /* ---- Tabs ---- */ | |
| .tabs { | |
| display: flex; | |
| gap: var(--spacing-4); | |
| padding: var(--spacing-12) var(--spacing-16) 0; | |
| border-bottom: 1px solid var(--border); | |
| background: var(--bg); | |
| flex-shrink: 0; | |
| overflow-x: auto; | |
| -webkit-overflow-scrolling: touch; | |
| } | |
| .tabs::-webkit-scrollbar { height: 0; } | |
| .tab-btn { | |
| appearance: none; | |
| background: transparent; | |
| border: 0; | |
| border-bottom: 2px solid transparent; | |
| color: var(--muted); | |
| font: inherit; | |
| font-size: var(--text-body); | |
| font-weight: var(--font-weight-regular); | |
| letter-spacing: var(--tracking-body); | |
| padding: var(--spacing-8) var(--spacing-12) var(--spacing-8); | |
| cursor: pointer; | |
| margin-bottom: -1px; | |
| border-radius: var(--radius-tags); | |
| } | |
| .tab-btn:hover { color: var(--fg); } | |
| .tab-btn.active { | |
| color: var(--accent); | |
| border-bottom-color: var(--accent); | |
| font-weight: var(--font-weight-w510); | |
| } | |
| /* ---- Analysis panel ---- */ | |
| #panel-analysis { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 28px 20px; | |
| display: none; | |
| flex-direction: column; | |
| gap: 32px; | |
| } | |
| #panel-analysis.visible { display: flex; } | |
| #panel-jobs { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: var(--spacing-28) var(--spacing-20); | |
| display: none; | |
| flex-direction: column; | |
| gap: var(--spacing-32); | |
| } | |
| #panel-jobs.visible { display: flex; } | |
| .panel-embed { | |
| flex: 1; | |
| min-height: 0; | |
| display: none; | |
| flex-direction: column; | |
| } | |
| .panel-embed.visible { display: flex; } | |
| .panel-embed iframe { | |
| flex: 1; | |
| width: 100%; | |
| min-height: 0; | |
| border: 0; | |
| background: var(--bg); | |
| } | |
| .analysis-section h2 .hint { | |
| text-transform: none; | |
| letter-spacing: 0; | |
| font-weight: 400; | |
| font-size: 12px; | |
| color: var(--muted); | |
| } | |
| .jobs-lead { | |
| font-size: 13px; | |
| color: var(--muted); | |
| margin: 0 0 16px; | |
| line-height: 1.5; | |
| } | |
| .jobs-grid { | |
| display: grid; | |
| gap: 12px; | |
| grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); | |
| } | |
| .jobs-field label { | |
| display: block; | |
| font-size: 13px; | |
| color: var(--muted); | |
| margin-bottom: 6px; | |
| } | |
| .jobs-field input, | |
| .jobs-field select { | |
| width: 100%; | |
| font: inherit; | |
| font-size: var(--text-body); | |
| color: var(--fg); | |
| background: var(--color-gunmetal); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-inputs); | |
| padding: var(--spacing-12) 14px; | |
| } | |
| .jobs-field input:focus, | |
| .jobs-field select:focus { | |
| outline: none; | |
| border-color: var(--color-muted-ash); | |
| box-shadow: var(--shadow-subtle-2); | |
| } | |
| .jobs-actions { | |
| display: flex; | |
| gap: 10px; | |
| align-items: center; | |
| margin-top: 14px; | |
| } | |
| .jobs-btn { | |
| background: var(--pill); | |
| color: var(--pill-fg); | |
| border: 0; | |
| border-radius: var(--radius-buttons); | |
| padding: var(--spacing-12) var(--spacing-24); | |
| font: inherit; | |
| font-size: 15px; | |
| font-weight: var(--font-weight-w590); | |
| letter-spacing: -0.11px; | |
| cursor: pointer; | |
| } | |
| .jobs-btn:hover { filter: brightness(1.04); } | |
| .jobs-btn.secondary { | |
| background: transparent; | |
| color: var(--color-light-steel); | |
| border: 1px solid var(--border); | |
| box-shadow: none; | |
| } | |
| .jobs-btn.danger { background: var(--err); color: var(--color-porcelain); box-shadow: none; } | |
| .jobs-btn[disabled] { opacity: 0.45; cursor: not-allowed; } | |
| .jobs-toast { font-size: 13px; color: var(--muted); } | |
| .jobs-toast.ok { color: var(--ok); } | |
| .jobs-toast.err { color: var(--err); } | |
| .jobs-list { | |
| border: 0; | |
| border-radius: 0; | |
| overflow: visible; | |
| background: transparent; | |
| box-shadow: none; | |
| } | |
| .job-row { | |
| display: grid; | |
| grid-template-columns: 28px 24px 1fr auto auto; | |
| gap: var(--spacing-12); | |
| align-items: center; | |
| padding: var(--spacing-12) 0; | |
| background: transparent; | |
| border-bottom: 1px solid var(--color-muted-ash); | |
| cursor: pointer; | |
| } | |
| .job-row:last-child { border-bottom: 0; } | |
| .job-row:hover { background: transparent; } | |
| .job-row.expanded { background: transparent; } | |
| .job-dot { | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| box-shadow: 0 0 0 2px var(--bg); | |
| } | |
| .job-dot.queued, .job-dot.pending { background: var(--muted); } | |
| .job-dot.running { background: var(--running); animation: job-pulse 1.4s ease-in-out infinite; } | |
| .job-dot.completed, .job-dot.success { background: var(--ok); } | |
| .job-dot.failed, .job-dot.error { background: var(--err); } | |
| .job-dot.cancelled, .job-dot.canceled { background: var(--warn); } | |
| .job-dot.unknown { background: var(--muted); opacity: 0.5; } | |
| @keyframes job-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } | |
| .job-kind { font-size: 16px; text-align: center; } | |
| .job-title { font-size: 14px; font-weight: 500; } | |
| .job-title .id { | |
| color: var(--meta); | |
| font-family: var(--mono); | |
| font-size: 11px; | |
| margin-left: 6px; | |
| font-weight: 400; | |
| } | |
| .job-sub { color: var(--muted); font-size: 12px; margin-top: 2px; } | |
| .job-meta { | |
| color: var(--muted); | |
| font-size: 12px; | |
| font-family: var(--mono); | |
| text-align: right; | |
| } | |
| .job-meta .status { | |
| display: block; | |
| color: var(--fg); | |
| font-size: 11px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.04em; | |
| } | |
| .job-chevron { color: var(--muted); font-size: 14px; transition: transform 0.15s; } | |
| .job-row.expanded .job-chevron { transform: rotate(90deg); } | |
| .job-detail { | |
| display: none; | |
| padding: 14px 0 18px; | |
| background: transparent; | |
| border-bottom: 1px solid var(--color-muted-ash); | |
| font-size: 13px; | |
| } | |
| .job-row.expanded + .job-detail { display: block; } | |
| .job-detail .params { color: var(--muted); margin-bottom: 10px; line-height: 1.6; } | |
| .job-detail .params b { color: var(--fg); font-weight: 500; } | |
| .job-detail .logs { | |
| background: var(--color-gunmetal); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius-inputs); | |
| padding: var(--spacing-12); | |
| max-height: 280px; | |
| overflow: auto; | |
| white-space: pre-wrap; | |
| font-family: var(--mono); | |
| font-size: 12px; | |
| line-height: 1.5; | |
| color: var(--fg); | |
| } | |
| .job-detail .job-detail-actions { display: flex; gap: 8px; margin-top: 10px; } | |
| .jobs-empty { padding: 24px; text-align: center; color: var(--muted); font-size: 13px; } | |
| .analysis-section h2 { | |
| font-size: 15px; | |
| font-weight: var(--font-weight-w510); | |
| color: var(--fg); | |
| letter-spacing: -0.11px; | |
| margin: 0 0 var(--spacing-16); | |
| } | |
| .analysis-section { | |
| background: transparent; | |
| border: 0; | |
| border-radius: 0; | |
| padding: 0; | |
| box-shadow: none; | |
| } | |
| .analysis-section + .analysis-section { | |
| padding-top: var(--section-gap); | |
| border-top: 1px solid var(--color-muted-ash); | |
| } | |
| /* ---- Emotion heatmap ---- */ | |
| .emotion-heatmap-wrap { overflow-x: auto; } | |
| .emotion-heatmap { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 3px; | |
| } | |
| .emotion-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .emotion-row-label { | |
| width: 120px; | |
| flex-shrink: 0; | |
| font-size: 11px; | |
| color: var(--muted); | |
| white-space: nowrap; | |
| overflow: hidden; | |
| text-overflow: ellipsis; | |
| text-align: right; | |
| padding-right: 4px; | |
| } | |
| .emotion-cells { | |
| display: flex; | |
| gap: 2px; | |
| } | |
| .emotion-cell { | |
| width: 10px; | |
| height: 20px; | |
| border-radius: 2px; | |
| flex-shrink: 0; | |
| position: relative; | |
| cursor: default; | |
| } | |
| .emotion-cell:hover::after { | |
| content: attr(title); | |
| position: absolute; | |
| bottom: calc(100% + 5px); | |
| left: 50%; | |
| transform: translateX(-50%); | |
| background: var(--fg); | |
| color: var(--bg); | |
| font-size: 11px; | |
| white-space: nowrap; | |
| padding: 3px 7px; | |
| border-radius: 5px; | |
| pointer-events: none; | |
| z-index: 20; | |
| } | |
| .emotion-legend { | |
| display: flex; | |
| align-items: center; | |
| gap: 0; | |
| margin-top: 12px; | |
| margin-left: 126px; | |
| } | |
| .emotion-legend-bar { | |
| width: 160px; | |
| height: 10px; | |
| border-radius: 3px; | |
| background: linear-gradient(to right, #67001f, #d6604d, #f7f7f7, #4393c3, #053061); | |
| } | |
| .emotion-legend-labels { | |
| display: flex; | |
| justify-content: space-between; | |
| width: 160px; | |
| margin-left: 126px; | |
| margin-top: 3px; | |
| font-size: 11px; | |
| color: var(--muted); | |
| } | |
| /* Stats */ | |
| .stats-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); | |
| gap: 12px; | |
| } | |
| .stat-card { | |
| background: var(--bubble-bot); | |
| border-radius: 12px; | |
| padding: 14px 16px; | |
| } | |
| .stat-card .value { | |
| font-size: 28px; | |
| font-weight: 700; | |
| line-height: 1; | |
| margin-bottom: 4px; | |
| } | |
| .stat-card .label { | |
| font-size: 13px; | |
| color: var(--muted); | |
| } | |
| /* Topic breakdown */ | |
| .topic-bars { display: flex; flex-direction: column; gap: 8px; } | |
| .topic-row { display: flex; align-items: center; gap: 10px; font-size: 13px; } | |
| .topic-row .name { width: 160px; flex-shrink: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| .topic-row .bar-wrap { flex: 1; height: 8px; background: var(--border); border-radius: 4px; overflow: hidden; } | |
| .topic-row .bar { height: 100%; background: var(--pill); border-radius: 4px; } | |
| .topic-row .count { width: 32px; text-align: right; color: var(--muted); } | |
| /* ---- Communication style tab ---- */ | |
| #panel-style { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 28px 20px; | |
| display: none; | |
| flex-direction: column; | |
| gap: 40px; | |
| } | |
| #panel-style.visible { display: flex; } | |
| /* Radar */ | |
| .radar-wrap { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 24px; | |
| align-items: flex-start; | |
| } | |
| .radar-svg-wrap { flex-shrink: 0; } | |
| .radar-legend { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| padding-top: 16px; | |
| } | |
| .radar-legend-item { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| font-size: 13px; | |
| } | |
| .radar-swatch { | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 2px; | |
| flex-shrink: 0; | |
| } | |
| /* Diff chart */ | |
| .diff-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| margin-bottom: 20px; | |
| flex-wrap: wrap; | |
| } | |
| .diff-controls label { font-size: 13px; color: var(--muted); } | |
| .diff-controls select { | |
| font: inherit; | |
| font-size: 13px; | |
| background: var(--composer-bg); | |
| color: var(--fg); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 5px 10px; | |
| cursor: pointer; | |
| outline: none; | |
| } | |
| .diff-controls select:focus { border-color: var(--pill); } | |
| .diff-vs { font-size: 13px; color: var(--muted); font-weight: 500; } | |
| .diff-chart { display: flex; flex-direction: column; gap: 10px; width: 100%; max-width: 560px; } | |
| .diff-row { display: flex; align-items: center; gap: 10px; font-size: 13px; } | |
| .diff-dim-label { width: 90px; flex-shrink: 0; text-align: right; color: var(--muted); } | |
| .diff-bar-track { | |
| flex: 1; | |
| position: relative; | |
| height: 20px; | |
| display: flex; | |
| align-items: center; | |
| } | |
| .diff-center-line { | |
| position: absolute; | |
| left: 50%; | |
| top: 0; | |
| bottom: 0; | |
| width: 1px; | |
| background: var(--border); | |
| } | |
| .diff-bar { | |
| position: absolute; | |
| height: 14px; | |
| border-radius: 3px; | |
| top: 3px; | |
| } | |
| .diff-bar.pos { background: #4393c3; left: 50%; } | |
| .diff-bar.neg { background: #d6604d; right: 50%; } | |
| .diff-val { width: 48px; font-size: 12px; color: var(--muted); } | |
| .diff-note { font-size: 12px; color: var(--muted); margin-top: 8px; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="main-wrap"> | |
| <main> | |
| <!-- Tab bar --> | |
| <div class="tabs"> | |
| <button class="tab-btn active" data-tab="buddy">Buddy</button> | |
| <button class="tab-btn" data-tab="mirror">Mirror</button> | |
| <button class="tab-btn" data-tab="cards">Cards</button> | |
| <button class="tab-btn" data-tab="quests">Quests</button> | |
| <button class="tab-btn" data-tab="marketplace">Marketplace</button> | |
| <button class="tab-btn" data-tab="arena">Arena</button> | |
| <button class="tab-btn" data-tab="jobs">Jobs</button> | |
| <button class="tab-btn" data-tab="mood-ci">Mood CI</button> | |
| <button class="tab-btn" data-tab="harness">Harness</button> | |
| </div> | |
| <div id="panel-jobs"> | |
| <div class="analysis-section"> | |
| <h2>Request a new model <span class="hint">runs emotion-vector extraction on hf jobs</span></h2> | |
| <div class="jobs-grid"> | |
| <div class="jobs-field"> | |
| <label for="ex-model">Model id</label> | |
| <input id="ex-model" type="text" placeholder="Qwen/Qwen2.5-7B-Instruct" /> | |
| </div> | |
| <div class="jobs-field"> | |
| <label for="ex-flavor">Hardware</label> | |
| <select id="ex-flavor"> | |
| <option value="a10g-large">a10g-large (default)</option> | |
| <option value="a10g-small">a10g-small</option> | |
| <option value="a100-large">a100-large</option> | |
| <option value="cpu-upgrade">cpu-upgrade</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="jobs-actions"> | |
| <button type="button" class="jobs-btn" id="ex-submit">Submit extraction</button> | |
| <span class="jobs-toast" id="ex-toast"></span> | |
| </div> | |
| </div> | |
| <div class="analysis-section"> | |
| <h2>Analyze traces <span class="hint">replays sessions through a model, emits per-turn emotion trajectories</span></h2> | |
| <div class="jobs-grid"> | |
| <div class="jobs-field"> | |
| <label for="an-source">Traces bucket</label> | |
| <input id="an-source" type="text" placeholder="HF-slyfox/traces" value="HF-slyfox/traces" /> | |
| </div> | |
| <div class="jobs-field"> | |
| <label for="an-prefix">Prefix (optional)</label> | |
| <input id="an-prefix" type="text" placeholder="Arthurs-beast/-Users-...-transformers" /> | |
| </div> | |
| <div class="jobs-field"> | |
| <label for="an-n">Num traces</label> | |
| <input id="an-n" type="number" min="1" max="100" value="5" /> | |
| </div> | |
| <div class="jobs-field"> | |
| <label for="an-model">Model</label> | |
| <input id="an-model" type="text" placeholder="google/gemma-2-2b-it" /> | |
| </div> | |
| <div class="jobs-field"> | |
| <label for="an-flavor">Hardware</label> | |
| <select id="an-flavor"> | |
| <option value="a10g-large">a10g-large</option> | |
| <option value="a10g-small">a10g-small</option> | |
| <option value="a100-large">a100-large</option> | |
| </select> | |
| </div> | |
| </div> | |
| <div class="jobs-actions"> | |
| <button type="button" class="jobs-btn" id="an-submit">Submit analysis</button> | |
| <span class="jobs-toast" id="an-toast"></span> | |
| </div> | |
| </div> | |
| <div class="analysis-section"> | |
| <h2>Recent runs</h2> | |
| <div class="jobs-list" id="jobs"></div> | |
| </div> | |
| </div> | |
| <div id="panel-buddy" class="panel-embed" data-embed-tab="buddy"> | |
| <iframe title="Buddy" data-src="/buddy?embed=1"></iframe> | |
| </div> | |
| <div id="panel-mirror" class="panel-embed" data-embed-tab="mirror"> | |
| <iframe title="Mirror" data-src="/mirror?embed=1"></iframe> | |
| </div> | |
| <div id="panel-cards" class="panel-embed" data-embed-tab="cards"> | |
| <iframe title="Cards" data-src="/cards?embed=1"></iframe> | |
| </div> | |
| <div id="panel-quests" class="panel-embed" data-embed-tab="quests"> | |
| <iframe title="Quests" data-src="/quests?embed=1"></iframe> | |
| </div> | |
| <div id="panel-marketplace" class="panel-embed" data-embed-tab="marketplace"> | |
| <iframe title="Marketplace" data-src="/marketplace?embed=1"></iframe> | |
| </div> | |
| <div id="panel-arena" class="panel-embed" data-embed-tab="arena"> | |
| <iframe title="Arena" data-src="/arena?embed=1"></iframe> | |
| </div> | |
| <div id="panel-mood-ci" class="panel-embed" data-embed-tab="mood-ci"> | |
| <iframe title="Mood CI" data-src="/mood-ci?embed=1"></iframe> | |
| </div> | |
| <div id="panel-harness" class="panel-embed" data-embed-tab="harness"> | |
| <div class="harness-overview" style="padding:28px 32px;overflow-y:auto;height:100%;box-sizing:border-box;line-height:1.55;color:var(--fg,#e6edf3)"> | |
| <h2 style="font-size:22px;font-weight:700;margin:0 0 16px;letter-spacing:-0.3px"> | |
| 🦊 slyfox — give Claude Code your team's prior context, when it matters | |
| </h2> | |
| <p style="margin:0 0 12px;color:var(--fg,#e6edf3);font-size:14px"> | |
| <b>The pitch:</b> maintainers re-explain the same things to every new contributor (and to every fresh AI session). | |
| Their Claude Code sessions are a treasure trove — encoding <i>how</i> this team thinks about specific files, | |
| which optimisation patterns they reach for, where the dragons live. slyfox surfaces that prior context inside | |
| any Claude Code session in the same repo. | |
| </p> | |
| <p style="margin:0 0 12px;color:var(--fg,#e6edf3);font-size:14px"> | |
| <b>What's in this Space:</b> the harness for measuring whether that injected context actually helps an AI fix | |
| real bugs. 100 perf-flavoured tasks from <code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">huggingface/transformers</code>, | |
| gold patches included, runnable both with and without slyfox. | |
| </p> | |
| <p style="margin:0 0 8px;color:var(--fg,#e6edf3);font-size:14px"> | |
| <b>Three companion pieces</b> (one repo: | |
| <a href="https://github.com/huggingface/slyfox" target="_blank" rel="noopener" style="color:#79c0ff">github.com/huggingface/slyfox</a>): | |
| </p> | |
| <ul style="margin:0 0 12px;padding-left:22px;color:var(--fg,#e6edf3);font-size:14px"> | |
| <li style="margin-bottom:4px"><code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">plugin/</code> — the Claude Code plugin that does the injection (<code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">UserPromptSubmit</code> hook + retrieval).</li> | |
| <li style="margin-bottom:4px"><code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">tools/hf-upload-traces/</code> — uploads filtered local Claude Code traces to a HF dataset (privacy scrubbed).</li> | |
| <li style="margin-bottom:4px"><code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">jobs_scripts/</code> — HF Jobs for extracting emotion-direction vectors per model and projecting traces through them (the "persona" layer, owned by <a href="https://github.com/AidanZach" target="_blank" rel="noopener" style="color:#79c0ff">@AidanZach</a>).</li> | |
| <li style="margin-bottom:4px"><code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">harness/</code> — what you're looking at: 100-task SWE bench + LLM-judge.</li> | |
| </ul> | |
| <p style="margin:0 0 18px;color:var(--fg,#e6edf3);font-size:14px"> | |
| The diagram below walks the full flow end-to-end and highlights the <b>buddy</b> and <b>mirror</b> ideas. | |
| Both pipelines require open weights; closed-API tokens-only models can't be substituted at any of the boxes | |
| tagged <code style="background:var(--sidebar-bg,#1c2128);padding:1px 5px;border-radius:3px;font-size:13px">open</code>. | |
| </p> | |
| <div class="flow-diagram" style="margin:8px 0 24px;background:#08090a;border:1px solid #383b3f;border-radius:8px;padding:10px;overflow-x:auto"> | |
| <svg style="width:100%;max-width:1400px;height:auto;display:block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1400 940" font-family="Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif" font-size="13"> | |
| <defs> | |
| <!-- Arrow markers, themed --> | |
| <marker id="arr-grey" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="10" markerHeight="10" orient="auto"> | |
| <path d="M 0 0 L 10 5 L 0 10 z" fill="#8a8f98"/> | |
| </marker> | |
| <marker id="arr-blue" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="10" markerHeight="10" orient="auto"> | |
| <path d="M 0 0 L 10 5 L 0 10 z" fill="#5e6ad2"/> | |
| </marker> | |
| <marker id="arr-violet" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="10" markerHeight="10" orient="auto"> | |
| <path d="M 0 0 L 10 5 L 0 10 z" fill="#8b5cf6"/> | |
| </marker> | |
| <marker id="arr-lime" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="10" markerHeight="10" orient="auto"> | |
| <path d="M 0 0 L 10 5 L 0 10 z" fill="#e4f222"/> | |
| </marker> | |
| <marker id="arr-emerald" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="10" markerHeight="10" orient="auto"> | |
| <path d="M 0 0 L 10 5 L 0 10 z" fill="#27a644"/> | |
| </marker> | |
| <style> | |
| .bg-fill { fill: #08090a; } | |
| .panel { fill: #161718; stroke: #383b3f; stroke-width: 1; } | |
| .panel-strong { fill: #161718; stroke: #f7f8f8; stroke-width: 1.5; } | |
| .panel-data { fill: #0f1011; stroke: #5e6ad2; stroke-width: 1.5; } | |
| .panel-persona { fill: #0f1011; stroke: #8b5cf6; stroke-width: 1.5; } | |
| .panel-plugin { fill: #1a1c1d; stroke: #e4f222; stroke-width: 2; } | |
| .panel-buddy { fill: #0f1011; stroke: #02b8cc; stroke-width: 1.5; } | |
| .panel-mirror { fill: #0f1011; stroke: #ec4899; stroke-width: 1.5; } | |
| .panel-quests { fill: #0f1011; stroke: #f59e0b; stroke-width: 1.5; } | |
| .panel-augmented { fill: #0f1011; stroke: #27a644; stroke-width: 1.5; } | |
| .panel-callout-red { fill: #1a0e10; stroke: #eb5757; stroke-width: 1; } | |
| .title-main { font-size: 22px; font-weight: 600; fill: #f7f8f8; letter-spacing: -0.22px; } | |
| .subtitle-main { font-size: 13px; fill: #8a8f98; } | |
| .box-step { font-size: 11px; font-weight: 600; fill: #e4f222; letter-spacing: 0.08em; } | |
| .box-step-blue { font-size: 11px; font-weight: 600; fill: #5e6ad2; letter-spacing: 0.08em; } | |
| .box-step-violet { font-size: 11px; font-weight: 600; fill: #8b5cf6; letter-spacing: 0.08em; } | |
| .box-title { font-size: 14px; font-weight: 600; fill: #f7f8f8; } | |
| .box-title-lg { font-size: 16px; font-weight: 600; fill: #f7f8f8; } | |
| .box-body { font-size: 12px; fill: #d0d6e0; } | |
| .box-meta { font-size: 11px; fill: #8a8f98; font-style: italic; } | |
| .arrow-label { font-size: 10.5px; fill: #8a8f98; } | |
| .pill-bg { fill: #27a644; } | |
| .pill-bg-red { fill: #eb5757; } | |
| .pill-fg { fill: #08090a; font-size: 10px; font-weight: 700; } | |
| .concept-title { font-size: 15px; font-weight: 700; } | |
| .concept-buddy { fill: #02b8cc; } | |
| .concept-mirror { fill: #ec4899; } | |
| .concept-quests { fill: #f59e0b; } | |
| .concept-plugin { fill: #e4f222; } | |
| </style> | |
| </defs> | |
| <!-- background --> | |
| <rect width="1400" height="940" class="bg-fill"/> | |
| <!-- Title --> | |
| <text x="40" y="50" class="title-main">🦊 slyfox — buddy, mirror, quests</text> | |
| <text x="40" y="74" class="subtitle-main">Three modes of one plugin. All three need open weights, and all three are reachable from the plugin AND from the Space tab bar.</text> | |
| <!-- ====================================================== --> | |
| <!-- COLUMN 1 (x=40-280): data sources --> | |
| <!-- ====================================================== --> | |
| <g> | |
| <!-- Sources --> | |
| <rect x="40" y="110" width="240" height="100" rx="8" class="panel"/> | |
| <text x="56" y="134" class="box-step">1 · SOURCE</text> | |
| <text x="56" y="156" class="box-title">Claude Code sessions</text> | |
| <text x="56" y="178" class="box-body">~/.claude/projects/</text> | |
| <text x="56" y="194" class="box-body">turns · tools · files · timestamps</text> | |
| <!-- Upload --> | |
| <rect x="40" y="240" width="240" height="100" rx="8" class="panel"/> | |
| <text x="56" y="264" class="box-step">2 · PUBLISH</text> | |
| <text x="56" y="286" class="box-title">hf upload-traces</text> | |
| <text x="56" y="308" class="box-body">Filter secrets + PII + paths.</text> | |
| <text x="56" y="324" class="box-body">Push to HF dataset.</text> | |
| <!-- Hub --> | |
| <rect x="40" y="370" width="240" height="100" rx="8" class="panel-strong"/> | |
| <text x="56" y="394" class="box-step">3 · HUB</text> | |
| <text x="56" y="416" class="box-title">HF-slyfox/example-traces</text> | |
| <text x="56" y="438" class="box-body">52 sessions · 40k turns</text> | |
| <text x="56" y="454" class="box-body">arthurs-beast/transformers/*</text> | |
| </g> | |
| <!-- vertical arrows in col 1 --> | |
| <line x1="160" y1="212" x2="160" y2="237" stroke="#8a8f98" stroke-width="1.5" marker-end="url(#arr-grey)"/> | |
| <line x1="160" y1="342" x2="160" y2="367" stroke="#8a8f98" stroke-width="1.5" marker-end="url(#arr-grey)"/> | |
| <!-- ====================================================== --> | |
| <!-- COLUMN 2 (x=320-650): two HF Jobs side by side, stacked --> | |
| <!-- ====================================================== --> | |
| <!-- Embed Job --> | |
| <g> | |
| <rect x="320" y="120" width="330" height="150" rx="8" class="panel-data"/> | |
| <text x="336" y="144" class="box-step-blue">3a · EMBED JOB (open weights)</text> | |
| <rect x="556" y="128" width="78" height="18" rx="9" class="pill-bg"/> | |
| <text x="595" y="142" class="pill-fg" text-anchor="middle">OPEN ✓</text> | |
| <text x="336" y="170" class="box-title">Qwen3-Embedding-0.6B → FAISS</text> | |
| <text x="336" y="192" class="box-body">Chunk each session → embed locally on the</text> | |
| <text x="336" y="208" class="box-body">job runner → write chunks.parquet + faiss.index</text> | |
| <text x="336" y="230" class="box-meta">→ HF-slyfox/example-traces-embeddings</text> | |
| <text x="336" y="246" class="box-meta"> 8,908 chunks · 1024-dim · 3,848 are yours</text> | |
| </g> | |
| <!-- Persona Job --> | |
| <g> | |
| <rect x="320" y="290" width="330" height="200" rx="8" class="panel-persona"/> | |
| <text x="336" y="314" class="box-step-violet">3b · PERSONA JOB (open weights)</text> | |
| <rect x="556" y="298" width="78" height="18" rx="9" class="pill-bg"/> | |
| <text x="595" y="312" class="pill-fg" text-anchor="middle">OPEN ✓</text> | |
| <text x="336" y="340" class="box-title">Aidan's emotion-scope pipeline</text> | |
| <text x="336" y="362" class="box-body"><tspan font-weight="600">extract_vectors.py:</tspan> load open model, layer-</text> | |
| <text x="336" y="378" class="box-body">sweep residual stream, save 20 emotion vectors.</text> | |
| <text x="336" y="402" class="box-body"><tspan font-weight="600">analyze_traces.py:</tspan> replay each session through</text> | |
| <text x="336" y="418" class="box-body">the same model → per-turn projection → parquet.</text> | |
| <text x="336" y="442" class="box-meta">Needs residual-stream access at chosen layers.</text> | |
| <text x="336" y="458" class="box-meta">Closed APIs return tokens only — impossible.</text> | |
| <rect x="336" y="468" width="120" height="18" rx="9" class="pill-bg-red"/> | |
| <text x="396" y="482" class="pill-fg" text-anchor="middle">CLOSED API: ✗</text> | |
| </g> | |
| <!-- arrows hub → embed, hub → persona --> | |
| <path d="M 280 410 C 300 410, 300 195, 320 195" fill="none" stroke="#5e6ad2" stroke-width="1.5" marker-end="url(#arr-blue)"/> | |
| <path d="M 280 430 C 300 430, 300 380, 320 380" fill="none" stroke="#8b5cf6" stroke-width="1.5" marker-end="url(#arr-violet)"/> | |
| <!-- ====================================================== --> | |
| <!-- COLUMN 3 (x=720-1020): THE PLUGIN — central --> | |
| <!-- ====================================================== --> | |
| <g> | |
| <rect x="720" y="180" width="300" height="380" rx="12" class="panel-plugin"/> | |
| <text x="740" y="208" class="box-step">4 · RUNTIME</text> | |
| <text x="740" y="232" class="box-title-lg">slyfox plugin</text> | |
| <text x="740" y="252" class="box-body">UserPromptSubmit hook · install.py</text> | |
| <text x="740" y="268" class="box-body">runs inside Claude Code on your machine</text> | |
| <line x1="740" y1="284" x2="1000" y2="284" stroke="#383b3f" stroke-width="1"/> | |
| <text x="740" y="306" class="box-body" font-weight="600" fill="#e4f222">on every user prompt:</text> | |
| <text x="740" y="326" class="box-body">a · embed the prompt (same model as index)</text> | |
| <text x="740" y="342" class="box-body">b · FAISS top-K → candidate prior turns</text> | |
| <text x="740" y="358" class="box-body">c · persona rerank (project prompt + match</text> | |
| <text x="740" y="374" class="box-body"> trajectory shape against candidates)</text> | |
| <text x="740" y="390" class="box-body">d · inject excerpts via additionalContext</text> | |
| <line x1="740" y1="408" x2="1000" y2="408" stroke="#383b3f" stroke-width="1"/> | |
| <text x="740" y="430" class="box-body" font-weight="600" fill="#e4f222">all three modes accessible from:</text> | |
| <text x="740" y="450" class="box-body">· /slyfox-expert /-status /-refresh</text> | |
| <text x="740" y="466" class="box-body"> (slash commands inside Claude Code)</text> | |
| <text x="740" y="488" class="box-body">· tab bar at HF-slyfox/slyfox</text> | |
| <text x="740" y="504" class="box-body"> (Buddy · Mirror · Quests tabs)</text> | |
| <text x="740" y="540" class="box-meta">≤1500 token budget · 1400ms timeout · fail-open</text> | |
| </g> | |
| <!-- arrows from jobs → plugin (clean horizontal/diagonal lines, no overlap) --> | |
| <path d="M 650 195 L 720 240" fill="none" stroke="#5e6ad2" stroke-width="1.5" marker-end="url(#arr-blue)"/> | |
| <path d="M 650 380 L 720 390" fill="none" stroke="#8b5cf6" stroke-width="1.5" marker-end="url(#arr-violet)"/> | |
| <text x="680" y="200" class="arrow-label">chunks.parquet + faiss.index</text> | |
| <text x="680" y="406" class="arrow-label">trajectory.parquet</text> | |
| <!-- ====================================================== --> | |
| <!-- COLUMN 4 (x=1070-1360): three modes --> | |
| <!-- ====================================================== --> | |
| <!-- Buddy --> | |
| <g> | |
| <rect x="1070" y="120" width="290" height="120" rx="8" class="panel-buddy"/> | |
| <text x="1088" y="146" class="concept-title concept-buddy">🫂 Buddy</text> | |
| <text x="1088" y="170" class="box-body">"Retrieve from someone who has</text> | |
| <text x="1088" y="186" class="box-body">already worked here." You / colleague /</text> | |
| <text x="1088" y="202" class="box-body">the whole org's pooled sessions.</text> | |
| <text x="1088" y="224" class="box-meta">Uses 3a (semantic) + 3b (persona rerank).</text> | |
| </g> | |
| <!-- Mirror --> | |
| <g> | |
| <rect x="1070" y="260" width="290" height="120" rx="8" class="panel-mirror"/> | |
| <text x="1088" y="286" class="concept-title concept-mirror">🪞 Mirror</text> | |
| <text x="1088" y="310" class="box-body">"Project everything through the same</text> | |
| <text x="1088" y="326" class="box-body">open model — index, query, persona.</text> | |
| <text x="1088" y="342" class="box-body">Two different X's = two activation spaces."</text> | |
| <text x="1088" y="364" class="box-meta">Same-model invariant — enforced by 3b.</text> | |
| </g> | |
| <!-- Quests --> | |
| <g> | |
| <rect x="1070" y="400" width="290" height="160" rx="8" class="panel-quests"/> | |
| <text x="1088" y="426" class="concept-title concept-quests">🎯 Quests</text> | |
| <text x="1088" y="450" class="box-body">Persona trajectories recommend a next</text> | |
| <text x="1088" y="466" class="box-body">task that fits your current emotional arc.</text> | |
| <text x="1088" y="482" class="box-body">A Quest = {issue, base_commit, target_files,</text> | |
| <text x="1088" y="498" class="box-body">judge_rubric} — same schema as the harness.</text> | |
| <text x="1088" y="520" class="box-meta">Driven by 3b (emotion trajectories).</text> | |
| <text x="1088" y="540" class="box-meta">Open inside the plugin OR from the tab bar.</text> | |
| </g> | |
| <!-- arrows plugin → buddy/mirror/quests (clean, separate vertical positions on plugin's right edge) --> | |
| <path d="M 1020 250 C 1045 250, 1045 180, 1070 180" fill="none" stroke="#02b8cc" stroke-width="1.5" marker-end="url(#arr-grey)"/> | |
| <path d="M 1020 330 C 1045 330, 1045 320, 1070 320" fill="none" stroke="#ec4899" stroke-width="1.5" marker-end="url(#arr-grey)"/> | |
| <path d="M 1020 430 C 1045 430, 1045 470, 1070 470" fill="none" stroke="#f59e0b" stroke-width="1.5" marker-end="url(#arr-grey)"/> | |
| <!-- feedback arrows (B/M/Q ARE the plugin's behavior — small dashed loop-back) --> | |
| <path d="M 1070 220 C 1040 220, 1040 280, 1020 280" fill="none" stroke="#02b8cc" stroke-width="1" stroke-dasharray="3 3" opacity="0.6"/> | |
| <path d="M 1070 360 C 1040 360, 1040 360, 1020 360" fill="none" stroke="#ec4899" stroke-width="1" stroke-dasharray="3 3" opacity="0.6"/> | |
| <path d="M 1070 540 C 1040 540, 1040 460, 1020 460" fill="none" stroke="#f59e0b" stroke-width="1" stroke-dasharray="3 3" opacity="0.6"/> | |
| <!-- ====================================================== --> | |
| <!-- BOTTOM ROW: augmented Claude (left) + why-open (right) --> | |
| <!-- ====================================================== --> | |
| <g> | |
| <rect x="40" y="610" width="640" height="180" rx="10" class="panel-augmented"/> | |
| <text x="60" y="638" class="box-step" fill="#27a644">5 · OUTPUT</text> | |
| <text x="60" y="664" class="box-title-lg">Claude Code session, augmented</text> | |
| <text x="60" y="688" class="box-body">Your prompt + fenced trace excerpts ("from buddy: ArthurZ · file-expert: …")</text> | |
| <text x="60" y="706" class="box-body">+ persona-aware Quest suggestion ("decisive → ready to land")</text> | |
| <text x="60" y="730" class="box-body">+ Mirror guarantee (every projection through the same open model).</text> | |
| <text x="60" y="760" class="box-meta">harness/run_eval.py measures the LLM-judge lift on 50 perf bugs in transformers.</text> | |
| <text x="60" y="778" class="box-meta">Result so far: signal exists per task (PR #44231 +3, #35184 +5, …) but sits inside generation noise at n=50.</text> | |
| </g> | |
| <!-- Plugin → Augmented Claude arrow --> | |
| <path d="M 870 560 L 870 600" fill="none" stroke="#e4f222" stroke-width="2" marker-end="url(#arr-lime)"/> | |
| <text x="880" y="585" class="arrow-label" fill="#e4f222">runtime output</text> | |
| <!-- Why-open callout --> | |
| <g> | |
| <rect x="720" y="610" width="640" height="180" rx="10" class="panel-callout-red"/> | |
| <text x="740" y="638" class="box-title-lg" fill="#eb5757">⚠ Why open weights — non-negotiable</text> | |
| <circle cx="748" cy="664" r="3" fill="#27a644"/> | |
| <text x="760" y="668" class="box-body"><tspan font-weight="600">Residual-stream access.</tspan> Persona extraction reads layer-L activations; tokens-only APIs can't do this.</text> | |
| <circle cx="748" cy="690" r="3" fill="#27a644"/> | |
| <text x="760" y="694" class="box-body"><tspan font-weight="600">Mirror (same model both sides).</tspan> Closed APIs silently swap model versions across calls — reproducibility = open.</text> | |
| <circle cx="748" cy="716" r="3" fill="#27a644"/> | |
| <text x="760" y="720" class="box-body"><tspan font-weight="600">Live per-turn projection.</tspan> Persona scoring runs as you type — must be a model you can hot-load.</text> | |
| <circle cx="748" cy="744" r="3" fill="#eb5757"/> | |
| <text x="760" y="748" class="box-body"><tspan font-weight="600">Buddy works *better* with open weights</tspan> too: index, query, AND rerank in the same activation space.</text> | |
| <text x="740" y="778" class="box-meta">Closed APIs degrade Buddy to text-only retrieval and disable Mirror + Quests entirely.</text> | |
| </g> | |
| <!-- Footer --> | |
| <text x="700" y="910" class="subtitle-main" text-anchor="middle">huggingface/slyfox · github.com/huggingface/slyfox · all three modes reachable from the plugin + the Space tab bar</text> | |
| </svg> | |
| </div> | |
| <p style="margin:14px 0 0;color:var(--muted,#8b949e);font-size:12px"> | |
| <a href="https://hf-slyfox-harness.hf.space/?__theme=dark" target="_blank" rel="noopener" style="color:#79c0ff">Open the full harness Space →</a> | |
| for the plugin / persona pipeline / results tabs. | |
| </p> | |
| </div> | |
| </div> | |
| </main> | |
| </div> | |
| <script> | |
| const _params = new URLSearchParams(location.search); | |
| // Anonymous per-browser user id for /api/jobs and related endpoints. | |
| function getUserId() { | |
| let id = localStorage.getItem("slyfox.user_id"); | |
| if (!id) { | |
| id = (crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random().toString(36).slice(2)); | |
| id = id.replace(/-/g, ""); | |
| localStorage.setItem("slyfox.user_id", id); | |
| } | |
| return id; | |
| } | |
| const USER_ID = getUserId(); | |
| const persistHeaders = () => ({ "X-User-Id": USER_ID }); | |
| // ---- Jobs panel ---- | |
| let jobsPanelReady = false; | |
| const jobsRoot = document.getElementById("jobs"); | |
| const jobsExpanded = new Set(); | |
| const jobsLogCache = new Map(); | |
| const JOBS_KIND_ICON = { extract: "🧠", analyze: "🧪" }; | |
| function jobsStatusClass(s) { | |
| if (!s) return "unknown"; | |
| s = String(s).toLowerCase(); | |
| if (s.includes("run")) return "running"; | |
| if (s.includes("queue") || s.includes("pend") || s.includes("start")) return "queued"; | |
| if (s.includes("complete") || s.includes("succ") || s.includes("done")) return "completed"; | |
| if (s.includes("fail") || s.includes("err")) return "failed"; | |
| if (s.includes("cancel")) return "cancelled"; | |
| return "unknown"; | |
| } | |
| function jobsFmtElapsed(s) { | |
| if (!s && s !== 0) return "—"; | |
| const m = Math.floor(s / 60), sec = s % 60; | |
| if (m === 0) return sec + "s"; | |
| if (m < 60) return m + "m " + sec + "s"; | |
| return Math.floor(m / 60) + "h " + (m % 60) + "m"; | |
| } | |
| function jobsTitle(j) { | |
| if (j.kind === "extract") return "Extract " + (j.params?.model_id || "?"); | |
| if (j.kind === "analyze") { | |
| return "Analyze " + (j.params?.source || "?") + " · " + (j.params?.model_id || "?"); | |
| } | |
| return j.job_id; | |
| } | |
| function jobsSubtitle(j) { | |
| if (j.kind === "extract") return "→ " + (j.params?.output || ""); | |
| if (j.kind === "analyze") { | |
| const n = j.params?.n_traces ?? "?"; | |
| return n + " traces → " + (j.params?.output || ""); | |
| } | |
| return ""; | |
| } | |
| function jobsParamRow(label, value) { | |
| if (!value) return ""; | |
| return `<div><b>${label}</b>: ${jobsEsc(String(value))}</div>`; | |
| } | |
| function jobsEsc(s) { | |
| return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); | |
| } | |
| function renderJobsList(jobs) { | |
| if (!jobs.length) { | |
| jobsRoot.innerHTML = `<div class="jobs-empty">No jobs yet — submit one above to get started.</div>`; | |
| return; | |
| } | |
| jobsRoot.innerHTML = jobs.map((j) => { | |
| const sCls = jobsStatusClass(j.live?.status); | |
| const elapsed = jobsFmtElapsed(j.live?.elapsed_s); | |
| const isOpen = jobsExpanded.has(j.job_id); | |
| const logs = (jobsLogCache.get(j.job_id) || []).join("\n"); | |
| const canCancel = sCls === "running" || sCls === "queued"; | |
| return ` | |
| <div class="job-row ${isOpen ? "expanded" : ""}" data-id="${j.job_id}"> | |
| <span class="job-dot ${sCls}" title="${j.live?.status ?? "unknown"}"></span> | |
| <span class="job-kind">${JOBS_KIND_ICON[j.kind] || "▶"}</span> | |
| <div> | |
| <div class="job-title">${jobsEsc(jobsTitle(j))}<span class="id">${j.job_id.slice(0, 12)}</span></div> | |
| <div class="job-sub">${jobsEsc(jobsSubtitle(j))}</div> | |
| </div> | |
| <div class="job-meta"><span class="status">${j.live?.status || "unknown"}</span>${elapsed}</div> | |
| <span class="job-chevron">▶</span> | |
| </div> | |
| <div class="job-detail" data-id="${j.job_id}"> | |
| <div class="params"> | |
| ${jobsParamRow("submitted", j.submitted_at)} | |
| ${jobsParamRow("requester", j.requester)} | |
| ${jobsParamRow("flavor", j.params?.flavor || j.live?.flavor)} | |
| ${jobsParamRow("started", j.live?.started_at)} | |
| ${jobsParamRow("ended", j.live?.ended_at)} | |
| ${j.kind === "analyze" ? jobsParamRow("source", j.params?.source) : ""} | |
| ${j.kind === "analyze" ? jobsParamRow("prefix", j.params?.prefix) : ""} | |
| ${j.kind === "analyze" ? jobsParamRow("n_traces", j.params?.n_traces) : ""} | |
| ${jobsParamRow("output", j.params?.output)} | |
| </div> | |
| <div class="logs" id="logs-${j.job_id}">${logs ? jobsEsc(logs) : '<span style="color:var(--muted)">no logs yet</span>'}</div> | |
| <div class="job-detail-actions"> | |
| <button type="button" class="jobs-btn secondary" data-act="refresh-logs" data-id="${j.job_id}">Refresh logs</button> | |
| ${canCancel ? `<button type="button" class="jobs-btn danger" data-act="cancel" data-id="${j.job_id}">Cancel</button>` : ""} | |
| </div> | |
| </div>`; | |
| }).join(""); | |
| } | |
| async function jobsApi(path, opts = {}) { | |
| const headers = { "X-User-Id": USER_ID, ...(opts.headers || {}) }; | |
| if (opts.body && typeof opts.body !== "string") { | |
| headers["Content-Type"] = "application/json"; | |
| opts.body = JSON.stringify(opts.body); | |
| } | |
| const res = await fetch(path, { ...opts, headers }); | |
| if (!res.ok) { | |
| let err; | |
| try { err = (await res.json()).detail || res.statusText; } catch { err = res.statusText; } | |
| throw new Error(err); | |
| } | |
| return res.json(); | |
| } | |
| let jobsRefreshInFlight = false; | |
| async function refreshJobsList() { | |
| if (jobsRefreshInFlight) return; | |
| jobsRefreshInFlight = true; | |
| try { | |
| renderJobsList(await jobsApi("/api/jobs")); | |
| } catch (e) { | |
| jobsRoot.innerHTML = `<div class="jobs-empty" style="color:#dc2626">${jobsEsc(e.message)}</div>`; | |
| } finally { | |
| jobsRefreshInFlight = false; | |
| } | |
| } | |
| async function refreshJobLogs(id) { | |
| try { | |
| const data = await jobsApi(`/api/jobs/${id}/logs?tail=200`); | |
| jobsLogCache.set(id, data.lines || []); | |
| const el = document.getElementById("logs-" + id); | |
| if (el) { | |
| el.textContent = (data.lines || []).join("\n"); | |
| el.scrollTop = el.scrollHeight; | |
| } | |
| } catch (e) { | |
| const el = document.getElementById("logs-" + id); | |
| if (el) el.textContent = "(logs: " + e.message + ")"; | |
| } | |
| } | |
| function bindJobsSubmit(buttonId, toastId, build, endpoint) { | |
| const btn = document.getElementById(buttonId); | |
| const toast = document.getElementById(toastId); | |
| btn.addEventListener("click", async () => { | |
| btn.disabled = true; | |
| toast.textContent = "submitting…"; | |
| toast.className = "jobs-toast"; | |
| try { | |
| const job = await jobsApi(endpoint, { method: "POST", body: build() }); | |
| toast.textContent = "queued: " + job.job_id; | |
| toast.className = "jobs-toast ok"; | |
| refreshJobsList(); | |
| } catch (e) { | |
| toast.textContent = "✗ " + e.message; | |
| toast.className = "jobs-toast err"; | |
| } finally { | |
| btn.disabled = false; | |
| } | |
| }); | |
| } | |
| function initJobsPanel() { | |
| if (jobsPanelReady) return; | |
| jobsPanelReady = true; | |
| bindJobsSubmit("ex-submit", "ex-toast", () => ({ | |
| model_id: document.getElementById("ex-model").value.trim(), | |
| flavor: document.getElementById("ex-flavor").value, | |
| }), "/api/models/extract"); | |
| bindJobsSubmit("an-submit", "an-toast", () => ({ | |
| source: document.getElementById("an-source").value.trim(), | |
| prefix: document.getElementById("an-prefix").value.trim(), | |
| n_traces: parseInt(document.getElementById("an-n").value, 10), | |
| model_id: document.getElementById("an-model").value.trim(), | |
| flavor: document.getElementById("an-flavor").value, | |
| }), "/api/analyses"); | |
| jobsRoot.addEventListener("click", async (ev) => { | |
| const actBtn = ev.target.closest("button[data-act]"); | |
| if (actBtn) { | |
| const id = actBtn.dataset.id; | |
| if (actBtn.dataset.act === "refresh-logs") { | |
| await refreshJobLogs(id); | |
| return; | |
| } | |
| if (actBtn.dataset.act === "cancel") { | |
| if (!confirm("Cancel job " + id + "?")) return; | |
| try { | |
| await jobsApi(`/api/jobs/${id}/cancel`, { method: "POST" }); | |
| refreshJobsList(); | |
| } catch (e) { alert("cancel failed: " + e.message); } | |
| } | |
| return; | |
| } | |
| const row = ev.target.closest(".job-row"); | |
| if (!row) return; | |
| const id = row.dataset.id; | |
| if (jobsExpanded.has(id)) jobsExpanded.delete(id); | |
| else { | |
| jobsExpanded.add(id); | |
| await refreshJobLogs(id); | |
| } | |
| refreshJobsList(); | |
| }); | |
| refreshJobsList(); | |
| setInterval(refreshJobsList, 5000); | |
| setInterval(() => jobsExpanded.forEach((id) => refreshJobLogs(id)), 3000); | |
| } | |
| // ---- Tabs ---- | |
| const panelJobs = document.getElementById("panel-jobs"); | |
| const embedPanels = document.querySelectorAll(".panel-embed[data-embed-tab]"); | |
| function embedSrcFor(panel) { | |
| let src = panel.querySelector("iframe")?.dataset.src || ""; | |
| if (panel.dataset.embedTab !== "cards") return src; | |
| const cardId = location.hash.slice(1) || _params.get("card") || ""; | |
| if (!cardId) return src; | |
| return src + "#" + encodeURIComponent(cardId); | |
| } | |
| function loadEmbedPanel(panel) { | |
| const iframe = panel.querySelector("iframe"); | |
| if (!iframe?.dataset.src || iframe.dataset.loaded) return; | |
| iframe.src = embedSrcFor(panel); | |
| iframe.dataset.loaded = "1"; | |
| } | |
| function syncCardsDeepLink() { | |
| const panel = document.querySelector('.panel-embed[data-embed-tab="cards"]'); | |
| const iframe = panel?.querySelector("iframe"); | |
| if (!iframe?.dataset.loaded) return; | |
| const nextSrc = embedSrcFor(panel); | |
| if (iframe.getAttribute("src") !== nextSrc) iframe.src = nextSrc; | |
| } | |
| function activateTab(tab) { | |
| document.querySelectorAll(".tab-btn").forEach((b) => { | |
| b.classList.toggle("active", b.dataset.tab === tab); | |
| }); | |
| panelJobs.classList.toggle("visible", tab === "jobs"); | |
| embedPanels.forEach((panel) => { | |
| const show = panel.dataset.embedTab === tab; | |
| panel.classList.toggle("visible", show); | |
| if (show) loadEmbedPanel(panel); | |
| }); | |
| if (tab === "jobs") initJobsPanel(); | |
| } | |
| document.querySelectorAll(".tab-btn").forEach((btn) => { | |
| btn.addEventListener("click", () => activateTab(btn.dataset.tab)); | |
| }); | |
| window.addEventListener("hashchange", syncCardsDeepLink); | |
| const tabParam = _params.get("tab"); | |
| const defaultTab = "buddy"; | |
| activateTab( | |
| tabParam && document.querySelector(`.tab-btn[data-tab="${tabParam}"]`) | |
| ? tabParam | |
| : defaultTab | |
| ); | |
| </script> | |
| </body> | |
| </html> | |