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
<!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 copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, 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 THE
SOFTWARE.