Skip to content

Commit eb4b51e

Browse files
feat(react): minimal tutorial sample application
1 parent 1b99ade commit eb4b51e

File tree

14 files changed

+441
-61
lines changed

14 files changed

+441
-61
lines changed

examples/react-chat-ai-tutorial/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
},
1212
"dependencies": {
1313
"@stream-io/chat-react-ai": "workspace:^",
14+
"clsx": "^2.1.1",
15+
"material-symbols": "^0.40.0",
16+
"nanoid": "^5.1.6",
1417
"react": "^19.2.0",
1518
"react-dom": "^19.2.0",
1619
"stream-chat": "^9.26.1",
Lines changed: 76 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,101 @@
1-
import type { ChannelFilters, ChannelOptions, ChannelSort } from "stream-chat";
1+
import type { ChannelFilters, ChannelOptions, ChannelSort } from 'stream-chat';
22
import {
3-
Chat,
4-
Channel,
5-
MessageList,
6-
useCreateChatClient,
7-
ChannelList,
8-
Window,
9-
} from "stream-chat-react";
3+
Chat,
4+
Channel,
5+
MessageList,
6+
useCreateChatClient,
7+
ChannelList,
8+
Window,
9+
MessageInput,
10+
useChatContext,
11+
} from 'stream-chat-react';
12+
import { Composer } from './components/Composer';
13+
import { MessageBubble } from './components/MessageBubble';
14+
import { AIStateIndicator } from './components/AIStateIndicator';
15+
import { useEffect } from 'react';
16+
import { nanoid } from 'nanoid';
17+
import { ChannelListItem } from './components/ChannelListItem';
1018

1119
const userToken = import.meta.env.VITE_STREAM_USER_TOKEN;
1220
const apiKey = import.meta.env.VITE_STREAM_API_KEY;
1321

