Skip to content

Commit f7818f8

Browse files
feat(react-sdk): StreamingMessage & AIMessageComposer (#2)
* Add AIMessageComposer and StreamingMessage component * Adjust react-example * Minor adjustments and cleanup * Baseline styling
1 parent ce26aa7 commit f7818f8

33 files changed

+2937
-429
lines changed

eslint.config.mjs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import reactHooksPlugin from 'eslint-plugin-react-hooks';
77

88
export default tseslint.config(
99
{
10-
ignores: ['**/node_modules/**', '**/build/**', '**/dist/**'],
10+
ignores: ['**/node_modules', '**/build', '**/dist'],
1111
},
1212
{
1313
name: 'default',
@@ -97,6 +97,7 @@ export default tseslint.config(
9797
'@typescript-eslint/no-explicit-any': 'off',
9898
'react-hooks/rules-of-hooks': 'error',
9999
'react-hooks/exhaustive-deps': 'error',
100+
'jsx-quotes': 'off', // deprecated rule, handled by prettier anyway
100101
},
101102
},
102103
);

examples/react-example/eslint.config.js

Lines changed: 0 additions & 23 deletions
This file was deleted.

examples/react-example/package.json

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,23 @@
1010
"preview": "vite preview"
1111
},
1212
"dependencies": {
13+
"@stream-io/ai-chat-react": "workspace:^",
14+
"clsx": "^2.1.1",
15+
"material-symbols": "^0.40.0",
16+
"nanoid": "^5.1.6",
1317
"react": "^19.1.1",
14-
"react-dom": "^19.1.1"
18+
"react-dom": "^19.1.1",
19+
"stream-chat": "^9.25.0",
20+
"stream-chat-react": "^13.10.0"
1521
},
1622
"devDependencies": {
17-
"@eslint/js": "^9.36.0",
1823
"@types/node": "^24.6.0",
1924
"@types/react": "^19.1.16",
2025
"@types/react-dom": "^19.1.9",
2126
"@vitejs/plugin-react": "^5.0.4",
22-
"eslint": "^9.36.0",
23-
"eslint-plugin-react-hooks": "^5.2.0",
24-
"eslint-plugin-react-refresh": "^0.4.22",
2527
"globals": "^16.4.0",
26-
"typescript": "~5.9.3",
27-
"typescript-eslint": "^8.45.0",
28+
"sass-embedded": "^1.93.2",
29+
"typescript": "catalog:",
2830
"vite": "catalog:"
2931
}
3032
}

examples/react-example/public/vite.svg

Lines changed: 0 additions & 1 deletion
This file was deleted.

examples/react-example/src/App.css

Lines changed: 0 additions & 42 deletions
This file was deleted.

examples/react-example/src/App.tsx

