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
+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>
);
}