mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-08-05 15:13:06 -07:00
429 lines
11 KiB
JavaScript
429 lines
11 KiB
JavaScript
import { createAskComposer } from "./composer/index.js";
|
|
import { createAskCompactor } from "./compactor.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";
|
|
import { conversationMarkdown } from "./transcript.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 busy = false;
|
|
/** @type {"unloaded" | "checking" | "loading" | "error" | "ready"} */
|
|
let modelState = "unloaded";
|
|
let followingOutput = false;
|
|
const compactor = createAskCompactor({
|
|
model,
|
|
onCompacted(id, update) {
|
|
const saved = askStorage.saveMemory(id, update);
|
|
if (!saved || chat.id !== id) return;
|
|
chat = saved;
|
|
workspace = { ...workspace, activeChat: saved };
|
|
},
|
|
});
|
|
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,
|
|
async onCopy() {
|
|
await navigator.clipboard.writeText(conversationMarkdown(chat));
|
|
},
|
|
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);
|
|
}
|
|
|
|
/** @param {"unloaded" | "checking" | "loading" | "error" | "ready"} nextState */
|
|
function setModelState(nextState) {
|
|
modelState = nextState;
|
|
}
|
|
|
|
function syncControls() {
|
|
sidebar.setBusy(busy);
|
|
hero.setEnabled(modelState === "ready" && !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() {
|
|
busy = false;
|
|
setModelState("checking");
|
|
composer.hide();
|
|
loader.checking();
|
|
syncControls();
|
|
}
|
|
|
|
/** @param {string | undefined} [detail] */
|
|
function setUnloaded(detail) {
|
|
busy = false;
|
|
setModelState("unloaded");
|
|
composer.hide();
|
|
loader.unloaded(detail);
|
|
syncControls();
|
|
}
|
|
|
|
/** @param {boolean} cached */
|
|
function setLoading(cached) {
|
|
busy = false;
|
|
setModelState("loading");
|
|
composer.hide();
|
|
loader.loading(cached);
|
|
syncControls();
|
|
}
|
|
|
|
/** @param {unknown} error */
|
|
function setLoadError(error) {
|
|
busy = false;
|
|
setModelState("error");
|
|
composer.hide();
|
|
loader.loadError(errorMessage(error));
|
|
syncControls();
|
|
}
|
|
|
|
function setReady() {
|
|
busy = false;
|
|
setModelState("ready");
|
|
loader.ready();
|
|
composer.ready();
|
|
syncControls();
|
|
}
|
|
|
|
/** @param {boolean} cached */
|
|
async function loadModel(cached) {
|
|
if (modelState === "ready" || modelState === "loading") return;
|
|
|
|
setLoading(cached);
|
|
|
|
try {
|
|
const modelLoading = model.load(
|
|
(update) => loader.reportProgress(update, cached),
|
|
(status) => loader.reportStatus(status, cached),
|
|
);
|
|
void assistant.prewarm().catch(() => {});
|
|
await modelLoading;
|
|
setReady();
|
|
composer.focus();
|
|
} catch (error) {
|
|
if (main.inert) return;
|
|
|
|
model.terminate();
|
|
setLoadError(error);
|
|
}
|
|
}
|
|
|
|
async function activateModel() {
|
|
if (modelState === "ready" || modelState === "loading" || modelState === "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.");
|
|
}
|
|
}
|
|
|
|
async function openNewChat() {
|
|
if (busy) return;
|
|
busy = true;
|
|
syncControls();
|
|
await compactor.cancel();
|
|
if (main.inert) return;
|
|
busy = false;
|
|
|
|
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 (modelState === "ready") {
|
|
setReady();
|
|
composer.focus();
|
|
} else syncControls();
|
|
}
|
|
|
|
/** @param {string} id */
|
|
async function selectChat(id) {
|
|
if (busy || id === chat.id) {
|
|
setSidebarOpen(false);
|
|
syncSidebar();
|
|
return;
|
|
}
|
|
busy = true;
|
|
syncControls();
|
|
await compactor.cancel();
|
|
if (main.inert) return;
|
|
busy = false;
|
|
|
|
workspace = askStorage.select(id);
|
|
chat = workspace.activeChat;
|
|
model.reset();
|
|
composer.value = "";
|
|
renderChat();
|
|
setSidebarOpen(false);
|
|
syncSidebar();
|
|
|
|
if (modelState === "ready") {
|
|
setReady();
|
|
composer.focus();
|
|
} else if (modelState !== "checking" && modelState !== "loading") {
|
|
setUnloaded();
|
|
}
|
|
}
|
|
|
|
/** @param {string} id */
|
|
async function removeChat(id) {
|
|
if (busy) return;
|
|
|
|
const item = workspace.chats.find((candidate) => candidate.id === id);
|
|
if (!item) return;
|
|
busy = true;
|
|
syncControls();
|
|
await compactor.cancel();
|
|
if (main.inert) return;
|
|
busy = false;
|
|
|
|
workspace = askStorage.remove(id);
|
|
chat = workspace.activeChat;
|
|
model.reset();
|
|
composer.value = "";
|
|
renderChat();
|
|
|
|
if (modelState === "ready") setReady();
|
|
else if (modelState !== "checking" && modelState !== "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 (modelState !== "ready" || busy) return;
|
|
|
|
composer.value = "";
|
|
const questionMessage = conversation.append("user", question);
|
|
const answerMessage = conversation.append("assistant", "");
|
|
const timer = createResponseTimer(answerMessage.setSteps);
|
|
|
|
busy = true;
|
|
followingOutput = true;
|
|
loader.ready();
|
|
composer.generating();
|
|
syncControls();
|
|
conversation.scrollToBottom();
|
|
|
|
try {
|
|
timer.set("Preparing context");
|
|
await compactor.cancel();
|
|
const draft = {
|
|
...chat,
|
|
messages: [
|
|
...chat.messages,
|
|
{ role: /** @type {const} */ ("user"), content: question },
|
|
],
|
|
};
|
|
const {
|
|
output,
|
|
artifacts = [],
|
|
capability,
|
|
metricPaths,
|
|
apiContext,
|
|
sourceContext,
|
|
knowledgeContext,
|
|
chat: preparedChat,
|
|
} = await assistant.answer({
|
|
question,
|
|
history: draft.messages,
|
|
model,
|
|
async prepare() {
|
|
timer.set("Preparing context");
|
|
const prepared = await prepareContext(draft, model);
|
|
timer.set("Routing request");
|
|
return prepared;
|
|
},
|
|
onToken({ text }) {
|
|
answerMessage.content.append(text);
|
|
if (followingOutput) conversation.scrollToBottom();
|
|
},
|
|
onStatus(status) {
|
|
timer.set(status);
|
|
if (followingOutput) conversation.scrollToBottom();
|
|
},
|
|
});
|
|
|
|
const { elapsedMs, steps } = timer.finish();
|
|
const response = output ?? "";
|
|
conversation.setContent(answerMessage.content, "assistant", response);
|
|
answerMessage.setArtifacts(artifacts);
|
|
answerMessage.setElapsed(elapsedMs);
|
|
if (followingOutput) conversation.scrollToBottom();
|
|
const answeredChat = preparedChat ?? draft;
|
|
workspace = askStorage.save({
|
|
...answeredChat,
|
|
messages: [
|
|
...answeredChat.messages,
|
|
{
|
|
role: /** @type {const} */ ("assistant"),
|
|
content: response,
|
|
elapsedMs,
|
|
steps,
|
|
capability,
|
|
metricPaths,
|
|
...(apiContext ? { apiContext } : {}),
|
|
...(sourceContext?.length ? { sourceContext } : {}),
|
|
...(knowledgeContext ? { knowledgeContext } : {}),
|
|
...(artifacts.length ? { artifacts } : {}),
|
|
},
|
|
],
|
|
});
|
|
chat = workspace.activeChat;
|
|
renderChatList();
|
|
setReady();
|
|
composer.focus();
|
|
compactor.schedule(chat);
|
|
} 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", () => {
|
|
compactor.stop();
|
|
model.terminate();
|
|
assistant.terminate();
|
|
setSidebarOpen(false);
|
|
setUnloaded();
|
|
syncSidebar();
|
|
});
|
|
|
|
renderChat();
|
|
setUnloaded();
|
|
return main;
|
|
}
|