Lines changed: 0 additions & 35 deletions
This file was deleted.
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import { AIMessageComposer, StreamingMessage } from '@stream-io/ai-chat-react';
2+
import type {
3+
ChannelFilters,
4+
ChannelOptions,
5+
ChannelSort,
6+
LocalMessage,
7+
} from 'stream-chat';
8+
import {
9+
AIStateIndicator,
10+
Channel,
11+
ChannelList,
12+
Chat,
13+
useCreateChatClient,
14+
MessageList,
15+
Window,
16+
type ChannelPreviewProps,
17+
ChannelPreview,
18+
useChannelStateContext,
19+
useChatContext,
20+
MessageInput,
21+
useChannelActionContext,
22+
useMessageComposer,
23+
useMessageContext,
24+
Attachment,
25+
messageHasAttachments,
26+
MessageErrorIcon,
27+
} from 'stream-chat-react';
28+
29+
import { customAlphabet } from 'nanoid';
30+
import { useEffect, useMemo } from 'react';
31+
import clsx from 'clsx';
32+
33+
const nanoId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10);
34+
35+
const params = new Proxy(new URLSearchParams(window.location.search), {
36+
get: (searchParams, property) => searchParams.get(property as string),
37+
}) as unknown as Record<string, string | null>;
38+
39+
const parseUserIdFromToken = (token: string) => {
40+
const [, payload] = token.split('.');
41+
42+
if (!payload) throw new Error('Token is missing');
43+
44+
return JSON.parse(atob(payload))?.user_id;
45+
};
46+
47+
const apiKey = params.key ?? (import.meta.env.VITE_STREAM_KEY as string);
48+
const userToken = params.ut ?? (import.meta.env.VITE_USER_TOKEN as string);
49+
const userId = parseUserIdFromToken(userToken);
50+
51+
const filters: ChannelFilters = {
52+
members: { $in: [userId] },
53+
type: 'messaging',
54+
archived: false,
55+
};
56+
const options: ChannelOptions = { limit: 5, presence: true, state: true };
57+
const sort: ChannelSort = { pinned_at: 1, last_message_at: -1, updated_at: -1 };
58+
59+
// @ts-ignore
60+
const isMessageAIGenerated = (message: LocalMessage) => !!message?.ai_generated;
61+
62+
const InputComponent = () => {
63+
const { updateMessage, sendMessage } = useChannelActionContext();
64+
const { channel } = useChannelStateContext();
65+
const composer = useMessageComposer();
66+
67+
return (
68+
<AIMessageComposer
69+
onSubmit={async (e) => {
70+
const event = e;
71+
const target = (event.currentTarget ??
72+
event.target) as HTMLFormElement | null;
73+
event.preventDefault();
74+
75+
const formData = new FormData(event.currentTarget);
76+
77+
const t = formData.get('message');
78+
const model = formData.get('model');
79+
80+
composer.textComposer.setText(t as string);
81+
82+
const d = await composer.compose();
83+
84+
if (!d) return;
85+
86+
target?.reset();
87+
composer.clear();
88+
89+
if (channel.initialized) {
90+
await sendMessage(d);
91+
} else {
92+
updateMessage(d?.localMessage);
93+
94+
await channel.watch();
95+
96+
// TODO: wrap in retry (in case channel creation takes longer)
97+
await fetch('http://localhost:3000/start-ai-agent', {
98+
method: 'POST',
99+
headers: {
100+
'Content-Type': 'application/json',
101+
},
102+
body: JSON.stringify({
103+
channel_id: channel.id,
104+
channel_type: channel.type,
105+
platform: 'openai',
106+
model: model,
107+
}),
108+
});
109+
110+
await sendMessage(d);
111+
}
112+
}}
113+
/>
114+
);
115+
};
116+
117+
const CustomPreview = (p: ChannelPreviewProps) => {
118+
const { setActiveChannel } = useChatContext();
119+
return (
120+
<div onClick={() => setActiveChannel(p.channel)}>
121+
{/* @ts-expect-error */}
122+
{p.channel.data.summary ?? p.channel.id}
123+
</div>
124+
);
125+
};
126+
127+
const EmptyPlaceholder = () => {
128+
const { channel, setActiveChannel, client } = useChatContext();
129+
130+
useEffect(() => {
131+
if (!channel) {
132+
setActiveChannel(
133+
client.channel('messaging', `ai-${nanoId()}`, {
134+
members: [client.userID as string],
135+
}),
136+
);
137+
}
138+
}, [channel, client, setActiveChannel]);
139+
140+
return (
141+
<div>
142+
<div style={{ display: 'flex', flexDirection: 'column' }}>
143+
Start a conversation!
144+
</div>
145+
</div>
146+
);
147+
};
148+
149+
const CustomMessage = () => {
150+
const { message, isMyMessage, highlighted, handleAction } =
151+
useMessageContext();
152+
153+
const hasAttachment = messageHasAttachments(message);
154+
const finalAttachments = useMemo(
155+
() =>
156+
!message.shared_location && !message.attachments
157+
? []
158+
: !message.shared_location
159+
? message.attachments
160+
: [message.shared_location, ...(message.attachments ?? [])],
161+
[message],
162+
);
163+
164+
const rootClassName = clsx(
165+
'str-chat__message str-chat__message-simple',
166+
`str-chat__message--${message.type}`,
167+
`str-chat__message--${message.status}`,
168+
{
169+
'str-chat__message--me str-chat__message-simple--me': isMyMessage(),
170+
'str-chat__message--other': !isMyMessage(),
171+
'str-chat__message--has-text': !!message.text,
172+
'has-no-text': !message.text,
173+
'str-chat__message--has-attachment': hasAttachment,
174+
'str-chat__message--highlighted': highlighted,
175+
'str-chat__message-send-can-be-retried':
176+
message?.status === 'failed' && message?.error?.status !== 403,
177+
},
178+
);
179+
180+
return (
181+
<div className={rootClassName}>
182+
<div className="str-chat__message-inner" data-testid="message-inner">
183+
<div className="str-chat__message-bubble">
184+
{finalAttachments?.length ? (
185+
<Attachment
186+
actionHandler={handleAction}
187+
attachments={finalAttachments}
188+
/>
189+
) : null}
190+
191+
<StreamingMessage text={message?.text || ''} />
192+
193+
<MessageErrorIcon />
194+
</div>
195+
</div>
196+
</div>
197+
);
198+
};
199+
200+
const App = () => {
201+
const chatClient = useCreateChatClient({
202+
apiKey,
203+
tokenOrProvider: userToken,
204+
userData: { id: userId },
205+
});
206+
207+
if (!chatClient) return <>Loading...</>;
208+
209+
return (
210+
<Chat client={chatClient} isMessageAIGenerated={isMessageAIGenerated}>
211+
<ChannelList
212+
setActiveChannelOnMount={false}
213+
Preview={(props) => (
214+
<ChannelPreview {...props} Preview={CustomPreview} />
215+
)}
216+
filters={filters}
217+
options={options}
218+
sort={sort}
219+
/>
220+
<Channel
221+
initializeOnMount={false}
222+
EmptyPlaceholder={<EmptyPlaceholder />}
223+
Message={CustomMessage}
224+
>
225+
<Window>
226+
<MessageList />
227+
<AIStateIndicator />
228+
<div
229+
style={{
230+
display: 'flex',
231+
justifyContent: 'center',
232+
padding: '.5rem',
233+
}}
234+
>
235+
<MessageInput Input={InputComponent} focus />
236+
</div>
237+
</Window>
238+
</Channel>
239+
{/* <InputComponent /> */}
240+
</Chat>
241+
);
242+
};
243+
244+
export default App;

0 commit comments

Comments
 (0)