Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion ai-docs/i18n-v15-key-map.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$comment": "Migration table: natural-language translation key (v14) -> namespaced key (v15). Hand-reviewed; nothing regenerates it. Integrators who passed translationsForLanguage/registerTranslation dictionaries keyed on the old strings should use this to rename their keys. See ai-docs/i18n-v15-migration.md.",
"count": 621,
"count": 627,
"keys": {
"aria/Active": {
"key": "a11y.accessibleLabel.active.ariaLabel",
Expand Down Expand Up @@ -186,6 +186,30 @@
"key": "aiComponents.stateIndicator.workingMyMagic.text",
"prose": true
},
"Connecting": {
"key": "aiComponents.voiceVisualization.connecting.text",
"prose": true
},
"Listening": {
"key": "aiComponents.voiceVisualization.listening.text",
"prose": true
},
"Ready": {
"key": "aiComponents.voiceVisualization.ready.text",
"prose": true
},
"Speaking": {
"key": "aiComponents.voiceVisualization.speaking.text",
"prose": true
},
"Thinking": {
"key": "aiComponents.voiceVisualization.thinking.text",
"prose": true
},
"aria/Voice agent: {{ state }}": {
"key": "aiComponents.voiceVisualization.voiceAgent.ariaLabel",
"prose": true
},
"Generating...": {
"key": "aiState.indicator.generating.label",
"prose": true
Expand Down
2 changes: 1 addition & 1 deletion ai-docs/i18n-v15-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ imported and copied into `Streami18n` at construction, so they shipped even if y

## Renaming your keys

Every old key maps to exactly one new key. The full table (621 rows, including the 18 `aiComponents.*` keys of `stream-chat-react/ai-components`) is
Every old key maps to exactly one new key. The full table (627 rows, including the 24 `aiComponents.*` keys of `stream-chat-react/ai-components`) is
[`i18n-v15-key-map.json`](https://github.com/GetStream/stream-chat-react/blob/master/ai-docs/i18n-v15-key-map.json):

```json
Expand Down
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 Down Expand Up @@ -33,4 +33,10 @@ expose `POST /start-ai-agent` and `POST /summarize`, as called from `src/compone
- **One copy of `@stream-io/state-store`.** The example keeps `installConfig.hoistingLimits: workspaces`, so the app installs its own `stream-chat` and `@stream-io/state-store` while the linked `stream-chat-react` workspace resolves the copies at the repository root. `stream-chat`, `@stream-io/i18n` and `stream-chat-react` hand each other store instances, so exactly one copy of `@stream-io/state-store` must be loaded. The app dedupes `stream-chat` and `@stream-io/state-store` through the Turbopack `resolveAlias` in `next.config.ts` plus the matching `paths` in `tsconfig.json`.
- **Sidebar pagination.** The sidebar uses infinite scroll: v15's `ChannelList` has no load-more button.

## 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 @@ -342,6 +342,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: creates a new conversation with `channel.ensureWatched()`, starts the AI agent when it is not watching yet, sends through `composer.send()` (with attachments), records the message against the rate limit and summarizes a new conversation in the background (the summary never delays or fails the send)
- 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` with a `MessageComposer` of its own (so a spoken turn leaves the typed draft and its attachments alone), 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, a failed send and a denied microphone stop it with a message; closing it refreshes the composer's rate-limit banner
- `--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 @@ -44,14 +45,17 @@ export const ChatContainer = ({ onToggleSidebar }: ChatContainerProps) => {
MessageComposerUI: MessageInputBar,
}}
>
{channel && (
<Channel channel={channel}>
<TopNavBar onToggleSidebar={onToggleSidebar} />
<MessageList />
<AIStateIndicator />
<MessageComposer focus />
</Channel>
)}
<VoiceModeProvider>
{channel && (
<Channel channel={channel}>
<TopNavBar onToggleSidebar={onToggleSidebar} />
<MessageList />
<AIStateIndicator />
<MessageComposer focus />
<VoiceMode />
</Channel>
)}
</VoiceModeProvider>
</WithComponents>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import { AIMessageComposer } from 'stream-chat-react/ai-components';
import { useEffect, useState } from 'react';
import {
type Channel,
isImageFile,
type LocalUploadAttachment,
type UploadRequestFn,
Expand All @@ -14,31 +13,17 @@ import {
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 { useVoiceMode, 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 channel = useChannel();
const composer = useMessageComposerController();
const sendAIMessage = useSendAIMessage();
const { isOpen: isVoiceModeOpen } = useVoiceMode();

const { attachments } = useAttachmentsForPreview();
const [selectedPlatformModel, setSelectedPlatformModel] = useState<string>();
Expand All @@ -52,7 +37,8 @@ export const MessageInputBar = () => {
remainingMessages: 10,
});

// Check rate limit when channel changes or on mount
// Check rate limit on mount, when the channel changes and when voice mode opens or closes
// (voice mode records its sends too)
useEffect(() => {
if (!channel?.id) return;

Expand All @@ -65,7 +51,7 @@ export const MessageInputBar = () => {

const interval = setInterval(updateRateLimit, 60000);
return () => clearInterval(interval);
}, [channel?.id]);
}, [channel?.id, isVoiceModeOpen]);

useEffect(() => {
if (!composer) return;
Expand Down Expand Up @@ -127,48 +113,14 @@ export const MessageInputBar = () => {
const platformModel = formData.get('platform-model');
setSelectedPlatformModel(platformModel as string);

composer.textComposer.setText(message as string);

// A new conversation exists only locally until now; `Channel` no longer creates it.
// Both setup steps run before `send()`, so a failure here leaves the draft untouched.
if (!channel.initialized) {
await channel.ensureWatched();
}

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

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

// `send()` composes the message, shows it optimistically, releases the composer and
// reports a failed send itself (the message stays in the list marked `failed`).
const result = await composer.send();

// `nothing-to-send` keeps the composer's contents (e.g. an upload is still running).
if (result === 'nothing-to-send') return;

target.reset();

if (result !== 'sent') return;

// 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 (!channel.data?.custom?.summary) {
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({ data: { custom: { summary } } });
}
}
if (sent) setRateLimitState(checkRateLimit(channel.id!));
}}
>
<AIMessageComposer.AttachmentPreview>
Expand Down Expand Up @@ -205,6 +157,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