"use client"; import { useEffect, useId, useLayoutEffect, useRef, useState, type KeyboardEvent } from "react"; import { rowsFor, tokens } from "./stream"; import "./ask-bar.css"; export type AskModel = { id: string; name: string; detail: string }; export type AskMessage = { role: "user" | "assistant"; text: string; model?: string; files?: string[] }; export type AskBarProps = { greeting: string; placeholder?: string; models: AskModel[]; defaultModel?: string; /** Produce the reply for a prompt. Return the full text; it is streamed in word by word. */ respond?: (prompt: string, model: AskModel) => string | Promise; onSend?: (message: AskMessage) => void; theme?: "light" | "dark"; className?: string; }; type Speech = { start: () => void; stop: () => void; abort: () => void; onresult: ((e: { results: ArrayLike> }) => void) | null; onend: (() => void) | null; onerror: (() => void) | null; interimResults: boolean; continuous: boolean; lang: string }; const LINE = 24, PAD = 0, MAX_ROWS = 8; const Icon = { plus: , chevron: , mic: , send: , stop: , file: , photo: , spark: , template: , x: , check: , }; /** * Ask Bar * A quiet, full-screen place to start a conversation: a greeting over a deep * glow and one rounded bar to ask in. The bar grows as you write, swaps the * microphone for send when there is something to send, attaches files from * its plus menu and switches model from its own. Once you ask, the greeting * steps aside and the answer streams in above the bar. */ export function AskBar({ greeting, placeholder = "Ask anything", models, defaultModel, respond, onSend, theme = "dark", className = "" }: AskBarProps) { const id = useId(); const [text, setText] = useState(""); const [model, setModel] = useState(models.find((m) => m.id === defaultModel) ?? models[0]); const [menu, setMenu] = useState(null); const [files, setFiles] = useState([]); const [messages, setMessages] = useState([]); const [streaming, setStreaming] = useState(false); const [listening, setListening] = useState(false); const [note, setNote] = useState(""); const [rows, setRows] = useState(1); const area = useRef(null); const plusBtn = useRef(null); const modelBtn = useRef(null); const menuEl = useRef(null); const fileIn = useRef(null); const photoIn = useRef(null); const log = useRef(null); const speech = useRef(null); const run = useRef(0); // Grow the field with its content, up to eight lines. useLayoutEffect(() => { const a = area.current; if (!a) return; a.style.height = "auto"; const r = rowsFor(a.scrollHeight, LINE, PAD, MAX_ROWS); a.style.height = `${r * LINE}px`; setRows(r); }, [text]); // Menus: focus the current item on open, close on a click elsewhere. useEffect(() => { if (!menu) return; menuEl.current?.querySelector("[aria-checked='true'], [role^='menuitem']")?.focus(); const away = (e: PointerEvent) => { const t = e.target as Node; if (!menuEl.current?.contains(t) && !plusBtn.current?.contains(t) && !modelBtn.current?.contains(t)) setMenu(null); }; document.addEventListener("pointerdown", away); return () => document.removeEventListener("pointerdown", away); }, [menu]); useEffect(() => { if (!note) return; const t = setTimeout(() => setNote(""), 3600); return () => clearTimeout(t); }, [note]); useEffect(() => { const el = log.current; if (el) el.scrollTop = el.scrollHeight; }, [messages]); useEffect(() => () => { run.current++; speech.current?.abort(); }, []); const close = (back: "plus" | "model") => { setMenu(null); (back === "plus" ? plusBtn : modelBtn).current?.focus(); }; const onMenuKey = (e: KeyboardEvent) => { const items = [...(menuEl.current?.querySelectorAll("[role^='menuitem']") ?? [])]; const k = items.indexOf(document.activeElement as HTMLElement); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); items[(k + (e.key === "ArrowDown" ? 1 : items.length - 1)) % items.length]?.focus(); } else if (e.key === "Home" || e.key === "End") { e.preventDefault(); items[e.key === "Home" ? 0 : items.length - 1]?.focus(); } else if (e.key === "Escape" || e.key === "Tab") { e.preventDefault(); close(menu!); } }; const send = async () => { const prompt = text.trim(); if (!prompt || streaming) return; speech.current?.stop(); const mine: AskMessage = { role: "user", text: prompt, files: files.length ? files : undefined }; onSend?.(mine); setMessages((m) => [...m, mine, { role: "assistant", text: "", model: model.name }]); setText(""); setFiles([]); setStreaming(true); const token = ++run.current; const full = respond ? await respond(prompt, model) : ""; const parts = tokens(full || "…"); for (let i = 0; i < parts.length; i++) { if (run.current !== token) return; await new Promise((r) => setTimeout(r, 28 + Math.random() * 40)); const so = parts.slice(0, i + 1).join(""); setMessages((m) => m.map((x, j) => (j === m.length - 1 ? { ...x, text: so } : x))); } if (run.current === token) setStreaming(false); area.current?.focus(); }; const stop = () => { run.current++; setStreaming(false); area.current?.focus(); }; const listen = () => { if (listening) { speech.current?.stop(); return; } const W = window as unknown as { SpeechRecognition?: new () => Speech; webkitSpeechRecognition?: new () => Speech }; const SR = W.SpeechRecognition ?? W.webkitSpeechRecognition; if (!SR) { setNote("Voice input isn't available in this browser."); return; } const r = new SR(); r.interimResults = true; r.continuous = false; r.lang = document.documentElement.lang || "en-GB"; const before = text ? `${text.trimEnd()} ` : ""; r.onresult = (e) => setText(before + Array.from(e.results).map((x) => x[0].transcript).join("")); r.onend = () => { setListening(false); area.current?.focus(); }; r.onerror = () => { setListening(false); setNote("Couldn't hear that. Check the microphone and try again."); }; speech.current = r; try { r.start(); setListening(true); } catch { setNote("Voice input isn't available right now."); } }; const addFiles = (list: FileList | null) => { // Read the names now: the input is cleared straight after, which empties the list. const names = Array.from(list ?? []).map((x) => x.name); if (names.length) setFiles((f) => [...f, ...names]); area.current?.focus(); }; const started = messages.length > 0; const expanded = rows > 1 || files.length > 0; const canSend = text.trim().length > 0; return (