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:
@@ -190,3 +190,76 @@ ul,
|
||||
ol {
|
||||
margin-left: 25px;
|
||||
}
|
||||
|
||||
/* Assistant replies in `elements/ai/AIChat`. Scoped, since Tailwind's
|
||||
preflight strips the default styles of the elements markdown produces */
|
||||
.twui-ai-chat-markdown {
|
||||
@apply flex flex-col gap-3 leading-7 break-words min-w-0;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown > * {
|
||||
@apply m-0;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown h1 {
|
||||
@apply text-2xl font-bold mt-2;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown h2 {
|
||||
@apply text-xl font-bold mt-2;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown h3,
|
||||
.twui-ai-chat-markdown h4,
|
||||
.twui-ai-chat-markdown h5,
|
||||
.twui-ai-chat-markdown h6 {
|
||||
@apply text-base font-semibold mt-1;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown ul,
|
||||
.twui-ai-chat-markdown ol {
|
||||
@apply ml-5 flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown li > ul,
|
||||
.twui-ai-chat-markdown li > ol {
|
||||
@apply mt-1;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown a {
|
||||
@apply text-link dark:text-link-dark underline;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown blockquote {
|
||||
@apply border-l-4 border-solid border-slate-300 dark:border-white/20 pl-4 opacity-80;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown hr {
|
||||
@apply border-slate-200 dark:border-white/10;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown :not(pre) > code {
|
||||
@apply px-1.5 py-0.5 rounded-default-sm text-[0.875em];
|
||||
@apply bg-slate-100 dark:bg-white/10;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown .twui-code-block-pre {
|
||||
@apply text-sm text-white whitespace-pre px-3 pt-2 pb-3;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown-table {
|
||||
@apply w-full overflow-x-auto;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown table {
|
||||
@apply w-full border-collapse text-sm;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown th,
|
||||
.twui-ai-chat-markdown td {
|
||||
@apply border border-solid border-slate-200 dark:border-white/10 px-3 py-1.5 text-left;
|
||||
}
|
||||
|
||||
.twui-ai-chat-markdown th {
|
||||
@apply font-semibold bg-slate-50 dark:bg-white/5;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use `elements/ai/AIChat`, which manages the conversation,
|
||||
* streaming, stop and regenerate itself.
|
||||
*/
|
||||
export default function AIPromptBlock({
|
||||
model,
|
||||
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 { default as imageInputToBase64 } from "./utils/form/imageInputToBase64";
|
||||
export * from "./utils/form/imageInputToBase64";
|
||||
export { default as twuiGroupByRecency } from "./utils/group-by-recency";
|
||||
export * from "./utils/group-by-recency";
|
||||
export { default as lowerToTitleCase } from "./utils/lower-to-title-case";
|
||||
export { default as twuiNormalizeText } from "./utils/normalize-text";
|
||||
export { default as twuiNumberfy } from "./utils/numberfy";
|
||||
export { default as twuiReadTextStream } from "./utils/read-text-stream";
|
||||
export * from "./utils/read-text-stream";
|
||||
export { default as TWUIRoundToNearestMagnitude } from "./utils/round-to-nearest-magnitude";
|
||||
export { default as twuiSerializeQuery } from "./utils/serialize-query";
|
||||
export { default as twuiSlugToNormalText } from "./utils/slug-to-normal-text";
|
||||
export { default as twuiSlugify } from "./utils/slugify";
|
||||
export { default as twuiStripMarkdown } from "./utils/strip-markdown";
|
||||
|
||||
// svgs
|
||||
export { default as CheckMarkSVG } from "./svgs/CheckMarkSVG";
|
||||
|
||||
@@ -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