Khanna, Videh Rakesh Rakesh
feat: security hardening, transcription mode, 15 new features, Gradio UI
0191dfa | <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover"> | |
| <meta name="apple-mobile-web-app-capable" content="yes"> | |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> | |
| <meta name="apple-mobile-web-app-title" content="JARVIS"> | |
| <meta name="mobile-web-app-capable" content="yes"> | |
| <meta name="theme-color" content="#0a0e17"> | |
| <meta name="format-detection" content="telephone=no"> | |
| <link rel="manifest" href="/static/manifest.json"> | |
| <!-- Apple touch icons for iPhone / iPad --> | |
| <link rel="apple-touch-icon" href="/static/apple-touch-icon.png"> | |
| <link rel="apple-touch-icon" sizes="152x152" href="/static/apple-touch-icon-152.png"> | |
| <link rel="apple-touch-icon" sizes="167x167" href="/static/apple-touch-icon-167.png"> | |
| <link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"> | |
| <!-- iOS splash screens --> | |
| <meta name="apple-mobile-web-app-orientations" content="portrait"> | |
| <title>J.A.R.V.I.S.</title> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| :root { | |
| --bg: #0a0e17; | |
| --surface: #111827; | |
| --surface2: #1a2332; | |
| --accent: #00b4d8; | |
| --accent2: #0077b6; | |
| --accent-glow: rgba(0, 180, 216, 0.3); | |
| --text: #e0e6ed; | |
| --text2: #8899aa; | |
| --success: #00e676; | |
| --warning: #ffab00; | |
| --danger: #ff5252; | |
| --purple: #bb86fc; | |
| } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', 'JetBrains Mono', monospace; | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| /* Header */ | |
| .header { | |
| background: linear-gradient(180deg, var(--surface) 0%, transparent 100%); | |
| padding: 12px 20px; | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| border-bottom: 1px solid rgba(0, 180, 216, 0.15); | |
| z-index: 10; | |
| } | |
| .logo { | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| } | |
| .arc-reactor { | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 50%; | |
| background: radial-gradient(circle, var(--accent) 0%, var(--accent2) 40%, transparent 70%); | |
| box-shadow: 0 0 20px var(--accent-glow), 0 0 40px rgba(0, 180, 216, 0.15); | |
| animation: pulse 2s ease-in-out infinite; | |
| position: relative; | |
| } | |
| .arc-reactor::after { | |
| content: ''; | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| width: 12px; | |
| height: 12px; | |
| border-radius: 50%; | |
| background: white; | |
| box-shadow: 0 0 10px var(--accent); | |
| } | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 0.8; transform: scale(1); } | |
| 50% { opacity: 1; transform: scale(1.05); } | |
| } | |
| .logo h1 { | |
| font-size: 18px; | |
| font-weight: 700; | |
| letter-spacing: 4px; | |
| color: var(--accent); | |
| text-shadow: 0 0 20px var(--accent-glow); | |
| } | |
| .logo .subtitle { | |
| font-size: 9px; | |
| color: var(--text2); | |
| letter-spacing: 2px; | |
| } | |
| .controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| justify-content: flex-end; | |
| } | |
| .status-dot { | |
| width: 8px; | |
| height: 8px; | |
| border-radius: 50%; | |
| background: var(--success); | |
| box-shadow: 0 0 8px var(--success); | |
| animation: pulse 2s ease-in-out infinite; | |
| } | |
| .ctrl-btn { | |
| background: var(--surface2); | |
| border: 1px solid rgba(0, 180, 216, 0.2); | |
| color: var(--text); | |
| padding: 4px 10px; | |
| border-radius: 12px; | |
| font-size: 10px; | |
| cursor: pointer; | |
| font-family: inherit; | |
| transition: all 0.2s; | |
| letter-spacing: 0.5px; | |
| } | |
| .ctrl-btn:hover { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 10px var(--accent-glow); | |
| } | |
| .ctrl-btn.active { | |
| background: rgba(0, 180, 216, 0.15); | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| } | |
| .ctrl-btn.multi { | |
| border-color: rgba(187, 134, 252, 0.3); | |
| color: var(--purple); | |
| } | |
| .ctrl-btn.multi.active { | |
| background: rgba(187, 134, 252, 0.15); | |
| border-color: var(--purple); | |
| box-shadow: 0 0 10px rgba(187, 134, 252, 0.3); | |
| } | |
| /* Settings Panel */ | |
| .settings-panel { | |
| position: fixed; | |
| top: 50px; | |
| right: 16px; | |
| width: 380px; | |
| max-height: 80vh; | |
| overflow-y: auto; | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 12px; | |
| padding: 20px; | |
| z-index: 1000; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.5); | |
| } | |
| .settings-group { | |
| margin-bottom: 14px; | |
| } | |
| .settings-group label { | |
| display: block; | |
| font-size: 11px; | |
| color: var(--text2); | |
| margin-bottom: 4px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.5px; | |
| } | |
| .settings-group select, .settings-group input[type="range"] { | |
| width: 100%; | |
| padding: 6px 10px; | |
| background: var(--bg2); | |
| border: 1px solid var(--border); | |
| color: var(--text); | |
| border-radius: 6px; | |
| font-size: 13px; | |
| font-family: inherit; | |
| } | |
| .settings-group select:focus, .settings-group input:focus { | |
| outline: 2px solid var(--accent); | |
| outline-offset: 2px; | |
| } | |
| /* Skip link for accessibility */ | |
| .skip-link { | |
| position: absolute; | |
| top: -40px; | |
| left: 0; | |
| background: var(--accent); | |
| color: #000; | |
| padding: 8px 16px; | |
| z-index: 9999; | |
| border-radius: 0 0 8px 0; | |
| font-weight: 600; | |
| } | |
| .skip-link:focus { | |
| top: 0; | |
| } | |
| select.ctrl-select { | |
| background: var(--surface2); | |
| border: 1px solid rgba(0, 180, 216, 0.2); | |
| color: var(--text); | |
| padding: 4px 8px; | |
| border-radius: 12px; | |
| font-size: 10px; | |
| font-family: inherit; | |
| cursor: pointer; | |
| outline: none; | |
| } | |
| select.ctrl-select:focus { | |
| border-color: var(--accent); | |
| } | |
| /* AutoTune indicator */ | |
| .autotune-badge { | |
| display: none; | |
| align-items: center; | |
| gap: 4px; | |
| padding: 2px 8px; | |
| border-radius: 8px; | |
| font-size: 9px; | |
| letter-spacing: 0.5px; | |
| background: rgba(255, 171, 0, 0.1); | |
| border: 1px solid rgba(255, 171, 0, 0.2); | |
| color: var(--warning); | |
| } | |
| .autotune-badge.visible { | |
| display: flex; | |
| } | |
| /* Chat Area */ | |
| .chat-container { | |
| flex: 1; | |
| overflow-y: auto; | |
| padding: 20px; | |
| scroll-behavior: smooth; | |
| } | |
| .chat-container::-webkit-scrollbar { width: 4px; } | |
| .chat-container::-webkit-scrollbar-track { background: transparent; } | |
| .chat-container::-webkit-scrollbar-thumb { background: var(--accent2); border-radius: 2px; } | |
| .message { | |
| max-width: 85%; | |
| margin-bottom: 16px; | |
| animation: fadeIn 0.3s ease; | |
| } | |
| @keyframes fadeIn { | |
| from { opacity: 0; transform: translateY(8px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| .message.user { margin-left: auto; } | |
| .message.assistant { margin-right: auto; } | |
| .message-bubble { | |
| padding: 12px 16px; | |
| border-radius: 16px; | |
| font-size: 14px; | |
| line-height: 1.6; | |
| word-wrap: break-word; | |
| } | |
| .message.user .message-bubble { | |
| background: linear-gradient(135deg, var(--accent2), var(--accent)); | |
| color: white; | |
| border-bottom-right-radius: 4px; | |
| } | |
| .message.assistant .message-bubble { | |
| background: var(--surface2); | |
| border: 1px solid rgba(0, 180, 216, 0.1); | |
| border-bottom-left-radius: 4px; | |
| } | |
| .message-label { | |
| font-size: 10px; | |
| color: var(--text2); | |
| margin-bottom: 4px; | |
| letter-spacing: 1px; | |
| text-transform: uppercase; | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| } | |
| .message.user .message-label { text-align: right; justify-content: flex-end; } | |
| .model-tag { | |
| font-size: 8px; | |
| padding: 1px 6px; | |
| border-radius: 6px; | |
| background: rgba(0, 180, 216, 0.1); | |
| border: 1px solid rgba(0, 180, 216, 0.15); | |
| color: var(--accent); | |
| } | |
| /* Tool execution indicator */ | |
| .tool-indicator { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| padding: 8px 14px; | |
| background: rgba(0, 180, 216, 0.08); | |
| border: 1px solid rgba(0, 180, 216, 0.15); | |
| border-radius: 8px; | |
| font-size: 12px; | |
| color: var(--accent); | |
| margin-bottom: 12px; | |
| animation: fadeIn 0.3s ease; | |
| } | |
| .tool-spinner { | |
| width: 14px; | |
| height: 14px; | |
| border: 2px solid rgba(0, 180, 216, 0.2); | |
| border-top-color: var(--accent); | |
| border-radius: 50%; | |
| animation: spin 0.8s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* STM indicator */ | |
| .stm-indicator { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| padding: 4px 10px; | |
| background: rgba(0, 230, 118, 0.08); | |
| border: 1px solid rgba(0, 230, 118, 0.15); | |
| border-radius: 6px; | |
| font-size: 10px; | |
| color: var(--success); | |
| margin-bottom: 8px; | |
| animation: fadeIn 0.3s ease; | |
| } | |
| /* Code blocks */ | |
| .message-bubble pre { | |
| background: var(--bg); | |
| padding: 10px; | |
| border-radius: 8px; | |
| overflow-x: auto; | |
| margin: 8px 0; | |
| font-size: 12px; | |
| border: 1px solid rgba(0, 180, 216, 0.1); | |
| } | |
| .message-bubble code { font-family: inherit; font-size: 13px; } | |
| .message-bubble p { margin-bottom: 8px; } | |
| .message-bubble p:last-child { margin-bottom: 0; } | |
| .message-bubble strong { color: var(--accent); } | |
| /* Input Area */ | |
| .input-area { | |
| padding: 16px 20px; | |
| background: var(--surface); | |
| border-top: 1px solid rgba(0, 180, 216, 0.15); | |
| } | |
| .input-row { | |
| display: flex; | |
| gap: 10px; | |
| align-items: flex-end; | |
| max-width: 900px; | |
| margin: 0 auto; | |
| } | |
| .input-wrapper { flex: 1; position: relative; } | |
| #message-input { | |
| width: 100%; | |
| background: var(--surface2); | |
| border: 1px solid rgba(0, 180, 216, 0.2); | |
| color: var(--text); | |
| padding: 12px 16px; | |
| border-radius: 16px; | |
| font-family: inherit; | |
| font-size: 14px; | |
| resize: none; | |
| max-height: 120px; | |
| outline: none; | |
| transition: border-color 0.2s; | |
| } | |
| #message-input:focus { | |
| border-color: var(--accent); | |
| box-shadow: 0 0 15px var(--accent-glow); | |
| } | |
| #message-input::placeholder { color: var(--text2); } | |
| .btn { | |
| width: 44px; | |
| height: 44px; | |
| border-radius: 50%; | |
| border: none; | |
| cursor: pointer; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| transition: all 0.2s; | |
| flex-shrink: 0; | |
| } | |
| .btn-send { | |
| background: linear-gradient(135deg, var(--accent2), var(--accent)); | |
| color: white; | |
| } | |
| .btn-send:hover { transform: scale(1.05); box-shadow: 0 0 20px var(--accent-glow); } | |
| .btn-send:disabled { opacity: 0.4; cursor: not-allowed; transform: none; } | |
| .btn-voice { | |
| background: var(--surface2); | |
| border: 1px solid rgba(0, 180, 216, 0.2); | |
| color: var(--text2); | |
| } | |
| .btn-voice:hover { border-color: var(--accent); color: var(--accent); } | |
| .btn-voice.listening { | |
| background: rgba(255, 82, 82, 0.15); | |
| border-color: var(--danger); | |
| color: var(--danger); | |
| animation: pulse 1s ease-in-out infinite; | |
| } | |
| .btn svg { width: 20px; height: 20px; fill: currentColor; } | |
| /* Welcome screen */ | |
| .welcome { | |
| text-align: center; | |
| padding: 60px 20px; | |
| animation: fadeIn 0.8s ease; | |
| } | |
| .welcome .arc-reactor-lg { | |
| width: 80px; | |
| height: 80px; | |
| border-radius: 50%; | |
| background: radial-gradient(circle, var(--accent) 0%, var(--accent2) 40%, transparent 70%); | |
| box-shadow: 0 0 40px var(--accent-glow), 0 0 80px rgba(0, 180, 216, 0.1); | |
| margin: 0 auto 24px; | |
| animation: pulse 2s ease-in-out infinite; | |
| position: relative; | |
| } | |
| .welcome .arc-reactor-lg::after { | |
| content: ''; | |
| position: absolute; | |
| top: 50%; left: 50%; | |
| transform: translate(-50%, -50%); | |
| width: 24px; height: 24px; | |
| border-radius: 50%; | |
| background: white; | |
| box-shadow: 0 0 15px var(--accent); | |
| } | |
| .welcome h2 { font-size: 24px; color: var(--accent); letter-spacing: 6px; margin-bottom: 8px; } | |
| .welcome p { color: var(--text2); font-size: 13px; line-height: 1.8; } | |
| .quick-actions { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| justify-content: center; | |
| margin-top: 24px; | |
| } | |
| .quick-btn { | |
| background: var(--surface2); | |
| border: 1px solid rgba(0, 180, 216, 0.15); | |
| color: var(--text2); | |
| padding: 8px 16px; | |
| border-radius: 20px; | |
| font-size: 12px; | |
| cursor: pointer; | |
| font-family: inherit; | |
| transition: all 0.2s; | |
| } | |
| .quick-btn:hover { | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| box-shadow: 0 0 10px var(--accent-glow); | |
| } | |
| /* Wake word indicator */ | |
| .wakeword-overlay { | |
| display: none; | |
| position: fixed; | |
| bottom: 100px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| padding: 8px 20px; | |
| background: rgba(0, 230, 118, 0.1); | |
| border: 1px solid rgba(0, 230, 118, 0.3); | |
| border-radius: 20px; | |
| font-size: 11px; | |
| color: var(--success); | |
| z-index: 100; | |
| animation: pulse 2s ease-in-out infinite; | |
| letter-spacing: 1px; | |
| } | |
| .wakeword-overlay.active { display: block; } | |
| .wakeword-overlay.heard { | |
| background: rgba(0, 180, 216, 0.15); | |
| border-color: var(--accent); | |
| color: var(--accent); | |
| animation: none; | |
| } | |
| /* Mobile responsive */ | |
| @media (max-width: 600px) { | |
| .header { padding: 10px 14px; flex-wrap: wrap; gap: 8px; } | |
| .logo h1 { font-size: 15px; letter-spacing: 3px; } | |
| .logo .subtitle { display: none; } | |
| .controls { gap: 4px; } | |
| .chat-container { padding: 12px; } | |
| .message { max-width: 92%; } | |
| .input-area { padding: 12px 14px; } | |
| .welcome h2 { font-size: 20px; } | |
| .quick-actions { gap: 6px; } | |
| } | |
| /* iOS safe areas β notch / Dynamic Island / home indicator */ | |
| @supports (padding-top: env(safe-area-inset-top)) { | |
| .header { | |
| padding-top: calc(12px + env(safe-area-inset-top)); | |
| } | |
| .input-area { | |
| padding-bottom: calc(16px + env(safe-area-inset-bottom)); | |
| } | |
| } | |
| /* iPad specific β wider layout */ | |
| @media (min-width: 768px) and (max-width: 1024px) { | |
| .message { max-width: 75%; } | |
| .input-row { max-width: 700px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- Accessibility: Skip to main content --> | |
| <a href="#message-input" class="skip-link">Skip to chat input</a> | |
| <!-- Header --> | |
| <div class="header"> | |
| <div class="logo"> | |
| <div class="arc-reactor"></div> | |
| <div> | |
| <h1>J.A.R.V.I.S.</h1> | |
| <div class="subtitle">JUST A RATHER VERY INTELLIGENT SYSTEM</div> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <div class="status-dot" id="status-dot"></div> | |
| <select class="ctrl-select" id="backend-select" onchange="setBackend(this.value)"> | |
| <option value="auto">AUTO</option> | |
| <option value="claude">CLAUDE</option> | |
| <option value="ollama">OLLAMA</option> | |
| <option value="openrouter">OPENROUTER</option> | |
| <option value="multi">MULTI-MODEL</option> | |
| </select> | |
| <button class="ctrl-btn active" id="stm-btn" onclick="toggleSTM()" title="Semantic Transform β strips filler & hedging">STM</button> | |
| <button class="ctrl-btn multi" id="multi-btn" onclick="setMultiModel()" title="Race 3 models, pick the best">RACE</button> | |
| <div class="autotune-badge" id="autotune-badge">AT: <span id="autotune-type">β</span></div> | |
| <button class="ctrl-btn" id="wakeword-btn" onclick="toggleWakeWord()" title="Always-on wake word: say 'Hey JARVIS'">WAKE</button> | |
| <button class="ctrl-btn" onclick="newChat()" title="New Chat">NEW</button> | |
| <button class="ctrl-btn" onclick="toggleSettings()" title="Settings" aria-label="Settings">β</button> | |
| </div> | |
| </div> | |
| <!-- Settings Panel --> | |
| <div class="settings-panel" id="settings-panel" role="dialog" aria-label="Settings" style="display:none;"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"> | |
| <h3 style="margin:0;color:var(--accent);">Settings</h3> | |
| <button onclick="toggleSettings()" style="background:none;border:none;color:var(--text2);font-size:20px;cursor:pointer;" aria-label="Close settings">×</button> | |
| </div> | |
| <div class="settings-group"> | |
| <label for="setting-language">Language</label> | |
| <select id="setting-language" aria-label="Language"> | |
| <option value="en">English</option> | |
| <option value="es">Spanish</option> | |
| <option value="fr">French</option> | |
| <option value="de">German</option> | |
| <option value="it">Italian</option> | |
| <option value="pt">Portuguese</option> | |
| <option value="ja">Japanese</option> | |
| <option value="ko">Korean</option> | |
| <option value="zh">Chinese</option> | |
| <option value="hi">Hindi</option> | |
| <option value="ar">Arabic</option> | |
| </select> | |
| </div> | |
| <div class="settings-group"> | |
| <label for="setting-tts">TTS Voice</label> | |
| <select id="setting-tts" aria-label="TTS Voice"> | |
| <option value="Daniel">Daniel (Default)</option> | |
| <option value="Samantha">Samantha</option> | |
| <option value="Alex">Alex</option> | |
| <option value="Karen">Karen</option> | |
| <option value="Moira">Moira</option> | |
| <option value="Tessa">Tessa</option> | |
| <option value="elevenlabs">ElevenLabs (Premium)</option> | |
| </select> | |
| </div> | |
| <div class="settings-group"> | |
| <label for="setting-rate">Speech Rate</label> | |
| <input type="range" id="setting-rate" min="120" max="250" value="180" aria-label="Speech rate"> | |
| <span id="rate-value">180 WPM</span> | |
| </div> | |
| <div class="settings-group"> | |
| <label>Scheduled Jobs</label> | |
| <div id="scheduled-jobs-list" style="font-size:12px;color:var(--text2);margin-top:4px;">Loading...</div> | |
| <div style="display:flex;gap:6px;margin-top:8px;"> | |
| <input type="text" id="job-name" placeholder="Job name" style="flex:1;padding:4px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px;"> | |
| <input type="text" id="job-command" placeholder="Command" style="flex:2;padding:4px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px;"> | |
| <input type="text" id="job-time" placeholder="HH:MM" style="width:60px;padding:4px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px;"> | |
| <button onclick="addScheduledJob()" style="padding:4px 10px;background:var(--accent);border:none;border-radius:4px;cursor:pointer;font-size:12px;color:#000;">Add</button> | |
| </div> | |
| </div> | |
| <div class="settings-group"> | |
| <label>Automation Rules</label> | |
| <div id="automation-rules-list" style="font-size:12px;color:var(--text2);margin-top:4px;">Loading...</div> | |
| </div> | |
| <div class="settings-group" style="margin-top:12px;"> | |
| <button onclick="saveSettings()" style="width:100%;padding:8px;background:var(--accent);border:none;border-radius:6px;cursor:pointer;font-weight:600;color:#000;">Save Settings</button> | |
| </div> | |
| </div> | |
| <!-- Chat --> | |
| <div class="chat-container" id="chat" role="log" aria-label="Chat messages" aria-live="polite"> | |
| <div class="welcome" id="welcome"> | |
| <div class="arc-reactor-lg"></div> | |
| <h2>J.A.R.V.I.S.</h2> | |
| <p>Good to see you. How may I assist you today?</p> | |
| <p style="margin-top:8px; font-size:11px; color: var(--text2);"> | |
| Multi-model racing / STM filtering / AutoTune enabled | |
| </p> | |
| <div class="quick-actions"> | |
| <button class="quick-btn" onclick="quickAction('What can you do?')">What can you do?</button> | |
| <button class="quick-btn" onclick="quickAction('Search the web for latest AI news')">AI News</button> | |
| <button class="quick-btn" onclick="quickAction('What is my system status?')">System Status</button> | |
| <button class="quick-btn" onclick="quickAction('What is the weather in New York?')">Weather</button> | |
| <button class="quick-btn" onclick="quickAction('Write a Python function to sort a list')">Code</button> | |
| <button class="quick-btn" onclick="quickAction('Compare React vs Vue for a startup')">Analysis</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Wake Word Overlay --> | |
| <div class="wakeword-overlay" id="wakeword-overlay">LISTENING FOR "HEY JARVIS"...</div> | |
| <!-- Input --> | |
| <div class="input-area"> | |
| <div class="input-row"> | |
| <button class="btn btn-voice" id="voice-btn" onclick="toggleVoice()" title="Voice Input"> | |
| <svg viewBox="0 0 24 24"><path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1-9c0-.55.45-1 1-1s1 .45 1 1v6c0 .55-.45 1-1 1s-1-.45-1-1V5zm6 6c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z"/></svg> | |
| </button> | |
| <div class="input-wrapper"> | |
| <textarea id="message-input" rows="1" placeholder="Talk to JARVIS..." | |
| onkeydown="handleKeydown(event)" oninput="autoResize(this)"></textarea> | |
| </div> | |
| <button class="btn btn-send" id="send-btn" onclick="sendMessage()" title="Send"> | |
| <svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg> | |
| </button> | |
| </div> | |
| </div> | |
| <script> | |
| // State | |
| let ws = null; | |
| let isStreaming = false; | |
| let currentBackend = 'auto'; | |
| let stmEnabled = true; | |
| let currentStreamEl = null; | |
| let currentStreamText = ''; | |
| let recognition = null; | |
| let isListening = false; | |
| let ttsEnabled = true; | |
| // βββ Auth ββββββββββββββββββββββββββββββββββββ | |
| function getToken() { | |
| return document.cookie.split(';').map(c => c.trim()).find(c => c.startsWith('jarvis_token='))?.split('=')[1] || ''; | |
| } | |
| async function checkAuth() { | |
| try { | |
| const resp = await fetch('/api/status'); | |
| if (resp.status === 401) { | |
| showLoginScreen(); | |
| return false; | |
| } | |
| return true; | |
| } catch(e) { | |
| return true; | |
| } | |
| } | |
| function showLoginScreen() { | |
| document.getElementById('chat').innerHTML = ` | |
| <div class="welcome" id="welcome"> | |
| <div class="arc-reactor-lg"></div> | |
| <h2>J.A.R.V.I.S.</h2> | |
| <p style="margin-top:16px;">Authentication required.</p> | |
| <div style="margin-top:20px; display:flex; gap:8px; justify-content:center; align-items:center;"> | |
| <input type="password" id="auth-input" placeholder="Enter access token" | |
| style="background:var(--surface2); border:1px solid rgba(0,180,216,0.3); color:var(--text); | |
| padding:10px 16px; border-radius:12px; font-family:inherit; font-size:14px; outline:none; width:260px;" | |
| onkeydown="if(event.key==='Enter') submitToken()"> | |
| <button onclick="submitToken()" class="ctrl-btn" style="padding:10px 20px; font-size:12px;">AUTHENTICATE</button> | |
| </div> | |
| <p id="auth-error" style="margin-top:10px; color:var(--danger); font-size:12px; display:none;"></p> | |
| </div>`; | |
| } | |
| async function submitToken() { | |
| const input = document.getElementById('auth-input'); | |
| const token = input.value.trim(); | |
| if (!token) return; | |
| try { | |
| const resp = await fetch('/api/auth/token', { | |
| method: 'POST', | |
| headers: {'Content-Type': 'application/json'}, | |
| body: JSON.stringify({token}), | |
| }); | |
| if (resp.ok) { | |
| location.reload(); | |
| } else { | |
| const err = document.getElementById('auth-error'); | |
| err.textContent = 'Invalid token. Check the server console for the correct token.'; | |
| err.style.display = 'block'; | |
| } | |
| } catch(e) { | |
| const err = document.getElementById('auth-error'); | |
| err.textContent = 'Cannot reach server.'; | |
| err.style.display = 'block'; | |
| } | |
| } | |
| // Connect WebSocket | |
| function connect() { | |
| const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| ws = new WebSocket(`${proto}//${location.host}/ws`); | |
| ws.onopen = () => { | |
| document.getElementById('status-dot').style.background = 'var(--success)'; | |
| fetchStatus(); | |
| }; | |
| ws.onclose = () => { | |
| document.getElementById('status-dot').style.background = 'var(--danger)'; | |
| setTimeout(connect, 3000); | |
| }; | |
| ws.onmessage = (event) => { | |
| const data = JSON.parse(event.data); | |
| handleMessage(data); | |
| }; | |
| } | |
| async function fetchStatus() { | |
| try { | |
| const resp = await fetch('/api/status'); | |
| const data = await resp.json(); | |
| currentBackend = data.backend; | |
| // Update dropdown to show active backend | |
| document.getElementById('backend-select').value = 'auto'; | |
| } catch(e) {} | |
| } | |
| function setBackend(value) { | |
| currentBackend = value; | |
| if (value === 'multi') { | |
| document.getElementById('multi-btn').classList.add('active'); | |
| } else { | |
| document.getElementById('multi-btn').classList.remove('active'); | |
| } | |
| } | |
| function setMultiModel() { | |
| const btn = document.getElementById('multi-btn'); | |
| const select = document.getElementById('backend-select'); | |
| if (currentBackend === 'multi') { | |
| currentBackend = 'auto'; | |
| select.value = 'auto'; | |
| btn.classList.remove('active'); | |
| } else { | |
| currentBackend = 'multi'; | |
| select.value = 'multi'; | |
| btn.classList.add('active'); | |
| } | |
| } | |
| function toggleSTM() { | |
| stmEnabled = !stmEnabled; | |
| const btn = document.getElementById('stm-btn'); | |
| btn.classList.toggle('active', stmEnabled); | |
| } | |
| // Handle incoming messages | |
| function handleMessage(data) { | |
| const chat = document.getElementById('chat'); | |
| switch(data.type) { | |
| case 'stream_start': | |
| if (!currentStreamEl) { | |
| hideWelcome(); | |
| const msg = createMessage('assistant'); | |
| currentStreamEl = msg.querySelector('.message-bubble'); | |
| currentStreamText = ''; | |
| chat.appendChild(msg); | |
| } | |
| break; | |
| case 'stream': | |
| if (currentStreamEl) { | |
| currentStreamText += data.content; | |
| currentStreamEl.innerHTML = formatMarkdown(currentStreamText); | |
| scrollToBottom(); | |
| } | |
| break; | |
| case 'stream_end': | |
| if (currentStreamEl && ttsEnabled) { | |
| speak(stripMarkdown(currentStreamText)); | |
| } | |
| currentStreamEl = null; | |
| currentStreamText = ''; | |
| isStreaming = false; | |
| updateSendButton(); | |
| break; | |
| case 'autotune': | |
| const badge = document.getElementById('autotune-badge'); | |
| const typeEl = document.getElementById('autotune-type'); | |
| typeEl.textContent = `${data.query_type} (t=${data.temperature})`; | |
| badge.classList.add('visible'); | |
| setTimeout(() => badge.classList.remove('visible'), 5000); | |
| break; | |
| case 'stm_applied': | |
| const saved = data.original_length - data.cleaned_length; | |
| if (saved > 10) { | |
| const indicator = document.createElement('div'); | |
| indicator.className = 'stm-indicator'; | |
| indicator.innerHTML = `⚡ STM: Removed ${saved} chars of filler`; | |
| chat.appendChild(indicator); | |
| } | |
| break; | |
| case 'tool_start': | |
| break; | |
| case 'tool_exec': | |
| hideWelcome(); | |
| const toolInd = document.createElement('div'); | |
| toolInd.className = 'tool-indicator'; | |
| toolInd.innerHTML = `<div class="tool-spinner"></div> Executing: ${data.tool}(${JSON.stringify(data.args).slice(0, 80)})`; | |
| chat.appendChild(toolInd); | |
| scrollToBottom(); | |
| break; | |
| case 'tool_result': | |
| const indicators = chat.querySelectorAll('.tool-indicator'); | |
| if (indicators.length) { | |
| const last = indicators[indicators.length - 1]; | |
| last.innerHTML = `✓ ${data.tool}: Done`; | |
| last.style.borderColor = 'rgba(0, 230, 118, 0.3)'; | |
| last.style.color = 'var(--success)'; | |
| } | |
| currentStreamEl = null; | |
| break; | |
| case 'error': | |
| const errMsg = createMessage('assistant'); | |
| errMsg.querySelector('.message-bubble').textContent = data.content; | |
| errMsg.querySelector('.message-bubble').style.borderColor = 'var(--danger)'; | |
| chat.appendChild(errMsg); | |
| isStreaming = false; | |
| updateSendButton(); | |
| break; | |
| case 'cleared': | |
| chat.innerHTML = ''; | |
| showWelcome(); | |
| break; | |
| } | |
| } | |
| // Message helpers | |
| function createMessage(role) { | |
| const div = document.createElement('div'); | |
| div.className = `message ${role}`; | |
| const label = role === 'user' ? 'YOU' : 'JARVIS'; | |
| const modelTag = role === 'assistant' && currentBackend !== 'auto' | |
| ? `<span class="model-tag">${currentBackend.toUpperCase()}</span>` : ''; | |
| div.innerHTML = ` | |
| <div class="message-label">${label} ${modelTag}</div> | |
| <div class="message-bubble"></div> | |
| `; | |
| return div; | |
| } | |
| function addUserMessage(text) { | |
| hideWelcome(); | |
| const msg = createMessage('user'); | |
| msg.querySelector('.message-bubble').textContent = text; | |
| document.getElementById('chat').appendChild(msg); | |
| scrollToBottom(); | |
| } | |
| // Send message | |
| function sendMessage() { | |
| const input = document.getElementById('message-input'); | |
| const text = input.value.trim(); | |
| if (!text || isStreaming || !ws) return; | |
| addUserMessage(text); | |
| ws.send(JSON.stringify({ | |
| type: 'message', | |
| content: text, | |
| backend: currentBackend, | |
| stm: stmEnabled, | |
| })); | |
| input.value = ''; | |
| input.style.height = 'auto'; | |
| isStreaming = true; | |
| updateSendButton(); | |
| } | |
| function quickAction(text) { | |
| document.getElementById('message-input').value = text; | |
| sendMessage(); | |
| } | |
| function newChat() { | |
| if (ws) ws.send(JSON.stringify({ type: 'clear' })); | |
| document.getElementById('chat').innerHTML = ''; | |
| showWelcome(); | |
| } | |
| // Voice | |
| function toggleVoice() { | |
| if (!('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) { | |
| alert('Speech recognition not supported. Use Chrome or Edge.'); | |
| return; | |
| } | |
| if (isListening) { recognition.stop(); return; } | |
| const SR = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| recognition = new SR(); | |
| recognition.continuous = false; | |
| recognition.interimResults = true; | |
| recognition.lang = 'en-US'; | |
| recognition.onstart = () => { | |
| isListening = true; | |
| document.getElementById('voice-btn').classList.add('listening'); | |
| }; | |
| recognition.onresult = (event) => { | |
| const transcript = Array.from(event.results).map(r => r[0].transcript).join(''); | |
| document.getElementById('message-input').value = transcript; | |
| }; | |
| recognition.onend = () => { | |
| isListening = false; | |
| document.getElementById('voice-btn').classList.remove('listening'); | |
| if (document.getElementById('message-input').value.trim()) sendMessage(); | |
| }; | |
| recognition.onerror = () => { | |
| isListening = false; | |
| document.getElementById('voice-btn').classList.remove('listening'); | |
| }; | |
| recognition.start(); | |
| } | |
| // TTS | |
| function speak(text) { | |
| if (!ttsEnabled || !text || text.length < 2) return; | |
| window.speechSynthesis.cancel(); | |
| const utter = new SpeechSynthesisUtterance(text.slice(0, 500)); | |
| utter.rate = 1.05; | |
| utter.pitch = 0.95; | |
| const voices = window.speechSynthesis.getVoices(); | |
| const british = voices.find(v => v.lang === 'en-GB' && v.name.includes('Male')); | |
| const english = voices.find(v => v.lang.startsWith('en')); | |
| utter.voice = british || english || voices[0]; | |
| window.speechSynthesis.speak(utter); | |
| } | |
| window.speechSynthesis.onvoiceschanged = () => {}; | |
| // Formatting | |
| function formatMarkdown(text) { | |
| return text | |
| .replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>') | |
| .replace(/`([^`]+)`/g, '<code>$1</code>') | |
| .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') | |
| .replace(/\*(.+?)\*/g, '<em>$1</em>') | |
| .replace(/\n/g, '<br>'); | |
| } | |
| function stripMarkdown(text) { | |
| return text | |
| .replace(/```[\s\S]*?```/g, '') | |
| .replace(/`[^`]+`/g, '') | |
| .replace(/\*\*(.+?)\*\*/g, '$1') | |
| .replace(/\*(.+?)\*/g, '$1') | |
| .replace(/\[Tool.*?\][\s\S]*?(?=\n\n|\n[A-Z]|$)/g, '') | |
| .replace(/```tool[\s\S]*?```/g, '') | |
| .trim(); | |
| } | |
| // UI helpers | |
| function scrollToBottom() { | |
| const chat = document.getElementById('chat'); | |
| chat.scrollTop = chat.scrollHeight; | |
| } | |
| function autoResize(el) { | |
| el.style.height = 'auto'; | |
| el.style.height = Math.min(el.scrollHeight, 120) + 'px'; | |
| } | |
| function handleKeydown(e) { | |
| if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } | |
| } | |
| function updateSendButton() { | |
| document.getElementById('send-btn').disabled = isStreaming; | |
| } | |
| function hideWelcome() { | |
| const w = document.getElementById('welcome'); | |
| if (w) w.style.display = 'none'; | |
| } | |
| function showWelcome() { | |
| const chat = document.getElementById('chat'); | |
| chat.innerHTML = ` | |
| <div class="welcome" id="welcome"> | |
| <div class="arc-reactor-lg"></div> | |
| <h2>J.A.R.V.I.S.</h2> | |
| <p>Good to see you. How may I assist you today?</p> | |
| <p style="margin-top:8px; font-size:11px; color: var(--text2);"> | |
| Multi-model racing / STM filtering / AutoTune enabled | |
| </p> | |
| <div class="quick-actions"> | |
| <button class="quick-btn" onclick="quickAction('What can you do?')">What can you do?</button> | |
| <button class="quick-btn" onclick="quickAction('Search the web for latest AI news')">AI News</button> | |
| <button class="quick-btn" onclick="quickAction('What is my system status?')">System Status</button> | |
| <button class="quick-btn" onclick="quickAction('What is the weather in New York?')">Weather</button> | |
| <button class="quick-btn" onclick="quickAction('Write a Python function to sort a list')">Code</button> | |
| <button class="quick-btn" onclick="quickAction('Compare React vs Vue for a startup')">Analysis</button> | |
| </div> | |
| </div>`; | |
| } | |
| // Wake Word Detection β always-on "Hey JARVIS" | |
| let wakeWordActive = false; | |
| let wakeRecognition = null; | |
| function toggleWakeWord() { | |
| if (wakeWordActive) { | |
| stopWakeWord(); | |
| } else { | |
| startWakeWord(); | |
| } | |
| } | |
| function startWakeWord() { | |
| if (!('webkitSpeechRecognition' in window || 'SpeechRecognition' in window)) { | |
| alert('Speech recognition not supported. Use Chrome or Edge.'); | |
| return; | |
| } | |
| wakeWordActive = true; | |
| document.getElementById('wakeword-btn').classList.add('active'); | |
| document.getElementById('wakeword-overlay').classList.add('active'); | |
| listenForWakeWord(); | |
| } | |
| function stopWakeWord() { | |
| wakeWordActive = false; | |
| document.getElementById('wakeword-btn').classList.remove('active'); | |
| document.getElementById('wakeword-overlay').classList.remove('active'); | |
| if (wakeRecognition) { | |
| wakeRecognition.abort(); | |
| wakeRecognition = null; | |
| } | |
| } | |
| function listenForWakeWord() { | |
| if (!wakeWordActive) return; | |
| const SR = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| wakeRecognition = new SR(); | |
| wakeRecognition.continuous = true; | |
| wakeRecognition.interimResults = true; | |
| wakeRecognition.lang = 'en-US'; | |
| wakeRecognition.onresult = (event) => { | |
| for (let i = event.resultIndex; i < event.results.length; i++) { | |
| const transcript = event.results[i][0].transcript.toLowerCase().trim(); | |
| // Check for wake word | |
| const wakeWords = ['hey jarvis', 'jarvis', 'hey j.a.r.v.i.s', 'okay jarvis', 'yo jarvis']; | |
| let triggered = false; | |
| let command = ''; | |
| for (const wake of wakeWords) { | |
| if (transcript.includes(wake)) { | |
| triggered = true; | |
| // Extract command after wake word | |
| const idx = transcript.indexOf(wake) + wake.length; | |
| command = transcript.slice(idx).trim().replace(/^[,.\s]+/, ''); | |
| break; | |
| } | |
| } | |
| if (triggered && event.results[i].isFinal) { | |
| // Stop wake word listening | |
| wakeRecognition.stop(); | |
| const overlay = document.getElementById('wakeword-overlay'); | |
| overlay.classList.add('heard'); | |
| overlay.textContent = 'JARVIS ACTIVATED'; | |
| // Play activation sound | |
| try { | |
| const ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| const osc = ctx.createOscillator(); | |
| const gain = ctx.createGain(); | |
| osc.connect(gain); | |
| gain.connect(ctx.destination); | |
| osc.frequency.value = 880; | |
| osc.type = 'sine'; | |
| gain.gain.value = 0.1; | |
| osc.start(); | |
| gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3); | |
| osc.stop(ctx.currentTime + 0.3); | |
| } catch(e) {} | |
| if (command) { | |
| // Already have command, send it | |
| document.getElementById('message-input').value = command; | |
| sendMessage(); | |
| setTimeout(() => { | |
| overlay.classList.remove('heard'); | |
| overlay.textContent = 'LISTENING FOR "HEY JARVIS"...'; | |
| listenForWakeWord(); | |
| }, 2000); | |
| } else { | |
| // Speak "Yes sir?" and listen for command | |
| speak('Yes, sir?'); | |
| setTimeout(() => { | |
| listenForCommand(); | |
| }, 1500); | |
| } | |
| return; | |
| } | |
| } | |
| }; | |
| wakeRecognition.onend = () => { | |
| // Auto-restart if still active | |
| if (wakeWordActive && !isStreaming) { | |
| setTimeout(listenForWakeWord, 300); | |
| } | |
| }; | |
| wakeRecognition.onerror = (e) => { | |
| if (e.error !== 'no-speech' && e.error !== 'aborted') { | |
| console.log('Wake word error:', e.error); | |
| } | |
| if (wakeWordActive) { | |
| setTimeout(listenForWakeWord, 1000); | |
| } | |
| }; | |
| wakeRecognition.start(); | |
| } | |
| function listenForCommand() { | |
| const SR = window.SpeechRecognition || window.webkitSpeechRecognition; | |
| const cmdRecognition = new SR(); | |
| cmdRecognition.continuous = false; | |
| cmdRecognition.interimResults = true; | |
| cmdRecognition.lang = 'en-US'; | |
| const overlay = document.getElementById('wakeword-overlay'); | |
| overlay.textContent = 'LISTENING FOR COMMAND...'; | |
| let finalTranscript = ''; | |
| cmdRecognition.onresult = (event) => { | |
| finalTranscript = ''; | |
| for (let i = 0; i < event.results.length; i++) { | |
| finalTranscript += event.results[i][0].transcript; | |
| } | |
| document.getElementById('message-input').value = finalTranscript; | |
| }; | |
| cmdRecognition.onend = () => { | |
| if (finalTranscript.trim()) { | |
| sendMessage(); | |
| } | |
| // Wait for response to finish, then resume wake word | |
| const checkDone = setInterval(() => { | |
| if (!isStreaming) { | |
| clearInterval(checkDone); | |
| overlay.classList.remove('heard'); | |
| overlay.textContent = 'LISTENING FOR "HEY JARVIS"...'; | |
| setTimeout(listenForWakeWord, 1000); | |
| } | |
| }, 500); | |
| }; | |
| cmdRecognition.onerror = () => { | |
| overlay.classList.remove('heard'); | |
| overlay.textContent = 'LISTENING FOR "HEY JARVIS"...'; | |
| listenForWakeWord(); | |
| }; | |
| cmdRecognition.start(); | |
| } | |
| // βββ PWA Install βββββββββββββββββββββββββββββ | |
| let deferredPrompt = null; | |
| window.addEventListener('beforeinstallprompt', (e) => { | |
| e.preventDefault(); | |
| deferredPrompt = e; | |
| showInstallBanner(); | |
| }); | |
| function showInstallBanner() { | |
| if (document.getElementById('install-banner')) return; | |
| const banner = document.createElement('div'); | |
| banner.id = 'install-banner'; | |
| banner.style.cssText = ` | |
| position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); | |
| background: var(--surface2); border: 1px solid var(--accent); | |
| border-radius: 16px; padding: 12px 20px; z-index: 1000; | |
| display: flex; align-items: center; gap: 12px; | |
| box-shadow: 0 0 30px var(--accent-glow); animation: fadeIn 0.5s ease; | |
| max-width: 90vw; | |
| `; | |
| banner.innerHTML = ` | |
| <span style="font-size:13px; color: var(--text);">Install JARVIS as an app?</span> | |
| <button onclick="installPWA()" style=" | |
| background: var(--accent); color: white; border: none; | |
| padding: 6px 16px; border-radius: 10px; font-family: inherit; | |
| font-size: 12px; cursor: pointer; font-weight: bold; | |
| ">INSTALL</button> | |
| <button onclick="this.parentElement.remove()" style=" | |
| background: none; border: none; color: var(--text2); | |
| font-size: 18px; cursor: pointer; padding: 0 4px; | |
| ">×</button> | |
| `; | |
| document.body.appendChild(banner); | |
| } | |
| async function installPWA() { | |
| if (!deferredPrompt) return; | |
| deferredPrompt.prompt(); | |
| const result = await deferredPrompt.userChoice; | |
| if (result.outcome === 'accepted') { | |
| document.getElementById('install-banner')?.remove(); | |
| } | |
| deferredPrompt = null; | |
| } | |
| // iOS / iPadOS install hint | |
| function checkiOSInstall() { | |
| const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || | |
| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPad with desktop UA | |
| const isStandalone = window.navigator.standalone; | |
| const dismissed = localStorage.getItem('jarvis_ios_install_dismissed'); | |
| if (isIOS && !isStandalone && !dismissed) { | |
| const isIPad = /iPad/.test(navigator.userAgent) || | |
| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); | |
| const deviceName = isIPad ? 'iPad' : 'iPhone'; | |
| const hint = document.createElement('div'); | |
| hint.id = 'ios-install-hint'; | |
| hint.style.cssText = ` | |
| position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); | |
| background: var(--surface2); border: 1px solid var(--accent); | |
| border-radius: 16px; padding: 16px 24px; z-index: 1000; | |
| box-shadow: 0 0 30px var(--accent-glow); animation: fadeIn 0.5s ease; | |
| max-width: 90vw; text-align: center; font-size: 13px; color: var(--text); | |
| `; | |
| hint.innerHTML = ` | |
| <div style="margin-bottom:8px; font-weight:bold; color: var(--accent); font-size:15px;">Install JARVIS on ${deviceName}</div> | |
| <div style="margin-bottom:6px;">Tap <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:middle;"><path d="M16 5l-1.42 1.42-1.59-1.59V16h-1.98V4.83L9.42 6.42 8 5l4-4 4 4zm4 5v11c0 1.1-.9 2-2 2H6c-1.11 0-2-.9-2-2V10c0-1.11.89-2 2-2h3v2H6v11h12V10h-3V8h3c1.1 0 2 .89 2 2z"/></svg> Share β <strong>Add to Home Screen</strong></div> | |
| <div style="color: var(--text2); font-size:11px; margin-bottom:10px;">JARVIS will run like a native app β fullscreen, with mic & voice</div> | |
| <div style="display:flex; gap:8px; justify-content:center;"> | |
| <button onclick="this.closest('#ios-install-hint').remove()" style=" | |
| background:none; border:1px solid var(--accent); | |
| color:var(--accent); padding:6px 16px; border-radius:10px; | |
| font-family:inherit; font-size:12px; cursor:pointer; | |
| ">GOT IT</button> | |
| <button onclick="localStorage.setItem('jarvis_ios_install_dismissed','1'); this.closest('#ios-install-hint').remove()" style=" | |
| background:none; border:1px solid var(--text2); | |
| color:var(--text2); padding:6px 16px; border-radius:10px; | |
| font-family:inherit; font-size:11px; cursor:pointer; | |
| ">DON'T SHOW AGAIN</button> | |
| </div> | |
| `; | |
| document.body.appendChild(hint); | |
| } | |
| } | |
| // βββ Auto Wake Word on Launch ββββββββββββββββ | |
| function autoStartWakeWord() { | |
| // If opened as installed PWA, auto-start wake word | |
| const isStandalone = window.matchMedia('(display-mode: standalone)').matches | |
| || window.navigator.standalone | |
| || document.referrer.includes('android-app://'); | |
| if (isStandalone) { | |
| // Small delay to let everything initialize | |
| setTimeout(() => { | |
| startWakeWord(); | |
| }, 1500); | |
| } | |
| } | |
| // βββ Keep Alive (prevent phone from killing the tab) ββββ | |
| function startKeepAlive() { | |
| // Play silent audio to keep the app alive on mobile | |
| const AudioContext = window.AudioContext || window.webkitAudioContext; | |
| if (!AudioContext) return; | |
| const ctx = new AudioContext(); | |
| const oscillator = ctx.createOscillator(); | |
| const gain = ctx.createGain(); | |
| gain.gain.value = 0.001; // Nearly silent | |
| oscillator.connect(gain); | |
| gain.connect(ctx.destination); | |
| oscillator.start(); | |
| // Also prevent screen dimming via Wake Lock API | |
| async function requestWakeLock() { | |
| try { | |
| if ('wakeLock' in navigator) { | |
| await navigator.wakeLock.request('screen'); | |
| } | |
| } catch(e) {} | |
| } | |
| requestWakeLock(); | |
| document.addEventListener('visibilitychange', () => { | |
| if (document.visibilityState === 'visible') { | |
| requestWakeLock(); | |
| // Restart wake word if it died | |
| if (wakeWordActive && !wakeRecognition) { | |
| listenForWakeWord(); | |
| } | |
| } | |
| }); | |
| } | |
| // βββ Service Worker Registration βββββββββββββ | |
| if ('serviceWorker' in navigator) { | |
| navigator.serviceWorker.register('/static/sw.js').catch(() => {}); | |
| } | |
| // βββ Request Permissions Upfront ββββββββββββββ | |
| async function requestPermissions() { | |
| // Mic permission | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| stream.getTracks().forEach(t => t.stop()); | |
| } catch(e) { | |
| console.log('Mic permission denied'); | |
| } | |
| // Notification permission | |
| if ('Notification' in window && Notification.permission === 'default') { | |
| Notification.requestPermission(); | |
| } | |
| } | |
| // ββ Settings Panel ββββββββββββββββββββββββββββββ | |
| function toggleSettings() { | |
| const panel = document.getElementById('settings-panel'); | |
| const visible = panel.style.display !== 'none'; | |
| panel.style.display = visible ? 'none' : 'block'; | |
| if (!visible) loadSettings(); | |
| } | |
| async function loadSettings() { | |
| try { | |
| const resp = await fetch('/api/settings', { headers: authHeaders() }); | |
| const data = await resp.json(); | |
| document.getElementById('setting-language').value = data.language || 'en'; | |
| document.getElementById('setting-tts').value = data.tts_voice || 'Daniel'; | |
| document.getElementById('setting-rate').value = data.tts_rate || 180; | |
| document.getElementById('rate-value').textContent = (data.tts_rate || 180) + ' WPM'; | |
| } catch(e) { console.log('Settings load error', e); } | |
| // Load scheduled jobs | |
| try { | |
| const resp = await fetch('/api/scheduler/jobs', { headers: authHeaders() }); | |
| const data = await resp.json(); | |
| const el = document.getElementById('scheduled-jobs-list'); | |
| if (data.jobs && data.jobs.length > 0) { | |
| el.innerHTML = data.jobs.map(j => | |
| `<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 0;">` + | |
| `<span>${j.name} β <em>${j.command}</em> ${j.run_at ? '@ '+j.run_at : j.interval_seconds+'s'}</span>` + | |
| `<button onclick="deleteJob(${j.id})" style="background:none;border:none;color:#f44;cursor:pointer;font-size:14px;">×</button></div>` | |
| ).join(''); | |
| } else { | |
| el.textContent = 'No scheduled jobs.'; | |
| } | |
| } catch(e) { document.getElementById('scheduled-jobs-list').textContent = 'Could not load jobs.'; } | |
| // Load automation rules | |
| try { | |
| const resp = await fetch('/api/automations', { headers: authHeaders() }); | |
| const data = await resp.json(); | |
| const el = document.getElementById('automation-rules-list'); | |
| if (data.rules && data.rules.length > 0) { | |
| el.innerHTML = data.rules.map(r => | |
| `<div style="padding:3px 0;">${r.name}: when ${r.trigger}="${r.condition}" β ${r.action}</div>` | |
| ).join(''); | |
| } else { | |
| el.textContent = 'No automation rules.'; | |
| } | |
| } catch(e) { document.getElementById('automation-rules-list').textContent = 'Could not load rules.'; } | |
| } | |
| // Speech rate slider live update | |
| document.getElementById('setting-rate').addEventListener('input', function() { | |
| document.getElementById('rate-value').textContent = this.value + ' WPM'; | |
| }); | |
| async function saveSettings() { | |
| const prefs = [ | |
| { key: 'language', value: document.getElementById('setting-language').value }, | |
| { key: 'tts_voice', value: document.getElementById('setting-tts').value }, | |
| { key: 'tts_rate', value: document.getElementById('setting-rate').value }, | |
| ]; | |
| for (const p of prefs) { | |
| await fetch('/api/settings', { | |
| method: 'PUT', | |
| headers: { ...authHeaders(), 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(p), | |
| }); | |
| } | |
| toggleSettings(); | |
| } | |
| async function addScheduledJob() { | |
| const name = document.getElementById('job-name').value.trim(); | |
| const command = document.getElementById('job-command').value.trim(); | |
| const runAt = document.getElementById('job-time').value.trim(); | |
| if (!name || !command) return alert('Name and command are required.'); | |
| await fetch('/api/scheduler/jobs', { | |
| method: 'POST', | |
| headers: { ...authHeaders(), 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ name, command, run_at: runAt, repeat_daily: !!runAt }), | |
| }); | |
| document.getElementById('job-name').value = ''; | |
| document.getElementById('job-command').value = ''; | |
| document.getElementById('job-time').value = ''; | |
| loadSettings(); | |
| } | |
| async function deleteJob(id) { | |
| await fetch('/api/scheduler/jobs/' + id, { method: 'DELETE', headers: authHeaders() }); | |
| loadSettings(); | |
| } | |
| function authHeaders() { | |
| const token = localStorage.getItem('jarvis_token') || ''; | |
| return token ? { 'Authorization': 'Bearer ' + token } : {}; | |
| } | |
| // Initialize | |
| checkAuth().then(ok => { | |
| if (ok) { | |
| connect(); | |
| requestPermissions(); | |
| startKeepAlive(); | |
| autoStartWakeWord(); | |
| setTimeout(checkiOSInstall, 3000); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> | |