next: ai part 1

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