mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-23 00:48:11 -07:00
391 lines
9.5 KiB
JavaScript
391 lines
9.5 KiB
JavaScript
import { createAskComposer } from "./composer/index.js";
|
|
import { prepareContext } from "./context.js";
|
|
import { createAskConversation } from "./conversation/index.js";
|
|
import { createAskHero } from "./hero/index.js";
|
|
import { createAskLayout } from "./layout/index.js";
|
|
import { createAskLoader } from "./loader/index.js";
|
|
import { AskModel } from "./model.js";
|
|
import { createAskSidebar } from "./sidebar/index.js";
|
|
import { askStorage } from "./storage.js";
|
|
import { createAskTools } from "./tools/index.js";
|
|
import { createResponseTimer } from "./timing.js";
|
|
|
|
const SIDEBAR_PREFERENCE_KEY = "bitview.ask.sidebar-collapsed.v1";
|
|
|
|
/** @param {unknown} error */
|
|
function errorMessage(error) {
|
|
return error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
export function createAskPage() {
|
|
const model = new AskModel();
|
|
const assistant = createAskTools();
|
|
let workspace = askStorage.load();
|
|
let chat = workspace.activeChat;
|
|
let loaded = false;
|
|
let busy = false;
|
|
let checking = false;
|
|
let loading = false;
|
|
let followingOutput = false;
|
|
const sidebarPreference = localStorage.getItem(SIDEBAR_PREFERENCE_KEY);
|
|
let sidebarCollapsed = sidebarPreference === null
|
|
? workspace.chats.length <= 1
|
|
: sidebarPreference === "true";
|
|
const mobileSidebar = window.matchMedia("(max-width: 48rem)");
|
|
|
|
const hero = createAskHero({
|
|
onPrompt(prompt) {
|
|
composer.value = prompt;
|
|
composer.focus();
|
|
},
|
|
});
|
|
const conversation = createAskConversation({
|
|
hero: hero.element,
|
|
onScroll() {
|
|
if (!busy) return;
|
|
|
|
followingOutput = conversation.isNearBottom();
|
|
if (!followingOutput) conversation.cancelScroll();
|
|
},
|
|
});
|
|
const composer = createAskComposer({
|
|
onSubmit: submitQuestion,
|
|
onStop() {
|
|
model.stop();
|
|
assistant.stop();
|
|
},
|
|
});
|
|
const loader = createAskLoader({
|
|
onLoad() {
|
|
void loadModel(false);
|
|
},
|
|
});
|
|
const sidebar = createAskSidebar({
|
|
onNew: openNewChat,
|
|
onToggle: toggleSidebar,
|
|
onSelect: selectChat,
|
|
onRemove: removeChat,
|
|
});
|
|
const { main } = createAskLayout({
|
|
sidebar,
|
|
conversation,
|
|
composer,
|
|
loader,
|
|
});
|
|
|
|
/** @param {boolean} open */
|
|
function setSidebarOpen(open) {
|
|
main.toggleAttribute("data-sidebar-open", open);
|
|
}
|
|
|
|
function syncControls() {
|
|
sidebar.setBusy(busy);
|
|
hero.setEnabled(loaded && !busy);
|
|
}
|
|
|
|
function syncSidebar() {
|
|
const drawerOpen = main.hasAttribute("data-sidebar-open");
|
|
const expanded = mobileSidebar.matches ? drawerOpen : !sidebarCollapsed;
|
|
|
|
main.toggleAttribute("data-sidebar-collapsed", sidebarCollapsed);
|
|
sidebar.setExpanded(expanded);
|
|
}
|
|
|
|
function renderChatList() {
|
|
sidebar.render(workspace.chats, chat.id);
|
|
}
|
|
|
|
function renderChat() {
|
|
renderChatList();
|
|
conversation.render(chat.messages);
|
|
syncControls();
|
|
syncSidebar();
|
|
conversation.scrollToBottom();
|
|
}
|
|
|
|
function setChecking() {
|
|
loaded = false;
|
|
busy = false;
|
|
checking = true;
|
|
loading = false;
|
|
main.dataset.state = "checking";
|
|
composer.hide();
|
|
loader.checking();
|
|
syncControls();
|
|
}
|
|
|
|
/** @param {string | undefined} [detail] */
|
|
function setUnloaded(detail) {
|
|
loaded = false;
|
|
busy = false;
|
|
checking = false;
|
|
loading = false;
|
|
main.dataset.state = "unloaded";
|
|
composer.hide();
|
|
loader.unloaded(detail);
|
|
syncControls();
|
|
}
|
|
|
|
/** @param {boolean} cached */
|
|
function setLoading(cached) {
|
|
loaded = false;
|
|
busy = false;
|
|
checking = false;
|
|
loading = true;
|
|
main.dataset.state = "loading";
|
|
composer.hide();
|
|
loader.loading(cached);
|
|
syncControls();
|
|
}
|
|
|
|
/** @param {unknown} error */
|
|
function setLoadError(error) {
|
|
loaded = false;
|
|
busy = false;
|
|
checking = false;
|
|
loading = false;
|
|
main.dataset.state = "error";
|
|
composer.hide();
|
|
loader.loadError(errorMessage(error));
|
|
syncControls();
|
|
}
|
|
|
|
function setReady() {
|
|
loaded = true;
|
|
busy = false;
|
|
checking = false;
|
|
loading = false;
|
|
main.dataset.state = "ready";
|
|
loader.ready();
|
|
composer.ready();
|
|
syncControls();
|
|
}
|
|
|
|
/** @param {boolean} cached */
|
|
async function loadModel(cached) {
|
|
if (loaded || loading) return;
|
|
|
|
setLoading(cached);
|
|
|
|
try {
|
|
await model.load(
|
|
(update) => loader.reportProgress(update, cached),
|
|
(status) => loader.reportStatus(status, cached),
|
|
);
|
|
setReady();
|
|
composer.focus();
|
|
} catch (error) {
|
|
if (main.inert) return;
|
|
|
|
model.terminate();
|
|
setLoadError(error);
|
|
}
|
|
}
|
|
|
|
async function activateModel() {
|
|
if (loaded || loading || checking) return;
|
|
|
|
setChecking();
|
|
try {
|
|
const modelCached = await model.isCached();
|
|
if (main.inert) return;
|
|
|
|
if (modelCached) await loadModel(true);
|
|
else setUnloaded();
|
|
} catch {
|
|
if (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();
|
|
composer.value = "";
|
|
renderChat();
|
|
}
|
|
|
|
setSidebarOpen(false);
|
|
syncSidebar();
|
|
if (loaded) {
|
|
setReady();
|
|
composer.focus();
|
|
}
|
|
}
|
|
|
|
/** @param {string} id */
|
|
function selectChat(id) {
|
|
if (busy || id === chat.id) {
|
|
setSidebarOpen(false);
|
|
syncSidebar();
|
|
return;
|
|
}
|
|
|
|
workspace = askStorage.select(id);
|
|
chat = workspace.activeChat;
|
|
model.reset();
|
|
composer.value = "";
|
|
renderChat();
|
|
setSidebarOpen(false);
|
|
syncSidebar();
|
|
|
|
if (loaded) {
|
|
setReady();
|
|
composer.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();
|
|
composer.value = "";
|
|
renderChat();
|
|
|
|
if (loaded) setReady();
|
|
else if (!checking && !loading) setUnloaded();
|
|
}
|
|
|
|
function toggleSidebar() {
|
|
if (mobileSidebar.matches) {
|
|
setSidebarOpen(!main.hasAttribute("data-sidebar-open"));
|
|
} else {
|
|
sidebarCollapsed = !sidebarCollapsed;
|
|
localStorage.setItem(SIDEBAR_PREFERENCE_KEY, String(sidebarCollapsed));
|
|
setSidebarOpen(false);
|
|
}
|
|
syncSidebar();
|
|
}
|
|
|
|
/** @param {string} question */
|
|
async function submitQuestion(question) {
|
|
if (!loaded || busy) return;
|
|
|
|
composer.value = "";
|
|
const questionMessage = conversation.append("user", question);
|
|
const answerMessage = conversation.append("assistant", "");
|
|
const timer = createResponseTimer(answerMessage.setSteps);
|
|
const draft = {
|
|
...chat,
|
|
messages: [
|
|
...chat.messages,
|
|
{ role: /** @type {const} */ ("user"), content: question },
|
|
],
|
|
};
|
|
|
|
busy = true;
|
|
followingOutput = true;
|
|
main.dataset.state = "generating";
|
|
loader.ready();
|
|
composer.generating();
|
|
syncControls();
|
|
conversation.scrollToBottom();
|
|
|
|
try {
|
|
timer.set("Preparing context");
|
|
const prepared = await prepareContext(
|
|
draft,
|
|
model,
|
|
assistant.toolsFor(),
|
|
() => timer.set("Compacting conversation"),
|
|
);
|
|
timer.set("Routing request");
|
|
const { output, artifacts } = await assistant.answer({
|
|
chatId: chat.id,
|
|
history: prepared.chat.messages,
|
|
model,
|
|
messages: prepared.messages,
|
|
onToken({ text }) {
|
|
answerMessage.content.append(text);
|
|
if (followingOutput) conversation.scrollToBottom();
|
|
},
|
|
onStatus(status) {
|
|
timer.set(status);
|
|
if (followingOutput) conversation.scrollToBottom();
|
|
},
|
|
});
|
|
|
|
const { elapsedMs, steps } = timer.finish();
|
|
conversation.setContent(answerMessage.content, "assistant", output);
|
|
answerMessage.setArtifacts(artifacts);
|
|
answerMessage.setElapsed(elapsedMs);
|
|
if (followingOutput) conversation.scrollToBottom();
|
|
workspace = askStorage.save({
|
|
...prepared.chat,
|
|
messages: [
|
|
...prepared.chat.messages,
|
|
{
|
|
role: /** @type {const} */ ("assistant"),
|
|
content: output,
|
|
elapsedMs,
|
|
steps,
|
|
...(artifacts.length ? { artifacts } : {}),
|
|
},
|
|
],
|
|
});
|
|
chat = workspace.activeChat;
|
|
renderChatList();
|
|
setReady();
|
|
composer.focus();
|
|
} catch (error) {
|
|
questionMessage.item.remove();
|
|
answerMessage.item.remove();
|
|
composer.value = question;
|
|
conversation.sync();
|
|
|
|
if (main.inert) {
|
|
setUnloaded();
|
|
return;
|
|
}
|
|
|
|
setReady();
|
|
loader.answerError(errorMessage(error));
|
|
composer.focus();
|
|
}
|
|
}
|
|
|
|
sidebar.backdrop.addEventListener("click", () => {
|
|
setSidebarOpen(false);
|
|
syncSidebar();
|
|
});
|
|
main.addEventListener("keydown", (event) => {
|
|
if (event.key !== "Escape") return;
|
|
|
|
setSidebarOpen(false);
|
|
syncSidebar();
|
|
});
|
|
mobileSidebar.addEventListener("change", syncSidebar);
|
|
main.addEventListener("pageactive", () => void activateModel());
|
|
main.addEventListener("pageinactive", () => {
|
|
model.terminate();
|
|
assistant.terminate();
|
|
setSidebarOpen(false);
|
|
setUnloaded();
|
|
syncSidebar();
|
|
});
|
|
|
|
renderChat();
|
|
setUnloaded();
|
|
return main;
|
|
}
|