diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js new file mode 100644 index 0000000..ec75622 --- /dev/null +++ b/src/main/resources/static/app.js @@ -0,0 +1,118 @@ +const chatState = { + sessionId: crypto.randomUUID(), + isWaiting: false +}; + +const dom = { + personality: document.getElementById('personality-select'), + input: document.getElementById('user-input'), + button: document.getElementById('send-button'), + window: document.getElementById('chat-window'), + typing: document.getElementById('typing-indicator'), + chips: document.querySelectorAll('.god-chip') +}; + +// ── God chip selection ────────────────────────────────────────── + +dom.chips.forEach(chip => { + chip.addEventListener('click', () => { + dom.chips.forEach(c => c.classList.remove('active')); + chip.classList.add('active'); + dom.personality.value = chip.dataset.god; + }); +}); + +// Set Heimdall as default active chip on load +const defaultChip = document.querySelector('.god-chip[data-god="HEIMDALL"]'); +if (defaultChip) defaultChip.classList.add('active'); +dom.personality.value = 'HEIMDALL'; + +// ── Chat ──────────────────────────────────────────────────────── + +async function sendMessage() { + const message = dom.input.value.trim(); + if (!message || chatState.isWaiting) return; + + const selectedPersonality = dom.personality.value; + const godName = getGodName(selectedPersonality); + + appendMessage('user', null, message); + dom.input.value = ''; + setLoading(true); + + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + let response; + + try { + response = await fetch('/api/v1/chat', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + personality: selectedPersonality, + message: message, + sessionId: chatState.sessionId + }), + signal: controller.signal + }); + } finally { + clearTimeout(timeoutId); + } + + if (!response.ok) throw new Error("Divine connection lost."); + + const aiText = await response.text(); + appendMessage('assistant', godName, aiText); + } catch (error) { + appendMessage('assistant', 'System', "Error: " + error.message); + } finally { + setLoading(false); + } +} + +function appendMessage(role, name, text) { + const msgDiv = document.createElement('div'); + msgDiv.className = `message ${role}`; + + if (role === 'assistant') { + const label = document.createElement('div'); + label.className = 'god-label'; + label.textContent = name; + msgDiv.appendChild(label); + msgDiv.appendChild(document.createTextNode(text)); + } else { + msgDiv.textContent = text; + } + + dom.window.appendChild(msgDiv); + dom.window.scrollTop = dom.window.scrollHeight; +} + +function setLoading(active) { + chatState.isWaiting = active; + dom.typing.classList.toggle('hidden', !active); + dom.button.disabled = active; +} + +function getGodName(value) { + const names = { + ODIN: 'Odin', + LOKI: 'Loki', + THOR: 'Thor', + FREYJA: 'Freyja', + HEIMDALL: 'Heimdall' + }; + return names[value] || value; +} + +// ── Event listeners ───────────────────────────────────────────── + +dom.button.addEventListener('click', sendMessage); +dom.input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + sendMessage().then(r => { + }).catch(err => console.error(err)); + } +}); \ No newline at end of file diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..ee4282a --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,67 @@ + + + + + + Bifrost AI | Mythological Gateway + + + + +
+ +
+
+
+
+ + + + + +
+
+

Asgard AI

+

SPEAK TO THE GODS

