Files
Clawd BotandClaude Opus 4.6 ca9b510922 chore: align with upstream openclaw/openclaw and overlay local additions
- Reset master to upstream/main (16,697 commits)
- Overlay 2,271 local-only files (skills, tools, workspace, configs, apps)
- Restore IDENTITY.md and USER.md templates
- Build verified, gateway running, Discord working

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-03 07:40:46 +01:00

753 lines
26 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>J.A.R.V.I.S</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #020c18;
color: #7dd6f5;
font-family: 'Courier New', monospace;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
}
/* Ambient glow bg */
body::before {
content: '';
position: fixed;
inset: 0;
background: radial-gradient(ellipse at 50% 50%, rgba(0,120,200,0.08) 0%, transparent 70%);
pointer-events: none;
}
.title {
letter-spacing: 0.4em;
font-size: 13px;
opacity: 0.6;
margin-bottom: 32px;
text-transform: uppercase;
}
/* ---- Orb ---- */
.orb-wrap {
position: relative;
width: 260px;
height: 260px;
margin-bottom: 32px;
}
.ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(0, 180, 255, 0.25);
animation: spin 8s linear infinite;
}
.ring:nth-child(2) { inset: 14px; border-color: rgba(0,200,255,0.3); animation-duration: 6s; animation-direction: reverse; }
.ring:nth-child(3) { inset: 28px; border-color: rgba(0,220,255,0.35); animation-duration: 4s; }
.ring:nth-child(4) { inset: 42px; border-color: rgba(0,200,255,0.2); animation-duration: 12s; animation-direction: reverse; }
/* Tick marks on ring 1 */
.ring::before {
content: '';
position: absolute;
top: -3px; left: 50%;
width: 6px; height: 6px;
background: #00d4ff;
border-radius: 50%;
box-shadow: 0 0 8px #00d4ff;
transform: translateX(-50%);
}
.orb-core {
position: absolute;
inset: 56px;
border-radius: 50%;
background: radial-gradient(circle at 35% 35%, rgba(0,180,255,0.5), rgba(0,80,160,0.3) 60%, rgba(0,20,60,0.8));
box-shadow:
0 0 30px rgba(0,180,255,0.4),
0 0 60px rgba(0,100,200,0.2),
inset 0 0 20px rgba(0,200,255,0.15);
display: flex;
align-items: center;
justify-content: center;
}
.orb-hex {
width: 54px;
height: 54px;
opacity: 0.7;
}
/* State animations */
.orb-wrap.listening .ring {
border-color: rgba(0, 230, 255, 0.5);
box-shadow: 0 0 12px rgba(0,200,255,0.2);
}
.orb-wrap.listening .orb-core {
box-shadow:
0 0 40px rgba(0,220,255,0.6),
0 0 80px rgba(0,150,220,0.3),
inset 0 0 24px rgba(0,220,255,0.3);
animation: pulse 1s ease-in-out infinite;
}
.orb-wrap.thinking .ring { animation-duration: 1.5s !important; }
.orb-wrap.thinking .orb-core { animation: throb 0.6s ease-in-out infinite alternate; }
.orb-wrap.speaking .orb-core { animation: speak 0.4s ease-in-out infinite alternate; }
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 40px rgba(0,220,255,0.6), 0 0 80px rgba(0,150,220,0.3), inset 0 0 24px rgba(0,220,255,0.3); }
50% { box-shadow: 0 0 60px rgba(0,220,255,0.9), 0 0 120px rgba(0,150,220,0.5), inset 0 0 32px rgba(0,220,255,0.5); }
}
@keyframes throb {
from { opacity: 0.7; } to { opacity: 1; }
}
@keyframes speak {
from { box-shadow: 0 0 40px rgba(0,255,200,0.5), 0 0 80px rgba(0,180,150,0.2), inset 0 0 20px rgba(0,255,200,0.2); }
to { box-shadow: 0 0 70px rgba(0,255,200,0.8), 0 0 130px rgba(0,200,160,0.4), inset 0 0 32px rgba(0,255,200,0.4); }
}
/* ---- Volume bar ---- */
.vol-bars {
display: flex;
align-items: flex-end;
gap: 3px;
height: 24px;
margin-bottom: 20px;
opacity: 0;
transition: opacity 0.3s;
}
.vol-bars.active { opacity: 1; }
.vol-bar {
width: 4px;
background: #00d4ff;
border-radius: 2px;
transition: height 0.08s;
min-height: 2px;
box-shadow: 0 0 4px #00d4ff;
}
/* ---- Status ---- */
.status-label {
font-size: 11px;
letter-spacing: 0.3em;
text-transform: uppercase;
opacity: 0.5;
margin-bottom: 4px;
}
.status-text {
font-size: 14px;
letter-spacing: 0.15em;
min-height: 20px;
text-align: center;
color: #a0e8ff;
}
/* ---- Transcript ---- */
.conversation {
width: min(520px, calc(100vw - 40px));
margin-top: 32px;
display: flex;
flex-direction: column;
gap: 12px;
}
.bubble {
padding: 12px 16px;
border-radius: 10px;
font-size: 14px;
line-height: 1.5;
display: none;
border: 1px solid rgba(0,180,255,0.2);
}
.bubble.you {
background: rgba(0,60,120,0.3);
color: #7dd6f5;
text-align: right;
}
.bubble.jarvis {
background: rgba(0,20,60,0.5);
color: #c0f0ff;
}
.bubble-label {
font-size: 10px;
letter-spacing: 0.2em;
text-transform: uppercase;
opacity: 0.5;
margin-bottom: 4px;
}
/* ---- VAD threshold slider ---- */
.controls {
margin-top: 24px;
display: flex;
align-items: center;
gap: 12px;
opacity: 0.4;
font-size: 11px;
letter-spacing: 0.1em;
}
.controls:hover { opacity: 0.8; }
input[type=range] { width: 80px; accent-color: #00d4ff; }
</style>
</head>
<body>
<div class="title">J · A · R · V · I · S</div>
<div class="orb-wrap" id="orb">
<div class="ring"></div>
<div class="ring"></div>
<div class="ring"></div>
<div class="ring"></div>
<div class="orb-core">
<!-- Hexagon SVG -->
<svg class="orb-hex" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
<polygon points="30,4 54,17 54,43 30,56 6,43 6,17" stroke="#00d4ff" stroke-width="1.5" fill="rgba(0,180,255,0.08)"/>
<polygon points="30,12 46,21 46,39 30,48 14,39 14,21" stroke="#00d4ff" stroke-width="1" fill="rgba(0,180,255,0.05)" opacity="0.6"/>
<circle cx="30" cy="30" r="6" fill="#00d4ff" opacity="0.8"/>
<circle cx="30" cy="30" r="10" stroke="#00d4ff" stroke-width="0.5" opacity="0.3"/>
</svg>
</div>
</div>
<!-- Volume visualiser -->
<div class="vol-bars" id="volBars">
<div class="vol-bar" id="b0"></div>
<div class="vol-bar" id="b1"></div>
<div class="vol-bar" id="b2"></div>
<div class="vol-bar" id="b3"></div>
<div class="vol-bar" id="b4"></div>
<div class="vol-bar" id="b5"></div>
<div class="vol-bar" id="b6"></div>
</div>
<div class="status-label">STATUS</div>
<div class="status-text" id="statusText">INITIALISING</div>
<div class="conversation">
<div class="bubble you" id="youBubble">
<div class="bubble-label">You</div>
<div id="youText"></div>
</div>
<div class="bubble jarvis" id="jarvisBubble">
<div class="bubble-label">Jarvis</div>
<div id="jarvisText"></div>
</div>
</div>
<!-- mic level readout -->
<div id="micLevel" style="font-size:11px;opacity:0.5;margin-top:4px;letter-spacing:0.15em;">MIC: —</div>
<!-- Wispr Flow / type input -->
<div style="margin-top:16px;display:flex;gap:8px;width:100%;max-width:480px;">
<input type="text" id="textInput"
placeholder="Dikter via Wispr Flow eller skriv her…"
style="flex:1;background:rgba(0,180,255,0.06);border:1px solid rgba(0,180,255,0.25);
border-radius:6px;padding:8px 12px;color:#7dd6f5;font-family:'Courier New',monospace;
font-size:13px;outline:none;letter-spacing:0.05em;"
autocomplete="off" autocorrect="off" spellcheck="false">
<button id="textSendBtn"
style="background:rgba(0,180,255,0.15);border:1px solid rgba(0,180,255,0.4);
border-radius:6px;padding:8px 14px;color:#00d4ff;font-family:'Courier New',monospace;
font-size:12px;cursor:pointer;letter-spacing:0.1em;">
SEND
</button>
</div>
<div class="controls">
<span>MIC SENSITIVITY</span>
<input type="range" id="thresh" min="1" max="30" value="5">
<span id="threshVal">5</span>
<label style="margin-left:12px;font-size:11px;opacity:0.7;">Use Porcupine</label>
<input type="checkbox" id="usePorcupine" style="margin-left:6px;" title="Enable client-side Porcupine wake-word (requires model in /porcupine)">
</div>
<script>
const token = new URLSearchParams(location.search).get('token');
if (!token) {
document.getElementById('statusText').textContent = 'MISSING TOKEN';
throw new Error('Missing token');
}
const orb = document.getElementById('orb');
const statusText = document.getElementById('statusText');
const volBars = document.getElementById('volBars');
const bars = [0,1,2,3,4,5,6].map(i => document.getElementById('b'+i));
const youBubble = document.getElementById('youBubble');
const youText = document.getElementById('youText');
const jarvisBubble = document.getElementById('jarvisBubble');
const jarvisText = document.getElementById('jarvisText');
const threshSlider = document.getElementById('thresh');
const threshVal = document.getElementById('threshVal');
const usePorcupineCheckbox = document.getElementById('usePorcupine');
// States: 'standby' | 'wake_check' | 'active' | 'processing'
// standby — always listening for wake word via VAD + whisper
// wake_check — short clip sent to server to check for "james"
// active — wake word confirmed, next speech is the command
// processing — agent running / audio playing
let state = 'standby';
let activeTimer = null; // auto-return to standby if no speech within 8s
let ws, mediaRecorder, audioCtx, analyser, source, vadInterval;
let chunks = [], recording = false, busy = false;
let silenceTimer = null;
let THRESH = 5;
let SILENCE_MS = 1200;
// Porcupine related
let porcupineActive = false;
let porcupineNode = null; // ScriptProcessor/AudioWorklet node used for detection
let porcupineReady = false;
// Audio streaming — collect binary WS chunks between audio-start/audio-end
let audioChunks = [];
let collectingAudio = false;
let audioMimeType = 'audio/mpeg';
threshSlider.addEventListener('input', () => {
THRESH = Number(threshSlider.value);
threshVal.textContent = THRESH;
});
usePorcupineCheckbox.addEventListener('change', async (e) => {
if (e.target.checked) {
setStatus('Loading Porcupine…');
try {
await loadPorcupineIfAvailable();
if (porcupineReady) setStatus('Porcupine ready — listening');
else setStatus('Listening for wake word...');
} catch (err) {
console.warn('Porcupine load failed', err);
setStatus('Listening for wake word...');
usePorcupineCheckbox.checked = false;
}
} else {
stopPorcupine();
setStatus('Porcupine disabled');
}
});
function setOrb(state) {
orb.className = 'orb-wrap ' + (state || '');
}
function setStatus(s) {
statusText.textContent = s.toUpperCase();
}
const micLevelEl = document.getElementById('micLevel');
// ---- WebSocket ----
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}/?token=${token}`);
ws.binaryType = 'arraybuffer'; // receive audio chunks as ArrayBuffer
ws.onopen = () => {
startVAD();
setStatus('STANDBY — say "James"');
};
ws.onclose = () => {
setStatus('Reconnecting…');
setOrb('');
stopVAD();
setTimeout(connect, 1500);
};
ws.onmessage = async (ev) => {
// Binary frame = audio chunk from ElevenLabs stream
if (ev.data instanceof ArrayBuffer) {
if (collectingAudio) audioChunks.push(ev.data);
return;
}
const msg = JSON.parse(ev.data);
if (msg.type === 'audio-start') {
collectingAudio = true;
audioChunks = [];
audioMimeType = msg.mimeType || 'audio/mpeg';
setOrb('speaking');
setStatus('Speaking');
return;
}
if (msg.type === 'audio-end') {
collectingAudio = false;
const blob = new Blob(audioChunks, { type: audioMimeType });
audioChunks = [];
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
// play() returns a Promise — catch NotAllowedError on mobile if no user gesture
audio.play().catch(() => {
// Fallback: user must tap to play
setStatus('TAP ORB TO PLAY REPLY');
document.getElementById('orb').addEventListener('click', () => {
audio.play();
}, { once: true });
});
audio.onended = () => {
URL.revokeObjectURL(url);
setOrb('');
setStatus('Ready — listening');
busy = false;
};
return;
}
if (msg.type === 'wake-result') {
if (msg.detected) {
// Wake word confirmed — play a soft chime and enter active mode
playChime();
if (msg.command && msg.command.length > 2) {
// Command was spoken in same breath — process it directly
setStatus('Heard: ' + msg.command);
youBubble.style.display = 'block';
youText.textContent = msg.command;
enterProcessing(msg.command);
} else {
enterActive();
}
} else {
// Not a wake word — back to standby
busy = false;
state = 'standby';
setOrb('');
setStatus('STANDBY — say "James"');
}
return;
}
if (msg.type === 'status') {
setStatus(msg.message);
if (msg.message.toLowerCase().includes('thinking')) setOrb('thinking');
if (msg.message.toLowerCase().includes('speaking')) setOrb('speaking');
if (msg.message.toLowerCase().includes('ready')) {
setOrb('');
state = 'standby';
busy = false;
setStatus('STANDBY — say "James"');
}
}
if (msg.type === 'transcript') {
youBubble.style.display = 'block';
youText.textContent = msg.text;
}
if (msg.type === 'reply') {
jarvisBubble.style.display = 'block';
jarvisText.textContent = msg.text;
}
if (msg.type === 'error') {
setStatus('Error: ' + msg.message);
setOrb('');
state = 'standby';
busy = false;
}
};
}
// ---- Porcupine integration (client-side wake word) ----
// This will attempt to load a local Porcupine bundle from /porcupine/
// Place a porcupine JS/WASM + the .ppn keyword model in public/porcupine/
// Example expected files:
// - /porcupine/porcupine.js (worker or UMD build that exposes `Porcupine`)
// - /porcupine/jarvis.ppn (keyword model)
async function loadPorcupineIfAvailable() {
if (porcupineReady) return true;
try {
// Try to load a local script that provides a global `Porcupine` object.
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '/porcupine/porcupine.js';
s.onload = resolve; s.onerror = reject; s.async = true;
document.head.appendChild(s);
});
if (!window.Porcupine) {
console.warn('porcupine.js loaded but global Porcupine not found');
return false;
}
// create audio context + detection node
if (!audioCtx) audioCtx = new AudioContext();
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mic = audioCtx.createMediaStreamSource(stream);
// ScriptProcessor fallback for browsers without AudioWorklet
const proc = audioCtx.createScriptProcessor(4096, 1, 1);
proc.onaudioprocess = (ev) => {
if (!porcupineReady) return;
const input = ev.inputBuffer.getChannelData(0);
// convert float32 to 16-bit PCM
const int16 = new Int16Array(input.length);
for (let i = 0; i < input.length; i++) {
let s = Math.max(-1, Math.min(1, input[i]));
int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
try {
const r = window.Porcupine && window.Porcupine.process && window.Porcupine.process(int16);
if (r === 0 || r === 1 || r > -1) {
// detection (API may return index or truthy). Trigger wake.
playChime();
enterActive();
}
} catch (err) {
// library specific errors; ignore and fallback
}
};
mic.connect(proc);
proc.connect(audioCtx.destination);
porcupineNode = proc;
porcupineReady = true;
porcupineActive = true;
return true;
} catch (err) {
console.warn('Porcupine load failed', err);
porcupineReady = false;
porcupineActive = false;
return false;
}
}
function stopPorcupine() {
try {
if (porcupineNode) {
porcupineNode.disconnect();
porcupineNode = null;
}
porcupineReady = false;
porcupineActive = false;
} catch (e) {}
}
// ---- VAD via Web Audio API ----
async function startVAD() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 }
});
audioCtx = new AudioContext();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source = audioCtx.createMediaStreamSource(stream);
source.connect(analyser);
// If Porcupine selected and available, start its detection chain as well
if (usePorcupineCheckbox.checked) {
const ok = await loadPorcupineIfAvailable();
if (ok) {
setStatus('Porcupine active — say "James"');
// Keep VAD running for UI only
} else {
setStatus('Porcupine not available — using VAD');
}
}
const dataArr = new Uint8Array(analyser.frequencyBinCount);
// MediaRecorder on same stream
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' });
mediaRecorder.ondataavailable = e => { if (e.data?.size > 0) chunks.push(e.data); };
mediaRecorder.onstop = async () => {
if (busy) return;
busy = true;
const blob = new Blob(chunks, { type: 'audio/webm' });
chunks = [];
const buf = await blob.arrayBuffer();
if (state === 'standby') {
// Short clip — check for wake word on server side (fallback)
if (buf.byteLength < 512) { busy = false; return; }
state = 'wake_check';
setOrb('');
setStatus('…');
ws.send(buf);
ws.send(JSON.stringify({ type: 'wake-check' }));
} else if (state === 'active') {
// Full command
clearTimeout(activeTimer);
if (buf.byteLength < 1024) { busy = false; enterActive(); return; }
state = 'processing';
setOrb('thinking');
setStatus('Transcribing…');
ws.send(buf);
ws.send(JSON.stringify({ type: 'audio-end' }));
} else {
busy = false;
}
};
// Animate bars + state machine VAD
vadInterval = setInterval(() => {
analyser.getByteFrequencyData(dataArr);
// RMS of first 80 bins (voice range)
let sum = 0;
for (let i = 2; i < 80; i++) sum += dataArr[i] ** 2;
const rms = Math.sqrt(sum / 78);
// Animate bars
for (let i = 0; i < 7; i++) {
const bin = Math.floor(2 + i * 10);
const h = Math.round((dataArr[bin] / 255) * 24);
bars[i].style.height = Math.max(2, h) + 'px';
}
micLevelEl.textContent = `MIC: ${Math.round(rms)} / ${THRESH}`;
// If porcupineActive, we let porcupine trigger enterActive(); otherwise use VAD recording triggers
const speaking = rms > THRESH;
const canRecord = !recording && !busy && (state === 'standby' || state === 'active');
if (speaking && canRecord && !porcupineActive) {
recording = true;
chunks = [];
clearTimeout(silenceTimer);
silenceTimer = null;
mediaRecorder.start();
setOrb('listening');
volBars.classList.add('active');
if (state === 'standby') setStatus('Hearing…');
else setStatus('Listening…');
}
if (recording && !porcupineActive) {
if (!speaking) {
if (!silenceTimer) {
silenceTimer = setTimeout(() => {
if (!recording) return;
recording = false;
silenceTimer = null;
volBars.classList.remove('active');
if (mediaRecorder.state === 'recording') mediaRecorder.stop();
// onstop will fire → sends correct msg type based on current state
}, SILENCE_MS);
}
} else {
clearTimeout(silenceTimer);
silenceTimer = null;
}
}
}, 80);
} catch (err) {
setStatus('Mic error: ' + err.message);
}
}
function stopVAD() {
clearInterval(vadInterval);
clearTimeout(silenceTimer);
if (audioCtx) audioCtx.close();
}
// ---- State helpers ----
function enterActive() {
state = 'active';
busy = false;
setOrb('listening');
setStatus('YES SIR — SPEAK NOW');
clearTimeout(activeTimer);
// Auto-return to standby after 8s of silence
activeTimer = setTimeout(() => {
if (state === 'active' && !recording) {
state = 'standby';
setOrb('');
setStatus('STANDBY — say "James"');
}
}, 8000);
}
function enterProcessing(command) {
state = 'processing';
busy = true;
setOrb('thinking');
setStatus('Thinking…');
// Send command text directly (already transcribed inline with wake word)
ws.send(new ArrayBuffer(0)); // empty binary (server needs it before audio-end)
ws.send(JSON.stringify({ type: 'audio-end', text: command }));
}
// Soft chime on wake detection (AudioContext beep — no file needed)
function playChime() {
try {
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain); gain.connect(ctx.destination);
osc.frequency.setValueAtTime(880, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(1320, ctx.currentTime + 0.1);
gain.gain.setValueAtTime(0.15, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3);
osc.start(); osc.stop(ctx.currentTime + 0.3);
} catch(e) {}
}
// ── Wispr Flow / text input ──────────────────────────────────────
const textInput = document.getElementById('textInput');
const textSendBtn = document.getElementById('textSendBtn');
let textAutoTimer = null;
function sendTextCommand() {
const cmd = textInput.value.trim();
if (!cmd || state === 'processing') return;
textInput.value = '';
clearTimeout(textAutoTimer);
youText.textContent = cmd;
youBubble.style.display = 'block';
jarvisBubble.style.display = 'none';
enterProcessing(cmd);
}
// Auto-send 1.5s after last keystroke (Wispr Flow pastes fast, then stops)
textInput.addEventListener('input', () => {
clearTimeout(textAutoTimer);
if (textInput.value.trim()) {
textAutoTimer = setTimeout(sendTextCommand, 1500);
}
});
// Enter key sends immediately
textInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); sendTextCommand(); }
});
textSendBtn.addEventListener('click', sendTextCommand);
// ─────────────────────────────────────────────────────────────────
// Tap orb = manual wake (fallback)
document.getElementById('orb').addEventListener('click', () => {
if (state === 'processing') return;
if (state === 'active') {
clearTimeout(activeTimer);
state = 'standby';
busy = false;
setOrb('');
setStatus('STANDBY — say "James"');
return;
}
playChime();
enterActive();
});
connect();
// Helpful dev note in console
console.log('Wake-word: client-side Porcupine integration scaffolded. To enable: place porcupine JS/WASM and jarvis.ppn in public/porcupine/ and toggle "Use Porcupine". Otherwise the page falls back to VAD + server-side wake-check.');
</script>
</body>
</html>