Skip to content

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.

<!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>