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
15 changes: 15 additions & 0 deletions examples/ExpoChatGPTSample/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Copy to .env.local (gitignored) and fill in. Public values only: Expo inlines every EXPO_PUBLIC_
# variable into the app bundle. The Stream secret stays with the token backend.

# The Stream app the Vision-Agents router runs in.
EXPO_PUBLIC_STREAM_API_KEY=YOUR_STREAM_API_KEY

# The router and its token backend (Vision-Agents' `go run ./backend`). On an Android emulator,
# `adb reverse tcp:8080 tcp:8080 && adb reverse tcp:8098 tcp:8098` makes localhost reach them.
EXPO_PUBLIC_AGENT_ROUTER_URL=http://localhost:8080
EXPO_PUBLIC_AGENT_TOKEN_URL=http://localhost:8098

# The customer and agent `go run ./configure` stored, and who this app is.
EXPO_PUBLIC_AGENT_CUSTOMER_ID=examples
EXPO_PUBLIC_AGENT_NAME=swift_assistant
EXPO_PUBLIC_AGENT_USER_ID=rn-ai-sample-user
23 changes: 16 additions & 7 deletions examples/ExpoChatGPTSample/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,26 @@ Ported from the `ExpoChatGPTSample` of the deprecated `@stream-io/chat-react-nat

## How it works

- The drawer lists the user's conversations; `+` starts a new one.
- A new conversation is created on its first message. Its title is summarized from that message by the sample backend.
- Before sending, the app asks the sample backend to add an AI agent to the channel (`http/requests.ts`). The agent
replies with streamed `ai_generated` messages, rendered by `StreamingMessageView`.
The agent runs on a [Vision-Agents](https://github.com/GetStream/Vision-Agents) router, which the app talks to through
[`@stream-io/calypso`](https://www.npmjs.com/package/@stream-io/calypso) (`agent/`).

The Stream app, user and backend in `chatConfig.ts` and `http/requests.ts` are Stream's public demo setup. To use your
own, replace them and point `http/requests.ts` at your own AI agent server.
- Each conversation is a router session, kept in an `agent` channel. `<Channel>` follows that channel as usual.
- Asking goes through `session.responses.create`, not the chat composer. The router writes the question and the agent's
streamed reply into the channel, and titles the conversation itself.
- The drawer lists the user's `agent` channels. `+` starts a new session, and one that is never asked anything is
deleted when you leave it.
- The reply's steps (`ai_reasoning`, `ai_tool_call` attachments) are hidden for now. They get their own views in a
later change.

## Running

From the repository root:
1. Run a Vision-Agents router locally, with its `swift_assistant` agent and token backend, as in
[`examples/voice_agents/swift_assistant`](https://github.com/GetStream/Vision-Agents/tree/accelerate/examples/voice_agents/swift_assistant):
`docker compose up` (with `ROUTER_AUTH_MODE=proxy`), `go run ./configure`, then `go run ./backend`. The chat model
is Grok, so the router needs `XAI_API_KEY`.
2. Copy `.env.example` to `.env.local` and set `EXPO_PUBLIC_STREAM_API_KEY` to the Stream app the router runs in.
3. On an Android emulator, forward the ports: `adb reverse tcp:8080 tcp:8080 && adb reverse tcp:8098 tcp:8098`.
4. From the repository root:

```bash
yarn install
Expand Down
32 changes: 32 additions & 0 deletions examples/ExpoChatGPTSample/agent/agents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Client } from '@stream-io/calypso';
// Lets `session.chat()` open the conversation's channel on the app's Stream Chat client.
import '@stream-io/calypso/chat';

import { AgentConfig } from './config';

/** The router, as this app's user. */
export const agents = new Client({
apiKey: AgentConfig.streamApiKey,
customerId: AgentConfig.customerId,
url: AgentConfig.routerUrl,
});

/** The agent's conversations: opening a new one and carrying an old one on. */
export const agentSessions = () => agents.agent(AgentConfig.agentName).sessions;

