mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-07-22 16:38:11 -07:00
next: ai part 1
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
});
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user