Hakkillo / app.js
alkasima's picture
Add reproducible starter prompts and copyable model feedback
31a8c5f verified
Raw History Blame Contribute Delete
5.59 kB
import { feedbackText } from './feedback.mjs';
const $ = (id) => document.getElementById(id);
const worker = new Worker(new URL('./worker.js', import.meta.url), { type: 'module' });
let busy = false;
let loaded = false;
let history = [];
let responseNode;
let pendingExchange;
let lastExchange;
let modelMetadata;
function setBusy(value) {
busy = value;
$('prompt').disabled = value || !loaded;
$('send').disabled = value || !loaded;
$('clear').disabled = value || !loaded;
$('load').disabled = value;
$('copy-feedback').disabled = value || !lastExchange;
document.querySelectorAll('.suggestion').forEach((button) => { button.disabled = value || !loaded; });
}
function message(role, text) {
const element = document.createElement('div');
element.className = `message ${role}`;
const speaker = document.createElement('span');
speaker.className = 'speaker';
speaker.textContent = role === 'user' ? 'You' : 'Hakkillo';
const content = document.createElement('span');
content.textContent = text;
element.append(speaker, content);
$('messages').append(element);
element.scrollIntoView({ block: 'nearest' });
return content;
}
function fail(error) {
$('status').textContent = error;
$('progress').hidden = true;
if (history.at(-1)?.role === 'user') history.pop();
if (responseNode && !responseNode.textContent) responseNode.textContent = 'The model could not finish this reply.';
pendingExchange = null;
setBusy(false);
}
$('load').addEventListener('click', () => {
setBusy(true);
$('status').textContent = 'Preparing your model download…';
$('progress').hidden = false;
worker.postMessage({ type: 'load' });
});
worker.addEventListener('message', ({ data }) => {
if (data.type === 'progress') {
$('status').textContent = data.message;
if (data.percent != null) $('progress').value = data.percent;
} else if (data.type === 'ready') {
loaded = true;
modelMetadata = data.metadata;
$('welcome').hidden = true;
$('progress').hidden = true;
$('status').textContent = 'Model ready · messages stay in this browser';
$('prompt').placeholder = 'Ask a short question in English…';
setBusy(false);
$('prompt').focus();
} else if (data.type === 'token') {
responseNode.textContent += data.text;
$('messages').scrollTop = $('messages').scrollHeight;
} else if (data.type === 'answer') {
responseNode.textContent = data.text || 'I could not produce an answer. Try a shorter question.';
history.push({ role: 'assistant', content: data.text });
history = history.slice(-4);
lastExchange = { ...pendingExchange, response: data.text };
pendingExchange = null;
$('feedback-panel').hidden = false;
$('status').textContent = `Answered in ${data.seconds}s · experimental model`;
setBusy(false);
$('prompt').focus();
} else if (data.type === 'error') {
fail(`Could not complete this request: ${data.message}. If loading failed, reload the page to retry.`);
}
});
worker.addEventListener('error', () => fail('The model worker could not start. Check your connection and reload this page.'));
$('chat-form').addEventListener('submit', (event) => {
event.preventDefault();
const text = $('prompt').value.trim();
if (!loaded || busy || !text) return;
setBusy(true);
$('feedback-panel').hidden = true;
$('feedback-status').textContent = '';
$('feedback-fallback').hidden = true;
$('status').textContent = 'Thinking on your CPU…';
message('user', text);
responseNode = message('assistant', '');
const starter = [...document.querySelectorAll('.suggestion')].find((button) => button.dataset.prompt === text);
pendingExchange = { prompt: text, context: history.map((turn) => ({ ...turn })), case_id: starter?.dataset.case || null };
history.push({ role: 'user', content: text });
$('prompt').value = '';
worker.postMessage({ type: 'generate', messages: history });
});
$('prompt').addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
$('chat-form').requestSubmit();
}
});
function clearConversation() {
history = [];
responseNode = null;
pendingExchange = null;
lastExchange = null;
$('messages').replaceChildren();
$('prompt').value = '';
$('feedback-panel').hidden = true;
$('feedback-fallback').hidden = true;
$('feedback-fallback').value = '';
$('feedback-status').textContent = '';
$('status').textContent = 'New conversation · model stays loaded';
}
$('clear').addEventListener('click', clearConversation);
document.querySelectorAll('.suggestion').forEach((button) => button.addEventListener('click', () => {
if (busy || !loaded) return;
clearConversation();
$('prompt').value = button.dataset.prompt;
$('chat-form').requestSubmit();
}));
$('copy-feedback').addEventListener('click', async () => {
if (!lastExchange || !modelMetadata || busy) return;
const text = feedbackText(lastExchange, modelMetadata);
try {
await navigator.clipboard.writeText(text);
$('feedback-status').textContent = 'Copied. Review the text before posting it.';
} catch {
$('feedback-fallback').hidden = false;
$('feedback-fallback').value = text;
$('feedback-fallback').focus();
$('feedback-fallback').select();
$('feedback-status').textContent = 'Copy the selected text manually, then review it before posting.';
}
});
fetch('./model.json').then((response) => response.json()).then((config) => {
if (config.download_mb) $('download-size').textContent = `${config.download_mb} MB`;
}).catch(() => {});