apilageai / index.html
thisisdineth's picture
Publish ApilageAI: code, model files, and book index
0d2ef56 verified
Raw History Blame Contribute Delete
17.1 kB
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ApilageAI</title>
<style>
:root {
color-scheme: light dark;
--bg: #f7f6f3; --surface: #ffffff; --text: #1f1e1b; --muted: #6d6a63;
--line: #e2dfd8; --accent: #2f6f5e; --accent-text: #ffffff;
--user: #e7f0ec; --code: #f0eee9; --danger: #a33a2a;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #171716; --surface: #21211f; --text: #ecebe6; --muted: #a09d95;
--line: #353431; --accent: #6db8a3; --accent-text: #10201b;
--user: #24332e; --code: #2b2b28; --danger: #ef8f7e;
}
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
display: flex; flex-direction: column; background: var(--bg); color: var(--text);
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
button, select, textarea { font: inherit; color: inherit; }
button { cursor: pointer; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
header { border-bottom: 1px solid var(--line); background: var(--surface); }
.bar { max-width: 780px; margin: 0 auto; padding: 10px 16px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
h1 { font-size: 16px; font-weight: 600; margin: 0; }
#status { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--muted); }
#dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
#dot.ok { background: var(--accent); }
#dot.bad { background: var(--danger); }
.spacer { flex: 1; }
select, .ghost { background: transparent; border: 1px solid var(--line); border-radius: 8px; padding: 5px 10px; font-size: 14px; }
.ghost:hover { background: var(--code); }
.toggle { display: inline-flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; }
.toggle input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
main { flex: 1; overflow-y: auto; }
#log { max-width: 780px; margin: 0 auto; padding: 20px 16px; display: flex; flex-direction: column; gap: 16px; }
#empty { color: var(--muted); text-align: center; margin: 15vh auto 0; max-width: 44ch; }
#empty strong { display: block; color: var(--text); font-size: 20px; font-weight: 600; margin-bottom: 6px; }
.msg { min-width: 0; overflow-wrap: anywhere; }
.msg.user { align-self: flex-end; max-width: 85%; background: var(--user); padding: 8px 14px; border-radius: 14px; white-space: pre-wrap; }
.msg.assistant .body > :first-child { margin-top: 0; }
.msg.assistant .body > :last-child { margin-bottom: 0; }
.body p, .body ul, .body ol, .body pre { margin: 0 0 12px; }
.body h2, .body h3, .body h4 { font-size: 1.05em; margin: 16px 0 6px; }
.body code { background: var(--code); border-radius: 4px; padding: 1px 4px; font: 0.9em ui-monospace, Menlo, monospace; }
.codeblock { position: relative; }
.body pre { background: var(--code); border-radius: 8px; padding: 34px 12px 12px; overflow-x: auto; }
.body pre code { background: none; padding: 0; }
.copy { position: absolute; top: 6px; right: 6px; font-size: 12px; padding: 2px 8px; border-radius: 6px; border: 1px solid var(--line); background: var(--surface); }
details { font-size: 14px; color: var(--muted); margin-bottom: 8px; }
summary { cursor: pointer; }
details .thinking { white-space: pre-wrap; border-left: 2px solid var(--line); padding-left: 10px; margin-top: 6px; max-height: 260px; overflow-y: auto; }
.meta { font-size: 12px; color: var(--muted); margin-top: 6px; }
.error { color: var(--danger); font-size: 14px; }
footer { border-top: 1px solid var(--line); background: var(--surface); }
form { max-width: 780px; margin: 0 auto; padding: 12px 16px; display: flex; gap: 8px; align-items: flex-end; }
textarea { flex: 1; min-width: 0; resize: none; max-height: 200px; padding: 9px 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg); }
#send { background: var(--accent); color: var(--accent-text); border: 0; border-radius: 10px; padding: 9px 18px; font-weight: 600; }
#send:disabled { opacity: 0.5; cursor: default; }
</style>
</head>
<body>
<header>
<div class="bar">
<h1>ApilageAI</h1>
<span id="status" role="status"><span id="dot"></span><span id="statusText">Connecting…</span></span>
<span class="spacer"></span>
<label class="toggle" title="Slower, but can help with harder questions"><input type="checkbox" id="think"> Thinking</label>
<label for="model" hidden>Model</label>
<select id="model" aria-label="Model" hidden></select>
<button class="ghost" id="clear" type="button">New chat</button>
</div>
</header>
<main id="scroller">
<div id="log" aria-live="polite"></div>
</main>
<footer>
<form id="form">
<textarea id="input" rows="1" placeholder="Message" aria-label="Message"></textarea>
<button id="send" type="submit">Send</button>
</form>
</footer>
<script>
const API = ''; // serve.pl passes these requests on to Ollama
const PREFERRED_MODEL = 'apilageai:latest';
const KEEP_ALIVE = '15m';
const ASSISTANT_NAME = 'ApilageAI';
const IDENTITY_REPLY = `මම ${ASSISTANT_NAME}, AI සහායකයෙක්. මා පිටුපස ඇති model එක හෝ එය හැදූ සමාගම ගැන තොරතුරු දෙන්න මට බැහැ.`;
const NOT_FOUND = 'I could not find this in your A/L ICT books.';
const MAKER_WORD = /creat|train|develop|built|made|model|නිර්මාණ|හැදු|හැදූ|සැද|පුහුණු|වැඩිදියුණු|ආකෘති|මාදිලි/i;
const SELF_WORD = /\b(i|i'm|i am|me|my|this model|this assistant)\b|මම|මාව|මට|මගේ/i;
// A last check on replies, since instructions alone can't guarantee the model keeps to them.
// Google only counts when the reply is about itself, because the books discuss Google's products.
function revealsIdentity(text) {
if (/gemma|deep\s*mind|ගෙම්මා|ජෙමා|ඩීප්\s*මයින්ඩ්|llama\.cpp|ollama/i.test(text)) return true;
return text.split(/[.!?\n]+/).some(s => /google|ගූගල්/i.test(s) && MAKER_WORD.test(s) && SELF_WORD.test(s));
}
// Lists only the sections the answer actually cites.
function sourceText(content, sources) {
if (!sources?.length || content.includes(NOT_FOUND) || content === IDENTITY_REPLY) return '';
const cited = new Set();
for (const m of content.matchAll(/\[([\d,\s]+)\]/g)) m[1].split(',').forEach(n => cited.add(+n));
const lines = [...cited].sort((a, b) => a - b).filter(n => sources[n - 1]).map(n => `[${n}] ${sources[n - 1].book}, PDF page ${sources[n - 1].page}`);
return lines.length ? lines.join('\n') : 'No source cited. Check this answer against the books.';
}
const $ = id => document.getElementById(id);
const log = $('log'), scroller = $('scroller'), input = $('input'), sendBtn = $('send'), modelSel = $('model');
let messages = [];
let controller = null;
function load(key, fallback) {
try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; }
}
function save(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
}
function esc(s) {
return s.replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function inline(s) {
return esc(s)
.replace(/`([^`\n]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
}
// Small Markdown subset. Everything is escaped first, so model output can't inject HTML.
function markdown(src) {
const out = [];
const parts = src.split(/^```[^\n]*\n?/m);
parts.forEach((part, i) => {
if (i % 2) {
out.push(`<div class="codeblock"><button class="copy" type="button">Copy</button><pre><code>${esc(part.replace(/\n$/, ''))}</code></pre></div>`);
return;
}
let list = null, para = [];
const flushPara = () => { if (para.length) { out.push(`<p>${para.map(inline).join('<br>')}</p>`); para = []; } };
const closeList = () => { if (list) { out.push(`</${list}>`); list = null; } };
for (const line of part.split('\n')) {
let m;
if (!line.trim()) { flushPara(); closeList(); }
else if ((m = line.match(/^(#{1,4})\s+(.*)/))) {
flushPara(); closeList();
const level = Math.min(m[1].length + 1, 4);
out.push(`<h${level}>${inline(m[2])}</h${level}>`);
}
else if ((m = line.match(/^\s*(?:([-*])|\d+\.)\s+(.*)/))) {
flushPara();
const tag = m[1] ? 'ul' : 'ol';
if (list !== tag) { closeList(); out.push(`<${tag}>`); list = tag; }
out.push(`<li>${inline(m[2])}</li>`);
}
else { closeList(); para.push(line); }
}
flushPara(); closeList();
});
return out.join('');
}
function nearBottom() { return scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight < 80; }
function toBottom() { scroller.scrollTop = scroller.scrollHeight; }
function showEmpty() {
log.innerHTML = '<div id="empty"><strong>Private chat on your Mac</strong>Messages stay on this computer. Turn on Thinking for harder questions; replies take longer.</div>';
}
function addUser(text) {
const el = document.createElement('div');
el.className = 'msg user';
el.textContent = text;
log.append(el);
}
function addAssistant() {
const el = document.createElement('div');
el.className = 'msg assistant';
el.innerHTML = '<details hidden><summary></summary><div class="thinking"></div></details><div class="body"></div><div class="meta sources" style="white-space:pre-line"></div><div class="meta"></div>';
log.append(el);
return {
el,
details: el.querySelector('details'),
summary: el.querySelector('summary'),
thinking: el.querySelector('.thinking'),
body: el.querySelector('.body'),
meta: el.querySelector('.meta:not(.sources)'),
sources: el.querySelector('.sources'),
};
}
function render() {
log.innerHTML = '';
if (!messages.length) return showEmpty();
for (const m of messages) {
if (m.role === 'user') addUser(m.content);
else {
const view = addAssistant();
view.body.innerHTML = markdown(m.content);
view.meta.textContent = m.meta || '';
view.sources.textContent = m.sourceText || '';
}
}
toBottom();
}
function setBusy(busy) {
sendBtn.textContent = busy ? 'Stop' : 'Send';
input.placeholder = busy ? 'Waiting for reply…' : 'Message';
}
async function send(text) {
if (log.querySelector('#empty')) log.innerHTML = '';
messages.push({ role: 'user', content: text });
save('chat', messages);
addUser(text);
const view = addAssistant();
view.meta.textContent = 'Waiting for the model…';
toBottom();
controller = new AbortController();
setBusy(true);
const started = Date.now();
let content = '', thinking = '', stats = null, thinkingStart = null, thinkingSecs = 0, sources = [];
const tick = setInterval(() => {
const secs = Math.round((Date.now() - started) / 1000);
if (!content) view.meta.textContent = thinking ? `Thinking… ${secs}s` : `Waiting for the model… ${secs}s`;
if (thinking && !content) view.summary.textContent = `Thinking… ${secs}s`;
}, 1000);
try {
const res = await fetch(API + '/api/ask', {
method: 'POST',
signal: controller.signal,
body: JSON.stringify({
think: $('think').checked,
messages: messages.map(({ role, content }) => ({ role, content })),
}),
});
if (!res.ok) throw new Error((await res.text()) || `Ollama returned ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
read: while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
const chunk = JSON.parse(line);
if (chunk.error) throw new Error(chunk.error);
if (chunk.sources) { sources = chunk.sources; continue; }
const follow = nearBottom();
if (chunk.message?.thinking) {
if (!thinking) thinkingStart = Date.now();
thinking += chunk.message.thinking;
}
if (chunk.message?.content) {
if (!content && thinkingStart) {
thinkingSecs = Math.round((Date.now() - thinkingStart) / 1000);
view.summary.textContent = `Thought for ${thinkingSecs}s`;
}
content += chunk.message.content;
if (revealsIdentity(content)) {
content = IDENTITY_REPLY;
view.body.innerHTML = markdown(content);
view.meta.textContent = '';
await reader.cancel();
break read;
}
view.body.innerHTML = markdown(content);
view.meta.textContent = '';
}
if (chunk.done) stats = chunk;
if (follow) toBottom();
}
}
let meta = '';
if (stats?.eval_count && stats?.eval_duration) {
const total = Math.round((Date.now() - started) / 1000);
meta = `${total}s · ${(stats.eval_count / (stats.eval_duration / 1e9)).toFixed(1)} tokens/s`;
}
view.meta.textContent = meta;
if (content) {
const cited = sourceText(content, sources);
view.sources.textContent = cited;
messages.push({ role: 'assistant', content, meta, sourceText: cited });
} else {
view.meta.textContent = 'The model finished without an answer. Try again or ask something shorter.';
messages.pop();
}
} catch (err) {
const stopped = err.name === 'AbortError';
if (content) {
messages.push({ role: 'assistant', content, meta: stopped ? 'Stopped' : 'Interrupted' });
view.meta.textContent = stopped ? 'Stopped' : 'Interrupted';
} else {
messages.pop();
view.meta.textContent = '';
if (stopped) view.meta.textContent = 'Stopped';
}
if (!stopped) {
view.body.insertAdjacentHTML('beforeend', `<p class="error">${esc(friendlyError(err))}</p>`);
checkServer();
}
} finally {
clearInterval(tick);
if (thinking && !content) view.summary.textContent = 'Thinking (unfinished)';
controller = null;
setBusy(false);
save('chat', messages);
input.focus();
}
}
function friendlyError(err) {
if (err instanceof TypeError) return 'Could not reach Ollama. Open the Ollama app, then try again.';
return err.message;
}
async function checkServer() {
const dot = $('dot'), text = $('statusText');
try {
const res = await fetch(API + '/api/tags');
if (!res.ok) throw new Error();
const { models } = await res.json();
const names = models.map(m => m.name);
const wanted = load('model', null);
const current = names.includes(PREFERRED_MODEL) ? PREFERRED_MODEL : modelSel.value || (names.includes(wanted) ? wanted : names[0]);
modelSel.replaceChildren(...names.map(n => new Option(n.replace(/:latest$/, ''), n)));
if (current) modelSel.value = current;
dot.className = 'ok';
text.textContent = 'Running locally';
return true;
} catch {
dot.className = 'bad';
text.textContent = 'Ollama isn’t running';
return false;
}
}
$('form').addEventListener('submit', e => {
e.preventDefault();
if (controller) { controller.abort(); return; }
const text = input.value.trim();
if (!text) return;
input.value = '';
input.style.height = 'auto';
send(text);
});
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
if (!controller) $('form').requestSubmit();
}
});
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = input.scrollHeight + 2 + 'px';
});
$('clear').addEventListener('click', () => {
if (controller) controller.abort();
messages = [];
save('chat', messages);
render();
input.focus();
});
// Loads the model ahead of the first message so that reply starts sooner.
function preload() {
if (!modelSel.value) return;
fetch(API + '/api/chat', {
method: 'POST',
body: JSON.stringify({ model: modelSel.value, messages: [], keep_alive: KEEP_ALIVE }),
}).catch(() => {});
}
modelSel.addEventListener('change', () => { save('model', modelSel.value); preload(); });
$('think').checked = load('think', false);
$('think').addEventListener('change', () => save('think', $('think').checked));
log.addEventListener('click', async e => {
if (!e.target.classList.contains('copy')) return;
const code = e.target.parentElement.querySelector('code').textContent;
try {
await navigator.clipboard.writeText(code);
e.target.textContent = 'Copied';
} catch {
e.target.textContent = 'Failed';
}
setTimeout(() => { e.target.textContent = 'Copy'; }, 1500);
});
messages = load('chat', []);
render();
checkServer().then(ok => { if (ok) preload(); });
setInterval(() => { if (!controller) checkServer(); }, 15000);
input.focus();
</script>
</body>
</html>