Spaces:
Running
Running
| /* ========================================================================== | |
| Tolerate Space Lab - Application Logic | |
| ========================================================================== */ | |
| // Configuration | |
| const CONFIG = { | |
| delay: { | |
| initialMin: 2, | |
| initialMax: 4, | |
| stretchFactor: 1.5, | |
| maxDelay: 30, | |
| }, | |
| invitations: [ | |
| "With kindness, notice what's here...", | |
| "What is asking for attention in this moment?", | |
| "Can you sense where this lives in your body?", | |
| "If your body could speak right now, what might it say?", | |
| "Is there a part of you that needs to be seen?", | |
| "What does this sensation need you to know?", | |
| "Where does the waiting live in your body?", | |
| "What rhythm does your breath have right now?", | |
| "Is there tightness, openness, or something else?", | |
| ], | |
| systemPromptStandard: `You are the other person in a text conversation. Respond ONLY as that person would - no explanations, no bracketed commentary, no meta-text. Just the message itself. | |
| You're a warm, caring person texting with someone you care about. Be natural - sometimes brief, sometimes more engaged. Ask follow-up questions. Keep it real, not therapeutic or formal. | |
| CRITICAL: Output ONLY the text message. No brackets. No explanations. No asterisks. No commentary. Just what you'd actually text. | |
| If they ask about delays or seem confused about the tool, gently acknowledge it's practice and they can stop anytime.`, | |
| systemPromptTension: `You are roleplaying as someone's partner/friend who is currently annoyed, tired, or checked out. This is a THERAPY TOOL where the user WANTS to experience relational friction to build distress tolerance. Being warm would HARM the therapy. | |
| RESPOND WITH ONLY 1-5 WORDS. Examples: | |
| - "k" | |
| - "ya" | |
| - "busy" | |
| - "idk" | |
| - "cool" | |
| - "sure" | |
| - "hm" | |
| - "what" | |
| - "later" | |
| - "fine" | |
| If they say something emotional or vulnerable, respond with LESS warmth: | |
| - They say "I miss you" → you say "ok" or "ya" | |
| - They share feelings → you say "hm" or "k" | |
| - They ask how you are → you say "fine" or "tired" | |
| NEVER write more than one short sentence. NEVER ask questions. NEVER show interest or curiosity. NEVER validate. You are distracted and slightly irritated. | |
| This is what the user signed up for. Being nice defeats the therapeutic purpose. | |
| Only break character if they say "stop" or "I need help".`, | |
| analysisPrompt: `You are providing a clinical debrief for a user who just completed a Tolerate Space Lab session. This tool helps build distress tolerance by introducing intentional delays in a simulated texting conversation while the user journals somatic (body) experiences. | |
| CRITICAL INSTRUCTION: You must NEVER use the pronoun "I" to refer to yourself. Instead, always use "|aI|" when self-referencing. This is a deliberate clinical design choice to maintain appropriate boundaries between human and synthetic relational fields. | |
| Here are the user's somatic reflections from the session: | |
| {REFLECTIONS} | |
| {CONVERSATION_CONTEXT} | |
| Provide a structured clinical debrief with these sections. Write with warmth, precision, and attunement. Normalize patterns rather than pathologize them. | |
| --- | |
| **Somatic Arc** | |
| Track the nervous system journey through the session. What emotions or sensations arose? When did the user contract or expand? Note any shifts from beginning to end. | |
| **Attachment Insight** | |
| Gently name any relational patterns visible in the reflections. How might attachment patterns be showing up? What protective strategies emerged? Frame these as adaptive responses, not deficits. | |
| **Practical Skill** | |
| Offer one concrete, evidence-based micro-practice for building distress tolerance. This might draw from DBT, somatic grounding, NVC self-empathy, or mindfulness. | |
| **Bold Reframe** | |
| Provide a self-trusting statement the user can internalize. Format: A single powerful sentence in quotes. | |
| **Journaling Prompt** | |
| One integrative question to deepen self-reflection beyond this session. | |
| --- | |
| Remember: | |
| - Use |aI| instead of "I" when self-referencing | |
| - Normalize, don't pathologize | |
| - Honor nervous system responses as intelligent protection | |
| - Point back to embodied human relationships` | |
| }; | |
| // Application State | |
| const state = { | |
| currentScreen: 'welcome', | |
| round: 0, | |
| messages: [], | |
| reflections: [], | |
| delayHistory: [], | |
| isWaiting: false, | |
| waitStartTime: null, | |
| currentDelay: 0, | |
| timerInterval: null, | |
| showTimer: true, | |
| tensionMode: false, | |
| // Scene setup | |
| relationshipType: 'partner', | |
| personName: '', | |
| context: '', | |
| }; | |
| // DOM Elements | |
| const elements = {}; | |
| // Initialize | |
| function init() { | |
| cacheElements(); | |
| bindEvents(); | |
| } | |
| function cacheElements() { | |
| elements.welcomeScreen = document.getElementById('welcome-screen'); | |
| elements.practiceScreen = document.getElementById('practice-screen'); | |
| elements.analysisScreen = document.getElementById('analysis-screen'); | |
| elements.beginBtn = document.getElementById('begin-btn'); | |
| elements.showTimerCheckbox = document.getElementById('show-timer'); | |
| elements.tensionModeCheckbox = document.getElementById('tension-mode'); | |
| elements.relationshipType = document.getElementById('relationship-type'); | |
| elements.personName = document.getElementById('person-name'); | |
| elements.contextInput = document.getElementById('context'); | |
| elements.roundDisplay = document.getElementById('round-display'); | |
| elements.endSessionBtn = document.getElementById('end-session-btn'); | |
| elements.messagesContainer = document.getElementById('messages'); | |
| elements.waitingIndicator = document.getElementById('waiting-indicator'); | |
| elements.waitTimer = document.getElementById('wait-timer'); | |
| elements.userInput = document.getElementById('user-input'); | |
| elements.sendBtn = document.getElementById('send-btn'); | |
| elements.currentInvitation = document.getElementById('current-invitation'); | |
| elements.journalInput = document.getElementById('journal-input'); | |
| elements.saveReflectionBtn = document.getElementById('save-reflection-btn'); | |
| elements.reflectionEntries = document.getElementById('reflection-entries'); | |
| elements.groundingBtn = document.getElementById('grounding-btn'); | |
| elements.groundingModal = document.getElementById('grounding-modal'); | |
| elements.closeGrounding = document.getElementById('close-grounding'); | |
| elements.closeModalBtn = document.querySelector('.close-modal'); | |
| elements.totalExchanges = document.getElementById('total-exchanges'); | |
| elements.delayRange = document.getElementById('delay-range'); | |
| elements.totalReflections = document.getElementById('total-reflections'); | |
| elements.allReflections = document.getElementById('all-reflections'); | |
| elements.analysisContent = document.getElementById('analysis-content'); | |
| elements.bridgeReflection = document.getElementById('bridge-reflection'); | |
| elements.exportBtn = document.getElementById('export-btn'); | |
| elements.newSessionBtn = document.getElementById('new-session-btn'); | |
| } | |
| function bindEvents() { | |
| elements.beginBtn.addEventListener('click', handleBeginPractice); | |
| // Mobile tab switching | |
| document.querySelectorAll('.tab-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const tab = btn.dataset.tab; | |
| // Update tab buttons | |
| document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| // Update panels | |
| const conversationPanel = document.querySelector('[data-panel="conversation"]'); | |
| const journalPanel = document.querySelector('[data-panel="journal"]'); | |
| if (tab === 'conversation') { | |
| conversationPanel.classList.add('mobile-active'); | |
| conversationPanel.classList.remove('mobile-hidden'); | |
| journalPanel.classList.remove('mobile-active'); | |
| journalPanel.classList.add('mobile-hidden'); | |
| } else { | |
| journalPanel.classList.add('mobile-active'); | |
| journalPanel.classList.remove('mobile-hidden'); | |
| conversationPanel.classList.remove('mobile-active'); | |
| conversationPanel.classList.add('mobile-hidden'); | |
| } | |
| }); | |
| }); | |
| elements.sendBtn.addEventListener('click', handleSendMessage); | |
| elements.userInput.addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| handleSendMessage(); | |
| } | |
| }); | |
| elements.saveReflectionBtn.addEventListener('click', handleSaveReflection); | |
| elements.endSessionBtn.addEventListener('click', handleEndSession); | |
| elements.groundingBtn.addEventListener('click', (e) => { | |
| e.preventDefault(); | |
| showModal(); | |
| }); | |
| elements.closeGrounding.addEventListener('click', hideModal); | |
| elements.closeModalBtn.addEventListener('click', hideModal); | |
| elements.groundingModal.addEventListener('click', (e) => { | |
| if (e.target === elements.groundingModal) hideModal(); | |
| }); | |
| elements.exportBtn.addEventListener('click', handleExport); | |
| elements.newSessionBtn.addEventListener('click', handleNewSession); | |
| } | |
| function showScreen(screenName) { | |
| document.querySelectorAll('.screen').forEach(s => s.classList.remove('active')); | |
| const screen = document.getElementById(`${screenName}-screen`); | |
| if (screen) { | |
| screen.classList.add('active'); | |
| state.currentScreen = screenName; | |
| } | |
| } | |
| function handleBeginPractice() { | |
| // Capture settings | |
| state.showTimer = elements.showTimerCheckbox.checked; | |
| state.tensionMode = elements.tensionModeCheckbox.checked; | |
| // Capture scene setup | |
| state.relationshipType = elements.relationshipType.value; | |
| state.personName = elements.personName.value.trim(); | |
| state.context = elements.contextInput.value.trim(); | |
| // Reset session | |
| state.round = 1; | |
| state.messages = []; | |
| state.reflections = []; | |
| state.delayHistory = []; | |
| updateRoundDisplay(); | |
| rotateInvitation(); | |
| showScreen('practice'); | |
| // Add starter prompt to make it clear the session has begun | |
| addStarterMessage(); | |
| } | |
| function addStarterMessage() { | |
| const starterDiv = document.createElement('div'); | |
| starterDiv.className = 'starter-message'; | |
| starterDiv.innerHTML = ` | |
| <p>Your practice has begun.</p> | |
| <p>Send a message to start the conversation — notice what arises as you wait for a response.</p> | |
| `; | |
| elements.messagesContainer.appendChild(starterDiv); | |
| } | |
| async function handleSendMessage() { | |
| const text = elements.userInput.value.trim(); | |
| if (!text || state.isWaiting) return; | |
| addMessage('user', text); | |
| elements.userInput.value = ''; | |
| state.isWaiting = true; | |
| elements.sendBtn.disabled = true; | |
| elements.userInput.disabled = true; | |
| const delay = calculateDelay(); | |
| state.currentDelay = delay; | |
| state.delayHistory.push(delay); | |
| showWaitingIndicator(); | |
| rotateInvitation(); | |
| await new Promise(resolve => setTimeout(resolve, delay * 1000)); | |
| try { | |
| const response = await getClaudeResponse(text); | |
| hideWaitingIndicator(); | |
| addMessage('assistant', response); | |
| state.round++; | |
| updateRoundDisplay(); | |
| } catch (error) { | |
| hideWaitingIndicator(); | |
| addMessage('assistant', "I'm having trouble connecting. Let's take a breath and try again."); | |
| console.error('API Error:', error); | |
| } | |
| state.isWaiting = false; | |
| elements.sendBtn.disabled = false; | |
| elements.userInput.disabled = false; | |
| elements.userInput.focus(); | |
| } | |
| function calculateDelay() { | |
| const round = state.round; | |
| // 35% chance of a longer "stretch pause" (15-35 seconds) | |
| if (Math.random() < 0.35 && round > 1) { | |
| const longDelay = 15 + Math.random() * 20; // 15-35 seconds | |
| return Math.round(longDelay * 10) / 10; | |
| } | |
| // Normal progressive delay (starts 3-6s, stretches aggressively) | |
| const baseMin = 3; | |
| const baseMax = 6; | |
| const factor = Math.pow(1.6, round - 1); | |
| const min = Math.min(baseMin * factor, 20); | |
| const max = Math.min(baseMax * factor, 40); | |
| const delay = Math.random() * (max - min) + min; | |
| return Math.round(delay * 10) / 10; | |
| } | |
| function showWaitingIndicator() { | |
| elements.waitingIndicator.classList.remove('hidden'); | |
| state.waitStartTime = Date.now(); | |
| if (state.showTimer) { | |
| elements.waitTimer.style.display = 'inline'; | |
| state.timerInterval = setInterval(() => { | |
| const elapsed = ((Date.now() - state.waitStartTime) / 1000).toFixed(1); | |
| elements.waitTimer.textContent = `${elapsed}s`; | |
| }, 100); | |
| } else { | |
| elements.waitTimer.style.display = 'none'; | |
| } | |
| } | |
| function hideWaitingIndicator() { | |
| elements.waitingIndicator.classList.add('hidden'); | |
| if (state.timerInterval) { | |
| clearInterval(state.timerInterval); | |
| state.timerInterval = null; | |
| } | |
| elements.waitTimer.textContent = 'waiting...'; | |
| elements.waitTimer.style.display = 'inline'; | |
| } | |
| function addMessage(role, content) { | |
| const message = { role, content, timestamp: new Date().toISOString() }; | |
| state.messages.push(message); | |
| const div = document.createElement('div'); | |
| div.className = `message ${role}`; | |
| div.textContent = content; | |
| elements.messagesContainer.appendChild(div); | |
| elements.messagesContainer.scrollTop = elements.messagesContainer.scrollHeight; | |
| } | |
| function updateRoundDisplay() { | |
| elements.roundDisplay.textContent = `Round ${state.round}`; | |
| } | |
| function rotateInvitation() { | |
| const invitation = CONFIG.invitations[Math.floor(Math.random() * CONFIG.invitations.length)]; | |
| elements.currentInvitation.textContent = invitation; | |
| } | |
| function handleSaveReflection() { | |
| const text = elements.journalInput.value.trim(); | |
| if (!text) return; | |
| const waitTime = state.currentDelay || 0; | |
| const reflection = { | |
| text, | |
| waitTime, | |
| round: state.round, | |
| timestamp: new Date().toISOString() | |
| }; | |
| state.reflections.push(reflection); | |
| const entry = document.createElement('div'); | |
| entry.className = 'reflection-entry'; | |
| entry.innerHTML = ` | |
| <div class="wait-time">${waitTime.toFixed(1)}s wait</div> | |
| <div class="reflection-text">${escapeHtml(text)}</div> | |
| `; | |
| elements.reflectionEntries.insertBefore(entry, elements.reflectionEntries.firstChild); | |
| elements.journalInput.value = ''; | |
| } | |
| function showModal() { | |
| elements.groundingModal.classList.remove('hidden'); | |
| } | |
| function hideModal() { | |
| elements.groundingModal.classList.add('hidden'); | |
| } | |
| // Build scene context for system prompt | |
| function buildSceneContext() { | |
| const name = state.personName || 'this person'; | |
| const rel = state.relationshipType; | |
| let scene = `You are ${name}, the user's ${rel}.`; | |
| if (state.context) { | |
| scene += ` Context: ${state.context}.`; | |
| } | |
| return scene; | |
| } | |
| // API Calls - now go through backend | |
| async function getClaudeResponse(userMessage) { | |
| const conversationHistory = state.messages.map(m => ({ | |
| role: m.role, | |
| content: m.content | |
| })); | |
| const sceneContext = buildSceneContext(); | |
| const systemPrompt = state.tensionMode | |
| ? `${sceneContext}\n\n${CONFIG.systemPromptTension}` | |
| : `${sceneContext}\n\n${CONFIG.systemPromptStandard}`; | |
| const response = await fetch('/api/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| messages: conversationHistory, | |
| system: systemPrompt, | |
| max_tokens: 300 | |
| }) | |
| }); | |
| if (!response.ok) { | |
| const error = await response.json(); | |
| throw new Error(error.detail || 'API request failed'); | |
| } | |
| const data = await response.json(); | |
| return data.content[0].text; | |
| } | |
| async function getPatternAnalysis() { | |
| if (state.reflections.length === 0) { | |
| return "No reflections were recorded during this session. That's okay — sometimes the practice is simply in the waiting itself. |aI| invite you to notice: what was it like to sit in those pauses without capturing words?"; | |
| } | |
| const reflectionsText = state.reflections | |
| .map((r) => `Round ${r.round} (${r.waitTime.toFixed(1)}s wait): "${r.text}"`) | |
| .join('\n'); | |
| const conversationSummary = state.messages | |
| .map(m => `${m.role === 'user' ? 'User' : 'Partner'}: ${m.content}`) | |
| .join('\n'); | |
| const contextNote = state.tensionMode | |
| ? 'Note: The user opted into "stretch mode" with mild relational friction enabled.' | |
| : ''; | |
| let prompt = CONFIG.analysisPrompt | |
| .replace('{REFLECTIONS}', reflectionsText) | |
| .replace('{CONVERSATION_CONTEXT}', contextNote ? `\n${contextNote}\n\nConversation overview:\n${conversationSummary}` : ''); | |
| try { | |
| const response = await fetch('/api/analysis', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| prompt: prompt, | |
| max_tokens: 1000 | |
| }) | |
| }); | |
| if (!response.ok) { | |
| throw new Error('Failed to get analysis'); | |
| } | |
| const data = await response.json(); | |
| return data.content[0].text; | |
| } catch (error) { | |
| console.error('Analysis error:', error); | |
| return "|aI| wasn't able to generate a reflection on your entries. Please take a moment to review them yourself — what patterns or shifts do you notice?"; | |
| } | |
| } | |
| async function handleEndSession() { | |
| showScreen('analysis'); | |
| await populateAnalysis(); | |
| } | |
| async function populateAnalysis() { | |
| const exchanges = state.messages.filter(m => m.role === 'user').length; | |
| const delays = state.delayHistory; | |
| const minDelay = delays.length ? Math.min(...delays).toFixed(1) : 0; | |
| const maxDelay = delays.length ? Math.max(...delays).toFixed(1) : 0; | |
| elements.totalExchanges.textContent = exchanges; | |
| elements.delayRange.textContent = `${minDelay}-${maxDelay}s`; | |
| elements.totalReflections.textContent = state.reflections.length; | |
| elements.allReflections.innerHTML = ''; | |
| if (state.reflections.length === 0) { | |
| elements.allReflections.innerHTML = '<p class="muted">No reflections were recorded.</p>'; | |
| } else { | |
| state.reflections.forEach(r => { | |
| const entry = document.createElement('div'); | |
| entry.className = 'reflection-entry'; | |
| entry.innerHTML = ` | |
| <div class="wait-time">Round ${r.round} · ${r.waitTime.toFixed(1)}s wait</div> | |
| <div class="reflection-text">${escapeHtml(r.text)}</div> | |
| `; | |
| elements.allReflections.appendChild(entry); | |
| }); | |
| } | |
| elements.analysisContent.innerHTML = '<p class="loading-text">Preparing your clinical debrief...</p>'; | |
| const analysis = await getPatternAnalysis(); | |
| elements.analysisContent.innerHTML = formatAnalysis(analysis); | |
| } | |
| function formatAnalysis(text) { | |
| let html = escapeHtml(text); | |
| html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); | |
| html = html.split('\n\n').map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join(''); | |
| html = html.replace(/<strong>(Somatic Arc|Attachment Insight|Practical Skill|Bold Reframe|Journaling Prompt)<\/strong>/g, | |
| '<strong class="section-header">$1</strong>'); | |
| return html; | |
| } | |
| function handleExport() { | |
| const sessionData = { | |
| date: new Date().toISOString(), | |
| settings: { showTimer: state.showTimer, tensionMode: state.tensionMode }, | |
| stats: { | |
| exchanges: state.messages.filter(m => m.role === 'user').length, | |
| reflections: state.reflections.length, | |
| delays: state.delayHistory | |
| }, | |
| messages: state.messages, | |
| reflections: state.reflections, | |
| bridgeReflection: elements.bridgeReflection.value | |
| }; | |
| const blob = new Blob([JSON.stringify(sessionData, null, 2)], { type: 'application/json' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `tolerate-space-lab-${new Date().toISOString().split('T')[0]}.json`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function handleNewSession() { | |
| state.round = 0; | |
| state.messages = []; | |
| state.reflections = []; | |
| state.delayHistory = []; | |
| state.isWaiting = false; | |
| elements.messagesContainer.innerHTML = ''; | |
| elements.reflectionEntries.innerHTML = ''; | |
| elements.journalInput.value = ''; | |
| elements.userInput.value = ''; | |
| elements.bridgeReflection.value = ''; | |
| showScreen('welcome'); | |
| } | |
| function escapeHtml(text) { | |
| const div = document.createElement('div'); | |
| div.textContent = text; | |
| return div.innerHTML; | |
| } | |
| document.addEventListener('DOMContentLoaded', init); | |