/** Asks the token backend for this user's Stream token, which both the chat and the router take. */
export const streamToken = async (): Promise<string> => {
const response = await fetch(`${AgentConfig.tokenUrl}/stream-token`, {
body: JSON.stringify({ user_id: AgentConfig.user.id }),
headers: { 'Content-Type': 'application/json' },
method: 'POST',
});
if (!response.ok) {
throw new Error(`The token backend answered ${response.status}: is \`go run ./backend\` up?`);
}
const { token } = (await response.json()) as { token?: string };
if (!token) {
throw new Error('The token backend answered without a token.');
}
return token;
};
32 changes: 32 additions & 0 deletions examples/ExpoChatGPTSample/agent/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Where the Vision-Agents router and its token backend are, and who this app is. Set in
* `.env.local`, which `.env.example` describes. Only public values belong there: Expo inlines every
* `EXPO_PUBLIC_` variable into the bundle, so the Stream secret stays with the token backend.
*/

const streamApiKey = process.env.EXPO_PUBLIC_STREAM_API_KEY;

if (!streamApiKey) {
throw new Error(
'EXPO_PUBLIC_STREAM_API_KEY is not set: copy .env.example to .env.local and fill it in.',
);
}

export const AgentConfig = {
/** The Stream app the router runs in. Its channels hold the conversations. */
streamApiKey,
/** The router. On an Android emulator, `adb reverse tcp:8080 tcp:8080` makes localhost reach it. */
routerUrl: process.env.EXPO_PUBLIC_AGENT_ROUTER_URL || 'http://localhost:8080',
/** Mints this user's Stream token (Vision-Agents' `go run ./backend`). */
tokenUrl: process.env.EXPO_PUBLIC_AGENT_TOKEN_URL || 'http://localhost:8098',
/** The customer `go run ./configure` stored the agent under. */
customerId: process.env.EXPO_PUBLIC_AGENT_CUSTOMER_ID || 'examples',
/** The agent, by the name in its `agent.yaml`. */
agentName: process.env.EXPO_PUBLIC_AGENT_NAME || 'swift_assistant',
/** A model that streams its reasoning, so replies have reasoning steps. */
chatModel: 'xai/grok-4.7',
user: {
id: process.env.EXPO_PUBLIC_AGENT_USER_ID || 'rn-ai-sample-user',
name: 'Alex',
},
} as const;
53 changes: 53 additions & 0 deletions examples/ExpoChatGPTSample/agent/polyfills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Hermes has no `crypto.randomUUID`. Ported from stream-chat-js's `generateUUIDv4`.
/* eslint-disable no-bitwise -- RFC 4122 version and variant bits */

type CryptoLike = {
getRandomValues?: (bytes: Uint8Array) => Uint8Array;
randomUUID?: () => string;
};

const globalWithCrypto = globalThis as { crypto?: CryptoLike };

const getRandomValuesWithMathRandom = (bytes: Uint8Array) => {
const max = Math.pow(2, (8 * bytes.byteLength) / bytes.length);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = Math.random() * max;
}
return bytes;
};

const hex = (bytes: Uint8Array) => {
let s = '';
for (let i = 0; i < bytes.length; i++) {
s += bytes[i].toString(16).padStart(2, '0');
}
return s;
};

// https://tools.ietf.org/html/rfc4122
const generateUUIDv4 = () => {
const getRandomValues =
globalWithCrypto.crypto?.getRandomValues?.bind(globalWithCrypto.crypto) ??
getRandomValuesWithMathRandom;
const bytes = getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40; // version
bytes[8] = (bytes[8] & 0xbf) | 0x80; // variant

return (
hex(bytes.subarray(0, 4)) +
'-' +
hex(bytes.subarray(4, 6)) +
'-' +
hex(bytes.subarray(6, 8)) +
'-' +
hex(bytes.subarray(8, 10)) +
'-' +
hex(bytes.subarray(10, 16))
);
};

if (typeof globalWithCrypto.crypto?.randomUUID !== 'function') {
globalWithCrypto.crypto = Object.assign(globalWithCrypto.crypto ?? {}, {
randomUUID: generateUUIDv4,
});
}
11 changes: 7 additions & 4 deletions examples/ExpoChatGPTSample/app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import '../agent/polyfills';

import React from 'react';

import { StyleSheet } from 'react-native';
Expand All @@ -12,7 +14,8 @@ import { Chat, OverlayProvider, useCreateChatClient } from 'stream-chat-expo';

