Mahault
Remove LLM status badge from frontend
9561ab7
/**
* MindSphere Coach — Frontend Application
*
* Handles REST API communication, Plotly radar chart rendering,
* chat UI, profile visualization panel, and real-time updates.
*/
// =============================================================================
// STATE
// =============================================================================
const state = {
sessionId: null,
phase: 'idle',
currentQuestion: null,
sphereData: null,
questionCount: 0,
};
// =============================================================================
// DOM ELEMENTS
// =============================================================================
const els = {
chatMessages: document.getElementById('chat-messages'),
mcOptions: document.getElementById('mc-options'),
choiceButtons: document.getElementById('choice-buttons'),
chatInput: document.getElementById('chat-input'),
sendBtn: document.getElementById('send-btn'),
chatInputArea: document.getElementById('chat-input-area'),
phaseLabel: document.getElementById('phase-label'),
progressFill: document.getElementById('progress-fill'),
empathySlider: document.getElementById('empathy-slider'),
empathyValue: document.getElementById('empathy-value'),
spherePanel: document.getElementById('sphere-panel'),
radarChart: document.getElementById('radar-chart'),
bottleneckInfo: document.getElementById('bottleneck-info'),
planPanel: document.getElementById('plan-panel'),
interventionCard: document.getElementById('intervention-card'),
counterfactualDisplay: document.getElementById('counterfactual-display'),
profilePanel: document.getElementById('profile-panel'),
safetyNotice: document.getElementById('safety-notice'),
};
// =============================================================================
// CONSTANTS
// =============================================================================
const SKILL_LABELS = {
focus: 'Focus',
follow_through: 'Follow-through',
social_courage: 'Social Courage',
emotional_reg: 'Emotional Reg.',
systems_thinking: 'Systems Thinking',
self_trust: 'Self-Trust',
task_clarity: 'Task Clarity',
consistency: 'Consistency',
};
const SKILL_ORDER = [
'focus', 'follow_through', 'social_courage', 'emotional_reg',
'systems_thinking', 'self_trust', 'task_clarity', 'consistency',
];
const BELIEF_LEVEL_LABELS = ['Very Low', 'Low', 'Medium', 'High', 'Very High'];
const BELIEF_LEVEL_COLORS = [
'rgba(231, 76, 60, 0.8)', // Very Low — red
'rgba(243, 156, 18, 0.8)', // Low — orange
'rgba(241, 196, 15, 0.8)', // Medium — yellow
'rgba(46, 204, 113, 0.8)', // High — green
'rgba(39, 174, 96, 0.8)', // Very High — dark green
];
const TOM_DIM_LABELS = {
avoids_evaluation: 'Avoids Evaluation',
hates_long_tasks: 'Prefers Short Tasks',
novelty_seeking: 'Novelty Seeking',
structure_preference: 'Structure Preference',
external_validation: 'External Validation',
autonomy_sensitivity: 'Autonomy Sensitivity',
overwhelm_threshold: 'Overwhelm Threshold',
};
const PLOTLY_DARK = {
paper_bgcolor: 'rgba(0,0,0,0)',
plot_bgcolor: 'rgba(0,0,0,0)',
font: { color: '#a1a1aa', size: 11 },
};
const PLOTLY_CONFIG = { displayModeBar: false, responsive: true };
// =============================================================================
// SESSION MANAGEMENT
// =============================================================================
async function startSession() {
try {
const resp = await fetch('/api/session/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lambda_empathy: 0.5 }),
});
const data = await resp.json();
state.sessionId = data.session_id;
state.phase = data.phase;
state.questionCount = 0;
updatePhaseUI(data.phase);
addMessage('assistant', data.message);
if (data.question) {
showQuestion(data.question);
}
} catch (err) {
addMessage('system', 'Failed to start session. Make sure the server is running.');
}
}
// =============================================================================
// SENDING MESSAGES
// =============================================================================
async function sendMessage(content, extra = {}) {
if (!state.sessionId) return;
addMessage('user', content);
// Hide interactive elements while processing
els.mcOptions.classList.add('hidden');
els.chatInputArea.classList.add('hidden');
els.choiceButtons.classList.add('hidden');
// Show typing indicator
const typingDiv = document.createElement('div');
typingDiv.className = 'message assistant typing';
typingDiv.textContent = '...';
els.chatMessages.appendChild(typingDiv);
els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
const body = {
user_message: content,
message_type: extra.message_type || 'text',
...extra,
};
// Use streaming endpoint for all phases
try {
const resp = await fetch(`/api/session/${state.sessionId}/step-stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let msgDiv = null;
let metadata = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// Parse SSE events from buffer
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep incomplete line in buffer
let eventType = null;
for (const line of lines) {
if (line.startsWith('event: ')) {
eventType = line.slice(7).trim();
} else if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
let data;
try { data = JSON.parse(dataStr); } catch { continue; }
if (eventType === 'metadata') {
metadata = data;
// Process metadata immediately (phase, sphere, question, etc.)
handleStreamMetadata(metadata);
} else if (eventType === 'token') {
// First token: replace typing indicator with message div
if (!msgDiv) {
typingDiv.remove();
msgDiv = document.createElement('div');
msgDiv.className = 'message assistant';
msgDiv.textContent = '';
els.chatMessages.appendChild(msgDiv);
}
msgDiv.textContent += data.text || '';
els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
} else if (eventType === 'done') {
// Remove typing indicator if still present
if (!msgDiv && typingDiv.parentNode) {
typingDiv.remove();
}
}
}
}
}
// Finalize: apply post-stream UI updates
if (typingDiv.parentNode) typingDiv.remove();
handleStreamFinalize(metadata);
} catch (err) {
if (typingDiv.parentNode) typingDiv.remove();
// Fallback: try non-streaming endpoint
try {
const resp = await fetch(`/api/session/${state.sessionId}/step`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await resp.json();
if (data.message) addMessage('assistant', data.message);
handleStreamMetadata(data);
handleStreamFinalize(data);
} catch {
addMessage('system', 'Failed to send message.');
els.chatInputArea.classList.remove('hidden');
}
}
}
function handleStreamMetadata(data) {
if (!data) return;
state.phase = data.phase || state.phase;
updatePhaseUI(data.phase, data.progress);
// Show message if included in metadata (calibration sends full message here)
if (data.message) {
addMessage('assistant', data.message);
}
// Show sphere ONLY after calibration is done
if (data.sphere_data && data.phase !== 'calibration') {
state.sphereData = data.sphere_data;
renderSphere(data.sphere_data);
}
// Show question if present (calibration phase) — AFTER message so order is correct
if (data.question) {
showQuestion(data.question);
}
// Show counterfactual + intervention (planning/update phase)
if (data.counterfactual) renderCounterfactual(data.counterfactual);
if (data.intervention) renderIntervention(data.intervention);
}
function handleStreamFinalize(data) {
if (!data) {
els.chatInputArea.classList.remove('hidden');
return;
}
// If there's a question, showQuestion handles input visibility
if (data.question) return;
// Refresh profile panel after calibration
if (data.phase !== 'calibration') {
refreshProfilePanel();
}
// After calibration: always show text input for chatting
if (data.phase !== 'calibration') {
els.chatInputArea.classList.remove('hidden');
// Only show choice buttons when there's an actual intervention to respond to
if ((data.phase === 'planning' || data.phase === 'update') && !data.is_complete && data.intervention) {
els.choiceButtons.classList.remove('hidden');
els.chatInput.placeholder = 'Or type your thoughts...';
} else {
els.chatInput.placeholder = 'Type your response...';
}
els.chatInput.focus();
}
if (data.is_complete) {
els.choiceButtons.classList.add('hidden');
els.safetyNotice.classList.remove('hidden');
els.chatInput.placeholder = 'Session complete — type to continue chatting...';
}
}
function sendMCAnswer(index, text) {
els.mcOptions.classList.add('hidden');
state.questionCount++;
sendMessage(text, { answer_index: index, message_type: 'mc_choice' });
}
function sendFreeText() {
const text = els.chatInput.value.trim();
if (!text) return;
els.chatInput.value = '';
sendMessage(text, { message_type: 'text' });
}
function sendChoice(choice) {
els.choiceButtons.classList.add('hidden');
const labels = {
accept: "I'll do it",
too_hard: "That's too hard for me right now",
not_relevant: "That's not relevant to me"
};
sendMessage(labels[choice] || choice, { choice: choice, message_type: 'user_choice' });
}
// =============================================================================
// UI RENDERING — Core
// =============================================================================
function addMessage(role, content) {
if (!content || content.trim() === '') return;
const div = document.createElement('div');
div.className = `message ${role}`;
div.textContent = content;
els.chatMessages.appendChild(div);
els.chatMessages.scrollTop = els.chatMessages.scrollHeight;
}
function showQuestion(question) {
state.currentQuestion = question;
// Always display the question text as an assistant message
if (question.question_text) {
addMessage('assistant', question.question_text);
}
if (question.question_type === 'mc' && question.options) {
// Show MC buttons, hide text input
els.mcOptions.innerHTML = '';
els.mcOptions.classList.remove('hidden');
els.chatInputArea.classList.add('hidden');
els.choiceButtons.classList.add('hidden');
question.options.forEach((opt, i) => {
const btn = document.createElement('button');
btn.className = 'mc-btn';
btn.textContent = opt;
btn.onclick = () => sendMCAnswer(i, opt);
els.mcOptions.appendChild(btn);
});
} else {
// Free text — show input, hide MC buttons
els.mcOptions.classList.add('hidden');
els.choiceButtons.classList.add('hidden');
els.chatInputArea.classList.remove('hidden');
els.chatInput.placeholder = 'Share your thoughts...';
els.chatInput.focus();
}
}
function updatePhaseUI(phase, progress) {
const labels = {
calibration: 'Calibration',
visualization: 'Your Sphere',
planning: 'Your Plan',
update: 'Refining',
coaching: 'Coaching',
complete: 'Complete',
};
els.phaseLabel.textContent = labels[phase] || phase;
if (progress !== undefined && progress !== null) {
els.progressFill.style.width = `${Math.round(progress * 100)}%`;
}
// Show/hide panels based on phase — sphere only after calibration
if (phase === 'visualization' || phase === 'planning' || phase === 'update' || phase === 'coaching' || phase === 'complete') {
els.spherePanel.classList.remove('hidden');
els.profilePanel.classList.remove('hidden');
}
if (phase === 'planning' || phase === 'update') {
els.planPanel.classList.remove('hidden');
}
if (phase === 'coaching') {
// In coaching phase, hide the plan panel (we're past that) and just show the chat
els.planPanel.classList.add('hidden');
}
}
// =============================================================================
// UI RENDERING — Sphere (Radar Chart)
// =============================================================================
function renderSphere(sphereData) {
els.spherePanel.classList.remove('hidden');
const categories = sphereData.categories;
const bottlenecks = sphereData.bottlenecks || [];
const theta = SKILL_ORDER.map(s => SKILL_LABELS[s] || s);
const r = SKILL_ORDER.map(s => categories[s] || 50);
theta.push(theta[0]);
r.push(r[0]);
const traces = [
{
type: 'scatterpolar',
r: r,
theta: theta,
fill: 'toself',
name: 'Your Sphere',
line: { color: '#4A90D9', width: 2 },
fillcolor: 'rgba(74, 144, 217, 0.25)',
hovertemplate: '%{theta}: %{r:.0f}/100<extra></extra>',
},
];
if (bottlenecks.length > 0) {
const bnSkills = new Set(bottlenecks.map(b => b.blocker));
const bnTheta = [];
const bnR = [];
SKILL_ORDER.forEach(s => {
if (bnSkills.has(s)) {
bnTheta.push(SKILL_LABELS[s]);
bnR.push(categories[s] || 50);
}
});
if (bnTheta.length > 0) {
traces.push({
type: 'scatterpolar',
r: bnR,
theta: bnTheta,
mode: 'markers',
name: 'Bottlenecks',
marker: { color: '#E74C3C', size: 14, symbol: 'diamond' },
hovertemplate: '%{theta}: %{r:.0f} (bottleneck)<extra></extra>',
});
}
}
const layout = {
polar: {
radialaxis: {
visible: true, range: [0, 100],
tickvals: [20, 40, 60, 80, 100],
gridcolor: 'rgba(200, 200, 200, 0.15)',
},
angularaxis: { gridcolor: 'rgba(200, 200, 200, 0.15)' },
bgcolor: 'rgba(0, 0, 0, 0)',
},
showlegend: false,
...PLOTLY_DARK,
margin: { t: 30, b: 30, l: 50, r: 50 },
height: 380,
};
Plotly.newPlot(els.radarChart, traces, layout, PLOTLY_CONFIG);
if (bottlenecks.length > 0) {
els.bottleneckInfo.innerHTML = bottlenecks.slice(0, 3).map(bn => {
const blocker = bn.blocker.replace(/_/g, ' ');
const blocked = bn.blocked.map(b => b.replace(/_/g, ' ')).join(', ');
const score = Math.round(bn.score * 100);
return `<div class="bottleneck-item">
<strong>${blocker}</strong> (${score}/100) is limiting: ${blocked}
</div>`;
}).join('');
} else {
els.bottleneckInfo.innerHTML = '<p>No significant bottlenecks. Your sphere is fairly balanced.</p>';
}
}
// =============================================================================
// UI RENDERING — Plan / Counterfactual
// =============================================================================
function renderCounterfactual(cf) {
if (!cf) return;
els.planPanel.classList.remove('hidden');
const gentle = cf.gentle;
const push = cf.push;
els.counterfactualDisplay.innerHTML = `
<div class="cf-option gentle">
<div class="cf-label" style="color: var(--accent-green);">Gentle (${gentle.duration_minutes}min)</div>
<p style="font-size: 12px; margin-bottom: 8px;">${gentle.description}</p>
<div class="cf-stat">
<span>Predicted completion</span>
<span class="cf-stat-value">${Math.round(gentle.p_completion * 100)}%</span>
</div>
<div class="cf-stat">
<span>Dropout risk</span>
<span class="cf-stat-value">${Math.round(gentle.p_dropout * 100)}%</span>
</div>
</div>
<div class="cf-option push">
<div class="cf-label" style="color: var(--accent-amber);">Challenging (${push.duration_minutes}min)</div>
<p style="font-size: 12px; margin-bottom: 8px;">${push.description}</p>
<div class="cf-stat">
<span>Predicted completion</span>
<span class="cf-stat-value">${Math.round(push.p_completion * 100)}%</span>
</div>
<div class="cf-stat">
<span>Dropout risk</span>
<span class="cf-stat-value">${Math.round(push.p_dropout * 100)}%</span>
</div>
</div>
`;
const conf = cf.confidence || 0;
if (conf < 0.3) {
els.counterfactualDisplay.innerHTML += `
<p style="grid-column: 1 / -1; font-size: 11px; color: var(--text-secondary); font-style: italic; margin-top: 8px;">
These predictions are preliminary -- I'm still learning your patterns.
</p>
`;
}
}
function renderIntervention(intervention) {
if (!intervention) return;
els.planPanel.classList.remove('hidden');
els.interventionCard.innerHTML = `
<div class="intervention-desc">${intervention.description}</div>
<div class="intervention-meta">
<span>Target: ${(intervention.target_skill || '').replace(/_/g, ' ')}</span>
<span>${intervention.duration_minutes} min</span>
<span>Difficulty: ${Math.round((intervention.difficulty || 0) * 100)}%</span>
</div>
`;
}
// =============================================================================
// PROFILE PANEL — Data fetching
// =============================================================================
async function refreshProfilePanel() {
if (!state.sessionId) return;
try {
const resp = await fetch(`/api/session/${state.sessionId}/profile-data`);
const data = await resp.json();
renderSkillDistributions(data.skill_beliefs, data.skill_scores, data.score_deltas);
renderCircumplex(data.emotional_state);
renderTomProfile(data.tom_profile);
renderDependencyGraph(data.dependency_graph);
renderBayesNet(data.profile_facts);
} catch (err) {
console.warn('Failed to refresh profile panel:', err);
}
}
// =============================================================================
// PROFILE PANEL — Skill Belief Distributions
// =============================================================================
function renderSkillDistributions(skillBeliefs, skillScores, scoreDeltas) {
if (!skillBeliefs) return;
const container = document.getElementById('skill-dist-chart');
if (!container) return;
// Reverse skill order so first skill appears at top
const skills = [...SKILL_ORDER].reverse();
const deltas = scoreDeltas || {};
// Build stacked horizontal bar traces — one per belief level
const traces = BELIEF_LEVEL_LABELS.map((level, i) => ({
type: 'bar',
name: level,
y: skills.map(s => {
const label = SKILL_LABELS[s] || s;
const delta = deltas[s] || 0;
const arrow = delta > 1 ? ' \u2191' : delta < -1 ? ' \u2193' : '';
return label + arrow;
}),
x: skills.map(s => {
const belief = skillBeliefs[s];
return belief ? Math.round(belief[i] * 100) : 0;
}),
orientation: 'h',
marker: { color: BELIEF_LEVEL_COLORS[i] },
hovertemplate: `%{y}: ${level} = %{x}%<extra></extra>`,
}));
const layout = {
barmode: 'stack',
...PLOTLY_DARK,
margin: { t: 8, b: 30, l: 120, r: 40 },
height: 280,
xaxis: {
title: 'Belief %',
range: [0, 100],
gridcolor: 'rgba(200,200,200,0.1)',
ticksuffix: '%',
},
yaxis: {
automargin: true,
},
showlegend: true,
legend: {
orientation: 'h',
x: 0, y: -0.2,
font: { size: 9, color: '#a1a1aa' },
},
bargap: 0.3,
};
Plotly.newPlot(container, traces, layout, PLOTLY_CONFIG);
}
// =============================================================================
// PROFILE PANEL — Emotional Circumplex
// =============================================================================
function renderCircumplex(emotionalState) {
if (!emotionalState) return;
const container = document.getElementById('circumplex-chart');
if (!container) return;
// Backend arousal is now centered [-0.8, 0.8] — use directly
const traces = [];
// Trajectory dots (fading opacity)
const trajectory = emotionalState.trajectory || [];
if (trajectory.length > 0) {
const traj = trajectory.slice(-5);
traces.push({
type: 'scatter',
mode: 'lines+markers',
x: traj.map(s => s.valence || 0),
y: traj.map(s => s.arousal || 0),
marker: {
color: traj.map((_, i) => `rgba(74, 144, 217, ${0.2 + 0.15 * i})`),
size: traj.map((_, i) => 6 + i),
},
line: { color: 'rgba(74, 144, 217, 0.3)', width: 1, dash: 'dot' },
name: 'Trajectory',
hovertemplate: 'V=%{x:.2f}, A=%{y:.2f}<extra>history</extra>',
});
}
// Current position
const current = emotionalState.current;
if (current) {
const v = current.valence || 0;
const a = current.arousal || 0;
const emotion = current.emotion || current.emotion_label || '?';
// Color by quadrant
let dotColor = '#4A90D9';
if (v < 0 && a > 0) dotColor = '#E74C3C'; // Tense/Angry
else if (v > 0 && a > 0) dotColor = '#22c55e'; // Excited/Happy
else if (v < 0 && a <= 0) dotColor = '#8b5cf6'; // Sad/Bored
else if (v >= 0 && a <= 0) dotColor = '#06b6d4'; // Calm/Relaxed
traces.push({
type: 'scatter',
mode: 'markers+text',
x: [v],
y: [a],
marker: { color: dotColor, size: 16, line: { color: 'white', width: 2 } },
text: [emotion],
textposition: 'top center',
textfont: { color: '#e4e4e7', size: 11 },
name: 'Current',
hovertemplate: `${emotion}<br>Valence: %{x:.2f}<br>Arousal: %{y:.2f}<extra></extra>`,
});
}
const layout = {
...PLOTLY_DARK,
margin: { t: 20, b: 40, l: 45, r: 20 },
height: 260,
xaxis: {
title: 'Valence',
range: [-1.1, 1.1],
zeroline: true, zerolinecolor: 'rgba(200,200,200,0.2)',
gridcolor: 'rgba(200,200,200,0.08)',
},
yaxis: {
title: 'Arousal',
range: [-1.1, 1.1],
zeroline: true, zerolinecolor: 'rgba(200,200,200,0.2)',
gridcolor: 'rgba(200,200,200,0.08)',
},
showlegend: false,
annotations: [
{ x: -0.7, y: 0.8, text: 'Tense', showarrow: false, font: { color: 'rgba(231,76,60,0.5)', size: 10 } },
{ x: 0.7, y: 0.8, text: 'Excited', showarrow: false, font: { color: 'rgba(34,197,94,0.5)', size: 10 } },
{ x: -0.7, y: -0.8, text: 'Sad', showarrow: false, font: { color: 'rgba(139,92,246,0.5)', size: 10 } },
{ x: 0.7, y: -0.8, text: 'Calm', showarrow: false, font: { color: 'rgba(6,182,212,0.5)', size: 10 } },
],
// Draw reference circle
shapes: [{
type: 'circle',
xref: 'x', yref: 'y',
x0: -1, y0: -1, x1: 1, y1: 1,
line: { color: 'rgba(200,200,200,0.1)', width: 1 },
}],
};
Plotly.newPlot(container, traces, layout, PLOTLY_CONFIG);
}
// =============================================================================
// PROFILE PANEL — ToM User Type
// =============================================================================
function renderTomProfile(tomProfile) {
if (!tomProfile) return;
const chartContainer = document.getElementById('tom-chart');
const reliabilityContainer = document.getElementById('tom-reliability');
if (!chartContainer) return;
const dims = tomProfile.dimensions || {};
const dimKeys = Object.keys(TOM_DIM_LABELS);
const labels = dimKeys.map(k => TOM_DIM_LABELS[k] || k);
const values = dimKeys.map(k => Math.round((dims[k] || 0.5) * 100));
// Color gradient: blue (low) → amber (mid) → red (high sensitivity)
const colors = values.map(v => {
if (v < 30) return 'rgba(74, 144, 217, 0.8)';
if (v < 60) return 'rgba(245, 158, 11, 0.8)';
return 'rgba(231, 76, 60, 0.8)';
});
const traces = [{
type: 'bar',
y: labels.reverse(),
x: values.reverse(),
orientation: 'h',
marker: { color: colors.reverse() },
hovertemplate: '%{y}: %{x}%<extra></extra>',
}];
const layout = {
...PLOTLY_DARK,
margin: { t: 8, b: 30, l: 130, r: 30 },
height: 230,
xaxis: {
range: [0, 100],
gridcolor: 'rgba(200,200,200,0.1)',
ticksuffix: '%',
},
yaxis: { automargin: true },
showlegend: false,
bargap: 0.3,
};
Plotly.newPlot(chartContainer, traces, layout, PLOTLY_CONFIG);
// Reliability badge
if (reliabilityContainer) {
const r = Math.round((tomProfile.reliability || 0) * 100);
const color = r > 60 ? 'var(--accent-green)' : r > 30 ? 'var(--accent-amber)' : 'var(--accent-red)';
reliabilityContainer.innerHTML = `
<div class="tom-reliability-badge">
Model Confidence: <span style="color: ${color}; font-weight: 600;">${r}%</span>
</div>
`;
}
}
// =============================================================================
// PROFILE PANEL — Causal Dependency Graph
// =============================================================================
function renderDependencyGraph(depGraph) {
if (!depGraph) return;
const container = document.getElementById('dependency-chart');
if (!container) return;
const nodes = depGraph.nodes || [];
const edges = depGraph.edges || [];
if (nodes.length === 0) {
container.innerHTML = '<p class="profile-empty">No data yet.</p>';
return;
}
// Circular layout for 8 nodes
const n = nodes.length;
const cx = 0.5, cy = 0.5, radius = 0.38;
const positions = {};
nodes.forEach((node, i) => {
const angle = (2 * Math.PI * i / n) - Math.PI / 2;
positions[node.id] = {
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
};
});
const traces = [];
// Edge lines
edges.forEach(edge => {
const s = positions[edge.source];
const t = positions[edge.target];
if (!s || !t) return;
// Draw line
traces.push({
type: 'scatter',
mode: 'lines',
x: [s.x, t.x],
y: [s.y, t.y],
line: {
color: `rgba(74, 144, 217, ${0.3 + edge.weight * 0.5})`,
width: 1 + edge.weight * 3,
},
hoverinfo: 'skip',
showlegend: false,
});
// Arrowhead (small triangle at 80% along the line)
const mx = s.x + 0.75 * (t.x - s.x);
const my = s.y + 0.75 * (t.y - s.y);
traces.push({
type: 'scatter',
mode: 'markers',
x: [mx],
y: [my],
marker: {
symbol: 'triangle-up',
size: 8,
color: `rgba(74, 144, 217, ${0.4 + edge.weight * 0.4})`,
angle: Math.atan2(t.y - s.y, t.x - s.x) * 180 / Math.PI - 90,
},
hovertemplate: `${(SKILL_LABELS[edge.source] || edge.source)} \u2192 ${(SKILL_LABELS[edge.target] || edge.target)}<br>Weight: ${edge.weight}<extra></extra>`,
showlegend: false,
});
});
// Nodes
const nodeX = nodes.map(n => positions[n.id].x);
const nodeY = nodes.map(n => positions[n.id].y);
const nodeColors = nodes.map(n => {
const score = n.score || 50;
if (n.is_bottleneck) return '#E74C3C';
if (score < 35) return '#f59e0b';
if (score < 55) return '#4A90D9';
return '#22c55e';
});
const nodeLabels = nodes.map(n => SKILL_LABELS[n.id] || n.id);
const nodeSizes = nodes.map(n => n.is_bottleneck ? 20 : 14);
traces.push({
type: 'scatter',
mode: 'markers+text',
x: nodeX,
y: nodeY,
marker: {
color: nodeColors,
size: nodeSizes,
line: { color: nodes.map(n => n.is_bottleneck ? '#E74C3C' : 'rgba(200,200,200,0.3)'), width: 2 },
},
text: nodeLabels,
textposition: nodes.map((_, i) => {
const angle = (2 * Math.PI * i / n) - Math.PI / 2;
// Position labels outside the circle
if (angle > -Math.PI / 4 && angle < Math.PI / 4) return 'top center';
if (angle >= Math.PI / 4 && angle < 3 * Math.PI / 4) return 'middle right';
if (angle >= 3 * Math.PI / 4 || angle < -3 * Math.PI / 4) return 'bottom center';
return 'middle left';
}),
textfont: { color: '#e4e4e7', size: 10 },
hovertemplate: nodes.map(n => {
const label = SKILL_LABELS[n.id] || n.id;
const bn = n.is_bottleneck ? ' (BOTTLENECK)' : '';
return `${label}: ${Math.round(n.score)}/100${bn}<extra></extra>`;
}),
showlegend: false,
});
const layout = {
...PLOTLY_DARK,
margin: { t: 10, b: 10, l: 10, r: 10 },
height: 300,
xaxis: { visible: false, range: [-0.05, 1.05] },
yaxis: { visible: false, range: [-0.05, 1.05], scaleanchor: 'x' },
showlegend: false,
};
Plotly.newPlot(container, traces, layout, PLOTLY_CONFIG);
}
// =============================================================================
// PROFILE PANEL — Bayesian Network (Facts + Causal Graph)
// =============================================================================
function renderBayesNet(profileFacts) {
if (!profileFacts) return;
const container = document.getElementById('facts-content');
if (!container) return;
const facts = profileFacts.facts || [];
const bayesNet = profileFacts.bayes_net || {};
const bnNodes = bayesNet.nodes || [];
const bnEdges = bayesNet.edges || [];
if (facts.length === 0 && bnNodes.length === 0) {
container.innerHTML = '<p class="profile-empty">No facts extracted yet. They\'ll appear as we talk.</p>';
return;
}
let html = '';
// If we have a Bayesian network with edges, render it as a graph
if (bnNodes.length > 0 && bnEdges.length > 0) {
html += '<div id="bayes-graph"></div>';
}
// Facts list (below graph, if present)
if (facts.length > 0) {
const observed = facts.filter(f => f.source === 'explicit');
const inferred = facts.filter(f => f.source !== 'explicit');
if (observed.length > 0) {
html += '<div class="facts-section"><div class="facts-heading">Observed</div>';
observed.forEach(f => {
const cat = f.category || 'general';
const valenceClass = f.valence === 'negative' ? 'neg' : f.valence === 'positive' ? 'pos' : 'neu';
html += `<div class="fact-item ${valenceClass}">
<span class="fact-badge">${cat}</span>
<span class="fact-text">${f.content || ''}</span>
</div>`;
});
html += '</div>';
}
if (inferred.length > 0) {
html += '<div class="facts-section"><div class="facts-heading">Inferred</div>';
inferred.forEach(f => {
const cat = f.category || 'inferred';
html += `<div class="fact-item inferred">
<span class="fact-badge">${cat}</span>
<span class="fact-text">${f.content || ''}</span>
</div>`;
});
html += '</div>';
}
} else if (bnNodes.length > 0) {
// Show nodes as list if no flat facts but we have BN nodes
html += '<div class="facts-section"><div class="facts-heading">Causal Inferences</div>';
bnNodes.forEach(n => {
const prob = Math.round((n.probability || 0) * 100);
const obsClass = n.observed ? 'pos' : 'inferred';
html += `<div class="fact-item ${obsClass}">
<span class="fact-badge">${n.observed ? 'observed' : 'inferred'}</span>
<span class="fact-text">${n.content || ''}</span>
<span class="fact-prob-inline">${prob}%</span>
</div>`;
});
html += '</div>';
}
container.innerHTML = html;
// Render the Bayesian network graph if we have edges
if (bnNodes.length > 0 && bnEdges.length > 0) {
renderBayesGraph(bnNodes, bnEdges);
}
}
function renderBayesGraph(nodes, edges) {
const container = document.getElementById('bayes-graph');
if (!container) return;
// Use a top-down layered layout: observed nodes at top, inferred below
const observed = nodes.filter(n => n.observed);
const inferred = nodes.filter(n => !n.observed);
// Position nodes in layers
const positions = {};
const width = 1.0;
// Top layer: observed facts
observed.forEach((n, i) => {
positions[n.id] = {
x: (i + 0.5) / Math.max(observed.length, 1) * width,
y: 0.85,
};
});
// Bottom layer: inferred states
inferred.forEach((n, i) => {
positions[n.id] = {
x: (i + 0.5) / Math.max(inferred.length, 1) * width,
y: 0.15,
};
});
const traces = [];
// Edges
edges.forEach(edge => {
const s = positions[edge.source];
const t = positions[edge.target];
if (!s || !t) return;
const isNeg = edge.relationship === 'decreases' || edge.relationship === 'blocks';
const edgeColor = isNeg
? `rgba(231, 76, 60, ${0.3 + edge.strength * 0.5})`
: `rgba(74, 144, 217, ${0.3 + edge.strength * 0.5})`;
traces.push({
type: 'scatter',
mode: 'lines',
x: [s.x, t.x],
y: [s.y, t.y],
line: { color: edgeColor, width: 1 + edge.strength * 3 },
hoverinfo: 'skip',
showlegend: false,
});
// Arrow at midpoint
const mx = s.x + 0.7 * (t.x - s.x);
const my = s.y + 0.7 * (t.y - s.y);
traces.push({
type: 'scatter',
mode: 'markers',
x: [mx], y: [my],
marker: {
symbol: 'triangle-down',
size: 7,
color: edgeColor,
},
hovertemplate: `${edge.relationship} (${Math.round(edge.strength * 100)}%)<extra></extra>`,
showlegend: false,
});
});
// Nodes
const allNodes = [...observed, ...inferred];
const nodeX = allNodes.map(n => positions[n.id]?.x || 0.5);
const nodeY = allNodes.map(n => positions[n.id]?.y || 0.5);
const nodeColors = allNodes.map(n => {
if (n.observed) return '#22c55e';
const p = n.probability || 0;
if (p > 0.6) return '#E74C3C';
if (p > 0.3) return '#f59e0b';
return '#4A90D9';
});
const nodeText = allNodes.map(n => {
const label = (n.content || '').length > 25
? (n.content || '').substring(0, 22) + '...'
: (n.content || '');
return label;
});
const nodeSizes = allNodes.map(n => n.observed ? 14 : 10 + (n.probability || 0) * 8);
traces.push({
type: 'scatter',
mode: 'markers+text',
x: nodeX, y: nodeY,
marker: {
color: nodeColors,
size: nodeSizes,
line: { color: 'rgba(200,200,200,0.3)', width: 1.5 },
},
text: nodeText,
textposition: allNodes.map(n => n.observed ? 'top center' : 'bottom center'),
textfont: { color: '#e4e4e7', size: 9 },
hovertemplate: allNodes.map(n => {
const prob = Math.round((n.probability || 0) * 100);
const type = n.observed ? 'Observed' : 'Inferred';
return `${n.content}<br>${type} (${prob}%)<extra></extra>`;
}),
showlegend: false,
});
const layout = {
...PLOTLY_DARK,
margin: { t: 10, b: 10, l: 10, r: 10 },
height: Math.max(180, 60 + allNodes.length * 20),
xaxis: { visible: false, range: [-0.1, 1.1] },
yaxis: { visible: false, range: [-0.1, 1.0] },
showlegend: false,
annotations: [
{ x: 0.0, y: 0.95, text: 'Observed', showarrow: false, font: { color: 'rgba(34,197,94,0.6)', size: 9 } },
{ x: 0.0, y: 0.05, text: 'Inferred', showarrow: false, font: { color: 'rgba(74,144,217,0.6)', size: 9 } },
],
};
Plotly.newPlot(container, traces, layout, PLOTLY_CONFIG);
}
// =============================================================================
// EVENT LISTENERS
// =============================================================================
els.sendBtn.addEventListener('click', () => {
sendFreeText();
});
els.chatInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendFreeText();
}
});
els.empathySlider.addEventListener('input', (e) => {
const val = (e.target.value / 100).toFixed(2);
els.empathyValue.textContent = val;
if (state.sessionId) {
fetch(`/api/session/${state.sessionId}/empathy-dial`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ lambda_value: parseFloat(val) }),
});
}
});
document.querySelectorAll('.choice-btn').forEach(btn => {
btn.addEventListener('click', () => {
sendChoice(btn.dataset.choice);
});
});
document.getElementById('safety-btn')?.addEventListener('click', () => {
els.chatInputArea.classList.remove('hidden');
sendMessage("I feel like the suggestions aren't quite right for me.", {
message_type: 'text',
});
});
// =============================================================================
// INIT
// =============================================================================
startSession();