mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-23 00:48:11 -07:00
next: ai part 2
This commit is contained in:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -109,6 +109,13 @@
|
||||
|
||||
<link rel="stylesheet" href="/home/style.css" />
|
||||
<link rel="stylesheet" href="/ask/style.css" />
|
||||
<link rel="stylesheet" href="/ask/layout/style.css" />
|
||||
<link rel="stylesheet" href="/ask/sidebar/style.css" />
|
||||
<link rel="stylesheet" href="/ask/hero/style.css" />
|
||||
<link rel="stylesheet" href="/ask/conversation/style.css" />
|
||||
<link rel="stylesheet" href="/ask/conversation/message/style.css" />
|
||||
<link rel="stylesheet" href="/ask/loader/style.css" />
|
||||
<link rel="stylesheet" href="/ask/composer/style.css" />
|
||||
<link rel="stylesheet" href="/explore/style.css" />
|
||||
<link rel="stylesheet" href="/explore/chain/cube/style.css" />
|
||||
<link rel="stylesheet" href="/explore/chain/style.css" />
|
||||
|
||||
Reference in New Issue
Block a user