Skip to content
Merged
8 changes: 7 additions & 1 deletion examples/ai-chatbot/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# AI chatbot example

A Next.js chatbot built with `stream-chat-react` and its `stream-chat-react/ai-components` entry point
(streaming messages, AI state indicator, AI message composer). Styles come from
(streaming messages, AI state indicator, AI message composer, voice agent visualizations). Styles come from
`stream-chat-react/css/ai-components.css`.

## Getting started
Expand All @@ -27,4 +27,10 @@ The hosted demo server only answers for channels of Stream's demo app. With your
`STREAM_API_SECRET` the AI agent never joins your channels, so run your own AI agent server (it must
expose `POST /start-ai-agent` and `POST /summarize`, as called from `src/components/api.ts`) and point `NEXT_PUBLIC_AI_SERVER_URL` at it.

## Voice mode

The waveform button in the composer opens voice mode: a hands-free conversation in which the AI agent is shown as a `VoiceGlow` and your microphone as a `VoiceWave`. It listens to you, sends what you said, and reads the answer aloud before listening again.

It needs a browser with the Web Speech API's `SpeechRecognition` (Chrome, Edge or Safari; the button is hidden elsewhere) and access to the microphone. Reading answers aloud uses `speechSynthesis`; where it is missing, the answer is only shown in the chat.

See [implementation.md](./implementation.md) for implementation notes.
17 changes: 17 additions & 0 deletions examples/ai-chatbot/implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,23 @@ examples/ai-chatbot/
- Posts to `/summarize` endpoint with OpenAI platform
- Returns summary string from API response

### 18. **useSendAIMessage.ts**

- The send path shared by `MessageInputBar` and voice mode: composes the message (with attachments), watches the channel if needed, starts the AI agent when it is not watching yet, sends, records the message against the rate limit and summarizes a new conversation
- Also holds `availableModels`, `defaultPlatformModel` and `isWatchedByAI`

### 19. **VoiceMode**

A hands-free conversation with the agent, built on `VoiceGlow`, `VoiceWave`, `useAudioLevel` and `useSpeechToText` from `stream-chat-react/ai-components`.

- `VoiceModeButton` sits in the composer next to the dictation button and appears only where the browser has `SpeechRecognition` (Chrome, Edge, Safari)
- `VoiceModeProvider` (in `ChatContainer`) holds whether voice mode is open; `VoiceMode` is mounted inside `Channel` only while open, so the microphone is released when it closes
- The overlay shows a large `VoiceGlow` for the agent and a `VoiceWave` for the user's microphone, fed by `useAudioLevel` on the `getUserMedia` stream
- `useVoiceConversation` runs the turn loop: listen (dictation ends on silence), send through `useSendAIMessage`, wait for the agent's `ai_indicator` events, read the finished answer aloud with `speechSynthesis` (markdown stripped, code skipped), then listen again
- Voice agent state: `connecting` while the microphone is requested or the agent is being started, `listening` during the user's turn, `thinking` while the agent works, `speaking` while the answer is read aloud, `idle` when paused or stopped
- Pause/resume and end buttons; Escape closes it; the rate limit and a denied microphone stop it with a message
- `--str-chat__ai-voice-backdrop` is set to the demo background in `index.scss`, so the visualizations pick their light or dark look from the demo theme rather than the SDK theme

---

## Next.js Architecture
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { MessageBubble } from '../MessageBubble';
import { MessageInputBar } from '../MessageInputBar';
import { AIStateIndicator } from '../AIStateIndicator';
import { TopNavBar } from '../TopNavBar';
import { VoiceMode, VoiceModeProvider } from '../VoiceMode';
import './ChatContainer.scss';

interface ChatContainerProps {
Expand Down Expand Up @@ -50,14 +51,17 @@ export const ChatContainer = ({ onToggleSidebar }: ChatContainerProps) => {
MessageComposerUI: MessageInputBar,
}}
>
<Channel initializeOnMount={false}>
<TopNavBar onToggleSidebar={onToggleSidebar} />
<Window>
<MessageList />
<AIStateIndicator />
<MessageComposer focus />
</Window>
</Channel>
<VoiceModeProvider>
<Channel initializeOnMount={false}>
<TopNavBar onToggleSidebar={onToggleSidebar} />
<Window>
<MessageList />
<AIStateIndicator />
<MessageComposer focus />
</Window>
<VoiceMode />
</Channel>
</VoiceModeProvider>
</WithComponents>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,26 @@
import { AIMessageComposer } from 'stream-chat-react/ai-components';
import { useEffect, useState } from 'react';
import {
type Channel,
isImageFile,
type LocalUploadAttachment,
type UploadRequestFn,
} from 'stream-chat';
import {
useAttachmentsForPreview,
useChannelActionContext,
useChannelStateContext,
useChatContext,
useMessageComposerController,
} from 'stream-chat-react';
import { startAiAgent, summarizeConversation } from '@/components/api';
import {
checkRateLimit,
formatTimeRemaining,
recordMessage,
} from '@/components/rateLimitUtils';
import { checkRateLimit, formatTimeRemaining } from '@/components/rateLimitUtils';
import { availableModels, useSendAIMessage } from '@/components/useSendAIMessage';
import { VoiceModeButton } from '@/components/VoiceMode';
import './MessageInputBar.scss';

