From f40d5e5c856c74429ccc0888ce61c51237ac8f69 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Tue, 3 Nov 2026 17:18:43 +0100 Subject: [PATCH] feat(ai-components): ai translations via the chat SDK, AI sample on the agents router --- examples/ExpoChatGPTSample/.env.example | 15 +++ examples/ExpoChatGPTSample/README.md | 23 +++- examples/ExpoChatGPTSample/agent/agents.ts | 32 +++++ examples/ExpoChatGPTSample/agent/config.ts | 32 +++++ examples/ExpoChatGPTSample/agent/polyfills.ts | 53 ++++++++ examples/ExpoChatGPTSample/app/_layout.tsx | 11 +- examples/ExpoChatGPTSample/chatConfig.ts | 5 - .../ExpoChatGPTSample/contexts/AppContext.tsx | 127 +++++++++++++++--- .../hooks/useSendAIMessage.ts | 50 +++---- examples/ExpoChatGPTSample/http/api.ts | 19 --- examples/ExpoChatGPTSample/http/requests.ts | 19 --- examples/ExpoChatGPTSample/package.json | 1 + .../ExpoChatGPTSample/screens/ChatContent.tsx | 77 +++++------ .../ExpoChatGPTSample/screens/MenuDrawer.tsx | 21 ++- package/bin/validate-entry-isolation.js | 4 + .../__tests__/translation.test.tsx | 83 ++++++++++++ package/src/ai-components/contexts/index.ts | 1 + .../contexts/translation-context.tsx | 41 ++++++ .../translationContext/TranslationContext.tsx | 4 +- package/src/utils/i18n/Streami18n.ts | 5 +- package/src/utils/i18n/utils.ts | 3 + yarn.lock | 16 +++ 22 files changed, 487 insertions(+), 155 deletions(-) create mode 100644 examples/ExpoChatGPTSample/.env.example create mode 100644 examples/ExpoChatGPTSample/agent/agents.ts create mode 100644 examples/ExpoChatGPTSample/agent/config.ts create mode 100644 examples/ExpoChatGPTSample/agent/polyfills.ts delete mode 100644 examples/ExpoChatGPTSample/chatConfig.ts delete mode 100644 examples/ExpoChatGPTSample/http/api.ts delete mode 100644 examples/ExpoChatGPTSample/http/requests.ts create mode 100644 package/src/ai-components/__tests__/translation.test.tsx create mode 100644 package/src/ai-components/contexts/translation-context.tsx create mode 100644 package/src/utils/i18n/utils.ts diff --git a/examples/ExpoChatGPTSample/.env.example b/examples/ExpoChatGPTSample/.env.example new file mode 100644 index 0000000000..0a0a5fcc3a --- /dev/null +++ b/examples/ExpoChatGPTSample/.env.example @@ -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 diff --git a/examples/ExpoChatGPTSample/README.md b/examples/ExpoChatGPTSample/README.md index 7f68eeacef..4da20e2836 100644 --- a/examples/ExpoChatGPTSample/README.md +++ b/examples/ExpoChatGPTSample/README.md @@ -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. `` 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 diff --git a/examples/ExpoChatGPTSample/agent/agents.ts b/examples/ExpoChatGPTSample/agent/agents.ts new file mode 100644 index 0000000000..b7b6c55ea9 --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/agents.ts @@ -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 => { + 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; +}; diff --git a/examples/ExpoChatGPTSample/agent/config.ts b/examples/ExpoChatGPTSample/agent/config.ts new file mode 100644 index 0000000000..772084b3c6 --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/config.ts @@ -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; diff --git a/examples/ExpoChatGPTSample/agent/polyfills.ts b/examples/ExpoChatGPTSample/agent/polyfills.ts new file mode 100644 index 0000000000..988a998f4f --- /dev/null +++ b/examples/ExpoChatGPTSample/agent/polyfills.ts @@ -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, + }); +} diff --git a/examples/ExpoChatGPTSample/app/_layout.tsx b/examples/ExpoChatGPTSample/app/_layout.tsx index aa358a0e9c..a83444ff2a 100644 --- a/examples/ExpoChatGPTSample/app/_layout.tsx +++ b/examples/ExpoChatGPTSample/app/_layout.tsx @@ -1,3 +1,5 @@ +import '../agent/polyfills'; + import React from 'react'; import { StyleSheet } from 'react-native'; @@ -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'; @@ -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) { diff --git a/examples/ExpoChatGPTSample/chatConfig.ts b/examples/ExpoChatGPTSample/chatConfig.ts deleted file mode 100644 index e385f5f427..0000000000 --- a/examples/ExpoChatGPTSample/chatConfig.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const chatApiKey = 'zcgvnykxsfm8'; -export const chatUserId = 'rn-ai-test-user-3'; -export const chatUserToken = - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoicm4tYWktdGVzdC11c2VyLTMifQ.kOg3zLkzHc-b_5IyckQr-RixbebN-GDs7N3c570oA5A'; -export const chatUserName = 'Bob'; diff --git a/examples/ExpoChatGPTSample/contexts/AppContext.tsx b/examples/ExpoChatGPTSample/contexts/AppContext.tsx index 5f0f685291..eeef3e9846 100644 --- a/examples/ExpoChatGPTSample/contexts/AppContext.tsx +++ b/examples/ExpoChatGPTSample/contexts/AppContext.tsx @@ -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; + /** 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({ - 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(); + const [failure, setFailure] = useState(); + const current = useRef(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) => { + 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(() => 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 {children}; }; diff --git a/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts b/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts index c81147a43b..efd6c8de9b 100644 --- a/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts +++ b/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts @@ -1,35 +1,39 @@ -import { useMessageComposer, useMessageInputContext, useStableCallback } from 'stream-chat-expo'; +import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; + +import { useStableCallback } from 'stream-chat-expo'; import type { ComposerViewProps } from 'stream-chat-expo/ai-components'; +import { useAppContext } from '../contexts/AppContext'; + export type AIMessage = Parameters[0]; +// The router takes photos inline, so they are shrunk first. +const MAX_IMAGE_WIDTH = 1280; + +const toDataUri = async (uri: string) => { + const { base64 } = await manipulateAsync(uri, [{ resize: { width: MAX_IMAGE_WIDTH } }], { + base64: true, + compress: 0.7, + format: SaveFormat.JPEG, + }); + return `data:image/jpeg;base64,${base64}`; +}; + /** - * Sends a message to the AI channel through the channel's composer, with its attachments. Shared - * by the composer and voice mode, so both go through `preSendMessageRequest` (which starts the - * agent on the first message). + * Asks the agent, with the photos picked in the composer. The router writes the question and its + * reply into the conversation's channel. Shared by the composer and voice mode. */ export const useSendAIMessage = () => { - const messageComposer = useMessageComposer(); - const { sendMessage } = useMessageInputContext(); + const { ask } = useAppContext(); return useStableCallback(async ({ text, attachments }: AIMessage) => { - messageComposer.textComposer.setText(text); - if (attachments && attachments.length > 0) { - const localAttachments = await Promise.all( - attachments.map((asset) => - messageComposer.attachmentManager.fileToLocalUploadAttachment({ - ...asset, - // The picker can leave these out (e.g. some iOS photos have no file name). - name: asset.name ?? asset.uri.split('/').pop() ?? 'image', - size: asset.size ?? 0, - type: asset.type ?? 'image/jpeg', - }), - ), - ); - messageComposer.attachmentManager.upsertAttachments(localAttachments); - } - - await sendMessage(); + const photos = (attachments ?? []).filter( + (asset) => !asset.type || asset.type.startsWith('image'), + ); + const images = await Promise.all( + photos.map(async (asset) => ({ url: await toDataUri(asset.uri) })), + ); + await ask(text, images); }); }; diff --git a/examples/ExpoChatGPTSample/http/api.ts b/examples/ExpoChatGPTSample/http/api.ts deleted file mode 100644 index 4ff8fba92f..0000000000 --- a/examples/ExpoChatGPTSample/http/api.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const post = async (url: string, data: unknown = {}) => { - try { - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(data), - }); - - if (!response.ok) { - throw new Error(`An HTTP Error has occurred. Status: ${response.status}`); - } - - return await response.json(); - } catch (error) { - console.error('Error:', error); - } -}; diff --git a/examples/ExpoChatGPTSample/http/requests.ts b/examples/ExpoChatGPTSample/http/requests.ts deleted file mode 100644 index fce4cd3941..0000000000 --- a/examples/ExpoChatGPTSample/http/requests.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { post } from './api'; - -// The AI provider the sample backend uses, as in the React SDK's ai-chatbot example. -const PLATFORM = 'openai'; - -export const startAI = (channelId: string) => - post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/start-ai-agent', { - channel_id: channelId, - platform: PLATFORM, - }); -export const stopAI = (channelId: string) => - post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/stop-ai-agent', { - channel_id: channelId, - }); -export const summarize = (text: string) => - post('https://ai-sdk-server-0f347d455e2e.herokuapp.com/summarize', { - platform: PLATFORM, - text, - }); diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json index 7d6dc5c668..48bf82209a 100644 --- a/examples/ExpoChatGPTSample/package.json +++ b/examples/ExpoChatGPTSample/package.json @@ -12,6 +12,7 @@ "dependencies": { "@react-native-community/netinfo": "12.0.1", "@shopify/react-native-skia": "^2.14.0", + "@stream-io/calypso": "^0.2.3", "expo": "^58.0.8", "expo-clipboard": "~58.0.3", "expo-constants": "~58.0.11", diff --git a/examples/ExpoChatGPTSample/screens/ChatContent.tsx b/examples/ExpoChatGPTSample/screens/ChatContent.tsx index b12acf5b3f..36b796c9f7 100644 --- a/examples/ExpoChatGPTSample/screens/ChatContent.tsx +++ b/examples/ExpoChatGPTSample/screens/ChatContent.tsx @@ -1,12 +1,11 @@ -import { useCallback, useMemo } from 'react'; -import { Platform, StatusBar, StyleSheet, Text, View } from 'react-native'; +import { useMemo, useState } from 'react'; +import { ActivityIndicator, Platform, StatusBar, StyleSheet, Text, 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 { LocalMessage } from 'stream-chat'; -import type { MessageProps } from 'stream-chat-expo'; +import type { MessageProps, ResolvedComponents } from 'stream-chat-expo'; import { AIStates, Channel, @@ -17,13 +16,14 @@ import { ThemeProvider, useAIState, useChannelContext, + useComponentsContext, useMessageContext, - useStableCallback, useTheme, WithComponents, } from 'stream-chat-expo'; import { + AI_MESSAGE_PART_TYPE_PREFIX, AITypingIndicatorView, ComposerView, StreamingMessageView, @@ -33,7 +33,6 @@ import { bottomSheetOptions } from '../bottomSheetOptions'; import { VoiceMode } from '../components/VoiceMode'; import { useAppContext } from '../contexts/AppContext'; import { useSendAIMessage } from '../hooks/useSendAIMessage'; -import { startAI, summarize } from '../http/requests'; const CustomMessage = (props: MessageProps) => { const { theme } = useTheme(); @@ -82,6 +81,17 @@ 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. +const hidingAISteps = (Fallback: UnsupportedAttachmentComponent) => { + const HideAISteps: UnsupportedAttachmentComponent = (props) => + props.attachment.type?.startsWith(AI_MESSAGE_PART_TYPE_PREFIX) ? null : ; + return HideAISteps; +}; + const CustomStreamingMessageView = () => { const { message } = useMessageContext(); return ( @@ -93,10 +103,12 @@ const CustomStreamingMessageView = () => { const CustomComposerView = () => { const { channel } = useChannelContext(); + const { stop } = useAppContext(); const { aiState } = useAIState(channel); - const stopGenerating = useCallback(() => channel?.stopAIResponse(), [channel]); + // 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); @@ -164,42 +176,21 @@ const additionalFlatListProps = { }; export const ChatContent = () => { - const { channel } = useAppContext(); + const { 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). const headerHeight = useHeaderHeight(); - - const preSendMessageRequest = useStableCallback( - async ({ localMessage }: { localMessage: LocalMessage }) => { - if (!channel) { - return; - } - - if (!channel.initialized) { - await channel.watch({ - created_by_id: localMessage.user_id, - }); - summarize(localMessage.text ?? '').then((response) => { - // `post` resolves to undefined when the request fails; the channel then keeps its id. - const summary = (response as { summary?: string } | undefined)?.summary; - if (summary) { - channel.update({ name: summary }); - } - }); - } - - if ( - !Object.keys(channel.state.watchers).some((watcher) => watcher.startsWith('ai-bot')) && - channel.id - ) { - await startAI(channel.id); - } - }, - ); + // Read above this screen's own overrides, so it is the SDK's view. + const { UnsupportedAttachment } = useComponentsContext(); + const [CustomUnsupportedAttachment] = useState(() => hidingAISteps(UnsupportedAttachment)); if (!channel) { - return null; + return ( + + {failure ? {failure} : } + + ); } return ( @@ -217,18 +208,13 @@ export const ChatContent = () => { MessageFooter: RenderNull, NetworkDownIndicator: RenderNull, StreamingMessageView: CustomStreamingMessageView, + UnsupportedAttachment: CustomUnsupportedAttachment, }} > - + + {failure ? {failure} : null} @@ -247,6 +233,7 @@ const styles = StyleSheet.create({ alignItems: 'center', }, emptyContainerText: { fontSize: 24, fontWeight: 'bold' }, + failureText: { color: '#B00020', fontSize: 15, paddingHorizontal: 24, textAlign: 'center' }, streamingMessageViewWrapper: { width: '100%', paddingHorizontal: 16 }, aiTypingIndicatorWrapper: { paddingHorizontal: 24, diff --git a/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx b/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx index 10fe99de57..3157a9dd42 100644 --- a/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx +++ b/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx @@ -6,25 +6,25 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import type { DrawerContentComponentProps } from 'expo-router/drawer'; import type { ChannelSort } from 'stream-chat'; -import type { Channel as ChannelClass } from 'stream-chat'; +import type { Channel as ChannelClass, ChannelFilters } from 'stream-chat'; import { ChannelList, ChannelPreviewProps, Plus, useChannelsContext, - useChatContext, useStableCallback, WithComponents, } from 'stream-chat-expo'; -import { chatUserId } from '../chatConfig'; +import { AgentConfig } from '../agent/config'; +import { useAppContext } from '../contexts/AppContext'; -import { createChannel, useAppContext } from '../contexts/AppContext'; - -const filters = { +// The router keeps each conversation in an `agent` channel the user is a member of. +const filters: ChannelFilters = { members: { - $in: [chatUserId], + $in: [AgentConfig.user.id], }, + type: 'agent', }; const sort: ChannelSort = { last_updated: -1 }; @@ -52,16 +52,15 @@ const ChannelPreview = (props: ChannelPreviewProps) => { }; export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => { - const { client } = useChatContext(); - const { setChannel } = useAppContext(); + const { openConversation, startConversation } = useAppContext(); const onSelect = useStableCallback((channel: ChannelClass) => { - setChannel(channel); + openConversation(channel); navigation.closeDrawer(); }); const onCreateNewChat = useStableCallback(() => { - setChannel(createChannel(client)); + startConversation(); navigation.closeDrawer(); }); diff --git a/package/bin/validate-entry-isolation.js b/package/bin/validate-entry-isolation.js index f5e4e4f527..adcd0d0047 100644 --- a/package/bin/validate-entry-isolation.js +++ b/package/bin/validate-entry-isolation.js @@ -40,6 +40,10 @@ const AI_ONLY_PACKAGES = [ const AI_ALLOWED_FILES = [ path.join(CORE_SRC, 'native.ts'), path.join(CORE_SRC, 'components/UIComponents/NativeShimmerView.tsx'), + // The chat SDK's translation context, which the AI components translate with (not Streami18n). + path.join(CORE_SRC, 'contexts/translationContext/TranslationContext.tsx'), + path.join(CORE_SRC, 'contexts/utils/isTestEnvironment.ts'), + path.join(CORE_SRC, 'utils/i18n/utils.ts'), ...WRAPPERS.flatMap((wrapper) => [ path.join(wrapper, 'ai-components/index.js'), path.join(wrapper, 'src/optionalDependencies/NativeShimmerView.ts'), diff --git a/package/src/ai-components/__tests__/translation.test.tsx b/package/src/ai-components/__tests__/translation.test.tsx new file mode 100644 index 0000000000..0193d0707b --- /dev/null +++ b/package/src/ai-components/__tests__/translation.test.tsx @@ -0,0 +1,83 @@ +import React, { type PropsWithChildren } from 'react'; + +import { renderHook } from '@testing-library/react-native'; +import type { TFunction } from 'i18next'; + +import { + TranslationProvider as ChatTranslationProvider, + type TranslationContextValue, +} from '../../contexts/translationContext'; +import { Streami18n } from '../../utils/i18n/Streami18n'; +import { + type AITranslate, + defaultAITranslate, + interpolateAIString, + TranslationProvider, + useAITranslation, +} from '../contexts'; + +const withValue = + (value: TranslationContextValue) => + ({ children }: PropsWithChildren) => ( + {children} + ); + +describe('AI translation', () => { + it('fills placeholders and leaves unknown ones', () => { + expect(interpolateAIString('Thought for {{ duration }}', { duration: '12s' })).toBe( + 'Thought for 12s', + ); + expect(interpolateAIString('Connected to {{provider}}', { provider: 'Linear' })).toBe( + 'Connected to Linear', + ); + expect(interpolateAIString('Thought for {{ duration }}', {})).toBe( + 'Thought for {{ duration }}', + ); + expect(interpolateAIString('Thinking…')).toBe('Thinking…'); + expect(interpolateAIString('{{ count }} files', { count: 0 })).toBe('0 files'); + }); + + it('is English without ', () => { + const { result } = renderHook(() => useAITranslation()); + expect(result.current.t).toBe(defaultAITranslate); + expect(result.current.t('Thinking… {{ duration }}', { duration: '4s' })).toBe('Thinking… 4s'); + }); + + it('follows the chat SDK translator, as inside ', async () => { + const i18n = new Streami18n({ language: 'fr' }); + // `registerTranslation` only takes keys in `en.json`; the AI keys join it with the views. + i18n.registerTranslation('fr', { + 'Thought for {{ duration }}': 'Réfléchi pendant {{ duration }}', + } as Parameters[1]); + const translators = (await i18n.getTranslators()) as unknown as TranslationContextValue; + + const { result } = renderHook(() => useAITranslation(), { + wrapper: withValue({ ...translators, userLanguage: 'fr' }), + }); + expect(result.current.t('Thought for {{ duration }}', { duration: '12s' })).toBe( + 'Réfléchi pendant 12s', + ); + }); + + it('is the same provider as the chat SDK, for apps without ', () => { + expect(TranslationProvider).toBe(ChatTranslationProvider); + const t = ((key: string) => `[es] ${key}`) as unknown as TFunction; + const { result } = renderHook(() => useAITranslation(), { + wrapper: ({ children }: PropsWithChildren) => ( + input ?? 0, userLanguage: 'es' }} + > + {children} + + ), + }); + expect(result.current.t('Allow')).toBe('[es] Allow'); + }); + + it('takes the chat SDK translator as it is', () => { + // A compile-time check: core's `t` (i18next's TFunction) must be assignable without a cast. + const coreT = ((key: string) => key) as unknown as TFunction; + const t: AITranslate = coreT; + expect(t('Allow')).toBe('Allow'); + }); +}); diff --git a/package/src/ai-components/contexts/index.ts b/package/src/ai-components/contexts/index.ts index d84d8bb745..f0e08d8aa0 100644 --- a/package/src/ai-components/contexts/index.ts +++ b/package/src/ai-components/contexts/index.ts @@ -1,2 +1,3 @@ export * from './composer-context'; export * from './theme-context'; +export * from './translation-context'; diff --git a/package/src/ai-components/contexts/translation-context.tsx b/package/src/ai-components/contexts/translation-context.tsx new file mode 100644 index 0000000000..c7bfca718c --- /dev/null +++ b/package/src/ai-components/contexts/translation-context.tsx @@ -0,0 +1,41 @@ +import { useContext, useMemo } from 'react'; + +import { TranslationContext } from '../../contexts/translationContext/TranslationContext'; +import { defaultTranslatorFunction } from '../../utils/i18n/utils'; + +// The chat SDK's own provider, for apps that use the AI components without ``. +export { + TranslationProvider, + type TranslationContextValue, +} from '../../contexts/translationContext/TranslationContext'; + +/** + * Translates one of the AI components' strings. The key is the English string itself, with + * `{{ name }}` placeholders filled from `options`, as in the chat SDK's i18n files. + */ +export type AITranslate = (key: string, options?: Record) => string; + +const PLACEHOLDER = /{{\s*([\w.]+)\s*}}/g; + +/** Fills `{{ name }}` placeholders in `text` from `options`, leaving unknown ones as they are. */ +export const interpolateAIString = (text: string, options?: Record) => + options + ? text.replace(PLACEHOLDER, (placeholder, name: string) => + options[name] === undefined || options[name] === null ? placeholder : String(options[name]), + ) + : text; + +/** English: the key itself, with its placeholders filled. */ +export const defaultAITranslate: AITranslate = (key, options) => interpolateAIString(key, options); + +/** + * The chat SDK's translator, so the AI components follow the chat's language inside ``. With + * no translator set up (no ``, or before it has one) they are in English. + */ +export const useAITranslation = () => { + const { t } = useContext(TranslationContext); + return useMemo( + () => ({ t: t === defaultTranslatorFunction ? defaultAITranslate : (t as AITranslate) }), + [t], + ); +}; diff --git a/package/src/contexts/translationContext/TranslationContext.tsx b/package/src/contexts/translationContext/TranslationContext.tsx index 4ab6737728..b93f9e055f 100644 --- a/package/src/contexts/translationContext/TranslationContext.tsx +++ b/package/src/contexts/translationContext/TranslationContext.tsx @@ -4,9 +4,9 @@ import Dayjs from 'dayjs'; import type { TranslationLanguages } from 'stream-chat'; -import { TranslatorFunctions } from './types'; +import type { TranslatorFunctions } from './types'; -import { defaultTranslatorFunction } from '../../utils/i18n/Streami18n'; +import { defaultTranslatorFunction } from '../../utils/i18n/utils'; import { isTestEnvironment } from '../utils/isTestEnvironment'; export const DEFAULT_USER_LANGUAGE: TranslationLanguages = 'en'; diff --git a/package/src/utils/i18n/Streami18n.ts b/package/src/utils/i18n/Streami18n.ts index 278919af97..5eefd361db 100644 --- a/package/src/utils/i18n/Streami18n.ts +++ b/package/src/utils/i18n/Streami18n.ts @@ -14,6 +14,8 @@ import { calendarFormats } from './calendarFormats'; import { predefinedFormatters } from './predefinedFormatters'; import { CustomFormatters, PredefinedFormatters } from './types'; +import { defaultTranslatorFunction } from './utils'; + import type { TDateTimeParser } from '../../contexts/translationContext/types'; import arTranslations from '../../i18n/ar.json'; import enTranslations from '../../i18n/en.json'; @@ -370,7 +372,8 @@ const defaultStreami18nOptions = { logger: (msg?: string) => console.warn(msg), }; -export const defaultTranslatorFunction = ((key: string) => key) as TFunction; +// Moved to ./utils, so the translation context doesn't import this file. Re-exported as before. +export { defaultTranslatorFunction }; export class Streami18n { i18nInstance: I18nInstance = i18n.createInstance(); diff --git a/package/src/utils/i18n/utils.ts b/package/src/utils/i18n/utils.ts new file mode 100644 index 0000000000..e68a18eb0f --- /dev/null +++ b/package/src/utils/i18n/utils.ts @@ -0,0 +1,3 @@ +import type { TFunction } from 'i18next'; + +export const defaultTranslatorFunction = ((key: string) => key) as TFunction; diff --git a/yarn.lock b/yarn.lock index ab453e7250..cae1e19fd7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6385,6 +6385,21 @@ __metadata: languageName: node linkType: hard +"@stream-io/calypso@npm:^0.2.3": + version: 0.2.3 + resolution: "@stream-io/calypso@npm:0.2.3" + peerDependencies: + "@stream-io/video-client": ">=1.0.0" + stream-chat: ">=9.0.0" + peerDependenciesMeta: + "@stream-io/video-client": + optional: true + stream-chat: + optional: true + checksum: 10c0/9bc97c29ee41f9ba7dfc46d9d5cd800717b44fa32b94dcfc5410a33a41800bd748e4078f6a28d818f06626dc52b030cdb008549966fa03f8269724887a02f374 + languageName: node + linkType: hard + "@stream-io/state-store@npm:^1.1.6": version: 1.1.6 resolution: "@stream-io/state-store@npm:1.1.6" @@ -11413,6 +11428,7 @@ __metadata: "@react-native-community/netinfo": "npm:12.0.1" "@rnx-kit/metro-config": "npm:^2.1.2" "@shopify/react-native-skia": "npm:^2.14.0" + "@stream-io/calypso": "npm:^0.2.3" "@stream-io/typescript-config": "workspace:^" "@types/react": "npm:~19.3.0" expo: "npm:^58.0.8"