Skip to content
Open
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
119 changes: 108 additions & 11 deletions examples/ExpoChatGPTSample/contexts/AppContext.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,64 @@
import type { PropsWithChildren } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';

import type { ImageSource, Session } from '@stream-io/calypso';
import type { Channel, StreamChat } from 'stream-chat';
import {
type ConversationSnapshot,
type ImageSource,
LiveReasoning,
type Session,
} from '@stream-io/calypso';
import type { Channel, Event, StreamChat } from 'stream-chat';
import { useStableCallback } from 'stream-chat-expo';
import type { AIToolApprover } from 'stream-chat-expo/ai-components';

import { agents, agentSessions, streamToken } from '../agent/agents';
import { AgentConfig } from '../agent/config';

type Conversation = { channel: Channel; session: Session };
type Conversation = { channel: Channel; reasoning: LiveReasoning; session: Session };

const IDLE: ConversationSnapshot = { kind: 'idle', skills: [] };

// This install, for the tool calls addressed to it. Kept for the app's lifetime.
const clientId = `rn-sample-${(globalThis as { crypto: { randomUUID: () => string } }).crypto.randomUUID()}`;

export type AppContextValue = {
/** What the agent is doing in the open conversation, from the session's socket. */
agentState: ConversationSnapshot;
/** Answers the open conversation's tool calls that ask first. */
approver: AIToolApprover | undefined;
/** The channel the router writes the open conversation into. */
channel: Channel | undefined;
/** Bumped when the drawer's list of conversations may have changed. */
conversationsVersion: number;
/** Why the last conversation couldn't be opened, or what asking failed with. */
failure: string | undefined;
/** The open conversation's session id. */
sessionId: string | undefined;
/** Asks the agent, with photos as data URIs. Its reply arrives in the channel. */
ask: (text: string, images?: ImageSource[]) => Promise<void>;
/** Carries on a conversation from the drawer. */
openConversation: (channel: Channel) => void;
/** Carries on a conversation from the drawer, by its session id. */
openConversation: (sessionId: string) => void;
/** The whole of a reply's reasoning step so far, from its live updates. */
reasoningText: (messageId: string, stepId: string) => string | undefined;
/** Bumped as reasoning arrives, so the steps showing it render again. */
reasoningVersion: number;
/** Starts a new conversation. */
startConversation: () => void;
/** Stops the reply being written. What it wrote so far stays. */
stop: () => void;
};

export const AppContext = React.createContext<AppContextValue>({
agentState: IDLE,
approver: undefined,
ask: async () => {},
channel: undefined,
conversationsVersion: 0,
failure: undefined,
openConversation: () => {},
reasoningText: () => undefined,
reasoningVersion: 0,
sessionId: undefined,
startConversation: () => {},
stop: () => {},
});
Expand All @@ -39,6 +68,9 @@ const messageOf = (error: unknown) => (error instanceof Error ? error.message :
export const AppProvider = ({ client, children }: PropsWithChildren<{ client: StreamChat }>) => {
const [conversation, setConversation] = useState<Conversation>();
const [failure, setFailure] = useState<string>();
const [agentState, setAgentState] = useState<ConversationSnapshot>(IDLE);
const [reasoningVersion, setReasoningVersion] = useState(0);
const [conversationsVersion, setConversationsVersion] = useState(0);
const current = useRef<Conversation | undefined>(undefined);

// Opening one conversation after another: the newest wins, and one that was never asked
Expand All @@ -49,13 +81,14 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St
const session = await opening();
const { channel } = await session.chat();
const previous = current.current;
const next = { channel: channel as Channel, session };
const next = { channel: channel as Channel, reasoning: new LiveReasoning(), session };
current.current = next;
setConversation(next);
if (previous && previous.session.id !== session.id) {
const unused = previous.channel.state.messages.length === 0;
await (unused ? previous.session.delete() : previous.session.close()).catch(() => {});
}
setConversationsVersion((version) => version + 1);
} catch (error) {
setFailure(messageOf(error));
}
Expand All @@ -65,12 +98,11 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St
void open(() => agentSessions().create({ modelOverwrites: { llm: AgentConfig.chatModel } }));
});

