import { el, mount as fill, clear, setText, copyButton, fmtMs, fmtNum, fmtInt, isNum } from './dom.js';
import { streamChat, getJson, cancelGeneration, routes, ApiError } from './api.js';
import { renderMarkdown } from './md.js';
export const title = 'Chat';
const CHAT_KEY = 'ferrox.studio.chat.v1';
const SETTINGS_KEY = 'ferrox.studio.sampling.v1';
const DEFAULT_SETTINGS = {
system: '',
temperature: 0.7,
topP: 0.95,
maxTokens: 512,
};
function load(key, fallback) {
try {
const raw = localStorage.getItem(key);
return raw ? { ...fallback, ...JSON.parse(raw) } : structuredClone(fallback);
} catch {
return structuredClone(fallback);
}
}
function save(key, value) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
}
}
function statLine(usage, requestId) {
const parts = [];
if (isNum(usage?.time_to_first_token_ms)) parts.push(`TTFT ${fmtMs(usage.time_to_first_token_ms)}`);
if (usage) {
const prefill = [`${fmtInt(usage.prompt_tokens)} tok`];
if (isNum(usage.prompt_per_second)) prefill.push(`${fmtNum(usage.prompt_per_second)} tok/s`);
if (isNum(usage.prompt_eval_duration_ms)) prefill.push(fmtMs(usage.prompt_eval_duration_ms));
parts.push(`prefill ${prefill.join(' · ')}`);
const decode = [`${fmtInt(usage.completion_tokens)} tok`];
if (isNum(usage.predicted_per_second)) decode.push(`${fmtNum(usage.predicted_per_second)} tok/s`);
if (isNum(usage.generation_duration_ms)) decode.push(fmtMs(usage.generation_duration_ms));
parts.push(`decode ${decode.join(' · ')}`);
if (isNum(usage.cached_tokens)) parts.push(`cached ${fmtInt(usage.cached_tokens)} tok`);
}
if (requestId) parts.push(requestId);
return parts.join(' · ');
}
export function mount(container) {
let messages = [];
try {
const raw = localStorage.getItem(CHAT_KEY);
if (raw) messages = JSON.parse(raw) || [];
} catch {
messages = [];
}
const settings = load(SETTINGS_KEY, DEFAULT_SETTINGS);
let modelId = null;
let controller = null;
let disposed = false;
let liveRequestId = null;
function stopGenerating() {
const id = liveRequestId;
controller?.abort();
cancelGeneration(id);
}
const onPageHide = () => {
if (controller) cancelGeneration(liveRequestId);
};
window.addEventListener('pagehide', onPageHide);
const list = el('div', { class: 'messages', id: 'messages' });
const banner = el('div');
const input = el('textarea', {
rows: 2,
placeholder: 'Message… (Enter to send, Shift+Enter for a newline)',
'aria-label': 'Message',
onkeydown: (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
send();
}
},
oninput: () => {
input.style.height = 'auto';
input.style.height = `${Math.min(input.scrollHeight, 224)}px`;
},
});
const sendButton = el('button', { class: 'primary', type: 'button', text: 'Send', onclick: () => send() });
const stopButton = el('button', {
type: 'button',
text: 'Stop',
hidden: true,
onclick: () => stopGenerating(),
});
const field = (label, attrs) => {
const control = el('input', attrs);
return { control, node: el('label', {}, [label, control]) };
};
const system = el('textarea', {
rows: 2,
placeholder: 'You are a helpful assistant.',
'aria-label': 'System prompt',
});
system.value = settings.system;
const temperature = field('temperature', { type: 'number', min: '0', max: '2', step: '0.05', value: settings.temperature });
const topP = field('top_p', { type: 'number', min: '0', max: '1', step: '0.05', value: settings.topP });
const maxTokens = field('max_tokens', { type: 'number', min: '1', max: '32768', step: '1', value: settings.maxTokens });
const persistSettings = () => {
settings.system = system.value;
settings.temperature = Number(temperature.control.value);
settings.topP = Number(topP.control.value);
settings.maxTokens = Math.max(1, Math.round(Number(maxTokens.control.value) || 1));
save(SETTINGS_KEY, settings);
};
for (const node of [system, temperature.control, topP.control, maxTokens.control]) {
node.addEventListener('change', persistSettings);
}
const modelLabel = el('span', { class: 'small muted', text: 'model: …' });
const controls = el('details', { class: 'settings panel' }, [
el('summary', { text: 'Sampling & system prompt' }),
el('div', { class: 'controls' }, [
temperature.node,
topP.node,
maxTokens.node,
el('label', { class: 'spacer' }, ['system prompt', system]),
]),
el('div', { class: 'row small faint' }, [
modelLabel,
el('span', { class: 'spacer' }),
el('span', { text: 'Transcript is stored in this browser only.' }),
el('button', {
class: 'small',
type: 'button',
text: 'New chat',
onclick: () => {
stopGenerating();
messages = [];
save(CHAT_KEY, messages);
paint();
},
}),
]),
]);
container.appendChild(
el('div', { class: 'screen chat' }, [
banner,
controls,
list,
el('div', { class: 'composer' }, [input, sendButton, stopButton]),
]),
);
function bubble(message) {
const body = el('div', { class: 'md msg-body' });
body.appendChild(renderMarkdown(message.content || ''));
const node = el('div', { class: `msg msg-${message.role}${message.error ? ' msg-error' : ''}` }, [
el('div', { class: 'msg-role' }, [
message.role === 'user' ? 'You' : message.role === 'system' ? 'System' : 'Assistant',
message.role === 'assistant' && message.content
? el('span', {}, [' ', copyButton('copy', () => message.content)])
: null,
]),
body,
]);
if (message.stats) node.appendChild(el('div', { class: 'msg-stats', text: message.stats }));
return node;
}
function paint() {
clear(list);
if (!messages.length) {
list.appendChild(
el('p', { class: 'empty' }, [
'Nothing here yet. This screen talks to ',
el('code', { text: '/v1/chat/completions' }),
' with ',
el('code', { text: 'stream: true' }),
' — the same endpoint any other client uses.',
]),
);
return;
}
for (const message of messages) list.appendChild(bubble(message));
list.scrollTop = list.scrollHeight;
}
function setBusy(busy) {
sendButton.disabled = busy;
stopButton.hidden = !busy;
input.disabled = busy;
}
function notice(text, kind = 'notice-warn') {
fill(banner, el('p', { class: `notice ${kind}`, text }));
}
async function send() {
const text = input.value.trim();
if (!text || controller) return;
persistSettings();
input.value = '';
input.style.height = 'auto';
messages.push({ role: 'user', content: text });
const reply = { role: 'assistant', content: '', stats: null };
messages.push(reply);
paint();
const replyNode = list.lastElementChild;
const replyBody = replyNode.querySelector('.msg-body');
replyBody.classList.add('cursor');
let scheduled = false;
const repaintBody = () => {
if (scheduled || disposed) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
clear(replyBody).appendChild(renderMarkdown(reply.content));
list.scrollTop = list.scrollHeight;
});
};
const wire = [];
if (settings.system.trim()) wire.push({ role: 'system', content: settings.system.trim() });
for (const message of messages) {
if (message === reply || message.error) continue;
wire.push({ role: message.role, content: message.content });
}
controller = new AbortController();
setBusy(true);
let requestId = null;
try {
const result = await streamChat(
{
model: modelId || 'ferrox',
messages: wire,
temperature: settings.temperature,
top_p: settings.topP,
max_tokens: settings.maxTokens,
},
{
signal: controller.signal,
onRequestId: (id) => {
requestId = id;
liveRequestId = id;
},
onToken: (token) => {
reply.content += token;
repaintBody();
},
onStall: (ms) => {
if (disposed) return;
if (ms === null) clear(banner);
else
notice(
`No data for ${Math.round(ms / 1000)}s. The generation may still be running — `
+ 'a proxy between you and the server may be buffering text/event-stream. '
+ 'Stop cancels it on the server, not just here.',
);
},
},
);
const line = statLine(result?.usage, result?.requestId || requestId);
reply.stats =
result?.finishReason === 'cancelled' ? `stopped · ${line}` : line;
} catch (error) {
if (error?.name === 'AbortError') {
reply.stats = `stopped by you${requestId ? ` · ${requestId}` : ''}`;
if (!reply.content) reply.content = '_(stopped before any token arrived)_';
} else if (error instanceof ApiError && error.isAuth) {
reply.error = true;
reply.content = `${error.message}\n\nThis server requires an API key. Set it on the Connect screen.`;
} else {
reply.error = true;
reply.content = String(error?.message || error);
}
} finally {
controller = null;
liveRequestId = null;
if (!disposed) {
setBusy(false);
save(CHAT_KEY, messages);
paint();
input.focus();
}
}
}
paint();
input.focus();
getJson(routes.models)
.then((body) => {
if (disposed) return;
modelId = body?.data?.[0]?.id || null;
setText(modelLabel, `model: ${modelId || 'none loaded'}`);
if (!modelId) {
notice('No model is loaded, so a message would fail. Load one on the Models screen.');
} else if (body.data[0].ferrox_synthetic_weights) {
notice(
`"${modelId}" is running on synthetic random weights — the output is noise, not a bad model.`,
);
}
})
.catch((error) => {
if (disposed) return;
setText(modelLabel, 'model: unknown');
notice(`Could not read ${routes.models}: ${error.message}`, 'notice-error');
});
return () => {
disposed = true;
stopGenerating();
window.removeEventListener('pagehide', onPageHide);
};
}