feat(ai-ui): Wave 2 MVP @bytelyst/ai-ui@0.1.0
This commit is contained in:
parent
ed5fb707ad
commit
c9a7f905af
@ -59,6 +59,7 @@ jobs:
|
||||
--filter @bytelyst/quick-actions \
|
||||
--filter @bytelyst/react-auth \
|
||||
--filter @bytelyst/dashboard-shell \
|
||||
--filter @bytelyst/ai-ui \
|
||||
run build
|
||||
|
||||
- name: Enforce size budgets
|
||||
|
||||
@ -65,4 +65,11 @@ module.exports = [
|
||||
limit: '30 KB',
|
||||
gzip: true,
|
||||
},
|
||||
// ── AI-native UI (35 KB — streaming + parsing is heavy) ─────────
|
||||
{
|
||||
name: '@bytelyst/ai-ui',
|
||||
path: 'packages/ai-ui/dist/index.js',
|
||||
limit: '35 KB',
|
||||
gzip: true,
|
||||
},
|
||||
];
|
||||
|
||||
107
packages/ai-ui/README.md
Normal file
107
packages/ai-ui/README.md
Normal file
@ -0,0 +1,107 @@
|
||||
# `@bytelyst/ai-ui`
|
||||
|
||||
AI-native UI primitives for ByteLyst products. The flagship deliverable
|
||||
of Wave 2 in `learning_ai_uxui_web/docs/ROADMAP_2026.md`.
|
||||
|
||||
> **Status:** `0.1.0` MVP — 3 components + 1 hook + 1 utility.
|
||||
> Subsequent 0.x bumps add `<ToolCallCard>`, `<CitationChip>`,
|
||||
> `<AgentTimeline>`, `<ModelPicker>`, `<ToolPalette>`, and the
|
||||
> `useToolCalls` / `usePromptHistory` / `useTokenCount` hooks.
|
||||
|
||||
## Quick start
|
||||
|
||||
```tsx
|
||||
import { ChatStream } from '@bytelyst/ai-ui';
|
||||
|
||||
export function ChatPage() {
|
||||
return (
|
||||
<div style={{ height: '70vh' }}>
|
||||
<ChatStream
|
||||
endpoint="/api/chat"
|
||||
placeholder="Ask anything…"
|
||||
emptyState={<EmptyHint />}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Architectural decisions
|
||||
|
||||
Per `learning_ai_common_plat/docs/ROADMAP_2026_DECISIONS.md`:
|
||||
|
||||
- **§10 hook shape** — `useChat()` returns the **same shape as Vercel
|
||||
AI SDK's `useChat`**. Drop-in compatible for any engineer who has
|
||||
already written a chat UI with the AI SDK.
|
||||
- **Transport is pluggable** — products inject `endpoint`,
|
||||
`streamProtocol`, `fetcher`, or `headers`. We are not locked to
|
||||
Vercel's SSE wire format.
|
||||
|
||||
## Components
|
||||
|
||||
### `<ChatStream>`
|
||||
|
||||
Opinionated composition of `useChat` + `<MessageBubble>` +
|
||||
`<PromptComposer>`. Auto-scrolls (sticky-bottom), shows Stop while
|
||||
streaming, surfaces transport errors inline.
|
||||
|
||||
### `<MessageBubble>`
|
||||
|
||||
Single chat message atom. Variants by role (user / assistant / system /
|
||||
tool). Built-in "thinking" indicator while streaming with no content
|
||||
yet. Honors `--bl-*` design tokens.
|
||||
|
||||
### `<PromptComposer>`
|
||||
|
||||
Multi-line input. Enter submits, Shift+Enter newlines, Cmd/Ctrl+Enter
|
||||
alternates submit. Auto-grows up to `maxLines`. Slot for leading
|
||||
actions (slash-commands etc.).
|
||||
|
||||
## Hooks
|
||||
|
||||
### `useChat(options?)`
|
||||
|
||||
Vercel-AI-SDK-shaped:
|
||||
|
||||
```ts
|
||||
const {
|
||||
messages, input, setInput, handleInputChange, handleSubmit,
|
||||
append, isLoading, error, stop, reload, setMessages
|
||||
} = useChat({
|
||||
endpoint: '/api/chat',
|
||||
streamProtocol: 'text', // 'text' | 'sse' | 'data'
|
||||
initialMessages: [],
|
||||
onFinish: (msg) => console.log('done', msg),
|
||||
onError: (err) => console.error(err),
|
||||
});
|
||||
```
|
||||
|
||||
### `streamText(response, protocol?, signal?)`
|
||||
|
||||
Low-level async generator that yields text deltas from a streaming
|
||||
HTTP response. Supports `'text'`, `'sse'`, and `'data'` protocols.
|
||||
|
||||
## Wire protocols
|
||||
|
||||
| Protocol | Body format | Use when |
|
||||
|---|---|---|
|
||||
| `'text'` (default) | Plain text, streamed chunk-by-chunk | Any backend that pipes an LLM stream directly |
|
||||
| `'sse'` | Server-Sent Events with `data: ...` frames | OpenAI-compatible, Anthropic, most HF inference |
|
||||
| `'data'` | Vercel AI SDK "data stream" prefix protocol | Backends using `streamText()` from `ai/server` |
|
||||
|
||||
For tool calls / citations / images, use `'sse'` or `'data'` once the
|
||||
matching components ship in `0.2.x`.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
pnpm --filter @bytelyst/ai-ui test
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- `0.2.0` — `<ToolCallCard>`, `<CitationChip>`, `useToolCalls()`
|
||||
- `0.3.0` — `<AgentTimeline>`, `<ModelPicker>`
|
||||
- `0.4.0` — `<ToolPalette>` with MCP discovery
|
||||
- `0.5.0` — `<Markdown>` + `<CodeDiff>` (Wave 2 stretch)
|
||||
- `1.0.0` — API freeze when 3+ products consume in production
|
||||
36
packages/ai-ui/package.json
Normal file
36
packages/ai-ui/package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "@bytelyst/ai-ui",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"description": "AI-native UI primitives — ChatStream, MessageBubble, PromptComposer, useChat. Transport-agnostic; adopts Vercel AI SDK hook shape.",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run --pool forks",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"happy-dom": "^18.0.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
136
packages/ai-ui/src/ChatStream.tsx
Normal file
136
packages/ai-ui/src/ChatStream.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react';
|
||||
import { MessageBubble, type MessageBubbleProps } from './MessageBubble.js';
|
||||
import { PromptComposer } from './PromptComposer.js';
|
||||
import { useChat } from './useChat.js';
|
||||
import type { Message, UseChatOptions } from './types.js';
|
||||
|
||||
export interface ChatStreamProps extends UseChatOptions {
|
||||
/** Render slot above the messages list (e.g. a header or model picker). */
|
||||
header?: ReactNode;
|
||||
/** Render slot below the composer (e.g. a disclaimer). */
|
||||
footer?: ReactNode;
|
||||
/** Slot to render when there are no messages yet. */
|
||||
emptyState?: ReactNode;
|
||||
/** Per-message custom renderer. Defaults to <MessageBubble />. */
|
||||
renderMessage?: (message: Message) => ReactNode;
|
||||
/** Forwarded to every <MessageBubble> when default renderer is used. */
|
||||
messageProps?: Omit<MessageBubbleProps, 'message'>;
|
||||
/** Placeholder shown inside <PromptComposer>. */
|
||||
placeholder?: string;
|
||||
/** Tailwind escape hatch. */
|
||||
className?: string;
|
||||
/** Inline style escape hatch. */
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* `<ChatStream>` — opinionated composition of `useChat` +
|
||||
* `<MessageBubble>` + `<PromptComposer>`. The 80% path for any chat UI
|
||||
* in a ByteLyst product. Drop it in, point it at an endpoint, done.
|
||||
*
|
||||
* Behavior:
|
||||
* - Auto-scrolls to bottom on new message (sticky-bottom pattern).
|
||||
* - Shows Stop affordance while streaming.
|
||||
* - Surfaces transport errors inline below the messages list.
|
||||
* - Renders `emptyState` when there are zero messages.
|
||||
*
|
||||
* For more control, compose the primitives yourself — `useChat` returns
|
||||
* everything you need.
|
||||
*/
|
||||
export function ChatStream({
|
||||
header,
|
||||
footer,
|
||||
emptyState,
|
||||
renderMessage,
|
||||
messageProps,
|
||||
placeholder,
|
||||
className,
|
||||
style,
|
||||
...chatOptions
|
||||
}: ChatStreamProps) {
|
||||
const chat = useChat(chatOptions);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sticky-bottom scroll — only auto-scroll if the user was already at
|
||||
// (or near) the bottom. Otherwise their reading position is preserved.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
||||
if (distanceFromBottom < 80) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [chat.messages]);
|
||||
|
||||
const messages = chat.messages;
|
||||
const showEmpty = messages.length === 0 && !chat.isLoading;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="bl-chat-stream"
|
||||
className={className}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
minHeight: 0,
|
||||
gap: 'var(--bl-space-3, 12px)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
data-testid="bl-chat-messages"
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 'var(--bl-space-3, 12px)',
|
||||
padding: 'var(--bl-space-2, 8px)',
|
||||
}}
|
||||
>
|
||||
{showEmpty
|
||||
? emptyState
|
||||
: messages.map(message =>
|
||||
renderMessage ? (
|
||||
<span key={message.id}>{renderMessage(message)}</span>
|
||||
) : (
|
||||
<MessageBubble key={message.id} message={message} {...messageProps} />
|
||||
),
|
||||
)}
|
||||
|
||||
{chat.error && (
|
||||
<div
|
||||
role="alert"
|
||||
data-testid="bl-chat-error"
|
||||
style={{
|
||||
padding: 'var(--bl-space-3, 12px)',
|
||||
borderRadius: 'var(--bl-radius-card, 12px)',
|
||||
background: 'var(--bl-danger-muted, rgba(239,68,68,0.1))',
|
||||
color: 'var(--bl-danger, #ef4444)',
|
||||
fontSize: '0.85rem',
|
||||
}}
|
||||
>
|
||||
{chat.error.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PromptComposer
|
||||
value={chat.input}
|
||||
onChange={chat.handleInputChange}
|
||||
onSubmit={chat.handleSubmit}
|
||||
disabled={chat.isLoading}
|
||||
showStop={chat.isLoading}
|
||||
onStop={chat.stop}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
packages/ai-ui/src/MessageBubble.tsx
Normal file
163
packages/ai-ui/src/MessageBubble.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { Message } from './types.js';
|
||||
|
||||
export interface MessageBubbleProps {
|
||||
message: Message;
|
||||
/**
|
||||
* Optional action buttons rendered in the footer (copy / retry /
|
||||
* feedback). Receives the message so each handler can be wired
|
||||
* statelessly.
|
||||
*/
|
||||
actions?: (message: Message) => ReactNode;
|
||||
/** Override the rendered role label (default: capitalized role). */
|
||||
roleLabel?: string;
|
||||
/** Tailwind-friendly className escape hatch. */
|
||||
className?: string;
|
||||
/** Inline style escape hatch. */
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atom: a single chat message bubble. Variants by role (user / assistant /
|
||||
* system / tool). Honors `--bl-*` design tokens so it inherits whatever
|
||||
* scheme + density is active on the surrounding `<html>`.
|
||||
*
|
||||
* The "thinking" indicator (three pulsing dots) appears for assistant
|
||||
* messages while `isStreaming` is true and content is empty — i.e. the
|
||||
* window between request-sent and first-token-received.
|
||||
*/
|
||||
export function MessageBubble({
|
||||
message,
|
||||
actions,
|
||||
roleLabel,
|
||||
className,
|
||||
style,
|
||||
}: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const isAssistant = message.role === 'assistant';
|
||||
const isSystem = message.role === 'system';
|
||||
const isTool = message.role === 'tool';
|
||||
|
||||
const thinking = isAssistant && message.isStreaming && !message.content;
|
||||
|
||||
// Variant colors via design tokens — tone shifts so the eye can
|
||||
// separate user from assistant without relying on alignment alone.
|
||||
const variantStyle: CSSProperties = isUser
|
||||
? {
|
||||
background: 'var(--bl-accent-muted, rgba(99,102,241,0.12))',
|
||||
color: 'var(--bl-text-primary, inherit)',
|
||||
marginInlineStart: 'auto',
|
||||
borderInlineEnd: '1px solid var(--bl-accent, #6366f1)',
|
||||
}
|
||||
: isAssistant
|
||||
? {
|
||||
background: 'var(--bl-surface-card, #fff)',
|
||||
color: 'var(--bl-text-primary, inherit)',
|
||||
border: '1px solid var(--bl-border, rgba(0,0,0,0.08))',
|
||||
}
|
||||
: isSystem
|
||||
? {
|
||||
background: 'transparent',
|
||||
color: 'var(--bl-text-tertiary, #888)',
|
||||
fontStyle: 'italic',
|
||||
border: '1px dashed var(--bl-border-subtle, rgba(0,0,0,0.06))',
|
||||
}
|
||||
: /* tool */ {
|
||||
background: 'var(--bl-surface-muted, #f6f6f6)',
|
||||
color: 'var(--bl-text-secondary, #555)',
|
||||
fontFamily: 'var(--bl-font-mono, ui-monospace, monospace)',
|
||||
border: '1px solid var(--bl-border-subtle, rgba(0,0,0,0.06))',
|
||||
};
|
||||
|
||||
return (
|
||||
<article
|
||||
data-testid={`bl-message-${message.id}`}
|
||||
data-role={message.role}
|
||||
data-streaming={message.isStreaming ? 'true' : 'false'}
|
||||
className={className}
|
||||
style={{
|
||||
maxWidth: isSystem || isTool ? '100%' : '85%',
|
||||
padding: 'var(--bl-space-3, 12px) var(--bl-space-4, 16px)',
|
||||
borderRadius: 'var(--bl-radius-card, 12px)',
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: 1.5,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
...variantStyle,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
fontSize: '0.75rem',
|
||||
color: 'var(--bl-text-tertiary, #888)',
|
||||
marginBottom: message.content || thinking ? 'var(--bl-space-1, 4px)' : 0,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.04em',
|
||||
}}
|
||||
>
|
||||
<span>{roleLabel ?? capitalize(message.role)}</span>
|
||||
{message.createdAt && (
|
||||
<time dateTime={message.createdAt.toISOString()}>
|
||||
{formatTime(message.createdAt)}
|
||||
</time>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{thinking ? <ThinkingDots /> : <div>{message.content}</div>}
|
||||
|
||||
{actions && (
|
||||
<footer
|
||||
style={{
|
||||
marginTop: 'var(--bl-space-2, 8px)',
|
||||
display: 'flex',
|
||||
gap: 'var(--bl-space-2, 8px)',
|
||||
}}
|
||||
>
|
||||
{actions(message)}
|
||||
</footer>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function formatTime(d: Date): string {
|
||||
// 24-h HH:MM — locale-agnostic so visual-regression snapshots stay stable.
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
return `${h}:${m}`;
|
||||
}
|
||||
|
||||
function ThinkingDots() {
|
||||
// Three blinking dots; sequenced with CSS-only animation-delay so we
|
||||
// don't depend on @bytelyst/motion (which lands in Wave 4).
|
||||
const dot: CSSProperties = {
|
||||
display: 'inline-block',
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: '0 2px',
|
||||
borderRadius: '50%',
|
||||
background: 'currentColor',
|
||||
opacity: 0.4,
|
||||
animation: 'bl-ai-pulse 1.2s infinite ease-in-out',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
aria-label="Assistant is typing"
|
||||
role="status"
|
||||
style={{ display: 'inline-flex', alignItems: 'center', padding: '2px 0' }}
|
||||
>
|
||||
<span style={{ ...dot, animationDelay: '0ms' }} />
|
||||
<span style={{ ...dot, animationDelay: '180ms' }} />
|
||||
<span style={{ ...dot, animationDelay: '360ms' }} />
|
||||
<style>{`@keyframes bl-ai-pulse{0%,80%,100%{opacity:.25;transform:scale(0.85)}40%{opacity:1;transform:scale(1)}}`}</style>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
209
packages/ai-ui/src/PromptComposer.tsx
Normal file
209
packages/ai-ui/src/PromptComposer.tsx
Normal file
@ -0,0 +1,209 @@
|
||||
import {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
type CSSProperties,
|
||||
type FormEvent,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
export interface PromptComposerProps {
|
||||
/** Controlled input value. */
|
||||
value: string;
|
||||
/** Standard onChange — accepts the Vercel-AI-SDK-shaped event. */
|
||||
onChange: (e: { target: { value: string } }) => void;
|
||||
/** Called on Enter (without Shift) or send-button click. */
|
||||
onSubmit: (e?: { preventDefault?: () => void }) => void | Promise<void>;
|
||||
/** Disable input + submit (e.g. while streaming). */
|
||||
disabled?: boolean;
|
||||
/** Placeholder when empty. */
|
||||
placeholder?: string;
|
||||
/** Render a Stop button instead of Send (e.g. during streaming). */
|
||||
showStop?: boolean;
|
||||
/** Called when the Stop button is clicked. */
|
||||
onStop?: () => void;
|
||||
/** Auto-grow up to this many lines before scrolling. Default 8. */
|
||||
maxLines?: number;
|
||||
/** Extra controls rendered to the left of the send button. */
|
||||
leadingActions?: ReactNode;
|
||||
/** Tailwind escape hatch. */
|
||||
className?: string;
|
||||
/** Inline style escape hatch. */
|
||||
style?: CSSProperties;
|
||||
/** ARIA label for the textarea. */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export interface PromptComposerHandle {
|
||||
focus: () => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-line prompt input with submit-on-Enter + Shift-Enter newline,
|
||||
* auto-growing textarea, optional Stop affordance during streaming, and
|
||||
* slots for slash-commands or other leading actions (Wave 2 extension).
|
||||
*
|
||||
* Keyboard:
|
||||
* Enter — submit
|
||||
* Shift+Enter — newline
|
||||
* Cmd/Ctrl+Enter — submit (alternate; useful when textarea grows)
|
||||
* Escape — blur
|
||||
*/
|
||||
export const PromptComposer = forwardRef<PromptComposerHandle, PromptComposerProps>(
|
||||
function PromptComposer(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
disabled = false,
|
||||
placeholder = 'Send a message…',
|
||||
showStop = false,
|
||||
onStop,
|
||||
maxLines = 8,
|
||||
leadingActions,
|
||||
className,
|
||||
style,
|
||||
ariaLabel = 'Message',
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
focus: () => textareaRef.current?.focus(),
|
||||
clear: () => {
|
||||
onChange({ target: { value: '' } });
|
||||
textareaRef.current?.focus();
|
||||
},
|
||||
}),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
const isCmdEnter = (e.metaKey || e.ctrlKey) && e.key === 'Enter';
|
||||
const isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey;
|
||||
if ((isPlainEnter || isCmdEnter) && !disabled && value.trim()) {
|
||||
e.preventDefault();
|
||||
void onSubmit({ preventDefault: () => undefined });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (!disabled && value.trim()) void onSubmit(e);
|
||||
};
|
||||
|
||||
// Compute auto-grow height — line-height (1.5) × maxLines, capped.
|
||||
const maxHeight = `calc(1.5em * ${maxLines} + var(--bl-space-3, 12px) * 2)`;
|
||||
|
||||
return (
|
||||
<form
|
||||
data-testid="bl-prompt-composer"
|
||||
onSubmit={handleFormSubmit}
|
||||
className={className}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
gap: 'var(--bl-space-2, 8px)',
|
||||
padding: 'var(--bl-space-2, 8px)',
|
||||
border: '1px solid var(--bl-border, rgba(0,0,0,0.12))',
|
||||
borderRadius: 'var(--bl-radius-card, 12px)',
|
||||
background: 'var(--bl-surface-card, #fff)',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{leadingActions && (
|
||||
<div
|
||||
data-testid="bl-prompt-leading"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 'var(--bl-space-1, 4px)' }}
|
||||
>
|
||||
{leadingActions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
data-testid="bl-prompt-textarea"
|
||||
aria-label={ariaLabel}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
// Auto-grow: reset, then expand to scrollHeight on each keystroke.
|
||||
onInput={e => {
|
||||
const ta = e.currentTarget;
|
||||
ta.style.height = 'auto';
|
||||
ta.style.height = `${ta.scrollHeight}px`;
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
resize: 'none',
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
background: 'transparent',
|
||||
color: 'var(--bl-text-primary, inherit)',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: '0.95rem',
|
||||
lineHeight: 1.5,
|
||||
padding: 'var(--bl-space-2, 8px)',
|
||||
maxHeight,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
/>
|
||||
|
||||
{showStop ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="bl-prompt-stop"
|
||||
onClick={() => onStop?.()}
|
||||
aria-label="Stop generating"
|
||||
style={sendButtonStyle('var(--bl-danger, #ef4444)')}
|
||||
>
|
||||
◼
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid="bl-prompt-send"
|
||||
disabled={disabled || !value.trim()}
|
||||
aria-label="Send message"
|
||||
style={{
|
||||
...sendButtonStyle('var(--bl-accent, #6366f1)'),
|
||||
opacity: disabled || !value.trim() ? 0.4 : 1,
|
||||
cursor: disabled || !value.trim() ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function sendButtonStyle(bg: string): CSSProperties {
|
||||
return {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 36,
|
||||
height: 36,
|
||||
border: 'none',
|
||||
borderRadius: 'var(--bl-radius-pill, 999px)',
|
||||
background: bg,
|
||||
color: 'var(--bl-accent-foreground, #fff)',
|
||||
fontSize: 16,
|
||||
cursor: 'pointer',
|
||||
transition: 'transform 80ms ease-out, opacity 120ms ease-out',
|
||||
};
|
||||
}
|
||||
379
packages/ai-ui/src/__tests__/ai-ui.test.tsx
Normal file
379
packages/ai-ui/src/__tests__/ai-ui.test.tsx
Normal file
@ -0,0 +1,379 @@
|
||||
import * as React from 'react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { act, cleanup, render, screen, fireEvent, renderHook, waitFor } from '@testing-library/react';
|
||||
|
||||
import { MessageBubble } from '../MessageBubble.js';
|
||||
import { PromptComposer } from '../PromptComposer.js';
|
||||
import { ChatStream } from '../ChatStream.js';
|
||||
import { useChat } from '../useChat.js';
|
||||
import { streamText } from '../useStreamingText.js';
|
||||
import type { Message } from '../types.js';
|
||||
|
||||
// ── Test helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a streaming Response that yields each chunk after a tick, so the
|
||||
* hook's async-iteration loop actually runs through multiple React updates.
|
||||
*/
|
||||
function makeStreamingResponse(chunks: string[], ok = true, status = 200): Response {
|
||||
const encoder = new TextEncoder();
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
for (const chunk of chunks) {
|
||||
await Promise.resolve(); // microtask yield
|
||||
controller.enqueue(encoder.encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
}) as Response & { ok: boolean };
|
||||
}
|
||||
|
||||
// ── MessageBubble ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('MessageBubble', () => {
|
||||
beforeEach(() => cleanup());
|
||||
|
||||
it('renders user message with content', () => {
|
||||
const msg: Message = { id: '1', role: 'user', content: 'Hi there' };
|
||||
render(<MessageBubble message={msg} />);
|
||||
expect(screen.getByTestId('bl-message-1')).toBeDefined();
|
||||
expect(screen.getByText('Hi there')).toBeDefined();
|
||||
});
|
||||
|
||||
it('renders the role label (capitalized) by default', () => {
|
||||
const msg: Message = { id: '1', role: 'assistant', content: 'Hello' };
|
||||
render(<MessageBubble message={msg} />);
|
||||
expect(screen.getByText('Assistant')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows thinking dots while assistant is streaming with empty content', () => {
|
||||
const msg: Message = { id: '1', role: 'assistant', content: '', isStreaming: true };
|
||||
render(<MessageBubble message={msg} />);
|
||||
expect(screen.getByRole('status', { name: /typing/i })).toBeDefined();
|
||||
});
|
||||
|
||||
it('does NOT show thinking dots for streaming user messages', () => {
|
||||
// User messages don't stream — guard against accidental indicator.
|
||||
const msg: Message = { id: '1', role: 'user', content: '', isStreaming: true };
|
||||
render(<MessageBubble message={msg} />);
|
||||
expect(screen.queryByRole('status')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders custom actions slot', () => {
|
||||
const msg: Message = { id: '1', role: 'assistant', content: 'X' };
|
||||
render(
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
actions={m => <button data-testid="act">copy {m.id}</button>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('act').textContent).toBe('copy 1');
|
||||
});
|
||||
|
||||
it('exposes data-role and data-streaming on the article', () => {
|
||||
const msg: Message = { id: '1', role: 'tool', content: '{}', isStreaming: false };
|
||||
render(<MessageBubble message={msg} />);
|
||||
const el = screen.getByTestId('bl-message-1');
|
||||
expect(el.getAttribute('data-role')).toBe('tool');
|
||||
expect(el.getAttribute('data-streaming')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
// ── PromptComposer ───────────────────────────────────────────────────────────
|
||||
|
||||
describe('PromptComposer', () => {
|
||||
beforeEach(() => cleanup());
|
||||
|
||||
it('renders textarea + send button', () => {
|
||||
render(<PromptComposer value="" onChange={() => {}} onSubmit={() => {}} />);
|
||||
expect(screen.getByTestId('bl-prompt-textarea')).toBeDefined();
|
||||
expect(screen.getByTestId('bl-prompt-send')).toBeDefined();
|
||||
});
|
||||
|
||||
it('propagates input into the controlled value via onChange', () => {
|
||||
// Stateful wrapper exercises the actual controlled-input round-trip,
|
||||
// which is more meaningful than asserting on the synthetic event shape
|
||||
// (and avoids a happy-dom + React 19 controlled-input quirk).
|
||||
function Wrapper() {
|
||||
const [v, setV] = React.useState('');
|
||||
return (
|
||||
<>
|
||||
<PromptComposer
|
||||
value={v}
|
||||
onChange={e => setV(e.target.value)}
|
||||
onSubmit={() => {}}
|
||||
/>
|
||||
<output data-testid="echo">{v}</output>
|
||||
</>
|
||||
);
|
||||
}
|
||||
render(<Wrapper />);
|
||||
const ta = screen.getByTestId('bl-prompt-textarea') as HTMLTextAreaElement;
|
||||
fireEvent.input(ta, { target: { value: 'hello' } });
|
||||
expect(screen.getByTestId('echo').textContent).toBe('hello');
|
||||
});
|
||||
|
||||
it('submits on plain Enter when value is non-empty', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} />);
|
||||
fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), { key: 'Enter' });
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does NOT submit on Shift+Enter (newline)', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} />);
|
||||
fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), {
|
||||
key: 'Enter',
|
||||
shiftKey: true,
|
||||
});
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits on Cmd+Enter', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} />);
|
||||
fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), {
|
||||
key: 'Enter',
|
||||
metaKey: true,
|
||||
});
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('does not submit when disabled', () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<PromptComposer value="hi" onChange={() => {}} onSubmit={onSubmit} disabled />,
|
||||
);
|
||||
fireEvent.keyDown(screen.getByTestId('bl-prompt-textarea'), { key: 'Enter' });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows Stop instead of Send when showStop=true and calls onStop', () => {
|
||||
const onStop = vi.fn();
|
||||
render(
|
||||
<PromptComposer
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
onSubmit={() => {}}
|
||||
showStop
|
||||
onStop={onStop}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByTestId('bl-prompt-send')).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('bl-prompt-stop'));
|
||||
expect(onStop).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('blurs on Escape', () => {
|
||||
render(<PromptComposer value="hi" onChange={() => {}} onSubmit={() => {}} />);
|
||||
const ta = screen.getByTestId('bl-prompt-textarea') as HTMLTextAreaElement;
|
||||
ta.focus();
|
||||
expect(document.activeElement).toBe(ta);
|
||||
fireEvent.keyDown(ta, { key: 'Escape' });
|
||||
expect(document.activeElement).not.toBe(ta);
|
||||
});
|
||||
});
|
||||
|
||||
// ── streamText (protocol parsing) ────────────────────────────────────────────
|
||||
|
||||
describe('streamText', () => {
|
||||
it('yields plain text chunks verbatim', async () => {
|
||||
const r = makeStreamingResponse(['Hel', 'lo ', 'world']);
|
||||
const out: string[] = [];
|
||||
for await (const c of streamText(r, 'text')) out.push(c);
|
||||
expect(out.join('')).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('parses SSE data: lines and skips [DONE]', async () => {
|
||||
const r = makeStreamingResponse([
|
||||
'data: "Hello"\n\n',
|
||||
'data: " there"\n\n',
|
||||
'data: [DONE]\n\n',
|
||||
]);
|
||||
const out: string[] = [];
|
||||
for await (const c of streamText(r, 'sse')) out.push(c);
|
||||
expect(out.join('')).toBe('Hello there');
|
||||
});
|
||||
|
||||
it('unwraps JSON SSE objects with content/text/delta fields', async () => {
|
||||
const r = makeStreamingResponse([
|
||||
'data: {"content":"A"}\n\n',
|
||||
'data: {"text":"B"}\n\n',
|
||||
'data: {"delta":"C"}\n\n',
|
||||
]);
|
||||
const out: string[] = [];
|
||||
for await (const c of streamText(r, 'sse')) out.push(c);
|
||||
expect(out.join('')).toBe('ABC');
|
||||
});
|
||||
|
||||
it("parses Vercel 'data' prefix protocol 0: text frames", async () => {
|
||||
const r = makeStreamingResponse(['0:"Hi"\n', '0:" there"\n', '2:[{"x":1}]\n']);
|
||||
const out: string[] = [];
|
||||
for await (const c of streamText(r, 'data')) out.push(c);
|
||||
expect(out.join('')).toBe('Hi there');
|
||||
});
|
||||
|
||||
it('terminates cleanly when the abort signal fires mid-stream', async () => {
|
||||
const ac = new AbortController();
|
||||
const r = makeStreamingResponse(['one ', 'two ', 'three']);
|
||||
const out: string[] = [];
|
||||
let i = 0;
|
||||
for await (const c of streamText(r, 'text', ac.signal)) {
|
||||
out.push(c);
|
||||
if (++i === 1) ac.abort();
|
||||
}
|
||||
expect(out.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── useChat ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('useChat', () => {
|
||||
it('starts with empty messages by default', () => {
|
||||
const { result } = renderHook(() => useChat());
|
||||
expect(result.current.messages).toEqual([]);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.input).toBe('');
|
||||
});
|
||||
|
||||
it('honors initialMessages + initialInput', () => {
|
||||
const init: Message[] = [{ id: 'a', role: 'user', content: 'seed' }];
|
||||
const { result } = renderHook(() =>
|
||||
useChat({ initialMessages: init, initialInput: 'draft' }),
|
||||
);
|
||||
expect(result.current.messages).toEqual(init);
|
||||
expect(result.current.input).toBe('draft');
|
||||
});
|
||||
|
||||
it('handleSubmit POSTs the conversation and streams a reply', async () => {
|
||||
const onFinish = vi.fn();
|
||||
const fetcher = vi.fn(async () =>
|
||||
makeStreamingResponse(['Hello', ' ', 'world']),
|
||||
);
|
||||
const { result } = renderHook(() =>
|
||||
useChat({ fetcher: fetcher as unknown as typeof fetch, onFinish }),
|
||||
);
|
||||
|
||||
act(() => result.current.setInput('Hi'));
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
const args = (fetcher.mock.calls[0] as unknown) as [string, RequestInit];
|
||||
expect(args[0]).toBe('/api/chat');
|
||||
expect(args[1].method).toBe('POST');
|
||||
|
||||
expect(result.current.messages.length).toBe(2);
|
||||
expect(result.current.messages[0]).toMatchObject({ role: 'user', content: 'Hi' });
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: 'Hello world',
|
||||
isStreaming: false,
|
||||
});
|
||||
expect(onFinish).toHaveBeenCalledOnce();
|
||||
expect(result.current.input).toBe('');
|
||||
});
|
||||
|
||||
it('surfaces transport errors via the error field', async () => {
|
||||
const fetcher = vi.fn(async () => new Response('boom', { status: 500 }));
|
||||
const onError = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useChat({ fetcher: fetcher as unknown as typeof fetch, onError }),
|
||||
);
|
||||
|
||||
act(() => result.current.setInput('die'));
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
|
||||
expect(result.current.error?.message).toMatch(/500/);
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('does not submit empty input', async () => {
|
||||
const fetcher = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useChat({ fetcher: fetcher as unknown as typeof fetch }),
|
||||
);
|
||||
await act(async () => {
|
||||
await result.current.handleSubmit();
|
||||
});
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('stop() aborts the in-flight request', async () => {
|
||||
let sawAbort = false;
|
||||
const fetcher = vi.fn(async (_url: RequestInfo, init?: RequestInit) => {
|
||||
init?.signal?.addEventListener('abort', () => {
|
||||
sawAbort = true;
|
||||
});
|
||||
// Keep the stream open indefinitely until aborted.
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('partial'));
|
||||
// Never closes.
|
||||
},
|
||||
});
|
||||
return new Response(body, { status: 200 });
|
||||
});
|
||||
const { result } = renderHook(() =>
|
||||
useChat({ fetcher: fetcher as unknown as typeof fetch }),
|
||||
);
|
||||
|
||||
act(() => result.current.setInput('long'));
|
||||
void result.current.handleSubmit();
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(true));
|
||||
act(() => result.current.stop());
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
expect(sawAbort).toBe(true);
|
||||
// Trailing assistant bubble should have isStreaming cleared.
|
||||
const trailing = result.current.messages.at(-1);
|
||||
expect(trailing?.isStreaming).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── ChatStream (integration) ─────────────────────────────────────────────────
|
||||
|
||||
describe('ChatStream', () => {
|
||||
beforeEach(() => cleanup());
|
||||
|
||||
it('renders empty state when there are no messages', () => {
|
||||
const fetcher = vi.fn();
|
||||
render(
|
||||
<ChatStream
|
||||
fetcher={fetcher as unknown as typeof fetch}
|
||||
emptyState={<div data-testid="empty">Say hi 👋</div>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('empty')).toBeDefined();
|
||||
});
|
||||
|
||||
it('end-to-end: type → submit → see user + assistant bubble', async () => {
|
||||
const fetcher = vi.fn(async () =>
|
||||
makeStreamingResponse(['Hi', ' there!']),
|
||||
);
|
||||
render(
|
||||
<ChatStream fetcher={fetcher as unknown as typeof fetch} />,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('bl-prompt-textarea'), {
|
||||
target: { value: 'hello' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('bl-prompt-send'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('hello')).toBeDefined();
|
||||
expect(screen.getByText('Hi there!')).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
36
packages/ai-ui/src/index.ts
Normal file
36
packages/ai-ui/src/index.ts
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @bytelyst/ai-ui — AI-native UI primitives.
|
||||
*
|
||||
* MVP exports (Wave 2 v0.1.0):
|
||||
* - <ChatStream> — composed chat surface (the 80% path)
|
||||
* - <MessageBubble> — single message atom
|
||||
* - <PromptComposer> — multi-line input with submit-on-Enter
|
||||
* - useChat() — Vercel AI SDK-shaped hook
|
||||
* - streamText() — low-level SSE/text/data stream consumer
|
||||
*
|
||||
* Coming in 0.2.x (per ROADMAP §Wave 2):
|
||||
* - <ToolCallCard>, <CitationChip>, <AgentTimeline>
|
||||
* - <ModelPicker>, <ToolPalette> (MCP discovery)
|
||||
* - useToolCalls(), usePromptHistory(), useTokenCount()
|
||||
*/
|
||||
|
||||
export { ChatStream } from './ChatStream.js';
|
||||
export type { ChatStreamProps } from './ChatStream.js';
|
||||
|
||||
export { MessageBubble } from './MessageBubble.js';
|
||||
export type { MessageBubbleProps } from './MessageBubble.js';
|
||||
|
||||
export { PromptComposer } from './PromptComposer.js';
|
||||
export type { PromptComposerHandle, PromptComposerProps } from './PromptComposer.js';
|
||||
|
||||
export { useChat } from './useChat.js';
|
||||
export { streamText } from './useStreamingText.js';
|
||||
|
||||
export type {
|
||||
ChatTransportOptions,
|
||||
Message,
|
||||
MessageRole,
|
||||
StreamProtocol,
|
||||
UseChatHelpers,
|
||||
UseChatOptions,
|
||||
} from './types.js';
|
||||
88
packages/ai-ui/src/types.ts
Normal file
88
packages/ai-ui/src/types.ts
Normal file
@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Shared types for @bytelyst/ai-ui.
|
||||
*
|
||||
* Message shape is the **subset of the Vercel AI SDK `Message` type** that
|
||||
* we commit to as stable. Per `docs/ROADMAP_2026_DECISIONS.md` §10 we adopt
|
||||
* the AI SDK hook shape but abstract the transport — additional fields
|
||||
* Vercel emits (e.g. `toolInvocations`, `experimental_attachments`) may be
|
||||
* exposed in later minor versions when the corresponding components
|
||||
* (`<ToolCallCard>`, `<AttachmentPreview>`) land.
|
||||
*/
|
||||
|
||||
export type MessageRole = 'system' | 'user' | 'assistant' | 'tool';
|
||||
|
||||
export interface Message {
|
||||
/** Stable id — used as React key and for deduplication. */
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
/** Plain text content. Markdown rendering is opt-in via component props. */
|
||||
content: string;
|
||||
/** Wall-clock time the message was authored on the client. */
|
||||
createdAt?: Date;
|
||||
/** When true, indicates content is still streaming in (assistant only). */
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire protocol for streamed responses. The default `'text'` protocol
|
||||
* expects a plain `text/plain` body with the assistant message streamed
|
||||
* token-by-token (or chunk-by-chunk). Other protocols are reserved.
|
||||
*/
|
||||
export type StreamProtocol = 'text' | 'sse' | 'data';
|
||||
|
||||
export interface ChatTransportOptions {
|
||||
/** Endpoint that accepts POST { messages } and returns a stream. */
|
||||
endpoint?: string;
|
||||
/**
|
||||
* Wire format. Defaults to `'text'` which works with any backend
|
||||
* returning a streaming text response — including LysnrAI, JarvisJr,
|
||||
* or any FastAPI/Express endpoint that pipes an LLM stream.
|
||||
*/
|
||||
streamProtocol?: StreamProtocol;
|
||||
/**
|
||||
* Optional fetch override. Useful for injecting auth headers, mock
|
||||
* transports under test (MSW), or proxying via a service-worker.
|
||||
* Defaults to the global `fetch`.
|
||||
*/
|
||||
fetcher?: typeof fetch;
|
||||
/** Request headers merged on every send. */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface UseChatOptions extends ChatTransportOptions {
|
||||
/** Seed conversation. Re-evaluated only on mount. */
|
||||
initialMessages?: Message[];
|
||||
/** Seed the input text. */
|
||||
initialInput?: string;
|
||||
/** Called whenever a new assistant message completes (not on each token). */
|
||||
onFinish?: (message: Message) => void;
|
||||
/** Called on transport-level error. */
|
||||
onError?: (error: Error) => void;
|
||||
/**
|
||||
* Optional id generator. Defaults to a monotonic counter so two
|
||||
* concurrent hooks in the same React tree never collide. Provide one
|
||||
* if you need cryptographic ids (e.g. for persistence by id).
|
||||
*/
|
||||
generateId?: () => string;
|
||||
}
|
||||
|
||||
export interface UseChatHelpers {
|
||||
messages: Message[];
|
||||
input: string;
|
||||
setInput: (value: string) => void;
|
||||
handleInputChange: (e: { target: { value: string } }) => void;
|
||||
/** Submit current `input` as a user message and start streaming a reply. */
|
||||
handleSubmit: (e?: { preventDefault?: () => void }) => Promise<void>;
|
||||
/** Programmatic equivalent of `handleSubmit`. */
|
||||
append: (message: Omit<Message, 'id' | 'createdAt'> & Partial<Pick<Message, 'id'>>) => Promise<void>;
|
||||
/** True while a request is in flight (from POST start to stream end). */
|
||||
isLoading: boolean;
|
||||
/** Latest transport-level error, if any. Cleared on next send. */
|
||||
error: Error | undefined;
|
||||
/** Abort the in-flight request (if any) and keep partial assistant text. */
|
||||
stop: () => void;
|
||||
/** Re-run the last user turn (drops the last assistant message first). */
|
||||
reload: () => Promise<void>;
|
||||
/** Replace the entire message list. */
|
||||
setMessages: (messages: Message[]) => void;
|
||||
}
|
||||
224
packages/ai-ui/src/useChat.ts
Normal file
224
packages/ai-ui/src/useChat.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { Message, UseChatHelpers, UseChatOptions } from './types.js';
|
||||
import { streamText } from './useStreamingText.js';
|
||||
|
||||
let __id = 0;
|
||||
const defaultGenerateId = (): string => `m-${Date.now().toString(36)}-${++__id}`;
|
||||
|
||||
/**
|
||||
* `useChat` — primary hook for chat UIs.
|
||||
*
|
||||
* Adopts the Vercel AI SDK `useChat` return shape (per
|
||||
* `learning_ai_common_plat/docs/ROADMAP_2026_DECISIONS.md` §10) so any
|
||||
* React engineer familiar with the AI SDK can drop straight in. The
|
||||
* transport is **pluggable** — products supply `endpoint`,
|
||||
* `streamProtocol`, `fetcher`, or `headers` to talk to OpenAI directly,
|
||||
* Anthropic, an LysnrAI service, or any backend that streams text.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { messages, input, handleInputChange, handleSubmit, isLoading } =
|
||||
* useChat({ endpoint: '/api/chat' });
|
||||
*
|
||||
* return (
|
||||
* <>
|
||||
* {messages.map(m => <MessageBubble key={m.id} message={m} />)}
|
||||
* <PromptComposer value={input} onChange={handleInputChange}
|
||||
* onSubmit={handleSubmit} disabled={isLoading} />
|
||||
* </>
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export function useChat(options: UseChatOptions = {}): UseChatHelpers {
|
||||
const {
|
||||
initialMessages = [],
|
||||
initialInput = '',
|
||||
endpoint = '/api/chat',
|
||||
streamProtocol = 'text',
|
||||
fetcher,
|
||||
headers,
|
||||
onFinish,
|
||||
onError,
|
||||
generateId = defaultGenerateId,
|
||||
} = options;
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>(initialMessages);
|
||||
const [input, setInput] = useState(initialInput);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | undefined>(undefined);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: { target: { value: string } }) => setInput(e.target.value),
|
||||
[],
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
// Drop the trailing isStreaming flag so the bubble settles into
|
||||
// its final state even though we stopped mid-stream.
|
||||
setMessages(prev =>
|
||||
prev.map(m => (m.isStreaming ? { ...m, isStreaming: false } : m)),
|
||||
);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Core send loop — appends `userMessage`, POSTs the conversation,
|
||||
* streams the response into a new assistant message, and updates
|
||||
* loading/error state along the way.
|
||||
*/
|
||||
const sendMessages = useCallback(
|
||||
async (next: Message[]) => {
|
||||
// Cancel any in-flight request before starting a new one.
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const assistantId = generateId();
|
||||
setMessages([
|
||||
...next,
|
||||
{
|
||||
id: assistantId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: new Date(),
|
||||
isStreaming: true,
|
||||
},
|
||||
]);
|
||||
setIsLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const doFetch = fetcher ?? globalThis.fetch.bind(globalThis);
|
||||
let finalContent = '';
|
||||
|
||||
try {
|
||||
const response = await doFetch(endpoint, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ messages: next }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Chat endpoint returned ${response.status}`);
|
||||
}
|
||||
|
||||
for await (const delta of streamText(
|
||||
response,
|
||||
streamProtocol,
|
||||
controller.signal,
|
||||
)) {
|
||||
finalContent += delta;
|
||||
// Functional update so concurrent setMessages calls don't lose work.
|
||||
setMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === assistantId ? { ...m, content: m.content + delta } : m,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Settle isStreaming flag.
|
||||
setMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === assistantId ? { ...m, isStreaming: false } : m,
|
||||
),
|
||||
);
|
||||
|
||||
if (onFinish && !controller.signal.aborted) {
|
||||
onFinish({
|
||||
id: assistantId,
|
||||
role: 'assistant',
|
||||
content: finalContent,
|
||||
isStreaming: false,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if ((err as Error).name === 'AbortError') {
|
||||
// User-initiated stop — already handled in `stop()`.
|
||||
return;
|
||||
}
|
||||
const e = err instanceof Error ? err : new Error(String(err));
|
||||
setError(e);
|
||||
// Mark the partial assistant message as no longer streaming so
|
||||
// the bubble's "thinking" indicator clears.
|
||||
setMessages(prev =>
|
||||
prev.map(m =>
|
||||
m.id === assistantId ? { ...m, isStreaming: false } : m,
|
||||
),
|
||||
);
|
||||
onError?.(e);
|
||||
} finally {
|
||||
if (abortRef.current === controller) abortRef.current = null;
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[endpoint, fetcher, generateId, headers, onError, onFinish, streamProtocol],
|
||||
);
|
||||
|
||||
const append = useCallback<UseChatHelpers['append']>(
|
||||
async incoming => {
|
||||
const message: Message = {
|
||||
id: incoming.id ?? generateId(),
|
||||
role: incoming.role,
|
||||
content: incoming.content,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
const next = [...messages, message];
|
||||
setMessages(next);
|
||||
await sendMessages(next);
|
||||
},
|
||||
[generateId, messages, sendMessages],
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback<UseChatHelpers['handleSubmit']>(
|
||||
async e => {
|
||||
e?.preventDefault?.();
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed || isLoading) return;
|
||||
setInput('');
|
||||
await append({ role: 'user', content: trimmed });
|
||||
},
|
||||
[append, input, isLoading],
|
||||
);
|
||||
|
||||
const reload = useCallback<UseChatHelpers['reload']>(async () => {
|
||||
// Find the last user message; drop everything after it.
|
||||
const lastUserIndex = [...messages]
|
||||
.reverse()
|
||||
.findIndex(m => m.role === 'user');
|
||||
if (lastUserIndex === -1) return;
|
||||
const cutoff = messages.length - lastUserIndex; // exclusive
|
||||
const truncated = messages.slice(0, cutoff);
|
||||
setMessages(truncated);
|
||||
await sendMessages(truncated);
|
||||
}, [messages, sendMessages]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
messages,
|
||||
input,
|
||||
setInput,
|
||||
handleInputChange,
|
||||
handleSubmit,
|
||||
append,
|
||||
isLoading,
|
||||
error,
|
||||
stop,
|
||||
reload,
|
||||
setMessages,
|
||||
}),
|
||||
[
|
||||
messages,
|
||||
input,
|
||||
handleInputChange,
|
||||
handleSubmit,
|
||||
append,
|
||||
isLoading,
|
||||
error,
|
||||
stop,
|
||||
reload,
|
||||
],
|
||||
);
|
||||
}
|
||||
130
packages/ai-ui/src/useStreamingText.ts
Normal file
130
packages/ai-ui/src/useStreamingText.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import type { StreamProtocol } from './types.js';
|
||||
|
||||
/**
|
||||
* Consume a streaming HTTP response body and yield text deltas.
|
||||
*
|
||||
* - `'text'` — body is plain text; each chunk yielded as-is.
|
||||
* - `'sse'` — body is Server-Sent Events; we parse `data: ...` lines.
|
||||
* `[DONE]` and empty data frames are skipped. JSON frames
|
||||
* that include a `content` or `text` field are unwrapped.
|
||||
* - `'data'` — Vercel AI SDK "data stream" prefix protocol. Lines look
|
||||
* like `0:"hello"`, `2:[{...}]`, etc. We extract the `0:`
|
||||
* (text) frames and ignore the rest until the matching
|
||||
* components ship.
|
||||
*
|
||||
* The generator terminates when the body ends OR when the `AbortSignal`
|
||||
* fires — callers can early-exit cleanly.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const ac = new AbortController();
|
||||
* for await (const chunk of streamText(response, 'text', ac.signal)) {
|
||||
* setDraft(prev => prev + chunk);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export async function* streamText(
|
||||
response: Response,
|
||||
protocol: StreamProtocol = 'text',
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<string, void, unknown> {
|
||||
if (!response.body) return;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) return;
|
||||
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
// Flush any tail bytes through the decoder.
|
||||
const tail = decoder.decode();
|
||||
if (tail) yield* parseChunk(tail, protocol, buffer)[0];
|
||||
return;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const [chunks, remaining] = parseChunk('', protocol, buffer);
|
||||
buffer = remaining;
|
||||
for (const chunk of chunks) {
|
||||
if (signal?.aborted) return;
|
||||
yield chunk;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Best-effort cancellation — ignore if the stream is already closed.
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse buffered text into emit-ready chunks per protocol.
|
||||
*
|
||||
* Returns `[chunksToEmit, remainingBuffer]`. The `remainingBuffer`
|
||||
* accumulates partial frames (e.g. an SSE `data:` line without its
|
||||
* terminating `\n\n`) until the next read fills them in.
|
||||
*/
|
||||
function parseChunk(
|
||||
flush: string,
|
||||
protocol: StreamProtocol,
|
||||
buffer: string,
|
||||
): [string[], string] {
|
||||
const combined = buffer + flush;
|
||||
|
||||
if (protocol === 'text') {
|
||||
// The caller-supplied buffer concept doesn't apply — emit everything.
|
||||
return combined ? [[combined], ''] : [[], ''];
|
||||
}
|
||||
|
||||
if (protocol === 'sse') {
|
||||
const out: string[] = [];
|
||||
// SSE frames are separated by a blank line (\n\n).
|
||||
const parts = combined.split('\n\n');
|
||||
const remaining = parts.pop() ?? '';
|
||||
for (const frame of parts) {
|
||||
for (const line of frame.split('\n')) {
|
||||
if (!line.startsWith('data:')) continue;
|
||||
const raw = line.slice(5).trimStart();
|
||||
if (raw === '[DONE]' || raw === '') continue;
|
||||
// Best-effort JSON unwrap — falls back to the raw string.
|
||||
try {
|
||||
const obj = JSON.parse(raw);
|
||||
if (typeof obj === 'string') out.push(obj);
|
||||
else if (typeof obj?.content === 'string') out.push(obj.content);
|
||||
else if (typeof obj?.text === 'string') out.push(obj.text);
|
||||
else if (typeof obj?.delta === 'string') out.push(obj.delta);
|
||||
} catch {
|
||||
out.push(raw);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [out, remaining];
|
||||
}
|
||||
|
||||
// protocol === 'data' — Vercel AI SDK data-stream prefix protocol.
|
||||
const out: string[] = [];
|
||||
const lines = combined.split('\n');
|
||||
const remaining = lines.pop() ?? '';
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
const colon = line.indexOf(':');
|
||||
if (colon < 1) continue;
|
||||
const prefix = line.slice(0, colon);
|
||||
if (prefix !== '0') continue; // Only text frames for the MVP.
|
||||
const payload = line.slice(colon + 1);
|
||||
try {
|
||||
const value = JSON.parse(payload);
|
||||
if (typeof value === 'string') out.push(value);
|
||||
} catch {
|
||||
/* ignore malformed frame */
|
||||
}
|
||||
}
|
||||
return [out, remaining];
|
||||
}
|
||||
11
packages/ai-ui/tsconfig.json
Normal file
11
packages/ai-ui/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
8
packages/ai-ui/vitest.config.ts
Normal file
8
packages/ai-ui/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'happy-dom',
|
||||
pool: 'forks',
|
||||
},
|
||||
});
|
||||
11771
pnpm-lock.yaml
generated
11771
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user