14-
console.log(import.meta, userToken, apiKey);
15-
16-
if (typeof apiKey !== "string" || !apiKey.length) {
17-
throw new Error("Missing VITE_STREAM_API_KEY");
22+
if (typeof apiKey !== 'string' || !apiKey.length) {
23+
throw new Error('Missing VITE_STREAM_API_KEY');
1824
}
1925

20-
if (typeof userToken !== "string" || !userToken.length) {
21-
throw new Error("Missing VITE_STREAM_USER_TOKEN");
26+
if (typeof userToken !== 'string' || !userToken.length) {
27+
throw new Error('Missing VITE_STREAM_USER_TOKEN');
2228
}
2329

2430
const userIdFromToken = (token: string) => {
25-
const [, payload] = token.split(".");
26-
const parsedPayload = JSON.parse(atob(payload));
27-
return parsedPayload.user_id as string;
31+
const [, payload] = token.split('.');
32+
const parsedPayload = JSON.parse(atob(payload));
33+
return parsedPayload.user_id as string;
2834
};
2935

3036
const userId = userIdFromToken(userToken!);
3137

3238
const filters: ChannelFilters = {
33-
members: { $in: [userId] },
34-
type: "messaging",
35-
archived: false,
39+
members: { $in: [userId] },
40+
type: 'messaging',
41+
archived: false,
3642
};
3743
const options: ChannelOptions = { limit: 5 };
3844
const sort: ChannelSort = { pinned_at: 1, last_message_at: -1, updated_at: -1 };
3945

46+
const ChatContent = () => {
47+
const { setActiveChannel, client, channel } = useChatContext();
48+
49+
useEffect(() => {
50+
if (!channel) {
51+
setActiveChannel(
52+
client.channel('messaging', `ai-${nanoid()}`, {
53+
members: [client.userID as string],
54+
// @ts-expect-error fix - this is a hack that allows a custom upload function to run
55+
own_capabilities: ['upload-file'],
56+
}),
57+
);
58+
}
59+
}, [channel]);
60+
61+
return (
62+
<>
63+
<ChannelList
64+
Preview={ChannelListItem}
65+
setActiveChannelOnMount={false}
66+
filters={filters}
67+
sort={sort}
68+
options={options}
69+
/>
70+
<Channel initializeOnMount={false} Message={MessageBubble}>
71+
<Window>
72+
<MessageList />
73+
<AIStateIndicator />
74+
<MessageInput Input={Composer} />
75+
</Window>
76+
</Channel>
77+
</>
78+
);
79+
};
80+
4081
function App() {
41-
const chatClient = useCreateChatClient({
42-
apiKey: apiKey!,
43-
tokenOrProvider: userToken!,
44-
userData: {
45-
id: userId,
46-
},
47-
});
82+
const chatClient = useCreateChatClient({
83+
apiKey: apiKey!,
84+
tokenOrProvider: userToken!,
85+
userData: {
86+
id: userId,
87+
},
88+
});
4889

49-
if (!chatClient) {
50-
return <div>Loading chat...</div>;
51-
}
90+
if (!chatClient) {
91+
return <div>Loading chat...</div>;
92+
}
5293

53-
return (
54-
<Chat client={chatClient}>
55-
<ChannelList filters={filters} sort={sort} options={options} />
56-
<Channel>
57-
<Window>
58-
<MessageList />
59-
</Window>
60-
</Channel>
61-
</Chat>
62-
);
94+
return (
95+
<Chat client={chatClient}>
96+
<ChatContent />
97+
</Chat>
98+
);
6399
}
64100

65101
export default App;
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { Channel } from 'stream-chat';
2+
3+
const baseApiUrl = 'http://localhost:3000';
4+
5+
export const startAiAgent = async (
6+
channel: Channel,
7+
model: string,
8+
platform:
9+
| 'openai'
10+
| 'anthropic'
11+
| 'gemini'
12+
| 'xai'
13+
| (string & {}) = 'openai',
14+
) =>
15+
fetch(`${baseApiUrl}/start-ai-agent`, {
16+
method: 'POST',
17+
headers: { 'Content-Type': 'application/json' },
18+
body: JSON.stringify({
19+
channel_id: channel.id,
20+
channel_type: channel.type,
21+
platform,
22+
model,
23+
}),
24+
});
25+
26+
export const summarizeConversation = (text: string) =>
27+
fetch(`${baseApiUrl}/summarize`, {
28+
method: 'POST',
29+
headers: { 'Content-Type': 'application/json' },
30+
body: JSON.stringify({ text, platform: 'openai' }),
31+
})
32+
.then((res) => res.json())
33+
.then((json) => json.summary as string);
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {
2+
AIStates,
3+
useAIState,
4+
useChannelStateContext,
5+
} from 'stream-chat-react';
6+
import { AIStateIndicator as StateIndicator } from '@stream-io/chat-react-ai';
7+
8+
export const AIStateIndicator = () => {
9+
const { channel } = useChannelStateContext();
10+
const { aiState } = useAIState(channel);
11+
12+
if (![AIStates.Generating, AIStates.Thinking].includes(aiState)) return null;
13+
14+
return <StateIndicator key={channel.state.last_message_at?.toString()} />;
15+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.tut__channel-preview {
2+
padding: 0.75rem 1rem;
3+
cursor: pointer;
4+
}
5+
6+
.tut__channel-preview--active {
7+
background-color: var(--str-chat__primary-surface-color);
8+
}
9+
10+
.tut__channel-preview__text {
11+
font-size: 1rem;
12+
font-weight: 500;
13+
color: var(--str-chat__text-color);
14+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { ChannelPreviewProps } from 'stream-chat-react';
2+
import { useChatContext } from 'stream-chat-react';
3+
import clsx from 'clsx';
4+
5+
export const ChannelListItem = (props: ChannelPreviewProps) => {
6+
const { id, data } = props.channel;
7+
const { setActiveChannel, channel: activeChannel } = useChatContext();
8+
const isActive = activeChannel?.id === id;
9+
10+
return (
11+
<div
12+
className={clsx('tut__channel-preview', {
13+
'tut__channel-preview--active': isActive,
14+
})}
15+
onClick={() => setActiveChannel(props.channel)}
16+
>
17+
<div className="tut__channel-preview__text">
18+
{data?.summary ?? 'New Chat'}
19+
</div>
20+
</div>
21+
);
22+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
.tut__composer-container {
2+
display: flex;
3+
justify-content: center;
4+
padding: 1rem;
5+
scrollbar-gutter: stable;
6+
scrollbar-width: thin;
7+
overflow-y: hidden;
8+
flex-shrink: 0;
9+
}
10+
11+
.aicr__ai-message-composer__form {
12+
max-width: 800px;
13+
width: 100%;
14+
}

0 commit comments

Comments
 (0)