+
+ + + +
+ + +
+
+
+
Heimdall
+ I see all who approach the bridge. Who do you wish to speak with, mortal? +
+
+ +
+ +
+ + +
+
+ + +
+ + + + \ No newline at end of file diff --git a/src/main/resources/static/style.css b/src/main/resources/static/style.css new file mode 100644 index 0000000..04b88ad --- /dev/null +++ b/src/main/resources/static/style.css @@ -0,0 +1,293 @@ +:root { + --void: #080a0f; + --abyss: #0d1117; + --runic-gold: #c8973a; + --runic-gold-light: #e8b84b; + --silver: #cbd5e1; + --silver-dim: #64748b; + --user-bubble: #1e3a5f; + --god-bubble: #161d2e; +} + +*, *::before, *::after { + box-sizing: border-box; +} + +body { + background-color: var(--void); + color: var(--silver); + font-family: Georgia, "Times New Roman", serif; + margin: 0; + display: flex; + flex-direction: column; + align-items: center; + min-height: 100vh; +} + +.rune-border { + width: 100%; + height: 5px; + flex-shrink: 0; + background: linear-gradient(90deg, #8b6914, #c8973a, #e8b84b, #c8973a, #8b6914); +} + +.app-container { + width: 100%; + max-width: 900px; + display: flex; + flex-direction: column; + padding: 2rem; + flex: 1; +} + +/* ── Header ── */ + +header { + text-align: center; + border-bottom: 1px solid #2a1f0a; + margin-bottom: 0; + padding-bottom: 1.25rem; +} + +.header-ornament { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 0.6rem; +} + +.ornament-line { + flex: 1; + height: 1px; + background: linear-gradient(90deg, transparent, #c8973a66, #c8973a, #c8973a66, transparent); +} + +.valknut { + width: 28px; + height: 28px; + flex-shrink: 0; +} + +header h1 { + color: var(--runic-gold); + margin: 0; + font-size: 2rem; + font-weight: 400; + letter-spacing: 0.3em; + text-transform: uppercase; + text-shadow: 0 0 40px #c8973a33; +} + +header p { + color: var(--silver-dim); + font-size: 0.8rem; + letter-spacing: 0.2em; + text-transform: uppercase; + margin: 0.5rem 0 0; +} + +/* ── God selector ── */ + +.hidden-select { + display: none; +} + +.god-selector { + display: flex; + gap: 0.5rem; + padding: 0.85rem 0; + border-bottom: 1px solid #1a1f2e; + overflow-x: auto; + scrollbar-width: none; +} + +.god-selector::-webkit-scrollbar { + display: none; +} + +.god-chip { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.9rem; + border-radius: 999px; + font-size: 0.78rem; + letter-spacing: 0.04em; + cursor: pointer; + border: 1px solid #2d2410; + color: var(--silver-dim); + background: transparent; + font-family: Georgia, serif; + white-space: nowrap; + transition: all 0.2s; + user-select: none; +} + +.god-chip:hover { + border-color: #c8973a55; + color: var(--silver); +} + +.god-chip.active { + background: #1a1000; + border-color: var(--runic-gold); + color: var(--runic-gold); +} + +/* ── Main layout ── */ + +main { + display: flex; + flex-direction: column; + flex: 1; + gap: 1rem; +} + +#chat-container { + flex: 1; + display: flex; + flex-direction: column; +} + +#chat-window { + flex-grow: 1; + background: var(--abyss); + border-radius: 12px; + padding: 1.5rem; + overflow-y: auto; + height: 60vh; + border: 1px solid #1a1f2e; + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* ── Messages ── */ + +.message { + padding: 0.85rem 1.2rem; + border-radius: 12px; + max-width: 75%; + font-size: 0.9rem; + line-height: 1.65; + animation: fade-in 0.3s ease; +} + +@keyframes fade-in { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.message.user { + background: var(--user-bubble); + color: #c8d8f0; + margin-left: auto; + border-bottom-right-radius: 3px; + border: 1px solid #2a4a7f; +} + +.message.assistant { + background: var(--god-bubble); + color: var(--silver); + margin-right: auto; + border: 1px solid #2a1f0a; + border-left: 2px solid var(--runic-gold); + border-radius: 0; + border-top-right-radius: 12px; + border-bottom-right-radius: 12px; +} + +.god-label { + font-size: 0.68rem; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--runic-gold); + margin-bottom: 0.45rem; +} + +#typing-indicator { + color: var(--silver-dim); + font-size: 0.82rem; + font-style: italic; + letter-spacing: 0.05em; + padding: 0.5rem 0 0; +} + +/* ── Input area ── */ + +.input-area { + display: flex; + gap: 0.75rem; + align-items: flex-end; +} + +textarea { + flex: 1; + background: #111827; + border: 1px solid #2a1f0a; + color: var(--silver); + padding: 0.85rem 1rem; + border-radius: 10px; + outline: none; + font-family: Georgia, serif; + font-size: 0.9rem; + resize: none; + transition: border-color 0.2s; + line-height: 1.5; +} + +textarea:focus { + border-color: var(--runic-gold); + box-shadow: 0 0 0 1px #c8973a33; +} + +textarea::placeholder { + color: var(--silver-dim); + opacity: 0.6; +} + +button { + background: #1a1000; + color: var(--runic-gold); + border: 1px solid var(--runic-gold); + padding: 0.75rem 1.5rem; + border-radius: 10px; + font-family: Georgia, serif; + font-size: 0.85rem; + font-weight: 400; + letter-spacing: 0.1em; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +button:hover { + background: #2a1a00; + border-color: var(--runic-gold-light); + color: var(--runic-gold-light); +} + +button:active { + transform: scale(0.98); +} + +button:disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; +} + +/* ── Footer ── */ + +.rune-footer { + text-align: center; + padding: 0.6rem; + font-size: 0.8rem; + letter-spacing: 0.5em; + color: #2a2010; + margin-top: auto; +} + +.hidden { + display: none; +} \ No newline at end of file