|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>Ollama / websocketd</title> |
|
<style> |
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
|
|
|
:root { |
|
--bg: #0d0d0f; |
|
--surface: #16161a; |
|
--surface2: #1e1e24; |
|
--border: #2a2a32; |
|
--text: #e8e8f0; |
|
--muted: #6b6b80; |
|
--accent: #7c6ff7; |
|
--accent-lo: #7c6ff718; |
|
--green: #4ade80; |
|
--yellow: #facc15; |
|
--red: #f87171; |
|
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace; |
|
--font-ui: system-ui, -apple-system, sans-serif; |
|
--radius: 10px; |
|
} |
|
|
|
body { |
|
background: var(--bg); color: var(--text); |
|
font-family: var(--font-ui); |
|
height: 100dvh; display: flex; flex-direction: column; overflow: hidden; |
|
} |
|
|
|
header { |
|
display: flex; align-items: center; gap: 10px; |
|
padding: 14px 20px; border-bottom: 1px solid var(--border); flex-shrink: 0; |
|
} |
|
.logo { |
|
width: 28px; height: 28px; border-radius: 7px; background: var(--accent); |
|
display: flex; align-items: center; justify-content: center; |
|
font-size: 13px; font-weight: 700; color: #fff; |
|
} |
|
header h1 { font-size: 14px; font-weight: 600; flex: 1; } |
|
|
|
.header-right { display: flex; align-items: center; gap: 8px; } |
|
|
|
#clear-btn { |
|
padding: 3px 10px; background: transparent; |
|
border: 1px solid var(--border); border-radius: 5px; color: var(--muted); |
|
font-family: var(--font-mono); font-size: 11px; cursor: pointer; |
|
transition: border-color 0.2s, color 0.2s; |
|
} |
|
#clear-btn:hover { border-color: var(--red); color: var(--red); } |
|
|
|
.status-pill { |
|
display: flex; align-items: center; gap: 6px; |
|
font-size: 12px; font-family: var(--font-mono); color: var(--muted); |
|
background: var(--surface); border: 1px solid var(--border); |
|
border-radius: 20px; padding: 4px 10px; |
|
} |
|
.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--red); transition: background 0.3s; } |
|
.dot.connected { background: var(--green); } |
|
.dot.connecting { background: var(--yellow); animation: pulse 1s infinite; } |
|
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} } |
|
|
|
#thread { |
|
flex: 1; overflow-y: auto; padding: 24px 20px; |
|
display: flex; flex-direction: column; gap: 20px; scroll-behavior: smooth; |
|
} |
|
#thread::-webkit-scrollbar { width: 4px; } |
|
#thread::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; } |
|
|
|
#empty { |
|
flex: 1; display: flex; flex-direction: column; |
|
align-items: center; justify-content: center; |
|
gap: 10px; color: var(--muted); font-size: 13px; text-align: center; |
|
} |
|
#empty svg { opacity: 0.25; } |
|
|
|
.msg { display: flex; flex-direction: column; gap: 4px; max-width: 700px; } |
|
.msg.user { align-self: flex-end; align-items: flex-end; } |
|
.msg.ollama { align-self: flex-start; align-items: flex-start; } |
|
.msg-label { |
|
font-size: 11px; font-family: var(--font-mono); |
|
color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; |
|
} |
|
.bubble { |
|
padding: 10px 14px; border-radius: var(--radius); |
|
font-size: 14px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; |
|
} |
|
.msg.user .bubble { background: var(--accent); color: #fff; border-bottom-right-radius: 3px; } |
|
.msg.ollama .bubble { |
|
background: var(--surface); border: 1px solid var(--border); |
|
color: var(--text); border-bottom-left-radius: 3px; |
|
font-family: var(--font-mono); font-size: 13px; |
|
} |
|
.cursor::after { |
|
content: "▋"; animation: blink 0.8s step-end infinite; |
|
color: var(--accent); margin-left: 1px; |
|
} |
|
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} } |
|
|
|
/* history counter badge */ |
|
.turn-count { |
|
font-size: 11px; font-family: var(--font-mono); color: var(--muted); |
|
padding: 2px 8px; background: var(--surface2); |
|
border: 1px solid var(--border); border-radius: 20px; |
|
align-self: center; flex-shrink: 0; |
|
} |
|
|
|
footer { padding: 12px 20px 16px; border-top: 1px solid var(--border); flex-shrink: 0; } |
|
.config-bar { |
|
display: flex; align-items: center; gap: 12px; padding: 0 0 10px; |
|
font-size: 12px; font-family: var(--font-mono); color: var(--muted); |
|
} |
|
.config-bar label { display: flex; align-items: center; gap: 5px; } |
|
.config-bar input[type="text"] { |
|
background: var(--surface2); border: 1px solid var(--border); |
|
color: var(--text); font-family: var(--font-mono); font-size: 12px; |
|
padding: 3px 8px; border-radius: 5px; width: 180px; outline: none; |
|
} |
|
.config-bar input[type="text"]:focus { border-color: var(--accent); } |
|
#reconnect-btn { |
|
margin-left: auto; padding: 3px 10px; background: transparent; |
|
border: 1px solid var(--border); border-radius: 5px; color: var(--muted); |
|
font-family: var(--font-mono); font-size: 11px; cursor: pointer; |
|
transition: border-color 0.2s, color 0.2s; |
|
} |
|
#reconnect-btn:hover { border-color: var(--accent); color: var(--accent); } |
|
|
|
.input-row { |
|
display: flex; gap: 8px; align-items: flex-end; |
|
background: var(--surface); border: 1px solid var(--border); |
|
border-radius: var(--radius); padding: 8px 8px 8px 14px; transition: border-color 0.2s; |
|
} |
|
.input-row:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-lo); } |
|
textarea { |
|
flex: 1; background: transparent; border: none; outline: none; resize: none; |
|
color: var(--text); font-family: var(--font-ui); font-size: 14px; line-height: 1.5; |
|
max-height: 140px; min-height: 24px; overflow-y: auto; |
|
} |
|
textarea::placeholder { color: var(--muted); } |
|
#send { |
|
flex-shrink: 0; width: 34px; height: 34px; border-radius: 7px; |
|
background: var(--accent); border: none; cursor: pointer; |
|
display: flex; align-items: center; justify-content: center; |
|
transition: opacity 0.15s, transform 0.1s; |
|
} |
|
#send:hover:not(:disabled) { opacity: 0.85; } |
|
#send:active:not(:disabled) { transform: scale(0.95); } |
|
#send:disabled { opacity: 0.35; cursor: default; } |
|
</style> |
|
</head> |
|
<body> |
|
|
|
<header> |
|
<div class="logo">Ol</div> |
|
<h1>Ollama via websocketd</h1> |
|
<div class="header-right"> |
|
<button id="clear-btn">clear history</button> |
|
<div class="status-pill"> |
|
<div class="dot" id="dot"></div> |
|
<span id="status-text">disconnected</span> |
|
</div> |
|
</div> |
|
</header> |
|
|
|
<div id="thread"> |
|
<div id="empty"> |
|
<svg width="38" height="38" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.2"> |
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/> |
|
</svg> |
|
<span>No messages yet. Say something.</span> |
|
</div> |
|
</div> |
|
|
|
<footer> |
|
<div class="config-bar"> |
|
<label>ws:// <input type="text" id="ws-host" value="localhost:9090" /></label> |
|
<button id="reconnect-btn">reconnect</button> |
|
</div> |
|
<div class="input-row"> |
|
<textarea id="prompt" rows="1" placeholder="Ask anything… (Enter to send, Shift+Enter for newline)"></textarea> |
|
<button id="send" aria-label="Send"> |
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"> |
|
<line x1="22" y1="2" x2="11" y2="13"/> |
|
<polygon points="22 2 15 22 11 13 2 9 22 2"/> |
|
</svg> |
|
</button> |
|
</div> |
|
</footer> |
|
|
|
<script> |
|
const thread = document.getElementById('thread'); |
|
const empty = document.getElementById('empty'); |
|
const promptEl = document.getElementById('prompt'); |
|
const sendBtn = document.getElementById('send'); |
|
const dot = document.getElementById('dot'); |
|
const statusText = document.getElementById('status-text'); |
|
const wsHostEl = document.getElementById('ws-host'); |
|
const reconnectBtn = document.getElementById('reconnect-btn'); |
|
const clearBtn = document.getElementById('clear-btn'); |
|
|
|
let ws = null; |
|
let streaming = false; |
|
let currentBubble = null; |
|
let currentText = ''; |
|
let reconnectTimer = null; |
|
let wsState = 'disconnected'; |
|
|
|
// history: [{role: 'user'|'assistant', content: '...'}] |
|
// This is the single source of truth — sent whole to the backend each turn |
|
let history = []; |
|
|
|
// ── status ─────────────────────────────────────────────────── |
|
function setDot(state) { |
|
wsState = state; |
|
dot.className = 'dot ' + state; |
|
statusText.textContent = |
|
state === 'connected' ? 'connected' : |
|
state === 'connecting' ? 'connecting…' : 'disconnected'; |
|
updateSendBtn(); |
|
} |
|
|
|
function updateSendBtn() { |
|
sendBtn.disabled = streaming || wsState !== 'connected'; |
|
} |
|
|
|
// ── connection ─────────────────────────────────────────────── |
|
function connect() { |
|
clearTimeout(reconnectTimer); |
|
if (ws) { |
|
ws.onopen = ws.onclose = ws.onerror = ws.onmessage = null; |
|
ws.close(); |
|
} |
|
const host = wsHostEl.value.trim() || 'localhost:9090'; |
|
setDot('connecting'); |
|
ws = new WebSocket('ws://' + host); |
|
ws.onopen = onOpen; |
|
ws.onclose = onClose; |
|
ws.onerror = onError; |
|
ws.onmessage = onMessage; |
|
} |
|
|
|
function onOpen() { setDot('connected'); promptEl.focus(); } |
|
function onError() { setDot('disconnected'); } |
|
|
|
function onClose() { |
|
setDot('disconnected'); |
|
if (streaming) finishStream(); // process exited mid-stream |
|
scheduleReconnect(); |
|
} |
|
|
|
function scheduleReconnect(delay = 300) { |
|
clearTimeout(reconnectTimer); |
|
reconnectTimer = setTimeout(connect, delay); |
|
} |
|
|
|
// ── messaging ──────────────────────────────────────────────── |
|
function onMessage(e) { |
|
const chunk = e.data; |
|
const isDone = chunk === '__DONE__' || chunk.endsWith('\n__DONE__') || chunk.endsWith('__DONE__'); |
|
|
|
if (isDone) { |
|
const real = chunk.replace(/__DONE__$/, '').replace(/\n$/, ''); |
|
if (real) appendToStream(real); |
|
finishStream(); |
|
scheduleReconnect(100); |
|
return; |
|
} |
|
|
|
appendToStream(chunk); |
|
} |
|
|
|
function appendToStream(text) { |
|
if (!currentBubble) return; |
|
currentText += text; |
|
currentBubble.textContent = currentText; |
|
currentBubble.classList.add('cursor'); |
|
thread.scrollTop = thread.scrollHeight; |
|
} |
|
|
|
function finishStream() { |
|
if (!streaming) return; |
|
if (currentBubble) currentBubble.classList.remove('cursor'); |
|
|
|
// Save completed assistant response into history |
|
if (currentText.trim()) { |
|
history.push({ role: 'assistant', content: currentText.trim() }); |
|
} |
|
|
|
streaming = false; |
|
currentBubble = null; |
|
currentText = ''; |
|
updateSendBtn(); |
|
promptEl.focus(); |
|
} |
|
|
|
// ── UI helpers ─────────────────────────────────────────────── |
|
function addMessage(role, text) { |
|
empty.style.display = 'none'; |
|
const msg = document.createElement('div'); |
|
msg.className = 'msg ' + role; |
|
const label = document.createElement('div'); |
|
label.className = 'msg-label'; |
|
label.textContent = role === 'user' ? 'you' : 'ollama'; |
|
const bubble = document.createElement('div'); |
|
bubble.className = 'bubble'; |
|
bubble.textContent = text; |
|
msg.appendChild(label); |
|
msg.appendChild(bubble); |
|
thread.appendChild(msg); |
|
thread.scrollTop = thread.scrollHeight; |
|
return bubble; |
|
} |
|
|
|
function addTurnDivider() { |
|
const turns = Math.floor(history.length / 2) + 1; |
|
const div = document.createElement('div'); |
|
div.className = 'turn-count'; |
|
div.textContent = `turn ${turns}`; |
|
thread.appendChild(div); |
|
} |
|
|
|
// ── send ───────────────────────────────────────────────────── |
|
function send() { |
|
const text = promptEl.value.trim(); |
|
if (!text || streaming) return; |
|
|
|
if (!ws || ws.readyState !== WebSocket.OPEN) { |
|
connect(); |
|
ws.addEventListener('open', () => sendNow(text), { once: true }); |
|
return; |
|
} |
|
sendNow(text); |
|
} |
|
|
|
function sendNow(text) { |
|
// Add user turn to history first |
|
history.push({ role: 'user', content: text }); |
|
|
|
addTurnDivider(); |
|
addMessage('user', text); |
|
promptEl.value = ''; |
|
promptEl.style.height = 'auto'; |
|
|
|
currentBubble = addMessage('ollama', ''); |
|
currentBubble.classList.add('cursor'); |
|
currentText = ''; |
|
streaming = true; |
|
updateSendBtn(); |
|
|
|
// Send full history array as JSON — backend deserializes and forwards to Ollama |
|
ws.send(JSON.stringify(history)); |
|
} |
|
|
|
// ── clear history ──────────────────────────────────────────── |
|
clearBtn.addEventListener('click', () => { |
|
history = []; |
|
thread.innerHTML = ''; |
|
thread.appendChild(empty); |
|
empty.style.display = ''; |
|
}); |
|
|
|
// ── events ─────────────────────────────────────────────────── |
|
promptEl.addEventListener('input', () => { |
|
promptEl.style.height = 'auto'; |
|
promptEl.style.height = Math.min(promptEl.scrollHeight, 140) + 'px'; |
|
}); |
|
promptEl.addEventListener('keydown', (e) => { |
|
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } |
|
}); |
|
sendBtn.addEventListener('click', send); |
|
reconnectBtn.addEventListener('click', connect); |
|
wsHostEl.addEventListener('change', connect); |
|
|
|
connect(); |
|
</script> |
|
</body> |
|
</html> |