const isWatchedByAI = (channel: Channel) =>
Object.keys(channel.state.watchers).some((watcher) => watcher.startsWith('ai-bot'));

const availableModels = [
{ platform: 'openai', value: 'gpt-5.4-mini', label: 'GPT-5.4 mini' },
{ platform: 'openai', value: 'gpt-5.4-nano', label: 'GPT-5.4 nano' },
{
platform: 'gemini',
value: 'gemini-flash-latest',
label: 'Gemini Flash (latest)',
},
] as const;

export const MessageInputBar = () => {
const { client } = useChatContext();
const { updateMessage, sendMessage } = useChannelActionContext();
const { channel } = useChannelStateContext();
const composer = useMessageComposerController();
const sendAIMessage = useSendAIMessage();

const { attachments } = useAttachmentsForPreview();
const [selectedPlatformModel, setSelectedPlatformModel] = useState<string>();
Expand Down Expand Up @@ -124,46 +106,14 @@ export const MessageInputBar = () => {
const platformModel = formData.get('platform-model');
setSelectedPlatformModel(platformModel as string);

composer.textComposer.setText(message as string);

const composedData = await composer.compose();

if (!composedData) return;

target.reset();
composer.clear();

updateMessage(composedData?.localMessage);

if (!channel.initialized) {
await channel.watch();
}

const [platform, model] = (platformModel as string).split('|');

if (!isWatchedByAI(channel)) {
await startAiAgent(channel, model, platform);
}

await sendMessage(composedData);

// Record message after successful send
recordMessage(channel.id!);
const sent = await sendAIMessage({
onComposed: () => target.reset(),
platformModel: platformModel as string,
text: message as string,
});

// Update rate limit state
const newState = checkRateLimit(channel.id!);
setRateLimitState(newState);

if (typeof channel.data?.summary !== 'string' || !channel.data.summary.length) {
const summary = await summarizeConversation(message as string).catch(() => {
console.warn('Failed to summarize conversation');
return null;
});

if (typeof summary === 'string' && summary.length > 0) {
await channel.update({ summary });
}
}
if (sent) setRateLimitState(checkRateLimit(channel.id!));
}}
>
<AIMessageComposer.AttachmentPreview>
Expand Down Expand Up @@ -200,6 +150,7 @@ export const MessageInputBar = () => {
<div style={{ display: 'flex', gap: '.25rem', alignItems: 'center' }}>
<AIMessageComposer.FileInput name='attachments' />
<AIMessageComposer.SpeechToTextButton />
<VoiceModeButton />
<AIMessageComposer.ModelSelect
name='platform-model'
value={selectedPlatformModel}
Expand Down
116 changes: 116 additions & 0 deletions examples/ai-chatbot/src/components/VoiceMode/VoiceMode.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
.ai-demo-voice-mode {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 2rem 1.5rem;
background-color: var(--ai-demo-bg-primary);
color: var(--ai-demo-text-primary);

&__stage {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
width: 100%;
max-width: 640px;
min-height: 0;
}

&__glow {
width: min(28rem, 80vw, 55vh);
height: min(28rem, 80vw, 55vh);
}

&__status {
margin: 0;
font-size: 1rem;
font-weight: 600;
}

&__caption {
margin: 0;
min-height: 3em;
max-height: 6em;
overflow: hidden;
color: var(--ai-demo-text-secondary);
font-size: 0.9375rem;
line-height: 1.5;
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}

// The wave is about 84% as long as its box's shorter side, so its box is square. The empty
// space above and below the line overlaps its neighbours, which the wave lets clicks through.
&__wave {
--ai-demo-voice-wave-size: min(18rem, 100%, 32vh);
flex-shrink: 0;
width: var(--ai-demo-voice-wave-size);
height: var(--ai-demo-voice-wave-size);
margin-block: calc(var(--ai-demo-voice-wave-size) * -0.3);
}

&__controls {
display: flex;
gap: 1rem;
}

&__button {
display: flex;
align-items: center;
justify-content: center;
width: 3.5rem;
height: 3.5rem;
border: none;
border-radius: 9999px;
background-color: var(--ai-demo-bg-tertiary);
color: var(--ai-demo-text-primary);
cursor: pointer;
transition: background-color 0.2s ease;

&:hover:not(:disabled) {
background-color: var(--ai-demo-border);
}

&:focus-visible {
outline: 2px solid var(--ai-demo-accent);
outline-offset: 2px;
}

&:disabled {
cursor: not-allowed;
opacity: 0.5;
}

&[aria-pressed='true'] {
background-color: var(--ai-demo-text-primary);
color: var(--ai-demo-bg-primary);
}

&--end {
background-color: #e5484d;
color: #ffffff;

&:hover:not(:disabled) {
background-color: #c93c40;
}
}

.material-symbols-rounded {
font-size: 1.75rem;
}
}
}

.ai-demo-voice-mode-button .material-symbols-rounded {
font-size: 1.5rem;
line-height: 1;
}
Loading
Loading