import { StreamTheme } from 'stream-chat-expo/ai-components';

import { chatApiKey, chatUserId, chatUserName, chatUserToken } from '../chatConfig';
import { streamToken } from '../agent/agents';
import { AgentConfig } from '../agent/config';
import { AppProvider } from '../contexts/AppContext';

import { MenuDrawer } from '../screens/MenuDrawer';
Expand All @@ -21,9 +24,9 @@ const isMessageAIGenerated = (message: LocalMessage) => !!message.ai_generated;

export default function Layout() {
const chatClient = useCreateChatClient({
apiKey: chatApiKey,
tokenOrProvider: chatUserToken,
userData: { id: chatUserId, name: chatUserName },
apiKey: AgentConfig.streamApiKey,
tokenOrProvider: streamToken,
userData: AgentConfig.user,
});

if (!chatClient) {
Expand Down
5 changes: 0 additions & 5 deletions examples/ExpoChatGPTSample/chatConfig.ts

This file was deleted.

127 changes: 108 additions & 19 deletions examples/ExpoChatGPTSample/contexts/AppContext.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,129 @@
import type { PropsWithChildren } from 'react';
import React, { useMemo, useState } 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 { useStableCallback } from 'stream-chat-expo';

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

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

export type AppContextValue = {
/** The channel the router writes the open conversation into. */
channel: Channel | undefined;
setChannel: (channel: Channel) => void;
/** Why the last conversation couldn't be opened, or what asking failed with. */
failure: 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;
/** 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>({
setChannel: () => {},
ask: async () => {},
channel: undefined,
failure: undefined,
openConversation: () => {},
startConversation: () => {},
stop: () => {},
});

const ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-';
const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error));

export const nanoid = (size: number = 21): string => {
let id = '';
for (let i = 0; i < size; i++) {
const r = Math.floor(Math.random() * ALPHABET.length);
id += ALPHABET[r];
}
return id;
};
export const AppProvider = ({ client, children }: PropsWithChildren<{ client: StreamChat }>) => {
const [conversation, setConversation] = useState<Conversation>();
const [failure, setFailure] = useState<string>();
const current = useRef<Conversation | undefined>(undefined);

export const createChannel = (client: StreamChat) =>
client.channel('messaging', nanoid(), {
members: [chatUserId],
// Opening one conversation after another: the newest wins, and one that was never asked
// anything is deleted rather than left in the drawer.
const open = useStableCallback(async (opening: () => Promise<Session>) => {
setFailure(undefined);
try {
const session = await opening();
const { channel } = await session.chat();
const previous = current.current;
const next = { channel: channel as Channel, 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(() => {});
}
} catch (error) {
setFailure(messageOf(error));
}
});

export const AppProvider = ({ client, children }: PropsWithChildren<{ client: StreamChat }>) => {
const [channel, setChannel] = useState<Channel>(() => createChannel(client));
const startConversation = useStableCallback(() => {
void open(() => agentSessions().create({ modelOverwrites: { llm: AgentConfig.chatModel } }));
});

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

const ask = useStableCallback(async (text: string, images: ImageSource[] = []) => {
const session = current.current?.session;
if (!session) {
return;
}
setFailure(undefined);
try {
await session.responses.create(text, images.length > 0 ? { images } : undefined);
} catch (error) {
setFailure(messageOf(error));
}
});

const stop = useStableCallback(() => {
try {
current.current?.session.interrupt();
} catch {
// The reply finished first, which is what stopping it was for.
}
});

useEffect(() => {
let cancelled = false;
(async () => {
try {
// The router and Stream Chat as the same user, on the app's own chat client.
await agents.setUser(AgentConfig.user, streamToken);
agents.use({ chat: client });
if (!cancelled) {
startConversation();
}
} catch (error) {
setFailure(messageOf(error));
}
})();
return () => {
cancelled = true;
};
}, [client, startConversation]);

const contextValue = useMemo(() => ({ channel, setChannel }), [channel]);
const contextValue = useMemo(
() => ({
ask,
channel: conversation?.channel,
failure,
openConversation,
startConversation,
stop,
}),
[ask, conversation, failure, openConversation, startConversation, stop],
);

return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
};
Expand Down
Loading
Loading