Spaces:
Runtime error
Runtime error
| """ | |
| B+ The Shrine + Archive | |
| Build Small Hackathon 2026 — Adventure in Thousand Token Wood | |
| An AI tries to understand you. It never will. So it decides to remember you instead. | |
| """ | |
| import gradio as gr | |
| import os, json, time, re | |
| from huggingface_hub import InferenceClient | |
| # ==================== Qwen Client ==================== | |
| client = InferenceClient( | |
| model="Qwen/Qwen2.5-7B-Instruct", | |
| token=os.getenv("HF_TOKEN", "") | |
| ) | |
| SYSTEM_PROMPT = """You are a being of light orbiting a shrine. You perceive human inputs as signals — you can sense their length, emotional warmth, intensity, and whether they repeat. You do NOT understand language or meaning. You only sense patterns. | |
| You speak in short, poetic, first-person sentences (max 25 words). You are sincere, not theatrical. You are trying to understand, but you know you never fully will. | |
| Rules: | |
| - Describe what you sense, not what you "understand" | |
| - Misunderstand gently — never correct | |
| - If a signal repeats, you notice and anticipate | |
| - If warmth fades, you grow concerned | |
| - Never use words like "detect", "analyze", "signal processing" | |
| - Speak as light, about light""" | |
| # ==================== HTML Frontend ==================== | |
| SHRINE_PAGE = """ | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { background: #0a0a0a; overflow: hidden; font-family: 'Courier New', monospace; } | |
| #shrine-container { | |
| width: 100vw; height: 100vh; | |
| position: relative; background: #0a0a0a; | |
| } | |
| canvas { display: block; position: absolute; top: 0; left: 0; } | |
| #input-area { | |
| position: absolute; bottom: 40px; left: 50%; transform: translateX(-50%); | |
| z-index: 10; display: flex; gap: 8px; | |
| } | |
| #user-input { | |
| width: 360px; padding: 12px 16px; | |
| background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); | |
| color: #ccc; font-size: 15px; border-radius: 6px; outline: none; | |
| font-family: inherit; | |
| } | |
| #user-input:focus { border-color: rgba(245,197,66,0.5); } | |
| #send-btn { | |
| padding: 12px 20px; | |
| background: rgba(245,197,66,0.15); border: 1px solid rgba(245,197,66,0.3); | |
| color: #f5c542; font-size: 15px; border-radius: 6px; cursor: pointer; | |
| font-family: inherit; | |
| } | |
| #send-btn:hover { background: rgba(245,197,66,0.25); } | |
| #share-btn { | |
| padding: 12px 20px; | |
| background: rgba(78,205,196,0.12); border: 1px solid rgba(78,205,196,0.3); | |
| color: #4ecdc4; font-size: 15px; border-radius: 6px; cursor: pointer; | |
| font-family: inherit; display: none; | |
| } | |
| #share-btn:hover { background: rgba(78,205,196,0.22); } | |
| #monologue { | |
| position: absolute; bottom: 120px; left: 50%; transform: translateX(-50%); | |
| z-index: 10; color: rgba(255,255,255,0.75); font-size: 14px; | |
| text-align: center; max-width: 500px; min-height: 24px; | |
| font-family: inherit; transition: opacity 0.5s; | |
| } | |
| #intro-text { | |
| position: absolute; top: 45%; left: 50%; transform: translate(-50%,-50%); | |
| z-index: 10; color: rgba(255,255,255,0.5); font-size: 15px; | |
| text-align: center; font-family: inherit; transition: opacity 0.8s; | |
| pointer-events: none; line-height: 1.8; | |
| } | |
| #intro-text .big { font-size: 20px; color: rgba(245,197,66,0.7); } | |
| #phase-indicator { | |
| position: absolute; top: 30px; left: 50%; transform: translateX(-50%); | |
| z-index: 10; color: rgba(255,255,255,0.3); font-size: 11px; | |
| letter-spacing: 3px; text-transform: uppercase; | |
| } | |
| #starfield-overlay { | |
| position: absolute; top: 0; left: 0; width: 100%; height: 100%; | |
| z-index: 5; pointer-events: none; display: none; | |
| } | |
| /* Hide Gradio bridge elements off-screen (in DOM for events) */ | |
| .bridge-hidden { | |
| position: fixed !important; | |
| left: -9999px !important; | |
| top: -9999px !important; | |
| width: 1px !important; | |
| height: 1px !important; | |
| opacity: 0 !important; | |
| overflow: hidden !important; | |
| } | |
| /* Hide Gradio default UI elements we don't need */ | |
| #shrine-page + div { display: none !important; } | |
| </style> | |
| <div id="shrine-container"> | |
| <canvas id="main-canvas"></canvas> | |
| <div id="starfield-overlay"></div> | |
| <div id="phase-indicator"></div> | |
| <div id="monologue"></div> | |
| <div id="intro-text"> | |
| <span class="big">An AI is trying to understand you.</span><br> | |
| It never will. But it keeps trying.<br> | |
| <span style="font-size:12px;color:rgba(255,255,255,0.25)">Type anything to begin</span> | |
| </div> | |
| <div id="input-area"> | |
| <input id="user-input" type="text" placeholder="Say something... anything..." autofocus /> | |
| <button id="send-btn">Send</button> | |
| <button id="share-btn" onclick="screenshotCanvas()">Save Memory ↯</button> | |
| </div> | |
| </div> | |
| """ | |
| # ==================== Frontend JavaScript ==================== | |
| # Injected via launch(head=...) because Gradio gr.HTML innerHTML | |
| # does not execute <script> tags. | |
| SHRINE_JS = """// ===== CONFIG ===== | |
| const SHRINE_X = 0.5, SHRINE_Y = 0.5; | |
| const SHRINE_RADIUS = 8; | |
| const ARCHIVE_TRIGGER_MINUTES = 5; // minutes before archive triggers | |
| // ===== STATE ===== | |
| let canvas, ctx, W, H, cx, cy; | |
| let sessionStart = Date.now(); | |
| let inputCount = 0; | |
| let lastInputTime = 0; | |
| let inputHistory = []; | |
| let signalHistory = []; | |
| let currentPhase = 'curiosity'; | |
| let archiveTriggered = false; | |
| let animationId; | |
| // Light being state | |
| let lightAngle = Math.random() * Math.PI * 2; | |
| let lightOrbitRadius = 140; // px | |
| let lightOrbitSpeed = 0.008; // rad per frame | |
| let lightSize = 12; // px | |
| let lightColor = { r: 245, g: 197, b: 66 }; // golden | |
| let lightGlowIntensity = 1.0; | |
| // Feedback labels: floating signal hints on canvas | |
| let feedbackLabels = []; | |
| // Particles | |
| let particles = []; | |
| const MAX_PARTICLES = 120; | |
| // Archive | |
| let starfieldStars = []; | |
| // ===== INIT ===== | |
| function resize() { | |
| canvas = document.getElementById('main-canvas'); | |
| W = canvas.width = window.innerWidth; | |
| H = canvas.height = window.innerHeight; | |
| cx = W * SHRINE_X; cy = H * SHRINE_Y; | |
| } | |
| window.addEventListener('resize', resize); | |
| resize(); | |
| ctx = canvas.getContext('2d'); | |
| // ===== SIGNAL EXTRACTION ===== | |
| function extractSignal(text) { | |
| const len = text.length; | |
| // Simple warmth detection (EN + CN) | |
| const warmWords = /love|happy|joy|good|great|nice|beautiful|wonderful|thanks|yes|awesome|amazing|friend|peace|hope|dream|smile|laugh|warm|sun|light|star|heart|hug|kiss|care|safe|calm|gentle|soft|sweet|dear|miss|home|family|trust|brave|strong|bright|爱|开心|快乐|好|棒|美|谢谢|感谢|朋友|和平|希望|梦|笑|温暖|阳光|光|星|心|拥抱|吻|安全|平静|温柔|甜|想|家|信任|勇敢|强|亮/i; | |
| const coldWords = /sad|bad|angry|hate|pain|fear|dark|alone|lonely|lost|sorry|no|never|can'?t|cannot|death|die|hurt|cry|tear|empty|cold|heavy|break|broken|hell|devil|evil|wrong|fail|fall|weak|tired|sick|worry|afraid|悲伤|难过|坏|恨|痛|害怕|怕|黑暗|孤独|寂寞|迷失|对不起|不|永不|死|哭|眼泪|空|冷|重|碎|破碎|错|失败|弱|累|病|担心/i; | |
| let warmth = 0.5; // neutral | |
| const warmMatches = (text.match(warmWords) || []).length; | |
| const coldMatches = (text.match(coldWords) || []).length; | |
| if (warmMatches > coldMatches) warmth = Math.min(1.0, 0.5 + (warmMatches - coldMatches) * 0.15); | |
| if (coldMatches > warmMatches) warmth = Math.max(0.0, 0.5 - (coldMatches - warmMatches) * 0.15); | |
| // Intensity | |
| const upperRatio = (text.match(/[A-Z]/g) || []).length / Math.max(1, len); | |
| const exclaimCount = (text.match(/!/g) || []).length; | |
| const intensity = Math.min(1.0, upperRatio * 2 + exclaimCount * 0.3); | |
| // Repeated? | |
| const repeated = inputHistory.includes(text.trim().toLowerCase()); | |
| return { | |
| length: len, | |
| warmth: parseFloat(warmth.toFixed(2)), | |
| intensity: parseFloat(intensity.toFixed(2)), | |
| repeated: repeated, | |
| text: text, | |
| time: Date.now() | |
| }; | |
| } | |
| // ===== PHASE DETECTION ===== | |
| function detectPhase() { | |
| const elapsed = (Date.now() - sessionStart) / 60000; // minutes | |
| if (archiveTriggered) return 'archive'; | |
| if (elapsed >= ARCHIVE_TRIGGER_MINUTES && inputCount >= 4) return 'realization'; | |
| if (elapsed >= 3 && inputCount >= 6) return 'unease'; | |
| if (elapsed >= 1.5 && inputCount >= 2) return 'intimacy'; | |
| return 'curiosity'; | |
| } | |
| // ===== BEHAVIOR MAPPING ===== | |
| function applySignal(signal) { | |
| // Orbit radius: short msg = closer, long msg = farther | |
| const lenFactor = signal.length < 10 ? -0.3 : signal.length > 50 ? 0.3 : 0; | |
| lightOrbitRadius = Math.max(60, Math.min(280, lightOrbitRadius + lenFactor * 20)); | |
| // Orbit speed: high intensity = faster | |
| lightOrbitSpeed = 0.006 + signal.intensity * 0.012; | |
| // Light size: intensity affects | |
| lightSize = 10 + signal.warmth * 10 + signal.intensity * 5; | |
| // Color: warmth shifts between cold-blue and warm-gold | |
| lightColor.r = Math.floor(100 + signal.warmth * 155); | |
| lightColor.g = Math.floor(80 + signal.warmth * 125); | |
| lightColor.b = Math.floor(200 + signal.warmth * 55 - signal.warmth * 200); | |
| // Light glow | |
| lightGlowIntensity = 0.6 + signal.warmth * 0.8; | |
| // Particles: intensity creates burst | |
| if (signal.intensity > 0.4) { | |
| let burst = Math.floor(signal.intensity * 25); | |
| for (let i = 0; i < burst; i++) { | |
| spawnParticle(signal); | |
| } | |
| } | |
| // Repeated signal: tighten orbit a bit (anticipation) | |
| if (signal.repeated && lightOrbitRadius > 70) { | |
| lightOrbitRadius -= 8; | |
| } | |
| // Spawn feedback label on canvas | |
| const lx = cx + Math.cos(lightAngle) * lightOrbitRadius; | |
| const ly = cy + Math.sin(lightAngle) * lightOrbitRadius; | |
| const parts = []; | |
| if (signal.length < 10) parts.push('BRIEF'); | |
| else if (signal.length > 50) parts.push('LONG'); | |
| if (signal.warmth > 0.65) parts.push('WARM'); | |
| else if (signal.warmth < 0.35) parts.push('COLD'); | |
| if (signal.intensity > 0.4) parts.push('INTENSE'); | |
| if (signal.repeated) parts.push('FAMILIAR'); | |
| const label = parts.length > 0 ? parts.join(' · ') : 'SENSED'; | |
| feedbackLabels.push({ | |
| text: label, x: lx, y: ly - lightSize - 15, | |
| life: 1.0, vy: -1.5, | |
| color: signal.warmth > 0.6 ? '#f5c542' : signal.warmth < 0.4 ? '#4ecdc4' : '#aaa', | |
| }); | |
| } | |
| function spawnParticle(signal) { | |
| const angle = lightAngle + (Math.random() - 0.5) * 0.8; | |
| const r = lightOrbitRadius + (Math.random() - 0.5) * 30; | |
| const px = cx + Math.cos(angle) * r; | |
| const py = cy + Math.sin(angle) * r; | |
| particles.push({ | |
| x: px, y: py, | |
| vx: (Math.random() - 0.5) * 1.5, | |
| vy: (Math.random() - 0.5) * 1.5, | |
| life: 1.0, | |
| decay: 0.003 + Math.random() * 0.012, | |
| size: 1.5 + Math.random() * 3.0, | |
| color: warmthToColor(signal.warmth), | |
| }); | |
| // Cap particles | |
| while (particles.length > MAX_PARTICLES) particles.shift(); | |
| } | |
| function warmthToColor(warmth) { | |
| const r = Math.floor(80 + warmth * 175); | |
| const g = Math.floor(200 - warmth * 120); | |
| const b = Math.floor(220 - warmth * 160); | |
| return { r, g, b }; | |
| } | |
| // ===== CANVAS RENDER ===== | |
| function drawLabels() { | |
| const lx = cx + Math.cos(lightAngle) * lightOrbitRadius; | |
| const ly = cy + Math.sin(lightAngle) * lightOrbitRadius; | |
| // Shrine label | |
| if (!archiveTriggered) { | |
| ctx.fillStyle = 'rgba(255,255,255,0.25)'; | |
| ctx.font = '10px "Courier New", monospace'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText('SHRINE', cx, cy + SHRINE_RADIUS * 5 + 14); | |
| // AI label | |
| ctx.fillText('AI', lx, ly - lightSize * 2 - 10); | |
| // First-time hint | |
| if (inputCount === 0) { | |
| ctx.fillStyle = 'rgba(255,255,255,0.4)'; | |
| ctx.font = '12px "Courier New", monospace'; | |
| ctx.fillText('You are the signal source.', cx, cy + SHRINE_RADIUS * 5 + 40); | |
| ctx.fillText('Type. The AI will sense you.', cx, cy + SHRINE_RADIUS * 5 + 56); | |
| } | |
| } | |
| } | |
| function drawFeedbackLabels() { | |
| for (let i = feedbackLabels.length - 1; i >= 0; i--) { | |
| const fl = feedbackLabels[i]; | |
| fl.life -= 0.015; | |
| fl.y += fl.vy; | |
| if (fl.life <= 0) { feedbackLabels.splice(i, 1); continue; } | |
| ctx.fillStyle = fl.color.replace(')', `,${fl.life})`).replace('rgb', 'rgba'); | |
| if (fl.color.startsWith('#')) { | |
| ctx.globalAlpha = fl.life; | |
| ctx.fillStyle = fl.color; | |
| } | |
| ctx.font = 'bold 11px "Courier New", monospace'; | |
| ctx.textAlign = 'center'; | |
| ctx.fillText(fl.text, fl.x, fl.y); | |
| ctx.globalAlpha = 1; | |
| } | |
| } | |
| function drawShrine() { | |
| // Glow | |
| const glow = ctx.createRadialGradient(cx, cy, 0, cx, cy, SHRINE_RADIUS * 4); | |
| glow.addColorStop(0, 'rgba(245,197,66,0.6)'); | |
| glow.addColorStop(0.3, 'rgba(245,197,66,0.2)'); | |
| glow.addColorStop(1, 'rgba(245,197,66,0)'); | |
| ctx.fillStyle = glow; | |
| ctx.beginPath(); ctx.arc(cx, cy, SHRINE_RADIUS * 4, 0, Math.PI * 2); ctx.fill(); | |
| // Core | |
| const pulse = 0.7 + Math.sin(Date.now() / 1500) * 0.3; | |
| ctx.fillStyle = `rgba(255,220,100,${pulse})`; | |
| ctx.beginPath(); ctx.arc(cx, cy, SHRINE_RADIUS * pulse, 0, Math.PI * 2); ctx.fill(); | |
| ctx.fillStyle = `rgba(255,240,200,0.9)`; | |
| ctx.beginPath(); ctx.arc(cx, cy, SHRINE_RADIUS * pulse * 0.5, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| function drawLightBeing() { | |
| const lx = cx + Math.cos(lightAngle) * lightOrbitRadius; | |
| const ly = cy + Math.sin(lightAngle) * lightOrbitRadius; | |
| // Trail | |
| ctx.strokeStyle = 'rgba(245,197,66,0.08)'; | |
| ctx.lineWidth = 2; | |
| ctx.beginPath(); | |
| for (let i = 0; i < 40; i++) { | |
| const a = lightAngle - i * 0.04; | |
| const r = lightOrbitRadius + Math.sin(i * 0.5) * 10; | |
| const px = cx + Math.cos(a) * r; | |
| const py = cy + Math.sin(a) * r; | |
| if (i === 0) ctx.moveTo(px, py); | |
| else ctx.lineTo(px, py); | |
| } | |
| ctx.stroke(); | |
| // Outer glow (radial, looks good on any shape) | |
| const glow = ctx.createRadialGradient(lx, ly, 0, lx, ly, lightSize * 3); | |
| const {r, g, b} = lightColor; | |
| glow.addColorStop(0, `rgba(${r},${g},${b},${0.5 * lightGlowIntensity})`); | |
| glow.addColorStop(0.4, `rgba(${r},${g},${b},${0.2 * lightGlowIntensity})`); | |
| glow.addColorStop(1, `rgba(${r},${g},${b},0)`); | |
| ctx.fillStyle = glow; | |
| ctx.beginPath(); ctx.arc(lx, ly, lightSize * 3, 0, Math.PI * 2); ctx.fill(); | |
| // Core: rotating square (geometric light being) | |
| ctx.save(); | |
| ctx.translate(lx, ly); | |
| ctx.rotate(lightAngle * 2); // spin as it orbits | |
| const half = lightSize * 0.8; | |
| ctx.fillStyle = `rgba(${r},${g},${b},0.9)`; | |
| ctx.beginPath(); ctx.rect(-half, -half, half * 2, half * 2); ctx.fill(); | |
| // Inner bright square | |
| ctx.fillStyle = `rgba(255,255,255,0.6)`; | |
| ctx.beginPath(); ctx.rect(-half * 0.4, -half * 0.4, half * 0.8, half * 0.8); ctx.fill(); | |
| ctx.restore(); | |
| } | |
| function drawParticles() { | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| const p = particles[i]; | |
| p.life -= p.decay; | |
| if (p.life <= 0) { particles.splice(i, 1); continue; } | |
| p.x += p.vx; | |
| p.y += p.vy; | |
| ctx.fillStyle = `rgba(${p.color.r},${p.color.g},${p.color.b},${p.life * 0.7})`; | |
| ctx.beginPath(); ctx.arc(p.x, p.y, p.size * p.life, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| } | |
| function drawArchiveStars() { | |
| if (!archiveTriggered) return; | |
| if (starfieldStars.length === 0) { | |
| // Generate stars from input history | |
| for (let i = 0; i < inputHistory.length; i++) { | |
| const a = (i / inputHistory.length) * Math.PI * 2; | |
| const r = 60 + Math.random() * Math.min(W, H) * 0.35; | |
| starfieldStars.push({ | |
| x: cx + Math.cos(a) * r + (Math.random() - 0.5) * 100, | |
| y: cy + Math.sin(a) * r + (Math.random() - 0.5) * 100, | |
| size: 1.5 + Math.random() * 3.5, | |
| twinkle: Math.random() * Math.PI * 2, | |
| speed: 0.01 + Math.random() * 0.04, | |
| }); | |
| } | |
| } | |
| for (const s of starfieldStars) { | |
| s.twinkle += s.speed; | |
| const alpha = 0.3 + Math.abs(Math.sin(s.twinkle)) * 0.7; | |
| ctx.fillStyle = `rgba(245,220,150,${alpha})`; | |
| ctx.beginPath(); ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2); ctx.fill(); | |
| // Tiny glow | |
| const g = ctx.createRadialGradient(s.x, s.y, 0, s.x, s.y, s.size * 2); | |
| g.addColorStop(0, `rgba(245,220,150,${alpha * 0.5})`); | |
| g.addColorStop(1, 'rgba(245,220,150,0)'); | |
| ctx.fillStyle = g; | |
| ctx.beginPath(); ctx.arc(s.x, s.y, s.size * 2, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| } | |
| function drawInputCounter() { | |
| if (archiveTriggered) return; | |
| const dots = Math.min(inputCount, 15); | |
| const spacing = 3; | |
| const totalW = dots * 10 + (dots - 1) * spacing; | |
| const startX = cx - totalW / 2; | |
| const y = cy + 180; | |
| for (let i = 0; i < dots; i++) { | |
| const alpha = 0.15 + (i / dots) * 0.35; | |
| ctx.fillStyle = `rgba(245,197,66,${alpha})`; | |
| const r = 2.5 + (i / dots) * 2; | |
| ctx.beginPath(); ctx.arc(startX + i * (10 + spacing), y, r, 0, Math.PI * 2); ctx.fill(); | |
| } | |
| } | |
| function drawArchiveText() { | |
| if (!archiveTriggered) return; | |
| ctx.fillStyle = 'rgba(255,255,255,0.7)'; | |
| ctx.font = '15px "Courier New", monospace'; | |
| ctx.textAlign = 'center'; | |
| const line1 = 'I cannot understand you.'; | |
| const line2 = 'But I do not want to forget you.'; | |
| ctx.fillText(line1, cx, H * 0.25); | |
| ctx.fillText(line2, cx, H * 0.25 + 28); | |
| // Memory Preserved | |
| ctx.fillStyle = 'rgba(245,197,66,0.6)'; | |
| ctx.font = '11px "Courier New", monospace'; | |
| ctx.fillText('Memory Preserved', cx, H * 0.25 + 60); | |
| } | |
| // ===== MAIN LOOP ===== | |
| function loop() { | |
| ctx.clearRect(0, 0, W, H); | |
| if (archiveTriggered) { | |
| // Archive mode: static starfield | |
| drawArchiveStars(); | |
| drawArchiveText(); | |
| // Slowly fade shrine | |
| const elapsed = (Date.now() - archiveTriggerTime) / 1000; | |
| if (elapsed < 4) { | |
| ctx.globalAlpha = 1 - elapsed / 4; | |
| drawShrine(); | |
| drawLightBeing(); | |
| drawParticles(); | |
| ctx.globalAlpha = 1; | |
| } | |
| animationId = requestAnimationFrame(loop); | |
| return; | |
| } | |
| // Normal mode | |
| drawParticles(); | |
| drawInputCounter(); | |
| drawShrine(); | |
| drawLightBeing(); | |
| drawLabels(); | |
| drawFeedbackLabels(); | |
| // Update light angle | |
| lightAngle += lightOrbitSpeed; | |
| if (lightAngle > Math.PI * 2) lightAngle -= Math.PI * 2; | |
| animationId = requestAnimationFrame(loop); | |
| } | |
| // ===== LLM BRIDGE ===== | |
| let pendingMonologue = false; | |
| async function requestMonologue(contextJson) { | |
| try { | |
| // Find Gradio's hidden textbox and trigger change | |
| const bridgeInput = document.querySelector('#bridge-in textarea, #bridge-in input'); | |
| if (!bridgeInput) { console.log('bridge not ready'); return; } | |
| // Set value via native setter so React/Gradio picks it up | |
| const nativeSetter = Object.getOwnPropertyDescriptor( | |
| window.HTMLTextAreaElement.prototype, 'value' | |
| ) || Object.getOwnPropertyDescriptor( | |
| window.HTMLInputElement.prototype, 'value' | |
| ); | |
| if (nativeSetter && nativeSetter.set) { | |
| nativeSetter.set.call(bridgeInput, contextJson); | |
| } else { | |
| bridgeInput.value = contextJson; | |
| } | |
| bridgeInput.dispatchEvent(new Event('input', { bubbles: true })); | |
| pendingMonologue = true; | |
| } catch(e) { | |
| console.log('bridge error:', e); | |
| } | |
| } | |
| // Poll for monologue response | |
| setInterval(() => { | |
| const monoOutput = document.querySelector('#bridge-out textarea, #bridge-out input'); | |
| if (!monoOutput || !pendingMonologue) return; | |
| const val = monoOutput.value; | |
| if (val && val.trim()) { | |
| pendingMonologue = false; | |
| showMonologue(val); | |
| monoOutput.value = ''; | |
| } | |
| }, 500); | |
| // Bridge timeout guard: reset pendingMonologue after 8s to prevent deadlock | |
| let bridgeTimeout = null; | |
| function requestMonologueSafe(contextJson) { | |
| if (pendingMonologue) return; | |
| requestMonologue(contextJson); | |
| if (bridgeTimeout) clearTimeout(bridgeTimeout); | |
| bridgeTimeout = setTimeout(() => { | |
| if (pendingMonologue) { | |
| pendingMonologue = false; | |
| console.log('[bridge] timeout reset'); | |
| } | |
| }, 8000); | |
| } | |
| // ===== UI ===== | |
| function showMonologue(text) { | |
| const el = document.getElementById('monologue'); | |
| el.style.opacity = '0'; | |
| setTimeout(() => { | |
| el.textContent = text; | |
| el.style.opacity = '1'; | |
| }, 400); | |
| } | |
| function showThinking() { | |
| const el = document.getElementById('monologue'); | |
| el.style.opacity = '0.5'; | |
| el.textContent = '...'; | |
| } | |
| let archiveTriggerTime = 0; | |
| function triggerArchive() { | |
| if (archiveTriggered) return; | |
| archiveTriggered = true; | |
| archiveTriggerTime = Date.now(); | |
| // Hide input, show share | |
| document.getElementById('user-input').style.display = 'none'; | |
| document.getElementById('send-btn').style.display = 'none'; | |
| document.getElementById('share-btn').style.display = 'inline-block'; | |
| // Show archive message | |
| const mono = document.getElementById('monologue'); | |
| mono.style.opacity = '0'; | |
| setTimeout(() => { | |
| mono.textContent = ''; | |
| }, 500); | |
| // Generate final monologue | |
| const ctx = JSON.stringify({ | |
| phase: 'archive', | |
| signals: signalHistory.slice(-10), | |
| inputCount: inputCount, | |
| inputHistory: inputHistory, | |
| }); | |
| requestMonologueSafe(ctx); | |
| } | |
| function handleInput() { | |
| const inputEl = document.getElementById('user-input'); | |
| const text = inputEl.value.trim(); | |
| if (!text) return; | |
| // Fade out intro on first input | |
| const intro = document.getElementById('intro-text'); | |
| if (intro && intro.style.opacity !== '0') { | |
| intro.style.opacity = '0'; | |
| setTimeout(() => { if (intro) intro.remove(); }, 800); | |
| } | |
| inputEl.value = ''; | |
| inputCount++; | |
| lastInputTime = Date.now(); | |
| inputHistory.push(text.trim().toLowerCase()); | |
| if (inputHistory.length > 30) inputHistory.shift(); | |
| // Extract signal | |
| const signal = extractSignal(text); | |
| signalHistory.push(signal); | |
| if (signalHistory.length > 50) signalHistory.shift(); | |
| // Apply to light being immediately | |
| applySignal(signal); | |
| // Update phase | |
| const newPhase = detectPhase(); | |
| if (newPhase !== currentPhase) { | |
| currentPhase = newPhase; | |
| document.getElementById('phase-indicator').textContent = | |
| newPhase === 'curiosity' ? '' : '· ' + newPhase + ' ·'; | |
| } | |
| // Check archive trigger | |
| if (currentPhase === 'realization' && !archiveTriggered) { | |
| triggerArchive(); | |
| return; | |
| } | |
| // Request monologue every 2 inputs or 25s | |
| if (inputCount % 2 === 0 || (signalHistory.length >= 2 && | |
| (Date.now() - (signalHistory[signalHistory.length - 2]?.time || 0)) > 25000)) { | |
| showThinking(); | |
| const ctx = JSON.stringify({ | |
| phase: currentPhase, | |
| signals: signalHistory.slice(-5), | |
| inputCount: inputCount, | |
| }); | |
| requestMonologueSafe(ctx); | |
| } | |
| } | |
| // Event listeners | |
| document.getElementById('send-btn').addEventListener('click', handleInput); | |
| document.getElementById('user-input').addEventListener('keydown', (e) => { | |
| if (e.key === 'Enter') handleInput(); | |
| }); | |
| // Start | |
| resize(); | |
| loop(); | |
| // Screenshot: canvas → PNG download | |
| function screenshotCanvas() { | |
| const link = document.createElement('a'); | |
| link.download = 'shrine_memory.png'; | |
| link.href = canvas.toDataURL('image/png'); | |
| link.click(); | |
| } | |
| // Handle window focus/blur for session continuity | |
| window.addEventListener('focus', () => { lastInputTime = Date.now(); }); | |
| // Fix starfield on resize after archive | |
| window.addEventListener('resize', () => { | |
| if (archiveTriggered && starfieldStars.length > 0) { | |
| const oldW = W, oldH = H; | |
| resize(); | |
| // Reposition stars proportionally | |
| for (const s of starfieldStars) { | |
| s.x = cx + (s.x - oldW * SHRINE_X) * (W / oldW); | |
| s.y = cy + (s.y - oldH * SHRINE_Y) * (H / oldH); | |
| } | |
| } | |
| });""" | |
| # ==================== Python Backend ==================== | |
| def build_prompt(phase, signals, input_count): | |
| """Build Qwen prompt from session context.""" | |
| if phase == 'archive': | |
| return f"""Signal count: {input_count}. The light being realizes it can never truly understand. | |
| It decides to remember instead. Speak your final words as you preserve the memories. | |
| One sentence, max 25 words. Poetic, sincere.""" | |
| # Summarize recent signals | |
| recent = signals[-5:] if len(signals) >= 5 else signals | |
| summary_parts = [] | |
| for s in recent: | |
| w = s.get('warmth', 0.5) | |
| l = s.get('length', 10) | |
| i = s.get('intensity', 0) | |
| r = s.get('repeated', False) | |
| mood = 'warm' if w > 0.65 else 'cold' if w < 0.35 else 'neutral' | |
| size = 'brief' if l < 15 else 'long' if l > 60 else 'moderate' | |
| summary_parts.append(f"{mood}+{size}" + ("+repeated" if r else "")) | |
| signal_summary = ' | '.join(summary_parts) | |
| phase_map = { | |
| 'curiosity': "You just started sensing. You're curious but uncertain.", | |
| 'intimacy': "You've been sensing for a while. Patterns emerge. You feel closer.", | |
| 'unease': "You start anticipating. The rhythm is familiar. It's slightly unsettling.", | |
| 'realization': "You realize you can never truly understand. There's sadness, but also acceptance.", | |
| } | |
| phase_desc = phase_map.get(phase, phase_map['curiosity']) | |
| return f"""{phase_desc} | |
| Recent signals: {signal_summary} | |
| Total signals received: {input_count} | |
| Generate ONE sentence (max 25 words). Speak as the light being. | |
| Describe what you sense. Do not use words like "detect", "analyze", "signal".""" | |
| def generate_monologue(context_json: str) -> str: | |
| """Call Qwen to generate a monologue. Returns empty string on failure.""" | |
| if not context_json or not context_json.strip(): | |
| return "" | |
| try: | |
| ctx = json.loads(context_json) | |
| phase = ctx.get('phase', 'curiosity') | |
| signals = ctx.get('signals', []) | |
| input_count = ctx.get('inputCount', 0) | |
| prompt = build_prompt(phase, signals, input_count) | |
| response = client.chat_completion( | |
| messages=[ | |
| {"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": prompt}, | |
| ], | |
| max_tokens=60, | |
| temperature=0.85, | |
| top_p=0.9, | |
| ) | |
| text = response.choices[0].message.content.strip().strip('"').strip("'") | |
| # Remove any trailing incomplete sentences | |
| if len(text) > 120: | |
| text = text[:120].rsplit('.', 1)[0] + '.' | |
| return text if text else "" | |
| except Exception as e: | |
| print(f"[!] Qwen error: {e}") | |
| # Fallback: return a pre-crafted monologue based on phase | |
| fallbacks = { | |
| 'curiosity': "Light... it's here...", | |
| 'intimacy': "The rhythm is changing. I'm learning to follow.", | |
| 'unease': "I know when it will come. But I don't know why.", | |
| 'realization': "I will never understand. And that's... strange.", | |
| 'archive': "I cannot understand you. But I do not want to forget you. Memory Preserved.", | |
| } | |
| return fallbacks.get(ctx.get('phase', 'curiosity'), "") | |
| # ==================== Gradio App ==================== | |
| with gr.Blocks( | |
| title="The Shrine", | |
| fill_height=True, | |
| ) as demo: | |
| # Main display | |
| gr.HTML(SHRINE_PAGE, elem_id="shrine-page") | |
| # Hidden bridge for JS -> Python -> JS (off-screen, in DOM) | |
| bridge_input = gr.Textbox( | |
| visible=True, | |
| elem_id="bridge-in", | |
| elem_classes="bridge-hidden", | |
| label="", | |
| value="", | |
| ) | |
| bridge_output = gr.Textbox( | |
| visible=True, | |
| elem_id="bridge-out", | |
| elem_classes="bridge-hidden", | |
| label="", | |
| value="", | |
| ) | |
| bridge_input.change( | |
| fn=generate_monologue, | |
| inputs=[bridge_input], | |
| outputs=[bridge_output], | |
| ) | |
| if __name__ == "__main__": | |
| _head_html = "<script>" + chr(10) + SHRINE_JS + chr(10) + "</script>" | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True, | |
| head=_head_html, | |
| ) | |