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]>
103 lines
3.5 KiB
TypeScript
103 lines
3.5 KiB
TypeScript
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>
|
|
);
|
|
}
|