From 1c607c4bcdf58913c4f0106c42d13972befeb8ba Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 26 Oct 2026 10:45:46 +0100 Subject: [PATCH] Add full-featured AIChat component AIChat (elements/ai/AIChat) is a complete LLM chat UI built on new hooks: - useAIChat: streaming conversation state (send, stop, regenerate, edit, feedback); `send` may return a string, async iterable, ReadableStream or fetch Response - useAIChatHistory: saved conversations with optional localStorage persistence and generated titles, shown in AIChatSidebar - useAIChatSpeech: read aloud via browser speech synthesis or a custom `speak` returning audio - useAIChatDictation: voice input via browser recognition or a custom `transcribe` Also adds a model picker, message editing, thumbs up/down, markdown export, scoped markdown styles in base.css, the twuiReadTextStream, twuiStripMarkdown and twuiGroupByRecency utils (with tests), and a playground page. AIPromptBlock is kept and marked deprecated. Co-Authored-By: Claude Opus 5.5 --- README.md | 35 +- dev/src/pages/__root.tsx | 1 + dev/src/pages/ai.tsx | 102 ++++ src/base.css | 73 +++ src/elements/ai/AIChat.tsx | 814 +++++++++++++++++++++++++ src/elements/ai/AIChatComposer.tsx | 330 ++++++++++ src/elements/ai/AIChatMarkdown.tsx | 75 +++ src/elements/ai/AIChatMessage.tsx | 506 +++++++++++++++ src/elements/ai/AIChatSidebar.tsx | 359 +++++++++++ src/elements/ai/AIPromptBlock.tsx | 4 + src/elements/ai/useAIChat.tsx | 378 ++++++++++++ src/elements/ai/useAIChatDictation.tsx | 261 ++++++++ src/elements/ai/useAIChatHistory.tsx | 387 ++++++++++++ src/elements/ai/useAIChatSpeech.tsx | 272 +++++++++ src/index.ts | 5 + src/utils/group-by-recency.test.ts | 46 ++ src/utils/group-by-recency.ts | 46 ++ src/utils/read-text-stream.test.ts | 55 ++ src/utils/read-text-stream.ts | 84 +++ src/utils/strip-markdown.test.ts | 52 ++ src/utils/strip-markdown.ts | 49 ++ 21 files changed, 3933 insertions(+), 1 deletion(-) create mode 100644 dev/src/pages/ai.tsx create mode 100644 src/elements/ai/AIChat.tsx create mode 100644 src/elements/ai/AIChatComposer.tsx create mode 100644 src/elements/ai/AIChatMarkdown.tsx create mode 100644 src/elements/ai/AIChatMessage.tsx create mode 100644 src/elements/ai/AIChatSidebar.tsx create mode 100644 src/elements/ai/useAIChat.tsx create mode 100644 src/elements/ai/useAIChatDictation.tsx create mode 100644 src/elements/ai/useAIChatHistory.tsx create mode 100644 src/elements/ai/useAIChatSpeech.tsx create mode 100644 src/utils/group-by-recency.test.ts create mode 100644 src/utils/group-by-recency.ts create mode 100644 src/utils/read-text-stream.test.ts create mode 100644 src/utils/read-text-stream.ts create mode 100644 src/utils/strip-markdown.test.ts create mode 100644 src/utils/strip-markdown.ts diff --git a/README.md b/README.md index 1493d25..21c7f95 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Only install these if you use the matching components: | `editors/AceEditor` | `ace-builds` | | `elements/RemoteCodeBlock`, `mdx/markdown/*` | `next-mdx-remote`, `remark-gfm`, `rehype-prism-plus` | | `mdx/mdx-components`, `next-js/hooks/useMDXComponents` | `@types/mdx` (dev) | -| `elements/ai/*` | `openai` (types), `react-markdown` | +| `elements/ai/*` | `react-markdown`, `remark-gfm` (+ `openai` types for the legacy `AIPrompt*` blocks) | `editors/TinyMCE` loads TinyMCE from a URL at runtime. Set it with the `base_url` prop, or with the `NEXT_PUBLIC_TINYMCE_BASE_URL` / `BUNEXT_PUBLIC_TINYMCE_BASE_URL` public env vars. @@ -67,6 +67,39 @@ import TinyMCEEditor from "@moduletrace/twui/editors/TinyMCE"; Editors, MDX, Next.js helpers, the AI blocks and `RemoteCodeBlock` are **deep-import only**, so the root entry never pulls in optional peers. +### AI chat + +`elements/ai/AIChat` is a complete chat UI. Give it a height and a `send` function. `send` can return a string, an async iterable of chunks, a `ReadableStream`, or a fetch `Response`: + +```tsx +import AIChat from "@moduletrace/twui/elements/ai/AIChat"; + + + fetch("/api/chat", { + method: "POST", + body: JSON.stringify({ messages, model }), + signal, + }) + } +/>; +``` + +| Feature | Props | +| --- | --- | +| Streaming replies, stop, regenerate, retry | built in | +| Saved conversations (sidebar, search, pin, rename, delete) | `history` (`true`, options, or a `useAIChatHistory()` result), `generateTitle` in the options | +| Read aloud (text-to-speech) | `speech` (browser voice by default, or `{ speak }` returning audio), `autoSpeak` | +| Voice input (speech-to-text) | `dictation` (browser recognition by default, or `{ transcribe }`) | +| Model picker | `models`, `model`, `onModelChange` | +| Edit messages, thumbs up/down, markdown export | `editable`, `feedback` / `onFeedback`, `exportable` | +| Empty state, avatars, custom rendering | `suggestions`, `emptyState`, `userImageUrl`, `renderMessageContent`, `renderMessageActions` | + +To drive the chat from outside, call `useAIChat` (and `useAIChatHistory`) yourself and pass the results as `chat` and `history`. The pieces (`AIChatMessage`, `AIChatComposer`, `AIChatSidebar`, `useAIChatSpeech`, `useAIChatDictation`) are exported from `elements/ai/` for custom layouts. + Component modules are marked `"use client"`, so they work in the Next.js App Router. ## Development diff --git a/dev/src/pages/__root.tsx b/dev/src/pages/__root.tsx index edb87d3..4eeab2b 100644 --- a/dev/src/pages/__root.tsx +++ b/dev/src/pages/__root.tsx @@ -7,6 +7,7 @@ const NAV = [ { href: "/buttons", label: "Buttons" }, { href: "/forms", label: "Forms" }, { href: "/elements", label: "Elements" }, + { href: "/ai", label: "AI" }, ]; export default function Root({ children }: BunextRootComponentProps) { diff --git a/dev/src/pages/ai.tsx b/dev/src/pages/ai.tsx new file mode 100644 index 0000000..e7baad1 --- /dev/null +++ b/dev/src/pages/ai.tsx @@ -0,0 +1,102 @@ +import AIChat from "@moduletrace/twui/elements/ai/AIChat"; +import type { TWUIAIChatSendFn } from "@moduletrace/twui/elements/ai/useAIChat"; +import H1 from "@moduletrace/twui/layout/H1"; +import P from "@moduletrace/twui/layout/P"; +import Stack from "@moduletrace/twui/layout/Stack"; +import DemoSection from "../(components)/DemoSection"; + +const REPLY = `Here's a quick rundown. + +## Streaming + +Replies arrive **chunk by chunk** and render as markdown on every chunk: + +1. Lists +2. \`inline code\` +3. Tables + +| Prop | What it does | +| --- | --- | +| \`send\` | Returns the reply stream | +| \`chat\` | External \`useAIChat()\` state | + +\`\`\`ts +const chat = useAIChat({ send }); +chat.sendMessage("Hello"); +\`\`\` + +> Press stop at any time to keep the partial reply.`; + +/** Fake model: streams a canned reply word by word, honouring `signal` */ +const fakeSend: TWUIAIChatSendFn = async function* ({ + messages, + signal, + model, +}) { + const last = messages.at(-1)?.content || ""; + + if (/error/i.test(last)) { + await new Promise((r) => setTimeout(r, 400)); + throw new Error("The model is overloaded. Try again in a moment."); + } + + const reply = + model == "demo-short" + ? `You said: "${last}". This is the short model.` + : REPLY; + + for (const token of reply.split(/(?<=\s)/)) { + if (signal.aborted) return; + await new Promise((r) => setTimeout(r, 25)); + yield token; + } +}; + +export default function AIPage() { + return ( + + +

