| <!DOCTYPE html>
|
| <html lang="en">
|
| <head>
|
| <meta charset="UTF-8">
|
| <meta http-equiv="Cache-Control" content="no-store, no-cache, must-revalidate, max-age=0">
|
| <meta http-equiv="Pragma" content="no-cache">
|
| <meta http-equiv="Expires" content="0">
|
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
| <title>Sentinel Vision Navigator</title>
|
| <style>
|
| :root { --bg: #0a0a0f; --panel: #111118; --border: #1e1e2e; --text: #e2e8f0; --muted: #64748b; --accent: #10b981; --danger: #ef4444; --blue: #3b82f6; }
|
| * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
| html, body { height: 100%; overflow: hidden; touch-action: manipulation; }
|
| body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); }
|
|
|
| #app { display: none; flex-direction: column; height: 100dvh; height: 100vh; }
|
| #app.active { display: flex; }
|
| #topbar { display: flex; align-items: center; justify-content: space-between; padding: 6px 12px; background: var(--panel); border-bottom: 1px solid var(--border); flex-shrink: 0; min-height: 40px; }
|
| #topbar .title { font-size: 12px; font-weight: 600; color: var(--muted); letter-spacing: 0.5px; }
|
| #topbar .status { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--muted); }
|
| .dot { width: 8px; height: 8px; border-radius: 50%; background: #333; flex-shrink: 0; }
|
| .dot.green { background: var(--accent); animation: blink 2s infinite; }
|
| .dot.amber { background: #f59e0b; animation: blink 0.6s infinite; }
|
| .dot.blue { background: #6366f1; animation: blink 1s infinite; }
|
| .dot.red { background: var(--danger); }
|
| @keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
|
|
| #content { flex: 1; display: flex; overflow: hidden; min-height: 0; }
|
| #video-col { width: 40%; position: relative; background: #000; flex-shrink: 0; }
|
| #camera { width: 100%; height: 100%; object-fit: cover; display: block; }
|
| #cam-status { position: absolute; bottom: 8px; left: 8px; font-size: 10px; color: var(--muted); background: rgba(0,0,0,0.7); padding: 3px 8px; border-radius: 4px; }
|
|
|
| #chat-col { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
| #messages { flex: 1; overflow-y: auto; padding: 10px 12px; scroll-behavior: smooth; -webkit-overflow-scrolling: touch; }
|
| .bubble { margin-bottom: 8px; padding: 8px 12px; border-radius: 12px; font-size: 13px; line-height: 1.55; max-width: 88%; word-wrap: break-word; animation: pop 0.2s ease-out; }
|
| @keyframes pop { from{opacity:0;transform:scale(0.97)} to{opacity:1;transform:scale(1)} }
|
| .bubble.ai { background: #1e293b; color: var(--text); border-bottom-left-radius: 3px; }
|
| .bubble.me { background: var(--blue); color: #fff; margin-left: auto; border-bottom-right-radius: 3px; }
|
| .bubble.sys { background: #1a1a2e; color: #a78bfa; font-size: 11px; text-align: center; max-width: 100%; border-radius: 6px; }
|
| .bubble .tag { display: inline-block; background: var(--accent); color:#fff; font-size:9px; padding:1px 5px; border-radius:3px; margin-right:4px; font-weight:700; vertical-align:middle; }
|
|
|
| #input-row { display: flex; gap: 6px; padding: 8px 10px; border-top: 1px solid var(--border); background: var(--panel); flex-shrink: 0; }
|
| #txt { flex: 1; background: #1a1a2a; border: 1px solid #2a2a3a; border-radius: 8px; padding: 10px 12px; color: #fff; font-size: 14px; outline: none; -webkit-appearance: none; }
|
| #txt:focus { border-color: var(--blue); }
|
| #txt::placeholder { color: #444; }
|
| .send-btn { background: var(--blue); border: none; border-radius: 8px; padding: 10px 14px; color: #fff; font-size: 16px; cursor: pointer; flex-shrink: 0; -webkit-appearance: none; }
|
|
|
| #toolbar { display: flex; gap: 6px; padding: 8px 10px; background: var(--panel); border-top: 1px solid var(--border); flex-shrink: 0; justify-content: center; flex-wrap: wrap; }
|
| .tb { padding: 8px 12px; border: none; border-radius: 8px; font-size: 12px; font-weight: 600; cursor: pointer; color: #fff; -webkit-appearance: none; touch-action: manipulation; user-select: none; -webkit-user-select: none; }
|
| .tb:active { transform: scale(0.93); }
|
| .tb-g { background: var(--accent); }
|
| .tb-g.on { background: var(--danger); }
|
| .tb-b { background: var(--blue); }
|
| .tb-p { background: #7c3aed; }
|
| .tb-d { background: #374151; }
|
| .tb-r { background: var(--danger); }
|
|
|
| @media (max-width: 768px) {
|
| #content { flex-direction: column; }
|
| #video-col { width: 100%; height: 25vh; flex-shrink: 0; }
|
| #chat-col { flex: 1; }
|
| .bubble { font-size: 14px; max-width: 92%; }
|
| #txt { font-size: 16px; }
|
| .tb { padding: 12px 14px; font-size: 13px; }
|
| }
|
|
|
|
|
| #welcome { position: fixed; inset: 0; background: #0a0a0f; z-index: 9999; display: flex; align-items: center; justify-content: center; text-align: center; padding: 20px; touch-action: manipulation; }
|
| #welcome.hidden { display: none !important; pointer-events: none !important; }
|
| #welcome h2 { font-size: 24px; color: var(--accent); margin-bottom: 14px; }
|
| #welcome p { color: #94a3b8; font-size: 15px; line-height: 1.7; margin-bottom: 10px; max-width: 440px; }
|
| #welcome .go { background: var(--accent); color: #fff; border: none; padding: 22px 60px; border-radius: 14px; font-size: 22px; font-weight: 700; cursor: pointer; margin-top: 18px; -webkit-appearance: none; touch-action: manipulation; user-select: none; -webkit-user-select: none; }
|
| #welcome .go:active { transform: scale(0.95); background: #059669; }
|
| #welcome .apk { display: block; background: #0891b2; color: #fff; text-decoration: none; padding: 16px 26px; border-radius: 10px; font-size: 17px; font-weight: 800; margin: 12px auto 0; max-width: 340px; }
|
| #welcome .apk small { display: block; color: #cffafe; font-size: 11px; font-weight: 600; margin-top: 4px; }
|
| #welcome .doclinks { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; margin-top: 10px; }
|
| #welcome .doclinks a { color: #ccfbf1; border: 1px solid #155e75; text-decoration: none; border-radius: 8px; padding: 9px 12px; font-size: 13px; font-weight: 700; background: rgba(8,47,73,0.7); }
|
|
|
| #permWait { position: fixed; inset: 0; background: #0a0a0f; z-index: 9998; display: none; align-items: center; justify-content: center; text-align: center; padding: 20px; }
|
| #permWait.show { display: flex; }
|
| #permWait p { color: #94a3b8; font-size: 18px; }
|
| </style>
|
| </head>
|
| <body>
|
|
|
| <div id="welcome">
|
| <div>
|
| <h2>Sentinel Vision Navigator</h2>
|
| <p>Your AI eyes and voice assistant. Camera, microphone, and real-time guidance.</p>
|
| <p><b>Tap anywhere to start.</b></p>
|
| <button class="go" id="goBtn">▶ TAP TO START</button>
|
| <a class="apk" data-no-boot="true" href="https://amdvision.qubitpage.com/downloads/sentinel-vision.apk" download>
|
| DOWNLOAD ANDROID APP
|
| <small>APK with assistant mode by default, one-shot camera guidance, SOS-only haptics, and phone connectors</small>
|
| </a>
|
| <div class="doclinks">
|
| <a data-no-boot="true" href="https://amdvision.qubitpage.com/downloads/sentinel-vision-whitepaper.pdf" download>Whitepaper PDF</a>
|
| <a data-no-boot="true" href="https://amdvision.qubitpage.com/downloads/sentinel-vision-pitch-deck.pdf" download>Pitch Deck PDF</a>
|
| </div>
|
| </div>
|
| </div>
|
|
|
| <div id="permWait"><div><p id="permMsg">Requesting permissions...</p></div></div>
|
|
|
| <div id="app">
|
| <div id="topbar">
|
| <span class="title">SENTINEL VISION</span>
|
| <div class="status"><span class="dot" id="dot"></span><span id="st">Off</span></div>
|
| <span id="modeBadge" style="font-size:10px;background:var(--accent);padding:2px 7px;border-radius:5px;font-weight:700">ASSISTANT</span>
|
| </div>
|
| <div id="content">
|
| <div id="video-col">
|
| <video id="camera" autoplay playsinline muted></video>
|
| <div id="cam-status">cam off</div>
|
| </div>
|
| <div id="chat-col">
|
| <div id="messages"></div>
|
| <div id="input-row">
|
| <input id="txt" type="text" placeholder="Type or just speak..." autocomplete="off" enterkeyhint="send">
|
| <button class="send-btn" id="sendBtn">⏎</button>
|
| </div>
|
| </div>
|
| </div>
|
| <div id="toolbar">
|
| <button class="tb tb-g" id="tbMic">🎤 Listen</button>
|
| <button class="tb tb-p" id="tbLook">👁 Look</button>
|
| <button class="tb tb-b" id="tbMode">🔄 Mode</button>
|
| <button class="tb tb-d" id="tbMute">🔊</button>
|
| <button class="tb tb-r" id="tbSos">SOS</button>
|
| </div>
|
| </div>
|
|
|
| <canvas id="cv" style="display:none"></canvas>
|
|
|
| <script>
|
| 'use strict';
|
| var API = 'https://api.akashml.com/v1/chat/completions';
|
| var KEY = 'akml-jMUUspexjNfNhIUvrawSZIkWvtxSadCe';
|
| var MDL = 'Qwen/Qwen3.6-35B-A3B';
|
| var APP_BUILD = 'main-english-linkfix-2026-05-07';
|
|
|
| var D = {
|
| contacts: [
|
| {name:'Mom',ph:'+40712345678',em:'mom@gmail.com'},
|
| {name:'Dad',ph:'+40723456789',em:'dad@yahoo.com'},
|
| {name:'Maria',ph:'+40734567890',em:'maria@gmail.com'},
|
| {name:'Alex',ph:'+40745678901',em:'alex@outlook.com'},
|
| {name:'Doctor Radu',ph:'+40756789012',em:'dr.radu@clinica.ro'},
|
| {name:'Work Ana',ph:'+40778901234',em:'ana@company.ro'},
|
| {name:'Pharmacy',ph:'+40789012345',em:null}
|
| ],
|
| emails: [
|
| {from:'Alex',subj:'Meeting tomorrow 3 PM',body:'Hi, confirming our meeting tomorrow at 3 PM in the office.',time:'10:23',read:false},
|
| {from:'Amazon',subj:'Your order shipped',body:'Your wireless earbuds are on the way. Delivery expected Thursday.',time:'9:45',read:false},
|
| {from:'Mom',subj:'Dinner Sunday?',body:'Hey sweetie, coming for dinner Sunday? Dad is making sarmale!',time:'Yesterday',read:true}
|
| ],
|
| notifs: [
|
| {app:'WhatsApp',from:'Maria',text:'Are we still meeting for coffee today?',ago:'2m'},
|
| {app:'Calendar',from:'',text:'Meeting with Alex in 1 hour',ago:'5m'}
|
| ],
|
| cal: [
|
| {t:'3:00 PM',what:'Meeting with Alex',where:'Office 4B'},
|
| {t:'5:30 PM',what:'Pharmacy pickup',where:'Catena, Mihai Bravu'},
|
| {t:'7:00 PM',what:'Dinner with Maria',where:'La Mama restaurant'}
|
| ],
|
| bat: 72, loc: 'Bucharest, near Piata Unirii', weather: '22C partly cloudy',
|
| calls: [], sms: [], wa: [], nav: null
|
| };
|
|
|
| var mode = 'assistant';
|
| var listening = false;
|
| var autoListen = false;
|
| var speaking = false;
|
| var muted = false;
|
| var busy = false;
|
| var rec = null;
|
| var memory = [];
|
| var booted = false;
|
| var lastInputKey = '';
|
| var lastInputAt = 0;
|
| var preferredEnglishVoice = null;
|
|
|
| var cam = document.getElementById('camera');
|
| var cv = document.getElementById('cv');
|
| var cx = cv.getContext('2d');
|
| var msgs = document.getElementById('messages');
|
| var txt = document.getElementById('txt');
|
|
|
| function gel(id) { return document.getElementById(id); }
|
| function dot(c, t) { gel('dot').className = 'dot ' + c; gel('st').textContent = t; }
|
|
|
| function addMsg(cls, text, tag) {
|
| var d = document.createElement('div');
|
| d.className = 'bubble ' + cls;
|
| if (tag) d.innerHTML = '<span class="tag">' + tag + '</span>' + esc(text);
|
| else d.textContent = text;
|
| msgs.appendChild(d);
|
| msgs.scrollTop = msgs.scrollHeight;
|
| }
|
| function esc(s) { return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
|
|
|
| function say(text) {
|
| return new Promise(function(resolve) {
|
| if (muted || !text) { resolve(); return; }
|
| window.speechSynthesis.cancel();
|
| speaking = true;
|
| dot('blue', 'Speaking...');
|
| var u = new SpeechSynthesisUtterance(text);
|
| u.lang = 'en-US';
|
| u.voice = getEnglishVoice();
|
| u.rate = 0.96; u.pitch = 1.0;
|
| u.onend = function() { speaking = false; resolve(); afterSpeak(); };
|
| u.onerror = function() { speaking = false; resolve(); afterSpeak(); };
|
| window.speechSynthesis.speak(u);
|
| });
|
| }
|
| function shutUp() { window.speechSynthesis.cancel(); speaking = false; }
|
| function getEnglishVoice() {
|
| if (preferredEnglishVoice) return preferredEnglishVoice;
|
| if (!window.speechSynthesis || !window.speechSynthesis.getVoices) return null;
|
| var voices = window.speechSynthesis.getVoices() || [];
|
| var english = voices.filter(function(v) { return /^en[-_]/i.test(v.lang || ''); });
|
| preferredEnglishVoice = english.find(function(v) { return /google|microsoft|natural|neural|enhanced|premium|samantha|jenny|aria|zira/i.test(v.name || ''); }) || english[0] || null;
|
| return preferredEnglishVoice;
|
| }
|
| if (window.speechSynthesis && window.speechSynthesis.onvoiceschanged !== undefined) {
|
| window.speechSynthesis.onvoiceschanged = function() { preferredEnglishVoice = null; getEnglishVoice(); };
|
| }
|
| function afterSpeak() {
|
| if (autoListen && !busy) setTimeout(startRec, 300);
|
| else dot(autoListen ? 'green' : '', autoListen ? 'Listening...' : 'Ready');
|
| }
|
|
|
|
|
| function initRec() {
|
| var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| if (!SR) { addMsg('sys', 'Speech not supported - use Chrome'); return false; }
|
| rec = new SR();
|
| rec.continuous = true;
|
| rec.interimResults = true;
|
| rec.lang = 'en-US';
|
| var buf = '';
|
| var timer = null;
|
| rec.onresult = function(e) {
|
| if (speaking) { shutUp(); return; }
|
| if (busy) return;
|
| buf = '';
|
| for (var i = e.resultIndex; i < e.results.length; i++) {
|
| if (e.results[i].isFinal) buf += e.results[i][0].transcript;
|
| }
|
| if (buf.trim()) {
|
| clearTimeout(timer);
|
| timer = setTimeout(function() {
|
| if (buf.trim().length > 1) {
|
| var input = buf.trim();
|
| buf = '';
|
| stopRec();
|
| handle(input);
|
| }
|
| }, 1400);
|
| }
|
| };
|
| rec.onend = function() {
|
| listening = false;
|
| if (autoListen && !speaking && !busy) setTimeout(startRec, 300);
|
| };
|
| rec.onerror = function(e) {
|
| if (e.error === 'no-speech' || e.error === 'aborted') return;
|
| listening = false;
|
| if (autoListen) setTimeout(startRec, 1000);
|
| };
|
| return true;
|
| }
|
| function startRec() {
|
| if (!rec || listening || speaking || busy) return;
|
| try { rec.start(); listening = true; dot('green', 'Listening...'); } catch (e) {}
|
| }
|
| function stopRec() {
|
| if (!rec) return;
|
| try { rec.stop(); } catch (e) {}
|
| listening = false;
|
| }
|
|
|
|
|
| function snap() {
|
| if (!cam.videoWidth) return null;
|
| cv.width = Math.min(cam.videoWidth, 640);
|
| cv.height = Math.min(cam.videoHeight, 480);
|
| cx.drawImage(cam, 0, 0, cv.width, cv.height);
|
| return cv.toDataURL('image/jpeg', 0.45).split(',')[1];
|
| }
|
|
|
|
|
| function handle(text) {
|
| text = (text || '').trim();
|
| if (!text || !acceptInput(text)) return;
|
| if (busy) return;
|
| busy = true;
|
| addMsg('me', text);
|
| dot('amber', 'Thinking...');
|
| var mobility = isMobilityQuery(text);
|
| var useVision = mobility || needsVision(text);
|
| var frame = useVision ? snap() : null;
|
|
|
| if (mobility && !frame) {
|
| busy = false;
|
| var noCam = 'I cannot guide you safely until the camera is live. Point the phone forward, allow camera access, then say, guide me again.';
|
| addMsg('ai', noCam);
|
| say(noCam);
|
| return;
|
| }
|
|
|
| memory.push({ role: 'user', content: text });
|
| var sysPrompt = buildPrompt();
|
| var apiMsgs = [{ role: 'system', content: sysPrompt }].concat(memory.slice(-16));
|
| if (frame) {
|
| apiMsgs[apiMsgs.length - 1] = { role: 'user', content: [
|
| { type: 'text', text: mobility ? buildMobilityRequest(text) : text },
|
| { type: 'image_url', image_url: { url: 'data:image/jpeg;base64,' + frame } }
|
| ]};
|
| }
|
| fetch(API, {
|
| method: 'POST',
|
| headers: { 'Authorization': 'Bearer ' + KEY, 'Content-Type': 'application/json' },
|
| body: JSON.stringify({ model: MDL, messages: apiMsgs, max_tokens: 400, temperature: 0.7, chat_template_kwargs: { enable_thinking: false } })
|
| }).then(function(r) {
|
| if (!r.ok) throw new Error('API ' + r.status);
|
| return r.json();
|
| }).then(function(j) {
|
| var reply = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || "Sorry, say again?";
|
| var parsed = parseAct(reply);
|
| memory.push({ role: 'assistant', content: reply });
|
| parsed.actions.forEach(doAction);
|
| addMsg('ai', parsed.spoken, parsed.actions.length ? parsed.actions.map(function(a){return a.t.toUpperCase()}).join(' ') : null);
|
| saveState();
|
| busy = false;
|
| say(parsed.spoken);
|
| }).catch(function(e) {
|
| console.error(e);
|
| busy = false;
|
| addMsg('ai', 'I am having trouble connecting. Please try again in a moment.');
|
| say('I am having trouble connecting. Please try again in a moment.');
|
| });
|
| }
|
|
|
| function buildPrompt() {
|
| var t = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
| var d2 = new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
|
| return 'You are Sentinel, a fluent English voice assistant for a blind person. Always answer in clear English, no matter the user IP country, browser locale, device language, or location. Speak naturally, calmly, and clearly. You control phone tasks, see through the camera, and guide physical movement.\n\nVOICE STYLE:\n- Use proper, simple English only. Sound like a patient human guide, not a robot.\n- Keep replies short: one or two clear sentences.\n- Do not ramble, apologize repeatedly, or over-explain.\n- Give one useful next step, then wait.\n- If the user interrupts, answer the newest request.\n\nMOBILITY / BLIND NAVIGATION RULES:\n- If the user asks to guide them, get out, walk, avoid obstacles, find a door, move through a room, or go outside, treat it as immediate camera-based mobility.\n- Use ONLY the camera image for room-scale guidance. Do NOT use the city, weather, GPS, or an old destination for local movement.\n- Start with urgent hazards in the path: stairs, chairs, tables, doors, walls, glass, curbs, people, cars, holes, cables, or wet floor.\n- Give exact body guidance in plain English: Stop. Turn slightly left. Turn right about 30 degrees. Take two small steps forward. Keep the wall on your left.\n- If the image is unclear, say: Stop for a moment. Slowly pan the phone left, then right.\n- Never invent a route beyond what is visible. For blind safety, be conservative.\n\nPHONE ACTIONS:\n[ACT:call:name] [ACT:sms:name:msg] [ACT:wa:name:msg] [ACT:nav:place] [ACT:email:to:subj:body] [ACT:read:idx] [ACT:dismiss:idx]\nConfirm before executing phone actions.\n\nDEVICE CONTEXT FOR PHONE TASKS ONLY:\nTime: ' + t + ' | ' + d2 + ' | Battery: ' + D.bat + '% | Approximate location: ' + D.loc + ' | ' + D.weather + '\n' + (D.nav ? 'Stored far destination: ' + D.nav + '\n' : '') + 'Contacts: ' + D.contacts.map(function(c){return c.name}).join(', ') + '\nUnread(' + D.emails.filter(function(e){return !e.read}).length + '): ' + D.emails.filter(function(e){return !e.read}).map(function(e){return e.from+': "'+e.subj+'"'}).join('; ') + '\nNotifications: ' + D.notifs.map(function(n){return '['+n.app+'] '+(n.from||'')+' '+n.text}).join('; ') + '\nCalendar: ' + D.cal.map(function(c){return c.t+' '+c.what}).join('; ') + '\nMode: ' + mode + '\n\n/no_think';
|
| }
|
|
|
| function needsVision(t) {
|
| return mode === 'navigation' || isMobilityQuery(t) || /see|look|what.?s|describe|read.*sign|front|around|color|person/i.test(t);
|
| }
|
|
|
| function isMobilityQuery(t) {
|
| return /guide|which way|walk|walking|move|room|get out|go out|outside|exit|door|obstacle|avoid|stairs|step|left|right|ahead|forward|back|cross|path|safe|unsafe|fall|chair|table|wall|floor|street|sidewalk|entrance|hallway|corridor|turn/i.test(t);
|
| }
|
|
|
| function buildMobilityRequest(text) {
|
| return text + '\n\nMOBILITY_GUIDANCE_REQUEST:\nUse the attached live camera frame only. Ignore GPS, city, weather, device locale, IP country, and old destinations. I am blind and need immediate physical guidance in the visible environment. Speak in fluent, natural English only. State urgent hazards first, then give one short next action with direction and distance. If you cannot see enough, tell me to stop and slowly pan the phone.';
|
| }
|
|
|
| function acceptInput(text) {
|
| var key = text.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
| var now = Date.now();
|
| if (key && key === lastInputKey && now - lastInputAt < 3500) {
|
| console.log('[Sentinel] duplicate input ignored:', text);
|
| return false;
|
| }
|
| lastInputKey = key;
|
| lastInputAt = now;
|
| return true;
|
| }
|
|
|
| function parseAct(r) {
|
| var acts = [];
|
| var rx = /\[ACT:([^\]]+)\]/g;
|
| var m;
|
| while ((m = rx.exec(r)) !== null) { var p = m[1].split(':'); acts.push({ t: p[0], p: p.slice(1) }); }
|
| return { spoken: r.replace(/\[ACT:[^\]]+\]/g, '').replace(/\s{2,}/g, ' ').trim(), actions: acts };
|
| }
|
|
|
| function doAction(a) {
|
| switch (a.t) {
|
| case 'call': D.calls.push({to:a.p[0],time:new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}); addMsg('sys','Calling '+a.p[0]+'...'); break;
|
| case 'sms': D.sms.push({to:a.p[0],msg:a.p.slice(1).join(':')}); addMsg('sys','SMS to '+a.p[0]); break;
|
| case 'wa': D.wa.push({to:a.p[0],msg:a.p.slice(1).join(':')}); addMsg('sys','WA to '+a.p[0]); break;
|
| case 'nav': D.nav=a.p[0]; addMsg('sys','Navigating to '+a.p[0]); break;
|
| case 'email': addMsg('sys','Email to '+a.p[0]); break;
|
| case 'read': var i=parseInt(a.p[0])-1; if(D.emails[i]) D.emails[i].read=true; break;
|
| case 'dismiss': var i=parseInt(a.p[0])-1; if(D.notifs[i]) D.notifs.splice(i,1); break;
|
| }
|
| }
|
|
|
|
|
| function saveState() {
|
| try { localStorage.setItem('sv_ses', JSON.stringify({build:APP_BUILD,memory:memory.slice(-20),mode:mode,nav:D.nav,calls:D.calls,sms:D.sms,wa:D.wa,ts:Date.now()})); } catch(e) {}
|
| }
|
| function loadState() {
|
| try {
|
| var d = JSON.parse(localStorage.getItem('sv_ses') || 'null');
|
| if (d && d.build !== APP_BUILD) { localStorage.removeItem('sv_ses'); return false; }
|
| if (!d || Date.now() - d.ts > 20*60*1000) return false;
|
| memory=d.memory||[]; mode=d.mode||'assistant'; D.nav=d.nav; D.calls=d.calls||[]; D.sms=d.sms||[]; D.wa=d.wa||[];
|
| return true;
|
| } catch(e) { return false; }
|
| }
|
|
|
|
|
| var resumed = loadState();
|
|
|
| function boot() {
|
| if (booted) return;
|
| booted = true;
|
| console.log('[Sentinel] Boot triggered');
|
|
|
|
|
| gel('welcome').className = 'hidden';
|
| gel('welcome').style.display = 'none';
|
| gel('welcome').style.pointerEvents = 'none';
|
|
|
|
|
| gel('permWait').classList.add('show');
|
| gel('permMsg').textContent = 'Allow camera and microphone...';
|
|
|
|
|
| navigator.mediaDevices.getUserMedia({
|
| video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } },
|
| audio: true
|
| }).then(function(stream) {
|
| cam.srcObject = stream;
|
| gel('cam-status').textContent = 'cam live';
|
|
|
| stream.getAudioTracks().forEach(function(t) { t.stop(); });
|
| finishBoot(true, true);
|
| }).catch(function(err) {
|
| console.log('[Sentinel] Combined failed:', err.message, '- trying separately');
|
|
|
| navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }).then(function(vs) {
|
| cam.srcObject = vs;
|
| gel('cam-status').textContent = 'cam live';
|
|
|
| navigator.mediaDevices.getUserMedia({ audio: true }).then(function(as) {
|
| as.getTracks().forEach(function(t){t.stop()});
|
| finishBoot(true, true);
|
| }).catch(function() { finishBoot(true, false); });
|
| }).catch(function() {
|
|
|
| navigator.mediaDevices.getUserMedia({ audio: true }).then(function(as) {
|
| as.getTracks().forEach(function(t){t.stop()});
|
| finishBoot(false, true);
|
| }).catch(function() { finishBoot(false, false); });
|
| });
|
| });
|
| }
|
|
|
| function finishBoot(camOk, micOk) {
|
| console.log('[Sentinel] finishBoot cam=' + camOk + ' mic=' + micOk);
|
| gel('permWait').classList.remove('show');
|
| gel('permWait').style.display = 'none';
|
| gel('app').classList.add('active');
|
|
|
| var recOk = initRec();
|
| autoListen = true;
|
| gel('tbMic').classList.add('on');
|
| gel('tbMic').innerHTML = '🔴 On';
|
| gel('modeBadge').textContent = mode.toUpperCase();
|
|
|
| if (recOk) setTimeout(startRec, 200);
|
|
|
| var g;
|
| if (!camOk && !recOk) {
|
| g = "I need camera and microphone access to help you safely. Please allow permissions and reload the page.";
|
| } else if (!recOk) {
|
| g = "The camera is on, but I cannot hear you yet. Allow microphone access, or type below.";
|
| } else if (resumed && D.nav) {
|
| g = "I am back. I still have " + D.nav + " saved. What do you need right now?";
|
| } else if (resumed) {
|
| g = "Welcome back. I am listening.";
|
| } else {
|
| g = "Hello, I am Sentinel. I can see through the camera and listen for your voice. Say, guide me, read my messages, or call someone.";
|
| }
|
| addMsg('ai', g);
|
| say(g);
|
| }
|
|
|
|
|
|
|
| var welcomeEl = gel('welcome');
|
| var goBtnEl = gel('goBtn');
|
|
|
| function isNoBootEvent(e) {
|
| return e && e.target && e.target.closest && e.target.closest('[data-no-boot]');
|
| }
|
|
|
| Array.prototype.forEach.call(document.querySelectorAll('[data-no-boot]'), function(el) {
|
| ['pointerdown', 'touchstart', 'mousedown', 'click'].forEach(function(type) {
|
| el.addEventListener(type, function(e) { e.stopPropagation(); }, { passive: true });
|
| });
|
| });
|
|
|
| goBtnEl.onclick = function() { boot(); };
|
| goBtnEl.ontouchstart = function(e) { e.stopPropagation(); boot(); };
|
| goBtnEl.onpointerdown = function(e) { e.stopPropagation(); boot(); };
|
|
|
| welcomeEl.onclick = function(e) { if (!isNoBootEvent(e)) boot(); };
|
| welcomeEl.ontouchstart = function(e) { if (!isNoBootEvent(e)) boot(); };
|
| welcomeEl.onpointerdown = function(e) { if (!isNoBootEvent(e)) boot(); };
|
|
|
|
|
| function bindBtn(id, fn) {
|
| var el = gel(id);
|
| var fired = false;
|
| el.ontouchstart = function(e) { e.preventDefault(); if(!fired){fired=true; fn(); setTimeout(function(){fired=false},300);} };
|
| el.onclick = function(e) { e.preventDefault(); if(!fired){fired=true; fn(); setTimeout(function(){fired=false},300);} };
|
| }
|
|
|
| bindBtn('tbMic', function() {
|
| autoListen = !autoListen;
|
| if (autoListen) { gel('tbMic').classList.add('on'); gel('tbMic').innerHTML='🔴 On'; startRec(); }
|
| else { gel('tbMic').classList.remove('on'); gel('tbMic').innerHTML='🎤 Listen'; stopRec(); dot('','Off'); }
|
| });
|
| bindBtn('tbLook', function() { handle('What do you see in front of me right now?'); });
|
| bindBtn('tbMode', function() {
|
| mode = mode==='assistant' ? 'navigation' : 'assistant';
|
| gel('modeBadge').textContent = mode.toUpperCase();
|
| gel('modeBadge').style.background = mode==='navigation' ? '#d97706' : 'var(--accent)';
|
| addMsg('sys', mode.toUpperCase()+' mode');
|
| say(mode+' mode activated.');
|
| });
|
| bindBtn('tbMute', function() {
|
| muted = !muted;
|
| gel('tbMute').innerHTML = muted ? '🔇' : '🔊';
|
| if (muted) shutUp();
|
| });
|
| bindBtn('tbSos', function() {
|
| addMsg('sys', 'Emergency call requested: 112');
|
| say('Emergency call requested. I would call one one two and share your location.');
|
| });
|
|
|
|
|
| function sendTxt() { var v=txt.value.trim(); if(!v)return; txt.value=''; stopRec(); handle(v); }
|
| gel('sendBtn').onclick = function(e) { e.preventDefault(); sendTxt(); };
|
| gel('sendBtn').ontouchstart = function(e) { e.preventDefault(); sendTxt(); };
|
| txt.addEventListener('keydown', function(e) { if(e.key==='Enter'){e.preventDefault();sendTxt();} });
|
|
|
|
|
| document.addEventListener('visibilitychange', function() {
|
| if (document.visibilityState==='visible' && booted && autoListen && !speaking && !busy) setTimeout(startRec, 500);
|
| });
|
|
|
| console.log('[Sentinel] Script loaded. Waiting for tap.');
|
| </script>
|
| </body>
|
| </html>
|
|
|