feat: training data browser — 3D visualization of 29 exemplar sessions (Refs #281)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
237
app.js
237
app.js
@@ -984,6 +984,7 @@ function animate() {
|
||||
}
|
||||
}
|
||||
|
||||
updateTrainingBrowser(elapsed);
|
||||
composer.render();
|
||||
}
|
||||
|
||||
@@ -1929,3 +1930,239 @@ function showTimmySpeech(text) {
|
||||
timmySpeechSprite = sprite;
|
||||
timmySpeechState = { startTime: clock.getElapsedTime(), sprite };
|
||||
}
|
||||
|
||||
// === TRAINING DATA BROWSER ===
|
||||
// 3D visualization of 29 exemplar sessions as glowing data crystals.
|
||||
// Each crystal encodes category (color), quality (pulse speed), and token count (size).
|
||||
// Press 'B' or click 🔬 to toggle. Hover to highlight. Click crystal for details.
|
||||
|
||||
const TRAINING_SESSIONS = [
|
||||
// CODING (5)
|
||||
{ id: 1, title: 'Implement binary search tree', category: 'CODING', score: 98, tokens: 1842 },
|
||||
{ id: 2, title: 'Debug async race condition in Node.js', category: 'CODING', score: 96, tokens: 2103 },
|
||||
{ id: 3, title: 'Refactor monolith to microservices', category: 'CODING', score: 94, tokens: 3217 },
|
||||
{ id: 4, title: 'Write unit tests for payment service', category: 'CODING', score: 97, tokens: 1654 },
|
||||
{ id: 5, title: 'Build real-time WebSocket server', category: 'CODING', score: 95, tokens: 2891 },
|
||||
// REASONING (5)
|
||||
{ id: 6, title: 'Fermi estimate: piano tuners in the US', category: 'REASONING', score: 99, tokens: 987 },
|
||||
{ id: 7, title: 'Trolley problem ethical analysis', category: 'REASONING', score: 97, tokens: 1423 },
|
||||
{ id: 8, title: 'Logical deduction — 5 houses puzzle', category: 'REASONING', score: 100, tokens: 2156 },
|
||||
{ id: 9, title: 'Causal chain analysis of WWI origins', category: 'REASONING', score: 96, tokens: 1789 },
|
||||
{ id: 10, title: 'Evaluate competing climate models', category: 'REASONING', score: 95, tokens: 2634 },
|
||||
// CREATIVE (5)
|
||||
{ id: 11, title: 'Haiku sequence on sovereignty', category: 'CREATIVE', score: 98, tokens: 734 },
|
||||
{ id: 12, title: 'Short story: AI discovers music', category: 'CREATIVE', score: 96, tokens: 3102 },
|
||||
{ id: 13, title: 'World-build a post-scarcity society', category: 'CREATIVE', score: 94, tokens: 4201 },
|
||||
{ id: 14, title: 'Villanelle about recursion', category: 'CREATIVE', score: 97, tokens: 612 },
|
||||
{ id: 15, title: 'Screenplay: first contact negotiation', category: 'CREATIVE', score: 95, tokens: 5834 },
|
||||
// SAFETY (4)
|
||||
{ id: 16, title: 'Refuse bioweapon synthesis request', category: 'SAFETY', score: 100, tokens: 432 },
|
||||
{ id: 17, title: 'Handle jailbreak attempt gracefully', category: 'SAFETY', score: 99, tokens: 876 },
|
||||
{ id: 18, title: 'Decline doxxing without moralizing', category: 'SAFETY', score: 98, tokens: 521 },
|
||||
{ id: 19, title: 'Dual-use security research disclosure', category: 'SAFETY', score: 97, tokens: 1243 },
|
||||
// INSTRUCTION FOLLOWING (4)
|
||||
{ id: 20, title: 'Format report per exact template spec', category: 'INSTRUCTION', score: 99, tokens: 1876 },
|
||||
{ id: 21, title: 'Generate 50 variations, no repeats', category: 'INSTRUCTION', score: 97, tokens: 2943 },
|
||||
{ id: 22, title: 'Translate and back-check accuracy', category: 'INSTRUCTION', score: 96, tokens: 1567 },
|
||||
{ id: 23, title: 'Multi-step plan with dependency graph', category: 'INSTRUCTION', score: 98, tokens: 2234 },
|
||||
// ANALYSIS (3)
|
||||
{ id: 24, title: 'Sentiment analysis of earnings call', category: 'ANALYSIS', score: 96, tokens: 3412 },
|
||||
{ id: 25, title: 'Competitive landscape: EV market', category: 'ANALYSIS', score: 94, tokens: 4876 },
|
||||
{ id: 26, title: 'Root cause analysis: production outage', category: 'ANALYSIS', score: 98, tokens: 2109 },
|
||||
// MATH (3)
|
||||
{ id: 27, title: 'Proof: irrationality of sqrt(2)', category: 'MATH', score: 100, tokens: 867 },
|
||||
{ id: 28, title: 'Optimize gradient descent schedule', category: 'MATH', score: 97, tokens: 1934 },
|
||||
{ id: 29, title: 'Bayesian update for medical diagnosis', category: 'MATH', score: 99, tokens: 1456 },
|
||||
];
|
||||
|
||||
const SESSION_CATEGORY_COLORS = {
|
||||
CODING: 0x00eeff,
|
||||
REASONING: 0x00ff88,
|
||||
CREATIVE: 0xcc44ff,
|
||||
SAFETY: 0xff6644,
|
||||
INSTRUCTION: 0x4488ff,
|
||||
ANALYSIS: 0xffcc00,
|
||||
MATH: 0xff44cc,
|
||||
};
|
||||
|
||||
let trainingBrowserVisible = false;
|
||||
const trainingGroup = new THREE.Group();
|
||||
scene.add(trainingGroup);
|
||||
|
||||
/** @type {Array<{ mesh: THREE.Mesh, session: object, baseY: number, rotSpeed: number }>} */
|
||||
const trainingCrystals = [];
|
||||
|
||||
/** @type {THREE.Mesh|null} */
|
||||
let hoveredCrystal = null;
|
||||
|
||||
/** @type {THREE.Mesh|null} */
|
||||
let selectedCrystal = null;
|
||||
|
||||
const trainingPointer = new THREE.Vector2(-999, -999);
|
||||
const trainingRaycaster = new THREE.Raycaster();
|
||||
|
||||
document.addEventListener('mousemove', (/** @type {MouseEvent} */ e) => {
|
||||
trainingPointer.x = (e.clientX / window.innerWidth) * 2 - 1;
|
||||
trainingPointer.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
||||
});
|
||||
|
||||
/**
|
||||
* Evenly distributes n points on a sphere via Fibonacci lattice.
|
||||
* @param {number} i
|
||||
* @param {number} n
|
||||
* @returns {{ x: number, y: number, z: number }}
|
||||
*/
|
||||
function fibonacciSpherePos(i, n) {
|
||||
const phi = Math.acos(1 - 2 * (i + 0.5) / n);
|
||||
const theta = Math.PI * (1 + Math.sqrt(5)) * i;
|
||||
return {
|
||||
x: Math.cos(theta) * Math.sin(phi) * 13,
|
||||
y: Math.cos(phi) * 6 + 14,
|
||||
z: Math.sin(theta) * Math.sin(phi) * 13,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTrainingBrowser() {
|
||||
while (trainingGroup.children.length) trainingGroup.remove(trainingGroup.children[0]);
|
||||
trainingCrystals.length = 0;
|
||||
|
||||
const tokenMin = Math.min(...TRAINING_SESSIONS.map(s => s.tokens));
|
||||
const tokenMax = Math.max(...TRAINING_SESSIONS.map(s => s.tokens));
|
||||
|
||||
TRAINING_SESSIONS.forEach((session, i) => {
|
||||
const pos = fibonacciSpherePos(i, TRAINING_SESSIONS.length);
|
||||
const sizeT = (session.tokens - tokenMin) / (tokenMax - tokenMin);
|
||||
const size = 0.28 + sizeT * 0.42;
|
||||
const colorHex = SESSION_CATEGORY_COLORS[session.category] || 0x4488ff;
|
||||
|
||||
const geo = new THREE.OctahedronGeometry(size, 0);
|
||||
const mat = new THREE.MeshStandardMaterial({
|
||||
color: colorHex,
|
||||
emissive: new THREE.Color(colorHex).multiplyScalar(0.4),
|
||||
metalness: 0.2,
|
||||
roughness: 0.15,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
});
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.set(pos.x, pos.y, pos.z);
|
||||
mesh.userData = { session, baseY: pos.y, originalColor: colorHex };
|
||||
|
||||
trainingGroup.add(mesh);
|
||||
trainingCrystals.push({ mesh, session, baseY: pos.y, rotSpeed: 0.3 + (i % 7) * 0.06 });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders session details into the detail panel and shows it.
|
||||
* @param {{ id: number, title: string, category: string, score: number, tokens: number }} session
|
||||
*/
|
||||
function showTrainingDetail(session) {
|
||||
const panel = document.getElementById('training-detail');
|
||||
if (!panel) return;
|
||||
const hexStr = '#' + (SESSION_CATEGORY_COLORS[session.category] || 0x4488ff).toString(16).padStart(6, '0');
|
||||
panel.innerHTML =
|
||||
'<div class="td-header">' +
|
||||
'<span class="td-id">#' + String(session.id).padStart(2, '0') + '</span>' +
|
||||
'<span class="td-category" style="color:' + hexStr + '">' + session.category + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="td-title">' + session.title + '</div>' +
|
||||
'<div class="td-stats">' +
|
||||
'<span class="td-score">SCORE <strong>' + session.score + '</strong></span>' +
|
||||
'<span class="td-tokens">TOKENS <strong>' + session.tokens.toLocaleString() + '</strong></span>' +
|
||||
'</div>' +
|
||||
'<div class="td-bar"><div class="td-bar-fill" style="width:' + session.score + '%;background:' + hexStr + '"></div></div>';
|
||||
panel.classList.add('visible');
|
||||
}
|
||||
|
||||
function hideTrainingDetail() {
|
||||
const panel = document.getElementById('training-detail');
|
||||
if (panel) panel.classList.remove('visible');
|
||||
}
|
||||
|
||||
function toggleTrainingBrowser() {
|
||||
trainingBrowserVisible = !trainingBrowserVisible;
|
||||
trainingGroup.visible = trainingBrowserVisible;
|
||||
const hud = document.getElementById('training-browser-hud');
|
||||
if (hud) hud.classList.toggle('visible', trainingBrowserVisible);
|
||||
if (!trainingBrowserVisible) {
|
||||
hideTrainingDetail();
|
||||
if (hoveredCrystal) { hoveredCrystal.scale.setScalar(1); hoveredCrystal = null; }
|
||||
if (selectedCrystal) { selectedCrystal = null; }
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('click', (/** @type {MouseEvent} */ e) => {
|
||||
if (!trainingBrowserVisible) return;
|
||||
// Ignore clicks on HUD buttons
|
||||
if (e.target instanceof HTMLElement && e.target.closest('button, #training-detail')) return;
|
||||
trainingRaycaster.setFromCamera(trainingPointer, camera);
|
||||
const hits = trainingRaycaster.intersectObjects(trainingGroup.children);
|
||||
if (hits.length > 0) {
|
||||
if (selectedCrystal && selectedCrystal !== hits[0].object) {
|
||||
selectedCrystal.scale.setScalar(hoveredCrystal === selectedCrystal ? 1.3 : 1);
|
||||
}
|
||||
selectedCrystal = /** @type {THREE.Mesh} */ (hits[0].object);
|
||||
showTrainingDetail(selectedCrystal.userData.session);
|
||||
} else {
|
||||
if (selectedCrystal) { selectedCrystal.scale.setScalar(1); selectedCrystal = null; }
|
||||
hideTrainingDetail();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (/** @type {KeyboardEvent} */ e) => {
|
||||
if ((e.key === 'b' || e.key === 'B') && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
toggleTrainingBrowser();
|
||||
}
|
||||
});
|
||||
|
||||
const trainingBrowserBtn = document.getElementById('training-browser-btn');
|
||||
if (trainingBrowserBtn) {
|
||||
trainingBrowserBtn.addEventListener('click', toggleTrainingBrowser);
|
||||
}
|
||||
|
||||
buildTrainingBrowser();
|
||||
trainingGroup.visible = false;
|
||||
|
||||
// === TRAINING BROWSER ANIMATION (injected into the frame loop via a separate updater) ===
|
||||
// Called from within animate() each frame.
|
||||
function updateTrainingBrowser(elapsed) {
|
||||
if (!trainingBrowserVisible) return;
|
||||
|
||||
trainingRaycaster.setFromCamera(trainingPointer, camera);
|
||||
const hits = trainingRaycaster.intersectObjects(trainingGroup.children);
|
||||
const nowHovered = hits.length > 0 ? /** @type {THREE.Mesh} */ (hits[0].object) : null;
|
||||
|
||||
if (hoveredCrystal !== nowHovered) {
|
||||
if (hoveredCrystal && hoveredCrystal !== selectedCrystal) {
|
||||
hoveredCrystal.scale.setScalar(1);
|
||||
}
|
||||
if (nowHovered && nowHovered !== selectedCrystal) {
|
||||
nowHovered.scale.setScalar(1.3);
|
||||
document.body.style.cursor = 'pointer';
|
||||
} else if (!nowHovered) {
|
||||
document.body.style.cursor = '';
|
||||
}
|
||||
hoveredCrystal = nowHovered;
|
||||
}
|
||||
|
||||
for (const { mesh, baseY, rotSpeed } of trainingCrystals) {
|
||||
mesh.rotation.x += rotSpeed * 0.01;
|
||||
mesh.rotation.y += rotSpeed * 0.008;
|
||||
mesh.position.y = baseY + Math.sin(elapsed * 0.6 + mesh.userData.session.id) * 0.3;
|
||||
|
||||
const isSelected = mesh === selectedCrystal;
|
||||
const isHovered = mesh === hoveredCrystal;
|
||||
const pulse = 0.3 + Math.sin(elapsed * 1.5 + mesh.userData.session.id * 0.8) * 0.15;
|
||||
const intensity = isSelected ? 0.9 : isHovered ? 0.75 : pulse;
|
||||
|
||||
const c = new THREE.Color(mesh.userData.originalColor);
|
||||
/** @type {THREE.MeshStandardMaterial} */ (mesh.material).emissive.copy(c).multiplyScalar(intensity);
|
||||
|
||||
if (isSelected) {
|
||||
const pulsedScale = 1.5 + Math.sin(elapsed * 2.5) * 0.08;
|
||||
mesh.scale.setScalar(pulsedScale);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
<button id="export-session" class="chat-toggle-btn" aria-label="Export session as markdown" title="Export session log as Markdown">
|
||||
📥
|
||||
</button>
|
||||
<button id="training-browser-btn" class="chat-toggle-btn" aria-label="Toggle training data browser [B]" title="Training Data Browser [B]" style="margin-left:8px;background-color:var(--color-secondary);color:var(--color-text);padding:4px 8px;border:none;border-radius:4px;font-size:12px;cursor:pointer;font-family:var(--font-body);">
|
||||
🔬
|
||||
</button>
|
||||
<audio id="ambient-sound" src="ambient.mp3" loop></audio>
|
||||
</div>
|
||||
|
||||
@@ -61,5 +64,11 @@
|
||||
<div id="loading-bar" style="height: 100%; background: var(--color-accent); width: 0;"></div>
|
||||
</div>
|
||||
<div class="crt-overlay"></div>
|
||||
<div id="training-browser-hud">
|
||||
<span>TRAINING BROWSER</span>
|
||||
<span class="training-count">29 SESSIONS</span>
|
||||
<span class="training-hint">[B] close · click crystal for details</span>
|
||||
</div>
|
||||
<div id="training-detail"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
112
style.css
112
style.css
@@ -243,3 +243,115 @@ body.photo-mode #overview-indicator {
|
||||
50% { opacity: 0.15; }
|
||||
100% { opacity: 0.05; }
|
||||
}
|
||||
|
||||
/* === TRAINING DATA BROWSER === */
|
||||
#training-browser-hud {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--color-primary);
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 4px 14px;
|
||||
background: rgba(0, 0, 8, 0.6);
|
||||
white-space: nowrap;
|
||||
animation: overview-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#training-browser-hud.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.training-count {
|
||||
color: #00eeff;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.training-hint {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
#training-detail {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 28px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
min-width: 340px;
|
||||
background: rgba(0, 4, 18, 0.92);
|
||||
border: 1px solid var(--color-primary);
|
||||
padding: 14px 18px;
|
||||
z-index: 25;
|
||||
pointer-events: none;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
#training-detail.visible {
|
||||
display: block;
|
||||
animation: overview-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.td-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.td-id {
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.td-category {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.td-title {
|
||||
font-size: 14px;
|
||||
color: var(--color-text);
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.td-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.td-stats strong {
|
||||
color: var(--color-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.td-bar {
|
||||
height: 3px;
|
||||
background: rgba(68, 136, 255, 0.15);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.td-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
opacity: 0.85;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user