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 <[email protected]>
This commit is contained in:
2026-10-26 10:45:46 +01:00
co-authored by Claude Opus 5.5
parent 1f74447d1c
commit 1c607c4bcd
21 changed files with 3933 additions and 1 deletions
+34 -1
View File
@@ -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";
<AIChat
className="h-[80vh]"
history={{ storageKey: "my-app-chats" }}
models={["gpt-5", "claude-opus-5-5"]}
send={({ messages, model, signal }) =>
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
+1
View File
@@ -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) {
+102
View File
@@ -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 (
<Stack className="w-full gap-10">
<Stack className="gap-2">
<H1 className="!mb-0">AI</H1>
<P className="!pt-0 opacity-70">
Deep-import only: <code>elements/ai/*</code> needs the{" "}
<code>react-markdown</code> and <code>remark-gfm</code>{" "}
optional peers.
</P>
</Stack>
<DemoSection
title="AIChat"
description='Streams from a fake model. History persists to localStorage. Send a message containing "error" to see the retry state.'
>
<div className="w-full h-[680px] rounded-default-xl border border-solid border-slate-200 dark:border-white/10">
<AIChat
className="h-full"
send={fakeSend}
history={{
storageKey: "twui-dev-ai-chat",
generateTitle: async (messages) => {
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."
/>
</div>
</DemoSection>
</Stack>
);
}
+73
View File
@@ -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;
}
File diff suppressed because it is too large Load Diff
+330
View File
@@ -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<FormHTMLAttributes<HTMLFormElement>, 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>,
HTMLTextAreaElement
>;
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
};
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<HTMLTextAreaElement>(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 (
<form
{...props}
className={twMerge(
"w-full flex flex-col gap-2 p-2 rounded-default-xl",
"border border-solid border-slate-300 dark:border-white/20",
"bg-background-light dark:bg-background-dark",
"focus-within:border-slate-400 dark:focus-within:border-white/40",
"transition-colors twui-ai-chat-composer",
props.className,
)}
onSubmit={(e) => {
e.preventDefault();
submit();
}}
>
<textarea
rows={1}
placeholder={listening ? "Listening…" : placeholder}
aria-label={placeholder}
disabled={disabled}
{...textareaProps}
ref={inputRef}
value={value}
readOnly={listening || transcribing}
onChange={(e) => {
setValue(e.target.value);
textareaProps?.onChange?.(e);
}}
onKeyDown={(e) => {
textareaProps?.onKeyDown?.(e);
if (e.defaultPrevented) return;
if (
e.key == "Enter" &&
!e.shiftKey &&
!e.nativeEvent.isComposing
) {
e.preventDefault();
submit();
}
}}
className={twMerge(
"w-full resize-none bg-transparent outline-none border-none",
"px-2 py-1.5 text-base leading-6 placeholder:opacity-50",
"twui-ai-chat-composer-input",
textareaProps?.className,
)}
/>
<Row className="w-full flex-nowrap gap-1">
<Row className="grow gap-1 min-w-0">
{modelOptions?.[0] && (
<label
className={twMerge(
"relative flex items-center gap-1 h-8 pl-2.5 pr-1.5 rounded-full text-xs",
"text-slate-600 dark:text-white/70 cursor-pointer",
"hover:bg-gray dark:hover:bg-gray-dark transition-colors",
"twui-ai-chat-model-select",
)}
>
<span className="truncate max-w-40">
{modelOptions.find((m) => m.value == model)
?.label ?? modelOptions[0].label}
</span>
<ChevronDown size={14} className="shrink-0" />
<select
aria-label="Model"
className="absolute inset-0 opacity-0 cursor-pointer"
value={model ?? modelOptions[0].value}
disabled={disabled}
onChange={(e) => onModelChange?.(e.target.value)}
>
{modelOptions.map((m) => (
<option key={m.value} value={m.value}>
{m.label}
</option>
))}
</select>
</label>
)}
{actions}
</Row>
{showDictation &&
(dictationCtl.phase == "idle" ? (
<Button
type="button"
title="Voice input"
variant="ghost"
color="gray"
className={twMerge(ICON_BUTTON, "twui-ai-chat-mic-button")}
disabled={disabled || streaming}
onClick={() => {
draftBeforeDictationRef.current =
valueRef.current;
dictationCtl.start();
}}
>
<Mic size={18} />
</Button>
) : (
<Row className="gap-0.5 flex-nowrap shrink-0">
<Button
type="button"
title="Cancel voice input"
variant="ghost"
color="gray"
className={ICON_BUTTON}
onClick={() => {
dictationCtl.cancel();
setValue(draftBeforeDictationRef.current);
}}
>
<X size={18} />
</Button>
{listening ? (
<Button
type="button"
title="Finish voice input"
color="error"
className={twMerge(
ICON_BUTTON,
"twui-ai-chat-mic-button",
)}
onClick={dictationCtl.stop}
>
<span className="relative flex w-3 h-3">
<span className="absolute inset-0 rounded-full bg-white opacity-75 animate-ping" />
<span className="relative w-3 h-3 rounded-full bg-white" />
</span>
</Button>
) : (
<div
className={twMerge(
ICON_BUTTON,
"flex items-center justify-center",
)}
title="Transcribing"
>
<Loading size="small" />
</div>
)}
</Row>
))}
{streaming && onStop ? (
<Button
type="button"
title="Stop generating"
className={twMerge(ICON_BUTTON, "twui-ai-chat-stop-button")}
onClick={onStop}
>
<Square size={14} fill="currentColor" />
</Button>
) : (
<Button
type="submit"
title="Send message"
className={twMerge(ICON_BUTTON, "twui-ai-chat-send-button")}
disabled={!canSend}
>
<ArrowUp size={18} />
</Button>
)}
</Row>
</form>
);
}
+75
View File
@@ -0,0 +1,75 @@
import React, { type DetailedHTMLProps, type HTMLAttributes } from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { twMerge } from "tailwind-merge";
import CodeBlock from "../CodeBlock";
type Props = DetailedHTMLProps<
HTMLAttributes<HTMLDivElement>,
HTMLDivElement
> & {
content: string;
/** Merged over the default element overrides */
components?: Components;
};
const DEFAULT_COMPONENTS: Components = {
pre: ({ children }) => {
// Unwrap react-markdown's `<pre><code>` so CodeBlock owns the `<pre>`
const code = React.isValidElement<{
children?: React.ReactNode;
className?: string;
}>(children)
? children.props
: undefined;
return (
<CodeBlock
className={code?.className}
wrapperProps={{ className: "max-w-full xl:max-w-full" }}
>
{code ? code.children : children}
</CodeBlock>
);
},
a: ({ href, title, children }) => (
<a href={href} title={title} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
table: ({ children }) => (
<div className="twui-ai-chat-markdown-table">
<table>{children}</table>
</div>
),
};
/**
* # AI Chat Markdown
* GitHub-flavored markdown for assistant replies. Re-renders cheaply enough
* to run on every streamed chunk.
*
* @className twui-ai-chat-markdown
*/
export default function AIChatMarkdown({
content,
components,
...props
}: Props) {
return (
<div
{...props}
className={twMerge(
"twui-ai-chat-markdown twui-llm-chat-block",
props.className,
)}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{ ...DEFAULT_COMPONENTS, ...components }}
>
{content}
</ReactMarkdown>
</div>
);
}
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
import React, { type DetailedHTMLProps, type HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
import {
Check,
MessageSquarePlus,
Pencil,
Pin,
PinOff,
Search,
Trash2,
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 twuiGroupByRecency from "../../utils/group-by-recency";
import type {
TWUIAIChatConversation,
TWUIAIChatHistoryController,
} from "./useAIChatHistory";
export type TWUIAIChatSidebarProps = DetailedHTMLProps<
HTMLAttributes<HTMLElement>,
HTMLElement
> & {
history: TWUIAIChatHistoryController;
onSelectConversation: (id: string) => void;
onNewChat: () => void;
/** Shown on narrow layouts, where the sidebar overlays the chat */
onClose?: () => void;
/** Default true */
searchable?: boolean;
};
const ICON = "p-1 opacity-60 hover:opacity-100";
function ConversationItem({
conversation,
active,
history,
onSelect,
}: {
conversation: TWUIAIChatConversation;
active: boolean;
history: TWUIAIChatHistoryController;
onSelect: () => void;
}) {
const [renaming, setRenaming] = React.useState(false);
const [confirmDelete, setConfirmDelete] = React.useState(false);
const [title, setTitle] = React.useState(conversation.title);
React.useEffect(() => {
if (!confirmDelete) return;
const timeout = setTimeout(() => setConfirmDelete(false), 3000);
return () => clearTimeout(timeout);
}, [confirmDelete]);
const saveTitle = () => {
setRenaming(false);
if (title.trim() && title !== conversation.title) {
history.rename(conversation.id, title);
} else {
setTitle(conversation.title);
}
};
const itemClassName = twMerge(
"group relative w-full flex items-center gap-1 rounded-default text-sm",
"hover:bg-gray/60 dark:hover:bg-gray-dark/60",
active && "bg-gray dark:bg-gray-dark",
"twui-ai-chat-sidebar-item",
active && "twui-ai-chat-sidebar-item-active",
);
if (renaming) {
return (
<div className={twMerge(itemClassName, "px-2 py-1")}>
<input
autoFocus
value={title}
aria-label="Conversation title"
className="w-full min-w-0 bg-transparent outline-none border-none py-1"
onChange={(e) => setTitle(e.target.value)}
onBlur={saveTitle}
onKeyDown={(e) => {
if (e.key == "Enter") saveTitle();
if (e.key == "Escape") {
setTitle(conversation.title);
setRenaming(false);
}
}}
/>
</div>
);
}
return (
<div className={itemClassName}>
<button
type="button"
className="grow min-w-0 text-left px-2.5 py-2 cursor-pointer bg-transparent border-none"
aria-current={active ? "page" : undefined}
title={conversation.title}
onClick={onSelect}
>
<span className="block truncate">{conversation.title}</span>
</button>
<Row
className={twMerge(
"gap-0 flex-nowrap shrink-0 pr-1",
confirmDelete
? ""
: "hidden group-hover:flex group-focus-within:flex",
)}
>
{confirmDelete ? (
<React.Fragment>
<Button
title="Confirm delete"
variant="ghost"
color="error"
size="smaller"
className="p-1"
onClick={() => history.remove(conversation.id)}
>
<Check size={14} />
</Button>
<Button
title="Keep conversation"
variant="ghost"
color="gray"
size="smaller"
className={ICON}
onClick={() => setConfirmDelete(false)}
>
<X size={14} />
</Button>
</React.Fragment>
) : (
<React.Fragment>
<Button
title={conversation.pinned ? "Unpin" : "Pin"}
variant="ghost"
color="gray"
size="smaller"
className={ICON}
onClick={() => history.togglePin(conversation.id)}
>
{conversation.pinned ? (
<PinOff size={14} />
) : (
<Pin size={14} />
)}
</Button>
<Button
title="Rename"
variant="ghost"
color="gray"
size="smaller"
className={ICON}
onClick={() => {
setTitle(conversation.title);
setRenaming(true);
}}
>
<Pencil size={14} />
</Button>
<Button
title="Delete"
variant="ghost"
color="gray"
size="smaller"
className={ICON}
onClick={() => setConfirmDelete(true)}
>
<Trash2 size={14} />
</Button>
</React.Fragment>
)}
</Row>
</div>
);
}
/**
* # AI Chat Sidebar
* Saved conversations for {@link AIChat}, grouped by date, with search,
* pin, rename and delete.
*
* @className twui-ai-chat-sidebar
* @className twui-ai-chat-sidebar-item
* @className twui-ai-chat-sidebar-item-active
* @className twui-ai-chat-sidebar-search
*/
export default function AIChatSidebar({
history,
onSelectConversation,
onNewChat,
onClose,
searchable = true,
...props
}: TWUIAIChatSidebarProps) {
const [query, setQuery] = React.useState("");
const [confirmClear, setConfirmClear] = React.useState(false);
const filtered = React.useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return history.conversations;
return history.conversations.filter(
(conv) =>
conv.title.toLowerCase().includes(q) ||
conv.messages.some((msg) =>
msg.content.toLowerCase().includes(q),
),
);
}, [history.conversations, query]);
const groups = React.useMemo(() => {
const pinned = filtered.filter((conv) => conv.pinned);
const rest = twuiGroupByRecency(
filtered.filter((conv) => !conv.pinned),
(conv) => conv.updatedAt,
);
return pinned.length ? [{ label: "Pinned", items: pinned }, ...rest] : rest;
}, [filtered]);
return (
<aside
aria-label="Chat history"
{...props}
className={twMerge(
"w-64 h-full shrink-0 flex flex-col gap-2 p-2",
"bg-background-light dark:bg-background-dark",
"border-r border-solid border-slate-200 dark:border-white/10",
"twui-ai-chat-sidebar",
props.className,
)}
>
<Row className="w-full flex-nowrap gap-1">
<Button
title="New chat"
variant="ghost"
color="gray"
size="small"
className="grow justify-start px-2.5"
beforeIcon={<MessageSquarePlus size={16} />}
onClick={onNewChat}
>
New chat
</Button>
{onClose && (
<Button
title="Close chat history"
variant="ghost"
color="gray"
size="small"
className="p-2"
onClick={onClose}
>
<X size={16} />
</Button>
)}
</Row>
{searchable && history.conversations.length > 0 && (
<label
className={twMerge(
"flex items-center gap-2 px-2.5 py-1.5 rounded-default text-sm",
"border border-solid border-slate-200 dark:border-white/10",
"twui-ai-chat-sidebar-search",
)}
>
<Search size={14} className="opacity-50 shrink-0" />
<input
type="search"
placeholder="Search chats"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full min-w-0 bg-transparent outline-none border-none"
/>
</label>
)}
<Stack className="w-full grow min-h-0 overflow-y-auto gap-4 items-stretch">
{groups.map((group) => (
<Stack key={group.label} className="gap-0.5 items-stretch">
<Span
size="smaller"
variant="faded"
className="px-2.5 py-1 font-medium"
>
{group.label}
</Span>
{group.items.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
active={conv.id == history.activeId}
history={history}
onSelect={() => onSelectConversation(conv.id)}
/>
))}
</Stack>
))}
{history.loaded && !filtered.length && (
<Span
size="small"
variant="faded"
className="px-2.5 py-2"
>
{query ? "No matching chats" : "No saved chats yet"}
</Span>
)}
</Stack>
{history.conversations.length > 0 &&
(confirmClear ? (
<Row className="w-full flex-nowrap gap-1">
<Button
title="Delete all chats"
color="error"
size="small"
className="grow"
onClick={() => {
history.removeAll();
setConfirmClear(false);
}}
>
Delete all
</Button>
<Button
title="Cancel"
variant="ghost"
color="gray"
size="small"
onClick={() => setConfirmClear(false)}
>
Cancel
</Button>
</Row>
) : (
<Button
title="Delete all chats"
variant="ghost"
color="gray"
size="small"
className="justify-start px-2.5 opacity-70"
beforeIcon={<Trash2 size={14} />}
onClick={() => setConfirmClear(true)}
>
Delete all chats
</Button>
))}
</aside>
);
}
+4
View File
@@ -19,6 +19,10 @@ type Props = {
placeholder?: string;
};
/**
* @deprecated Use `elements/ai/AIChat`, which manages the conversation,
* streaming, stop and regenerate itself.
*/
export default function AIPromptBlock({
model,
promptFn,
+378
View File
@@ -0,0 +1,378 @@
import React from "react";
import twuiReadTextStream, {
type TWUITextStreamSource,
} from "../../utils/read-text-stream";
export const TWUIAIChatRoles = ["system", "user", "assistant"] as const;
export type TWUIAIChatRole = (typeof TWUIAIChatRoles)[number];
export const TWUIAIChatMessageStatuses = [
"streaming",
"done",
"stopped",
"error",
] as const;
export type TWUIAIChatMessageStatus =
(typeof TWUIAIChatMessageStatuses)[number];
/**
* A chat message. `role` and `content` match the OpenAI / Anthropic message
* shape, so `messages.map(({ role, content }) => ({ role, content }))` is a
* valid request body for either API.
*/
export type TWUIAIChatMessage = {
id: string;
role: TWUIAIChatRole;
content: string;
/** Assistant messages only. Absent means `done` */
status?: TWUIAIChatMessageStatus;
/** Set when `status` is `error` */
error?: string;
createdAt?: number;
/** The model that wrote an assistant message */
model?: string;
/** The user's rating of an assistant message */
feedback?: TWUIAIChatFeedback;
};
export type TWUIAIChatFeedback = "up" | "down";
export type TWUIAIChatStatus = "idle" | "streaming";
export type TWUIAIChatSendParams = {
/** The conversation so far, oldest first, ending with the user's message */
messages: TWUIAIChatMessage[];
/** Aborted when the user presses stop, regenerates or clears the chat */
signal: AbortSignal;
/** The selected model, when a model list is in use */
model?: string;
};
/**
* Sends the conversation to the model and returns its reply: a string, an
* async iterable of chunks, a `ReadableStream`, or a fetch `Response`
* streaming text - see {@link TWUITextStreamSource}.
*
* @example
* send={({ messages, signal }) =>
* fetch("/api/chat", {
* method: "POST",
* body: JSON.stringify({ messages }),
* signal,
* })
* }
*/
export type TWUIAIChatSendFn = (
params: TWUIAIChatSendParams,
) => TWUITextStreamSource | Promise<TWUITextStreamSource>;
export type UseAIChatParams = {
send: TWUIAIChatSendFn;
initialMessages?: TWUIAIChatMessage[];
/** Prepended to the messages handed to `send` as a `system` message */
systemPrompt?: string;
/** Passed to `send` and stamped on each assistant reply */
model?: string;
/** Fires on every change, including each streamed chunk */
onMessagesChange?: (messages: TWUIAIChatMessage[]) => void;
/** Fires once an assistant reply finishes, is stopped or fails */
onFinish?: (message: TWUIAIChatMessage) => void;
onError?: (error: Error, message: TWUIAIChatMessage) => void;
};
export type TWUIAIChatController = {
messages: TWUIAIChatMessage[];
status: TWUIAIChatStatus;
isStreaming: boolean;
/** Appends a user message and streams the assistant's reply */
sendMessage: (content: string) => Promise<void>;
/** Aborts the reply in flight, keeping what has streamed so far */
stop: () => void;
/** Re-runs an assistant reply (the last one by default) */
regenerate: (assistantMessageId?: string) => Promise<void>;
/** Replaces a user message, drops everything after it and re-runs */
editMessage: (userMessageId: string, content: string) => Promise<void>;
deleteMessage: (id: string) => void;
/** Shallow-merges fields into a message, e.g. `{ feedback: "up" }` */
updateMessage: (id: string, patch: Partial<TWUIAIChatMessage>) => void;
/** Stops any reply and empties the conversation */
clear: () => void;
setMessages: (messages: TWUIAIChatMessage[]) => void;
};
/**
* Replies saved mid-stream (e.g. the user switched conversations) can't
* resume, so they load as `stopped`.
*/
export function twuiSettleAIChatMessages(messages: TWUIAIChatMessage[]) {
return messages.some((msg) => msg.status == "streaming")
? messages.map((msg) =>
msg.status == "streaming"
? { ...msg, status: "stopped" as const }
: msg,
)
: messages;
}
let idCounter = 0;
export function twuiAIChatMessageId() {
idCounter += 1;
return `twui-msg-${Date.now().toString(36)}-${idCounter}-${Math.random()
.toString(36)
.slice(2, 8)}`;
}
/**
* # useAIChat
* Conversation state for {@link AIChat}: message list, streaming, stop,
* regenerate and edit. Use it directly to drive `AIChat` from outside
* (pass the result as its `chat` prop) or to build your own chat UI.
*/
export default function useAIChat({
send,
initialMessages,
systemPrompt,
model,
onMessagesChange,
onFinish,
onError,
}: UseAIChatParams): TWUIAIChatController {
const [messages, setMessagesState] = React.useState<TWUIAIChatMessage[]>(
() => twuiSettleAIChatMessages(initialMessages || []),
);
const [status, setStatus] = React.useState<TWUIAIChatStatus>("idle");
const messagesRef = React.useRef(messages);
const abortRef = React.useRef<AbortController | null>(null);
// Latest callbacks, so a reply in flight never calls a stale closure
const optionsRef = React.useRef({
send,
systemPrompt,
model,
onMessagesChange,
onFinish,
onError,
});
optionsRef.current = {
send,
systemPrompt,
model,
onMessagesChange,
onFinish,
onError,
};
const commit = React.useCallback((next: TWUIAIChatMessage[]) => {
messagesRef.current = next;
setMessagesState(next);
optionsRef.current.onMessagesChange?.(next);
}, []);
const patchMessage = React.useCallback(
(id: string, patch: (msg: TWUIAIChatMessage) => TWUIAIChatMessage) => {
commit(
messagesRef.current.map((msg) =>
msg.id == id ? patch(msg) : msg,
),
);
},
[commit],
);
const abortCurrent = React.useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
React.useEffect(() => abortCurrent, [abortCurrent]);
/**
* Appends an empty assistant message after `history` and streams the
* reply into it.
*/
const run = React.useCallback(
async (history: TWUIAIChatMessage[]) => {
abortCurrent();
const controller = new AbortController();
abortRef.current = controller;
const { systemPrompt, model } = optionsRef.current;
const assistant: TWUIAIChatMessage = {
id: twuiAIChatMessageId(),
role: "assistant",
content: "",
status: "streaming",
createdAt: Date.now(),
...(model ? { model } : {}),
};
commit([...history, assistant]);
setStatus("streaming");
const requestMessages = history.filter(
(msg) => msg.status !== "error" && msg.role !== "system",
);
const systemMessages = history.filter(
(msg) => msg.role == "system",
);
if (systemPrompt) {
systemMessages.unshift({
id: "twui-system-prompt",
role: "system",
content: systemPrompt,
});
}
let content = "";
const isCurrent = () => abortRef.current === controller;
try {
const source = await optionsRef.current.send({
messages: [...systemMessages, ...requestMessages],
signal: controller.signal,
model,
});
for await (const chunk of twuiReadTextStream(source)) {
if (controller.signal.aborted) break;
content += chunk;
patchMessage(assistant.id, (msg) => ({
...msg,
content,
}));
}
if (!isCurrent() && controller.signal.aborted) {
// Superseded by regenerate/clear: that call owns state now
return;
}
const finished: TWUIAIChatMessage = {
...assistant,
content,
status: controller.signal.aborted ? "stopped" : "done",
};
patchMessage(assistant.id, () => finished);
optionsRef.current.onFinish?.(finished);
} catch (err) {
if (controller.signal.aborted) {
if (!isCurrent()) return;
const stopped: TWUIAIChatMessage = {
...assistant,
content,
status: "stopped",
};
patchMessage(assistant.id, () => stopped);
optionsRef.current.onFinish?.(stopped);
return;
}
const error =
err instanceof Error ? err : new Error(String(err));
const failed: TWUIAIChatMessage = {
...assistant,
content,
status: "error",
error: error.message || "Something went wrong",
};
patchMessage(assistant.id, () => failed);
optionsRef.current.onError?.(error, failed);
optionsRef.current.onFinish?.(failed);
} finally {
if (isCurrent()) {
abortRef.current = null;
setStatus("idle");
}
}
},
[abortCurrent, commit, patchMessage],
);
const sendMessage = React.useCallback(
async (content: string) => {
if (!content.trim()) return;
const user: TWUIAIChatMessage = {
id: twuiAIChatMessageId(),
role: "user",
content,
createdAt: Date.now(),
};
await run([...messagesRef.current, user]);
},
[run],
);
const stop = React.useCallback(() => {
abortRef.current?.abort();
}, []);
const regenerate = React.useCallback(
async (assistantMessageId?: string) => {
const list = messagesRef.current;
const index = assistantMessageId
? list.findIndex((msg) => msg.id == assistantMessageId)
: list.findLastIndex((msg) => msg.role == "assistant");
if (index < 0) {
// No assistant reply yet: answer the last user message
if (list.at(-1)?.role == "user") await run(list);
return;
}
await run(list.slice(0, index));
},
[run],
);
const editMessage = React.useCallback(
async (userMessageId: string, content: string) => {
const list = messagesRef.current;
const index = list.findIndex((msg) => msg.id == userMessageId);
if (index < 0 || !content.trim()) return;
await run([...list.slice(0, index), { ...list[index], content }]);
},
[run],
);
const deleteMessage = React.useCallback(
(id: string) => {
commit(messagesRef.current.filter((msg) => msg.id !== id));
},
[commit],
);
const updateMessage = React.useCallback(
(id: string, patch: Partial<TWUIAIChatMessage>) => {
patchMessage(id, (msg) => ({ ...msg, ...patch }));
},
[patchMessage],
);
const setMessages = React.useCallback(
(next: TWUIAIChatMessage[]) => {
abortCurrent();
setStatus("idle");
commit(twuiSettleAIChatMessages(next));
},
[abortCurrent, commit],
);
const clear = React.useCallback(() => setMessages([]), [setMessages]);
return {
messages,
status,
isStreaming: status == "streaming",
sendMessage,
stop,
regenerate,
editMessage,
deleteMessage,
updateMessage,
clear,
setMessages,
};
}
+261
View File
@@ -0,0 +1,261 @@
import React from "react";
/**
* Custom speech-to-text: receives the recorded audio and returns its text.
*
* @example
* transcribe={async (audio, { signal }) => {
* const body = new FormData();
* body.append("file", audio, "speech.webm");
* const res = await fetch("/api/transcribe", { method: "POST", body, signal });
* return (await res.json()).text;
* }}
*/
export type TWUIAIChatTranscribeFn = (
audio: Blob,
params: { signal: AbortSignal },
) => string | Promise<string>;
export type UseAIChatDictationParams = {
/**
* Replaces the browser's built-in speech recognition (Chrome, Edge,
* Safari) with your own service. Audio is recorded with `MediaRecorder`.
*/
transcribe?: TWUIAIChatTranscribeFn;
/** Built-in recognition: BCP 47 language, e.g. `en-US` */
lang?: string;
/**
* The transcript of the current session so far. Fires repeatedly with
* interim text while listening, then once with `isFinal`.
*/
onTranscript: (text: string, isFinal: boolean) => void;
onError?: (error: Error) => void;
};
export type TWUIAIChatDictationPhase = "idle" | "listening" | "transcribing";
export type TWUIAIChatDictationController = {
/** False during SSR and where neither engine is available */
supported: boolean;
phase: TWUIAIChatDictationPhase;
start: () => Promise<void>;
/** Stops listening and keeps (or transcribes) what was heard */
stop: () => void;
/** Stops listening and discards the session */
cancel: () => void;
};
// The Web Speech API isn't in TypeScript's DOM lib
type SpeechRecognitionResultLike = {
isFinal: boolean;
0: { transcript: string };
};
type SpeechRecognitionLike = {
lang: string;
continuous: boolean;
interimResults: boolean;
onresult:
| ((e: {
resultIndex: number;
results: ArrayLike<SpeechRecognitionResultLike>;
}) => void)
| null;
onerror: ((e: { error: string }) => void) | null;
onend: (() => void) | null;
start: () => void;
stop: () => void;
abort: () => void;
};
type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
function getRecognitionCtor(): SpeechRecognitionCtor | undefined {
if (typeof window == "undefined") return undefined;
const w = window as unknown as {
SpeechRecognition?: SpeechRecognitionCtor;
webkitSpeechRecognition?: SpeechRecognitionCtor;
};
return w.SpeechRecognition || w.webkitSpeechRecognition;
}
/**
* # useAIChatDictation
* Voice input for the chat composer.
*/
export default function useAIChatDictation({
transcribe,
lang,
onTranscript,
onError,
}: UseAIChatDictationParams): TWUIAIChatDictationController {
const [phase, setPhase] = React.useState<TWUIAIChatDictationPhase>("idle");
const [engines, setEngines] = React.useState({
recognition: false,
recorder: false,
});
const recognitionRef = React.useRef<SpeechRecognitionLike | null>(null);
const recorderRef = React.useRef<MediaRecorder | null>(null);
const abortRef = React.useRef<AbortController | null>(null);
const cancelledRef = React.useRef(false);
const optionsRef = React.useRef({ transcribe, lang, onTranscript, onError });
optionsRef.current = { transcribe, lang, onTranscript, onError };
React.useEffect(() => {
setEngines({
recognition: Boolean(getRecognitionCtor()),
recorder:
typeof window !== "undefined" &&
typeof MediaRecorder !== "undefined" &&
Boolean(navigator.mediaDevices?.getUserMedia),
});
}, []);
const fail = (err: unknown) => {
optionsRef.current.onError?.(
err instanceof Error ? err : new Error(String(err)),
);
};
const startRecognition = () => {
const Ctor = getRecognitionCtor();
if (!Ctor) return;
const recognition = new Ctor();
recognition.lang =
optionsRef.current.lang ||
(typeof navigator !== "undefined" ? navigator.language : "en-US");
recognition.continuous = true;
recognition.interimResults = true;
let finalText = "";
recognition.onresult = (e) => {
let interim = "";
for (let i = e.resultIndex; i < e.results.length; i++) {
const result = e.results[i];
if (result.isFinal) finalText += result[0].transcript;
else interim += result[0].transcript;
}
if (!cancelledRef.current) {
optionsRef.current.onTranscript(
(finalText + interim).trim(),
false,
);
}
};
recognition.onerror = (e) => {
if (e.error !== "aborted" && e.error !== "no-speech") {
fail(
new Error(
e.error == "not-allowed"
? "Microphone access was denied"
: `Speech recognition error: ${e.error}`,
),
);
}
};
recognition.onend = () => {
recognitionRef.current = null;
setPhase("idle");
if (!cancelledRef.current) {
optionsRef.current.onTranscript(finalText.trim(), true);
}
};
recognitionRef.current = recognition;
recognition.start();
setPhase("listening");
};
const startRecording = async () => {
let stream: MediaStream;
try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
} catch {
fail(new Error("Microphone access was denied"));
return;
}
const recorder = new MediaRecorder(stream);
const chunks: Blob[] = [];
recorder.ondataavailable = (e) => {
if (e.data.size) chunks.push(e.data);
};
recorder.onstop = async () => {
stream.getTracks().forEach((track) => track.stop());
recorderRef.current = null;
const { transcribe } = optionsRef.current;
if (cancelledRef.current || !chunks.length || !transcribe) {
setPhase("idle");
return;
}
const controller = new AbortController();
abortRef.current = controller;
setPhase("transcribing");
try {
const text = await transcribe(
new Blob(chunks, { type: recorder.mimeType }),
{ signal: controller.signal },
);
if (!controller.signal.aborted) {
optionsRef.current.onTranscript(text.trim(), true);
}
} catch (err) {
if (!controller.signal.aborted) fail(err);
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
setPhase("idle");
}
}
};
recorderRef.current = recorder;
recorder.start();
setPhase("listening");
};
const start = React.useCallback(async () => {
if (recognitionRef.current || recorderRef.current) return;
cancelledRef.current = false;
try {
if (optionsRef.current.transcribe) await startRecording();
else startRecognition();
} catch (err) {
setPhase("idle");
fail(err);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const stop = React.useCallback(() => {
recognitionRef.current?.stop();
if (recorderRef.current?.state == "recording") {
recorderRef.current.stop();
}
}, []);
const cancel = React.useCallback(() => {
cancelledRef.current = true;
abortRef.current?.abort();
abortRef.current = null;
recognitionRef.current?.abort();
if (recorderRef.current?.state == "recording") {
recorderRef.current.stop();
}
setPhase("idle");
}, []);
React.useEffect(() => cancel, [cancel]);
return {
supported: transcribe ? engines.recorder : engines.recognition,
phase,
start,
stop,
cancel,
};
}
+387
View File
@@ -0,0 +1,387 @@
import React from "react";
import _ from "lodash";
import twuiStripMarkdown from "../../utils/strip-markdown";
import {
twuiAIChatMessageId,
twuiSettleAIChatMessages,
type TWUIAIChatMessage,
} from "./useAIChat";
export type TWUIAIChatConversation = {
id: string;
title: string;
/** `auto`: from the first prompt. `generated`: by `generateTitle`.
* `user`: renamed by hand. Only `auto` titles get replaced */
titleSource?: "auto" | "generated" | "user";
messages: TWUIAIChatMessage[];
createdAt: number;
updatedAt: number;
pinned?: boolean;
};
export type UseAIChatHistoryParams = {
/**
* Persist conversations to `localStorage` under this key. Leave it out
* to keep history in memory, or to persist it yourself through
* `onConversationsChange`.
*/
storageKey?: string;
initialConversations?: TWUIAIChatConversation[];
initialActiveId?: string | null;
/** Fires after every change, throttled while a reply streams */
onConversationsChange?: (conversations: TWUIAIChatConversation[]) => void;
/**
* Names a conversation once its first reply finishes, e.g. by asking
* the model for a short title. Until then the first prompt is used.
*/
generateTitle?: (
messages: TWUIAIChatMessage[],
) => string | Promise<string>;
/** Oldest unpinned conversations past this count are dropped */
maxConversations?: number;
};
export type TWUIAIChatHistoryController = {
/** Pinned first, then most recently updated */
conversations: TWUIAIChatConversation[];
activeId: string | null;
active: TWUIAIChatConversation | null;
/** False until stored history has been read (always after mount) */
loaded: boolean;
/** `null` starts a new, not yet saved conversation */
select: (id: string | null) => TWUIAIChatConversation | null;
/**
* Saves messages to the active conversation, creating it from the
* first message when none is active. Returns the conversation's id
*/
saveMessages: (messages: TWUIAIChatMessage[]) => string | null;
rename: (id: string, title: string) => void;
togglePin: (id: string) => void;
remove: (id: string) => void;
removeAll: () => void;
};
const TITLE_LENGTH = 60;
export function twuiAIChatDefaultTitle(messages: TWUIAIChatMessage[]) {
const first = messages.find((msg) => msg.role == "user")?.content;
const text = twuiStripMarkdown(first).replace(/\s+/g, " ").trim();
if (!text) return "New chat";
return text.length > TITLE_LENGTH
? `${text.slice(0, TITLE_LENGTH - 1).trimEnd()}…`
: text;
}
function sortConversations(list: TWUIAIChatConversation[]) {
return [...list].sort(
(a, b) =>
Number(Boolean(b.pinned)) - Number(Boolean(a.pinned)) ||
b.updatedAt - a.updatedAt,
);
}
function capConversations(list: TWUIAIChatConversation[], max?: number) {
if (!max || list.length <= max) return list;
const keep = new Set(
sortConversations(list)
.filter((conv, index) => conv.pinned || index < max)
.map((conv) => conv.id),
);
return list.filter((conv) => keep.has(conv.id));
}
type StoredHistory = {
conversations: TWUIAIChatConversation[];
activeId: string | null;
};
function readStorage(key: string): StoredHistory | null {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<StoredHistory>;
if (!Array.isArray(parsed.conversations)) return null;
return {
conversations: parsed.conversations.map((conv) => ({
...conv,
messages: twuiSettleAIChatMessages(conv.messages || []),
})),
activeId: parsed.activeId ?? null,
};
} catch {
return null;
}
}
/**
* # useAIChatHistory
* Saved conversations for {@link AIChat}: create, switch, rename, pin and
* delete, with optional `localStorage` persistence. Pass the result to
* `AIChat` as `history` to control it from outside.
*/
export default function useAIChatHistory({
storageKey,
initialConversations,
initialActiveId,
onConversationsChange,
generateTitle,
maxConversations,
}: UseAIChatHistoryParams = {}): TWUIAIChatHistoryController {
const [conversations, setConversationsState] = React.useState<
TWUIAIChatConversation[]
>(() => initialConversations || []);
const [activeId, setActiveIdState] = React.useState<string | null>(
initialActiveId ?? null,
);
const [loaded, setLoaded] = React.useState(false);
// Refs mirror state so streamed saves never read a stale render
const conversationsRef = React.useRef(conversations);
const activeIdRef = React.useRef(activeId);
const titlingRef = React.useRef(new Set<string>());
const optionsRef = React.useRef({
onConversationsChange,
generateTitle,
maxConversations,
});
optionsRef.current = {
onConversationsChange,
generateTitle,
maxConversations,
};
const setConversations = React.useCallback(
(next: TWUIAIChatConversation[]) => {
const capped = capConversations(
next,
optionsRef.current.maxConversations,
);
conversationsRef.current = capped;
setConversationsState(capped);
},
[],
);
const setActiveId = React.useCallback((id: string | null) => {
activeIdRef.current = id;
setActiveIdState(id);
}, []);
React.useEffect(() => {
if (storageKey) {
const stored = readStorage(storageKey);
if (stored) {
setConversations(stored.conversations);
setActiveId(
stored.conversations.some(
(conv) => conv.id == stored.activeId,
)
? stored.activeId
: null,
);
}
}
setLoaded(true);
// Read once: a changing key mid-session would mix two histories
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Writing on every streamed chunk would thrash storage: throttle
const persist = React.useMemo(
() =>
_.throttle(
(list: TWUIAIChatConversation[], active: string | null) => {
if (storageKey) {
try {
localStorage.setItem(
storageKey,
JSON.stringify({
conversations: list,
activeId: active,
}),
);
} catch {
// Quota exceeded or storage disabled: keep going
}
}
optionsRef.current.onConversationsChange?.(list);
},
500,
{ leading: false, trailing: true },
),
[storageKey],
);
React.useEffect(() => {
if (loaded) persist(conversations, activeId);
}, [conversations, activeId, loaded, persist]);
React.useEffect(() => () => persist.flush(), [persist]);
const patch = React.useCallback(
(
id: string,
fn: (conv: TWUIAIChatConversation) => TWUIAIChatConversation,
) => {
setConversations(
conversationsRef.current.map((conv) =>
conv.id == id ? fn(conv) : conv,
),
);
},
[setConversations],
);
const maybeGenerateTitle = React.useCallback(
(conv: TWUIAIChatConversation) => {
const { generateTitle } = optionsRef.current;
if (
!generateTitle ||
(conv.titleSource && conv.titleSource !== "auto") ||
titlingRef.current.has(conv.id) ||
!conv.messages.some(
(msg) =>
msg.role == "assistant" &&
(msg.status == undefined || msg.status == "done"),
)
) {
return;
}
titlingRef.current.add(conv.id);
Promise.resolve(generateTitle(conv.messages))
.then((title) => {
const clean = title?.trim().replace(/^["']|["']$/g, "");
if (!clean) return;
patch(conv.id, (current) =>
current.titleSource == "user"
? current
: {
...current,
title: clean,
titleSource: "generated",
},
);
})
.catch(() => {
// Keep the prompt-based title; allow a later retry
titlingRef.current.delete(conv.id);
});
},
[patch],
);
const saveMessages = React.useCallback(
(messages: TWUIAIChatMessage[]) => {
const id = activeIdRef.current;
const now = Date.now();
if (!id) {
if (!messages.length) return null;
const conv: TWUIAIChatConversation = {
id: twuiAIChatMessageId().replace("twui-msg", "twui-conv"),
title: twuiAIChatDefaultTitle(messages),
titleSource: "auto",
messages,
createdAt: now,
updatedAt: now,
};
setActiveId(conv.id);
setConversations([conv, ...conversationsRef.current]);
return conv.id;
}
const current = conversationsRef.current.find(
(conv) => conv.id == id,
);
if (!current || current.messages === messages) return id;
const next: TWUIAIChatConversation = {
...current,
messages,
updatedAt: now,
...(current.titleSource == "auto" || !current.titleSource
? { title: twuiAIChatDefaultTitle(messages) }
: {}),
};
patch(id, () => next);
maybeGenerateTitle(next);
return id;
},
[maybeGenerateTitle, patch, setActiveId, setConversations],
);
const select = React.useCallback(
(id: string | null) => {
const conv =
(id &&
conversationsRef.current.find((conv) => conv.id == id)) ||
null;
// A reply cut off by switching away can't resume
const previous = activeIdRef.current;
if (previous && previous !== conv?.id) {
patch(previous, (prev) => {
const messages = twuiSettleAIChatMessages(prev.messages);
return messages === prev.messages
? prev
: { ...prev, messages };
});
}
setActiveId(conv ? conv.id : null);
return conv;
},
[patch, setActiveId],
);
const rename = React.useCallback(
(id: string, title: string) => {
const clean = title.trim();
if (!clean) return;
patch(id, (conv) => ({ ...conv, title: clean, titleSource: "user" }));
},
[patch],
);
const togglePin = React.useCallback(
(id: string) => {
patch(id, (conv) => ({ ...conv, pinned: !conv.pinned }));
},
[patch],
);
const remove = React.useCallback(
(id: string) => {
if (activeIdRef.current == id) setActiveId(null);
setConversations(
conversationsRef.current.filter((conv) => conv.id !== id),
);
},
[setActiveId, setConversations],
);
const removeAll = React.useCallback(() => {
setActiveId(null);
setConversations([]);
}, [setActiveId, setConversations]);
const sorted = React.useMemo(
() => sortConversations(conversations),
[conversations],
);
return {
conversations: sorted,
activeId,
active: conversations.find((conv) => conv.id == activeId) || null,
loaded,
select,
saveMessages,
rename,
togglePin,
remove,
removeAll,
};
}
+272
View File
@@ -0,0 +1,272 @@
import React from "react";
import twuiStripMarkdown from "../../utils/strip-markdown";
import type { TWUIAIChatMessage } from "./useAIChat";
/**
* Custom text-to-speech. Return audio to have it played for you - a fetch
* `Response`, a `Blob`, or an audio URL - or play it yourself and resolve
* when done. Stop `signal` aborts either way.
*
* @example
* speak={({ text, signal }) =>
* fetch("/api/tts", { method: "POST", body: text, signal })
* }
*/
export type TWUIAIChatSpeakFn = (params: {
/** The message with markdown stripped */
text: string;
message: TWUIAIChatMessage;
signal: AbortSignal;
}) => void | Response | Blob | string | Promise<void | Response | Blob | string>;
export type UseAIChatSpeechParams = {
/** Replaces the browser's built-in speech synthesis */
speak?: TWUIAIChatSpeakFn;
/** Built-in voice: BCP 47 language, e.g. `en-US` */
lang?: string;
/** Built-in voice: a voice name from `speechSynthesis.getVoices()` */
voiceName?: string;
/** Built-in voice: 0.1-10, default 1 */
rate?: number;
/** Built-in voice: 0-2, default 1 */
pitch?: number;
onError?: (error: Error) => void;
};
export type TWUIAIChatSpeechState = {
messageId: string;
/** `loading`: waiting on a custom `speak`. `speaking`: audio is playing */
phase: "loading" | "speaking";
} | null;
export type TWUIAIChatSpeechController = {
/** False during SSR and in browsers without speech synthesis */
supported: boolean;
state: TWUIAIChatSpeechState;
speak: (message: TWUIAIChatMessage) => Promise<void>;
stop: () => void;
};
/**
* Chrome cuts off utterances after ~15s, so long replies are queued as
* sentence-sized chunks.
*/
function chunkText(text: string, max = 220) {
const sentences = text.match(/[^.!?\n]+[.!?]*[\s]*|\n+/g) || [text];
const chunks: string[] = [];
let current = "";
for (const sentence of sentences) {
if ((current + sentence).length > max && current.trim()) {
chunks.push(current.trim());
current = "";
}
current += sentence;
while (current.length > max) {
const cut = current.lastIndexOf(" ", max);
const at = cut > 0 ? cut : max;
chunks.push(current.slice(0, at).trim());
current = current.slice(at);
}
}
if (current.trim()) chunks.push(current.trim());
return chunks;
}
function waitForAbort(signal: AbortSignal) {
return new Promise<void>((resolve) => {
if (signal.aborted) resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
}
/**
* # useAIChatSpeech
* Reads assistant messages aloud, one at a time.
*/
export default function useAIChatSpeech({
speak: customSpeak,
lang,
voiceName,
rate,
pitch,
onError,
}: UseAIChatSpeechParams = {}): TWUIAIChatSpeechController {
const [state, setState] = React.useState<TWUIAIChatSpeechState>(null);
const [browserSupported, setBrowserSupported] = React.useState(false);
const currentRef = React.useRef<AbortController | null>(null);
const optionsRef = React.useRef({
customSpeak,
lang,
voiceName,
rate,
pitch,
onError,
});
optionsRef.current = {
customSpeak,
lang,
voiceName,
rate,
pitch,
onError,
};
React.useEffect(() => {
setBrowserSupported(
typeof window !== "undefined" && "speechSynthesis" in window,
);
}, []);
const stop = React.useCallback(() => {
currentRef.current?.abort();
currentRef.current = null;
setState(null);
}, []);
React.useEffect(() => stop, [stop]);
const playAudio = async (
source: Response | Blob | string,
signal: AbortSignal,
) => {
let url: string;
let revoke = false;
if (typeof source == "string") {
url = source;
} else {
const blob =
source instanceof Blob
? source
: await (async () => {
if (!source.ok) {
throw new Error(
(await source.text().catch(() => "")) ||
`Speech request failed with status ${source.status}`,
);
}
return source.blob();
})();
url = URL.createObjectURL(blob);
revoke = true;
}
const audio = new Audio(url);
try {
if (signal.aborted) return;
await audio.play();
return await new Promise<void>((resolve, reject) => {
audio.onended = () => resolve();
audio.onerror = () =>
reject(new Error("Could not play the speech audio"));
signal.addEventListener(
"abort",
() => {
audio.pause();
resolve();
},
{ once: true },
);
});
} finally {
audio.pause();
if (revoke) URL.revokeObjectURL(url);
}
};
const speakWithBrowser = (text: string, signal: AbortSignal) => {
const synth = window.speechSynthesis;
synth.cancel();
const { lang, voiceName, rate, pitch } = optionsRef.current;
const voice = voiceName
? synth.getVoices().find((v) => v.name == voiceName)
: undefined;
const chunks = chunkText(text);
return new Promise<void>((resolve) => {
signal.addEventListener(
"abort",
() => {
synth.cancel();
resolve();
},
{ once: true },
);
chunks.forEach((chunk, index) => {
const utterance = new SpeechSynthesisUtterance(chunk);
if (voice) utterance.voice = voice;
if (lang) utterance.lang = lang;
if (rate) utterance.rate = rate;
if (pitch !== undefined) utterance.pitch = pitch;
if (index == chunks.length - 1) {
utterance.onend = () => resolve();
}
// `cancel()` fires errors too; any error ends this read-out
utterance.onerror = () => resolve();
synth.speak(utterance);
});
});
};
const speak = React.useCallback(
async (message: TWUIAIChatMessage) => {
stop();
const text = twuiStripMarkdown(message.content);
if (!text) return;
const controller = new AbortController();
currentRef.current = controller;
const { signal } = controller;
const isCurrent = () => currentRef.current === controller;
const { customSpeak } = optionsRef.current;
try {
if (customSpeak) {
setState({ messageId: message.id, phase: "loading" });
const result = await Promise.race([
Promise.resolve(
customSpeak({ text, message, signal }),
),
waitForAbort(signal),
]);
if (signal.aborted || !isCurrent()) return;
if (result) {
setState({ messageId: message.id, phase: "speaking" });
await playAudio(result, signal);
}
} else {
if (!("speechSynthesis" in window)) return;
setState({ messageId: message.id, phase: "speaking" });
await speakWithBrowser(text, signal);
}
} catch (err) {
if (!signal.aborted) {
optionsRef.current.onError?.(
err instanceof Error ? err : new Error(String(err)),
);
}
} finally {
if (isCurrent()) {
currentRef.current = null;
setState(null);
}
}
},
// `playAudio`/`speakWithBrowser` only read refs
// eslint-disable-next-line react-hooks/exhaustive-deps
[stop],
);
return {
supported: Boolean(customSpeak) || browserSupported,
state,
speak,
stop,
};
}
+5
View File
@@ -125,13 +125,18 @@ export { default as fileInputToBase64 } from "./utils/form/fileInputToBase64";
export * from "./utils/form/fileInputToBase64";
export { default as imageInputToBase64 } from "./utils/form/imageInputToBase64";
export * from "./utils/form/imageInputToBase64";
export { default as twuiGroupByRecency } from "./utils/group-by-recency";
export * from "./utils/group-by-recency";
export { default as lowerToTitleCase } from "./utils/lower-to-title-case";
export { default as twuiNormalizeText } from "./utils/normalize-text";
export { default as twuiNumberfy } from "./utils/numberfy";
export { default as twuiReadTextStream } from "./utils/read-text-stream";
export * from "./utils/read-text-stream";
export { default as TWUIRoundToNearestMagnitude } from "./utils/round-to-nearest-magnitude";
export { default as twuiSerializeQuery } from "./utils/serialize-query";
export { default as twuiSlugToNormalText } from "./utils/slug-to-normal-text";
export { default as twuiSlugify } from "./utils/slugify";
export { default as twuiStripMarkdown } from "./utils/strip-markdown";
// svgs
export { default as CheckMarkSVG } from "./svgs/CheckMarkSVG";
+46
View File
@@ -0,0 +1,46 @@
import { describe, expect, test } from "bun:test";
import twuiGroupByRecency from "./group-by-recency";
const DAY = 24 * 60 * 60 * 1000;
const NOW = new Date(2026, 9, 26, 15, 0, 0).getTime();
describe("twuiGroupByRecency", () => {
test("returns no groups for no items", () => {
expect(twuiGroupByRecency([], (n: number) => n, NOW)).toEqual([]);
});
test("buckets by recency, newest first, skipping empty groups", () => {
const items = [
NOW - 40 * DAY,
NOW - 1000,
NOW - DAY,
NOW - 3 * DAY,
NOW - 2000,
];
const groups = twuiGroupByRecency(items, (n) => n, NOW);
expect(groups.map((g) => g.label)).toEqual([
"Today",
"Yesterday",
"Previous 7 days",
new Date(NOW - 40 * DAY).toLocaleDateString(undefined, {
month: "long",
year: "numeric",
}),
]);
expect(groups[0].items).toEqual([NOW - 1000, NOW - 2000]);
});
test("treats midnight today as today", () => {
const midnight = new Date(2026, 9, 26, 0, 0, 0).getTime();
expect(twuiGroupByRecency([midnight], (n) => n, NOW)[0].label).toBe(
"Today",
);
});
test("does not mutate the input", () => {
const items = [1, NOW];
twuiGroupByRecency(items, (n) => n, NOW);
expect(items).toEqual([1, NOW]);
});
});
+46
View File
@@ -0,0 +1,46 @@
export type TWUIRecencyGroup<T> = {
label: string;
items: T[];
};
const DAY = 24 * 60 * 60 * 1000;
/**
* # Group by recency
* Buckets items into "Today", "Yesterday", "Previous 7 days",
* "Previous 30 days" and then by month, newest first - the grouping chat
* apps use for conversation lists. Empty groups are left out.
*/
export default function twuiGroupByRecency<T>(
items: T[],
getTime: (item: T) => number,
now: number = Date.now(),
): TWUIRecencyGroup<T>[] {
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const today = startOfToday.getTime();
const groups = new Map<string, T[]>();
const label = (time: number) => {
if (time >= today) return "Today";
if (time >= today - DAY) return "Yesterday";
if (time >= today - 7 * DAY) return "Previous 7 days";
if (time >= today - 30 * DAY) return "Previous 30 days";
return new Date(time).toLocaleDateString(undefined, {
month: "long",
year: "numeric",
});
};
[...items]
.sort((a, b) => getTime(b) - getTime(a))
.forEach((item) => {
const key = label(getTime(item));
const list = groups.get(key);
if (list) list.push(item);
else groups.set(key, [item]);
});
return Array.from(groups, ([label, items]) => ({ label, items }));
}
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test";
import twuiReadTextStream from "./read-text-stream";
async function collect(source: Parameters<typeof twuiReadTextStream>[0]) {
const chunks: string[] = [];
for await (const chunk of twuiReadTextStream(source)) chunks.push(chunk);
return chunks;
}
describe("twuiReadTextStream", () => {
test("yields a whole string once", async () => {
expect(await collect("hello")).toEqual(["hello"]);
});
test("yields nothing for an empty string", async () => {
expect(await collect("")).toEqual([]);
});
test("passes through an async iterable of strings", async () => {
async function* gen() {
yield "a";
yield "";
yield "b";
}
expect(await collect(gen())).toEqual(["a", "b"]);
});
test("decodes a byte stream, keeping split multi-byte characters", async () => {
const bytes = new TextEncoder().encode("é€");
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(bytes.slice(0, 1));
controller.enqueue(bytes.slice(1, 3));
controller.enqueue(bytes.slice(3));
controller.close();
},
});
expect((await collect(stream)).join("")).toBe("é€");
});
test("reads the body of an ok Response", async () => {
const res = new Response("streamed text");
expect((await collect(res)).join("")).toBe("streamed text");
});
test("throws with the body text of a failed Response", async () => {
const res = new Response("Rate limited", { status: 429 });
await expect(collect(res)).rejects.toThrow("Rate limited");
});
test("throws with the status when a failed Response has no body", async () => {
const res = new Response(null, { status: 500 });
await expect(collect(res)).rejects.toThrow("status 500");
});
});
+84
View File
@@ -0,0 +1,84 @@
export type TWUITextStreamChunk = string | Uint8Array;
/**
* Anything a chat backend can hand back: a whole string, an async iterable
* of chunks (e.g. an async generator over an SDK stream), a web
* `ReadableStream`, or a fetch `Response` whose body is the text stream.
*/
export type TWUITextStreamSource =
| string
| AsyncIterable<TWUITextStreamChunk>
| ReadableStream<TWUITextStreamChunk>
| Response;
function isResponse(source: unknown): source is Response {
return typeof Response !== "undefined" && source instanceof Response;
}
function isReadableStream(
source: unknown,
): source is ReadableStream<TWUITextStreamChunk> {
return (
typeof source == "object" &&
source !== null &&
typeof (source as ReadableStream).getReader == "function"
);
}
/**
* # Read a text stream
* Normalizes a {@link TWUITextStreamSource} into an async iterable of text
* chunks. Byte chunks are decoded as UTF-8, keeping multi-byte characters
* split across chunks intact. A non-2xx `Response` throws with its body text.
*/
export default async function* twuiReadTextStream(
source: TWUITextStreamSource,
): AsyncGenerator<string, void, undefined> {
if (typeof source == "string") {
if (source) yield source;
return;
}
if (isResponse(source)) {
if (!source.ok) {
const body = await source.text().catch(() => "");
throw new Error(
body || `Request failed with status ${source.status}`,
);
}
if (!source.body) return;
yield* twuiReadTextStream(source.body);
return;
}
const decoder = new TextDecoder();
const decode = (chunk: TWUITextStreamChunk) =>
typeof chunk == "string"
? chunk
: decoder.decode(chunk, { stream: true });
if (isReadableStream(source)) {
// `getReader` rather than `for await`: not every browser makes
// `ReadableStream` async-iterable
const reader = source.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decode(value);
if (text) yield text;
}
} finally {
reader.releaseLock();
}
} else {
for await (const chunk of source) {
const text = decode(chunk);
if (text) yield text;
}
}
const rest = decoder.decode();
if (rest) yield rest;
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";
import twuiStripMarkdown from "./strip-markdown";
describe("twuiStripMarkdown", () => {
test("returns empty string for undefined input", () => {
expect(twuiStripMarkdown(undefined)).toBe("");
});
test("removes headings and emphasis", () => {
expect(twuiStripMarkdown("## Title\n\nSome **bold** and _italic_ text")).toBe(
"Title\n\nSome bold and italic text",
);
});
test("keeps link and image text", () => {
expect(twuiStripMarkdown("See [the docs](https://x.y) ![logo](a.png)")).toBe(
"See the docs logo",
);
});
test("removes list markers and blockquotes", () => {
expect(twuiStripMarkdown("- one\n* two\n1. three\n> quoted")).toBe(
"one\ntwo\nthree\nquoted",
);
});
test("unwraps inline code", () => {
expect(twuiStripMarkdown("Run `bun test` now")).toBe("Run bun test now");
});
test("drops fenced code blocks by default", () => {
expect(twuiStripMarkdown("Before\n\n```ts\nconst a = 1;\n```\n\nAfter")).toBe(
"Before\n\nAfter",
);
});
test("keeps code block contents when asked", () => {
expect(
twuiStripMarkdown("```ts\nconst a = 1;\n```", { keepCodeBlocks: true }),
).toBe("const a = 1;");
});
test("drops an unterminated (still streaming) code block", () => {
expect(twuiStripMarkdown("Text\n\n```js\nlet x")).toBe("Text");
});
test("flattens tables into readable rows", () => {
expect(
twuiStripMarkdown("| Prop | Use |\n| --- | --- |\n| send | stream |"),
).toBe("Prop, Use\nsend, stream");
});
});
+49
View File
@@ -0,0 +1,49 @@
/**
* # Strip markdown
* Reduces markdown to plain readable text, e.g. for text-to-speech or
* previews. Code blocks are dropped by default: read aloud, they are noise.
*/
export default function twuiStripMarkdown(
markdown?: string,
options?: { keepCodeBlocks?: boolean },
): string {
if (!markdown) return "";
let text = markdown.replace(/\r\n/g, "\n");
text = text.replace(/^(```|~~~)[^\n]*\n([\s\S]*?)(\n\1[ \t]*$|$(?![\s\S]))/gm, (_, __, code) =>
options?.keepCodeBlocks ? code : "",
);
text = text
// Images, then links: keep the visible text
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
// Inline code
.replace(/`([^`]+)`/g, "$1")
// Headings, blockquotes, list markers
.replace(/^\s{0,3}#{1,6}\s+/gm, "")
.replace(/^\s{0,3}>\s?/gm, "")
.replace(/^\s*[-*+]\s+(\[[ xX]\]\s+)?/gm, "")
.replace(/^\s*\d+[.)]\s+/gm, "")
// Horizontal rules and table separator rows
.replace(/^\s*([-*_]\s*){3,}$/gm, "")
.replace(/^\s*\|?(\s*:?-+:?\s*\|)+\s*:?-*:?\s*\|?\s*$/gm, "")
// Table pipes
.replace(/^\s*\|/gm, "")
.replace(/\|\s*$/gm, "")
.replace(/\s*\|\s*/g, ", ")
// Emphasis and strikethrough
.replace(/(\*\*|__)(.+?)\1/g, "$2")
.replace(/(\*|_)(.+?)\1/g, "$2")
.replace(/~~(.+?)~~/g, "$1")
// HTML tags
.replace(/<\/?[a-zA-Z][^>]*>/g, "");
return text
.split("\n")
.map((line) => line.trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}