diff --git a/website_next/ask/context.js b/website_next/ask/context.js new file mode 100644 index 000000000..7cff87495 --- /dev/null +++ b/website_next/ask/context.js @@ -0,0 +1,81 @@ +const MAX_INPUT_TOKENS = 5_600; +const KEEP_RECENT_MESSAGES = 5; + +const BASE_INSTRUCTIONS = + "You are a helpful assistant. Answer clearly and concisely."; + +const MEMORY_INSTRUCTIONS = `You maintain compact memory for a conversation. +Merge the existing memory with the newly provided dialogue. +Preserve concrete facts, names, numbers, user preferences, decisions, constraints, and unresolved questions. +Do not invent information. Remove repetition and obsolete conversational filler. +Return only the updated memory, using short factual bullet points.`; + +/** @typedef {import("./storage.js").StoredChat} StoredChat */ +/** @typedef {import("./model.js").AskModel} AskModel */ + +/** @param {StoredChat} chat */ +function messagesFor(chat) { + const instructions = chat.memory + ? `${BASE_INSTRUCTIONS}\n\nEarlier conversation memory:\n${chat.memory}` + : BASE_INSTRUCTIONS; + + return [ + { role: /** @type {const} */ ("system"), content: instructions }, + ...chat.messages.slice(chat.compactedCount), + ]; +} + +/** + * @param {string} memory + * @param {StoredChat["messages"]} messages + */ +function compactionPrompt(memory, messages) { + const dialogue = messages + .map((message) => `${message.role.toUpperCase()}: ${message.content}`) + .join("\n\n"); + + return [ + { role: /** @type {const} */ ("system"), content: MEMORY_INSTRUCTIONS }, + { + role: /** @type {const} */ ("user"), + content: `Existing memory:\n${memory || "(none)"}\n\nDialogue to incorporate:\n${dialogue}`, + }, + ]; +} + +/** + * @param {StoredChat} chat + * @param {AskModel} model + * @param {() => void} onCompacting + */ +export async function prepareContext(chat, model, onCompacting) { + const messages = messagesFor(chat); + const tokenCount = await model.countTokens(messages); + const compactThrough = chat.messages.length - KEEP_RECENT_MESSAGES; + + if ( + tokenCount <= MAX_INPUT_TOKENS || + compactThrough <= chat.compactedCount + ) { + return { chat, messages, compacted: false }; + } + + onCompacting(); + const memory = await model.compact( + compactionPrompt( + chat.memory, + chat.messages.slice(chat.compactedCount, compactThrough), + ), + ); + const compactedChat = { + ...chat, + memory: memory.trim(), + compactedCount: compactThrough, + }; + + return { + chat: compactedChat, + messages: messagesFor(compactedChat), + compacted: true, + }; +} diff --git a/website_next/ask/index.js b/website_next/ask/index.js new file mode 100644 index 000000000..c5697cdef --- /dev/null +++ b/website_next/ask/index.js @@ -0,0 +1,487 @@ +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; +} diff --git a/website_next/ask/markdown.js b/website_next/ask/markdown.js new file mode 100644 index 000000000..7c41e5fc3 --- /dev/null +++ b/website_next/ask/markdown.js @@ -0,0 +1,210 @@ +/** + * @typedef {Object} InlinePattern + * @property {RegExp} pattern + * @property {(match: RegExpExecArray) => HTMLElement} create + */ + +/** @type {InlinePattern[]} */ +const INLINE_PATTERNS = [ + { + pattern: /`([^`\n]+)`/, + create(match) { + const code = document.createElement("code"); + code.textContent = match[1]; + return code; + }, + }, + { + pattern: /\[([^\]\n]+)\]\(((?:https?:\/\/|\/|#)[^\s)]+)\)/, + create(match) { + const anchor = document.createElement("a"); + anchor.href = match[2]; + appendInline(anchor, match[1]); + + if (anchor.origin !== window.location.origin) { + anchor.target = "_blank"; + anchor.rel = "noreferrer"; + } + + return anchor; + }, + }, + { + pattern: /\*\*([^*\n]+)\*\*/, + create(match) { + const strong = document.createElement("strong"); + appendInline(strong, match[1]); + return strong; + }, + }, + { + pattern: /__([^_\n]+)__/, + create(match) { + const strong = document.createElement("strong"); + appendInline(strong, match[1]); + return strong; + }, + }, + { + pattern: /\*([^*\n]+)\*/, + create(match) { + const emphasis = document.createElement("em"); + appendInline(emphasis, match[1]); + return emphasis; + }, + }, + { + pattern: /_([^_\n]+)_/, + create(match) { + const emphasis = document.createElement("em"); + appendInline(emphasis, match[1]); + return emphasis; + }, + }, +]; + +/** + * @param {HTMLElement} parent + * @param {string} source + */ +function appendInline(parent, source) { + let remaining = source; + + while (remaining) { + /** @type {{ entry: InlinePattern, match: RegExpExecArray } | undefined} */ + let token; + + for (const entry of INLINE_PATTERNS) { + const match = entry.pattern.exec(remaining); + if (match && (!token || match.index < token.match.index)) { + token = { entry, match }; + } + } + + if (!token) { + parent.append(remaining); + return; + } + + const { entry, match } = token; + parent.append(remaining.slice(0, match.index), entry.create(match)); + remaining = remaining.slice(match.index + match[0].length); + } +} + +/** @param {string} line */ +function isBlockStart(line) { + return ( + /^```/.test(line) || + /^#{1,4}\s/.test(line) || + /^>\s?/.test(line) || + /^(?:[-+*]|\d+\.)\s+/.test(line) + ); +} + +/** + * @param {HTMLElement} parent + * @param {string} text + */ +function appendTextBlock(parent, text) { + appendInline(parent, text.trim()); +} + +/** + * @param {HTMLElement} target + * @param {string} source + */ +export function renderMarkdown(target, source) { + const lines = source.replaceAll("\r\n", "\n").split("\n"); + const fragment = document.createDocumentFragment(); + let index = 0; + + while (index < lines.length) { + const line = lines[index]; + + if (!line.trim()) { + index += 1; + continue; + } + + const fence = /^```([^\s`]*)\s*$/.exec(line); + if (fence) { + const pre = document.createElement("pre"); + const code = document.createElement("code"); + const content = []; + index += 1; + + while (index < lines.length && !/^```\s*$/.test(lines[index])) { + content.push(lines[index]); + index += 1; + } + + code.textContent = content.join("\n"); + if (fence[1]) code.dataset.language = fence[1]; + pre.append(code); + fragment.append(pre); + index += index < lines.length ? 1 : 0; + continue; + } + + const heading = /^(#{1,4})\s+(.+)$/.exec(line); + if (heading) { + const element = document.createElement(`h${heading[1].length}`); + appendTextBlock(element, heading[2]); + fragment.append(element); + index += 1; + continue; + } + + if (/^>\s?/.test(line)) { + const quote = document.createElement("blockquote"); + const content = []; + + while (index < lines.length && /^>\s?/.test(lines[index])) { + content.push(lines[index].replace(/^>\s?/, "")); + index += 1; + } + + appendTextBlock(quote, content.join(" ")); + fragment.append(quote); + continue; + } + + const listItem = /^(?:([-+*])|(\d+)\.)\s+(.+)$/.exec(line); + if (listItem) { + const ordered = Boolean(listItem[2]); + const list = document.createElement(ordered ? "ol" : "ul"); + + while (index < lines.length) { + const itemMatch = /^(?:([-+*])|(\d+)\.)\s+(.+)$/.exec(lines[index]); + if (!itemMatch || Boolean(itemMatch[2]) !== ordered) break; + + const item = document.createElement("li"); + appendTextBlock(item, itemMatch[3]); + list.append(item); + index += 1; + } + + fragment.append(list); + continue; + } + + const paragraph = document.createElement("p"); + const content = [line]; + index += 1; + + while ( + index < lines.length && + lines[index].trim() && + !isBlockStart(lines[index]) + ) { + content.push(lines[index]); + index += 1; + } + + appendTextBlock(paragraph, content.join(" ")); + fragment.append(paragraph); + } + + target.replaceChildren(fragment); +} diff --git a/website_next/ask/model.js b/website_next/ask/model.js new file mode 100644 index 000000000..855814c6f --- /dev/null +++ b/website_next/ask/model.js @@ -0,0 +1,183 @@ +const WORKER_URL = import.meta.resolve("./worker.js"); + +/** + * @typedef {{ role: "system" | "user" | "assistant", content: string }} ChatMessage + * @typedef {{ progress: number, loaded: number, total: number }} LoadProgress + * @typedef {{ text: string, tokensPerSecond?: number }} TokenUpdate + */ + +export class AskModel { + /** @type {Worker | undefined} */ + #worker; + + /** @type {((progress: LoadProgress) => void) | undefined} */ + #onProgress; + + /** @type {((status: string) => void) | undefined} */ + #onStatus; + + /** @type {((update: TokenUpdate) => void) | undefined} */ + #onToken; + + /** @type {((value: any) => void) | undefined} */ + #resolve; + + /** @type {((reason: Error) => void) | undefined} */ + #reject; + + /** + * @param {(progress: LoadProgress) => void} onProgress + * @param {(status: string) => void} onStatus + */ + load(onProgress, onStatus) { + this.#ensureWorker(); + this.#onProgress = onProgress; + this.#onStatus = onStatus; + + return new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + this.#worker?.postMessage({ type: "load" }); + }); + } + + isCached() { + this.#ensureWorker(); + + return /** @type {Promise} */ ( + new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + this.#worker?.postMessage({ type: "cache-status" }); + }) + ); + } + + /** + * @param {ChatMessage[]} messages + * @param {(update: TokenUpdate) => void} onToken + */ + generate(messages, onToken) { + return /** @type {Promise} */ ( + this.#request("generate", messages, onToken) + ); + } + + /** @param {ChatMessage[]} messages */ + compact(messages) { + return /** @type {Promise} */ ( + this.#request("compact", messages) + ); + } + + /** @param {ChatMessage[]} messages */ + countTokens(messages) { + return /** @type {Promise} */ ( + this.#request("count", messages) + ); + } + + stop() { + this.#worker?.postMessage({ type: "interrupt" }); + } + + reset() { + this.#worker?.postMessage({ type: "reset" }); + } + + #ensureWorker() { + if (this.#worker) return; + + this.#worker = new Worker(WORKER_URL, { type: "module" }); + this.#worker.addEventListener("message", this.#handleMessage); + this.#worker.addEventListener("error", this.#handleWorkerError); + } + + /** + * @param {"generate" | "compact" | "count"} type + * @param {ChatMessage[]} messages + * @param {((update: TokenUpdate) => void) | undefined} [onToken] + */ + #request(type, messages, onToken) { + if (!this.#worker) throw new Error("Model is not loaded"); + + this.#onToken = onToken; + return new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + this.#worker?.postMessage({ type, data: messages }); + }); + } + + terminate() { + const reject = this.#reject; + this.#worker?.terminate(); + this.#worker = undefined; + this.#onProgress = undefined; + this.#onStatus = undefined; + this.#onToken = undefined; + this.#resolve = undefined; + this.#reject = undefined; + reject?.(new Error("Model stopped")); + } + + /** @param {MessageEvent} event */ + #handleMessage = (event) => { + const message = event.data; + + switch (message.status) { + case "loading": + this.#onStatus?.(message.data); + break; + case "progress_total": + this.#onProgress?.({ + progress: message.progress, + loaded: message.loaded, + total: message.total, + }); + break; + case "ready": + this.#settle(message.status); + break; + case "update": + this.#onToken?.({ + text: message.output, + tokensPerSecond: message.tokensPerSecond, + }); + break; + case "complete": + this.#settle(message.output); + break; + case "counted": + this.#settle(message.count); + break; + case "cache-status": + this.#settle(message.cached); + break; + case "error": + this.#fail(new Error(message.data)); + break; + } + }; + + /** @param {ErrorEvent} event */ + #handleWorkerError = (event) => { + this.#fail(new Error(event.message || "The model worker failed")); + }; + + /** @param {any} value */ + #settle(value) { + this.#resolve?.(value); + this.#onToken = undefined; + this.#resolve = undefined; + this.#reject = undefined; + } + + /** @param {Error} error */ + #fail(error) { + this.#reject?.(error); + this.#onToken = undefined; + this.#resolve = undefined; + this.#reject = undefined; + } +} diff --git a/website_next/ask/models.js b/website_next/ask/models.js new file mode 100644 index 000000000..d5b4f4ebb --- /dev/null +++ b/website_next/ask/models.js @@ -0,0 +1,7 @@ +export const ASK_MODEL = /** @type {const} */ ({ + name: "Ternary Bonsai 1.7B", + modelId: "onnx-community/Ternary-Bonsai-1.7B-ONNX", + revision: "8beb5ca724ad1489e02c7834d065cfce1a759628", + dtype: "q2", + size: "~507 MB", +}); diff --git a/website_next/ask/storage.js b/website_next/ask/storage.js new file mode 100644 index 000000000..dd7f360ab --- /dev/null +++ b/website_next/ask/storage.js @@ -0,0 +1,239 @@ +const INDEX_KEY = "bitview.ask.chats.v2"; +const CHAT_KEY_PREFIX = "bitview.ask.chat.v2."; +const LEGACY_KEY = "bitview.ask.v1"; +const NEW_CHAT_TITLE = "New chat"; + +/** + * @typedef {Object} StoredMessage + * @property {"user" | "assistant"} role + * @property {string} content + * + * @typedef {Object} ChatMeta + * @property {string} id + * @property {string} title + * @property {number} createdAt + * @property {number} updatedAt + * @property {number} messageCount + * + * @typedef {Object} StoredChat + * @property {string} id + * @property {string} title + * @property {number} createdAt + * @property {number} updatedAt + * @property {number} messageCount + * @property {StoredMessage[]} messages + * @property {string} memory + * @property {number} compactedCount + * + * @typedef {Object} ChatIndex + * @property {string} activeChatId + * @property {ChatMeta[]} chats + */ + +/** @param {unknown} value */ +function isStoredMessage(value) { + if (!value || typeof value !== "object") return false; + + const message = /** @type {Record} */ (value); + return ( + (message.role === "user" || message.role === "assistant") && + typeof message.content === "string" + ); +} + +/** @param {string} question */ +function titleFromQuestion(question) { + const title = question.replace(/\s+/g, " ").trim(); + return title.length > 44 ? `${title.slice(0, 43)}…` : title; +} + +/** @returns {ChatMeta} */ +function createMeta() { + const now = Date.now(); + return { + id: crypto.randomUUID(), + title: NEW_CHAT_TITLE, + createdAt: now, + updatedAt: now, + messageCount: 0, + }; +} + +/** @param {ChatMeta} meta */ +function emptyChat(meta) { + return { ...meta, messages: [], memory: "", compactedCount: 0 }; +} + +/** @param {string} id */ +function chatKey(id) { + return `${CHAT_KEY_PREFIX}${id}`; +} + +/** @param {ChatIndex} index */ +function writeIndex(index) { + localStorage.setItem(INDEX_KEY, JSON.stringify(index)); +} + +/** @param {StoredChat} chat */ +function writeChat(chat) { + localStorage.setItem( + chatKey(chat.id), + JSON.stringify({ + messages: chat.messages, + memory: chat.memory, + compactedCount: chat.compactedCount, + }), + ); +} + +/** @returns {ChatIndex | undefined} */ +function readIndex() { + const value = localStorage.getItem(INDEX_KEY); + if (!value) return undefined; + + const index = /** @type {ChatIndex} */ (JSON.parse(value)); + let migrated = false; + const chats = index.chats.map((meta) => { + if (Number.isInteger(meta.messageCount)) return meta; + + migrated = true; + return { ...meta, messageCount: readChat(meta).messages.length }; + }); + + if (!migrated) return index; + + const next = { ...index, chats }; + writeIndex(next); + return next; +} + +/** @param {ChatMeta} meta */ +function readChat(meta) { + const value = localStorage.getItem(chatKey(meta.id)); + if (!value) return emptyChat(meta); + + const stored = JSON.parse(value); + const messages = Array.isArray(stored.messages) + ? stored.messages.filter(isStoredMessage) + : []; + const compactedCount = Math.min( + Number(stored.compactedCount) || 0, + messages.length, + ); + + return { + ...meta, + messageCount: messages.length, + messages, + memory: typeof stored.memory === "string" ? stored.memory : "", + compactedCount, + }; +} + +/** @returns {ChatIndex} */ +function initialize() { + const meta = createMeta(); + const legacy = localStorage.getItem(LEGACY_KEY); + const messages = /** @type {StoredMessage[]} */ ( + legacy ? JSON.parse(legacy).filter(isStoredMessage) : [] + ); + + if (messages.length) { + meta.messageCount = messages.length; + const firstQuestion = messages.find((message) => message.role === "user"); + if (firstQuestion) meta.title = titleFromQuestion(firstQuestion.content); + } + + const index = { activeChatId: meta.id, chats: [meta] }; + writeChat({ ...emptyChat(meta), messages }); + writeIndex(index); + localStorage.removeItem(LEGACY_KEY); + return index; +} + +function load() { + const index = readIndex() ?? initialize(); + const activeMeta = + index.chats.find((chat) => chat.id === index.activeChatId) ?? index.chats[0]; + + return { + chats: index.chats, + activeChat: readChat(activeMeta), + }; +} + +function create() { + const index = readIndex() ?? initialize(); + const meta = createMeta(); + const next = { activeChatId: meta.id, chats: [meta, ...index.chats] }; + + writeChat(emptyChat(meta)); + writeIndex(next); + return { chats: next.chats, activeChat: emptyChat(meta) }; +} + +/** @param {string} id */ +function select(id) { + const index = readIndex() ?? initialize(); + const meta = index.chats.find((chat) => chat.id === id); + if (!meta) throw new Error("Chat not found"); + + writeIndex({ ...index, activeChatId: id }); + return { chats: index.chats, activeChat: readChat(meta) }; +} + +/** @param {StoredChat} chat */ +function save(chat) { + const index = readIndex() ?? initialize(); + const now = Date.now(); + const firstQuestion = chat.messages.find((message) => message.role === "user"); + const title = + chat.title === NEW_CHAT_TITLE && firstQuestion + ? titleFromQuestion(firstQuestion.content) + : chat.title; + const saved = { + ...chat, + title, + updatedAt: now, + messageCount: chat.messages.length, + }; + const meta = { + id: saved.id, + title: saved.title, + createdAt: saved.createdAt, + updatedAt: saved.updatedAt, + messageCount: saved.messages.length, + }; + const chats = [meta, ...index.chats.filter((item) => item.id !== chat.id)]; + + writeChat(saved); + writeIndex({ activeChatId: saved.id, chats }); + return { chats, activeChat: saved }; +} + +/** @param {string} id */ +function remove(id) { + const index = readIndex() ?? initialize(); + const chats = index.chats.filter((chat) => chat.id !== id); + + if (!chats.length) { + const next = createMeta(); + writeChat(emptyChat(next)); + writeIndex({ activeChatId: next.id, chats: [next] }); + } else { + const activeChatId = + index.activeChatId === id ? chats[0].id : index.activeChatId; + writeIndex({ activeChatId, chats }); + } + + localStorage.removeItem(chatKey(id)); + return load(); +} + +export const askStorage = /** @type {const} */ ({ + load, + create, + select, + save, + remove, +}); diff --git a/website_next/ask/style.css b/website_next/ask/style.css new file mode 100644 index 000000000..ec372b538 --- /dev/null +++ b/website_next/ask/style.css @@ -0,0 +1,766 @@ +main[data-page="ask"] { + --ask-border: color-mix(in oklch, var(--white) 10%, transparent); + --ask-panel: color-mix(in oklch, var(--white) 3%, var(--black)); + --ask-content-width: 52rem; + --ask-sidebar-width: 17rem; + --ask-sidebar-gutter: 1.25rem; + --ask-control-size: 2.375rem; + --ask-header-control-size: 2rem; + --ask-header-control-gap: 0.5rem; + + position: relative; + width: 100%; + height: 100dvh; + overflow: hidden; + + [data-ask="backdrop"] { + display: none; + } + + [data-ask="sidebar"] { + position: absolute; + inset: 0 auto 0 0; + z-index: 9; + display: grid; + grid-template-rows: minmax(0, 1fr); + width: var(--ask-sidebar-width); + min-width: 0; + padding: 4.5rem var(--ask-sidebar-gutter) 1rem; + border-right: 1px solid var(--ask-border); + background: transparent; + overflow: hidden; + transition: + opacity 140ms ease, + transform 180ms ease; + } + + [data-ask="history"] { + height: 100%; + min-height: 0; + } + + [data-ask="chats"] { + display: grid; + align-content: start; + gap: 0.125rem; + max-height: 100%; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; + } + + [data-ask="chat"] { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + min-width: 0; + border-radius: 0.25rem; + overflow: hidden; + + @media (hover: hover) and (pointer: fine) { + &:has([data-ask="chat-select"]:hover) { + background: var(--white); + + [data-ask="chat-remove"] { + color: var(--black); + } + } + } + + &:has([data-ask="chat-select"]:active) { + background: var(--orange); + + [data-ask="chat-remove"] { + color: var(--black); + } + } + + &:has([data-ask="chat-select"][data-press]) { + background: var(--white); + + [data-ask="chat-remove"] { + color: var(--black); + } + } + } + + [data-ask="chat-select"] { + --line-gap: 0.5rem; + --line-gutter: 1.25rem; + --line-inset: 0.5rem; + + position: relative; + display: block; + width: 100%; + min-width: 0; + border: 0; + border-radius: 0; + padding: 0.25rem; + padding-inline-start: calc(var(--line-gutter) + var(--line-gap)); + overflow: hidden; + color: var(--gray); + background: transparent; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + text-align: left; + text-overflow: ellipsis; + text-transform: none; + white-space: nowrap; + + &::after { + position: absolute; + top: 50%; + left: var(--line-inset); + width: calc(var(--line-gutter) - var(--line-inset)); + border-block-start: 1px solid currentColor; + opacity: 0.5; + pointer-events: none; + translate: 0 -50%; + content: ""; + } + + &[aria-current="page"] { + color: var(--white); + } + + @media (hover: hover) and (pointer: fine) { + &:hover { + color: var(--black); + background: transparent; + } + } + + &:active { + color: var(--black); + background: transparent; + } + + &[data-press] { + color: var(--black); + background: transparent; + } + } + + [data-ask="workspace"] { + position: absolute; + inset: 0; + overflow: hidden; + transition: left 180ms ease; + } + + [data-ask="topbar"] { + position: absolute; + inset: 0; + z-index: 10; + pointer-events: none; + } + + [data-ask="topbar-main"] { + position: absolute; + display: flex; + align-items: center; + gap: var(--ask-header-control-gap); + min-width: 0; + pointer-events: auto; + } + + [data-ask="topbar-main"] { + position: fixed; + top: 1.5rem; + left: calc(var(--page-x) + 8.5rem); + } + + [data-ask="new-chat"], + [data-ask="sidebar-toggle"] { + width: var(--ask-header-control-size); + height: var(--ask-header-control-size); + border-radius: 0.5rem; + padding: 0; + font-size: var(--font-size-lg); + line-height: 1; + text-transform: none; + } + + [data-ask="sidebar-toggle-icon"] { + transition: transform 160ms ease; + } + + [data-ask="sidebar-toggle"][aria-expanded="true"] + [data-ask="sidebar-toggle-icon"] { + transform: rotate(180deg); + } + + [data-ask="conversation"] { + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + scroll-padding-block: 1.5rem 7rem; + scrollbar-gutter: stable; + mask-image: linear-gradient( + to bottom, + transparent, + black 0.85rem, + black calc(100% - 6rem), + transparent + ); + } + + [data-ask="empty"] { + display: grid; + place-content: center; + width: min(100%, 44rem); + min-height: 100%; + margin-inline: auto; + padding: 6rem 1.5rem 10rem; + text-align: center; + + h2, + p { + margin: 0; + } + + h2 { + margin-top: 0.75rem; + font-family: var(--font-serif); + font-size: clamp(2.75rem, 5vw, 3.75rem); + font-weight: var(--font-weight-regular); + line-height: 0.95; + letter-spacing: -0.03em; + text-wrap: balance; + } + + > p:not([data-ask="eyebrow"]) { + max-width: 28rem; + margin: 0.75rem auto 0; + color: var(--gray); + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + text-wrap: balance; + } + } + + [data-ask="eyebrow"] { + color: var(--orange); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-strong); + line-height: 1; + text-transform: uppercase; + letter-spacing: 0.12em; + } + + [data-ask="suggestions"] { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; + width: min(100%, 38rem); + margin-top: 1.5rem; + margin-inline: auto; + } + + [data-ask="suggestion"] { + justify-content: space-between; + gap: 0.625rem; + width: 100%; + min-height: var(--ask-control-size); + border-radius: var(--control-radius); + padding: 0.5rem 0.75rem; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + text-transform: none; + + span:last-child { + color: inherit; + opacity: 0.55; + } + } + + [data-ask="transcript"] { + display: grid; + align-content: start; + gap: 1.5rem; + width: min(100% - 3rem, var(--ask-content-width)); + margin: 0 auto; + padding: 1.5rem 0 7.5rem; + list-style: none; + } + + [data-ask="message"] { + display: grid; + gap: 0.35rem; + max-width: 100%; + + &[data-role="user"] { + justify-self: end; + max-width: min(82%, 34rem); + padding: 0.5rem 0.75rem; + border: 0; + border-radius: 0.65rem 0.65rem 0.15rem 0.65rem; + color: var(--black); + background: var(--white); + } + + } + + [data-ask="role"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + [data-ask="content"] { + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + white-space: pre-wrap; + overflow-wrap: anywhere; + + > * { + white-space: normal; + } + + > * + * { + margin-top: 0.75rem; + } + + h1, + h2, + h3, + h4 { + color: var(--white); + font-weight: var(--font-weight-regular); + line-height: 1.1; + } + + h1, + h2 { + font-size: var(--font-size-lg); + } + + h3, + h4 { + font-size: var(--font-size-base); + } + + :is(ul, ol) { + display: grid; + gap: 0.4rem; + padding-left: 1.35rem; + } + + li::marker { + color: var(--orange); + } + + strong { + font-weight: 600; + } + + a { + color: var(--white); + text-decoration-color: var(--orange); + text-decoration-thickness: 1px; + text-underline-offset: 0.15em; + } + + :not(pre) > code { + border: 1px solid var(--ask-border); + border-radius: 0.2rem; + padding: 0.08em 0.28em; + color: color-mix(in oklch, var(--orange) 72%, var(--white)); + background: var(--ask-panel); + font-size: 0.9em; + } + + pre { + max-width: 100%; + border: 1px solid var(--ask-border); + border-radius: var(--control-radius); + padding: 0.85rem 1rem; + background: var(--ask-panel); + overflow-x: auto; + white-space: pre; + + code { + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + } + } + + blockquote { + border-left: 1px solid var(--orange); + padding-left: 0.85rem; + color: color-mix(in oklch, var(--white) 72%, transparent); + } + } + + [data-ask="dock"] { + position: absolute; + bottom: 0; + left: 50%; + z-index: 8; + display: grid; + gap: 0.5rem; + width: min(calc(100% - 2rem), var(--ask-content-width)); + padding: 1.25rem 0 max(var(--page-x), env(safe-area-inset-bottom)); + background: linear-gradient(to bottom, transparent, var(--black) 1.25rem); + translate: -50% 0; + } + + [data-ask="composer"] { + border: 1px solid var(--field-border-color); + border-radius: var(--control-radius); + background: color-mix(in oklch, var(--white) 3%, var(--black)); + } + + [data-ask="loader"] { + display: grid; + gap: 0.5rem; + + progress { + appearance: none; + width: 100%; + height: 0.2rem; + border: 0; + border-radius: 999px; + overflow: hidden; + background: color-mix(in oklch, var(--white) 10%, transparent); + accent-color: var(--orange); + + &::-webkit-progress-bar { + border-radius: inherit; + background: color-mix(in oklch, var(--white) 10%, transparent); + } + + &::-webkit-progress-value { + border-radius: inherit; + background: var(--orange); + } + + &::-moz-progress-bar { + border-radius: inherit; + background: var(--orange); + } + } + } + + [data-ask="load"] { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.75rem; + width: 100%; + border-radius: var(--control-radius); + padding: var(--control-padding); + text-align: left; + text-transform: none; + + small { + color: inherit; + font-size: var(--font-size-xs); + opacity: 0.55; + } + } + + [data-ask="form"], + [data-ask="form"] fieldset, + [data-ask="form"] label { + display: block; + margin: 0; + padding: 0; + border: 0; + } + + [data-ask="composer"] { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.25rem; + padding: 0.25rem; + transition: border-color 120ms ease; + + &:focus-within { + border-color: var(--orange); + } + + textarea { + display: block; + field-sizing: content; + width: 100%; + min-height: var(--ask-control-size); + max-height: 12rem; + border: 0; + padding: 0.5625rem 0.75rem; + background: transparent; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + resize: none; + + &:focus-visible { + outline: 0; + } + } + } + + [data-ask="composer"] > label { + grid-column: 1; + grid-row: 1; + min-width: 0; + } + + [data-ask="composer-bar"] { + grid-column: 2; + grid-row: 1; + align-self: center; + padding: 0; + } + + [data-ask="composer-hint"] { + display: none; + } + + [data-ask="composer-actions"] { + display: flex; + align-items: center; + } + + [data-ask="chat-remove"] { + position: relative; + isolation: isolate; + width: 1.75rem; + height: 1.75rem; + border-radius: 0.25rem; + overflow: hidden; + padding: 0; + color: color-mix(in oklch, var(--gray) 76%, transparent); + background: transparent; + font-size: var(--font-size-lg); + line-height: 1; + text-transform: none; + --hold-progress: 0; + --hold-progress-width: 0%; + + &::before, + &::after { + position: absolute; + inset: 0; + opacity: 0; + content: ""; + } + + &::before { + z-index: -1; + background: var(--red); + transform: scaleX(var(--hold-progress)); + transform-origin: left; + } + + &::after { + content: attr(data-label); + display: flex; + align-items: center; + justify-content: center; + padding: inherit; + color: var(--black); + pointer-events: none; + white-space: nowrap; + clip-path: inset(0 calc(100% - var(--hold-progress-width)) 0 0); + } + + @media (hover: hover) and (pointer: fine) { + &:hover:not([data-holding]) { + color: var(--red); + background: transparent; + } + } + + &:is(:focus-visible, :active, [data-press]):not([data-holding]) { + color: var(--red); + background: transparent; + } + + &[data-active] { + color: var(--red); + } + + &[data-active]::before, + &[data-active]::after { + opacity: 1; + } + } + + [data-ask="send"], + [data-ask="stop"] { + width: var(--ask-control-size); + height: var(--ask-control-size); + border-radius: var(--control-radius); + padding: 0; + font-size: 1.15rem; + line-height: 1; + } + + [data-ask="stop"] { + &::before { + width: 0.55rem; + height: 0.55rem; + border-radius: 0.08rem; + background: currentColor; + content: ""; + } + } + + [data-ask="activity"] { + margin: 0; + color: color-mix(in oklch, var(--white) 42%, transparent); + font-size: var(--font-size-xs); + line-height: var(--line-height-xs); + text-align: center; + overflow-wrap: anywhere; + } + + [data-ask="visually-hidden"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } +} + +@media (min-width: 48.001rem) { + main[data-page="ask"][data-sidebar-collapsed] { + [data-ask="sidebar"] { + opacity: 0; + pointer-events: none; + transform: translateX(-1rem); + } + } + + main[data-page="ask"]:not([data-sidebar-collapsed]) { + [data-ask="workspace"] { + left: var(--ask-sidebar-width); + } + } +} + +@media (max-width: 48rem) { + main[data-page="ask"] { + [data-ask="backdrop"] { + position: fixed; + inset: 0; + z-index: 19; + display: block; + width: 100%; + height: 100%; + border-radius: 0; + padding: 0; + background: color-mix(in oklch, var(--black) 65%, transparent); + opacity: 0; + pointer-events: none; + transition: opacity 160ms ease; + } + + [data-ask="sidebar"] { + position: fixed; + inset: 0 auto 0 0; + z-index: 20; + width: min(86vw, 19rem); + background: var(--black); + box-shadow: 1.5rem 0 4rem color-mix(in oklch, var(--black) 60%, transparent); + transform: translateX(-105%); + transition: transform 180ms ease; + } + + &[data-sidebar-open] { + [data-ask="topbar"] { + z-index: 21; + } + + [data-ask="backdrop"] { + opacity: 1; + pointer-events: auto; + } + + [data-ask="sidebar"] { + transform: translateX(0); + } + } + + [data-ask="topbar"] { + padding: 0; + } + + [data-ask="sidebar-toggle"] { + display: inline-flex; + } + + [data-ask="transcript"] { + width: min(100% - 2rem, var(--ask-content-width)); + padding-top: 5.75rem; + } + + [data-ask="conversation"] { + scroll-padding-top: 5.75rem; + } + + [data-ask="message"][data-role="user"] { + max-width: 90%; + } + + [data-ask="dock"] { + width: 100%; + padding: 0.75rem 1rem max(1rem, env(safe-area-inset-bottom)); + } + + [data-ask="empty"] { + padding: 6rem 1rem 8rem; + } + + [data-ask="composer-hint"] { + display: none; + } + } +} + +@media (max-width: 32rem) { + main[data-page="ask"] { + [data-ask="loader"] { + grid-template-columns: 1fr; + } + + [data-ask="load"] { + width: 100%; + } + + [data-ask="empty"] h2 { + font-size: 3.25rem; + } + + [data-ask="suggestions"] { + grid-template-columns: 1fr; + width: min(100%, 20rem); + margin-inline: auto; + } + } +} + +@media (prefers-reduced-motion: reduce) { + main[data-page="ask"] { + transition: none; + + [data-ask="sidebar"], + [data-ask="backdrop"], + [data-ask="workspace"], + [data-ask="composer"], + [data-ask="sidebar-toggle-icon"] { + transition: none; + } + } +} diff --git a/website_next/ask/view.js b/website_next/ask/view.js new file mode 100644 index 000000000..c4998162f --- /dev/null +++ b/website_next/ask/view.js @@ -0,0 +1,277 @@ +import { ASK_MODEL } from "./models.js"; +import { renderMarkdown } from "./markdown.js"; + +/** @typedef {ReturnType} AskView */ +/** @typedef {import("./storage.js").ChatMeta} ChatMeta */ + +/** + * @template {keyof HTMLElementTagNameMap} Tag + * @param {Tag} tag + * @param {string} part + * @returns {HTMLElementTagNameMap[Tag]} + */ +function createPart(tag, part) { + const element = document.createElement(tag); + element.dataset.ask = part; + return element; +} + +function createNewChatButton() { + const button = createPart("button", "new-chat"); + + button.type = "button"; + button.ariaLabel = "New chat"; + button.title = "New chat"; + button.append("+"); + return button; +} + +export function createAskView() { + const main = createPart("main", "page"); + const backdrop = createPart("button", "backdrop"); + const sidebar = createPart("aside", "sidebar"); + + main.dataset.page = "ask"; + main.dataset.state = "unloaded"; + backdrop.type = "button"; + backdrop.ariaLabel = "Close chats"; + sidebar.id = "ask-sidebar"; + + const history = createPart("section", "history"); + const chats = createPart("ol", "chats"); + + history.append(chats); + + sidebar.append(history); + + const workspace = createPart("section", "workspace"); + const topbar = createPart("header", "topbar"); + const topbarMain = createPart("div", "topbar-main"); + const newChatButton = createNewChatButton(); + const sidebarToggle = createPart("button", "sidebar-toggle"); + const sidebarToggleIcon = createPart("span", "sidebar-toggle-icon"); + + sidebarToggle.type = "button"; + sidebarToggle.ariaLabel = "Show chats"; + sidebarToggle.title = "Show chats"; + sidebarToggleIcon.ariaHidden = "true"; + sidebarToggleIcon.append("→"); + sidebarToggle.append(sidebarToggleIcon); + sidebarToggle.setAttribute("aria-controls", sidebar.id); + sidebarToggle.setAttribute("aria-expanded", "false"); + topbarMain.append(newChatButton, sidebarToggle); + topbar.append(topbarMain); + + const conversation = createPart("section", "conversation"); + const empty = createPart("div", "empty"); + const eyebrow = createPart("p", "eyebrow"); + const greeting = document.createElement("h2"); + const description = document.createElement("p"); + const suggestions = createPart("div", "suggestions"); + const transcript = createPart("ol", "transcript"); + + eyebrow.append("Bitcoin · Private · On-device"); + greeting.append("Ask about Bitcoin"); + description.append( + "Explore the protocol, network, and on-chain data—privately, in your browser.", + ); + + for (const [label, prompt] of [ + ["Explain a metric", "Explain this Bitcoin metric simply: "], + ["Explore mining", "Explain Bitcoin mining and proof of work."], + ["Understand fees", "Explain how Bitcoin transaction fees work."], + ]) { + const suggestion = createPart("button", "suggestion"); + const suggestionLabel = document.createElement("span"); + const suggestionArrow = document.createElement("span"); + + suggestion.type = "button"; + suggestion.dataset.prompt = prompt; + suggestionLabel.append(label); + suggestionArrow.ariaHidden = "true"; + suggestionArrow.append("↗"); + suggestion.append(suggestionLabel, suggestionArrow); + suggestions.append(suggestion); + } + + empty.append(eyebrow, greeting, description, suggestions); + transcript.ariaLive = "polite"; + transcript.hidden = true; + conversation.append(empty, transcript); + + const dock = createPart("footer", "dock"); + const loader = createPart("section", "loader"); + const activity = createPart("p", "activity"); + const loadButton = createPart("button", "load"); + const loadLabel = document.createElement("span"); + const loadDetail = document.createElement("small"); + const progress = document.createElement("progress"); + + activity.role = "status"; + activity.ariaLive = "polite"; + activity.append("Not loaded"); + activity.hidden = true; + loadButton.type = "button"; + loadLabel.append("Load model"); + loadDetail.append(ASK_MODEL.size); + loadButton.append(loadLabel, loadDetail); + progress.max = 100; + progress.hidden = true; + loader.append(loadButton, progress); + + const form = createPart("form", "form"); + const fieldset = document.createElement("fieldset"); + const composer = createPart("div", "composer"); + const inputLabel = document.createElement("label"); + const inputLabelText = createPart("span", "visually-hidden"); + const input = document.createElement("textarea"); + const composerBar = createPart("div", "composer-bar"); + const composerHint = createPart("span", "composer-hint"); + const composerActions = createPart("div", "composer-actions"); + const askButton = createPart("button", "send"); + const stopButton = createPart("button", "stop"); + + form.hidden = true; + fieldset.disabled = true; + inputLabelText.append("Message assistant"); + input.name = "question"; + input.rows = 1; + input.maxLength = 4_000; + input.placeholder = "Ask anything"; + input.required = true; + inputLabel.append(inputLabelText, input); + composerHint.append("Enter to send · Shift Enter for a new line"); + askButton.type = "submit"; + askButton.ariaLabel = "Send message"; + askButton.title = "Send message"; + askButton.append("↑"); + stopButton.type = "button"; + stopButton.ariaLabel = "Stop generating"; + stopButton.title = "Stop generating"; + stopButton.hidden = true; + composerActions.append(askButton, stopButton); + composerBar.append(composerHint, composerActions); + composer.append(inputLabel, composerBar); + fieldset.append(composer); + form.append(fieldset); + dock.append(loader, form, activity); + + workspace.append(topbar, conversation, dock); + main.append(backdrop, sidebar, workspace); + + return { + main, + backdrop, + chats, + sidebarToggle, + suggestions, + empty, + conversation, + transcript, + loader, + loadButton, + loadLabel, + loadDetail, + progress, + form, + fieldset, + input, + activity, + askButton, + stopButton, + newChatButtons: [newChatButton], + }; +} + +/** + * @param {HTMLOListElement} transcript + * @param {"user" | "assistant"} role + * @param {string} text + */ +export function appendMessage(transcript, role, text) { + const item = createPart("li", "message"); + const label = createPart("strong", "role"); + const content = createPart("div", "content"); + + item.dataset.role = role; + label.append(role === "user" ? "You" : "Assistant"); + setMessageContent(content, role, text); + item.append(label, content); + transcript.append(item); + transcript.hidden = false; + + return { item, content }; +} + +/** + * @param {HTMLElement} content + * @param {"user" | "assistant"} role + * @param {string} text + */ +export function setMessageContent(content, role, text) { + if (role === "assistant") renderMarkdown(content, text); + else content.textContent = text; +} + +/** @param {AskView} view */ +export function clearMessages(view) { + view.transcript.replaceChildren(); + view.transcript.hidden = true; + view.empty.hidden = false; + delete view.main.dataset.hasMessages; +} + +/** + * @param {AskView} view + * @param {import("./storage.js").StoredMessage[]} messages + */ +export function renderMessages(view, messages) { + clearMessages(view); + for (const message of messages) { + appendMessage(view.transcript, message.role, message.content); + } +} + +/** + * @param {AskView} view + * @param {ChatMeta[]} chats + * @param {string} activeChatId + */ +export function renderChats(view, chats, activeChatId) { + const scrollTop = view.chats.scrollTop; + const items = chats.filter((chat) => chat.messageCount > 0).map((chat) => { + const item = createPart("li", "chat"); + const active = chat.id === activeChatId; + const select = createPart("button", "chat-select"); + const remove = createPart("button", "chat-remove"); + + item.toggleAttribute("data-active", active); + select.type = "button"; + select.dataset.action = "select"; + select.dataset.chatId = chat.id; + if (active) select.setAttribute("aria-current", "page"); + select.title = chat.title; + select.textContent = chat.title; + remove.type = "button"; + remove.dataset.action = "remove"; + remove.dataset.chatId = chat.id; + remove.dataset.label = "×"; + remove.ariaLabel = `Hold to delete ${chat.title}`; + remove.title = "Hold to delete chat"; + remove.textContent = "×"; + item.append(select, remove); + return item; + }); + + view.chats.replaceChildren(...items); + view.chats.scrollTop = scrollTop; +} + +/** + * @param {AskView} view + * @param {boolean} open + */ +export function setSidebarOpen(view, open) { + view.main.toggleAttribute("data-sidebar-open", open); + view.sidebarToggle.setAttribute("aria-expanded", String(open)); +} diff --git a/website_next/ask/worker.js b/website_next/ask/worker.js new file mode 100644 index 000000000..e583df02b --- /dev/null +++ b/website_next/ask/worker.js @@ -0,0 +1,172 @@ +import { ASK_MODEL } from "./models.js"; + +const TRANSFORMERS_URL = + "https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.1.0"; + +/** @type {any} CDN module without local declarations. */ +let generator; + +/** @type {any} CDN module without local declarations. */ +let stoppingCriteria; + +/** @type {any} CDN module without local declarations. */ +let transformers; + +/** @param {unknown} error */ +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function load() { + const adapter = await navigator.gpu?.requestAdapter(); + if (!adapter) throw new Error("WebGPU is unavailable or no adapter was found"); + + self.postMessage({ status: "loading", data: "Loading AI runtime..." }); + await loadRuntime(); + stoppingCriteria = new transformers.InterruptableStoppingCriteria(); + + self.postMessage({ status: "loading", data: `Loading ${ASK_MODEL.name}...` }); + generator = await transformers.pipeline("text-generation", ASK_MODEL.modelId, { + device: "webgpu", + dtype: ASK_MODEL.dtype, + revision: ASK_MODEL.revision, + /** @param {{ status: string, progress?: number, loaded?: number, total?: number }} info */ + progress_callback: (info) => { + if (info.status !== "progress_total") return; + + self.postMessage({ + status: "progress_total", + progress: Number(info.progress ?? 0), + loaded: Number(info.loaded ?? 0), + total: Number(info.total ?? 0), + }); + }, + }); + + self.postMessage({ status: "loading", data: "Warming up WebGPU..." }); + const inputs = generator.tokenizer("a"); + await generator.model.generate({ ...inputs, max_new_tokens: 1 }); + self.postMessage({ status: "ready" }); +} + +async function loadRuntime() { + transformers ??= await import(TRANSFORMERS_URL); + transformers.env.allowLocalModels = false; +} + +async function checkCache() { + await loadRuntime(); + const cached = await transformers.ModelRegistry.is_pipeline_cached( + "text-generation", + ASK_MODEL.modelId, + { + device: "webgpu", + dtype: ASK_MODEL.dtype, + revision: ASK_MODEL.revision, + }, + ); + self.postMessage({ status: "cache-status", cached }); +} + +/** + * @param {{ role: string, content: string }[]} messages + * @param {{ maxNewTokens: number, stream: boolean }} options + */ +async function generate(messages, options) { + if (!generator || !stoppingCriteria || !transformers) { + throw new Error("Model is not loaded"); + } + + let startedAt; + let tokenCount = 0; + /** @type {number | undefined} */ + let tokensPerSecond; + const streamer = options.stream + ? new transformers.TextStreamer(generator.tokenizer, { + skip_prompt: true, + skip_special_tokens: true, + /** @param {string} output */ + callback_function: (output) => { + self.postMessage({ status: "update", output, tokensPerSecond }); + }, + token_callback_function: () => { + startedAt ??= performance.now(); + tokenCount += 1; + + if (tokenCount > 1) { + tokensPerSecond = + (tokenCount / (performance.now() - startedAt)) * 1_000; + } + }, + }) + : undefined; + const cache = new transformers.DynamicCache(); + + try { + const output = await generator(messages, { + max_new_tokens: options.maxNewTokens, + do_sample: false, + streamer, + stopping_criteria: stoppingCriteria, + past_key_values: cache, + }); + + self.postMessage({ + status: "complete", + output: output[0].generated_text.at(-1).content, + }); + } finally { + cache.dispose?.(); + } +} + +function reset() { + stoppingCriteria?.reset(); +} + +/** @param {{ role: string, content: string }[]} messages */ +function countTokens(messages) { + if (!generator) throw new Error("Model is not loaded"); + + const tokens = generator.tokenizer.apply_chat_template(messages, { + add_generation_prompt: true, + tokenize: true, + return_tensor: false, + return_dict: false, + }); + self.postMessage({ status: "counted", count: tokens.length }); +} + +self.addEventListener("message", async (event) => { + const { type, data } = event.data; + + try { + switch (type) { + case "cache-status": + await checkCache(); + break; + case "load": + await load(); + break; + case "generate": + stoppingCriteria?.reset(); + await generate(data, { maxNewTokens: 384, stream: true }); + break; + case "compact": + stoppingCriteria?.reset(); + await generate(data, { maxNewTokens: 512, stream: false }); + break; + case "count": + countTokens(data); + break; + case "interrupt": + stoppingCriteria?.interrupt(); + break; + case "reset": + reset(); + break; + } + } catch (error) { + self.postMessage({ status: "error", data: errorMessage(error) }); + } +}); diff --git a/website_next/home/index.js b/website_next/home/index.js index 6f5ad88d2..29e8b1803 100644 --- a/website_next/home/index.js +++ b/website_next/home/index.js @@ -2,6 +2,7 @@ const links = [ { href: "/explore", label: "Explore" }, { href: "/learn", label: "Learn" }, { href: "/build", label: "Build" }, + { href: "/ask", label: "Ask" }, { href: "/wallets", label: "Wallets" }, ]; diff --git a/website_next/home/style.css b/website_next/home/style.css index f4297139f..8f4aa6c79 100644 --- a/website_next/home/style.css +++ b/website_next/home/style.css @@ -13,6 +13,8 @@ main[data-page="home"] { nav { display: flex; + flex-wrap: wrap; + justify-content: center; gap: 0.5rem; font-size: var(--font-size-xs); line-height: 1; diff --git a/website_next/index.html b/website_next/index.html index a685b04b1..ec667ef6c 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -108,6 +108,7 @@ + diff --git a/website_next/routes.js b/website_next/routes.js index 112127d60..e75a6878c 100644 --- a/website_next/routes.js +++ b/website_next/routes.js @@ -1,3 +1,4 @@ +import { createAskPage } from "./ask/index.js"; import { createBuildPage } from "./build/index.js"; import { createExplorePage } from "./explore/index.js"; import { createHomePage } from "./home/index.js"; @@ -6,6 +7,7 @@ import { createWalletsPage } from "./wallets/index.js"; const routes = /** @type {const} */ ({ "/": createHomePage, + "/ask": createAskPage, "/explore": createExplorePage, "/learn": createLearnPage, "/build": createBuildPage, diff --git a/website_next/utils/hold.js b/website_next/utils/hold.js new file mode 100644 index 000000000..c6141d9ab --- /dev/null +++ b/website_next/utils/hold.js @@ -0,0 +1,104 @@ +const FILL_MS = 2_000; +const DRAIN_MS = 600; + +/** @param {number} value */ +function clampProgress(value) { + return Math.max(0, Math.min(1, value)); +} + +/** + * @param {HTMLButtonElement} button + * @param {() => void} onHold + * @param {number} [fillMs] + */ +export function bindHold(button, onHold, fillMs = FILL_MS) { + /** @type {number | undefined} */ + let frame; + let holding = false; + let progress = 0; + let previous = 0; + + function render() { + button.style.setProperty("--hold-progress", String(progress)); + button.style.setProperty("--hold-progress-width", `${progress * 100}%`); + button.toggleAttribute("data-active", progress > 0); + } + + function stop() { + if (frame === undefined) return; + + cancelAnimationFrame(frame); + frame = undefined; + } + + /** @param {number} now */ + function tick(now) { + const elapsed = now - previous; + const rate = elapsed / (holding ? fillMs : DRAIN_MS); + + previous = now; + progress = clampProgress(progress + (holding ? rate : -rate)); + render(); + + if (holding && progress === 1) { + stop(); + holding = false; + progress = 0; + button.removeAttribute("data-holding"); + render(); + onHold(); + return; + } + + if (!holding && progress === 0) { + stop(); + return; + } + + frame = requestAnimationFrame(tick); + } + + function run() { + if (frame !== undefined) return; + + previous = performance.now(); + frame = requestAnimationFrame(tick); + } + + function release() { + if (!holding) return; + + holding = false; + button.removeAttribute("data-holding"); + run(); + } + + function hold() { + stop(); + holding = true; + button.dataset.holding = ""; + run(); + } + + render(); + + button.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + + button.setPointerCapture(event.pointerId); + hold(); + }); + button.addEventListener("pointerup", release); + button.addEventListener("pointercancel", release); + button.addEventListener("lostpointercapture", release); + button.addEventListener("keydown", (event) => { + if (event.repeat || (event.key !== " " && event.key !== "Enter")) return; + + event.preventDefault(); + hold(); + }); + button.addEventListener("keyup", (event) => { + if (event.key === " " || event.key === "Enter") release(); + }); + button.addEventListener("blur", release); +} diff --git a/website_next/wallets/hold/index.js b/website_next/wallets/hold/index.js index eb6e8b585..ae4a1ed99 100644 --- a/website_next/wallets/hold/index.js +++ b/website_next/wallets/hold/index.js @@ -1,115 +1,5 @@ import { createWalletPart } from "../dom.js"; - -const FILL_MS = 2_000; -const DRAIN_MS = 600; - -/** - * @param {number} value - */ -function clampProgress(value) { - return Math.max(0, Math.min(1, value)); -} - -/** - * @param {HTMLButtonElement} button - * @param {() => void} onHold - */ -function bindHold(button, onHold) { - /** @type {number | undefined} */ - let frame; - let holding = false; - let progress = 0; - let previous = 0; - - function render() { - button.style.setProperty("--hold-progress", String(progress)); - button.style.setProperty("--hold-progress-width", `${progress * 100}%`); - button.toggleAttribute("data-active", progress > 0); - } - - function stop() { - if (frame === undefined) return; - - cancelAnimationFrame(frame); - frame = undefined; - } - - /** - * @param {number} now - */ - function tick(now) { - const elapsed = now - previous; - const rate = elapsed / (holding ? FILL_MS : DRAIN_MS); - - previous = now; - progress = clampProgress(progress + (holding ? rate : -rate)); - render(); - - if (holding && progress === 1) { - stop(); - holding = false; - progress = 0; - button.removeAttribute("data-holding"); - render(); - onHold(); - return; - } - - if (!holding && progress === 0) { - stop(); - return; - } - - frame = requestAnimationFrame(tick); - } - - function run() { - if (frame !== undefined) return; - - previous = performance.now(); - frame = requestAnimationFrame(tick); - } - - function release() { - if (!holding) return; - - holding = false; - button.removeAttribute("data-holding"); - run(); - } - - function hold() { - stop(); - - holding = true; - button.dataset.holding = ""; - run(); - } - - render(); - - button.addEventListener("pointerdown", (event) => { - if (event.button !== 0) return; - - button.setPointerCapture(event.pointerId); - hold(); - }); - button.addEventListener("pointerup", release); - button.addEventListener("pointercancel", release); - button.addEventListener("lostpointercapture", release); - button.addEventListener("keydown", (event) => { - if (event.repeat || (event.key !== " " && event.key !== "Enter")) return; - - event.preventDefault(); - hold(); - }); - button.addEventListener("keyup", (event) => { - if (event.key === " " || event.key === "Enter") { - release(); - } - }); - button.addEventListener("blur", release); -} +import { bindHold } from "../../utils/hold.js"; /** * @param {Object} options