From 52e4db5ea600b484a972cec4799680542dff6fbc Mon Sep 17 00:00:00 2001 From: nym21 Date: Wed, 22 Jul 2026 16:50:27 +0200 Subject: [PATCH] next: ai part 2 --- website_next/ask/composer/index.js | 86 ++ website_next/ask/composer/style.css | 76 ++ website_next/ask/conversation/index.js | 79 ++ .../ask/conversation/message/index.js | 30 + .../ask/conversation/message/style.css | 119 +++ website_next/ask/conversation/style.css | 35 + website_next/ask/hero/index.js | 46 ++ website_next/ask/hero/style.css | 88 ++ website_next/ask/index.js | 402 ++++------ website_next/ask/layout/index.js | 32 + website_next/ask/layout/style.css | 41 + website_next/ask/loader/index.js | 120 +++ website_next/ask/loader/style.css | 57 ++ website_next/ask/sidebar/index.js | 117 +++ website_next/ask/sidebar/style.css | 233 ++++++ website_next/ask/style.css | 754 +----------------- website_next/ask/view.js | 277 ------- website_next/index.html | 7 + 18 files changed, 1318 insertions(+), 1281 deletions(-) create mode 100644 website_next/ask/composer/index.js create mode 100644 website_next/ask/composer/style.css create mode 100644 website_next/ask/conversation/index.js create mode 100644 website_next/ask/conversation/message/index.js create mode 100644 website_next/ask/conversation/message/style.css create mode 100644 website_next/ask/conversation/style.css create mode 100644 website_next/ask/hero/index.js create mode 100644 website_next/ask/hero/style.css create mode 100644 website_next/ask/layout/index.js create mode 100644 website_next/ask/layout/style.css create mode 100644 website_next/ask/loader/index.js create mode 100644 website_next/ask/loader/style.css create mode 100644 website_next/ask/sidebar/index.js create mode 100644 website_next/ask/sidebar/style.css delete mode 100644 website_next/ask/view.js diff --git a/website_next/ask/composer/index.js b/website_next/ask/composer/index.js new file mode 100644 index 000000000..de58fa06d --- /dev/null +++ b/website_next/ask/composer/index.js @@ -0,0 +1,86 @@ +/** + * @typedef {Object} AskComposerOptions + * @property {(question: string) => void} onSubmit + * @property {() => void} onStop + */ + +/** @param {AskComposerOptions} options */ +export function createAskComposer(options) { + const form = document.createElement("form"); + const fieldset = document.createElement("fieldset"); + const label = document.createElement("label"); + const labelText = document.createElement("span"); + const input = document.createElement("textarea"); + const actions = document.createElement("div"); + const send = document.createElement("button"); + const sendIcon = document.createElement("span"); + const stop = document.createElement("button"); + + form.dataset.askComposer = ""; + form.hidden = true; + fieldset.disabled = true; + labelText.append("Message assistant"); + input.name = "question"; + input.rows = 1; + input.maxLength = 4_000; + input.placeholder = "Ask anything"; + input.required = true; + send.type = "submit"; + send.ariaLabel = "Send message"; + send.title = "Send message"; + sendIcon.ariaHidden = "true"; + sendIcon.append("↑"); + send.append(sendIcon); + stop.type = "button"; + stop.ariaLabel = "Stop generating"; + stop.title = "Stop generating"; + stop.hidden = true; + stop.addEventListener("click", options.onStop); + input.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.shiftKey) return; + + event.preventDefault(); + form.requestSubmit(); + }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + + const question = input.value.trim(); + if (question) options.onSubmit(question); + }); + label.append(labelText, input); + actions.append(send, stop); + fieldset.append(label, actions); + form.append(fieldset); + + return { + element: form, + get value() { + return input.value; + }, + set value(value) { + input.value = value; + }, + focus() { + input.focus(); + }, + hide() { + form.hidden = true; + fieldset.disabled = true; + }, + ready() { + form.hidden = false; + fieldset.disabled = false; + input.disabled = false; + send.hidden = false; + stop.hidden = true; + }, + generating() { + form.hidden = false; + fieldset.disabled = false; + input.disabled = true; + send.hidden = true; + stop.hidden = false; + }, + }; +} diff --git a/website_next/ask/composer/style.css b/website_next/ask/composer/style.css new file mode 100644 index 000000000..5fb52efa2 --- /dev/null +++ b/website_next/ask/composer/style.css @@ -0,0 +1,76 @@ +main[data-page="ask"] [data-ask-composer] { + position: relative; + display: block; + border: 1px solid var(--field-border-color); + border-radius: var(--control-radius); + padding: 0.25rem; + background: var(--ask-panel); + transition: border-color 120ms ease; + + &:focus-within { + border-color: var(--orange); + } + + > fieldset { + display: block; + margin: 0; + padding: 0; + border: 0; + + > label { + display: block; + margin: 0; + padding: 0; + border: 0; + + > span { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + > textarea { + display: block; + field-sizing: content; + width: 100%; + min-height: var(--ask-control-size); + max-height: 12rem; + border: 0; + padding: 0.5625rem + calc(var(--ask-icon-size) + 0.75rem) 0.5625rem 0.75rem; + background: transparent; + font-size: var(--font-size-sm); + line-height: var(--line-height-sm); + resize: none; + + &:focus-visible { + outline: 0; + } + } + } + + > div { + position: absolute; + top: 50%; + right: 0.25rem; + display: grid; + place-items: center; + width: var(--ask-icon-size); + height: var(--ask-icon-size); + translate: 0 -50%; + + > button:last-child::before { + width: 0.55rem; + height: 0.55rem; + border-radius: 0.08rem; + background: currentColor; + content: ""; + } + } + } +} diff --git a/website_next/ask/conversation/index.js b/website_next/ask/conversation/index.js new file mode 100644 index 000000000..b12c4f9d8 --- /dev/null +++ b/website_next/ask/conversation/index.js @@ -0,0 +1,79 @@ +import { + createAskMessage, + setMessageContent, +} from "./message/index.js"; + +/** @typedef {import("../storage.js").StoredMessage} StoredMessage */ + +/** + * @typedef {Object} AskConversationOptions + * @property {HTMLElement} hero + * @property {() => void} onScroll + */ + +/** @param {AskConversationOptions} options */ +export function createAskConversation(options) { + const conversation = document.createElement("section"); + const transcript = document.createElement("ol"); + let scrollFrame = 0; + + conversation.dataset.askConversation = ""; + conversation.setAttribute("aria-label", "Conversation"); + transcript.ariaLive = "polite"; + transcript.hidden = true; + conversation.append(options.hero, transcript); + conversation.addEventListener("scroll", options.onScroll); + + function sync() { + const hasMessages = transcript.childElementCount > 0; + + options.hero.hidden = hasMessages; + transcript.hidden = !hasMessages; + } + + return { + element: conversation, + sync, + isNearBottom() { + const { clientHeight, scrollHeight, scrollTop } = conversation; + return scrollHeight - clientHeight - scrollTop < 96; + }, + cancelScroll() { + cancelAnimationFrame(scrollFrame); + }, + scrollToBottom() { + cancelAnimationFrame(scrollFrame); + scrollFrame = requestAnimationFrame(() => { + conversation.scrollTop = conversation.scrollHeight; + }); + }, + /** + * @param {"user" | "assistant"} role + * @param {string} text + */ + append(role, text) { + const message = createAskMessage(role, text); + + transcript.append(message.item); + sync(); + return message; + }, + /** + * @param {HTMLElement} content + * @param {"user" | "assistant"} role + * @param {string} text + */ + setContent(content, role, text) { + setMessageContent(content, role, text); + }, + /** @param {StoredMessage[]} messages */ + render(messages) { + transcript.replaceChildren( + ...messages.map((message) => + createAskMessage(message.role, message.content).item + ), + ); + sync(); + }, + }; +} diff --git a/website_next/ask/conversation/message/index.js b/website_next/ask/conversation/message/index.js new file mode 100644 index 000000000..3f6d1cb60 --- /dev/null +++ b/website_next/ask/conversation/message/index.js @@ -0,0 +1,30 @@ +import { renderMarkdown } from "../../markdown.js"; + +/** @typedef {"user" | "assistant"} MessageRole */ + +/** + * @param {HTMLElement} content + * @param {MessageRole} role + * @param {string} text + */ +export function setMessageContent(content, role, text) { + if (role === "assistant") renderMarkdown(content, text); + else content.textContent = text; +} + +/** + * @param {MessageRole} role + * @param {string} text + */ +export function createAskMessage(role, text) { + const item = document.createElement("li"); + const label = document.createElement("strong"); + const content = document.createElement("div"); + + item.dataset.role = role; + label.append(role === "user" ? "You" : "Assistant"); + setMessageContent(content, role, text); + item.append(label, content); + + return { item, content }; +} diff --git a/website_next/ask/conversation/message/style.css b/website_next/ask/conversation/message/style.css new file mode 100644 index 000000000..a4d4de864 --- /dev/null +++ b/website_next/ask/conversation/message/style.css @@ -0,0 +1,119 @@ +main[data-page="ask"] [data-ask-conversation] > ol > li { + 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-radius: 0.65rem 0.65rem 0.15rem 0.65rem; + color: var(--black); + background: var(--white); + } + + > strong { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; + } + + > div { + 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); + } + } +} + +@media (max-width: 48rem) { + main[data-page="ask"] + [data-ask-conversation] + > ol + > li[data-role="user"] { + max-width: 90%; + } +} diff --git a/website_next/ask/conversation/style.css b/website_next/ask/conversation/style.css new file mode 100644 index 000000000..903185c4b --- /dev/null +++ b/website_next/ask/conversation/style.css @@ -0,0 +1,35 @@ +main[data-page="ask"] [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 + ); + + > ol { + 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; + } +} + +@media (max-width: 48rem) { + main[data-page="ask"] [data-ask-conversation] { + scroll-padding-top: 5.75rem; + + > ol { + width: min(100% - 2rem, var(--ask-content-width)); + padding-top: 5.75rem; + } + } +} diff --git a/website_next/ask/hero/index.js b/website_next/ask/hero/index.js new file mode 100644 index 000000000..aca05a52d --- /dev/null +++ b/website_next/ask/hero/index.js @@ -0,0 +1,46 @@ +const SUGGESTIONS = /** @type {const} */ ([ + ["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."], +]); + +/** @param {{ onPrompt: (prompt: string) => void }} options */ +export function createAskHero(options) { + const hero = document.createElement("div"); + const eyebrow = document.createElement("p"); + const title = document.createElement("h1"); + const description = document.createElement("p"); + const suggestions = document.createElement("nav"); + + hero.dataset.askHero = ""; + eyebrow.append("Bitcoin · Private · On-device"); + title.append("Ask about Bitcoin"); + description.append( + "Explore the protocol, network, and on-chain data—privately, in your browser.", + ); + suggestions.setAttribute("aria-label", "Suggested questions"); + + for (const [label, prompt] of SUGGESTIONS) { + const button = document.createElement("button"); + const arrow = document.createElement("span"); + + button.type = "button"; + arrow.ariaHidden = "true"; + arrow.append("↗"); + button.append(label, arrow); + button.addEventListener("click", () => options.onPrompt(prompt)); + suggestions.append(button); + } + + hero.append(eyebrow, title, description, suggestions); + + return { + element: hero, + /** @param {boolean} enabled */ + setEnabled(enabled) { + for (const button of suggestions.querySelectorAll("button")) { + button.disabled = !enabled; + } + }, + }; +} diff --git a/website_next/ask/hero/style.css b/website_next/ask/hero/style.css new file mode 100644 index 000000000..fcaf3aa11 --- /dev/null +++ b/website_next/ask/hero/style.css @@ -0,0 +1,88 @@ +main[data-page="ask"] [data-ask-hero] { + display: grid; + place-content: center; + width: min(100%, 44rem); + min-height: 100%; + margin-inline: auto; + padding: 6rem 1.5rem 10rem; + text-align: center; + + > p, + > h1 { + margin: 0; + } + + > p:first-child { + 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; + } + + > h1 { + 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:nth-child(3) { + 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; + } + + > nav { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.5rem; + width: min(100%, 38rem); + margin-top: 1.5rem; + margin-inline: auto; + + > button { + 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-align: left; + text-transform: none; + + > span { + color: inherit; + opacity: 0.55; + } + } + } +} + +@media (max-width: 48rem) { + main[data-page="ask"] [data-ask-hero] { + padding: 6rem 1rem 8rem; + } +} + +@media (max-width: 32rem) { + main[data-page="ask"] [data-ask-hero] { + > h1 { + font-size: 3.25rem; + } + + > nav { + grid-template-columns: 1fr; + width: min(100%, 20rem); + } + } +} diff --git a/website_next/ask/index.js b/website_next/ask/index.js index c5697cdef..15345ebf7 100644 --- a/website_next/ask/index.js +++ b/website_next/ask/index.js @@ -1,33 +1,21 @@ +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 { ASK_MODEL } from "./models.js"; +import { createAskSidebar } from "./sidebar/index.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; @@ -36,80 +24,79 @@ export function createAskPage() { 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"; + 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; + 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(); + }, + }); + 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 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 syncControls() { + sidebar.setBusy(busy); + hero.setEnabled(loaded && !busy); } function syncSidebar() { - const drawerOpen = view.main.hasAttribute("data-sidebar-open"); + const drawerOpen = 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; + main.toggleAttribute("data-sidebar-collapsed", sidebarCollapsed); + sidebar.setExpanded(expanded); } 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); - } + sidebar.render(workspace.chats, chat.id); } function renderChat() { renderChatList(); - renderMessages(view, chat.messages); - syncConversation(); - syncChatControls(); + conversation.render(chat.messages); + syncControls(); syncSidebar(); - scrollToBottom(); + conversation.scrollToBottom(); } function setChecking() { @@ -117,17 +104,10 @@ export function createAskPage() { 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(); + main.dataset.state = "checking"; + composer.hide(); + loader.checking(); + syncControls(); } /** @param {string | undefined} [detail] */ @@ -136,19 +116,10 @@ export function createAskPage() { 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(); + main.dataset.state = "unloaded"; + composer.hide(); + loader.unloaded(detail); + syncControls(); } /** @param {boolean} cached */ @@ -157,20 +128,10 @@ export function createAskPage() { 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(); + main.dataset.state = "loading"; + composer.hide(); + loader.loading(cached); + syncControls(); } /** @param {unknown} error */ @@ -179,17 +140,10 @@ export function createAskPage() { 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(); + main.dataset.state = "error"; + composer.hide(); + loader.loadError(errorMessage(error)); + syncControls(); } function setReady() { @@ -197,16 +151,10 @@ export function createAskPage() { 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(); + main.dataset.state = "ready"; + loader.ready(); + composer.ready(); + syncControls(); } /** @param {boolean} cached */ @@ -217,31 +165,13 @@ export function createAskPage() { 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; - }, + (update) => loader.reportProgress(update, cached), + (status) => loader.reportStatus(status, cached), ); - setReady(); - view.input.focus(); + composer.focus(); } catch (error) { - if (view.main.inert) return; + if (main.inert) return; model.terminate(); setLoadError(error); @@ -254,12 +184,12 @@ export function createAskPage() { setChecking(); try { const modelCached = await model.isCached(); - if (view.main.inert) return; + if (main.inert) return; if (modelCached) await loadModel(true); else setUnloaded(); } catch { - if (view.main.inert) return; + if (main.inert) return; model.terminate(); setUnloaded("Cache status unavailable. Download the model to continue."); @@ -271,6 +201,7 @@ export function createAskPage() { if (chat.messages.length) { const wasSingleChat = workspace.chats.length === 1; + workspace = askStorage.create(); chat = workspace.activeChat; if (wasSingleChat) { @@ -278,19 +209,22 @@ export function createAskPage() { localStorage.setItem(SIDEBAR_PREFERENCE_KEY, "false"); } model.reset(); - view.input.value = ""; + composer.value = ""; renderChat(); } - setSidebarOpen(view, false); + setSidebarOpen(false); syncSidebar(); - if (loaded) view.input.focus(); + if (loaded) { + setReady(); + composer.focus(); + } } /** @param {string} id */ function selectChat(id) { if (busy || id === chat.id) { - setSidebarOpen(view, false); + setSidebarOpen(false); syncSidebar(); return; } @@ -298,14 +232,14 @@ export function createAskPage() { workspace = askStorage.select(id); chat = workspace.activeChat; model.reset(); - view.input.value = ""; + composer.value = ""; renderChat(); - setSidebarOpen(view, false); + setSidebarOpen(false); syncSidebar(); if (loaded) { setReady(); - view.input.focus(); + composer.focus(); } else if (!checking && !loading) { setUnloaded(); } @@ -321,27 +255,31 @@ export function createAskPage() { workspace = askStorage.remove(id); chat = workspace.activeChat; model.reset(); - view.input.value = ""; + composer.value = ""; renderChat(); if (loaded) setReady(); else if (!checking && !loading) setUnloaded(); } - renderChat(); + function toggleSidebar() { + if (mobileSidebar.matches) { + setSidebarOpen(!main.hasAttribute("data-sidebar-open")); + } else { + sidebarCollapsed = !sidebarCollapsed; + localStorage.setItem(SIDEBAR_PREFERENCE_KEY, String(sidebarCollapsed)); + setSidebarOpen(false); + } + syncSidebar(); + } - view.loadButton.addEventListener("click", () => void loadModel(false)); - - view.form.addEventListener("submit", async (event) => { - event.preventDefault(); + /** @param {string} question */ + async function submitQuestion(question) { 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", ""); + composer.value = ""; + const questionMessage = conversation.append("user", question); + const answerMessage = conversation.append("assistant", ""); const draft = { ...chat, messages: [ @@ -349,16 +287,14 @@ export function createAskPage() { { 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(); + main.dataset.state = "generating"; + loader.ready(); + composer.generating(); + syncControls(); + conversation.scrollToBottom(); try { const prepared = await prepareContext(draft, model, () => {}); @@ -366,12 +302,12 @@ export function createAskPage() { prepared.messages, ({ text }) => { answerMessage.content.append(text); - if (followingOutput) scrollToBottom(); + if (followingOutput) conversation.scrollToBottom(); }, ); - setMessageContent(answerMessage.content, "assistant", output); - if (followingOutput) scrollToBottom(); + conversation.setContent(answerMessage.content, "assistant", output); + if (followingOutput) conversation.scrollToBottom(); workspace = askStorage.save({ ...prepared.chat, messages: [ @@ -382,106 +318,44 @@ export function createAskPage() { chat = workspace.activeChat; renderChatList(); setReady(); - view.input.focus(); + composer.focus(); } catch (error) { questionMessage.item.remove(); answerMessage.item.remove(); - view.input.value = question; - syncConversation(); + composer.value = question; + conversation.sync(); - if (view.main.inert) { + if (main.inert) { setUnloaded(); return; } setReady(); - view.activity.hidden = false; - view.activity.textContent = `Could not answer · ${errorMessage(error)}`; - view.input.focus(); + loader.answerError(errorMessage(error)); + composer.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); - } + sidebar.backdrop.addEventListener("click", () => { + setSidebarOpen(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) => { + main.addEventListener("keydown", (event) => { if (event.key !== "Escape") return; - setSidebarOpen(view, false); + setSidebarOpen(false); syncSidebar(); }); - mobileSidebar.addEventListener("change", syncSidebar); - - view.main.addEventListener("pageactive", () => void activateModel()); - - view.main.addEventListener("pageinactive", () => { + main.addEventListener("pageactive", () => void activateModel()); + main.addEventListener("pageinactive", () => { model.terminate(); - setSidebarOpen(view, false); + setSidebarOpen(false); setUnloaded(); syncSidebar(); }); + renderChat(); setUnloaded(); - return view.main; + return main; } diff --git a/website_next/ask/layout/index.js b/website_next/ask/layout/index.js new file mode 100644 index 000000000..6b220a82e --- /dev/null +++ b/website_next/ask/layout/index.js @@ -0,0 +1,32 @@ +/** + * @typedef {Object} AskLayoutPart + * @property {HTMLElement} element + * + * @typedef {Object} AskSidebarPart + * @property {HTMLButtonElement} backdrop + * @property {HTMLElement} controls + * @property {HTMLElement} element + * + * @typedef {Object} AskLayoutOptions + * @property {AskSidebarPart} sidebar + * @property {AskLayoutPart} conversation + * @property {AskLayoutPart} composer + * @property {AskLayoutPart} loader + */ + +/** @param {AskLayoutOptions} options */ +export function createAskLayout(options) { + const main = document.createElement("main"); + const workspace = document.createElement("section"); + const header = document.createElement("header"); + const dock = document.createElement("footer"); + + main.dataset.page = "ask"; + main.dataset.state = "unloaded"; + header.append(options.sidebar.controls); + dock.append(options.composer.element, options.loader.element); + workspace.append(header, options.conversation.element, dock); + main.append(options.sidebar.backdrop, options.sidebar.element, workspace); + + return { main }; +} diff --git a/website_next/ask/layout/style.css b/website_next/ask/layout/style.css new file mode 100644 index 000000000..100c29126 --- /dev/null +++ b/website_next/ask/layout/style.css @@ -0,0 +1,41 @@ +main[data-page="ask"] { + > section { + position: absolute; + inset: 0; + overflow: hidden; + transition: left 180ms ease; + + > header { + position: absolute; + inset: 0; + z-index: 10; + pointer-events: none; + } + + > footer { + 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; + } + } +} + +@media (min-width: 48.001rem) { + main[data-page="ask"]:not([data-sidebar-collapsed]) > section { + left: var(--ask-sidebar-width); + } +} + +@media (max-width: 48rem) { + main[data-page="ask"] > section > footer { + width: 100%; + padding: 0.75rem 1rem max(1rem, env(safe-area-inset-bottom)); + } +} diff --git a/website_next/ask/loader/index.js b/website_next/ask/loader/index.js new file mode 100644 index 000000000..56244be7c --- /dev/null +++ b/website_next/ask/loader/index.js @@ -0,0 +1,120 @@ +import { ASK_MODEL } from "../models.js"; + +/** @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 {{ onLoad: () => void }} options */ +export function createAskLoader(options) { + const loader = document.createElement("section"); + const load = document.createElement("button"); + const label = document.createElement("span"); + const detail = document.createElement("small"); + const progress = document.createElement("progress"); + const status = document.createElement("p"); + + loader.dataset.askLoader = ""; + load.type = "button"; + load.addEventListener("click", options.onLoad); + label.append("Download & use"); + detail.append(ASK_MODEL.size); + load.append(label, detail); + progress.max = 100; + progress.hidden = true; + status.role = "status"; + status.ariaLive = "polite"; + status.hidden = true; + loader.append(load, progress, status); + + /** @param {string} message */ + function showStatus(message) { + status.hidden = !message; + status.textContent = message; + } + + return { + element: loader, + checking() { + loader.hidden = false; + load.hidden = true; + progress.hidden = false; + progress.removeAttribute("value"); + showStatus("Checking browser cache..."); + }, + /** @param {string | undefined} [message] */ + unloaded(message) { + loader.hidden = false; + load.hidden = false; + load.disabled = false; + label.textContent = "Download & use"; + detail.textContent = ASK_MODEL.size; + progress.hidden = true; + showStatus(message ?? ""); + }, + /** @param {boolean} cached */ + loading(cached) { + loader.hidden = false; + load.hidden = true; + progress.hidden = false; + if (cached) progress.removeAttribute("value"); + else progress.value = 0; + showStatus( + cached + ? "Loading the model from browser storage..." + : "Preparing the one-time local download...", + ); + }, + /** @param {string} message */ + loadError(message) { + loader.hidden = false; + load.hidden = false; + load.disabled = false; + label.textContent = "Try again"; + progress.hidden = true; + showStatus(`Load failed · ${message}`); + }, + ready() { + loader.hidden = true; + }, + /** @param {string} message */ + answerError(message) { + loader.hidden = false; + load.hidden = true; + progress.hidden = true; + showStatus(`Could not answer · ${message}`); + }, + /** + * @param {{ progress: number, loaded: number, total: number }} update + * @param {boolean} cached + */ + reportProgress(update, cached) { + if (!cached) { + progress.hidden = false; + progress.value = update.progress; + } + showStatus( + update.total + ? cached + ? "Loading the model from browser storage..." + : `Downloading ${formatBytes(update.loaded)} of ${formatBytes(update.total)}...` + : cached + ? "Starting the cached model..." + : `Downloading model... ${update.progress.toFixed(0)}%`, + ); + }, + /** + * @param {string} message + * @param {boolean} cached + */ + reportStatus(message, cached) { + showStatus( + cached && message.includes("Downloading") + ? "Loading the model from browser storage..." + : message, + ); + }, + }; +} diff --git a/website_next/ask/loader/style.css b/website_next/ask/loader/style.css new file mode 100644 index 000000000..ed1906d55 --- /dev/null +++ b/website_next/ask/loader/style.css @@ -0,0 +1,57 @@ +main[data-page="ask"] [data-ask-loader] { + display: grid; + gap: 0.5rem; + + > button { + 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; + } + } + + > 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); + } + } + + > p { + 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; + } +} diff --git a/website_next/ask/sidebar/index.js b/website_next/ask/sidebar/index.js new file mode 100644 index 000000000..4590048cc --- /dev/null +++ b/website_next/ask/sidebar/index.js @@ -0,0 +1,117 @@ +import { bindHold } from "../../utils/hold.js"; + +const SIDEBAR_ID = "ask-sidebar"; + +/** @typedef {import("../storage.js").ChatMeta} ChatMeta */ + +/** + * @typedef {Object} AskSidebarOptions + * @property {() => void} onNew + * @property {() => void} onToggle + * @property {(id: string) => void} onSelect + * @property {(id: string) => void} onRemove + */ + +/** + * @param {string} label + * @param {string} glyph + */ +function createControl(label, glyph) { + const button = document.createElement("button"); + const icon = document.createElement("span"); + + button.type = "button"; + button.ariaLabel = label; + button.title = label; + icon.ariaHidden = "true"; + icon.append(glyph); + button.append(icon); + return button; +} + +/** @param {AskSidebarOptions} options */ +export function createAskSidebar(options) { + const backdrop = document.createElement("button"); + const aside = document.createElement("aside"); + const chats = document.createElement("ol"); + const controls = document.createElement("nav"); + const newChat = createControl("New chat", "+"); + const toggle = createControl("Show chats", "→"); + let busy = false; + + backdrop.type = "button"; + backdrop.ariaLabel = "Close chats"; + aside.id = SIDEBAR_ID; + chats.setAttribute("aria-label", "Chats"); + controls.setAttribute("aria-label", "Chat controls"); + toggle.setAttribute("aria-controls", SIDEBAR_ID); + toggle.setAttribute("aria-expanded", "false"); + newChat.addEventListener("click", options.onNew); + toggle.addEventListener("click", options.onToggle); + controls.append(newChat, toggle); + aside.append(chats); + + function syncDisabled() { + newChat.disabled = busy; + toggle.disabled = busy; + for (const button of chats.querySelectorAll("button")) { + button.disabled = busy; + } + } + + return { + backdrop, + controls, + element: aside, + newChat, + toggle, + /** @param {boolean} value */ + setBusy(value) { + busy = value; + syncDisabled(); + }, + /** @param {boolean} expanded */ + setExpanded(expanded) { + const label = expanded ? "Hide chats" : "Show chats"; + + toggle.setAttribute("aria-expanded", String(expanded)); + toggle.ariaLabel = label; + toggle.title = label; + }, + /** + * @param {ChatMeta[]} items + * @param {string} activeId + */ + render(items, activeId) { + const scrollTop = chats.scrollTop; + const rows = items.filter((chat) => chat.messageCount > 0).map((chat) => { + const item = document.createElement("li"); + const select = document.createElement("button"); + const remove = document.createElement("button"); + + select.type = "button"; + select.title = chat.title; + select.textContent = chat.title; + if (chat.id === activeId) select.setAttribute("aria-current", "page"); + select.addEventListener("click", () => options.onSelect(chat.id)); + + remove.type = "button"; + remove.ariaLabel = `Hold to delete ${chat.title}`; + remove.title = "Hold to delete chat"; + const icon = document.createElement("span"); + + icon.ariaHidden = "true"; + icon.append("×"); + remove.append(icon); + bindHold(remove, () => options.onRemove(chat.id), 1_000); + + item.append(select, remove); + return item; + }); + + chats.replaceChildren(...rows); + chats.scrollTop = scrollTop; + syncDisabled(); + }, + }; +} diff --git a/website_next/ask/sidebar/style.css b/website_next/ask/sidebar/style.css new file mode 100644 index 000000000..2bdad45cb --- /dev/null +++ b/website_next/ask/sidebar/style.css @@ -0,0 +1,233 @@ +main[data-page="ask"] { + > button { + display: none; + } + + > aside { + position: absolute; + inset: 0 auto 0 0; + z-index: 9; + width: var(--ask-sidebar-width); + min-width: 0; + padding: 4.5rem var(--ask-sidebar-gutter) 1rem; + background: transparent; + overflow: hidden; + transition: + opacity 140ms ease, + transform 180ms ease; + + &::after { + position: absolute; + inset: 0 0 0 auto; + width: 1px; + background: var(--ask-border); + pointer-events: none; + content: ""; + } + + > ol { + display: grid; + align-content: start; + gap: 0.125rem; + max-height: 100%; + margin: 0; + padding: 0; + overflow-y: auto; + list-style: none; + + > li { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + min-width: 0; + border-radius: 0.25rem; + overflow: hidden; + + > button:first-child { + --line-gap: 0.5rem; + --line-gutter: 1.25rem; + --line-inset: 0.5rem; + + position: relative; + display: block; + width: 100%; + min-width: 0; + border-radius: 0; + padding: 0.3125rem; + 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: ""; + } + + &:is(:hover, :active, [data-press], [aria-current="page"]) { + color: var(--black); + background: transparent; + } + } + + > button:last-child { + position: relative; + isolation: isolate; + overflow: hidden; + color: color-mix(in oklch, var(--gray) 76%, transparent); + background: transparent; + --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 { + display: grid; + place-items: center; + color: var(--black); + pointer-events: none; + clip-path: inset(0 calc(100% - var(--hold-progress-width)) 0 0); + content: "×"; + } + + @media (hover: hover) and (pointer: fine) { + &:hover:not([data-holding]) { + color: var(--red); + background: transparent; + } + } + + &:is(:focus-visible, :active, [data-press]):not([data-holding]), + &[data-active] { + color: var(--red); + background: transparent; + } + + &[data-active]::before, + &[data-active]::after { + opacity: 1; + } + } + + &:has(> button:first-child:is(:hover, :active, [data-press], [aria-current="page"])) { + background: var(--white); + + > button:last-child:not(:hover, :active, [data-active]) { + color: var(--black); + } + } + } + } + } + + > section > header > nav { + position: fixed; + top: 1.5rem; + left: var(--ask-sidebar-gutter); + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: flex-end; + width: calc( + var(--ask-sidebar-width) - var(--ask-sidebar-gutter) - + var(--ask-sidebar-gutter) + ); + min-width: 0; + pointer-events: auto; + + > button:last-child { + > span { + transition: transform 160ms ease; + } + + &[aria-expanded="true"] > span { + transform: rotate(180deg); + } + } + } +} + +@media (min-width: 48.001rem) { + main[data-page="ask"][data-sidebar-collapsed] > aside { + opacity: 0; + pointer-events: none; + transform: translateX(-1rem); + } +} + +@media (max-width: 48rem) { + main[data-page="ask"] { + > button { + 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; + } + + > aside { + position: fixed; + 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; + } + + > section > header > nav { + width: calc( + min(86vw, 19rem) - var(--ask-sidebar-gutter) - + var(--ask-sidebar-gutter) + ); + } + + &[data-sidebar-open] { + > button { + opacity: 1; + pointer-events: auto; + } + + > aside { + transform: translateX(0); + } + + > section > header { + z-index: 21; + } + } + } +} diff --git a/website_next/ask/style.css b/website_next/ask/style.css index ec372b538..f4974aa89 100644 --- a/website_next/ask/style.css +++ b/website_next/ask/style.css @@ -3,764 +3,38 @@ main[data-page="ask"] { --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-sidebar-gutter: 1rem; --ask-control-size: 2.375rem; - --ask-header-control-size: 2rem; - --ask-header-control-gap: 0.5rem; + --ask-icon-size: 1.875rem; 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); + :is(header nav > button, aside li > button:last-child, footer form button) { + display: inline-grid; + flex: 0 0 var(--ask-icon-size); + place-items: center; + width: var(--ask-icon-size); + height: var(--ask-icon-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) { + > span { 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; + place-items: center; + line-height: 1; } } } @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; - } + main[data-page="ask"] * { + scroll-behavior: auto !important; + transition-duration: 0s !important; } } diff --git a/website_next/ask/view.js b/website_next/ask/view.js deleted file mode 100644 index c4998162f..000000000 --- a/website_next/ask/view.js +++ /dev/null @@ -1,277 +0,0 @@ -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/index.html b/website_next/index.html index ec667ef6c..1ee927514 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -109,6 +109,13 @@ + + + + + + +