Prompt App Template
A minimal AI chat interface powered by nomad.ai. Type a message and get a streaming response from your local AI runtime. Edit ai/system.md to give the assistant a persona, and update index.json to set the model.
Source
Section titled “Source”<!doctype html><html><head> <meta charset="utf-8"> <title>Prompt App</title> <link rel="icon" type="image/png" sizes="32x32" href="/thumb.png"> <style> *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-size: 14px; background: #f5f5f7; color: #1d1d1f; display: flex; flex-direction: column; height: 100vh; }
header { padding: 10px 16px; background: #fff; border-bottom: 1px solid #e5e5ea; display: flex; align-items: center; gap: 10px; }
header span { font-weight: 600; font-size: 15px; flex: 1; }
header .session-id { font-size: 11px; color: #999; font-family: monospace; }
header button { font-size: 12px; padding: 4px 10px; border: 1px solid #d1d1d6; border-radius: 6px; background: #fff; cursor: pointer; color: #444; }
header button:hover { background: #f5f5f7; }
#messages { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px; }
.message { max-width: 680px; width: fit-content; padding: 10px 14px; border-radius: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
.message.user { background: #0a84ff; color: #fff; align-self: flex-end; border-bottom-right-radius: 4px; }
.message.assistant { background: #fff; border: 1px solid #e5e5ea; align-self: flex-start; border-bottom-left-radius: 4px; }
.message.assistant.streaming::after { content: '▋'; animation: blink 0.8s step-end infinite; margin-left: 2px; opacity: 0.6; }
@keyframes blink { 0%, 100% { opacity: 0.6; } 50% { opacity: 0; } }
.message.error { background: #fff0f0; border: 1px solid #ffcccc; color: #c0392b; align-self: flex-start; border-bottom-left-radius: 4px; }
#input-row { display: flex; gap: 8px; padding: 14px 20px; background: #fff; border-top: 1px solid #e5e5ea; }
#prompt { flex: 1; padding: 9px 12px; border: 1px solid #d1d1d6; border-radius: 8px; font-size: 14px; font-family: inherit; resize: none; outline: none; height: 40px; line-height: 1.4; transition: border-color 0.15s; }
#prompt:focus { border-color: #0a84ff; }
#send { padding: 0 16px; background: #0a84ff; color: #fff; border: none; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; height: 40px; transition: background 0.15s; }
#send:hover { background: #0071e3; } #send:disabled { background: #a0c4ff; cursor: default; } </style></head><body> <header> <span>Prompt App</span> <span class="session-id" id="session-label"></span> <button id="new-session">New chat</button> </header> <div id="messages"></div> <div id="input-row"> <textarea id="prompt" placeholder="Ask something…" rows="1"></textarea> <button id="send">Send</button> </div>
<script> const messagesEl = document.getElementById('messages') const promptEl = document.getElementById('prompt') const sendEl = document.getElementById('send') const labelEl = document.getElementById('session-label')
let sessionId = null let history = []
// alias to avoid shadowing the `history` messages array const history_ = window.history
// Scoped to the current drive — plain paths work on drive instances const drive = nomad.fs.drive(location.href)
// — Session ID —
function newSessionId() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7) }
function setSession(id) { sessionId = id labelEl.textContent = id const url = new URL(location.href) url.searchParams.set('session', id) history_.replaceState({}, '', url) }
// — Persistence —
async function loadHistory() { try { const text = await drive.readFile('/history/' + sessionId + '.json') return JSON.parse(text) } catch { return [] } }
async function saveHistory() { try { await drive.writeFile('/history/' + sessionId + '.json', JSON.stringify(history, null, 2)) } catch (err) { console.warn('[prompt-app] could not save history:', err.message) } }
// — Render —
function renderHistory() { messagesEl.innerHTML = '' for (const msg of history) { if (msg.role === 'user' || msg.role === 'assistant') { appendMessage(msg.role, msg.content) } } }
function appendMessage(role, text) { const el = document.createElement('div') el.className = `message ${role}` el.textContent = text messagesEl.appendChild(el) messagesEl.scrollTop = messagesEl.scrollHeight return el }
// — Init —
async function init() { const params = new URLSearchParams(location.search) const existing = params.get('session') if (existing) { setSession(existing) history = await loadHistory() renderHistory() } else { setSession(newSessionId()) // create history dir implicitly on first save; nothing to load yet } }
// — Send —
sendEl.addEventListener('click', send) promptEl.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } })
document.getElementById('new-session').addEventListener('click', () => { history = [] messagesEl.innerHTML = '' setSession(newSessionId()) })
async function send() { const text = promptEl.value.trim() if (!text) return
promptEl.value = '' sendEl.disabled = true
appendMessage('user', text) history.push({ role: 'user', content: text })
const replyEl = appendMessage('assistant', '') replyEl.classList.add('streaming')
try { let reply = '' for await (const chunk of nomad.ai.chat(history)) { reply += chunk replyEl.textContent = reply messagesEl.scrollTop = messagesEl.scrollHeight } history.push({ role: 'assistant', content: reply }) await saveHistory() } catch (err) { replyEl.remove() appendMessage('error', `Error: ${err.message}`) } finally { replyEl.classList.remove('streaming') sendEl.disabled = false promptEl.focus() } }
init() </script></body></html>{ "title": "My Prompt App", "ai": { "model": "" }}You are a helpful assistant. Answer clearly and concisely.MIT License
Copyright (c) 2020 Blue Link Labs
Permission is hereby granted, free of charge, to any person obtaining a copyof this software and associated documentation files (the "Software"), to dealin the Software without restriction, including without limitation the rightsto use, copy, modify, merge, publish, distribute, sublicense, and/or sellcopies of the Software, and to permit persons to whom the Software isfurnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in allcopies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS ORIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THEAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHERLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THESOFTWARE.