Files
brk/website_next/ask/index.js
T
2026-07-21 12:15:41 +02:00

488 lines
13 KiB
JavaScript

import { prepareContext } from "./context.js";
import { AskModel } from "./model.js";
import { ASK_MODEL } from "./models.js";
import { askStorage } from "./storage.js";
import { bindHold } from "../utils/hold.js";
import {
appendMessage,
createAskView,
renderChats,
renderMessages,
setMessageContent,
setSidebarOpen,
} from "./view.js";
const SIDEBAR_PREFERENCE_KEY = "bitview.ask.sidebar-collapsed.v1";
/** @param {number} bytes */
function formatBytes(bytes) {
return bytes >= 1_000_000
? `${(bytes / 1_000_000).toFixed(0)} MB`
: `${(bytes / 1_000).toFixed(0)} KB`;
}
/** @param {unknown} error */
function errorMessage(error) {
return error instanceof Error ? error.message : String(error);
}
export function createAskPage() {
const view = createAskView();
const model = new AskModel();
let workspace = askStorage.load();
let chat = workspace.activeChat;
let loaded = false;
let busy = false;
let checking = false;
let loading = false;
let followingOutput = false;
let scrollFrame = 0;
const sidebarPreference = localStorage.getItem(SIDEBAR_PREFERENCE_KEY);
let sidebarCollapsed =
sidebarPreference === null
? workspace.chats.length <= 1
: sidebarPreference === "true";
const mobileSidebar = window.matchMedia("(max-width: 48rem)");
function isNearBottom() {
const { clientHeight, scrollHeight, scrollTop } = view.conversation;
return scrollHeight - clientHeight - scrollTop < 96;
}
function scrollToBottom() {
cancelAnimationFrame(scrollFrame);
scrollFrame = requestAnimationFrame(() => {
view.conversation.scrollTop = view.conversation.scrollHeight;
});
}
function syncConversation() {
const hasMessages = view.transcript.childElementCount > 0;
view.empty.hidden = hasMessages;
view.transcript.hidden = !hasMessages;
}
function syncChatControls() {
view.sidebarToggle.disabled = busy;
for (const button of view.newChatButtons) {
button.disabled = busy;
}
for (const button of view.suggestions.querySelectorAll("button")) {
button.disabled = !loaded || busy;
}
for (const button of view.chats.querySelectorAll("button")) {
button.disabled = busy;
}
}
function syncSidebar() {
const drawerOpen = view.main.hasAttribute("data-sidebar-open");
const expanded = mobileSidebar.matches ? drawerOpen : !sidebarCollapsed;
view.main.toggleAttribute("data-sidebar-collapsed", sidebarCollapsed);
view.sidebarToggle.setAttribute("aria-expanded", String(expanded));
view.sidebarToggle.ariaLabel = expanded ? "Hide chats" : "Show chats";
view.sidebarToggle.title = view.sidebarToggle.ariaLabel;
}
function renderChatList() {
renderChats(view, workspace.chats, chat.id);
for (const button of view.chats.querySelectorAll(
'[data-action="remove"]',
)) {
if (!(button instanceof HTMLButtonElement)) continue;
bindHold(button, () => {
const id = button.dataset.chatId;
if (id) removeChat(id);
}, 1_000);
}
}
function renderChat() {
renderChatList();
renderMessages(view, chat.messages);
syncConversation();
syncChatControls();
syncSidebar();
scrollToBottom();
}
function setChecking() {
loaded = false;
busy = false;
checking = true;
loading = false;
view.main.dataset.state = "checking";
view.loader.hidden = false;
view.form.hidden = true;
view.fieldset.disabled = true;
view.loadButton.hidden = true;
view.progress.hidden = false;
view.progress.removeAttribute("value");
view.activity.hidden = false;
view.activity.textContent = "Checking browser cache...";
syncConversation();
syncChatControls();
}
/** @param {string | undefined} [detail] */
function setUnloaded(detail) {
loaded = false;
busy = false;
checking = false;
loading = false;
view.main.dataset.state = "unloaded";
view.loader.hidden = false;
view.form.hidden = true;
view.fieldset.disabled = true;
view.loadButton.hidden = false;
view.loadButton.disabled = false;
view.loadLabel.textContent = "Download & use";
view.loadDetail.textContent = ASK_MODEL.size;
view.progress.hidden = true;
view.activity.hidden = !detail;
view.activity.textContent = detail ?? "";
syncConversation();
syncChatControls();
}
/** @param {boolean} cached */
function setLoading(cached) {
loaded = false;
busy = false;
checking = false;
loading = true;
view.main.dataset.state = "loading";
view.loader.hidden = false;
view.form.hidden = true;
view.fieldset.disabled = true;
view.loadButton.hidden = true;
view.progress.hidden = false;
if (cached) view.progress.removeAttribute("value");
else view.progress.value = 0;
view.activity.hidden = false;
view.activity.textContent = cached
? "Loading the model from browser storage..."
: "Preparing the one-time local download...";
syncConversation();
syncChatControls();
}
/** @param {unknown} error */
function setLoadError(error) {
loaded = false;
busy = false;
checking = false;
loading = false;
view.main.dataset.state = "error";
view.loader.hidden = false;
view.form.hidden = true;
view.fieldset.disabled = true;
view.progress.hidden = true;
view.loadButton.hidden = false;
view.loadButton.disabled = false;
view.loadLabel.textContent = "Try again";
view.activity.hidden = false;
view.activity.textContent = `Load failed · ${errorMessage(error)}`;
syncChatControls();
}
function setReady() {
loaded = true;
busy = false;
checking = false;
loading = false;
view.main.dataset.state = "ready";
view.loader.hidden = true;
view.form.hidden = false;
view.fieldset.disabled = false;
view.input.disabled = false;
view.askButton.hidden = false;
view.stopButton.hidden = true;
view.activity.hidden = true;
syncConversation();
syncChatControls();
}
/** @param {boolean} cached */
async function loadModel(cached) {
if (loaded || loading) return;
setLoading(cached);
try {
await model.load(
({ progress, loaded: loadedBytes, total }) => {
if (!cached) {
view.progress.hidden = false;
view.progress.value = progress;
}
view.activity.textContent = total
? cached
? "Loading the model from browser storage..."
: `Downloading ${formatBytes(loadedBytes)} of ${formatBytes(total)}...`
: cached
? "Starting the cached model..."
: `Downloading model... ${progress.toFixed(0)}%`;
},
(status) => {
view.activity.textContent =
cached && status.includes("Downloading")
? "Loading the model from browser storage..."
: status;
},
);
setReady();
view.input.focus();
} catch (error) {
if (view.main.inert) return;
model.terminate();
setLoadError(error);
}
}
async function activateModel() {
if (loaded || loading || checking) return;
setChecking();
try {
const modelCached = await model.isCached();
if (view.main.inert) return;
if (modelCached) await loadModel(true);
else setUnloaded();
} catch {
if (view.main.inert) return;
model.terminate();
setUnloaded("Cache status unavailable. Download the model to continue.");
}
}
function openNewChat() {
if (busy) return;
if (chat.messages.length) {
const wasSingleChat = workspace.chats.length === 1;
workspace = askStorage.create();
chat = workspace.activeChat;
if (wasSingleChat) {
sidebarCollapsed = false;
localStorage.setItem(SIDEBAR_PREFERENCE_KEY, "false");
}
model.reset();
view.input.value = "";
renderChat();
}
setSidebarOpen(view, false);
syncSidebar();
if (loaded) view.input.focus();
}
/** @param {string} id */
function selectChat(id) {
if (busy || id === chat.id) {
setSidebarOpen(view, false);
syncSidebar();
return;
}
workspace = askStorage.select(id);
chat = workspace.activeChat;
model.reset();
view.input.value = "";
renderChat();
setSidebarOpen(view, false);
syncSidebar();
if (loaded) {
setReady();
view.input.focus();
} else if (!checking && !loading) {
setUnloaded();
}
}
/** @param {string} id */
function removeChat(id) {
if (busy) return;
const item = workspace.chats.find((candidate) => candidate.id === id);
if (!item) return;
workspace = askStorage.remove(id);
chat = workspace.activeChat;
model.reset();
view.input.value = "";
renderChat();
if (loaded) setReady();
else if (!checking && !loading) setUnloaded();
}
renderChat();
view.loadButton.addEventListener("click", () => void loadModel(false));
view.form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!loaded || busy) return;
const question = view.input.value.trim();
if (!question) return;
view.input.value = "";
const questionMessage = appendMessage(view.transcript, "user", question);
const answerMessage = appendMessage(view.transcript, "assistant", "");
const draft = {
...chat,
messages: [
...chat.messages,
{ role: /** @type {const} */ ("user"), content: question },
],
};
busy = true;
followingOutput = true;
view.main.dataset.state = "generating";
view.input.disabled = true;
view.askButton.hidden = true;
view.stopButton.hidden = false;
view.activity.hidden = true;
syncConversation();
syncChatControls();
scrollToBottom();
try {
const prepared = await prepareContext(draft, model, () => {});
const output = await model.generate(
prepared.messages,
({ text }) => {
answerMessage.content.append(text);
if (followingOutput) scrollToBottom();
},
);
setMessageContent(answerMessage.content, "assistant", output);
if (followingOutput) scrollToBottom();
workspace = askStorage.save({
...prepared.chat,
messages: [
...prepared.chat.messages,
{ role: /** @type {const} */ ("assistant"), content: output },
],
});
chat = workspace.activeChat;
renderChatList();
setReady();
view.input.focus();
} catch (error) {
questionMessage.item.remove();
answerMessage.item.remove();
view.input.value = question;
syncConversation();
if (view.main.inert) {
setUnloaded();
return;
}
setReady();
view.activity.hidden = false;
view.activity.textContent = `Could not answer · ${errorMessage(error)}`;
view.input.focus();
}
});
view.stopButton.addEventListener("click", () => {
model.stop();
});
view.conversation.addEventListener("scroll", () => {
if (!busy) return;
followingOutput = isNearBottom();
if (!followingOutput) cancelAnimationFrame(scrollFrame);
});
for (const button of view.newChatButtons) {
button.addEventListener("click", openNewChat);
}
view.sidebarToggle.addEventListener("click", () => {
if (mobileSidebar.matches) {
setSidebarOpen(view, !view.main.hasAttribute("data-sidebar-open"));
} else {
sidebarCollapsed = !sidebarCollapsed;
localStorage.setItem(
SIDEBAR_PREFERENCE_KEY,
String(sidebarCollapsed),
);
setSidebarOpen(view, false);
}
syncSidebar();
});
view.backdrop.addEventListener("click", () => {
setSidebarOpen(view, false);
syncSidebar();
});
view.chats.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const button = target.closest("button[data-chat-id]");
if (!(button instanceof HTMLButtonElement)) return;
const id = button.dataset.chatId;
if (!id) return;
if (button.dataset.action !== "select") return;
selectChat(id);
});
view.suggestions.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
const button = target.closest("button[data-prompt]");
if (!(button instanceof HTMLButtonElement)) return;
view.input.value = button.dataset.prompt ?? "";
view.input.focus();
});
view.input.addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
view.form.requestSubmit();
});
view.main.addEventListener("keydown", (event) => {
if (event.key !== "Escape") return;
setSidebarOpen(view, false);
syncSidebar();
});
mobileSidebar.addEventListener("change", syncSidebar);
view.main.addEventListener("pageactive", () => void activateModel());
view.main.addEventListener("pageinactive", () => {
model.terminate();
setSidebarOpen(view, false);
setUnloaded();
syncSidebar();
});
setUnloaded();
return view.main;
}