mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-23 08:58:11 -07:00
488 lines
13 KiB
JavaScript
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;
|
|
}
|