next: ai part 2

This commit is contained in:
nym21
2026-07-22 16:50:27 +02:00
parent 227a31f64e
commit 52e4db5ea6
18 changed files with 1318 additions and 1281 deletions
+86
View File
@@ -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;
},
};
}
+76
View File
@@ -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: "";
}
}
}
}
+79
View File
@@ -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();
},
};
}
@@ -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 };
}
@@ -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%;
}
}
+35
View File
@@ -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;
}
}
}
+46
View File
@@ -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;
}
},
};
}
+88
View File
@@ -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);
}
}
}
+138 -264
View File
@@ -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;
}
+32
View File
@@ -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 };
}
+41
View File
@@ -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));
}
}
+120
View File
@@ -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,
);
},
};
}
+57
View File
@@ -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;
}
}
+117
View File
@@ -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();
},
};
}
+233
View File
@@ -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;
}
}
}
}
+14 -740
View File
@@ -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;
}
}
-277
View File
@@ -1,277 +0,0 @@
import { ASK_MODEL } from "./models.js";
import { renderMarkdown } from "./markdown.js";
/** @typedef {ReturnType<typeof createAskView>} 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));
}