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:
@@ -25,7 +25,7 @@ Only install these if you use the matching components:
|
|||||||
| `editors/AceEditor` | `ace-builds` |
|
| `editors/AceEditor` | `ace-builds` |
|
||||||
| `elements/RemoteCodeBlock`, `mdx/markdown/*` | `next-mdx-remote`, `remark-gfm`, `rehype-prism-plus` |
|
| `elements/RemoteCodeBlock`, `mdx/markdown/*` | `next-mdx-remote`, `remark-gfm`, `rehype-prism-plus` |
|
||||||
| `mdx/mdx-components`, `next-js/hooks/useMDXComponents` | `@types/mdx` (dev) |
|
| `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.
|
`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.
|
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.
|
Component modules are marked `"use client"`, so they work in the Next.js App Router.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const NAV = [
|
|||||||
{ href: "/buttons", label: "Buttons" },
|
{ href: "/buttons", label: "Buttons" },
|
||||||
{ href: "/forms", label: "Forms" },
|
{ href: "/forms", label: "Forms" },
|
||||||
{ href: "/elements", label: "Elements" },
|
{ href: "/elements", label: "Elements" },
|
||||||
|
{ href: "/ai", label: "AI" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Root({ children }: BunextRootComponentProps) {
|
export default function Root({ children }: BunextRootComponentProps) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -190,3 +190,76 @@ ul,
|
|||||||
ol {
|
ol {
|
||||||
margin-left: 25px;
|
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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,10 @@ type Props = {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Use `elements/ai/AIChat`, which manages the conversation,
|
||||||
|
* streaming, stop and regenerate itself.
|
||||||
|
*/
|
||||||
export default function AIPromptBlock({
|
export default function AIPromptBlock({
|
||||||
model,
|
model,
|
||||||
promptFn,
|
promptFn,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -125,13 +125,18 @@ export { default as fileInputToBase64 } from "./utils/form/fileInputToBase64";
|
|||||||
export * from "./utils/form/fileInputToBase64";
|
export * from "./utils/form/fileInputToBase64";
|
||||||
export { default as imageInputToBase64 } from "./utils/form/imageInputToBase64";
|
export { default as imageInputToBase64 } from "./utils/form/imageInputToBase64";
|
||||||
export * 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 lowerToTitleCase } from "./utils/lower-to-title-case";
|
||||||
export { default as twuiNormalizeText } from "./utils/normalize-text";
|
export { default as twuiNormalizeText } from "./utils/normalize-text";
|
||||||
export { default as twuiNumberfy } from "./utils/numberfy";
|
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 TWUIRoundToNearestMagnitude } from "./utils/round-to-nearest-magnitude";
|
||||||
export { default as twuiSerializeQuery } from "./utils/serialize-query";
|
export { default as twuiSerializeQuery } from "./utils/serialize-query";
|
||||||
export { default as twuiSlugToNormalText } from "./utils/slug-to-normal-text";
|
export { default as twuiSlugToNormalText } from "./utils/slug-to-normal-text";
|
||||||
export { default as twuiSlugify } from "./utils/slugify";
|
export { default as twuiSlugify } from "./utils/slugify";
|
||||||
|
export { default as twuiStripMarkdown } from "./utils/strip-markdown";
|
||||||
|
|
||||||
// svgs
|
// svgs
|
||||||
export { default as CheckMarkSVG } from "./svgs/CheckMarkSVG";
|
export { default as CheckMarkSVG } from "./svgs/CheckMarkSVG";
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 }));
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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) ")).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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user