AI

+

+ Deep-import only: elements/ai/* needs the{" "} + react-markdown and remark-gfm{" "} + optional peers. +

+
+ + +
+ { + await new Promise((r) => setTimeout(r, 300)); + const words = messages[0]?.content + .split(/\s+/) + .slice(0, 4) + .join(" "); + return `Chat: ${words}`; + }, + }} + models={[ + { label: "Demo Long", value: "demo-long" }, + { label: "Demo Short", value: "demo-short" }, + ]} + feedback + suggestions={[ + "Explain streaming", + "Show me a table", + { label: "Trigger an error", prompt: "error please" }, + ]} + footer="Replies are canned for the demo." + /> +
+
+
+ ); +} diff --git a/src/base.css b/src/base.css index f7e4c35..7111e7f 100644 --- a/src/base.css +++ b/src/base.css @@ -190,3 +190,76 @@ ul, ol { margin-left: 25px; } + +/* Assistant replies in `elements/ai/AIChat`. Scoped, since Tailwind's + preflight strips the default styles of the elements markdown produces */ +.twui-ai-chat-markdown { + @apply flex flex-col gap-3 leading-7 break-words min-w-0; +} + +.twui-ai-chat-markdown > * { + @apply m-0; +} + +.twui-ai-chat-markdown h1 { + @apply text-2xl font-bold mt-2; +} + +.twui-ai-chat-markdown h2 { + @apply text-xl font-bold mt-2; +} + +.twui-ai-chat-markdown h3, +.twui-ai-chat-markdown h4, +.twui-ai-chat-markdown h5, +.twui-ai-chat-markdown h6 { + @apply text-base font-semibold mt-1; +} + +.twui-ai-chat-markdown ul, +.twui-ai-chat-markdown ol { + @apply ml-5 flex flex-col gap-1; +} + +.twui-ai-chat-markdown li > ul, +.twui-ai-chat-markdown li > ol { + @apply mt-1; +} + +.twui-ai-chat-markdown a { + @apply text-link dark:text-link-dark underline; +} + +.twui-ai-chat-markdown blockquote { + @apply border-l-4 border-solid border-slate-300 dark:border-white/20 pl-4 opacity-80; +} + +.twui-ai-chat-markdown hr { + @apply border-slate-200 dark:border-white/10; +} + +.twui-ai-chat-markdown :not(pre) > code { + @apply px-1.5 py-0.5 rounded-default-sm text-[0.875em]; + @apply bg-slate-100 dark:bg-white/10; +} + +.twui-ai-chat-markdown .twui-code-block-pre { + @apply text-sm text-white whitespace-pre px-3 pt-2 pb-3; +} + +.twui-ai-chat-markdown-table { + @apply w-full overflow-x-auto; +} + +.twui-ai-chat-markdown table { + @apply w-full border-collapse text-sm; +} + +.twui-ai-chat-markdown th, +.twui-ai-chat-markdown td { + @apply border border-solid border-slate-200 dark:border-white/10 px-3 py-1.5 text-left; +} + +.twui-ai-chat-markdown th { + @apply font-semibold bg-slate-50 dark:bg-white/5; +} diff --git a/src/elements/ai/AIChat.tsx b/src/elements/ai/AIChat.tsx new file mode 100644 index 0000000..1f39a59 --- /dev/null +++ b/src/elements/ai/AIChat.tsx @@ -0,0 +1,814 @@ +import React, { type DetailedHTMLProps, type HTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; +import { + ArrowDown, + Download, + MessageSquarePlus, + PanelLeft, + Sparkles, + Volume2, + VolumeX, + X, +} from "lucide-react"; +import Row from "../../layout/Row"; +import Stack from "../../layout/Stack"; +import Span from "../../layout/Span"; +import Button from "../../layout/Button"; +import Img from "../../layout/Img"; +import twuiSlugify from "../../utils/slugify"; +import AIChatMessage, { type TWUIAIChatMessageProps } from "./AIChatMessage"; +import AIChatComposer, { + twuiAIChatModelOption, + type TWUIAIChatComposerProps, + type TWUIAIChatDictationOptions, + type TWUIAIChatModelOption, +} from "./AIChatComposer"; +import AIChatSidebar from "./AIChatSidebar"; +import useAIChat, { + type TWUIAIChatController, + type TWUIAIChatFeedback, + type TWUIAIChatMessage, + type TWUIAIChatSendFn, + type UseAIChatParams, +} from "./useAIChat"; +import useAIChatHistory, { + type TWUIAIChatHistoryController, + type UseAIChatHistoryParams, +} from "./useAIChatHistory"; +import useAIChatSpeech, { type UseAIChatSpeechParams } from "./useAIChatSpeech"; + +export type TWUIAIChatSuggestion = string | { label: string; prompt: string }; + +export type TWUIAIChatProps = Omit< + DetailedHTMLProps, HTMLDivElement>, + keyof UseAIChatParams | "title" +> & + Partial> & { + /** + * State from your own `useAIChat()` call, to send, clear or read + * messages from outside. When set, `send` and the other + * conversation options are ignored. + */ + chat?: TWUIAIChatController; + /** + * Saved conversations in a sidebar. `true` keeps them in memory; + * pass options (e.g. `{ storageKey: "my-app-chats" }` for + * `localStorage`), or your own `useAIChatHistory()` result. + */ + history?: + | boolean + | UseAIChatHistoryParams + | TWUIAIChatHistoryController; + /** Whether the history sidebar starts open on wide layouts. Default true */ + defaultSidebarOpen?: boolean; + /** + * Read-aloud button on replies. Default `true` (browser speech + * synthesis, where available). Pass options for a custom voice or + * your own `speak` function; `false` hides it. + */ + speech?: boolean | UseAIChatSpeechParams; + /** Read each new reply aloud. The header has a toggle for it */ + autoSpeak?: boolean; + /** + * Voice input in the composer. Default `true` (browser speech + * recognition, where available). Pass `{ transcribe }` to use your + * own speech-to-text; `false` hides it. + */ + dictation?: boolean | TWUIAIChatDictationOptions; + /** A model picker in the composer; the choice is passed to `send` */ + models?: TWUIAIChatModelOption[]; + /** Controlled model. With `models` alone, the first is selected */ + model?: string; + onModelChange?: (model: string) => void; + /** Thumbs up/down on replies. On when `onFeedback` is set */ + feedback?: boolean; + onFeedback?: ( + message: TWUIAIChatMessage, + feedback: TWUIAIChatFeedback | undefined, + ) => void; + /** Edit button on user messages. Default true */ + editable?: boolean; + /** "Download as markdown" in the header. Default true */ + exportable?: boolean; + /** `false` hides the header bar */ + header?: boolean; + /** Header title. Defaults to the conversation's title */ + title?: React.ReactNode; + description?: React.ReactNode; + headerActions?: React.ReactNode; + placeholder?: string; + /** Prompts offered while the conversation is empty */ + suggestions?: TWUIAIChatSuggestion[]; + /** Replaces the default empty-conversation view */ + emptyState?: React.ReactNode; + emptyTitle?: React.ReactNode; + emptyDescription?: React.ReactNode; + assistantAvatar?: React.ReactNode; + /** Avatar image shown beside the user's messages */ + userImageUrl?: string; + /** Replaces the user avatar entirely */ + userAvatar?: React.ReactNode; + /** Custom message body, e.g. your own markdown renderer */ + renderMessageContent?: TWUIAIChatMessageProps["renderContent"]; + /** Extra per-message buttons */ + renderMessageActions?: ( + message: TWUIAIChatMessage, + chat: TWUIAIChatController, + ) => React.ReactNode; + /** Extra controls in the composer, e.g. attach */ + composerActions?: React.ReactNode; + /** Below the composer, e.g. a disclaimer */ + footer?: React.ReactNode; + disabled?: boolean; + autoFocus?: boolean; + /** Classes for the centered column holding messages and composer */ + contentClassName?: string; + composerProps?: Partial; + }; + +const missingSend: TWUIAIChatSendFn = () => { + throw new Error("AIChat needs a `send` function or a `chat` controller"); +}; + +/** Distance from the bottom, in px, that still counts as "at the bottom" */ +const STICK_THRESHOLD = 80; +/** Below this width the sidebar overlays the chat instead of sitting beside it */ +const SIDEBAR_BREAKPOINT = 768; + +function isHistoryController( + history: TWUIAIChatProps["history"], +): history is TWUIAIChatHistoryController { + return ( + typeof history == "object" && + typeof (history as TWUIAIChatHistoryController).saveMessages == + "function" + ); +} + +function conversationToMarkdown(title: string, messages: TWUIAIChatMessage[]) { + const body = messages + .filter((msg) => msg.role !== "system" && msg.content) + .map( + (msg) => + `### ${msg.role == "user" ? "You" : "Assistant"}\n\n${msg.content}`, + ) + .join("\n\n---\n\n"); + return `# ${title}\n\n${body}\n`; +} + +function downloadText(filename: string, text: string) { + const url = URL.createObjectURL( + new Blob([text], { type: "text/markdown;charset=utf-8" }), + ); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +const HEADER_ICON = "p-1.5"; + +/** + * # AI Chat + * A complete LLM chat UI: + * - streamed markdown replies with copy, regenerate, retry and feedback + * - saved conversations in a sidebar (search, pin, rename, delete), + * optionally persisted to `localStorage` + * - read aloud (text-to-speech) and voice input (speech-to-text), using + * the browser's engines or your own + * - message editing, a model picker, markdown export, suggested prompts + * + * Pass `send` and the component runs the conversation, or pass `chat` + * (and `history`) from your own hooks to control it from outside. + * Give it a height (default `h-[70vh]`): the message list scrolls inside. + * + * @example + * + * fetch("/api/chat", { + * method: "POST", + * body: JSON.stringify({ messages }), + * signal, + * }) + * } + * /> + * + * @className twui-ai-chat + * @className twui-ai-chat-main + * @className twui-ai-chat-header + * @className twui-ai-chat-messages + * @className twui-ai-chat-empty + * @className twui-ai-chat-suggestion + * @className twui-ai-chat-scroll-button + * @className twui-ai-chat-notice + * @className twui-ai-chat-footer + */ +export default function AIChat({ + chat: externalChat, + send, + initialMessages, + systemPrompt, + onMessagesChange, + onFinish, + onError, + history: historyOption, + defaultSidebarOpen = true, + speech: speechOption = true, + autoSpeak: defaultAutoSpeak = false, + dictation = true, + models, + model: controlledModel, + onModelChange, + feedback, + onFeedback, + editable = true, + exportable = true, + header = true, + title, + description, + headerActions, + placeholder, + suggestions, + emptyState, + emptyTitle = "How can I help?", + emptyDescription, + assistantAvatar, + userImageUrl, + userAvatar, + renderMessageContent, + renderMessageActions, + composerActions, + footer, + disabled, + autoFocus, + contentClassName, + composerProps, + ...props +}: TWUIAIChatProps) { + /* ---------- Model ---------- */ + + const modelOptions = models?.map(twuiAIChatModelOption); + const [internalModel, setInternalModel] = React.useState( + modelOptions?.[0]?.value, + ); + const model = controlledModel ?? internalModel; + + /* ---------- Conversation ---------- */ + + const internalChat = useAIChat({ + send: send || missingSend, + initialMessages, + systemPrompt, + model, + onMessagesChange, + onFinish, + onError, + }); + const chat = externalChat || internalChat; + const { messages, isStreaming } = chat; + + /* ---------- History ---------- */ + + const historyEnabled = Boolean(historyOption); + const internalHistory = useAIChatHistory( + typeof historyOption == "object" && !isHistoryController(historyOption) + ? historyOption + : {}, + ); + const history = isHistoryController(historyOption) + ? historyOption + : internalHistory; + + // Which saved conversation `chat.messages` currently belongs to + const chatConversationIdRef = React.useRef(null); + + // Save every change to the active conversation (creating it on the + // first message) + React.useEffect(() => { + if (!historyEnabled || !history.loaded) return; + chatConversationIdRef.current = history.saveMessages(messages); + // Only message changes save; switching conversations must not + // write the old messages into the new one + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages]); + + // Load a conversation into the chat when another one is selected, + // restored from storage or deleted + React.useEffect(() => { + if (!historyEnabled || !history.loaded) return; + if (history.activeId === chatConversationIdRef.current) return; + chatConversationIdRef.current = history.activeId; + chat.setMessages(history.active?.messages || []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [historyEnabled, history.loaded, history.activeId]); + + /* ---------- Layout ---------- */ + + const rootRef = React.useRef(null); + const [narrow, setNarrow] = React.useState(false); + const [sidebarOpen, setSidebarOpen] = React.useState(false); + + React.useEffect(() => { + const el = rootRef.current; + if (!el) return; + const isNarrow = () => el.clientWidth < SIDEBAR_BREAKPOINT; + setNarrow(isNarrow()); + setSidebarOpen(defaultSidebarOpen && !isNarrow()); + + if (typeof ResizeObserver == "undefined") return; + let wasNarrow = isNarrow(); + const observer = new ResizeObserver(() => { + const nowNarrow = isNarrow(); + if (nowNarrow == wasNarrow) return; + wasNarrow = nowNarrow; + setNarrow(nowNarrow); + // An overlay left open after shrinking would cover the chat + if (nowNarrow) setSidebarOpen(false); + }); + observer.observe(el); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /* ---------- Speech ---------- */ + + const speechEnabled = Boolean(speechOption); + const speech = useAIChatSpeech({ + ...(typeof speechOption == "object" ? speechOption : {}), + onError: (error) => { + setNotice(error.message); + if (typeof speechOption == "object") speechOption.onError?.(error); + }, + }); + const canSpeak = speechEnabled && speech.supported; + const [autoSpeak, setAutoSpeak] = React.useState(defaultAutoSpeak); + const streamingIdsRef = React.useRef(new Set()); + + // Read replies aloud as they finish, when auto-speak is on + React.useEffect(() => { + messages.forEach((msg) => { + if (msg.role !== "assistant") return; + if (msg.status == "streaming") { + streamingIdsRef.current.add(msg.id); + } else if (streamingIdsRef.current.delete(msg.id)) { + if (autoSpeak && canSpeak && msg.status == "done") { + speech.speak(msg); + } + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages]); + + // Stop reading when the conversation is switched or cleared + React.useEffect(() => { + const id = speech.state?.messageId; + if (id && !messages.some((msg) => msg.id == id)) speech.stop(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages]); + + /* ---------- Notices (speech/dictation errors) ---------- */ + + const [notice, setNotice] = React.useState(null); + + React.useEffect(() => { + if (!notice) return; + const timeout = setTimeout(() => setNotice(null), 6000); + return () => clearTimeout(timeout); + }, [notice]); + + /* ---------- Scrolling ---------- */ + + const scrollRef = React.useRef(null); + const inputRef = React.useRef(null); + const stickToBottomRef = React.useRef(true); + const [showScrollButton, setShowScrollButton] = React.useState(false); + + const scrollToBottom = React.useCallback( + (behavior: ScrollBehavior = "auto") => { + const el = scrollRef.current; + if (!el) return; + el.scrollTo({ top: el.scrollHeight, behavior }); + }, + [], + ); + + // Follow the reply as it streams, unless the user scrolled up to read + React.useEffect(() => { + if (stickToBottomRef.current) scrollToBottom(); + }, [messages, scrollToBottom]); + + React.useEffect(() => { + if (autoFocus) inputRef.current?.focus(); + }, [autoFocus]); + + /* ---------- Actions ---------- */ + + const followLatest = () => { + stickToBottomRef.current = true; + setShowScrollButton(false); + }; + + const submit = (prompt: string) => { + followLatest(); + chat.sendMessage(prompt); + }; + + const newChat = () => { + if (historyEnabled) history.select(null); + chat.clear(); + followLatest(); + if (narrow) setSidebarOpen(false); + inputRef.current?.focus(); + }; + + const selectConversation = (id: string) => { + history.select(id); + followLatest(); + if (narrow) setSidebarOpen(false); + }; + + const visibleMessages = messages.filter((msg) => msg.role !== "system"); + const lastAssistantId = visibleMessages.findLast( + (msg) => msg.role == "assistant", + )?.id; + const isEmpty = visibleMessages.length == 0; + + const conversationTitle = + (historyEnabled && history.active?.title) || undefined; + + const exportConversation = () => { + const name = conversationTitle || "Chat"; + downloadText( + `${twuiSlugify(name) || "chat"}.md`, + conversationToMarkdown(name, messages), + ); + }; + + const finalUserAvatar = + userAvatar ?? + (userImageUrl ? ( + You + ) : undefined); + + const showFeedback = Boolean(feedback || onFeedback); + const showSidebar = historyEnabled && sidebarOpen; + + return ( +
+ {showSidebar && narrow && ( +
setSidebarOpen(false)} + /> + )} + + {showSidebar && ( + setSidebarOpen(false) : undefined} + className={ + narrow + ? "absolute inset-y-0 left-0 z-20 shadow-xl max-w-[85%]" + : undefined + } + /> + )} + + + {header && ( + + + {historyEnabled && ( + + )} + + + {title ?? conversationTitle ?? "New chat"} + + {description && ( + + {description} + + )} + + + + + {headerActions} + {canSpeak && ( + + )} + {exportable && ( + + )} + + + + )} + +
{ + const el = e.currentTarget; + const atBottom = + el.scrollHeight - el.scrollTop - el.clientHeight < + STICK_THRESHOLD; + stickToBottomRef.current = atBottom; + setShowScrollButton(!atBottom); + }} + > + + {isEmpty + ? (emptyState ?? ( + + + + + {emptyTitle} + + {emptyDescription && ( + + {emptyDescription} + + )} + + {suggestions?.[0] && ( + + {suggestions.map((suggestion) => { + const { label, prompt } = + typeof suggestion == + "string" + ? { + label: suggestion, + prompt: suggestion, + } + : suggestion; + + return ( + + ); + })} + + )} + + )) + : visibleMessages.map((message) => ( + 1 + } + modelLabel={ + modelOptions?.find( + (m) => m.value == message.model, + )?.label + } + onRegenerate={(msg) => { + followLatest(); + chat.regenerate(msg.id); + }} + onEdit={ + editable + ? (msg, content) => { + followLatest(); + chat.editMessage( + msg.id, + content, + ); + } + : undefined + } + onFeedback={ + showFeedback + ? (msg, value) => { + chat.updateMessage(msg.id, { + feedback: value, + }); + onFeedback?.(msg, value); + } + : undefined + } + onSpeak={canSpeak ? speech.speak : undefined} + onStopSpeaking={speech.stop} + speechState={speech.state} + /> + ))} + +
+ +
+ {showScrollButton && !isEmpty && ( + + )} + + + {notice && ( + + {notice} + + + )} + { + setInternalModel(next); + onModelChange?.(next); + }} + {...composerProps} + onDictationError={(error) => { + setNotice(error.message); + composerProps?.onDictationError?.(error); + }} + inputRef={inputRef} + streaming={isStreaming} + onStop={chat.stop} + onSubmit={submit} + /> + {footer && ( + + {footer} + + )} + +
+
+
+ ); +} diff --git a/src/elements/ai/AIChatComposer.tsx b/src/elements/ai/AIChatComposer.tsx new file mode 100644 index 0000000..ed6fa3b --- /dev/null +++ b/src/elements/ai/AIChatComposer.tsx @@ -0,0 +1,330 @@ +import React, { + type DetailedHTMLProps, + type FormHTMLAttributes, + type TextareaHTMLAttributes, +} from "react"; +import { twMerge } from "tailwind-merge"; +import { ArrowUp, ChevronDown, Mic, Square, X } from "lucide-react"; +import Row from "../../layout/Row"; +import Button from "../../layout/Button"; +import Loading from "../Loading"; +import useAIChatDictation, { + type TWUIAIChatTranscribeFn, +} from "./useAIChatDictation"; + +export type TWUIAIChatModelOption = string | { label: string; value: string }; + +export type TWUIAIChatDictationOptions = { + /** Your own speech-to-text; the browser's is used otherwise */ + transcribe?: TWUIAIChatTranscribeFn; + lang?: string; + /** Send the message as soon as dictation finishes */ + autoSend?: boolean; +}; + +export type TWUIAIChatComposerProps = Omit< + DetailedHTMLProps, HTMLFormElement>, + "onSubmit" +> & { + /** Called with the prompt; the input clears straight after */ + onSubmit: (prompt: string) => void; + onStop?: () => void; + /** A reply is streaming: the send button becomes a stop button */ + streaming?: boolean; + disabled?: boolean; + placeholder?: string; + /** Controlled draft. Leave out to let the composer keep its own */ + value?: string; + onValueChange?: (value: string) => void; + /** Max height of the auto-growing input, in px. Default 200 */ + maxInputHeight?: number; + /** Voice input. `true` uses the browser's speech recognition */ + dictation?: boolean | TWUIAIChatDictationOptions; + onDictationError?: (error: Error) => void; + /** Shows a model picker */ + models?: TWUIAIChatModelOption[]; + model?: string; + onModelChange?: (model: string) => void; + /** Extra controls left of the send button, e.g. attach */ + actions?: React.ReactNode; + textareaProps?: DetailedHTMLProps< + TextareaHTMLAttributes, + HTMLTextAreaElement + >; + inputRef?: React.RefObject; +}; + +export function twuiAIChatModelOption(option: TWUIAIChatModelOption) { + return typeof option == "string" + ? { label: option, value: option } + : option; +} + +const ICON_BUTTON = "w-9 h-9 p-0 rounded-full shrink-0"; + +/** + * # AI Chat Composer + * Auto-growing prompt input with voice input and a model picker. + * Enter sends, Shift+Enter adds a line. + * + * @className twui-ai-chat-composer + * @className twui-ai-chat-composer-input + * @className twui-ai-chat-send-button + * @className twui-ai-chat-stop-button + * @className twui-ai-chat-mic-button + * @className twui-ai-chat-model-select + */ +export default function AIChatComposer({ + onSubmit, + onStop, + streaming, + disabled, + placeholder = "Send a message", + value: controlledValue, + onValueChange, + maxInputHeight = 200, + dictation, + onDictationError, + models, + model, + onModelChange, + actions, + textareaProps, + inputRef: externalInputRef, + ...props +}: TWUIAIChatComposerProps) { + const [internalValue, setInternalValue] = React.useState(""); + const value = controlledValue ?? internalValue; + const valueRef = React.useRef(value); + valueRef.current = value; + + const setValue = React.useCallback( + (next: string) => { + valueRef.current = next; + setInternalValue(next); + onValueChange?.(next); + }, + [onValueChange], + ); + + const internalInputRef = React.useRef(null); + const inputRef = externalInputRef || internalInputRef; + + React.useEffect(() => { + const el = inputRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, maxInputHeight)}px`; + el.style.overflowY = + el.scrollHeight > maxInputHeight ? "auto" : "hidden"; + }, [value, maxInputHeight, inputRef]); + + const dictationOptions = + typeof dictation == "object" ? dictation : undefined; + const draftBeforeDictationRef = React.useRef(""); + + const submit = (text = valueRef.current) => { + if (!text.trim() || disabled || streaming) return; + onSubmit(text); + setValue(""); + }; + + const dictationCtl = useAIChatDictation({ + transcribe: dictationOptions?.transcribe, + lang: dictationOptions?.lang, + onError: onDictationError, + onTranscript: (text, isFinal) => { + const base = draftBeforeDictationRef.current; + const next = text + ? base && !/\s$/.test(base) + ? `${base} ${text}` + : base + text + : base; + setValue(next); + if (isFinal) { + if (dictationOptions?.autoSend && text) submit(next); + else inputRef.current?.focus(); + } + }, + }); + + const showDictation = Boolean(dictation) && dictationCtl.supported; + const listening = dictationCtl.phase == "listening"; + const transcribing = dictationCtl.phase == "transcribing"; + + const canSend = + Boolean(value.trim()) && + !disabled && + !streaming && + dictationCtl.phase == "idle"; + + const modelOptions = models?.map(twuiAIChatModelOption); + + return ( +
{ + e.preventDefault(); + submit(); + }} + > +