const openConversation = useStableCallback((channel: Channel) => {
if (!channel.id || channel.id === current.current?.session.id) {
const openConversation = useStableCallback((sessionId: string) => {
if (sessionId === current.current?.session.id) {
return;
}
// A conversation's session id is its channel's id.
void open(() => agentSessions().resume(channel.id as string));
void open(() => agentSessions().resume(sessionId));
});

const ask = useStableCallback(async (text: string, images: ImageSource[] = []) => {
Expand All @@ -94,6 +126,53 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St
}
});

const reasoningText = useStableCallback((messageId: string, stepId: string) =>
current.current?.reasoning.get(messageId, stepId),
);

// The open conversation: what the agent is doing, and every live update's slice of thinking,
// read from each event rather than the message list, which can fold several into one.
useEffect(() => {
if (!conversation) {
return;
}
const { channel, reasoning, session } = conversation;
setAgentState(session.state.get());
const stopState = session.state.subscribe(() => {
const state = session.state.get();
setAgentState(state);
// The router titles a conversation as it goes.
if (state.kind === 'idle') setConversationsVersion((version) => version + 1);
});
const subscription = channel.on('message.updated', (event: Event) => {
if (event.message) {
reasoning.read(event.message as { id: string; reasoning?: unknown });
setReasoningVersion((version) => version + 1);
}
});
return () => {
stopState();
subscription.unsubscribe();
};
}, [conversation]);

const approver = useMemo<AIToolApprover | undefined>(
() =>
conversation
? {
clientId,
// `decide` sends over the session's socket and throws when it can't.
decide: (call, allowed) =>
new Promise<void>((resolve) => {
conversation.session.decide(call.id, { allowed });
resolve();
}),
userId: AgentConfig.user.id,
}
: undefined,
[conversation],
);

useEffect(() => {
let cancelled = false;
(async () => {
Expand All @@ -115,14 +194,32 @@ export const AppProvider = ({ client, children }: PropsWithChildren<{ client: St

const contextValue = useMemo(
() => ({
agentState,
approver,
ask,
channel: conversation?.channel,
conversationsVersion,
failure,
openConversation,
reasoningText,
reasoningVersion,
sessionId: conversation?.session.id,
startConversation,
stop,
}),
[ask, conversation, failure, openConversation, startConversation, stop],
[
agentState,
approver,
ask,
conversation,
conversationsVersion,
failure,
openConversation,
reasoningText,
reasoningVersion,
startConversation,
stop,
],
);

return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
Expand Down
109 changes: 69 additions & 40 deletions examples/ExpoChatGPTSample/screens/ChatContent.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,41 @@
import { useMemo, useState } from 'react';
import { ActivityIndicator, Platform, StatusBar, StyleSheet, Text, View } from 'react-native';
import {
ActivityIndicator,
Platform,
StatusBar,
StyleSheet,
Text,
useWindowDimensions,
View,
} from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { useHeaderHeight } from 'expo-router/react-navigation';

import type { MessageProps, ResolvedComponents } from 'stream-chat-expo';
import {
AIStates,
Channel,
isLocalUrl,
mergeThemes,
Message,
MessageList,
ThemeProvider,
useAIState,
useChannelContext,
useComponentsContext,
useMessageContext,
usePaginatedMessageListContext,
useTheme,
WithComponents,
} from 'stream-chat-expo';

import {
AI_MESSAGE_PART_TYPE_PREFIX,
AIMessageParts,
AIThinking,
AIToolApproverProvider,
AITypingIndicatorView,
ComposerView,
parseMessageParts,
StreamingMessageView,
} from 'stream-chat-expo/ai-components';

Expand Down Expand Up @@ -84,33 +94,43 @@ const CustomMessage = (props: MessageProps) => {
type UnsupportedAttachmentComponent = ResolvedComponents['UnsupportedAttachment'];

// A reply's steps (`ai_reasoning`, `ai_tool_call`) are attachments the chat SDK doesn't know. They
// get their own views; until then they're left out rather than shown as unsupported files, and any
// other unknown attachment keeps the SDK's own view.
// show in the reply's own view, so they're left out here; any other unknown attachment keeps the
// SDK's own view.
const hidingAISteps = (Fallback: UnsupportedAttachmentComponent) => {
const HideAISteps: UnsupportedAttachmentComponent = (props) =>
props.attachment.type?.startsWith(AI_MESSAGE_PART_TYPE_PREFIX) ? null : <Fallback {...props} />;
return HideAISteps;
};

// A reply: its steps (reasoning, tool calls and their questions), then its answer.
const CustomStreamingMessageView = () => {
const { message } = useMessageContext();
// Read so the steps render again as more of their reasoning arrives.
const { reasoningText, reasoningVersion } = useAppContext();
const parts = useMemo(() => parseMessageParts(message.attachments), [message.attachments]);
const stepText = useMemo(
() => (stepId: string) => reasoningText(message.id, stepId),
// eslint-disable-next-line react-hooks/exhaustive-deps -- a new function per slice of reasoning
[message.id, reasoningText, reasoningVersion],
);
// The message list sizes a bubble to its content; a reply's steps take the row's whole width,
// the screen less the list's 16 on each side.
const { width } = useWindowDimensions();
return (
<View style={styles.streamingMessageViewWrapper}>
<StreamingMessageView text={message.text ?? ''} />
<View style={[styles.streamingMessageViewWrapper, { width: width - 32 }]}>
{parts.length > 0 ? <AIMessageParts parts={parts} reasoningText={stepText} /> : null}
{message.text ? <StreamingMessageView text={message.text} /> : null}
</View>
);
};

const CustomComposerView = () => {
const { channel } = useChannelContext();
const { stop } = useAppContext();

const { aiState } = useAIState(channel);
const { agentState, stop } = useAppContext();

// The router stops through its own session, not with an AI indicator event.
const stopGenerating = useMemo(() => () => Promise.resolve(stop()), [stop]);

const isGenerating = [AIStates.Thinking, AIStates.Generating].includes(aiState);
const isGenerating = agentState.kind === 'responding' || agentState.kind === 'working';

const safeAreaInsets = useSafeAreaInsets();
const insets = useMemo(
Expand Down Expand Up @@ -142,27 +162,30 @@ const EmptyStateIndicator = () => (
</View>
);

// What the agent is doing while its reply shows nothing yet: thinking, or running skills.
const CustomAITypingIndicatorView = () => {
const { channel } = useChannelContext();
const { aiState } = useAIState(channel);

const allowedStates = {
[AIStates.Thinking]: 'Thinking about the question...',
[AIStates.Generating]: 'Generating a response...',
[AIStates.ExternalSources]: 'Checking external sources...',
};
const { agentState } = useAppContext();
const { messages } = usePaginatedMessageListContext();
const latest = messages[messages.length - 1];
const replyHasBegun =
!!latest?.ai_generated && (!!latest.text || parseMessageParts(latest.attachments).length > 0);

const indicatorText = allowedStates[aiState as keyof typeof allowedStates];

if (!indicatorText) {
return null;
if (agentState.kind === 'working') {
const skills = agentState.skills.map((skill) => skill.replace(/_/g, ' ')).join(' and ');
return (
<View style={styles.aiTypingIndicatorWrapper}>
<AITypingIndicatorView text={`Working on ${skills}`} />
</View>
);
}

return (
<View style={styles.aiTypingIndicatorWrapper}>
<AITypingIndicatorView text={indicatorText} />
</View>
);
if (agentState.kind === 'responding' && !replyHasBegun) {
return (
<View style={styles.aiTypingIndicatorWrapper}>
<AIThinking />
</View>
);
}
return null;
};

const RenderNull = () => null;
Expand All @@ -176,7 +199,7 @@ const additionalFlatListProps = {
};

export const ChatContent = () => {
const { channel, failure } = useAppContext();
const { approver, channel, failure } = useAppContext();
const { bottom } = useSafeAreaInsets();
// The channel sits below the drawer header, so the keyboard offset has to account for it
// (without it, Android keeps the composer behind the keyboard).
Expand Down Expand Up @@ -211,13 +234,19 @@ export const ChatContent = () => {
UnsupportedAttachment: CustomUnsupportedAttachment,
}}
>
<Channel channel={channel} keyboardVerticalOffset={headerHeight} enableSwipeToReply={false}>
<MessageList additionalFlatListProps={additionalFlatListProps} />
<CustomAITypingIndicatorView />
{failure ? <Text style={styles.failureText}>{failure}</Text> : null}
<VoiceMode />
<CustomComposerView />
</Channel>
<AIToolApproverProvider approver={approver}>
<Channel
channel={channel}
keyboardVerticalOffset={headerHeight}
enableSwipeToReply={false}
>
<MessageList additionalFlatListProps={additionalFlatListProps} />
<CustomAITypingIndicatorView />
{failure ? <Text style={styles.failureText}>{failure}</Text> : null}
<VoiceMode />
<CustomComposerView />
</Channel>
</AIToolApproverProvider>
</WithComponents>
</Animated.View>
);
Expand All @@ -234,7 +263,7 @@ const styles = StyleSheet.create({
},
emptyContainerText: { fontSize: 24, fontWeight: 'bold' },
failureText: { color: '#B00020', fontSize: 15, paddingHorizontal: 24, textAlign: 'center' },
streamingMessageViewWrapper: { width: '100%', paddingHorizontal: 16 },
streamingMessageViewWrapper: { paddingHorizontal: 16 },
aiTypingIndicatorWrapper: {
paddingHorizontal: 24,
paddingVertical: 12,
Expand Down
Loading
Loading