Ana / index.html
OrbitMC's picture
Create index.html
7ef6c85 verified
<!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>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Share+Tech+Mono&family=Orbitron:wght@400;700;900&display=swap" rel="stylesheet"/>
<style>
:root {
--bg: #020b14;
--panel: #040f1c;
--border: #0a3a5c;
--glow: #00aaff;
--glow2: #00ffcc;
--text: #c8e8ff;
--dim: #3a6080;
--user-bg: #001a2e;
--ai-bg: #001228;
--danger: #ff3860;
--font-hud: 'Orbitron', monospace;
--font-mono: 'Share Tech Mono', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg);
color: var(--text);
font-family: var(--font-mono);
overflow: hidden;
}
/* ── animated grid background ── */
body::before {
content: '';
position: fixed; inset: 0;
background-image:
linear-gradient(rgba(0,170,255,.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,170,255,.04) 1px, transparent 1px);
background-size: 40px 40px;
animation: gridScroll 20s linear infinite;
pointer-events: none;
z-index: 0;
}
@keyframes gridScroll {
from { background-position: 0 0; }
to { background-position: 40px 40px; }
}
/* ── scanlines ── */
body::after {
content: '';
position: fixed; inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0,0,0,.15) 2px,
rgba(0,0,0,.15) 4px
);
pointer-events: none;
z-index: 0;
}
/* ── layout ── */
#shell {
position: relative; z-index: 1;
display: flex; flex-direction: column;
height: 100vh;
max-width: 860px;
margin: 0 auto;
padding: 16px 16px 0;
}
/* ── header ── */
header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 18px;
border: 1px solid var(--border);
background: var(--panel);
margin-bottom: 10px;
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 0 100%);
}
.logo {
font-family: var(--font-hud);
font-weight: 900;
font-size: 1.3rem;
letter-spacing: .15em;
color: var(--glow);
text-shadow: 0 0 12px var(--glow), 0 0 30px rgba(0,170,255,.3);
}
.logo span { color: var(--glow2); text-shadow: 0 0 12px var(--glow2); }
.status-row { display: flex; gap: 12px; align-items: center; }
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: var(--dim);
transition: background .4s, box-shadow .4s;
}
.status-dot.online {
background: var(--glow2);
box-shadow: 0 0 8px var(--glow2);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,100% { opacity: 1; }
50% { opacity: .4; }
}
.status-label {
font-size: .65rem;
letter-spacing: .1em;
color: var(--dim);
font-family: var(--font-hud);
}
.status-dot.online + .status-label { color: var(--glow2); }
.btn-icon {
background: transparent;
border: 1px solid var(--border);
color: var(--dim);
padding: 5px 10px;
font-family: var(--font-hud);
font-size: .6rem;
letter-spacing: .1em;
cursor: pointer;
transition: all .2s;
}
.btn-icon:hover {
border-color: var(--glow);
color: var(--glow);
box-shadow: 0 0 8px rgba(0,170,255,.3);
}
.btn-icon.active {
border-color: var(--glow2);
color: var(--glow2);
box-shadow: 0 0 8px rgba(0,255,204,.3);
}
/* ── boot screen ── */
#boot {
flex: 1;
display: flex; flex-direction: column; justify-content: center; align-items: center;
gap: 8px;
}
.boot-line {
font-size: .78rem;
color: var(--glow);
opacity: 0;
animation: fadeIn .3s forwards;
}
.boot-line.dim { color: var(--dim); }
.boot-bar {
width: 300px; height: 2px;
background: var(--border);
margin-top: 16px;
overflow: hidden;
}
.boot-fill {
height: 100%;
background: linear-gradient(90deg, var(--glow), var(--glow2));
width: 0%;
transition: width .4s ease;
box-shadow: 0 0 8px var(--glow);
}
@keyframes fadeIn { to { opacity: 1; } }
/* ── messages ── */
#messages {
flex: 1;
overflow-y: auto;
display: none;
flex-direction: column;
gap: 10px;
padding: 4px 2px 12px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
#messages.visible { display: flex; }
.msg {
display: flex;
gap: 10px;
animation: msgIn .25s ease;
}
@keyframes msgIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.msg.user { justify-content: flex-end; }
.avatar {
width: 30px; height: 30px; flex-shrink: 0;
border: 1px solid var(--border);
display: flex; align-items: center; justify-content: center;
font-family: var(--font-hud);
font-size: .55rem;
color: var(--glow);
background: var(--panel);
clip-path: polygon(0 0, 80% 0, 100% 20%, 100% 100%, 0 100%);
}
.bubble {
max-width: 75%;
padding: 10px 14px;
font-size: .82rem;
line-height: 1.55;
border: 1px solid var(--border);
background: var(--ai-bg);
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%);
}
.msg.user .bubble {
background: var(--user-bg);
border-color: #0a2a44;
clip-path: polygon(10px 0, 100% 0, 100% 100%, 0 100%, 0 10px);
color: #90c8f0;
}
.msg.ai .bubble {
border-color: var(--border);
color: var(--text);
}
.typing-dots span {
display: inline-block;
width: 5px; height: 5px; border-radius: 50%;
background: var(--glow);
margin: 0 2px;
animation: blink 1.2s ease-in-out infinite;
}
.typing-dots span:nth-child(2) { animation-delay: .2s; }
.typing-dots span:nth-child(3) { animation-delay: .4s; }
@keyframes blink { 0%,80%,100% { opacity: .2; } 40% { opacity: 1; } }
/* ── input bar ── */
#input-bar {
display: none;
gap: 8px;
padding: 10px 0 14px;
align-items: flex-end;
}
#input-bar.visible { display: flex; }
#user-input {
flex: 1;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
font-size: .82rem;
padding: 10px 14px;
resize: none;
outline: none;
min-height: 42px; max-height: 140px;
transition: border-color .2s, box-shadow .2s;
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 0 100%);
}
#user-input:focus {
border-color: var(--glow);
box-shadow: 0 0 12px rgba(0,170,255,.2);
}
#user-input::placeholder { color: var(--dim); }
#send-btn {
background: linear-gradient(135deg, #003a5c, #001a2e);
border: 1px solid var(--glow);
color: var(--glow);
font-family: var(--font-hud);
font-size: .65rem;
letter-spacing: .12em;
padding: 10px 18px;
cursor: pointer;
height: 42px;
transition: all .2s;
clip-path: polygon(0 0, 80% 0, 100% 20%, 100% 100%, 0 100%);
}
#send-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #004a7a, #002244);
box-shadow: 0 0 16px rgba(0,170,255,.4);
}
#send-btn:disabled { opacity: .4; cursor: not-allowed; }
/* ── corner decoration ── */
.corner-deco {
position: fixed;
width: 60px; height: 60px;
opacity: .15;
pointer-events: none;
}
.corner-deco.tl { top: 0; left: 0;
border-top: 2px solid var(--glow); border-left: 2px solid var(--glow); }
.corner-deco.br { bottom: 0; right: 0;
border-bottom: 2px solid var(--glow2); border-right: 2px solid var(--glow2); }
/* scrollbar */
#messages::-webkit-scrollbar { width: 4px; }
#messages::-webkit-scrollbar-track { background: transparent; }
#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
</style>
</head>
<body>
<div class="corner-deco tl"></div>
<div class="corner-deco br"></div>
<div id="shell">
<!-- header -->
<header>
<div class="logo">J.<span>A</span>.R.V.I.S</div>
<div class="status-row">
<div class="status-dot" id="status-dot"></div>
<span class="status-label" id="status-label">INITIALIZING</span>
<button class="btn-icon" id="tts-btn" title="Toggle voice">VOICE</button>
<button class="btn-icon" id="clear-btn" title="Clear chat">CLEAR</button>
<button class="btn-icon" id="save-btn" title="Save chat">SAVE</button>
</div>
</header>
<!-- boot screen -->
<div id="boot"></div>
<!-- messages -->
<div id="messages"></div>
<!-- input -->
<div id="input-bar">
<textarea id="user-input" rows="1" placeholder="Enter query..."></textarea>
<button id="send-btn" disabled>SEND</button>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
// ── State ──
let history = []; // [[user, assistant], ...]
let ttsOn = false;
let busy = false;
let modelReady = false;
const statusDot = $('status-dot');
const statusLabel = $('status-label');
const messagesEl = $('messages');
const inputBar = $('input-bar');
const bootEl = $('boot');
const sendBtn = $('send-btn');
const inputEl = $('user-input');
const ttsBtn = $('tts-btn');
// ── Boot sequence ──
const bootLines = [
{ text: '// INITIALIZING NEURAL CORE', dim: false },
{ text: '// LOADING LANGUAGE MODEL', dim: false },
{ text: '// ESTABLISHING VECTOR INDEX', dim: false },
{ text: '// VOICE SYNTHESIZER STANDBY', dim: true },
{ text: '// AWAITING SERVER HANDSHAKE', dim: false },
];
async function runBoot() {
const bar = document.createElement('div');
bar.className = 'boot-bar';
const fill = document.createElement('div');
fill.className = 'boot-fill';
bar.appendChild(fill);
for (let i = 0; i < bootLines.length; i++) {
const l = bootLines[i];
const el = document.createElement('div');
el.className = 'boot-line' + (l.dim ? ' dim' : '');
el.style.animationDelay = (i * 0.12) + 's';
el.textContent = l.text;
bootEl.appendChild(el);
await sleep(130);
}
bootEl.appendChild(bar);
await sleep(100);
// Poll /health until server is ready
while (true) {
try {
const r = await fetch('/health');
if (r.ok) {
const d = await r.json();
if (d.llm) break;
}
} catch (_) {}
fill.style.width = (Math.min(parseInt(fill.style.width || '0') + 8, 85)) + '%';
await sleep(600);
}
fill.style.width = '100%';
await sleep(400);
// Transition to chat UI
bootEl.style.display = 'none';
messagesEl.classList.add('visible');
inputBar.classList.add('visible');
statusDot.classList.add('online');
statusLabel.textContent = 'ONLINE';
sendBtn.disabled = false;
modelReady = true;
inputEl.focus();
}
// ── Helpers ──
const sleep = ms => new Promise(r => setTimeout(r, ms));
function scrollBottom() {
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function addMsg(role, text) {
const wrapper = document.createElement('div');
wrapper.className = 'msg ' + role;
if (role === 'ai') {
const av = document.createElement('div');
av.className = 'avatar';
av.textContent = 'AI';
wrapper.appendChild(av);
}
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.textContent = text;
wrapper.appendChild(bubble);
if (role === 'user') {
const av = document.createElement('div');
av.className = 'avatar';
av.textContent = 'YOU';
wrapper.appendChild(av);
}
messagesEl.appendChild(wrapper);
scrollBottom();
return bubble;
}
function addTyping() {
const wrapper = document.createElement('div');
wrapper.className = 'msg ai';
wrapper.id = 'typing';
const av = document.createElement('div');
av.className = 'avatar';
av.textContent = 'AI';
wrapper.appendChild(av);
const bubble = document.createElement('div');
bubble.className = 'bubble';
bubble.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div>';
wrapper.appendChild(bubble);
messagesEl.appendChild(wrapper);
scrollBottom();
return wrapper;
}
function removeTyping() {
const t = $('typing');
if (t) t.remove();
}
// ── Send message (streaming) ──
async function send() {
const text = inputEl.value.trim();
if (!text || busy || !modelReady) return;
busy = true;
sendBtn.disabled = true;
inputEl.value = '';
inputEl.style.height = 'auto';
addMsg('user', text);
const typingEl = addTyping();
let reply = '';
try {
const res = await fetch('/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text, history })
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let aiBubble = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const raw = decoder.decode(value, { stream: true });
for (const line of raw.split('\n')) {
if (!line.startsWith('data:')) continue;
const payload = line.slice(5).trim();
if (payload === '[DONE]') break;
try {
const piece = JSON.parse(payload);
if (!aiBubble) {
removeTyping();
aiBubble = addMsg('ai', '');
}
reply += piece;
aiBubble.textContent = reply;
scrollBottom();
} catch (_) {}
}
}
} catch (err) {
removeTyping();
addMsg('ai', '[Error: ' + err.message + ']');
busy = false;
sendBtn.disabled = false;
return;
}
removeTyping();
if (!reply) addMsg('ai', '[No response]');
reply = reply.trim();
if (reply) {
history.push([text, reply]);
if (history.length > 20) history.shift();
if (ttsOn) speakText(reply);
}
busy = false;
sendBtn.disabled = false;
inputEl.focus();
}
// ── TTS ──
async function speakText(text) {
try {
const res = await fetch('/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
});
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const audio = new Audio(url);
audio.play();
} catch (_) {}
}
// ── Controls ──
sendBtn.addEventListener('click', send);
inputEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); }
});
inputEl.addEventListener('input', () => {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 140) + 'px';
});
ttsBtn.addEventListener('click', () => {
ttsOn = !ttsOn;
ttsBtn.classList.toggle('active', ttsOn);
ttsBtn.textContent = ttsOn ? 'VOICE ON' : 'VOICE';
});
$('clear-btn').addEventListener('click', () => {
history = [];
messagesEl.innerHTML = '';
addMsg('ai', 'Memory cleared. How can I assist you?');
});
$('save-btn').addEventListener('click', async () => {
try {
const res = await fetch('/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ history })
});
const d = await res.json();
addMsg('ai', d.saved ? 'Chat session saved to disk.' : 'Nothing to save.');
} catch (_) {
addMsg('ai', 'Save failed.');
}
});
// ── Start ──
runBoot();
</script>
</body>
</html>