Download index.html from thisisdineth/apilageai: direct link, hf CLI and curl.
- Browser
- Download file 17.1 kB
-
https://huggingface.co/thisisdineth/apilageai/resolve/main/index.html
- Command line
-
hf download hf://thisisdineth/apilageai/index.html
-
curl -L -o index.html https://huggingface.co/thisisdineth/apilageai/resolve/main/index.html
17.1 kB
| <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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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> | |