Spaces:
Running
Running
| // RMScript App - Frontend Application | |
| // CodeMirror imports - bare specifiers resolved via import map in index.html | |
| import { EditorView, basicSetup } from "codemirror"; | |
| import { EditorState, Compartment, StateEffect, StateField } from "@codemirror/state"; | |
| import { Decoration } from "@codemirror/view"; | |
| import { HighlightStyle, syntaxHighlighting, StreamLanguage } from "@codemirror/language"; | |
| import { tags } from "@lezer/highlight"; | |
| // API is served from same origin | |
| const API_BASE = ''; | |
| // Global state | |
| const state = { | |
| currentIR: null, | |
| isExecuting: false, | |
| abortRequested: false | |
| }; | |
| // CodeMirror editor instance | |
| let editorView = null; | |
| // Compartment for dynamic error styling | |
| const errorStyleCompartment = new Compartment(); | |
| // Error line highlighting | |
| const setErrorLine = StateEffect.define(); | |
| const clearErrorLine = StateEffect.define(); | |
| const errorLineMark = Decoration.line({ class: "cm-errorLine" }); | |
| const errorLineField = StateField.define({ | |
| create() { | |
| return Decoration.none; | |
| }, | |
| update(decorations, tr) { | |
| for (let e of tr.effects) { | |
| if (e.is(clearErrorLine)) { | |
| return Decoration.none; | |
| } | |
| if (e.is(setErrorLine)) { | |
| const lineNum = e.value; | |
| if (lineNum >= 1 && lineNum <= tr.state.doc.lines) { | |
| const line = tr.state.doc.line(lineNum); | |
| return Decoration.set([errorLineMark.range(line.from)]); | |
| } | |
| } | |
| } | |
| return decorations; | |
| }, | |
| provide: f => EditorView.decorations.from(f) | |
| }); | |
| // Debounce timer for real-time validation | |
| let validationTimer = null; | |
| const VALIDATION_DELAY = 500; | |
| // Example scripts with better names | |
| const examples = { | |
| look_around: { | |
| name: 'Look around', | |
| code: `look left | |
| wait 1s | |
| look right | |
| wait 1s | |
| look center` | |
| }, | |
| nod: { | |
| name: 'Nod', | |
| code: `repeat 3 | |
| look up small veryfast | |
| look down small veryfast | |
| look center` | |
| }, | |
| dance: { | |
| name: 'Dance', | |
| code: `repeat 3 | |
| look left 45 fast and antenna both up fast and tilt right alittle fast | |
| look right 45 fast and antenna both down fast and tilt left alittle fast | |
| look center | |
| antenna both up` | |
| }, | |
| antenna_wave: { | |
| name: 'Antenna wave', | |
| code: `antenna left up | |
| wait 0.3s | |
| antenna right up | |
| antenna left down | |
| wait 0.3s | |
| antenna right down | |
| antenna left up | |
| wait 0.3s | |
| antenna both up` | |
| }, | |
| all_at_once: { | |
| name: 'All at once', | |
| code: `# A complex example showing most of the commands | |
| # Every line start starts with # is a comment | |
| look left | |
| # You can specify how many degrees | |
| look right 45 | |
| # Specify duration | |
| look up 30 2s | |
| # Use qualitative keywords for both intensity and duration | |
| look down maximum veryfast | |
| # Qualitative keywords have a lot of synonyms | |
| look center | |
| look left strong | |
| look straight | |
| look right small | |
| look neutral | |
| # Combine directions for the same move using "and" | |
| look up and left | |
| # Move the head, amount is in millimeters | |
| head up | |
| head down 20 | |
| head left 20 fast | |
| # Combine different movements with and | |
| head up and look right | |
| # Turn the body | |
| turn right alot | |
| # Just wait | |
| wait 1s | |
| turn neutral | |
| # Repeat a sequence, don't forget to indent | |
| repeat 3 | |
| tilt left maximum fast | |
| tilt right maximum fast | |
| tilt center | |
| antenna both up | |
| antenna both down | |
| # For antennas numbers indicate position on the clock | |
| antenna left 3 and antenna right 9` | |
| } | |
| }; | |
| // DOM elements (populated after DOM loads) | |
| let elements = {}; | |
| // Action type to icon mapping | |
| const ACTION_ICONS = { | |
| wait: 'β³', | |
| picture: 'π·', | |
| sound: 'π' | |
| }; | |
| // Get icon based on action content | |
| function getActionIcon(action) { | |
| if (action.type === 'action') { | |
| if (action.body_yaw !== null && action.body_yaw !== undefined) return 'π'; // Turn | |
| if (action.antennas) return 'π‘'; // Antenna | |
| if (action.head_pose) { | |
| // Check if it's rotation (look/tilt) or translation (head movement) | |
| // Look at translation component of 4x4 matrix (last column) | |
| const m = action.head_pose; | |
| const hasTranslation = m[0][3] !== 0 || m[1][3] !== 0 || m[2][3] !== 0; | |
| if (hasTranslation) return 'π€'; // Head movement | |
| // Check for tilt (roll rotation) | |
| const roll = Math.atan2(m[2][1], m[2][2]) * 180 / Math.PI; | |
| if (Math.abs(roll) >= 5) return 'π'; // Tilt | |
| return 'π'; // Look | |
| } | |
| return 'βΆοΈ'; | |
| } | |
| return ACTION_ICONS[action.type] || 'βΆοΈ'; | |
| } | |
| // Get action label (e.g., "LOOK LEFT", "TURN RIGHT", or combined like "HEAD UP + LOOK RIGHT") | |
| function getActionLabel(action) { | |
| if (action.type === 'wait') return 'WAIT'; | |
| if (action.type === 'picture') return 'TAKE PICTURE'; | |
| if (action.type === 'sound') return `PLAY SOUND`; | |
| if (action.type === 'action') { | |
| const parts = []; | |
| // Body turn | |
| if (action.body_yaw !== null && action.body_yaw !== undefined) { | |
| if (Math.abs(action.body_yaw) < 0.01) { | |
| parts.push('TURN CENTER'); | |
| } else { | |
| const dir = action.body_yaw > 0 ? 'LEFT' : 'RIGHT'; | |
| parts.push(`TURN ${dir}`); | |
| } | |
| } | |
| // Head pose (can have both translation AND rotation) | |
| if (action.head_pose) { | |
| const m = action.head_pose; | |
| // Translation (head movement) | |
| const tx = m[0][3], ty = m[1][3], tz = m[2][3]; | |
| const hasTranslation = Math.abs(tx) > 0.001 || Math.abs(ty) > 0.001 || Math.abs(tz) > 0.001; | |
| if (hasTranslation) { | |
| // Head translation - fix axis mapping: ty=left/right, tx=forward/back, tz=up/down | |
| let dir = ''; | |
| if (Math.abs(ty) > Math.abs(tx) && Math.abs(ty) > Math.abs(tz)) { | |
| dir = ty > 0 ? 'LEFT' : 'RIGHT'; | |
| } else if (Math.abs(tx) > Math.abs(tz)) { | |
| dir = tx > 0 ? 'BACKWARD' : 'FORWARD'; | |
| } else { | |
| dir = tz > 0 ? 'UP' : 'DOWN'; | |
| } | |
| parts.push(`HEAD ${dir}`); | |
| } | |
| // Rotation - extract from rotation matrix | |
| const r11 = m[0][0], r21 = m[1][0], r31 = m[2][0]; | |
| const r32 = m[2][1], r33 = m[2][2]; | |
| const yaw = Math.atan2(r21, r11) * 180 / Math.PI; | |
| const pitch = Math.atan2(-r31, Math.sqrt(r32*r32 + r33*r33)) * 180 / Math.PI; | |
| const roll = Math.atan2(r32, r33) * 180 / Math.PI; | |
| // Check for tilt (roll) | |
| if (Math.abs(roll) >= 5) { | |
| parts.push(`TILT ${roll > 0 ? 'LEFT' : 'RIGHT'}`); | |
| } | |
| // Check for look (yaw/pitch) | |
| if (Math.abs(yaw) >= 5 || Math.abs(pitch) >= 5) { | |
| let dirs = []; | |
| if (Math.abs(yaw) >= 5) dirs.push(yaw > 0 ? 'LEFT' : 'RIGHT'); | |
| if (Math.abs(pitch) >= 5) dirs.push(pitch < 0 ? 'UP' : 'DOWN'); | |
| parts.push(`LOOK ${dirs.join(' ')}`); | |
| } | |
| // If no significant rotation or translation detected, it's returning to center | |
| if (!hasTranslation && Math.abs(yaw) < 5 && Math.abs(pitch) < 5 && Math.abs(roll) < 5) { | |
| parts.push('CENTER'); | |
| } | |
| } | |
| // Antennas | |
| if (action.antennas) { | |
| parts.push('ANTENNA'); | |
| } | |
| if (parts.length === 0) return 'MOVE'; | |
| return parts.join(' + '); | |
| } | |
| return action.type.toUpperCase(); | |
| } | |
| // Get action details (duration, intensity) | |
| function getActionDetails(action) { | |
| const parts = []; | |
| // Duration | |
| if (action.duration > 0) { | |
| parts.push(`${action.duration.toFixed(1)}s`); | |
| } | |
| if (action.type === 'action') { | |
| // Body yaw angle | |
| if (action.body_yaw !== null && action.body_yaw !== undefined && action.body_yaw !== 0) { | |
| const deg = Math.abs(action.body_yaw * 180 / Math.PI).toFixed(0); | |
| parts.push(`${deg}Β°`); | |
| } | |
| // Head pose intensity | |
| if (action.head_pose) { | |
| const m = action.head_pose; | |
| const tx = m[0][3], ty = m[1][3], tz = m[2][3]; | |
| const hasTranslation = Math.abs(tx) > 0.001 || Math.abs(ty) > 0.001 || Math.abs(tz) > 0.001; | |
| if (hasTranslation) { | |
| // Translation in mm | |
| const dist = Math.sqrt(tx*tx + ty*ty + tz*tz) * 1000; | |
| parts.push(`${dist.toFixed(0)}mm`); | |
| } else { | |
| // Rotation in degrees | |
| const r11 = m[0][0], r21 = m[1][0], r31 = m[2][0]; | |
| const r32 = m[2][1], r33 = m[2][2]; | |
| const yaw = Math.abs(Math.atan2(r21, r11) * 180 / Math.PI); | |
| const pitch = Math.abs(Math.atan2(-r31, Math.sqrt(r32*r32 + r33*r33)) * 180 / Math.PI); | |
| const maxAngle = Math.max(yaw, pitch); | |
| if (maxAngle >= 1) { | |
| parts.push(`${maxAngle.toFixed(0)}Β°`); | |
| } | |
| } | |
| } | |
| // Antennas | |
| if (action.antennas) { | |
| const [left, right] = action.antennas; | |
| const leftDeg = (left * 180 / Math.PI).toFixed(0); | |
| const rightDeg = (right * 180 / Math.PI).toFixed(0); | |
| parts.push(`L:${leftDeg}Β° R:${rightDeg}Β°`); | |
| } | |
| } else if (action.type === 'sound' && action.sound_name) { | |
| parts.push(`"${action.sound_name}"`); | |
| } | |
| return parts.join(' Β· '); | |
| } | |
| // RMScript language definition with context-aware highlighting | |
| const rmscriptLanguage = StreamLanguage.define({ | |
| startState() { | |
| return { context: null }; | |
| }, | |
| token(stream, state) { | |
| // Skip whitespace (preserve context across spaces) | |
| if (stream.eatSpace()) return null; | |
| // Comments - gray | |
| if (stream.match(/#.*/)) { | |
| state.context = null; | |
| return "comment"; | |
| } | |
| // Duration with 's' suffix (e.g., 2s, 0.5s) - always blue | |
| if (stream.match(/\d+(?:\.\d+)?s\b/)) { | |
| state.context = null; | |
| return "number"; | |
| } | |
| // Numbers (context-dependent) | |
| if (stream.match(/\d+(?:\.\d+)?/)) { | |
| if (state.context === 'repeat') { | |
| // Number after repeat -> purple | |
| state.context = null; | |
| return "meta"; | |
| } | |
| if (state.context === 'action') { | |
| // Number after action keyword (angle/quantity) -> brown | |
| state.context = null; | |
| return "propertyName"; | |
| } | |
| state.context = null; | |
| return "number"; | |
| } | |
| // Keywords and identifiers | |
| if (stream.match(/[a-zA-Z_][a-zA-Z0-9_]*/)) { | |
| const word = stream.current().toLowerCase(); | |
| // Action keywords - green | |
| if (/^(look|tilt|turn|head|antenna)$/.test(word)) { | |
| state.context = 'action'; | |
| return "keyword"; | |
| } | |
| // Control flow keywords - purple | |
| if (/^(repeat)$/.test(word)) { | |
| state.context = 'repeat'; | |
| return "meta"; | |
| } | |
| if (/^(and|wait)$/.test(word)) { | |
| state.context = null; | |
| return "meta"; | |
| } | |
| // Directions - orange | |
| if (/^(left|right|up|down|both|center|neutral|straight|backward|backwards|forward|back)$/.test(word)) { | |
| // Don't reset context - number might follow direction | |
| return "string"; | |
| } | |
| // Duration/speed keywords - blue | |
| if (/^(slow|fast|superslow|superfast)$/.test(word)) { | |
| state.context = null; | |
| return "number"; | |
| } | |
| // Qualitative quantities - brown | |
| if (/^(alittle|tiny|small|medium|large|maximum|minimum|alot)$/.test(word)) { | |
| state.context = null; | |
| return "propertyName"; | |
| } | |
| // Unknown identifier | |
| state.context = null; | |
| return "variableName"; | |
| } | |
| // Skip any other character | |
| stream.next(); | |
| state.context = null; | |
| return null; | |
| } | |
| }); | |
| // Syntax highlighting colors: | |
| // - Green: action keywords (look, tilt, turn, head, antenna) | |
| // - Orange: directions (left, right, up, down, both) | |
| // - Purple: control flow (repeat, and, wait) + repeat count | |
| // - Blue: durations (slow, fast, 3s, etc.) | |
| // - Brown: quantities (numbers after actions, tiny, maximum, etc.) | |
| const rmscriptHighlightStyle = HighlightStyle.define([ | |
| { tag: tags.keyword, color: "#11733b", fontWeight: "bold" }, // green | |
| { tag: tags.string, color: "#94b31f" }, // orange | |
| { tag: tags.meta, color: "#9C27B0", fontWeight: "bold" }, // purple | |
| { tag: tags.number, color: "#4daaff" }, // blue | |
| { tag: tags.propertyName, color: "#f38236" }, // brown | |
| { tag: tags.comment, color: "#888888", fontStyle: "italic" }, // gray | |
| { tag: tags.variableName, color: "#333333" }, // dark gray | |
| ]); | |
| // Error theme (red border) | |
| const errorTheme = EditorView.theme({ | |
| "&.cm-editor": { | |
| borderColor: "#dc3545 !important" | |
| } | |
| }); | |
| // No error theme (normal border) | |
| const noErrorTheme = EditorView.theme({}); | |
| // Create CodeMirror editor | |
| function createEditor(parentElement, initialContent) { | |
| const startState = EditorState.create({ | |
| doc: initialContent, | |
| extensions: [ | |
| basicSetup, | |
| rmscriptLanguage, | |
| syntaxHighlighting(rmscriptHighlightStyle), | |
| errorStyleCompartment.of(noErrorTheme), | |
| errorLineField, | |
| EditorView.updateListener.of((update) => { | |
| if (update.docChanged) { | |
| scheduleValidation(); | |
| } | |
| }), | |
| EditorView.theme({ | |
| ".cm-errorLine": { | |
| backgroundColor: "rgba(220, 53, 69, 0.15)", | |
| }, | |
| "&": { | |
| height: "450px", | |
| fontSize: "16px", | |
| }, | |
| "&.cm-editor": { | |
| border: "2px solid #e0e0e0", | |
| borderRadius: "8px", | |
| backgroundColor: "#fafafa", | |
| }, | |
| "&.cm-editor.cm-focused": { | |
| borderColor: "#667eea", | |
| backgroundColor: "white", | |
| outline: "none", | |
| }, | |
| ".cm-scroller": { | |
| fontFamily: "'Monaco', 'Menlo', 'Consolas', monospace", | |
| lineHeight: "1.6", | |
| overflow: "auto", | |
| }, | |
| ".cm-content": { | |
| padding: "10px 0", | |
| }, | |
| ".cm-gutters": { | |
| backgroundColor: "#f5f5f5", | |
| borderRight: "1px solid #e0e0e0", | |
| color: "#999", | |
| }, | |
| ".cm-activeLineGutter": { | |
| backgroundColor: "#e8e8e8", | |
| }, | |
| ".cm-activeLine": { | |
| backgroundColor: "#f0f0f0", | |
| }, | |
| }), | |
| ] | |
| }); | |
| return new EditorView({ | |
| state: startState, | |
| parent: parentElement, | |
| }); | |
| } | |
| // Get editor content | |
| function getEditorContent() { | |
| return editorView ? editorView.state.doc.toString() : ''; | |
| } | |
| // Set editor content | |
| function setEditorContent(content) { | |
| if (editorView) { | |
| editorView.dispatch({ | |
| changes: { | |
| from: 0, | |
| to: editorView.state.doc.length, | |
| insert: content | |
| } | |
| }); | |
| } | |
| } | |
| // Update compile status UI (below editor) | |
| function updateCompileStatus(status, message) { | |
| elements.compileStatus.className = `compile-status ${status}`; | |
| elements.compileStatus.querySelector('.status-message').textContent = message; | |
| } | |
| // Log to console | |
| function log(message, type = 'info') { | |
| const line = document.createElement('div'); | |
| line.className = `console-line ${type}`; | |
| line.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; | |
| elements.console.appendChild(line); | |
| elements.console.scrollTop = elements.console.scrollHeight; | |
| } | |
| // Clear console | |
| function clearConsole() { | |
| elements.console.innerHTML = ''; | |
| } | |
| // Check if editor has content | |
| function editorHasContent() { | |
| return getEditorContent().trim().length > 0; | |
| } | |
| // Handle dropdown selection for examples | |
| function handleExampleSelect(selectElement) { | |
| const exampleName = selectElement.value; | |
| if (!exampleName) return; | |
| selectElement.selectedIndex = 0; | |
| if (editorHasContent()) { | |
| if (!confirm(`Load "${examples[exampleName].name}" example? This will replace your current script.`)) { | |
| return; | |
| } | |
| } | |
| loadExample(exampleName); | |
| } | |
| // Load example script | |
| function loadExample(exampleName) { | |
| const example = examples[exampleName]; | |
| if (example) { | |
| setEditorContent(example.code); | |
| log(`Loaded "${example.name}" example`, 'info'); | |
| scheduleValidation(); | |
| } | |
| } | |
| // Clear editor | |
| function clearEditor() { | |
| if (editorHasContent()) { | |
| if (!confirm('Clear the editor? This will remove your current script.')) { | |
| return; | |
| } | |
| } | |
| setEditorContent(''); | |
| state.currentIR = null; | |
| elements.irDisplay.innerHTML = '<div style="color: #999; text-align: center; padding: 20px;">No actions yet. Write a script and click Run!</div>'; | |
| updateCompileStatus('idle', 'Ready'); | |
| hideError(); | |
| log('Editor cleared', 'info'); | |
| } | |
| // Save/Load using download/upload (works on all browsers) | |
| function saveScript() { | |
| const source = getEditorContent().trim(); | |
| if (!source) { | |
| log('Cannot save empty script', 'warning'); | |
| return; | |
| } | |
| const blob = new Blob([source], { type: 'text/plain' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'script.rmscript'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| log('Script downloaded as "script.rmscript"', 'success'); | |
| } | |
| function loadScript() { | |
| const input = document.getElementById('fileInput'); | |
| input.click(); | |
| } | |
| function handleFileSelect(event) { | |
| const file = event.target.files[0]; | |
| if (!file) return; | |
| if (editorHasContent()) { | |
| if (!confirm(`Load "${file.name}"? This will replace your current script.`)) { | |
| event.target.value = ''; | |
| return; | |
| } | |
| } | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| setEditorContent(e.target.result); | |
| log(`Loaded "${file.name}"`, 'info'); | |
| scheduleValidation(); | |
| }; | |
| reader.onerror = () => { | |
| log(`Failed to read "${file.name}"`, 'error'); | |
| }; | |
| reader.readAsText(file); | |
| event.target.value = ''; | |
| } | |
| // Cheat sheet | |
| function showCheatSheet() { | |
| document.getElementById('cheatSheetModal').style.display = 'flex'; | |
| } | |
| function hideCheatSheet() { | |
| document.getElementById('cheatSheetModal').style.display = 'none'; | |
| } | |
| // Browser Sound Feedback using Web Audio API | |
| let audioContext = null; | |
| function getAudioContext() { | |
| if (!audioContext) { | |
| audioContext = new (window.AudioContext || window.webkitAudioContext)(); | |
| } | |
| return audioContext; | |
| } | |
| function playTone(frequency, duration, type = 'sine') { | |
| try { | |
| const ctx = getAudioContext(); | |
| const oscillator = ctx.createOscillator(); | |
| const gainNode = ctx.createGain(); | |
| oscillator.connect(gainNode); | |
| gainNode.connect(ctx.destination); | |
| oscillator.frequency.value = frequency; | |
| oscillator.type = type; | |
| gainNode.gain.setValueAtTime(0.3, ctx.currentTime); | |
| gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration); | |
| oscillator.start(ctx.currentTime); | |
| oscillator.stop(ctx.currentTime + duration); | |
| } catch (e) { | |
| console.log('Audio not available:', e); | |
| } | |
| } | |
| function playStartSound() { | |
| playTone(523, 0.1); | |
| setTimeout(() => playTone(659, 0.1), 100); | |
| setTimeout(() => playTone(784, 0.15), 200); | |
| } | |
| function playSuccessSound() { | |
| playTone(523, 0.2); | |
| setTimeout(() => playTone(659, 0.2), 50); | |
| setTimeout(() => playTone(784, 0.3), 100); | |
| } | |
| function playErrorSound() { | |
| playTone(400, 0.15); | |
| setTimeout(() => playTone(300, 0.2), 150); | |
| } | |
| // Show error in compile status | |
| function showError(message, lineNumber) { | |
| const prefix = lineNumber ? `Line ${lineNumber}: ` : ''; | |
| updateCompileStatus('error', prefix + message); | |
| // Apply error border and highlight error line | |
| if (editorView) { | |
| const effects = [errorStyleCompartment.reconfigure(errorTheme)]; | |
| if (lineNumber) { | |
| effects.push(setErrorLine.of(lineNumber)); | |
| } | |
| editorView.dispatch({ effects }); | |
| } | |
| } | |
| function hideError() { | |
| if (editorView) { | |
| editorView.dispatch({ | |
| effects: [ | |
| errorStyleCompartment.reconfigure(noErrorTheme), | |
| clearErrorLine.of(null) | |
| ] | |
| }); | |
| } | |
| } | |
| // Real-time validation | |
| async function validateRealtime() { | |
| const source = getEditorContent().trim(); | |
| if (!source) { | |
| updateCompileStatus('idle', 'Ready'); | |
| hideError(); | |
| return; | |
| } | |
| try { | |
| const response = await fetch(`${API_BASE}/api/verify`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ source }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| updateCompileStatus('success', 'Valid script'); | |
| hideError(); | |
| } else { | |
| updateCompileStatus('error', 'Invalid script'); | |
| if (result.errors && result.errors.length > 0) { | |
| showError(result.errors[0].message, result.errors[0].line); | |
| } | |
| } | |
| } catch (error) { | |
| updateCompileStatus('error', 'Backend error'); | |
| } | |
| } | |
| function scheduleValidation() { | |
| if (validationTimer) { | |
| clearTimeout(validationTimer); | |
| } | |
| validationTimer = setTimeout(validateRealtime, VALIDATION_DELAY); | |
| } | |
| // Verify script | |
| async function verifyScript() { | |
| const source = getEditorContent().trim(); | |
| if (!source) { | |
| log('Editor is empty', 'warning'); | |
| return; | |
| } | |
| clearConsole(); | |
| log('Verifying script...', 'info'); | |
| updateCompileStatus('idle', 'Verifying...'); | |
| try { | |
| const response = await fetch(`${API_BASE}/api/verify`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ source }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| log('Verification successful!', 'success'); | |
| if (result.name) log(` Name: ${result.name}`, 'info'); | |
| if (result.description) log(` Description: ${result.description}`, 'info'); | |
| if (result.warnings && result.warnings.length > 0) { | |
| result.warnings.forEach(w => { | |
| log(` Warning (line ${w.line}): ${w.message}`, 'warning'); | |
| }); | |
| } | |
| updateCompileStatus('success', 'Valid script'); | |
| hideError(); | |
| } else { | |
| log('Verification failed', 'error'); | |
| result.errors.forEach(e => { | |
| log(` Error (line ${e.line}): ${e.message}`, 'error'); | |
| }); | |
| updateCompileStatus('error', 'Invalid script'); | |
| if (result.errors && result.errors.length > 0) { | |
| showError(result.errors[0].message, result.errors[0].line); | |
| } | |
| } | |
| } catch (error) { | |
| log(`Backend error: ${error.message}`, 'error'); | |
| updateCompileStatus('error', 'Backend error'); | |
| } | |
| } | |
| // Compile script to IR | |
| async function compileScript() { | |
| const source = getEditorContent().trim(); | |
| if (!source) { | |
| log('Editor is empty', 'warning'); | |
| return null; | |
| } | |
| log('Compiling script to IR...', 'info'); | |
| updateCompileStatus('idle', 'Compiling...'); | |
| try { | |
| const response = await fetch(`${API_BASE}/api/compile`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ source }) | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| log(`Compiled ${result.ir.length} actions`, 'success'); | |
| if (result.warnings && result.warnings.length > 0) { | |
| result.warnings.forEach(w => { | |
| log(` Warning (line ${w.line}): ${w.message}`, 'warning'); | |
| }); | |
| } | |
| state.currentIR = result.ir; | |
| displayIR(result.ir); | |
| updateCompileStatus('success', `${result.ir.length} actions ready`); | |
| return result.ir; | |
| } else { | |
| log('Compilation failed', 'error'); | |
| result.errors.forEach(e => { | |
| log(` Error (line ${e.line}): ${e.message}`, 'error'); | |
| }); | |
| updateCompileStatus('error', 'Compilation failed'); | |
| return null; | |
| } | |
| } catch (error) { | |
| log(`Backend error: ${error.message}`, 'error'); | |
| updateCompileStatus('error', 'Backend error'); | |
| return null; | |
| } | |
| } | |
| // Display IR in the UI | |
| function displayIR(ir, currentActionIndex = -1) { | |
| if (!ir || ir.length === 0) { | |
| elements.irDisplay.innerHTML = '<div style="color: #999; text-align: center; padding: 20px;">No actions yet. Write a script and click Run!</div>'; | |
| return; | |
| } | |
| const html = ir.map((action, idx) => { | |
| const icon = getActionIcon(action); | |
| const label = getActionLabel(action); | |
| const details = getActionDetails(action); | |
| const isCurrent = idx === currentActionIndex; | |
| return ` | |
| <div class="ir-action${isCurrent ? ' current' : ''}" data-action-index="${idx}"> | |
| <span class="ir-action-icon">${icon}</span> | |
| <div class="ir-action-content"> | |
| <div class="ir-action-type">${idx + 1}. ${label}</div> | |
| <div class="ir-action-details">${details}</div> | |
| </div> | |
| </div> | |
| `; | |
| }).join(''); | |
| elements.irDisplay.innerHTML = html; | |
| } | |
| // Update run button status | |
| function updateRunButton(status, message, icon = 'π€') { | |
| const btn = elements.executeBtn; | |
| if (!btn) return; | |
| const iconEl = btn.querySelector('.btn-run-icon'); | |
| const textEl = btn.querySelector('.btn-run-text'); | |
| if (iconEl) iconEl.textContent = icon; | |
| if (textEl) textEl.textContent = message; | |
| btn.classList.remove('running', 'disconnected'); | |
| if (status === 'running') { | |
| btn.classList.add('running'); | |
| } else if (status === 'disconnected') { | |
| btn.classList.add('disconnected'); | |
| btn.disabled = true; | |
| } else { | |
| btn.disabled = false; | |
| } | |
| } | |
| // Helper to describe an action for timeline display | |
| function describeAction(action) { | |
| if (action.type === 'wait') return 'Wait'; | |
| if (action.type === 'picture') return 'Take picture'; | |
| if (action.type === 'sound') return `Play sound`; | |
| if (action.type === 'action') { | |
| if (action.head_pose) return 'Head movement'; | |
| if (action.antennas) return 'Antenna'; | |
| if (action.body_yaw !== null && action.body_yaw !== undefined) return 'Turn body'; | |
| return 'Movement'; | |
| } | |
| return action.type; | |
| } | |
| // Sleep helper | |
| function sleep(ms) { | |
| return new Promise(resolve => setTimeout(resolve, ms)); | |
| } | |
| // Simulate execution progress (runs in parallel with actual execution) | |
| async function simulateProgress(ir) { | |
| for (let i = 0; i < ir.length; i++) { | |
| if (!state.isExecuting || state.abortRequested) break; // Stop if execution ended or aborted | |
| displayIR(ir, i); // Highlight current action | |
| const desc = describeAction(ir[i]); | |
| updateRunButton('running', `${i + 1}/${ir.length}: ${desc}`, 'βΆοΈ'); | |
| // Wait for action duration (in small chunks to check for abort) | |
| const duration = ir[i].duration || 0.5; | |
| const totalMs = duration * 1000; | |
| const checkInterval = 100; // Check every 100ms | |
| let elapsed = 0; | |
| while (elapsed < totalMs && !state.abortRequested) { | |
| await sleep(Math.min(checkInterval, totalMs - elapsed)); | |
| elapsed += checkInterval; | |
| } | |
| } | |
| } | |
| // Execute script on robot | |
| async function executeScript() { | |
| if (state.isExecuting) { | |
| log('Already executing a script', 'warning'); | |
| return; | |
| } | |
| const ir = await compileScript(); | |
| if (!ir) { | |
| log('Cannot execute - compilation failed', 'error'); | |
| updateRunButton('error', 'Compilation failed', 'β'); | |
| setTimeout(() => updateRunButton('idle', 'Run on robot', 'π€'), 2000); | |
| return; | |
| } | |
| state.isExecuting = true; | |
| state.abortRequested = false; | |
| elements.executeBtn.disabled = true; | |
| elements.abortBtn.style.display = 'block'; | |
| updateRunButton('running', 'Running...', 'βΆοΈ'); | |
| clearConsole(); | |
| log('Sending to robot...', 'info'); | |
| playStartSound(); | |
| // Start progress simulation in parallel with actual execution | |
| const progressPromise = simulateProgress(ir); | |
| try { | |
| const response = await fetch(`${API_BASE}/api/execute`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ ir }) | |
| }); | |
| const result = await response.json(); | |
| // Wait for progress simulation to complete | |
| await progressPromise; | |
| if (result.success) { | |
| if (result.aborted) { | |
| log(`Aborted after ${result.actions_executed} actions.`, 'warning'); | |
| } else { | |
| log(`Done! ${result.actions_executed} actions executed.`, 'success'); | |
| playSuccessSound(); | |
| } | |
| displayIR(ir, -1); // Clear highlighting | |
| } else { | |
| log(`Execution failed: ${result.message}`, 'error'); | |
| displayIR(ir, -1); | |
| playErrorSound(); | |
| } | |
| } catch (error) { | |
| log(`Execution error: ${error.message}`, 'error'); | |
| displayIR(ir, -1); | |
| playErrorSound(); | |
| } finally { | |
| state.isExecuting = false; | |
| elements.executeBtn.disabled = false; | |
| elements.abortBtn.style.display = 'none'; | |
| updateRunButton('idle', 'Run on robot', 'π€'); | |
| } | |
| } | |
| // Abort execution | |
| async function abortExecution() { | |
| if (!state.isExecuting) { | |
| return; | |
| } | |
| log('Requesting abort...', 'warning'); | |
| elements.abortBtn.disabled = true; | |
| elements.abortBtn.textContent = 'Aborting...'; | |
| try { | |
| const response = await fetch(`${API_BASE}/api/abort`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' } | |
| }); | |
| const result = await response.json(); | |
| if (result.success) { | |
| log('Abort signal sent', 'warning'); | |
| state.abortRequested = true; // Stop UI simulation | |
| } else { | |
| log(`Abort failed: ${result.message}`, 'error'); | |
| } | |
| } catch (error) { | |
| log(`Abort error: ${error.message}`, 'error'); | |
| } finally { | |
| elements.abortBtn.disabled = false; | |
| elements.abortBtn.textContent = 'Abort'; | |
| } | |
| } | |
| // Log execution info (now uses main console) | |
| function logExecution(message, type = 'info') { | |
| log(message, type); | |
| } | |
| // Initialize application | |
| function init() { | |
| console.log('Initializing RMScript App'); | |
| // Get DOM elements | |
| elements = { | |
| editorContainer: document.getElementById('editor'), | |
| compileStatus: document.getElementById('compileStatus'), | |
| console: document.getElementById('console'), | |
| irDisplay: document.getElementById('irDisplay'), | |
| executeBtn: document.getElementById('executeBtn'), | |
| abortBtn: document.getElementById('abortBtn') | |
| }; | |
| // Create CodeMirror editor | |
| editorView = createEditor(elements.editorContainer, examples.look_around.code); | |
| log('Loaded "Look around" example', 'info'); | |
| // Trigger initial validation (wait for editor to be fully ready) | |
| requestAnimationFrame(() => { | |
| validateRealtime(); | |
| }); | |
| console.log('RMScript App ready'); | |
| } | |
| // Make functions available globally for onclick handlers | |
| window.handleExampleSelect = handleExampleSelect; | |
| window.clearEditor = clearEditor; | |
| window.saveScript = saveScript; | |
| window.loadScript = loadScript; | |
| window.handleFileSelect = handleFileSelect; | |
| window.showCheatSheet = showCheatSheet; | |
| window.hideCheatSheet = hideCheatSheet; | |
| window.verifyScript = verifyScript; | |
| window.executeScript = executeScript; | |
| window.abortExecution = abortExecution; | |
| // Start when DOM is loaded | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| init(); | |
| } | |