diff --git a/.yarnrc.yml b/.yarnrc.yml index aa748d8f6a..d85843a87a 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -22,7 +22,6 @@ npmPreapprovedPackages: - '@stream-io/state-store' - '@stream-io/worker-timer' - '@stream-io/ws' - - '@stream-io/stream-agents' - '@a2ui/web_core' npmPublishProvenance: true diff --git a/AGENTS.md b/AGENTS.md index 1cee69414d..58539287b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,7 +20,7 @@ Targets iOS and Android. - **Languages:** TypeScript + React Native - **Runtime:** Node 24 (`.nvmrc` is `v24`; root `engines.node` is `>=22.13.0`; CI runs `24.x`) - **Package manager:** Yarn 4.15.0 (Berry). The binary lives at `.yarn/releases/yarn-4.15.0.cjs` and is activated via `yarnPath` in `.yarnrc.yml`. Any globally installed `yarn` (even the Homebrew classic 1.x) acts only as a launcher — no Corepack required. -- **Workspaces:** single root `yarn.lock`; workspaces are `configs/typescript-config`, `package`, `package/native-package`, `package/expo-package`, `examples/SampleApp`, `examples/E2ETestApp`, `examples/ExpoMessaging`, `examples/ExpoChatGPTSample`. **No Lerna.** +- **Workspaces:** single root `yarn.lock`; workspaces are `configs/typescript-config`, `package`, `package/native-package`, `package/expo-package`, `examples/SampleApp`, `examples/E2ETestApp`, `examples/ExpoMessaging`. **No Lerna.** - **Testing:** Jest with the `@react-native/jest-preset` + `@testing-library/react-native`. - **Build:** `react-native-builder-bob` → CommonJS (`lib/commonjs`), ESM (`lib/module`), types (`lib/typescript`) - **Lint/format:** ESLint 9 flat config + Prettier, strict (`--max-warnings 0`) @@ -41,7 +41,7 @@ Respect repo-specific rules. Do not suppress lint rules broadly; justify and sco - `native-package/` — bare RN wrapper (`stream-chat-react-native`) - `expo-package/` — Expo wrapper (`stream-chat-expo`) - `shared-native/{ios,android}` — native source shared by both wrappers; synced into them, never edited in place -- `examples/` — `SampleApp` (full-featured), `E2ETestApp` (minimal app for the Detox e2e suite), `ExpoMessaging`, `ExpoChatGPTSample` (AI components) +- `examples/` — `SampleApp` (full-featured), `E2ETestApp` (minimal app for the Detox e2e suite), `ExpoMessaging`. The AI components samples live in Vision-Agents (`examples/voice_agents/react_native_assistant`, `examples/voice_agents/crew_assistant/app`) - `configs/typescript-config/` — shared `tsconfig` presets - `ai-docs/` — agent-facing deep dives (see [References](#references)) - `perf/` — on-device performance toolkit (see [Accessibility, RTL & performance](#accessibility-rtl--performance)) diff --git a/examples/ExpoChatGPTSample/.env.example b/examples/ExpoChatGPTSample/.env.example deleted file mode 100644 index 0a0a5fcc3a..0000000000 --- a/examples/ExpoChatGPTSample/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# 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/.gitignore b/examples/ExpoChatGPTSample/.gitignore deleted file mode 100644 index 5f1ef56382..0000000000 --- a/examples/ExpoChatGPTSample/.gitignore +++ /dev/null @@ -1,40 +0,0 @@ -# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files - -# dependencies -node_modules/ - -# Expo -.expo/ -ios/ -android/ -dist/ -web-build/ -expo-env.d.ts - -# Native -*.orig.* -*.jks -*.p8 -*.p12 -*.key -*.mobileprovision - -# Metro -.metro-health-check* - -# debug -npm-debug.* -yarn-debug.* -yarn-error.* - -# macOS -.DS_Store -*.pem - -# local env files -.env*.local - -# typescript -*.tsbuildinfo - -app-example diff --git a/examples/ExpoChatGPTSample/README.md b/examples/ExpoChatGPTSample/README.md deleted file mode 100644 index 3eabfc3bbb..0000000000 --- a/examples/ExpoChatGPTSample/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# ExpoChatGPTSample - -A ChatGPT-style assistant built with [`stream-chat-expo`](../../package/expo-package) and its AI components -(`stream-chat-expo/ai-components`): streamed markdown answers with syntax highlighting, tables and charts, an "AI is -thinking" indicator, and a composer with attachments, a custom actions sheet and speech-to-text. - -Ported from the `ExpoChatGPTSample` of the deprecated `@stream-io/chat-react-native-ai` repository -([`GetStream/ai-components-js`](https://github.com/GetStream/ai-components-js)). - -## How it works - -The agent runs on a [Vision-Agents](https://github.com/GetStream/Vision-Agents) router, which the app talks to through -[`@stream-io/stream-agents`](https://www.npmjs.com/package/@stream-io/stream-agents) (`agent/`). - -- Each conversation is a router session, kept in an `agent` channel. 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 conversation is `AIConversation`, over an `AIConversationController` that `contexts/AppContext.tsx` builds from - the SDK's `AgentChat` and `agentChatTransport` (`@stream-io/stream-agents/chat`). `AgentChat` watches the channel, - loads older pages, keeps each reply's full reasoning from its live updates, and follows what the agent is doing on - the session's socket. The controller offers no edit or regenerate: the router can't rewind a conversation. -- Asking goes through the session, not the chat composer. The router writes the question and the streamed reply into - the channel, the reply's steps as `ai_reasoning` and `ai_tool_call` attachments, and titles the conversation itself. - Photos go as data URIs, and "Think harder" as a high reasoning effort. The phone's tools (`agent/deviceTools.ts`) are - declared on the session, which runs them. -- The keyboard is handled by the AI components through `react-native-keyboard-controller`; the app only wraps itself - in `KeyboardProvider`. The starters and the voice button sit in the composer's `header`, so they ride the keyboard. - -## Running - -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 -yarn workspace expochatgptsample ios # or: android -``` - -`ios` and `android` sync the SDK's shared native code, run `expo prebuild` and build the app. The native `ios` and -`android` folders are generated and not committed. - -On iOS, if the app crashes at launch in `dyld` with a missing symbol, reinstall the pods without Expo's -precompiled modules: `cd ios && EXPO_USE_PRECOMPILED_MODULES=0 pod install`, then build again. diff --git a/examples/ExpoChatGPTSample/agent/UIComponents.json b/examples/ExpoChatGPTSample/agent/UIComponents.json deleted file mode 100644 index 869bd82218..0000000000 --- a/examples/ExpoChatGPTSample/agent/UIComponents.json +++ /dev/null @@ -1,76 +0,0 @@ -[ - { - "name": "place_card", - "description": "One place of a plan, with its photo and a short summary, which the phone loads from Wikipedia, so it needs no image URL. Use one per sight, beach, monastery, village or day trip.", - "parameters": { - "type": "object", - "properties": { - "title": { - "type": "string", - "description": "The place's exact English Wikipedia article title, such as \"Church of St. John at Kaneo\"" - }, - "note": { - "type": "string", - "description": "One short line about it for this plan, such as \"Best at sunset\" or \"2 hours\"" - } - }, - "required": ["title"], - "x-order": ["title", "note"] - }, - "events": [ - { - "name": "ask_about", - "description": "Tapping the card asks you about the place." - } - ] - }, - { - "name": "trip_map", - "description": "A map of a plan with a pin for each place, drawn on the phone. Use one near the top of every trip plan.", - "parameters": { - "type": "object", - "properties": { - "places": { - "type": "array", - "description": "Every place of the plan, with its coordinates", - "items": { - "type": "object", - "properties": { - "name": { "type": "string" }, - "latitude": { "type": "number" }, - "longitude": { "type": "number" } - }, - "required": ["name", "latitude", "longitude"], - "x-order": ["name", "latitude", "longitude"] - } - } - }, - "required": ["places"], - "x-order": ["places"] - }, - "events": [ - { - "name": "select_place", - "description": "Tapping a pin asks you about that place." - } - ] - }, - { - "name": "calendar_check", - "description": "The person's own calendar for the days of a plan, read on the phone. You never see it: use it whenever a plan has dates, so they can see what the trip would clash with.", - "parameters": { - "type": "object", - "properties": { - "start": { - "type": "string", - "format": "date", - "description": "The first day, as YYYY-MM-DD" - }, - "end": { "type": "string", "format": "date", "description": "The last day, as YYYY-MM-DD" } - }, - "required": ["start", "end"], - "x-order": ["start", "end"] - }, - "events": [] - } -] diff --git a/examples/ExpoChatGPTSample/agent/agents.ts b/examples/ExpoChatGPTSample/agent/agents.ts deleted file mode 100644 index 9d557feecb..0000000000 --- a/examples/ExpoChatGPTSample/agent/agents.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Client } from '@stream-io/stream-agents'; -// Lets `session.chat()` open the conversation's channel on the app's Stream Chat client. -import '@stream-io/stream-agents/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 deleted file mode 100644 index 772084b3c6..0000000000 --- a/examples/ExpoChatGPTSample/agent/config.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * 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/conversation.ts b/examples/ExpoChatGPTSample/agent/conversation.ts deleted file mode 100644 index be2bc27293..0000000000 --- a/examples/ExpoChatGPTSample/agent/conversation.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { StateStore } from '@stream-io/state-store'; -import type { AgentChat, AgentChatState } from '@stream-io/stream-agents/chat'; -import type { LocalMessage } from 'stream-chat'; - -/** A written conversation with the agent, over the messages of the channel it is kept in. */ -export type Chat = AgentChat; - -export type ChatState = AgentChatState; - -/** - * The chat's state for `useStateStore`. The SDK's store has the shape of `@stream-io/state-store`'s - * `StateStore` (`getLatestValue` and `subscribeWithSelector`, which is all `useStateStore` calls) - * without depending on it, so the type is only told so here. - */ -export const chatStore = (chat: Chat) => chat.state as unknown as StateStore; - -let keys = 0; -const chatKeys = new WeakMap(); - -/** Tells a conversation apart from the one shown before it, for a React key. */ -export const keyOf = (chat: Chat) => { - let key = chatKeys.get(chat); - if (!key) { - key = `chat-${++keys}`; - chatKeys.set(chat, key); - } - return key; -}; - -/** A reply the agent wrote. */ -export const isAgentReply = (message: LocalMessage) => message.custom?.ai_generated === true; - -/** - * Calls `used` when a new conversation is used, such as asked something: the router lists it from - * then on. A first question that never reached the router has its session deleted, and the next - * one creates another, which is listed in its turn. A conversation carried on is listed already, - * and isn't waited for. Returns a function that stops waiting. - */ -export const whenUsed = (chat: Chat, used: () => void) => { - if (chat.id !== undefined) return () => {}; - let listed: string | undefined; - return chat.state.subscribeWithSelector( - ({ usedSessionId }) => ({ usedSessionId }), - ({ usedSessionId }) => { - if (usedSessionId === undefined || usedSessionId === listed) return; - listed = usedSessionId; - used(); - }, - ); -}; diff --git a/examples/ExpoChatGPTSample/agent/deviceTools.ts b/examples/ExpoChatGPTSample/agent/deviceTools.ts deleted file mode 100644 index 9ade250922..0000000000 --- a/examples/ExpoChatGPTSample/agent/deviceTools.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { Tools } from '@stream-io/stream-agents'; - -import { - type AIClientTool, - type AIClientToolResult, - AIClientToolRunner, - type AIToolCallPart, - completedToolResult, -} from 'stream-chat-expo/ai-components'; - -/** - * What only this phone knows: its date, clock, time zone and language. The same tool, by the same - * name, as the iOS, Android and web assistants, whose instructions call it before a plan with - * dates. - */ -const localContext: AIClientTool = { - definition: { - annotations: { readOnlyHint: true }, - description: - "The person's current date, local time, timezone and language. Call it before answering anything that depends on today or the time.", - inputSchema: { properties: {}, type: 'object' }, - name: 'get_local_context', - }, - run: () => { - const now = new Date(); - const { locale, timeZone } = Intl.DateTimeFormat().resolvedOptions(); - return completedToolResult( - { language: locale, local: now.toString(), timeZone, utc: now.toISOString() }, - 'Shared the local time', - ); - }, -}; - -const DEVICE_TOOLS = [localContext]; - -// What a call says it is doing on the reply's step, by tool. -const DISPLAY_TITLES: Record = { get_local_context: "Checking your phone's time" }; - -/** - * The results the runner reported, by call, for the session's socket to answer with. A call can - * reach the socket before its step shows it waiting for this phone, or after. - */ -class Reports { - private readonly reported = new Map(); - private readonly waiting = new Map void>(); - - report(callId: string, result: AIClientToolResult) { - const waiting = this.waiting.get(callId); - this.waiting.delete(callId); - if (waiting) waiting(result); - else this.reported.set(callId, result); - } - - // A call that was cancelled stops being waited on by the session's tools, which leave its - // result unread. - wait(callId: string) { - return new Promise((resolve) => { - const reported = this.reported.get(callId); - this.reported.delete(callId); - if (reported) return resolve(reported); - this.waiting.set(callId, resolve); - }); - } -} - -/** - * The phone's tools, run by the AI components' `AIClientToolRunner` from each reply's steps, as - * an app whose backend holds the session would run them. - * - * Here the phone holds the session too. The router sends it each call over the session's socket - * and, for a question that named this install, shows the call on the reply as waiting for it. The - * runner runs it from the reply, and the socket answers the router with what the runner reported. - * A call no step addresses to this phone, from a question that couldn't name the install, is run - * by the socket itself. - */ -export const createDeviceTools = ({ clientId, userId }: { clientId: string; userId: string }) => { - const reports = new Reports(); - const runner = new AIClientToolRunner({ clientId, tools: DEVICE_TOOLS, userId }); - - const runUnaddressed = async (name: string, call: AIToolCallPart) => { - const tool = DEVICE_TOOLS.find(({ definition }) => definition.name === name); - try { - return (await tool?.run(call)) ?? { failure: `This phone has no ${name}` }; - } catch { - return { failure: "The tool couldn't run" }; - } - }; - - const deviceTools = { - /** Whether the latest question named this install, so its calls wait for this phone. */ - addressed: false, - runner, - /** Hands the runner's result to the session's socket. */ - send: (callId: string, result: AIClientToolResult) => { - reports.report(callId, result); - return Promise.resolve(); - }, - /** How the router is told of the tools: declared on the session, with the socket's answer. */ - sessionTools: () => { - const tools = new Tools(); - for (const { description, name, parameters } of runner.registrations) { - tools.register({ - description, - displayTitle: DISPLAY_TITLES[name], - executor: 'client', - name, - parameters, - run: async (input, call) => { - const { failure, output } = deviceTools.addressed - ? await reports.wait(call.id) - : await runUnaddressed(name, { - arguments: input, - executor: 'client', - id: call.id, - name, - status: 'running', - }); - if (failure) throw new Error(failure); - return output; - }, - }); - } - return tools; - }, - }; - return deviceTools; -}; diff --git a/examples/ExpoChatGPTSample/agent/photos.ts b/examples/ExpoChatGPTSample/agent/photos.ts deleted file mode 100644 index 7dac92f52e..0000000000 --- a/examples/ExpoChatGPTSample/agent/photos.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; -import type { AIConversationRequest } from 'stream-chat-expo/ai-components'; - -// The router takes photos inline, so they are shrunk first. -const MAX_IMAGE_WIDTH = 1280; - -/** A photo the person picked, as the data URI the router takes. */ -export 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}`; -}; - -/** - * The photos among the files the person picked, as the images the agent's transport sends. - * Anything else they picked isn't sent: the router takes images only. - */ -export const imagesOf = (attachments: readonly unknown[]) => - Promise.all( - (attachments as AIConversationRequest['attachments']) - .filter((asset) => !asset.type || asset.type.startsWith('image')) - .map(async (asset) => ({ url: await toDataUri(asset.uri) })), - ); diff --git a/examples/ExpoChatGPTSample/agent/polyfills.ts b/examples/ExpoChatGPTSample/agent/polyfills.ts deleted file mode 100644 index 988a998f4f..0000000000 --- a/examples/ExpoChatGPTSample/agent/polyfills.ts +++ /dev/null @@ -1,53 +0,0 @@ -// 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.json b/examples/ExpoChatGPTSample/app.json deleted file mode 100644 index 6607bf3c99..0000000000 --- a/examples/ExpoChatGPTSample/app.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "expo": { - "name": "ExpoChatGPTSample", - "slug": "ExpoChatGPTSample", - "scheme": ["expochatgptsample", "io.getstream.expochatgptsample"], - "ios": { - "bundleIdentifier": "io.getstream.expochatgptsample", - "appleTeamId": "EHV7XZLAHA" - }, - "android": { - "permissions": ["android.permission.RECORD_AUDIO"], - "package": "io.getstream.expochatgptsample" - }, - "plugins": [ - "expo-router", - [ - "expo-image-picker", - { - "photosPermission": "The app accesses your photos to let them share with others.", - "cameraPermission": "The app accesses your camera to let you take photos and share with others." - } - ], - [ - "stream-chat-expo", - { - "dictationMicrophoneUsageDescription": "$(PRODUCT_NAME) would like to access your microphone to capture your voice.", - "dictationSpeechRecognitionUsageDescription": "$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice." - } - ] - ] - } -} diff --git a/examples/ExpoChatGPTSample/app/+native-intent.tsx b/examples/ExpoChatGPTSample/app/+native-intent.tsx deleted file mode 100644 index a54ac3b988..0000000000 --- a/examples/ExpoChatGPTSample/app/+native-intent.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// A sign-in opened in a Custom Tab comes back to the app on its scheme (`io.getstream.expochatgptsample:// -// sign-in?…`), which the SDK's sign-in reads itself. Stay on the screen it was opened from rather -// than routing to a page that doesn't exist. -export const redirectSystemPath = ({ path }: { initial: boolean; path: string | null }) => - path?.includes('sign-in') ? null : path; diff --git a/examples/ExpoChatGPTSample/app/_layout.tsx b/examples/ExpoChatGPTSample/app/_layout.tsx deleted file mode 100644 index dc55854fa6..0000000000 --- a/examples/ExpoChatGPTSample/app/_layout.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import '../agent/polyfills'; - -import React from 'react'; - -import { StyleSheet } from 'react-native'; - -import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { KeyboardProvider } from 'react-native-keyboard-controller'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; - -import { Drawer } from 'expo-router/drawer'; - -import type { LocalMessage } from 'stream-chat'; -import { Chat, OverlayProvider, useCreateChatClient } from 'stream-chat-expo'; - -import { StreamTheme } from 'stream-chat-expo/ai-components'; - -import { streamToken } from '../agent/agents'; -import { AgentConfig } from '../agent/config'; -import { AppProvider } from '../contexts/AppContext'; - -import { MenuDrawer } from '../screens/MenuDrawer'; - -const isMessageAIGenerated = (message: LocalMessage) => !!message.custom?.ai_generated; - -export default function Layout() { - const chatClient = useCreateChatClient({ - apiKey: AgentConfig.streamApiKey, - tokenOrProvider: streamToken, - userData: AgentConfig.user, - }); - - if (!chatClient) { - return null; - } - return ( - - - - - - - - - - - - - - - - - - ); -} - -const styles = StyleSheet.create({ - container: { flex: 1 }, -}); diff --git a/examples/ExpoChatGPTSample/app/index.tsx b/examples/ExpoChatGPTSample/app/index.tsx deleted file mode 100644 index 062b54b1a5..0000000000 --- a/examples/ExpoChatGPTSample/app/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ChatContent } from '../screens/ChatContent'; - -const Page = () => { - return ; -}; -export default Page; diff --git a/examples/ExpoChatGPTSample/babel.config.js b/examples/ExpoChatGPTSample/babel.config.js deleted file mode 100644 index 5b84251884..0000000000 --- a/examples/ExpoChatGPTSample/babel.config.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = function (api) { - api.cache(true); - return { - presets: ['babel-preset-expo'], - plugins: [ - // Needed by `stream-chat-expo/ai-components`: its `zod` dependency ships `export * as`. - '@babel/plugin-transform-export-namespace-from', - // react-native-worklets/plugin has to be the last one - 'react-native-worklets/plugin', - ], - }; -}; diff --git a/examples/ExpoChatGPTSample/chatOptions.ts b/examples/ExpoChatGPTSample/chatOptions.ts deleted file mode 100644 index 03943899c8..0000000000 --- a/examples/ExpoChatGPTSample/chatOptions.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { AIChatOption } from 'stream-chat-expo/ai-components'; - -/** Asks with more time to reason. */ -export const THINK_HARDER: AIChatOption = { - description: 'More time to reason before answering', - id: 'think_harder', - shortTitle: 'Think', - title: 'Think harder', -}; - -export const chatOptions = [THINK_HARDER]; - -/** Starters for an empty conversation. */ -export const STARTERS = [ - "What's on my calendar today?", - 'Remind me to call mum at 6pm', - 'Plan a weekend in Lisbon', - 'What should I cook tonight?', -]; diff --git a/examples/ExpoChatGPTSample/components/TripComponents.tsx b/examples/ExpoChatGPTSample/components/TripComponents.tsx deleted file mode 100644 index 1d62b0807a..0000000000 --- a/examples/ExpoChatGPTSample/components/TripComponents.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { useEffect, useState } from 'react'; -import { Image, Pressable, StyleSheet, Text, View } from 'react-native'; - -import { - type AIUIComponent, - type AIUIComponentDefinition, - defineAIUIComponent, - parseAIUIComponentDefinition, -} from 'stream-chat-expo/ai-components'; - -import uiComponents from '../agent/UIComponents.json'; - -// The trip planner's components, as the Swift, Android and web assistants describe them, from the -// same `UIComponents.json` the agent's instructions are written from. -const definitionOf = (name: string): AIUIComponentDefinition => { - const definition = parseAIUIComponentDefinition( - uiComponents.find((component) => component.name === name), - ); - if (!definition) throw new Error(`UIComponents.json has no ${name}`); - return definition; -}; - -type Summary = { extract?: string; thumbnail?: { source: string } }; - -// A place's photo and first lines from Wikipedia, loaded on the phone, so the agent writes no -// image links. -const useWikipediaSummary = (title: string) => { - const [summary, setSummary] = useState(); - useEffect(() => { - const controller = new AbortController(); - fetch( - `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(title.replace(/ /g, '_'))}`, - { signal: controller.signal }, - ) - .then((response) => (response.ok ? response.json() : undefined)) - .then((json: Summary | undefined) => setSummary(json)) - .catch(() => undefined); - return () => controller.abort(); - }, [title]); - return summary; -}; - -const PlaceCard = defineAIUIComponent<{ title: string; note?: string }>( - definitionOf('place_card'), - ({ args, context }) => { - const summary = useWikipediaSummary(args.title); - return ( - - context.emit('ask_about', `Tell me more about ${args.title}`, { title: args.title }) - } - style={({ pressed }) => [styles.card, pressed && styles.pressed]} - > - {summary?.thumbnail ? ( - - ) : ( - - )} - - - {args.title} - - {args.note ? {args.note} : null} - {summary?.extract ? ( - - {summary.extract} - - ) : null} - - - ); - }, -); - -type Place = { latitude: number; longitude: number; name: string }; - -const TripMap = defineAIUIComponent<{ places: Place[] }>( - definitionOf('trip_map'), - ({ args, context }) => ( - - {args.places.map((place, index) => ( - - context.emit('select_place', `Tell me more about ${place.name}`, { name: place.name }) - } - style={({ pressed }) => [styles.pin, pressed && styles.pressed]} - > - {index + 1} - - {place.name} - - - {place.latitude.toFixed(3)}, {place.longitude.toFixed(3)} - - - ))} - - ), -); - -const dayOf = (date: Date) => - date.toLocaleDateString(undefined, { day: 'numeric', month: 'short', weekday: 'short' }); - -const CalendarCheck = defineAIUIComponent<{ end: Date; start: Date }>( - definitionOf('calendar_check'), - ({ args }) => ( - - - {dayOf(args.start)} – {dayOf(args.end)} - - Your calendar stays on this phone. - - ), -); - -/** The app's components the agent can place in a reply's UI. */ -export const tripComponents: AIUIComponent[] = [PlaceCard, TripMap, CalendarCheck]; - -const styles = StyleSheet.create({ - calendar: { - backgroundColor: '#ffffff', - borderColor: '#ececec', - borderRadius: 12, - borderWidth: 1, - gap: 2, - padding: 12, - }, - card: { - alignItems: 'center', - backgroundColor: '#ffffff', - borderColor: '#ececec', - borderRadius: 12, - borderWidth: 1, - flexDirection: 'row', - gap: 12, - padding: 8, - }, - cardText: { flex: 1, gap: 2 }, - coordinates: { color: '#7a7a7a', fontSize: 12 }, - extract: { color: '#4b4b4b', fontSize: 13 }, - map: { backgroundColor: '#eef4ff', borderRadius: 12, gap: 4, padding: 8 }, - note: { color: '#7a7a7a', fontSize: 13 }, - photo: { borderRadius: 8, height: 64, width: 64 }, - photoPlaceholder: { backgroundColor: '#f2f2f2' }, - pin: { alignItems: 'center', flexDirection: 'row', gap: 8, paddingVertical: 6 }, - pinIndex: { - backgroundColor: '#005fff', - borderRadius: 10, - color: '#ffffff', - fontSize: 12, - fontWeight: '700', - height: 20, - lineHeight: 20, - overflow: 'hidden', - textAlign: 'center', - width: 20, - }, - pinName: { flex: 1, fontSize: 15 }, - pressed: { opacity: 0.7 }, - title: { fontSize: 15, fontWeight: '600' }, -}); diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx b/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx deleted file mode 100644 index 935778df95..0000000000 --- a/examples/ExpoChatGPTSample/components/VoiceMode/VoiceMode.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useState } from 'react'; -import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Svg, { Line } from 'react-native-svg'; - -import { - StreamTheme, - useDictationAudioLevel, - useTheme, - type VoiceAgentState, - VoiceGlow, - VoiceWave, -} from 'stream-chat-expo/ai-components'; - -import { useVoiceConversation } from './useVoiceConversation'; - -const BACKDROP = '#0b0b10'; -const COLORS = ['#005fff', '#7b5cff', '#00c2ff']; - -const LABELS: Record = { - connecting: 'Connecting…', - idle: 'Tap the glow to talk', - listening: 'Listening', - speaking: 'Speaking', - thinking: 'Thinking', -}; - -const VoiceModeOverlay = ({ onClose }: { onClose: () => void }) => { - const { top, bottom } = useSafeAreaInsets(); - const { error, listen, phase, userIsTalking } = useVoiceConversation(true); - const micLevel = useDictationAudioLevel(); - - return ( - - - - - ✕ - - - - - - - {error ? 'Couldn’t hear you. Tap the glow to try again.' : LABELS[phase]} - - - - - ); -}; - -/** - * A static waveform icon. The button stays still: a live `VoiceGlow` here would redraw every frame - * for as long as the chat is open. - */ -const VoiceIcon = ({ color, size }: { color: string; size: number }) => ( - - {[ - [3, 8, 14], - [7, 4, 18], - [11, 2, 20], - [15, 5, 17], - [19, 9, 13], - ].map(([x, y1, y2]) => ( - - ))} - -); - -/** - * Opens a full-screen voice conversation with the AI agent: a button after the composer, the size - * of the attach button before it. - */ -export const VoiceMode = () => { - const [open, setOpen] = useState(false); - const { - theme: { composer }, - } = useTheme(); - const size = composer.inputPillHeight; - - return ( - <> - setOpen(true)} - style={({ pressed }) => [ - styles.button, - { borderRadius: size / 2, height: size, width: size }, - pressed && styles.buttonPressed, - ]} - > - - - setOpen(false)} visible={open}> - {open ? setOpen(false)} /> : null} - - - ); -}; - -const styles = StyleSheet.create({ - button: { - alignItems: 'center', - backgroundColor: '#ffffff', - borderColor: '#ececec', - borderWidth: 1, - justifyContent: 'center', - overflow: 'hidden', - }, - buttonPressed: { opacity: 0.8 }, - close: { color: '#ffffff', fontSize: 22 }, - glow: { aspectRatio: 1, width: '100%' }, - glowContainer: { alignSelf: 'stretch', flex: 1, justifyContent: 'center' }, - header: { alignItems: 'flex-end', paddingHorizontal: 20 }, - label: { color: '#c9cbd3', fontSize: 17, marginVertical: 16, textAlign: 'center' }, - overlay: { backgroundColor: BACKDROP, flex: 1 }, - wave: { alignSelf: 'stretch', height: 140 }, -}); diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/index.ts b/examples/ExpoChatGPTSample/components/VoiceMode/index.ts deleted file mode 100644 index abe97f1086..0000000000 --- a/examples/ExpoChatGPTSample/components/VoiceMode/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './VoiceMode'; diff --git a/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts b/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts deleted file mode 100644 index 78d1643c49..0000000000 --- a/examples/ExpoChatGPTSample/components/VoiceMode/useVoiceConversation.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import * as Speech from 'expo-speech'; - -import { useStableCallback } from 'stream-chat-expo'; - -import { - dictationStore, - useDictation, - useDictationState, - useDictationTranscript, - type VoiceAgentState, -} from 'stream-chat-expo/ai-components'; - -import { useAppContext } from '../../contexts/AppContext'; -import { useSendAIMessage } from '../../hooks/useSendAIMessage'; - -/** A turn the agent is never seen working on ends after this long. */ -const TURN_TIMEOUT_MS = 20_000; - -// Marks voice mode's dictation as its own, so the composer underneath ignores it. -const DICTATION_SOURCE = { source: 'voice-mode' }; - -/** Markdown read aloud as plain words. */ -const toSpeech = (text: string) => - text - .replace(/```[\s\S]*?```/g, ' ') - .replace(/!\[[^\]]*]\([^)]*\)/g, ' ') - .replace(/\[([^\]]+)]\([^)]*\)/g, '$1') - .replace(/[*_#>`~|]/g, '') - .replace(/\s+/g, ' ') - .trim(); - -/** - * A hands-free conversation with the AI agent: it listens with dictation, sends what it heard, - * waits for the agent to answer, reads the answer aloud and listens again. - * - * Returns what the agent is doing, for `VoiceGlow`, and whether the user is being listened to, - * for `VoiceWave`. - */ -export const useVoiceConversation = (active: boolean) => { - const [phase, setPhase] = useState('idle'); - const { start, cancel } = useDictation(undefined, DICTATION_SOURCE); - const { state: dictationState, error } = useDictationState(); - const { transcript } = useDictationTranscript(); - const send = useSendAIMessage(); - const { agentState, chat } = useAppContext(); - // The agent answering, from the session's socket. - const isWorking = agentState.kind === 'responding' || agentState.kind === 'working'; - - // The messages already in the channel when the question was sent, so the reply is found by - // identity rather than by comparing the server's timestamps with the device's clock. - const turn = useRef<{ seenIds: Set; sawWorking: boolean } | null>(null); - const timeout = useRef | null>(null); - // An error left over from an earlier turn, ignored until dictation starts again and clears it. - const staleError = useRef(dictationStore.getLatestValue().error); - - const listen = useStableCallback(() => { - if (!active) return; - turn.current = null; - staleError.current = dictationStore.getLatestValue().error; - setPhase('connecting'); - void start(); - }); - - const latestReply = useStableCallback(() => { - const seenIds = turn.current?.seenIds ?? new Set(); - const messages = chat?.state.getLatestValue().messages ?? []; - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (seenIds.has(message.id)) break; - if (message.custom?.ai_generated && message.text) return message.text; - } - return null; - }); - - /** Ends the agent's turn: reads its answer aloud if there is one, then listens again. */ - const finishTurn = useStableCallback(() => { - if (timeout.current) clearTimeout(timeout.current); - timeout.current = null; - const reply = latestReply(); - turn.current = null; - if (!active) return; - if (!reply) { - listen(); - return; - } - setPhase('speaking'); - Speech.speak(toSpeech(reply), { - onDone: listen, - onError: listen, - // Its own audio session on iOS, so the reply is heard whatever dictation left the app's in. - useApplicationAudioSession: false, - }); - }); - - // Starts listening when voice mode opens, and stops everything when it closes. - useEffect(() => { - if (!active) return; - listen(); - return () => { - if (timeout.current) clearTimeout(timeout.current); - timeout.current = null; - turn.current = null; - cancel(); - void Speech.stop(); - setPhase('idle'); - }; - }, [active, cancel, listen]); - - // Follows dictation: listening while it records, and sends what it heard once it stops. - useEffect(() => { - if (!active || turn.current) return; - if (dictationState === 'listening') { - setPhase('listening'); - return; - } - if (dictationState !== 'idle' || (phase !== 'listening' && phase !== 'connecting')) return; - if (error && error !== staleError.current) { - setPhase('idle'); - return; - } - if (phase === 'connecting') return; - const text = transcript.trim(); - if (!text) { - // Nothing was heard; keep listening. - listen(); - return; - } - turn.current = { - sawWorking: false, - seenIds: new Set((chat?.state.getLatestValue().messages ?? []).map((message) => message.id)), - }; - setPhase('thinking'); - void send({ text }); - timeout.current = setTimeout(() => { - if (!turn.current?.sawWorking) finishTurn(); - }, TURN_TIMEOUT_MS); - }, [active, chat, dictationState, error, finishTurn, listen, phase, send, transcript]); - - // Follows the agent: its turn ends once it has been seen working and is done. - useEffect(() => { - const current = turn.current; - if (!active || !current) return; - if (isWorking) { - current.sawWorking = true; - } else if (current.sawWorking) { - finishTurn(); - } - }, [active, isWorking, finishTurn]); - - return { - // Only an error from the current attempt, not one left over while dictation restarts. - error: phase === 'idle' ? error : null, - listen, - phase, - userIsTalking: phase === 'listening', - }; -}; diff --git a/examples/ExpoChatGPTSample/contexts/AppContext.tsx b/examples/ExpoChatGPTSample/contexts/AppContext.tsx deleted file mode 100644 index 2314b61a41..0000000000 --- a/examples/ExpoChatGPTSample/contexts/AppContext.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import type { PropsWithChildren } from 'react'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; - -import { type ConversationSnapshot, type Session } from '@stream-io/stream-agents'; -import { - AgentChat, - agentChatTransport, - type OpenAgentSession, -} from '@stream-io/stream-agents/chat'; -import type { LocalMessage, StreamChat } from 'stream-chat'; -import { useStableCallback, useStateStore } from 'stream-chat-expo'; -import { - AIClientIdentity, - AIConversationController, - type AIToolApprover, - createAuthSessionAuthorizer, - fromStreamMessage, - parseMessageParts, -} from 'stream-chat-expo/ai-components'; - -import { agents, agentSessions, streamToken } from '../agent/agents'; -import { AgentConfig } from '../agent/config'; -import { - type Chat, - type ChatState, - chatStore, - isAgentReply, - whenUsed, -} from '../agent/conversation'; -import { createDeviceTools } from '../agent/deviceTools'; -import { imagesOf } from '../agent/photos'; -import { THINK_HARDER } from '../chatOptions'; - -const IDLE: ConversationSnapshot = { kind: 'idle', skills: [] }; - -// This install, for the tool calls addressed to it. -const clientId = AIClientIdentity.installId(); - -// The tools this phone runs for the agent, such as telling it the local time. -const deviceTools = createDeviceTools({ clientId, userId: AgentConfig.user.id }); - -// Where a sign-in comes back to: the app's own scheme, which the web auth session hands back. -// The router takes only https or a native app's own scheme in reverse domain form (RFC 8252). -const SIGN_IN_SCHEME = 'io.getstream.expochatgptsample'; -const SIGN_IN_RETURN_URL = `${SIGN_IN_SCHEME}://sign-in`; - -// The person's answer to a call that asks first. stream-agents' `decide` answers only a call this -// session was asked on its own socket, and leaves any other alone, so a question shown from the -// channel, as after reopening the conversation or reloading the app, would wait forever. Such a -// call is answered with the frames `decide` sends, which the router matches by the call's id: an -// allowed client tool's call then waits for this phone, whose runner runs it. -const answerApproval = (session: Session, toolCallId: string, allowed: boolean) => { - if (session.approvals.some((request) => request.id === toolCallId)) { - session.decide(toolCallId, { allowed }); - return; - } - const socket = ( - session as unknown as { held(): { send(frame: Record): void } } - ).held(); - socket.send({ allowed, tool_call_id: toolCallId, type: 'tool_approval' }); - if (!allowed) { - socket.send({ - error: 'The person declined this call.', - tool_call_id: toolCallId, - type: 'tool_result', - }); - } -}; - -// Answers go over the session's socket, which opens for them in a conversation that was only -// read so far. Each throws when it can't send, so the card lets the person answer again. -const approverFor = (chat: Chat): AIToolApprover => ({ - authorizer: createAuthSessionAuthorizer({ - callbackScheme: SIGN_IN_SCHEME, - decline: async (call) => (await chat.session()).declineLogin(call.id), - }), - clientId, - decide: async (call, allowed) => answerApproval(await chat.session(), call.id, allowed), - userId: AgentConfig.user.id, -}); - -export type AppContextValue = { - /** What the agent is doing in the open conversation, from the session's socket. */ - agentState: ConversationSnapshot; - /** The open conversation's messages and live reasoning. */ - chat: Chat | undefined; - /** The open conversation's state, and what the person does in it. */ - conversation: AIConversationController | undefined; - /** Bumped when the drawer's list of conversations may have changed. */ - conversationsVersion: number; - /** Why the last conversation couldn't be opened. */ - failure: string | undefined; - /** The open conversation's session id, or `undefined` for a new one that wasn't asked anything. */ - sessionId: string | undefined; - /** - * Carries on a conversation from the drawer, by its session id. Its messages show as soon as its - * channel, `type:id` as the router lists it, is read. - */ - openConversation: (sessionId: string, conversationId?: string) => void; - /** Starts a new conversation. The router creates it with its first question. */ - startConversation: () => void; -}; - -export const AppContext = React.createContext({ - agentState: IDLE, - chat: undefined, - conversation: undefined, - conversationsVersion: 0, - failure: undefined, - openConversation: () => {}, - sessionId: undefined, - startConversation: () => {}, -}); - -const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)); - -// What the provider follows of the messages: the latest reply, whose calls this phone runs, and -// the session, once one is open. -const selector = ({ messages, session }: ChatState) => ({ - lastReply: [...messages].reverse().find(isAgentReply), - session, -}); - -// Carries a conversation on, or creates one, with the phone's tools declared on it. -const openSession: OpenAgentSession = (sessionId) => - sessionId - ? agentSessions().resume(sessionId, { tools: deviceTools.sessionTools() }) - : agentSessions().create({ - modelOverwrites: { llm: AgentConfig.chatModel }, - return_url: SIGN_IN_RETURN_URL, - tools: deviceTools.sessionTools(), - }); - -const newChat = (sessionId?: string): Chat => - new AgentChat({ open: openSession, sessionId }); - -// The conversation's state over the chat. Asking goes through the session, which records the -// question in the channel and answers it there; the router takes photos as data URIs, and -// thinking harder as a high reasoning effort. -const conversationOf = (chat: Chat) => - new AIConversationController({ - // The agents router keeps the conversation in Stream Chat and can't rewind it, so a reply - // can't be written again or a message edited: either would only be asked again as new. - actions: { edit: false, regenerate: false }, - approver: approverFor(chat), - transport: agentChatTransport(chat, { - adapt: (message, reasoningText) => fromStreamMessage(message, { reasoningText }), - images: imagesOf, - reasoningEffort: (option) => (option?.id === THINK_HARDER.id ? 'high' : undefined), - }), - }); - -type Opened = { - chat: Chat; - conversation: AIConversationController; - /** Stops waiting for a new conversation to be used. */ - stopWaiting: () => void; -}; - -export const AppProvider = ({ client, children }: PropsWithChildren<{ client: StreamChat }>) => { - const [opened, setOpened] = useState(); - const [failure, setFailure] = useState(); - const [agentState, setAgentState] = useState(IDLE); - const [conversationsVersion, setConversationsVersion] = useState(0); - const current = useRef(undefined); - const chat = opened?.chat; - - const followed = useStateStore(chat ? chatStore(chat) : undefined, selector); - const session = followed?.session; - - // Runs the calls of the latest reply that wait for this phone, each once. - const lastReply = followed?.lastReply; - useEffect(() => { - if (!lastReply) return; - deviceTools.runner.run(parseMessageParts(lastReply.attachments), (call, result) => - deviceTools.send(call.id, result), - ); - }, [lastReply]); - - // Shows a conversation in place of the one before it, at once, and lets the one before go. - const show = (next: Chat) => { - const previous = current.current; - current.current = { - chat: next, - conversation: conversationOf(next), - // The router lists a new conversation once its first question created it. - stopWaiting: whenUsed(next, () => setConversationsVersion((version) => version + 1)), - }; - setFailure(undefined); - setOpened(current.current); - previous?.stopWaiting(); - previous?.conversation.dispose(); - void previous?.chat.close().catch(() => {}); - }; - - // A new conversation is only shown: the router creates it with its first question, so one - // that is never asked anything isn't left in the drawer. - const startConversation = useStableCallback(() => show(newChat())); - - const openConversation = useStableCallback((sessionId: string, conversationId?: string) => { - if (sessionId === current.current?.chat.id) { - return; - } - const next = newChat(sessionId); - show(next); - // The channel, when the drawer named it: reading it is quick, and carrying the session on, - // which waits for something to need it, would also mark the conversation active. - const [type, ...id] = (conversationId ?? '').split(':'); - const following = conversationId - ? next.follow(client.channel(type || 'agent', id.join(':'))) - : next.session(); - void following.catch((error: unknown) => { - if (current.current?.chat === next) setFailure(messageOf(error)); - }); - }); - - // The open conversation: what the agent is doing, from the session's socket, once one is open. - useEffect(() => { - if (!session) { - setAgentState(IDLE); - return; - } - setAgentState(session.state.get()); - return 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); - }); - }, [session]); - - 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 sessionId = session?.id ?? chat?.id; - const conversation = opened?.conversation; - const contextValue = useMemo( - () => ({ - agentState, - chat, - conversation, - conversationsVersion, - failure, - openConversation, - sessionId, - startConversation, - }), - [ - agentState, - chat, - conversation, - conversationsVersion, - failure, - openConversation, - sessionId, - startConversation, - ], - ); - - return {children}; -}; - -export const useAppContext = () => React.useContext(AppContext); diff --git a/examples/ExpoChatGPTSample/custom-types.d.ts b/examples/ExpoChatGPTSample/custom-types.d.ts deleted file mode 100644 index e8aceb7b9b..0000000000 --- a/examples/ExpoChatGPTSample/custom-types.d.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { - DefaultAttachmentData, - DefaultChannelData, - DefaultEventData, - DefaultMemberData, - DefaultMessageData, - DefaultPollData, - DefaultPollOptionData, - DefaultReactionData, - DefaultThreadData, - DefaultUserData, -} from 'stream-chat-expo'; -import type { AIUIActionJSON, AIUIDocumentJSON } from 'stream-chat-expo/ai-components'; - -declare module 'stream-chat' { - // Module augmentation: the empty interfaces are how the default data types are applied. - /* eslint-disable @typescript-eslint/no-empty-object-type */ - - interface CustomAttachmentData extends DefaultAttachmentData {} - - interface CustomChannelData extends DefaultChannelData {} - - interface CustomEventData extends DefaultEventData {} - - interface CustomMemberData extends DefaultMemberData {} - - interface CustomUserData extends DefaultUserData {} - - interface CustomMessageData extends DefaultMessageData { - ai_generated?: boolean; - /** Set by the router while the agent is still writing a reply. */ - generating?: boolean; - /** The UI the agent wrote for a reply, which the router moves out of its text. */ - ai_ui?: AIUIDocumentJSON; - /** What the person did in a reply's UI, on their message that says so. */ - ai_ui_action?: AIUIActionJSON; - } - - interface CustomPollOptionData extends DefaultPollOptionData {} - - interface CustomPollData extends DefaultPollData {} - - interface CustomReactionData extends DefaultReactionData {} - - interface CustomThreadData extends DefaultThreadData {} -} diff --git a/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts b/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts deleted file mode 100644 index a7ece9afe9..0000000000 --- a/examples/ExpoChatGPTSample/hooks/useSendAIMessage.ts +++ /dev/null @@ -1,20 +0,0 @@ -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]; - -/** - * Asks the agent through the open conversation, as the composer would: the question shows at once, - * and the router writes it and its reply into the conversation's channel. Used by voice mode. - */ -export const useSendAIMessage = () => { - const { conversation } = useAppContext(); - - return useStableCallback((message: AIMessage) => { - conversation?.send(message); - return Promise.resolve(); - }); -}; diff --git a/examples/ExpoChatGPTSample/metro.config.js b/examples/ExpoChatGPTSample/metro.config.js deleted file mode 100644 index 5fa02acffa..0000000000 --- a/examples/ExpoChatGPTSample/metro.config.js +++ /dev/null @@ -1,74 +0,0 @@ -const { getDefaultConfig } = require('@expo/metro-config'); -const { exclusionList, resolveUniqueModule } = require('@rnx-kit/metro-config'); - -/** - * Metro configuration - * https://facebook.github.io/metro/docs/configuration - * - * @type {import('metro-config').MetroConfig} - */ -const config = getDefaultConfig(__dirname); - -const PATH = require('path'); -const packageDirPath = PATH.resolve(__dirname, '../../package'); -const expoPackageDirPath = PATH.resolve(__dirname, '../../package/expo-package'); - -const symlinked = { - 'stream-chat-expo': expoPackageDirPath, - 'stream-chat-react-native-core': packageDirPath, -}; - -// find what all modules need to be unique for the app (mainly react and react-native) -// note: we filter the symlinked modules as they are already unique -// and as they dont follow the workspace pattern the auto-generated path to the module is incorrect -const dependencyPackageNames = Object.keys(require('./package.json').dependencies); - -const uniqueModules = dependencyPackageNames.map((packageName) => { - if (symlinked[packageName]) { - const modulePath = symlinked[packageName]; - const escapedPackageName = PATH.normalize(packageName).replace(/\\/g, '\\\\'); - - // exclude the symlinked package from being resolved from node_modules - // example: .*\/node_modules\/stream-chat-react-native-core\/.* - // the above would avoid native-package to resolve core from its own node_modules - const exclusionRE = new RegExp( - `.*${PATH.sep}node_modules\\${PATH.sep}${escapedPackageName}\\${PATH.sep}.*`, - ); - - return { - packageName, // name of the package - modulePath, - blockPattern: exclusionRE, // paths that match this pattern will be blocked from being resolved - }; - } - const [modulePath, blockPattern] = resolveUniqueModule(packageName, __dirname); - // resolveUniqueModule emits a regex ending in `/` (package-dir boundary). metro-config's - // exclusionList anchors every alternation branch with `$`, which would require file paths to - // literally end with `/node_modules//`. Append `.*` so the anchor lands at end-of-file - // instead of end-of-directory, otherwise the blocklist silently matches nothing. - const fixedBlockPattern = new RegExp(blockPattern.source + '.*'); - return { - packageName, // name of the package - modulePath, // actual path to the module in the project's node modules - blockPattern: fixedBlockPattern, // paths that match this pattern will be blocked from being resolved - }; -}); - -// block the other paths for unique modules from being resolved -const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); - -// provide the path for the unique modules -const extraNodeModules = uniqueModules.reduce((acc, item) => { - acc[item.packageName] = item.modulePath; - return acc; -}, {}); - -config.resolver.blockList = exclusionList(blockList); -config.resolver.extraNodeModules = extraNodeModules; - -config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; - -// add the package dir for metro to access the package folder -config.watchFolders = [packageDirPath]; - -module.exports = config; diff --git a/examples/ExpoChatGPTSample/package.json b/examples/ExpoChatGPTSample/package.json deleted file mode 100644 index b8e5e86fc7..0000000000 --- a/examples/ExpoChatGPTSample/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "expochatgptsample", - "version": "1.0.1", - "main": "expo-router/entry", - "scripts": { - "sync-native": "bash ../../package/scripts/reconcile-shared-native.sh expo-package && bash ../../package/scripts/sync-shared-native.sh expo-package", - "start": "yarn sync-native && expo start --dev-client", - "android": "yarn sync-native && expo run:android", - "ios": "yarn sync-native && expo run:ios", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@react-native-community/netinfo": "12.0.1", - "@shopify/react-native-skia": "^2.14.0", - "@stream-io/state-store": "^1.2.0", - "@stream-io/stream-agents": "^0.4.2", - "expo": "^58.0.8", - "expo-clipboard": "~58.0.3", - "expo-constants": "~58.0.11", - "expo-image-manipulator": "~58.0.14", - "expo-image-picker": "~58.0.10", - "expo-linking": "~58.0.12", - "expo-router": "~58.0.18", - "expo-speech": "~58.0.3", - "react": "19.3.0", - "react-native": "0.88.0", - "react-native-gesture-handler": "~3.4.0", - "react-native-keyboard-controller": "1.22.9", - "react-native-reanimated": "4.7.1", - "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "~4.29.0", - "react-native-svg": "15.15.5", - "react-native-teleport": "^1.2.2", - "react-native-worklets": "0.13.0", - "stream-chat": "^10.0.1", - "stream-chat-expo": "workspace:^", - "stream-chat-react-native-core": "workspace:^", - "use-sync-external-store": "^1.7.0", - "victory-native": "^42.0.1" - }, - "devDependencies": { - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@rnx-kit/metro-config": "^2.1.2", - "@stream-io/typescript-config": "workspace:^", - "@types/react": "~19.3.0", - "typescript": "6.0.3" - }, - "private": true -} diff --git a/examples/ExpoChatGPTSample/screens/ChatContent.tsx b/examples/ExpoChatGPTSample/screens/ChatContent.tsx deleted file mode 100644 index b4620ad8f7..0000000000 --- a/examples/ExpoChatGPTSample/screens/ChatContent.tsx +++ /dev/null @@ -1,212 +0,0 @@ -import { useMemo, useState } from 'react'; -import { - ActivityIndicator, - Platform, - Pressable, - StatusBar, - StyleSheet, - Text, - View, -} from 'react-native'; -import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { useStateStore } from 'stream-chat-expo'; - -import { createA2UIRenderer } from 'stream-chat-expo/a2ui'; -import { - type AIConversationController, - type AIConversationControllerState, - AISuggestions, - AITypingIndicatorView, - aiUIActionJSON, - AIUIHost, - ComposerView, - isAIConversationAnswering, - useAIConversationState, -} from 'stream-chat-expo/ai-components'; -import { AIConversation } from 'stream-chat-expo/ai-components/conversation'; - -import { type Chat, type ChatState, chatStore, keyOf } from '../agent/conversation'; -import { chatOptions, STARTERS } from '../chatOptions'; -import { tripComponents } from '../components/TripComponents'; -import { VoiceMode } from '../components/VoiceMode'; -import { useAppContext } from '../contexts/AppContext'; - -const errorSelector = ({ error }: AIConversationControllerState) => ({ error }); - -/** - * The composer, with what rides the keyboard above it: why something failed, with a way to try - * again, and the starters, which share the composer's draft and picked option. And the way into - * voice mode beside it. - */ -const ComposerArea = ({ - chat, - conversation, - failure, -}: { - chat: Chat; - conversation: AIConversationController; - failure: string | undefined; -}) => { - const { error } = useAIConversationState(conversation, errorSelector); - - const safeAreaInsets = useSafeAreaInsets(); - const insets = useMemo( - () => ({ - ...safeAreaInsets, - bottom: - safeAreaInsets.bottom + - (Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) * 2 : 0), - }), - [safeAreaInsets], - ); - - return ( - - {failure ? {failure} : null} - {error ? ( - conversation.retry()}> - {error.message} - Try again - - ) : null} - - - } - trailing={} - /> - ); -}; - -const EmptyState = () => What can I help with ?; - -// A conversation still loading isn't empty: it shows no starters meanwhile. -const isEmptySelector = ({ isLoaded, messages }: ChatState) => ({ - isEmpty: isLoaded && messages.length === 0, -}); - -const pendingSelector = ({ pendingMessage }: AIConversationControllerState) => ({ - pendingMessage, -}); - -/** - * Starters, above the composer, until the conversation has a first question. One is asked with the - * option picked in the composer, as the composer would ask it. - */ -const Starters = ({ - chat, - conversation, -}: { - chat: Chat; - conversation: AIConversationController; -}) => { - const { isEmpty } = useStateStore(chatStore(chat), isEmptySelector); - const { pendingMessage } = useAIConversationState(conversation, pendingSelector); - if (!isEmpty || pendingMessage) return null; - const onSelect = (text: string) => { - const { chatOption } = conversation.composer.getLatestValue(); - conversation.send({ chatOption: chatOption ?? undefined, text }); - }; - return ; -}; - -const loadedSelector = ({ isLoaded }: ChatState) => ({ isLoaded }); - -/** The open conversation, as its controller has it. */ -const Conversation = ({ - chat, - conversation, -}: { - chat: Chat; - conversation: AIConversationController; -}) => { - const { agentState } = useAppContext(); - const { isLoaded } = useStateStore(chatStore(chat), loadedSelector); - - // The UI the agent writes for its replies, in A2UI, with the trip planner's own components. One - // host per conversation, for as long as it shows, so what a reply's UI holds survives scrolling. - // What the person does in it goes to the agent as their next message. - const [ui] = useState( - () => - new AIUIHost({ - components: tripComponents, - renderers: [createA2UIRenderer()], - send: (action) => { - if (isAIConversationAnswering(conversation.state.getLatestValue().status)) { - throw new Error('The agent is still answering'); - } - const custom = { uiAction: aiUIActionJSON(action) }; - conversation.send({ custom, text: action.label }); - }, - }), - ); - - // What the agent is doing while it runs skills, under the latest turn. - const footer = - agentState.kind === 'working' ? ( - skill.replace(/_/g, ' ')).join(' and ')}`} - /> - ) : undefined; - - return ( - : } - footer={footer} - ui={ui} - /> - ); -}; - -export const ChatContent = () => { - const { chat, conversation, failure } = useAppContext(); - const { bottom } = useSafeAreaInsets(); - - if (!chat || !conversation) { - return ( - - {failure ? {failure} : } - - ); - } - - return ( - - {/* The keyboard is the SDK's: the conversation makes room for it, the composer rides it. */} - - - - ); -}; - -const styles = StyleSheet.create({ - wrapper: { flex: 1, backgroundColor: '#fcfcfc' }, - emptyContainer: { - flex: 1, - width: '100%', - backgroundColor: 'transparent', - justifyContent: 'center', - alignItems: 'center', - }, - emptyText: { fontSize: 24, fontWeight: 'bold' }, - failureText: { color: '#B00020', fontSize: 15, paddingHorizontal: 24, textAlign: 'center' }, - retryText: { - color: '#005FFF', - fontSize: 15, - fontWeight: '600', - paddingTop: 4, - textAlign: 'center', - }, -}); diff --git a/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx b/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx deleted file mode 100644 index 874e6aaf49..0000000000 --- a/examples/ExpoChatGPTSample/screens/MenuDrawer.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; - -import { SafeAreaView } from 'react-native-safe-area-context'; - -import type { Schemas } from '@stream-io/stream-agents'; -import type { DrawerContentComponentProps } from 'expo-router/drawer'; - -import { Plus, useStableCallback } from 'stream-chat-expo'; - -import { agentSessions } from '../agent/agents'; -import { useAppContext } from '../contexts/AppContext'; - -type SessionRow = Schemas['Session']; - -const keyExtractor = (session: SessionRow) => session.id; - -// When the conversation was last answered, or else started. The router lists conversations by when -// they were last touched, which opening one to read it does too, so a conversation looked at would -// jump to the top. When the agent last answered moves only when it is used. -const lastUsed = (session: SessionRow) => - Date.parse(session.last_response_at ?? session.created_at ?? '') || 0; - -const byLastUsed = (sessions: readonly SessionRow[]) => - [...sessions].sort((a, b) => lastUsed(b) - lastUsed(a)); - -/** - * The agent's conversations, most recently used first. Only those kept in writing: a session that - * was only ever a call has no channel to read back. - */ -export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => { - const { conversationsVersion, openConversation, sessionId, startConversation } = useAppContext(); - const [sessions, setSessions] = useState([]); - - useEffect(() => { - let cancelled = false; - agentSessions() - .query({ limit: 50 }) - .then((page) => { - if (!cancelled) setSessions(byLastUsed(page.items.filter((item) => item.conversation_id))); - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, [conversationsVersion]); - - const onSelect = useStableCallback((session: SessionRow) => { - openConversation(session.id, session.conversation_id ?? undefined); - navigation.closeDrawer(); - }); - - const onCreateNewChat = useStableCallback(() => { - startConversation(); - navigation.closeDrawer(); - }); - - const renderItem = ({ item }: { item: SessionRow }) => ( - onSelect(item)} - style={({ pressed }) => [styles.row, { opacity: pressed ? 0.6 : 1 }]} - > - - {item.title || 'New conversation'} - - - ); - - return ( - - - Conversations - - - - - - - ); -}; - -const styles = StyleSheet.create({ - container: { - borderBottomColor: 'grey', - borderBottomWidth: 1, - flexDirection: 'row', - justifyContent: 'space-between', - marginHorizontal: 12, - paddingVertical: 16, - }, - current: { color: '#005FFF' }, - previewText: { fontSize: 15, fontWeight: 'bold' }, - row: { paddingHorizontal: 12, paddingVertical: 8 }, - title: { fontSize: 15, fontWeight: 'bold' }, - wrapper: { backgroundColor: '#FFFFFF', flex: 1 }, -}); diff --git a/examples/ExpoChatGPTSample/tsconfig.json b/examples/ExpoChatGPTSample/tsconfig.json deleted file mode 100644 index af8edb52a3..0000000000 --- a/examples/ExpoChatGPTSample/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": ["expo/tsconfig.base", "@stream-io/typescript-config/base.json"], - "compilerOptions": { - "paths": { - // Same as ExpoMessaging: pins the whole program to this workspace's copy of React Native. - "react-native": ["./node_modules/react-native/types_generated/index.d.ts"], - "react-native/*": ["./node_modules/react-native/*"], - // The same for the state stores: AgentChat's and the SDK's are then one class to TypeScript. - "@stream-io/state-store": ["./node_modules/@stream-io/state-store"] - } - }, - "include": ["**/*.ts", "**/*.tsx"] -} diff --git a/examples/ExpoCrewAssistant/.env.example b/examples/ExpoCrewAssistant/.env.example deleted file mode 100644 index 8edd276f0c..0000000000 --- a/examples/ExpoCrewAssistant/.env.example +++ /dev/null @@ -1,22 +0,0 @@ -# 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 crew backend. - -# The Stream app the agent runs in. -EXPO_PUBLIC_STREAM_API_KEY=YOUR_STREAM_API_KEY - -# The crew backend (crew_assistant's `go run .` in backend/). On an Android emulator, -# `adb reverse tcp:8097 tcp:8097` makes localhost reach it. -EXPO_PUBLIC_CREW_BACKEND_URL=http://localhost:8097 - -# The agent `getstream agents sync` stored, and who this app is. -EXPO_PUBLIC_AGENT_NAME=crew_assistant -EXPO_PUBLIC_AGENT_USER_ID=crew-jordan - -# Who the team lead view signs in as. The crew backend keeps each person's crew role in their Stream -# user's custom data, and only a leader reads the leaders' inbox. -EXPO_PUBLIC_AGENT_LEADER_ID=crew-maya - -# Only for a router of your own rather than Stream's hosted one, such as a local router without keys -# (`adb reverse tcp:8080 tcp:8080` on an emulator): -# EXPO_PUBLIC_AGENT_ROUTER_URL=http://localhost:8080 -# EXPO_PUBLIC_AGENT_CUSTOMER_ID=examples diff --git a/examples/ExpoCrewAssistant/.gitignore b/examples/ExpoCrewAssistant/.gitignore deleted file mode 100644 index 5f1ef56382..0000000000 --- a/examples/ExpoCrewAssistant/.gitignore +++ /dev/null @@ -1,40 +0,0 @@ -# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files - -# dependencies -node_modules/ - -# Expo -.expo/ -ios/ -android/ -dist/ -web-build/ -expo-env.d.ts - -# Native -*.orig.* -*.jks -*.p8 -*.p12 -*.key -*.mobileprovision - -# Metro -.metro-health-check* - -# debug -npm-debug.* -yarn-debug.* -yarn-error.* - -# macOS -.DS_Store -*.pem - -# local env files -.env*.local - -# typescript -*.tsbuildinfo - -app-example diff --git a/examples/ExpoCrewAssistant/README.md b/examples/ExpoCrewAssistant/README.md deleted file mode 100644 index dd967fef63..0000000000 --- a/examples/ExpoCrewAssistant/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# Crew assistant (Expo) - -An AI assistant for a restaurant's team members, built on the AI components and the Vision-Agents -router. A team member opens it before, during or after a shift. The home screen lists what it -helps with, and each flow starts a conversation with the agent: `AIConversation` for the chat, -A2UI for checklists, quizzes and forms, and approval cards for what the agent does in your name. - -The backend is `examples/voice_agents/crew_assistant` in Vision-Agents, forked from -`swift_assistant`. It stands in for the restaurant's schedule, handbook, equipment and leaders' -inbox, hosts their tools on the router, signs each person's Stream token, and makes the agent -speak first when something happens, such as a clock-in. Everything the app asks it carries the -user's Stream token, and the backend acts for that user alone. - -## What it shows - -| Flow | How it starts | What to look at | -| ---------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Clock in** | the agent speaks first | A fit-check checklist and the focus for the shift, as interactive UI. Tick the boxes and tap "I'm ready". | -| **Call out sick** | a question | Schedule and call-out policy as tool steps. With a cold it offers cover, the swap asks you first, and the shift leader gets a note. With a fever it says stay home. | -| **Ask the handbook** | a question | The answer comes from `search_handbook`, and the passages show as **Sources** under the reply. | -| **Equipment problem** | a question, a photo or a scan | The quick fix for the asset and code. "Still broken" drafts a repair ticket, which asks you first. Send a photo with "+" to have it look first. | -| **Back from time off** | the agent speaks first | What you missed since your time off started, grouped. | -| **I'm new here** | the agent speaks first | Who's who, a menu quiz as interactive UI, and booking a check-in with the trainer. | -| **Interview practice** | a question | A mock shift leader interview, one question at a time, with feedback. Try it in voice mode (the button beside the composer). | -| **Clock out** | the agent speaks first | An end-of-shift reflection form. What it saves shows in the team lead view, without your name. | - -**Team lead** (top right) is what the shift leaders and the owner see: call-out notes, swap -requests, drafted tickets, booked check-ins and the team's pulse from reflections. It signs in as -a leader (`EXPO_PUBLIC_AGENT_LEADER_ID`, Maya by default), since the backend answers the leaders' -inbox only to a user whose Stream user has `crew_role: "leader"`. It refreshes every few seconds, -so keep it open next to a flow. - -**Scanning a machine.** A QR code on a machine holds a link such as -`crewassistant://equipment?asset=FRY-02&code=E-41`. Opening it starts a conversation about that -machine. On an emulator, open it with: - -```bash -adb shell am start -a android.intent.action.VIEW -d "crewassistant://equipment?asset=FRY-02\&code=E-41" -``` - -Known assets and codes: `FRY-02` (`E-41`, `E-07`), `POS-1` (`E-NET`), `ICE-1` (`BIN`). - -## Run it - -The agent runs on Stream's hosted Agents. - -1. Sync the agent and start its backend, as Vision-Agents' `crew_assistant` README says: with the - beta Stream CLI (`curl -fsSL https://getstream.io/cli/beta/install.sh | bash`) and your Stream - app's key and secret, from the Vision-Agents root: - - ```bash - export STREAM_API_KEY= STREAM_API_SECRET= - getstream agents sync ./examples/voice_agents/crew_assistant - (cd examples/voice_agents/crew_assistant/configure && GOWORK=off go run . -dir ..) - (cd examples/voice_agents/crew_assistant/backend && GOWORK=off go run .) - ``` - -2. Copy `.env.example` to `.env.local` and set `EXPO_PUBLIC_STREAM_API_KEY` to the same Stream app. - -3. Build and run the app: - - ```bash - yarn install - cd examples/ExpoCrewAssistant - npx expo prebuild - yarn android # or yarn ios - ``` - - On an Android emulator, `adb reverse tcp:8097 tcp:8097` lets the app reach the backend on - `localhost`. To use a router of your own instead of the hosted one, set - `EXPO_PUBLIC_AGENT_ROUTER_URL`, as `.env.example` shows. - -## How it's built - -- `contexts/AppContext.tsx` opens conversations: new ones, ones from the drawer, ones that start - with a question (`startConversationWith`), and ones the agent opens about an event - (`startConversationAbout`), which asks the backend's `POST /events`. -- `agent/crewTools.ts` declares the phone's tools: its clock (`get_local_context`), and - `request_shift_swap`, `create_support_ticket` and `book_check_in`, which ask first with the AI - components' approval card, then call the backend. The router asks for a yes only on a tool the - phone declares, so those stay here; the restaurant's other tools run on the backend. - `DISPLAY_TITLES` titles every tool's step, the backend's too (`agent/conversation.ts`). -- `agent/agents.ts` signs in with the backend and asks it with the user's Stream token - (`crewBackend`). -- The handbook is the agent's knowledge base on the router, filled from `crew_assistant/knowledge/`: - Markdown files and documents such as PDFs. `search_handbook` answers with the passages that - match as `{"status": "answered", "citations": [...]}`, which the router adds to the reply's - `support_message.sources`. `screens/ChatContent.tsx` shows them under the reply. -- `app/leader.tsx` is the team lead view, and `app/equipment.tsx` the scanned-code link. - -**Your calendar.** Booking a check-in ("Book a check-in with Priya on Thursday afternoon when I'm -free") first reads your own Cal.com. The first time, the reply asks you to **Connect Cal.com**, the -same sign-in the AI components show for any connector: Connect opens Cal.com's sign-in, and the -booking carries on once you're connected. Not now books the time you asked for without it. - -**Voice memos.** The red mic beside the composer records a memo with the chat SDK's own recorder -(`AudioRecorderManager` and `AudioRecordingInProgress`). Send uploads it to the conversation's -channel with `channel.sendFile`, then asks the router with `audio: { url }`: the router transcribes -it, answers it and shows it on your message as a voice recording, which plays with the chat SDK's -`AudioAttachment`. At clock-out, a memo is your reflection. The router transcribes recordings with -Deepgram's batch model only, so it needs a `DEEPGRAM_API_KEY` with credit. - -Not here yet: approvals answered by someone other than the person chatting. diff --git a/examples/ExpoCrewAssistant/agent/agents.ts b/examples/ExpoCrewAssistant/agent/agents.ts deleted file mode 100644 index cce11a0f87..0000000000 --- a/examples/ExpoCrewAssistant/agent/agents.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { Client } from '@stream-io/stream-agents'; -// Lets `session.chat()` open the conversation's channel on the app's Stream Chat client. -import '@stream-io/stream-agents/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; - -/** - * Signs in as `userId` with the crew backend, which answers the user's Stream token. The chat, - * the router and the backend itself all take it. It stands in for the restaurant's own sign-in. - */ -export const streamTokenFor = async (userId: string): Promise => { - const response = await fetch(`${AgentConfig.backendUrl}/stream-token`, { - body: JSON.stringify({ user_id: userId }), - 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; -}; - -/** This app's user's Stream token. */ -export const streamToken = () => streamTokenFor(AgentConfig.user.id); - -// A token is used for 50 minutes of its hour, so one never expires on the way. -const TOKEN_REUSED_FOR_MS = 50 * 60 * 1000; -const signedIn = new Map }>(); - -const tokenOf = (userId: string) => { - const held = signedIn.get(userId); - if (held && held.expiresAt > Date.now()) return held.token; - const token = streamTokenFor(userId); - signedIn.set(userId, { expiresAt: Date.now() + TOKEN_REUSED_FOR_MS, token }); - token.catch(() => signedIn.delete(userId)); - return token; -}; - -/** - * Asks the crew backend as `userId`, this app's user unless another is named, with their Stream - * token. It answers the JSON the backend answered. - */ -export const crewBackend = async ( - path: string, - { body, userId = AgentConfig.user.id }: { body?: unknown; userId?: string } = {}, -) => { - const response = await fetch(`${AgentConfig.backendUrl}/${path}`, { - ...(body === undefined ? {} : { body: JSON.stringify(body) }), - headers: { - Authorization: `Bearer ${await tokenOf(userId)}`, - ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), - }, - method: body === undefined ? 'GET' : 'POST', - }); - if (!response.ok) { - throw new Error(`The crew backend answered ${response.status}: ${await response.text()}`); - } - return (await response.json()) as Record; -}; diff --git a/examples/ExpoCrewAssistant/agent/config.ts b/examples/ExpoCrewAssistant/agent/config.ts deleted file mode 100644 index e7f66eed3a..0000000000 --- a/examples/ExpoCrewAssistant/agent/config.ts +++ /dev/null @@ -1,38 +0,0 @@ -/** - * Where the Vision-Agents router and the crew 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 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, - /** A router of your own. Unset, the app talks to Stream's hosted router. */ - routerUrl: process.env.EXPO_PUBLIC_AGENT_ROUTER_URL || undefined, - /** - * The crew backend (`crew_assistant/backend`): it mints this user's Stream token, holds the - * restaurant's schedule, handbook, equipment and leaders' inbox, and makes the agent speak first. - */ - backendUrl: process.env.EXPO_PUBLIC_CREW_BACKEND_URL || 'http://localhost:8097', - /** The customer a local router without keys stores the agent under. Unset on the hosted router. */ - customerId: process.env.EXPO_PUBLIC_AGENT_CUSTOMER_ID || undefined, - /** The agent, by the name in its `agent.yaml`. */ - agentName: process.env.EXPO_PUBLIC_AGENT_NAME || 'crew_assistant', - user: { - id: process.env.EXPO_PUBLIC_AGENT_USER_ID || 'crew-jordan', - name: 'Jordan Lee', - }, - /** Who the team lead view signs in as: a user whose crew role on the backend is leader. */ - leader: { - id: process.env.EXPO_PUBLIC_AGENT_LEADER_ID || 'crew-maya', - name: 'Maya Chen', - }, -} as const; diff --git a/examples/ExpoCrewAssistant/agent/conversation.ts b/examples/ExpoCrewAssistant/agent/conversation.ts deleted file mode 100644 index b398e8aa88..0000000000 --- a/examples/ExpoCrewAssistant/agent/conversation.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { StateStore } from '@stream-io/state-store'; -import type { AgentChat, AgentChatState } from '@stream-io/stream-agents/chat'; -import type { LocalMessage } from 'stream-chat'; -import type { AIMessage, AIMessagePartData } from 'stream-chat-expo/ai-components'; - -import { DISPLAY_TITLES } from './crewTools'; - -/** A written conversation with the agent, over the messages of the channel it is kept in. */ -export type Chat = AgentChat; - -export type ChatState = AgentChatState; - -/** - * The chat's state for `useStateStore`. The SDK's store has the shape of `@stream-io/state-store`'s - * `StateStore` (`getLatestValue` and `subscribeWithSelector`, which is all `useStateStore` calls) - * without depending on it, so the type is only told so here. - */ -export const chatStore = (chat: Chat) => chat.state as unknown as StateStore; - -let keys = 0; -const chatKeys = new WeakMap(); - -/** Tells a conversation apart from the one shown before it, for a React key. */ -export const keyOf = (chat: Chat) => { - let key = chatKeys.get(chat); - if (!key) { - key = `chat-${++keys}`; - chatKeys.set(chat, key); - } - return key; -}; - -/** A reply the agent wrote. */ -export const isAgentReply = (message: LocalMessage) => message.custom?.ai_generated === true; - -/** - * Calls `used` when a new conversation is used, such as asked something: the router lists it from - * then on. A first question that never reached the router has its session deleted, and the next - * one creates another, which is listed in its turn. A conversation carried on is listed already, - * and isn't waited for. Returns a function that stops waiting. - */ -export const whenUsed = (chat: Chat, used: () => void) => { - if (chat.id !== undefined) return () => {}; - let listed: string | undefined; - return chat.state.subscribeWithSelector( - ({ usedSessionId }) => ({ usedSessionId }), - ({ usedSessionId }) => { - if (usedSessionId === undefined || usedSessionId === listed) return; - listed = usedSessionId; - used(); - }, - ); -}; - -/** - * A reply of the agent's that has something to show: the router writes it empty first and fills - * it in, and the conversation hides it until then. - */ -export const showsAgentReply = (message: LocalMessage) => - isAgentReply(message) && - (message.custom?.generating !== true || !!message.text || (message.attachments?.length ?? 0) > 0); - -/** - * Titles the steps of the backend's tools, which the router shows without one, from - * `DISPLAY_TITLES`. - */ -export const withTitles = (message: AIMessage): AIMessage => { - const untitled = (part: AIMessagePartData) => - part.toolCall && !part.toolCall.displayTitle && DISPLAY_TITLES[part.toolCall.name]; - return message.parts?.some(untitled) - ? { - ...message, - parts: message.parts.map((part) => - untitled(part) && part.toolCall - ? { - ...part, - toolCall: { ...part.toolCall, displayTitle: DISPLAY_TITLES[part.toolCall.name] }, - } - : part, - ), - } - : message; -}; diff --git a/examples/ExpoCrewAssistant/agent/crewTools.ts b/examples/ExpoCrewAssistant/agent/crewTools.ts deleted file mode 100644 index d40e67da40..0000000000 --- a/examples/ExpoCrewAssistant/agent/crewTools.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { ToolApproval } from '@stream-io/stream-agents'; - -import { type AIClientTool, completedToolResult } from 'stream-chat-expo/ai-components'; - -import { crewBackend } from './agents'; - -type Schema = Record; - -const object = (properties: Record, required: string[] = []): Schema => ({ - properties, - required, - type: 'object', -}); -const text = (description: string): Schema => ({ description, type: 'string' }); - -const tool = ( - name: string, - description: string, - inputSchema: Schema, - run: (input: Record) => Promise, - summary: string, - readOnly = true, -): AIClientTool => ({ - definition: { - annotations: { readOnlyHint: readOnly }, - description, - inputSchema, - name, - }, - run: async (call) => - completedToolResult(await run((call.arguments ?? {}) as Record), summary), -}); - -const localContext: AIClientTool = { - definition: { - annotations: { readOnlyHint: true }, - description: - "The person's current date, local time, timezone and language. Call it before answering anything that depends on today or the time.", - inputSchema: { properties: {}, type: 'object' }, - name: 'get_local_context', - }, - run: () => { - const now = new Date(); - const { locale, timeZone } = Intl.DateTimeFormat().resolvedOptions(); - return completedToolResult( - { language: locale, local: now.toString(), timeZone, utc: now.toISOString() }, - 'Shared the local time', - ); - }, -}; - -/** - * The phone's tools: its clock, and the three actions the person says yes to here before they - * run. The restaurant's other tools run on the crew backend, which hosts them on the router. - */ -export const CREW_TOOLS: AIClientTool[] = [ - localContext, - tool( - 'request_shift_swap', - 'Asks a teammate to take one of the person’s shifts. The phone asks the person first.', - object( - { - note: text('A short note to the teammate'), - shift_id: text('The shift to give away'), - teammate_id: text('The teammate, from find_coverage'), - }, - ['shift_id', 'teammate_id'], - ), - (body) => crewBackend('crew/swaps', { body }), - 'Asked for a swap', - false, - ), - tool( - 'create_support_ticket', - 'Drafts a repair ticket for the owner to review and submit. The phone asks the person first.', - object( - { - asset: text('The asset id'), - details: text('What they saw and already tried'), - priority: { enum: ['low', 'normal', 'urgent'], type: 'string' }, - summary: text('The problem, in a short line'), - }, - ['summary', 'details'], - ), - (body) => crewBackend('crew/tickets', { body }), - 'Drafted a ticket', - false, - ), - tool( - 'book_check_in', - 'Books a 1-on-1 check-in with a leader or trainer. The phone asks the person first.', - object( - { - topic: text('What it is about'), - when: text('When, such as "Thursday 14:00"'), - with: text('Who, by id or name, from get_team'), - }, - ['with', 'when'], - ), - (body) => crewBackend('crew/check-ins', { body }), - 'Booked a check-in', - false, - ), -]; - -/** - * What a call says it is doing on the reply's step, by tool: the phone's own tools declare theirs, - * and the conversation gives the backend's theirs. - */ -export const DISPLAY_TITLES: Record = { - book_check_in: 'Booking a check-in', - calcom__call_tool: 'Using your Cal.com', - calcom__list_tools: 'Opening your Cal.com', - check_callout: 'Checking the call-out policy', - create_support_ticket: 'Drafting a repair ticket', - find_coverage: 'Finding who can cover', - get_equipment_guide: 'Looking up the quick fix', - get_local_context: "Checking your phone's time", - get_menu: 'Reading the menu', - get_my_schedule: 'Reading your schedule', - get_team: 'Looking up the team', - get_updates_since: 'Reading the announcements', - notify_shift_leader: 'Telling the shift leader', - request_shift_swap: 'Asking for a swap', - save_shift_reflection: 'Saving your reflection', - search_handbook: 'Searching the handbook', -}; - -/** What the person is asked before a call runs, by tool. */ -export const APPROVALS: Record = { - book_check_in: { allowTitle: 'Book it', title: 'Book this check-in?' }, - create_support_ticket: { - allowTitle: 'Send for review', - message: 'The owner reviews it before it goes to repairs.', - title: 'Draft this repair ticket?', - }, - request_shift_swap: { - allowTitle: 'Send request', - message: 'Your teammate and the shift leader see it.', - title: 'Ask a teammate to take your shift?', - }, -}; diff --git a/examples/ExpoCrewAssistant/agent/deviceTools.ts b/examples/ExpoCrewAssistant/agent/deviceTools.ts deleted file mode 100644 index 25cb559e96..0000000000 --- a/examples/ExpoCrewAssistant/agent/deviceTools.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Tools } from '@stream-io/stream-agents'; - -import { - type AIClientToolResult, - AIClientToolRunner, - type AIToolCallPart, -} from 'stream-chat-expo/ai-components'; - -import { APPROVALS, CREW_TOOLS, DISPLAY_TITLES } from './crewTools'; - -const DEVICE_TOOLS = CREW_TOOLS; - -// A call that asks first waits for the person, which takes longer than a call should. -const APPROVAL_TIMEOUT_MS = 5 * 60 * 1000; - -/** - * The results the runner reported, by call, for the session's socket to answer with. A call can - * reach the socket before its step shows it waiting for this phone, or after. - */ -class Reports { - private readonly reported = new Map(); - private readonly waiting = new Map void>(); - - report(callId: string, result: AIClientToolResult) { - const waiting = this.waiting.get(callId); - this.waiting.delete(callId); - if (waiting) waiting(result); - else this.reported.set(callId, result); - } - - // A call that was cancelled stops being waited on by the session's tools, which leave its - // result unread. - wait(callId: string) { - return new Promise((resolve) => { - const reported = this.reported.get(callId); - this.reported.delete(callId); - if (reported) return resolve(reported); - this.waiting.set(callId, resolve); - }); - } -} - -/** - * The phone's tools, run by the AI components' `AIClientToolRunner` from each reply's steps, as - * an app whose backend holds the session would run them. - * - * Here the phone holds the session too. The router sends it each call over the session's socket - * and, for a question that named this install, shows the call on the reply as waiting for it. The - * runner runs it from the reply, and the socket answers the router with what the runner reported. - * A call no step addresses to this phone, from a question that couldn't name the install, is run - * by the socket itself. - */ -export const createDeviceTools = ({ clientId, userId }: { clientId: string; userId: string }) => { - const reports = new Reports(); - const runner = new AIClientToolRunner({ clientId, tools: DEVICE_TOOLS, userId }); - - const runUnaddressed = async (name: string, call: AIToolCallPart) => { - const tool = DEVICE_TOOLS.find(({ definition }) => definition.name === name); - try { - return (await tool?.run(call)) ?? { failure: `This phone has no ${name}` }; - } catch { - return { failure: "The tool couldn't run" }; - } - }; - - const deviceTools = { - /** Whether the latest question named this install, so its calls wait for this phone. */ - addressed: false, - runner, - /** Hands the runner's result to the session's socket. */ - send: (callId: string, result: AIClientToolResult) => { - reports.report(callId, result); - return Promise.resolve(); - }, - /** How the router is told of the tools: declared on the session, with the socket's answer. */ - sessionTools: () => { - const tools = new Tools(); - for (const { description, name, parameters } of runner.registrations) { - tools.register({ - approval: APPROVALS[name], - ...(APPROVALS[name] ? { timeoutMs: APPROVAL_TIMEOUT_MS } : {}), - description, - displayTitle: DISPLAY_TITLES[name], - executor: 'client', - name, - parameters, - run: async (input, call) => { - const { failure, output } = deviceTools.addressed - ? await reports.wait(call.id) - : await runUnaddressed(name, { - arguments: input, - executor: 'client', - id: call.id, - name, - status: 'running', - }); - if (failure) throw new Error(failure); - return output; - }, - }); - } - return tools; - }, - }; - return deviceTools; -}; diff --git a/examples/ExpoCrewAssistant/agent/photos.ts b/examples/ExpoCrewAssistant/agent/photos.ts deleted file mode 100644 index 7dac92f52e..0000000000 --- a/examples/ExpoCrewAssistant/agent/photos.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; -import type { AIConversationRequest } from 'stream-chat-expo/ai-components'; - -// The router takes photos inline, so they are shrunk first. -const MAX_IMAGE_WIDTH = 1280; - -/** A photo the person picked, as the data URI the router takes. */ -export 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}`; -}; - -/** - * The photos among the files the person picked, as the images the agent's transport sends. - * Anything else they picked isn't sent: the router takes images only. - */ -export const imagesOf = (attachments: readonly unknown[]) => - Promise.all( - (attachments as AIConversationRequest['attachments']) - .filter((asset) => !asset.type || asset.type.startsWith('image')) - .map(async (asset) => ({ url: await toDataUri(asset.uri) })), - ); diff --git a/examples/ExpoCrewAssistant/agent/polyfills.ts b/examples/ExpoCrewAssistant/agent/polyfills.ts deleted file mode 100644 index 988a998f4f..0000000000 --- a/examples/ExpoCrewAssistant/agent/polyfills.ts +++ /dev/null @@ -1,53 +0,0 @@ -// 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/ExpoCrewAssistant/app.json b/examples/ExpoCrewAssistant/app.json deleted file mode 100644 index 9891a3cb72..0000000000 --- a/examples/ExpoCrewAssistant/app.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "expo": { - "name": "Crew", - "slug": "ExpoCrewAssistant", - "scheme": ["crewassistant", "io.getstream.expocrewassistant"], - "ios": { - "bundleIdentifier": "io.getstream.expocrewassistant", - "appleTeamId": "EHV7XZLAHA" - }, - "android": { - "permissions": ["android.permission.RECORD_AUDIO"], - "package": "io.getstream.expocrewassistant" - }, - "plugins": [ - "expo-router", - [ - "expo-audio", - { - "microphonePermission": "$(PRODUCT_NAME) records voice memos you send to Crew." - } - ], - [ - "expo-image-picker", - { - "photosPermission": "The app accesses your photos to let them share with others.", - "cameraPermission": "The app accesses your camera to let you take photos and share with others." - } - ], - [ - "stream-chat-expo", - { - "dictationMicrophoneUsageDescription": "$(PRODUCT_NAME) would like to access your microphone to capture your voice.", - "dictationSpeechRecognitionUsageDescription": "$(PRODUCT_NAME) would like to access speech recognition to transcribe your voice." - } - ] - ] - } -} diff --git a/examples/ExpoCrewAssistant/app/+native-intent.tsx b/examples/ExpoCrewAssistant/app/+native-intent.tsx deleted file mode 100644 index 8f36edd902..0000000000 --- a/examples/ExpoCrewAssistant/app/+native-intent.tsx +++ /dev/null @@ -1,5 +0,0 @@ -// A sign-in opened in a Custom Tab comes back to the app on its scheme (`io.getstream.expocrewassistant:// -// sign-in?…`), which the SDK's sign-in reads itself. Stay on the screen it was opened from rather -// than routing to a page that doesn't exist. -export const redirectSystemPath = ({ path }: { initial: boolean; path: string | null }) => - path?.includes('sign-in') ? null : path; diff --git a/examples/ExpoCrewAssistant/app/_layout.tsx b/examples/ExpoCrewAssistant/app/_layout.tsx deleted file mode 100644 index dd0c47aa8a..0000000000 --- a/examples/ExpoCrewAssistant/app/_layout.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import '../agent/polyfills'; - -import React from 'react'; - -import { LogBox, Pressable, StyleSheet, Text } from 'react-native'; - -import { GestureHandlerRootView } from 'react-native-gesture-handler'; -import { KeyboardProvider } from 'react-native-keyboard-controller'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; - -import { router } from 'expo-router'; -import { Drawer } from 'expo-router/drawer'; - -import type { LocalMessage } from 'stream-chat'; -import { Chat, OverlayProvider, useCreateChatClient } from 'stream-chat-expo'; - -import { StreamTheme } from 'stream-chat-expo/ai-components'; - -import { streamToken } from '../agent/agents'; -import { AgentConfig } from '../agent/config'; -import { AppProvider } from '../contexts/AppContext'; - -import { MenuDrawer } from '../screens/MenuDrawer'; - -LogBox.ignoreAllLogs(); - -const isMessageAIGenerated = (message: LocalMessage) => !!message.custom?.ai_generated; - -export default function Layout() { - const chatClient = useCreateChatClient({ - apiKey: AgentConfig.streamApiKey, - tokenOrProvider: streamToken, - userData: AgentConfig.user, - }); - - if (!chatClient) { - return null; - } - return ( - - - - - - - - - - - - - - - - - - - - ); -} - -const TeamLeadButton = () => ( - router.push('/leader')} - style={styles.teamLead} - testID='team-lead-view' - > - Team lead - -); - -const styles = StyleSheet.create({ - container: { flex: 1 }, - teamLead: { paddingHorizontal: 16, paddingVertical: 8 }, - teamLeadText: { color: '#C8102E', fontSize: 15, fontWeight: '700' }, -}); diff --git a/examples/ExpoCrewAssistant/app/equipment.tsx b/examples/ExpoCrewAssistant/app/equipment.tsx deleted file mode 100644 index 2e9e0db93c..0000000000 --- a/examples/ExpoCrewAssistant/app/equipment.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useEffect, useRef } from 'react'; -import { ActivityIndicator, StyleSheet, View } from 'react-native'; - -import { useLocalSearchParams, useRouter } from 'expo-router'; - -import { useAppContext } from '../contexts/AppContext'; - -/** - * Where a scanned equipment code leads: a QR code on a machine holds - * `crewassistant://equipment?asset=FRY-02&code=E-41`, which opens the app here. It starts a new - * conversation about that machine and its error, and goes back to the chat. - */ -export default function Equipment() { - const { asset, code } = useLocalSearchParams<{ asset?: string; code?: string }>(); - const { isReady, startConversationWith } = useAppContext(); - const router = useRouter(); - const started = useRef(false); - - useEffect(() => { - if (!isReady || started.current) return; - started.current = true; - const showing = code ? ` It shows error ${code}.` : ''; - startConversationWith(`I scanned the code on ${asset ?? 'a machine'}.${showing}`); - router.replace('/'); - }, [asset, code, isReady, router, startConversationWith]); - - return ( - - - - ); -} - -const styles = StyleSheet.create({ - container: { alignItems: 'center', flex: 1, justifyContent: 'center' }, -}); diff --git a/examples/ExpoCrewAssistant/app/index.tsx b/examples/ExpoCrewAssistant/app/index.tsx deleted file mode 100644 index 062b54b1a5..0000000000 --- a/examples/ExpoCrewAssistant/app/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ChatContent } from '../screens/ChatContent'; - -const Page = () => { - return ; -}; -export default Page; diff --git a/examples/ExpoCrewAssistant/app/leader.tsx b/examples/ExpoCrewAssistant/app/leader.tsx deleted file mode 100644 index 5e7db80a80..0000000000 --- a/examples/ExpoCrewAssistant/app/leader.tsx +++ /dev/null @@ -1,151 +0,0 @@ -import React, { useCallback, useEffect, useState } from 'react'; -import { RefreshControl, ScrollView, StyleSheet, Text, View } from 'react-native'; - -import { crewBackend } from '../agent/agents'; -import { AgentConfig } from '../agent/config'; - -type InboxItem = { - id: string; - kind: 'alert' | 'swap' | 'ticket' | 'check_in'; - from: string; - title: string; - details: string; - status: string; - at: string; -}; - -type Pulse = { - reflections: number; - average_energy?: number; - themes: Record; - wins: string[]; -}; - -const LABELS: Record = { - alert: '🚨 Call-out', - check_in: '📅 Check-in', - swap: '🔁 Shift swap', - ticket: '🛠️ Repair ticket', -}; - -/** - * What the shift leaders and the owner see of what the assistant did: call-out alerts, swap - * requests, drafted repair tickets and booked check-ins, and the team's pulse from end-of-shift - * reflections, without names. It signs in as a leader, since the crew backend answers the - * leaders' inbox only to a user whose crew role is leader. - */ -export default function LeaderView() { - const [inbox, setInbox] = useState([]); - const [pulse, setPulse] = useState(); - const [failure, setFailure] = useState(); - const [refreshing, setRefreshing] = useState(false); - - const load = useCallback(async () => { - try { - const read = (await crewBackend('crew/leader', { userId: AgentConfig.leader.id })) as { - inbox: InboxItem[]; - pulse: Pulse; - }; - setInbox(read.inbox); - setPulse(read.pulse); - setFailure(undefined); - } catch (error) { - setFailure(error instanceof Error ? error.message : String(error)); - } - }, []); - - useEffect(() => { - void load(); - const timer = setInterval(() => void load(), 3000); - return () => clearInterval(timer); - }, [load]); - - const refresh = async () => { - setRefreshing(true); - await load(); - setRefreshing(false); - }; - - const themes = Object.entries(pulse?.themes ?? {}).sort(([, a], [, b]) => b - a); - - return ( - } - > - Signed in as {AgentConfig.leader.name}, shift leader - {failure ? {failure} : null} - Inbox - {inbox.length === 0 ? ( - Nothing yet. Call-outs, swaps and tickets show up here. - ) : ( - inbox.map((item) => ( - - {LABELS[item.kind] ?? item.kind} - {item.title} - {item.details ? {item.details} : null} - - {item.from} · {new Date(item.at).toLocaleTimeString([], { timeStyle: 'short' })} ·{' '} - {item.status} - - - )) - )} - - Team pulse - {!pulse || pulse.reflections === 0 ? ( - No end-of-shift reflections yet. - ) : ( - - - {pulse.reflections} reflection{pulse.reflections === 1 ? '' : 's'} · energy{' '} - {pulse.average_energy?.toFixed(1)}/5 - - - {themes.map(([theme, count]) => ( - - {theme} {count > 1 ? `×${count}` : ''} - - ))} - - {pulse.wins.map((win) => ( - - “{win}” - - ))} - Shared without names. - - )} - - ); -} - -const styles = StyleSheet.create({ - card: { - backgroundColor: '#FFFFFF', - borderColor: '#E6E8EC', - borderRadius: 14, - borderWidth: 1, - gap: 4, - marginBottom: 10, - padding: 14, - }, - container: { backgroundColor: '#F7F8FA', flexGrow: 1, padding: 16 }, - details: { color: '#33394A', fontSize: 14 }, - empty: { color: '#5C6370', fontSize: 14, marginBottom: 12 }, - failure: { color: '#C8102E', fontSize: 13, marginTop: 6 }, - heading: { color: '#0B1220', fontSize: 20, fontWeight: '800', marginBottom: 10, marginTop: 8 }, - kind: { color: '#C8102E', fontSize: 12, fontWeight: '700' }, - meta: { color: '#8A919E', fontSize: 12, marginTop: 4 }, - theme: { - backgroundColor: '#FDECEE', - borderRadius: 10, - color: '#C8102E', - fontSize: 13, - overflow: 'hidden', - paddingHorizontal: 8, - paddingVertical: 3, - }, - themes: { flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginVertical: 4 }, - title: { color: '#0B1220', fontSize: 15, fontWeight: '700' }, -}); diff --git a/examples/ExpoCrewAssistant/babel.config.js b/examples/ExpoCrewAssistant/babel.config.js deleted file mode 100644 index 5b84251884..0000000000 --- a/examples/ExpoCrewAssistant/babel.config.js +++ /dev/null @@ -1,12 +0,0 @@ -module.exports = function (api) { - api.cache(true); - return { - presets: ['babel-preset-expo'], - plugins: [ - // Needed by `stream-chat-expo/ai-components`: its `zod` dependency ships `export * as`. - '@babel/plugin-transform-export-namespace-from', - // react-native-worklets/plugin has to be the last one - 'react-native-worklets/plugin', - ], - }; -}; diff --git a/examples/ExpoCrewAssistant/components/VoiceMode/VoiceMode.tsx b/examples/ExpoCrewAssistant/components/VoiceMode/VoiceMode.tsx deleted file mode 100644 index 935778df95..0000000000 --- a/examples/ExpoCrewAssistant/components/VoiceMode/VoiceMode.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useState } from 'react'; -import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import Svg, { Line } from 'react-native-svg'; - -import { - StreamTheme, - useDictationAudioLevel, - useTheme, - type VoiceAgentState, - VoiceGlow, - VoiceWave, -} from 'stream-chat-expo/ai-components'; - -import { useVoiceConversation } from './useVoiceConversation'; - -const BACKDROP = '#0b0b10'; -const COLORS = ['#005fff', '#7b5cff', '#00c2ff']; - -const LABELS: Record = { - connecting: 'Connecting…', - idle: 'Tap the glow to talk', - listening: 'Listening', - speaking: 'Speaking', - thinking: 'Thinking', -}; - -const VoiceModeOverlay = ({ onClose }: { onClose: () => void }) => { - const { top, bottom } = useSafeAreaInsets(); - const { error, listen, phase, userIsTalking } = useVoiceConversation(true); - const micLevel = useDictationAudioLevel(); - - return ( - - - - - ✕ - - - - - - - {error ? 'Couldn’t hear you. Tap the glow to try again.' : LABELS[phase]} - - - - - ); -}; - -/** - * A static waveform icon. The button stays still: a live `VoiceGlow` here would redraw every frame - * for as long as the chat is open. - */ -const VoiceIcon = ({ color, size }: { color: string; size: number }) => ( - - {[ - [3, 8, 14], - [7, 4, 18], - [11, 2, 20], - [15, 5, 17], - [19, 9, 13], - ].map(([x, y1, y2]) => ( - - ))} - -); - -/** - * Opens a full-screen voice conversation with the AI agent: a button after the composer, the size - * of the attach button before it. - */ -export const VoiceMode = () => { - const [open, setOpen] = useState(false); - const { - theme: { composer }, - } = useTheme(); - const size = composer.inputPillHeight; - - return ( - <> - setOpen(true)} - style={({ pressed }) => [ - styles.button, - { borderRadius: size / 2, height: size, width: size }, - pressed && styles.buttonPressed, - ]} - > - - - setOpen(false)} visible={open}> - {open ? setOpen(false)} /> : null} - - - ); -}; - -const styles = StyleSheet.create({ - button: { - alignItems: 'center', - backgroundColor: '#ffffff', - borderColor: '#ececec', - borderWidth: 1, - justifyContent: 'center', - overflow: 'hidden', - }, - buttonPressed: { opacity: 0.8 }, - close: { color: '#ffffff', fontSize: 22 }, - glow: { aspectRatio: 1, width: '100%' }, - glowContainer: { alignSelf: 'stretch', flex: 1, justifyContent: 'center' }, - header: { alignItems: 'flex-end', paddingHorizontal: 20 }, - label: { color: '#c9cbd3', fontSize: 17, marginVertical: 16, textAlign: 'center' }, - overlay: { backgroundColor: BACKDROP, flex: 1 }, - wave: { alignSelf: 'stretch', height: 140 }, -}); diff --git a/examples/ExpoCrewAssistant/components/VoiceMode/index.ts b/examples/ExpoCrewAssistant/components/VoiceMode/index.ts deleted file mode 100644 index abe97f1086..0000000000 --- a/examples/ExpoCrewAssistant/components/VoiceMode/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './VoiceMode'; diff --git a/examples/ExpoCrewAssistant/components/VoiceMode/useVoiceConversation.ts b/examples/ExpoCrewAssistant/components/VoiceMode/useVoiceConversation.ts deleted file mode 100644 index 78d1643c49..0000000000 --- a/examples/ExpoCrewAssistant/components/VoiceMode/useVoiceConversation.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; - -import * as Speech from 'expo-speech'; - -import { useStableCallback } from 'stream-chat-expo'; - -import { - dictationStore, - useDictation, - useDictationState, - useDictationTranscript, - type VoiceAgentState, -} from 'stream-chat-expo/ai-components'; - -import { useAppContext } from '../../contexts/AppContext'; -import { useSendAIMessage } from '../../hooks/useSendAIMessage'; - -/** A turn the agent is never seen working on ends after this long. */ -const TURN_TIMEOUT_MS = 20_000; - -// Marks voice mode's dictation as its own, so the composer underneath ignores it. -const DICTATION_SOURCE = { source: 'voice-mode' }; - -/** Markdown read aloud as plain words. */ -const toSpeech = (text: string) => - text - .replace(/```[\s\S]*?```/g, ' ') - .replace(/!\[[^\]]*]\([^)]*\)/g, ' ') - .replace(/\[([^\]]+)]\([^)]*\)/g, '$1') - .replace(/[*_#>`~|]/g, '') - .replace(/\s+/g, ' ') - .trim(); - -/** - * A hands-free conversation with the AI agent: it listens with dictation, sends what it heard, - * waits for the agent to answer, reads the answer aloud and listens again. - * - * Returns what the agent is doing, for `VoiceGlow`, and whether the user is being listened to, - * for `VoiceWave`. - */ -export const useVoiceConversation = (active: boolean) => { - const [phase, setPhase] = useState('idle'); - const { start, cancel } = useDictation(undefined, DICTATION_SOURCE); - const { state: dictationState, error } = useDictationState(); - const { transcript } = useDictationTranscript(); - const send = useSendAIMessage(); - const { agentState, chat } = useAppContext(); - // The agent answering, from the session's socket. - const isWorking = agentState.kind === 'responding' || agentState.kind === 'working'; - - // The messages already in the channel when the question was sent, so the reply is found by - // identity rather than by comparing the server's timestamps with the device's clock. - const turn = useRef<{ seenIds: Set; sawWorking: boolean } | null>(null); - const timeout = useRef | null>(null); - // An error left over from an earlier turn, ignored until dictation starts again and clears it. - const staleError = useRef(dictationStore.getLatestValue().error); - - const listen = useStableCallback(() => { - if (!active) return; - turn.current = null; - staleError.current = dictationStore.getLatestValue().error; - setPhase('connecting'); - void start(); - }); - - const latestReply = useStableCallback(() => { - const seenIds = turn.current?.seenIds ?? new Set(); - const messages = chat?.state.getLatestValue().messages ?? []; - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i]; - if (seenIds.has(message.id)) break; - if (message.custom?.ai_generated && message.text) return message.text; - } - return null; - }); - - /** Ends the agent's turn: reads its answer aloud if there is one, then listens again. */ - const finishTurn = useStableCallback(() => { - if (timeout.current) clearTimeout(timeout.current); - timeout.current = null; - const reply = latestReply(); - turn.current = null; - if (!active) return; - if (!reply) { - listen(); - return; - } - setPhase('speaking'); - Speech.speak(toSpeech(reply), { - onDone: listen, - onError: listen, - // Its own audio session on iOS, so the reply is heard whatever dictation left the app's in. - useApplicationAudioSession: false, - }); - }); - - // Starts listening when voice mode opens, and stops everything when it closes. - useEffect(() => { - if (!active) return; - listen(); - return () => { - if (timeout.current) clearTimeout(timeout.current); - timeout.current = null; - turn.current = null; - cancel(); - void Speech.stop(); - setPhase('idle'); - }; - }, [active, cancel, listen]); - - // Follows dictation: listening while it records, and sends what it heard once it stops. - useEffect(() => { - if (!active || turn.current) return; - if (dictationState === 'listening') { - setPhase('listening'); - return; - } - if (dictationState !== 'idle' || (phase !== 'listening' && phase !== 'connecting')) return; - if (error && error !== staleError.current) { - setPhase('idle'); - return; - } - if (phase === 'connecting') return; - const text = transcript.trim(); - if (!text) { - // Nothing was heard; keep listening. - listen(); - return; - } - turn.current = { - sawWorking: false, - seenIds: new Set((chat?.state.getLatestValue().messages ?? []).map((message) => message.id)), - }; - setPhase('thinking'); - void send({ text }); - timeout.current = setTimeout(() => { - if (!turn.current?.sawWorking) finishTurn(); - }, TURN_TIMEOUT_MS); - }, [active, chat, dictationState, error, finishTurn, listen, phase, send, transcript]); - - // Follows the agent: its turn ends once it has been seen working and is done. - useEffect(() => { - const current = turn.current; - if (!active || !current) return; - if (isWorking) { - current.sawWorking = true; - } else if (current.sawWorking) { - finishTurn(); - } - }, [active, isWorking, finishTurn]); - - return { - // Only an error from the current attempt, not one left over while dictation restarts. - error: phase === 'idle' ? error : null, - listen, - phase, - userIsTalking: phase === 'listening', - }; -}; diff --git a/examples/ExpoCrewAssistant/contexts/AppContext.tsx b/examples/ExpoCrewAssistant/contexts/AppContext.tsx deleted file mode 100644 index 20e91b2d7d..0000000000 --- a/examples/ExpoCrewAssistant/contexts/AppContext.tsx +++ /dev/null @@ -1,456 +0,0 @@ -import type { PropsWithChildren } from 'react'; -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { AppState } from 'react-native'; - -import { type ConversationSnapshot, type Session } from '@stream-io/stream-agents'; -import { - AgentChat, - agentChatTransport, - type OpenAgentSession, -} from '@stream-io/stream-agents/chat'; -import type { Channel, LocalMessage, StreamChat } from 'stream-chat'; -import { useStableCallback, useStateStore } from 'stream-chat-expo'; -import { - AIClientIdentity, - AIConversationController, - type AIToolApprover, - createAuthSessionAuthorizer, - fromStreamMessage, - parseMessageParts, -} from 'stream-chat-expo/ai-components'; - -import { agents, agentSessions, crewBackend, streamToken } from '../agent/agents'; -import { AgentConfig } from '../agent/config'; -import { - type Chat, - type ChatState, - chatStore, - isAgentReply, - showsAgentReply, - whenUsed, - withTitles, -} from '../agent/conversation'; -import { createDeviceTools } from '../agent/deviceTools'; -import { imagesOf } from '../agent/photos'; - -const IDLE: ConversationSnapshot = { kind: 'idle', skills: [] }; - -// This install, for the tool calls addressed to it. -const clientId = AIClientIdentity.installId(); - -// The tools this phone runs for the agent, such as telling it the local time. -const deviceTools = createDeviceTools({ clientId, userId: AgentConfig.user.id }); - -// Where a sign-in comes back to: the app's own scheme, which the web auth session hands back. -// The router takes only https or a native app's own scheme in reverse domain form (RFC 8252). -const SIGN_IN_SCHEME = 'io.getstream.expocrewassistant'; -const SIGN_IN_RETURN_URL = `${SIGN_IN_SCHEME}://sign-in`; - -// The person's answer to a call that asks first. stream-agents' `decide` answers only a call this -// session was asked on its own socket, and leaves any other alone, so a question shown from the -// channel, as after reopening the conversation or reloading the app, would wait forever. Such a -// call is answered with the frames `decide` sends, which the router matches by the call's id: an -// allowed client tool's call then waits for this phone, whose runner runs it. -const answerApproval = (session: Session, toolCallId: string, allowed: boolean) => { - if (session.approvals.some((request) => request.id === toolCallId)) { - session.decide(toolCallId, { allowed }); - return; - } - const socket = ( - session as unknown as { held(): { send(frame: Record): void } } - ).held(); - socket.send({ allowed, tool_call_id: toolCallId, type: 'tool_approval' }); - if (!allowed) { - socket.send({ - error: 'The person declined this call.', - tool_call_id: toolCallId, - type: 'tool_result', - }); - } -}; - -// Answers go over the session's socket, which opens for them in a conversation that was only -// read so far. Each throws when it can't send, so the card lets the person answer again. -const approverFor = (chat: Chat): AIToolApprover => ({ - authorizer: createAuthSessionAuthorizer({ - callbackScheme: SIGN_IN_SCHEME, - decline: async (call) => (await chat.session()).declineLogin(call.id), - }), - clientId, - decide: async (call, allowed) => answerApproval(await chat.session(), call.id, allowed), - userId: AgentConfig.user.id, -}); - -export type AppContextValue = { - /** What the agent is doing in the open conversation, from the session's socket. */ - agentState: ConversationSnapshot; - /** The open conversation's messages and live reasoning. */ - chat: Chat | undefined; - /** Whether the agent was asked to open the open conversation and hasn't written yet. */ - awaitingAgent: boolean; - /** The open conversation's state, and what the person does in it. */ - conversation: AIConversationController | undefined; - /** Bumped when the drawer's list of conversations may have changed. */ - conversationsVersion: number; - /** Why the last conversation couldn't be opened. */ - failure: string | undefined; - /** Whether the router knows who this is, so conversations can start. */ - isReady: boolean; - /** The open conversation's session id, or `undefined` for a new one that wasn't asked anything. */ - sessionId: string | undefined; - /** - * Carries on a conversation from the drawer, by its session id. Its messages show as soon as its - * channel, `type:id` as the router lists it, is read. - */ - openConversation: (sessionId: string, conversationId?: string) => void; - /** Starts a new conversation. The router creates it with its first question. */ - startConversation: () => void; - /** - * Asks with a voice memo recorded on the phone: it goes up to the conversation's channel, and the - * router transcribes it, answers it and shows it on the person's message. - */ - sendVoiceMemo: (uri: string) => Promise; - /** Asks again what the agent couldn't answer: the last question, or the event it was told of. */ - retry: () => void; - /** Starts a new conversation and asks it `text`, as a flow or a scanned code does. */ - startConversationWith: (text: string) => void; - /** - * Starts a new conversation that the agent opens, about something that happened, such as a - * clock-in: the restaurant's systems would tell the backend, here the app does. - */ - startConversationAbout: (event: CrewEvent) => void; -}; - -export const AppContext = React.createContext({ - agentState: IDLE, - awaitingAgent: false, - chat: undefined, - conversation: undefined, - conversationsVersion: 0, - failure: undefined, - isReady: false, - openConversation: () => {}, - retry: () => {}, - sendVoiceMemo: () => Promise.resolve(), - sessionId: undefined, - startConversation: () => {}, - startConversationAbout: () => {}, - startConversationWith: () => {}, -}); - -/** What the restaurant's systems tell the agent about, so it speaks first. */ -export type CrewEvent = 'clock_in' | 'clock_out' | 'back_from_time_off' | 'onboarding'; - -/** What was last asked of the agent here, so it can be asked again when it couldn't answer. */ -type AgentRequest = - | { text: string; custom?: Record } - | { event: CrewEvent } - | { voiceMemoUrl: string }; - -// Tells the backend what happened, which has the agent open the conversation about it. -const tellBackend = (kind: CrewEvent, sessionId: string) => - crewBackend('events', { - body: { - kind, - session_id: sessionId, - timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, - }, - }); - -// Asks with a voice memo at `url`. stream-agents' `responses.create` has no voice note yet, so this is -// the request it makes, with the voice note the router takes beside the text. -const askWithVoiceMemo = async (session: Session, url: string) => { - deviceTools.addressed = false; - await agents.post('/v1/agents/sessions/{id}/responses', { - body: { audio: { url }, request_id: crypto.randomUUID(), text: '' }, - path: { id: session.id }, - }); -}; - -// Tells the backend of an event, or asks with a voice memo, in `chat`, and only then counts the -// conversation as used, as a question does: one where neither got through isn't listed, and is -// deleted when the person leaves it. -const tellAbout = async (chat: Chat, kind: CrewEvent) => { - const session = await chat.session(); - await tellBackend(kind, session.id); - await chat.use(); -}; - -const askVoiceMemo = async (chat: Chat, url: string) => { - await askWithVoiceMemo(await chat.session(), url); - await chat.use(); -}; - -// What the recorder wrote, by its file's extension. -const audioTypeOf = (uri: string) => (/\.(m4a|mp4)$/i.test(uri) ? 'audio/mp4' : 'audio/aac'); - -const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)); - -// What the provider follows of the messages: the latest reply, whose calls this phone runs, -// whether the agent has written anything yet, and the session, once one is open. -const selector = ({ messages, session }: ChatState) => ({ - hasShownReply: messages.some(showsAgentReply), - lastReply: [...messages].reverse().find(isAgentReply), - session, -}); - -// Carries a conversation on, or creates one, with the phone's tools declared on it. -const openSession: OpenAgentSession = (sessionId) => - sessionId - ? agentSessions().resume(sessionId, { tools: deviceTools.sessionTools() }) - : agentSessions().create({ - return_url: SIGN_IN_RETURN_URL, - tools: deviceTools.sessionTools(), - }); - -const newChat = (sessionId?: string): Chat => - new AgentChat({ open: openSession, sessionId }); - -// The conversation's state over the chat. Asking goes through the session, which records the -// question in the channel and answers it there, with photos as data URIs. `asked` hears each -// request as it goes out: the question, or `undefined` for a retry of the last one. -const conversationOf = (chat: Chat, asked: (request: AgentRequest | undefined) => void) => { - // Every tool's step is titled, the backend's too. - const transport = agentChatTransport(chat, { - adapt: (message, reasoningText) => withTitles(fromStreamMessage(message, { reasoningText })), - images: imagesOf, - }); - return new AIConversationController({ - // The agents router keeps the conversation in Stream Chat and can't rewind it, so a reply - // can't be written again or a message edited: either would only be asked again as new. - actions: { edit: false, regenerate: false }, - approver: approverFor(chat), - transport: { - ...transport, - send: (request) => { - asked( - // A retry asks the same question again without what it went out with, such as a - // generated-UI action, which the request it retries still has. - request.kind === 'regenerate' - ? undefined - : { custom: request.custom, text: request.text }, - ); - return transport.send(request); - }, - }, - }); -}; - -type Opened = { - chat: Chat; - conversation: AIConversationController; - /** Stops waiting for a new conversation to be used. */ - stopWaiting: () => void; - /** What was last asked of the agent here. */ - lastRequest?: AgentRequest; -}; - -export const AppProvider = ({ client, children }: PropsWithChildren<{ client: StreamChat }>) => { - const [opened, setOpened] = useState(); - const [failure, setFailure] = useState(); - const [isReady, setIsReady] = useState(false); - const [agentState, setAgentState] = useState(IDLE); - const [conversationsVersion, setConversationsVersion] = useState(0); - // The conversation the agent was asked to open, about an event. - const [awaitedChat, setAwaitedChat] = useState(); - const current = useRef(undefined); - const chat = opened?.chat; - - const followed = useStateStore(chat ? chatStore(chat) : undefined, selector); - const session = followed?.session; - const awaitingAgent = chat !== undefined && chat === awaitedChat && !followed?.hasShownReply; - - // Runs the calls of the latest reply that wait for this phone, each once. - const lastReply = followed?.lastReply; - useEffect(() => { - if (!lastReply) return; - deviceTools.runner.run(parseMessageParts(lastReply.attachments), (call, result) => - deviceTools.send(call.id, result), - ); - }, [lastReply]); - - // Shows a conversation in place of the one before it, at once, and lets the one before go. - const show = (next: Chat) => { - const previous = current.current; - const shown: Opened = { - chat: next, - conversation: conversationOf(next, (request) => { - if (request) shown.lastRequest = request; - setFailure(undefined); - }), - // The router lists a new conversation once something used it. - stopWaiting: whenUsed(next, () => setConversationsVersion((version) => version + 1)), - }; - current.current = shown; - setFailure(undefined); - setOpened(shown); - previous?.stopWaiting(); - previous?.conversation.dispose(); - void previous?.chat.close().catch(() => {}); - return shown; - }; - - // A new conversation is only shown: the router creates it with its first question, so one - // that is never asked anything isn't left in the drawer. - const startConversation = useStableCallback(() => { - show(newChat()); - }); - - const startConversationWith = useStableCallback((text: string) => { - void show(newChat()).conversation.send({ text }); - }); - - const startConversationAbout = useStableCallback((kind: CrewEvent) => { - const next = newChat(); - show(next).lastRequest = { event: kind }; - setAwaitedChat(next); - // Nothing is asked here, but once the backend has the event the conversation is used: the - // agent writes first, and it is kept and listed. - tellAbout(next, kind).catch((error: unknown) => { - setAwaitedChat((awaited) => (awaited === next ? undefined : awaited)); - if (current.current?.chat === next) setFailure(messageOf(error)); - }); - }); - - const sendVoiceMemo = useStableCallback(async (uri: string) => { - const shown = current.current; - if (!shown) return; - setFailure(undefined); - try { - const session = await shown.chat.session(); - const { channel } = await session.chat(); - const name = uri.split('/').pop() ?? 'voice-memo.aac'; - const { file } = await (channel as Channel).uploadChannelFile({ - file: { name, type: audioTypeOf(uri), uri }, - }); - if (!file) throw new Error('The voice memo did not upload'); - shown.lastRequest = { voiceMemoUrl: file }; - await askVoiceMemo(shown.chat, file); - } catch (error) { - setFailure(messageOf(error)); - } - }); - - const retry = useStableCallback(() => { - const shown = current.current; - const last = shown?.lastRequest; - if (!shown || !last) return; - setFailure(undefined); - if ('voiceMemoUrl' in last) { - askVoiceMemo(shown.chat, last.voiceMemoUrl).catch((error: unknown) => - setFailure(messageOf(error)), - ); - return; - } - if ('event' in last) { - tellAbout(shown.chat, last.event).catch((error: unknown) => setFailure(messageOf(error))); - return; - } - void shown.conversation.send({ custom: last.custom, text: last.text }); - }); - - const openConversation = useStableCallback((sessionId: string, conversationId?: string) => { - if (sessionId === current.current?.chat.id) { - return; - } - const next = newChat(sessionId); - show(next); - // The channel, when the drawer named it: reading it is quick, and carrying the session on, - // which waits for something to need it, would also mark the conversation active. - const [type, ...id] = (conversationId ?? '').split(':'); - const following = conversationId - ? next.follow(client.channel(type || 'agent', id.join(':'))) - : next.session(); - void following.catch((error: unknown) => { - if (current.current?.chat === next) setFailure(messageOf(error)); - }); - }); - - // The session's socket drops once the app has been in the background a while. Coming back, the - // open conversation is carried on in a new one, so what the agent does shows again. - useEffect(() => { - const subscription = AppState.addEventListener('change', (state) => { - const shown = current.current?.chat; - if (state !== 'active' || !shown) return; - const { session: held } = shown.state.getLatestValue(); - if (held && !held.live) void shown.prepare().catch(() => {}); - }); - return () => subscription.remove(); - }, []); - - // The open conversation: what the agent is doing, from the session's socket, once one is open. - useEffect(() => { - if (!session) { - setAgentState(IDLE); - return; - } - setAgentState(session.state.get()); - return 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); - }); - }, [session]); - - 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(); - setIsReady(true); - } - } catch (error) { - setFailure(messageOf(error)); - } - })(); - return () => { - cancelled = true; - }; - }, [client, startConversation]); - - const sessionId = session?.id ?? chat?.id; - const conversation = opened?.conversation; - const contextValue = useMemo( - () => ({ - agentState, - awaitingAgent, - chat, - conversation, - conversationsVersion, - failure, - isReady, - openConversation, - retry, - sendVoiceMemo, - sessionId, - startConversation, - startConversationAbout, - startConversationWith, - }), - [ - agentState, - awaitingAgent, - chat, - conversation, - conversationsVersion, - failure, - isReady, - openConversation, - retry, - sendVoiceMemo, - sessionId, - startConversation, - startConversationAbout, - startConversationWith, - ], - ); - - return {children}; -}; - -export const useAppContext = () => React.useContext(AppContext); diff --git a/examples/ExpoCrewAssistant/custom-types.d.ts b/examples/ExpoCrewAssistant/custom-types.d.ts deleted file mode 100644 index 12dfe77c24..0000000000 --- a/examples/ExpoCrewAssistant/custom-types.d.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - DefaultAttachmentData, - DefaultChannelData, - DefaultCommandData, - DefaultEventData, - DefaultMemberData, - DefaultMessageData, - DefaultPollData, - DefaultPollOptionData, - DefaultReactionData, - DefaultThreadData, - DefaultUserData, -} from 'stream-chat-expo'; -import type { AIUIActionJSON, AIUIDocumentJSON } from 'stream-chat-expo/ai-components'; - -declare module 'stream-chat' { - // Module augmentation: the empty interfaces are how the default data types are applied. - /* eslint-disable @typescript-eslint/no-empty-object-type */ - - interface CustomAttachmentData extends DefaultAttachmentData { - /** A voice memo's length in seconds, which the router writes on it. */ - duration?: number; - } - - interface CustomChannelData extends DefaultChannelData {} - - interface CustomCommandData extends DefaultCommandData {} - - interface CustomEventData extends DefaultEventData {} - - interface CustomMemberData extends DefaultMemberData {} - - interface CustomUserData extends DefaultUserData {} - - interface CustomMessageData extends DefaultMessageData { - ai_generated?: boolean; - /** Set by the router while the agent is still writing a reply. */ - generating?: boolean; - /** The UI the agent wrote for a reply, which the router moves out of its text. */ - ai_ui?: AIUIDocumentJSON; - /** What the person did in a reply's UI, on their message that says so. */ - ai_ui_action?: AIUIActionJSON; - /** The handbook sources a reply drew on, which the backend writes on it. */ - support_message?: { - sources?: { citation?: string; id: string; title: string; url: string }[]; - }; - } - - interface CustomPollOptionData extends DefaultPollOptionData {} - - interface CustomPollData extends DefaultPollData {} - - interface CustomReactionData extends DefaultReactionData {} - - interface CustomThreadData extends DefaultThreadData {} -} diff --git a/examples/ExpoCrewAssistant/flows.ts b/examples/ExpoCrewAssistant/flows.ts deleted file mode 100644 index 6feeaae8a9..0000000000 --- a/examples/ExpoCrewAssistant/flows.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { CrewEvent } from './contexts/AppContext'; - -/** One of the things the assistant helps with, as the home screen offers it. */ -export type Flow = { - id: string; - title: string; - subtitle: string; - emoji: string; -} & ({ ask: string } | { event: CrewEvent }); - -/** - * The flows, each started by asking the agent or by an event the restaurant's systems would send, - * which has the agent speak first. - */ -export const FLOWS: Flow[] = [ - { - emoji: '🕕', - event: 'clock_in', - id: 'clock-in', - subtitle: 'Fit check and focus for the shift', - title: 'Clock in', - }, - { - ask: "I'm not feeling well and need to call out of my next shift.", - emoji: '🤒', - id: 'call-out', - subtitle: 'Policy, cover and the shift leader', - title: 'Call out sick', - }, - { - ask: 'What are the uniform rules, and can I wear my own shoes?', - emoji: '📘', - id: 'handbook', - subtitle: 'Answers from the handbook, with sources', - title: 'Ask the handbook', - }, - { - ask: 'I think some equipment is broken and I need help!', - emoji: '🛠️', - id: 'equipment', - subtitle: 'Quick fix, then a ticket if needed', - title: 'Equipment problem', - }, - { - emoji: '🏖️', - event: 'back_from_time_off', - id: 'back', - subtitle: 'What you missed, summarized', - title: 'Back from time off', - }, - { - emoji: '👋', - event: 'onboarding', - id: 'onboarding', - subtitle: 'Your first 30 days: team, menu, check-ins', - title: "I'm new here", - }, - { - ask: "Let's practice for my shift leader interview. Ask me the first question.", - emoji: '🎯', - id: 'interview', - subtitle: 'Mock interview with feedback', - title: 'Interview practice', - }, - { - emoji: '🌙', - event: 'clock_out', - id: 'clock-out', - subtitle: 'Reflect on the shift', - title: 'Clock out', - }, -]; diff --git a/examples/ExpoCrewAssistant/hooks/useSendAIMessage.ts b/examples/ExpoCrewAssistant/hooks/useSendAIMessage.ts deleted file mode 100644 index a7ece9afe9..0000000000 --- a/examples/ExpoCrewAssistant/hooks/useSendAIMessage.ts +++ /dev/null @@ -1,20 +0,0 @@ -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]; - -/** - * Asks the agent through the open conversation, as the composer would: the question shows at once, - * and the router writes it and its reply into the conversation's channel. Used by voice mode. - */ -export const useSendAIMessage = () => { - const { conversation } = useAppContext(); - - return useStableCallback((message: AIMessage) => { - conversation?.send(message); - return Promise.resolve(); - }); -}; diff --git a/examples/ExpoCrewAssistant/metro.config.js b/examples/ExpoCrewAssistant/metro.config.js deleted file mode 100644 index 5fa02acffa..0000000000 --- a/examples/ExpoCrewAssistant/metro.config.js +++ /dev/null @@ -1,74 +0,0 @@ -const { getDefaultConfig } = require('@expo/metro-config'); -const { exclusionList, resolveUniqueModule } = require('@rnx-kit/metro-config'); - -/** - * Metro configuration - * https://facebook.github.io/metro/docs/configuration - * - * @type {import('metro-config').MetroConfig} - */ -const config = getDefaultConfig(__dirname); - -const PATH = require('path'); -const packageDirPath = PATH.resolve(__dirname, '../../package'); -const expoPackageDirPath = PATH.resolve(__dirname, '../../package/expo-package'); - -const symlinked = { - 'stream-chat-expo': expoPackageDirPath, - 'stream-chat-react-native-core': packageDirPath, -}; - -// find what all modules need to be unique for the app (mainly react and react-native) -// note: we filter the symlinked modules as they are already unique -// and as they dont follow the workspace pattern the auto-generated path to the module is incorrect -const dependencyPackageNames = Object.keys(require('./package.json').dependencies); - -const uniqueModules = dependencyPackageNames.map((packageName) => { - if (symlinked[packageName]) { - const modulePath = symlinked[packageName]; - const escapedPackageName = PATH.normalize(packageName).replace(/\\/g, '\\\\'); - - // exclude the symlinked package from being resolved from node_modules - // example: .*\/node_modules\/stream-chat-react-native-core\/.* - // the above would avoid native-package to resolve core from its own node_modules - const exclusionRE = new RegExp( - `.*${PATH.sep}node_modules\\${PATH.sep}${escapedPackageName}\\${PATH.sep}.*`, - ); - - return { - packageName, // name of the package - modulePath, - blockPattern: exclusionRE, // paths that match this pattern will be blocked from being resolved - }; - } - const [modulePath, blockPattern] = resolveUniqueModule(packageName, __dirname); - // resolveUniqueModule emits a regex ending in `/` (package-dir boundary). metro-config's - // exclusionList anchors every alternation branch with `$`, which would require file paths to - // literally end with `/node_modules//`. Append `.*` so the anchor lands at end-of-file - // instead of end-of-directory, otherwise the blocklist silently matches nothing. - const fixedBlockPattern = new RegExp(blockPattern.source + '.*'); - return { - packageName, // name of the package - modulePath, // actual path to the module in the project's node modules - blockPattern: fixedBlockPattern, // paths that match this pattern will be blocked from being resolved - }; -}); - -// block the other paths for unique modules from being resolved -const blockList = uniqueModules.map(({ blockPattern }) => blockPattern); - -// provide the path for the unique modules -const extraNodeModules = uniqueModules.reduce((acc, item) => { - acc[item.packageName] = item.modulePath; - return acc; -}, {}); - -config.resolver.blockList = exclusionList(blockList); -config.resolver.extraNodeModules = extraNodeModules; - -config.resolver.nodeModulesPaths = [PATH.resolve(__dirname, 'node_modules')]; - -// add the package dir for metro to access the package folder -config.watchFolders = [packageDirPath]; - -module.exports = config; diff --git a/examples/ExpoCrewAssistant/package.json b/examples/ExpoCrewAssistant/package.json deleted file mode 100644 index 2e2fbe7fd7..0000000000 --- a/examples/ExpoCrewAssistant/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "expocrewassistant", - "version": "1.0.0", - "main": "expo-router/entry", - "scripts": { - "sync-native": "bash ../../package/scripts/reconcile-shared-native.sh expo-package && bash ../../package/scripts/sync-shared-native.sh expo-package", - "start": "yarn sync-native && expo start --dev-client", - "android": "yarn sync-native && expo run:android", - "ios": "yarn sync-native && expo run:ios", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "@react-native-community/netinfo": "12.0.1", - "@shopify/react-native-skia": "^2.14.0", - "@stream-io/state-store": "^1.2.0", - "@stream-io/stream-agents": "^0.4.2", - "expo": "^58.0.8", - "expo-audio": "patch:expo-audio@npm%3A58.0.9#~/.yarn/patches/expo-audio-npm-58.0.9-5c313f7a3f.patch", - "expo-clipboard": "~58.0.3", - "expo-constants": "~58.0.11", - "expo-image-manipulator": "~58.0.14", - "expo-image-picker": "~58.0.10", - "expo-linking": "~58.0.12", - "expo-router": "~58.0.18", - "expo-speech": "~58.0.3", - "react": "19.3.0", - "react-native": "0.88.0", - "react-native-gesture-handler": "~3.4.0", - "react-native-keyboard-controller": "1.22.9", - "react-native-reanimated": "4.7.1", - "react-native-safe-area-context": "~5.10.1", - "react-native-screens": "~4.29.0", - "react-native-svg": "15.15.5", - "react-native-teleport": "^1.2.2", - "react-native-worklets": "0.13.0", - "stream-chat": "^10.0.1", - "stream-chat-expo": "workspace:^", - "stream-chat-react-native-core": "workspace:^", - "use-sync-external-store": "^1.7.0", - "victory-native": "^42.0.1" - }, - "devDependencies": { - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@rnx-kit/metro-config": "^2.1.2", - "@stream-io/typescript-config": "workspace:^", - "@types/react": "~19.3.0", - "typescript": "6.0.3" - }, - "private": true -} diff --git a/examples/ExpoCrewAssistant/screens/AgentNotices.tsx b/examples/ExpoCrewAssistant/screens/AgentNotices.tsx deleted file mode 100644 index f95b2787e2..0000000000 --- a/examples/ExpoCrewAssistant/screens/AgentNotices.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useSyncExternalStore } from 'react'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; - -import type { Session, ToolApprovalRequest } from '@stream-io/stream-agents'; -import { useStateStore } from 'stream-chat-expo'; -import { AIToolApprovalCard } from 'stream-chat-expo/ai-components'; - -import { type Chat, type ChatState, chatStore } from '../agent/conversation'; -import { useAppContext } from '../contexts/AppContext'; - -const NONE: readonly ToolApprovalRequest[] = []; - -const sessionSelector = ({ session }: ChatState) => ({ session }); - -// What the call would do, in a line, from the arguments the model wrote. -const detailsOf = ({ arguments: written }: ToolApprovalRequest) => { - try { - const parsed = JSON.parse(written) as Record; - return Object.entries(parsed) - .filter(([, value]) => typeof value === 'string' || typeof value === 'number') - .map(([key, value]) => `${key.replace(/_/g, ' ')}: ${String(value)}`) - .join(' · '); - } catch { - return undefined; - } -}; - -const Failure = ({ session }: { session: Session }) => { - const { retry } = useAppContext(); - const failure = useSyncExternalStore( - session.conversation.subscribe, - () => session.conversation.get().failure, - ); - if (!failure) return null; - return ( - - Crew couldn't answer that. The model may be busy. - Try again - - ); -}; - -const Requests = ({ session }: { session: Session }) => { - const approvals = useSyncExternalStore( - session.conversation.subscribe, - () => session.conversation.get().approvals ?? NONE, - ); - if (approvals.length === 0) return null; - return ( - - {approvals.map((request) => ( - session.decide(request.id, { allowed })} - /> - ))} - - ); -}; - -/** - * What the open conversation's session needs the person for, above the composer: why the agent - * couldn't answer, with a way to ask again, and the calls of the phone's tools that ask first. The - * phone asks through `responses.create`, so those wait on its session rather than on the reply's - * step, and the person answers them here. - */ -export const AgentNotices = ({ chat }: { chat: Chat }) => { - const { session } = useStateStore(chatStore(chat), sessionSelector); - return session ? ( - <> - - - - ) : null; -}; - -const styles = StyleSheet.create({ - container: { gap: 8, paddingBottom: 8, paddingHorizontal: 16 }, - failure: { alignItems: 'center', paddingBottom: 8 }, - failureText: { color: '#B00020', fontSize: 14 }, - retryText: { color: '#C8102E', fontSize: 15, fontWeight: '700', paddingTop: 2 }, -}); diff --git a/examples/ExpoCrewAssistant/screens/ChatContent.tsx b/examples/ExpoCrewAssistant/screens/ChatContent.tsx deleted file mode 100644 index 0e2ab2121b..0000000000 --- a/examples/ExpoCrewAssistant/screens/ChatContent.tsx +++ /dev/null @@ -1,587 +0,0 @@ -import { - createContext, - type ReactNode, - useCallback, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; -import { - ActivityIndicator, - Alert, - Linking, - Platform, - Pressable, - ScrollView, - StatusBar, - StyleSheet, - Text, - View, -} from 'react-native'; -import Animated, { - Easing, - FadeIn, - FadeOut, - useAnimatedStyle, - useSharedValue, - withSpring, - withTiming, -} from 'react-native-reanimated'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import type { Attachment, LocalMessage } from 'stream-chat'; -import { AudioAttachment, AudioPlayerProvider, useStateStore } from 'stream-chat-expo'; - -import { createA2UIRenderer } from 'stream-chat-expo/a2ui'; -import { - AIAssistantMessage, - type AIConversationController, - type AIConversationControllerState, - AITypingIndicatorView, - type AIUIAction, - aiUIActionJSON, - AIUIHost, - AIUserMessage, - ComposerView, - isAIConversationAnswering, - useAIConversationState, -} from 'stream-chat-expo/ai-components'; -import { - AIConversation, - type AIConversationAssistantMessageProps, - type AIConversationUserMessageProps, -} from 'stream-chat-expo/ai-components/conversation'; - -import { AgentNotices } from './AgentNotices'; -import { VoiceMemoBar, VoiceMemoButton } from './VoiceMemo'; - -import { crewBackend } from '../agent/agents'; -import { type Chat, type ChatState, chatStore, keyOf } from '../agent/conversation'; -import { VoiceMode } from '../components/VoiceMode'; -import { useAppContext } from '../contexts/AppContext'; -import { type Flow, FLOWS } from '../flows'; - -const errorSelector = ({ error }: AIConversationControllerState) => ({ error }); - -/** - * The composer, with what rides the keyboard above it: the questions of calls that ask first, and - * why something failed, with a way to try again. And voice mode beside it. - */ -const ComposerArea = ({ - chat, - conversation, - failure, -}: { - chat: Chat; - conversation: AIConversationController; - failure: string | undefined; -}) => { - const { error } = useAIConversationState(conversation, errorSelector); - - const safeAreaInsets = useSafeAreaInsets(); - const insets = useMemo( - () => ({ - ...safeAreaInsets, - bottom: - safeAreaInsets.bottom + - (Platform.OS === 'android' ? (StatusBar.currentHeight ?? 0) * 2 : 0), - }), - [safeAreaInsets], - ); - - return ( - - - - {failure ? {failure} : null} - {error ? ( - conversation.retry()}> - {error.message} - Try again - - ) : null} - - } - SpeechToTextButton={null} - trailing={ - - - - - } - /> - ); -}; - -const pendingSelector = ({ pendingMessage }: AIConversationControllerState) => ({ - pendingMessage, -}); - -/** What the assistant helps with, for an empty conversation: each starts a new one. */ -const Flows = ({ conversation }: { conversation: AIConversationController }) => { - const { startConversationAbout } = useAppContext(); - const { pendingMessage } = useAIConversationState(conversation, pendingSelector); - if (pendingMessage) return null; - const start = (flow: Flow) => - 'event' in flow ? startConversationAbout(flow.event) : conversation.send({ text: flow.ask }); - return ( - - Hi Jordan 👋 - What do you need before, during or after your shift? - - {FLOWS.map((flow) => ( - start(flow)} - style={({ pressed }) => [styles.flow, pressed && styles.pressed]} - testID={`flow-${flow.id}`} - > - {flow.emoji} - {flow.title} - {flow.subtitle} - - ))} - - - ); -}; - -const emptySelector = ({ isLoaded }: ChatState) => ({ isLoaded }); - -const EmptyContent = ({ - chat, - conversation, -}: { - chat: Chat; - conversation: AIConversationController; -}) => { - const { awaitingAgent } = useAppContext(); - const { isLoaded } = useStateStore(chatStore(chat), emptySelector); - if (awaitingAgent) { - return ( - - - - ); - } - return isLoaded ? : ; -}; - -type Source = { id: string; title: string; url: string; citation?: string }; - -const NO_SOURCES: Source[] = []; - -const sourcesOf = (message: LocalMessage | undefined): Source[] => - message?.custom?.support_message?.sources ?? NO_SOURCES; - -/** The chat the open conversation is kept in, for the views of its messages. */ -const ConversationChatContext = createContext(undefined); - -/** The message of the conversation with `messageId`, following only that message. */ -const useChatMessage = (messageId: string | undefined) => { - const chat = useContext(ConversationChatContext); - const selector = useMemo( - () => - ({ messages }: ChatState) => ({ - message: messages.find(({ id }) => id === messageId), - }), - [messageId], - ); - return useStateStore(chat ? chatStore(chat) : undefined, selector)?.message; -}; - -const SOURCES_OPEN = { duration: 300, easing: Easing.out(Easing.ease) }; - -/** Opens what it holds from nothing as it shows, so what is below it moves down rather than jumps. */ -const Opening = ({ children }: { children: ReactNode }) => { - const height = useSharedValue(0); - const progress = useSharedValue(0); - const style = useAnimatedStyle(() => ({ - height: height.value * progress.value, - opacity: progress.value, - })); - return ( - - { - height.value = nativeEvent.layout.height; - if (progress.value === 0) progress.value = withTiming(1, SOURCES_OPEN); - }} - style={styles.openingContent} - > - {children} - - - ); -}; - -/** The handbook passages and pages a reply was answered from, under it. */ -const ReplySources = ({ - messageId, - opens, -}: { - messageId: string; - /** Whether it opens as it shows, rather than show at once. */ - opens: boolean; -}) => { - const sources = sourcesOf(useChatMessage(messageId)); - if (sources.length === 0) return null; - const card = ( - - Sources - {sources.map((source, index) => ( - void Linking.openURL(source.url)} - style={styles.source} - > - {index + 1} - - {source.title} - {source.citation ? ( - - {source.citation} - - ) : null} - - - ))} - - ); - return opens ? {card} : card; -}; - -// One player pool for the conversation, as a channel has: starting one memo pauses another. -const AUDIO_PLAYER = { allowConcurrentAudioPlayback: false }; - -const voiceNotesOf = (message: LocalMessage | undefined) => - (message?.attachments ?? []).filter( - (attachment): attachment is Attachment & { asset_url: string } => - attachment.type === 'voiceRecording' && !!attachment.asset_url, - ); - -/** The voice memos on a person's message, played with the chat SDK's own voice recording view. */ -const VoiceNotes = ({ - message, - notes, -}: { - message: LocalMessage; - notes: ReturnType; -}) => ( - - {notes.map((note, index) => ( - - ))} - -); - -// The spring the agent's reasoning folds with. -const FOLD_SPRING = { damping: 26.5, mass: 1, stiffness: 195 }; - -/** The transcript, springing open and folded with `isOpen`. */ -const TranscriptFold = ({ isOpen, text }: { isOpen: boolean; text: string }) => { - const height = useSharedValue(0); - const progress = useSharedValue(0); - useEffect(() => { - progress.value = withSpring(isOpen ? 1 : 0, FOLD_SPRING); - }, [isOpen, progress]); - const style = useAnimatedStyle(() => ({ - height: height.value * progress.value, - opacity: progress.value, - })); - return ( - - { - height.value = nativeEvent.layout.height; - }} - style={styles.transcriptPanel} - > - {text} - - - ); -}; - -/** What the router heard in a voice memo, folded away until asked for. */ -const Transcript = ({ text }: { text: string }) => { - const [isOpen, setIsOpen] = useState(false); - const [hasOpened, setHasOpened] = useState(false); - return ( - - { - setHasOpened(true); - setIsOpen(!isOpen); - }} - testID='voice-memo-transcript-toggle' - > - - {isOpen ? 'Hide transcript' : 'Show transcript'} - - - {hasOpened ? : null} - - ); -}; - -/** - * The person's message as the AI components show it. One sent as a voice memo shows the memo, and - * the words the router heard in it only when asked for. - */ -const UserMessage = ({ attachments, message, onEdit, text }: AIConversationUserMessageProps) => { - const stored = useChatMessage(message?.id); - const notes = useMemo(() => voiceNotesOf(stored), [stored]); - if (stored && notes.length > 0) { - return ( - - - {text ? : null} - {attachments.length > 0 ? : null} - - ); - } - return text || attachments.length > 0 ? ( - - ) : null; -}; - -/** The agent's reply as the AI components show it, then the sources it was answered from. */ -const AssistantMessage = ({ - approver, - attachments, - isGenerating, - message, - onWritingChange, - parts, - reasoningText, - text, -}: AIConversationAssistantMessageProps) => { - const [watched, setWatched] = useState(); - if (isGenerating && watched !== message.id) setWatched(message.id); - const [writing, setWriting] = useState(); - const onWriting = useCallback( - (isWriting: boolean) => { - setWriting(isWriting ? message.id : undefined); - onWritingChange?.(isWriting); - }, - [message.id, onWritingChange], - ); - return ( - - - {isGenerating || writing === message.id ? null : ( - - )} - - ); -}; - -/** The event the agent's clock-out form sends when the person saves their reflection. */ -const REFLECTION_SAVED = 'reflection_done'; - -/** - * Saves a reflection from the agent's clock-out form with the backend, and gives back the action - * to send the agent, which says it is saved. - */ -const saveReflection = async (action: AIUIAction): Promise => { - const context = (action.payload.context ?? {}) as Record; - const challenges = Array.isArray(context.challenges) ? context.challenges : []; - await crewBackend('crew/reflections', { - body: { - energy: Math.round(Number(context.energy)), - themes: challenges - .filter((challenge): challenge is string => typeof challenge === 'string') - .map((challenge) => challenge.replace(/_/g, ' ')), - win: typeof context.win === 'string' ? context.win.trim() : '', - }, - }); - return { ...action, payload: { ...action.payload, context: { ...context, saved: true } } }; -}; - -/** The open conversation, as its controller has it. */ -const Conversation = ({ - chat, - conversation, -}: { - chat: Chat; - conversation: AIConversationController; -}) => { - const { agentState } = useAppContext(); - - // The UI the agent writes for its replies, in A2UI: checklists, quizzes and forms. One host per - // conversation, so what a reply's UI holds survives scrolling. What the person does in it goes - // to the agent as their next message. - const [ui] = useState( - () => - new AIUIHost({ - renderers: [createA2UIRenderer()], - send: async (action) => { - if (isAIConversationAnswering(conversation.state.getLatestValue().status)) { - throw new Error('Crew is still answering'); - } - let sent = action; - if (action.name === REFLECTION_SAVED) { - try { - sent = await saveReflection(action); - } catch (error) { - Alert.alert( - "Your reflection wasn't saved", - error instanceof Error ? error.message : String(error), - ); - throw error; - } - } - const custom = { uiAction: aiUIActionJSON(sent) }; - conversation.send({ custom, text: sent.label }); - }, - }), - ); - - const footer = - agentState.kind === 'working' ? ( - skill.replace(/_/g, ' ')).join(' and ')}`} - /> - ) : undefined; - - return ( - - - } - footer={footer} - ui={ui} - UserMessage={UserMessage} - /> - - - ); -}; - -export const ChatContent = () => { - const { chat, conversation, failure } = useAppContext(); - const { bottom } = useSafeAreaInsets(); - - if (!chat || !conversation) { - return ( - - {failure ? {failure} : } - - ); - } - - return ( - - - - - ); -}; - -const styles = StyleSheet.create({ - emptyContainer: { - alignItems: 'center', - backgroundColor: 'transparent', - flex: 1, - justifyContent: 'center', - width: '100%', - }, - failureText: { color: '#B00020', fontSize: 15, paddingHorizontal: 24, textAlign: 'center' }, - flow: { - backgroundColor: '#FFFFFF', - borderColor: '#E6E8EC', - borderRadius: 16, - borderWidth: 1, - gap: 4, - padding: 14, - width: '48%', - }, - flowEmoji: { fontSize: 24 }, - flowSubtitle: { color: '#5C6370', fontSize: 13 }, - flowTitle: { color: '#0B1220', fontSize: 15, fontWeight: '700' }, - flows: { paddingHorizontal: 16, paddingVertical: 24 }, - grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, justifyContent: 'space-between' }, - hello: { color: '#0B1220', fontSize: 26, fontWeight: '800' }, - helloSub: { color: '#5C6370', fontSize: 15, marginBottom: 20, marginTop: 4 }, - pressed: { opacity: 0.6 }, - retryText: { - color: '#C8102E', - fontSize: 15, - fontWeight: '600', - paddingTop: 4, - textAlign: 'center', - }, - source: { flexDirection: 'row', gap: 10, paddingVertical: 6 }, - sourceBody: { flex: 1 }, - sourceCitation: { color: '#5C6370', fontSize: 13 }, - sourceIndex: { - backgroundColor: '#FDECEE', - borderRadius: 10, - color: '#C8102E', - fontSize: 12, - fontWeight: '700', - height: 20, - lineHeight: 20, - textAlign: 'center', - width: 20, - }, - sourceTitle: { color: '#0B1220', fontSize: 14, fontWeight: '600' }, - opening: { overflow: 'hidden' }, - openingContent: { left: 0, position: 'absolute', right: 0, top: 0 }, - sources: { - backgroundColor: '#F7F8FA', - borderRadius: 12, - marginHorizontal: 16, - marginTop: 4, - padding: 12, - }, - sourcesTitle: { color: '#5C6370', fontSize: 12, fontWeight: '700', marginBottom: 4 }, - trailing: { alignItems: 'center', flexDirection: 'row', gap: 8 }, - transcript: { alignItems: 'flex-end', paddingHorizontal: 20, paddingTop: 6 }, - transcriptFold: { alignSelf: 'stretch', overflow: 'hidden' }, - transcriptPanel: { alignItems: 'flex-end', paddingTop: 4, position: 'absolute', width: '100%' }, - transcriptText: { color: '#5C6370', fontSize: 14, lineHeight: 20 }, - transcriptToggle: { color: '#C8102E', fontSize: 13, fontWeight: '600' }, - voiceNotes: { alignItems: 'flex-end', gap: 6, paddingHorizontal: 16, paddingTop: 8 }, - wrapper: { backgroundColor: '#FCFCFC', flex: 1 }, -}); diff --git a/examples/ExpoCrewAssistant/screens/MenuDrawer.tsx b/examples/ExpoCrewAssistant/screens/MenuDrawer.tsx deleted file mode 100644 index 838f4e68ba..0000000000 --- a/examples/ExpoCrewAssistant/screens/MenuDrawer.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { Pressable, SectionList, StyleSheet, Text, View } from 'react-native'; - -import { SafeAreaView } from 'react-native-safe-area-context'; - -import type { Schemas } from '@stream-io/stream-agents'; -import { router } from 'expo-router'; -import { type DrawerContentComponentProps, useDrawerStatus } from 'expo-router/drawer'; - -import { Plus, useStableCallback } from 'stream-chat-expo'; - -import { agentSessions } from '../agent/agents'; -import { AgentConfig } from '../agent/config'; -import { useAppContext } from '../contexts/AppContext'; - -type SessionRow = Schemas['Session']; - -type Section = { data: SessionRow[]; title: string }; - -const keyExtractor = (session: SessionRow) => session.id; - -// When the conversation was last answered, or else started. The router lists conversations by when -// they were last touched, which opening one to read it does too, so a conversation looked at would -// jump to the top. When the agent last answered moves only when it is used. -const lastUsed = (session: SessionRow) => - Date.parse(session.last_response_at ?? session.created_at ?? '') || 0; - -const DAY = 24 * 60 * 60 * 1000; - -// Most recently used first, under Today, Yesterday, Previous 7 days and Older. -const sectionsOf = (sessions: readonly SessionRow[]): Section[] => { - const today = new Date(); - today.setHours(0, 0, 0, 0); - const startOfToday = today.getTime(); - const sections: Section[] = [ - { data: [], title: 'Today' }, - { data: [], title: 'Yesterday' }, - { data: [], title: 'Previous 7 days' }, - { data: [], title: 'Older' }, - ]; - [...sessions] - .sort((a, b) => lastUsed(b) - lastUsed(a)) - .forEach((session) => { - const at = lastUsed(session); - const index = - at >= startOfToday - ? 0 - : at >= startOfToday - DAY - ? 1 - : at >= startOfToday - 7 * DAY - ? 2 - : 3; - sections[index].data.push(session); - }); - return sections.filter((section) => section.data.length > 0); -}; - -const initialsOf = (name: string) => - name - .split(' ') - .map((part) => part[0]) - .join('') - .slice(0, 2) - .toUpperCase(); - -/** - * The agent's conversations, most recently used first. Only those kept in writing: a session that - * was only ever a call has no channel to read back. - */ -export const MenuDrawer = ({ navigation }: DrawerContentComponentProps) => { - const { conversationsVersion, isReady, openConversation, sessionId, startConversation } = - useAppContext(); - const [sessions, setSessions] = useState([]); - const isOpen = useDrawerStatus() === 'open'; - const sections = useMemo(() => sectionsOf(sessions), [sessions]); - - useEffect(() => { - if (!isReady || !isOpen) return; - let cancelled = false; - agentSessions() - .query({ limit: 50 }) - .then((page) => { - if (!cancelled) setSessions(page.items.filter((item) => item.conversation_id)); - }) - .catch(() => undefined); - return () => { - cancelled = true; - }; - }, [conversationsVersion, isOpen, isReady]); - - const onSelect = useStableCallback((session: SessionRow) => { - openConversation(session.id, session.conversation_id ?? undefined); - router.navigate('/'); - navigation.closeDrawer(); - }); - - const onCreateNewChat = useStableCallback(() => { - startConversation(); - router.navigate('/'); - navigation.closeDrawer(); - }); - - const renderItem = ({ item }: { item: SessionRow }) => { - const isCurrent = item.id === sessionId; - return ( - onSelect(item)} - style={({ pressed }) => [ - styles.row, - isCurrent ? styles.rowCurrent : pressed ? styles.rowPressed : null, - ]} - > - - {item.title || 'New conversation'} - - - ); - }; - - const renderSectionHeader = ({ section }: { section: Section }) => ( - - {section.title} - - ); - - return ( - - - - Conversations - - [styles.newButton, pressed && styles.newButtonPressed]} - > - - New conversation - - - - - - {initialsOf(AgentConfig.user.name)} - - - {AgentConfig.user.name} - - - - ); -}; - -const ACCENT = '#C8102E'; -const ACCENT_TINT = '#FDECEE'; -const BORDER = '#E6E8EC'; -const TEXT = '#0B1220'; -const TEXT_SECONDARY = '#5C6370'; - -const styles = StyleSheet.create({ - avatar: { - alignItems: 'center', - backgroundColor: ACCENT_TINT, - borderRadius: 18, - height: 36, - justifyContent: 'center', - width: 36, - }, - avatarText: { color: ACCENT, fontSize: 14, fontWeight: '700' }, - footer: { - alignItems: 'center', - borderTopColor: BORDER, - borderTopWidth: StyleSheet.hairlineWidth, - flexDirection: 'row', - gap: 12, - paddingHorizontal: 20, - paddingVertical: 14, - }, - footerName: { color: TEXT, flex: 1, fontSize: 15, fontWeight: '600' }, - header: { gap: 16, paddingBottom: 8, paddingHorizontal: 20, paddingTop: 16 }, - list: { paddingBottom: 16, paddingHorizontal: 12 }, - newButton: { - alignItems: 'center', - alignSelf: 'stretch', - backgroundColor: ACCENT_TINT, - borderRadius: 14, - flexDirection: 'row', - gap: 8, - paddingHorizontal: 14, - paddingVertical: 12, - }, - newButtonPressed: { opacity: 0.7 }, - newButtonText: { color: ACCENT, fontSize: 15, fontWeight: '600' }, - row: { borderRadius: 12, paddingHorizontal: 12, paddingVertical: 11 }, - rowCurrent: { backgroundColor: ACCENT_TINT }, - rowPressed: { backgroundColor: '#F2F3F5' }, - rowText: { color: TEXT, fontSize: 15 }, - rowTextCurrent: { color: ACCENT, fontWeight: '600' }, - sectionTitle: { - color: TEXT_SECONDARY, - fontSize: 13, - fontWeight: '600', - paddingBottom: 6, - paddingHorizontal: 12, - paddingTop: 18, - }, - title: { color: TEXT, fontSize: 22, fontWeight: '700' }, - wrapper: { backgroundColor: '#FFFFFF', flex: 1 }, -}); diff --git a/examples/ExpoCrewAssistant/screens/VoiceMemo.tsx b/examples/ExpoCrewAssistant/screens/VoiceMemo.tsx deleted file mode 100644 index d4453e3ef2..0000000000 --- a/examples/ExpoCrewAssistant/screens/VoiceMemo.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React, { useState } from 'react'; -import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'; - -import { - AudioRecordingInProgress, - MessageInputContext, - type MessageInputContextValue, - useComponentsContext, - useStateStore, -} from 'stream-chat-expo'; -import { - AudioRecorderManager, - type AudioRecorderManagerState, -} from 'stream-chat-react-native-core/src/state-store/audio-recorder-manager'; - -import { useAppContext } from '../contexts/AppContext'; - -// One recorder for the app: the button beside the composer starts it, the bar above stops it. -const recorder = new AudioRecorderManager(); - -// AudioRecordingInProgress reads only the recorder from the composer's context. -const recorderContext = { audioRecorderManager: recorder } as unknown as MessageInputContextValue; - -const statusSelector = ({ isStarting, status }: AudioRecorderManagerState) => ({ - isStarting, - status, -}); - -const uriOf = ({ recording }: AudioRecorderManagerState) => - typeof recording === 'string' ? recording : recording?.getURI(); - -/** Starts a voice memo, beside the composer. */ -export const VoiceMemoButton = () => { - const { icons } = useComponentsContext(); - const { isStarting, status } = useStateStore(recorder.state, statusSelector); - if (status === 'recording' || isStarting) return null; - return ( - void recorder.startRecording()} - style={({ pressed }) => [styles.button, pressed && styles.pressed]} - testID='voice-memo-start' - > - - - ); -}; - -/** - * The memo being recorded, above the composer: its time and waveform, from the chat SDK's own - * recording view, with a way to throw it away and to send it. - */ -export const VoiceMemoBar = () => { - const { sendVoiceMemo } = useAppContext(); - const { status } = useStateStore(recorder.state, statusSelector); - const [isSending, setIsSending] = useState(false); - if (status !== 'recording' && !isSending) return null; - - const cancel = () => void recorder.stopRecording(true); - const send = async () => { - setIsSending(true); - try { - await recorder.stopRecording(); - const uri = uriOf(recorder.state.getLatestValue()); - recorder.reset(); - if (uri) await sendVoiceMemo(uri); - } finally { - setIsSending(false); - } - }; - - return ( - - {isSending ? ( - - - Sending your memo - - ) : ( - <> - - Cancel - - - - - - - - Send - - - )} - - ); -}; - -const styles = StyleSheet.create({ - bar: { - alignItems: 'center', - backgroundColor: '#FFFFFF', - borderColor: '#E6E8EC', - borderRadius: 24, - borderWidth: 1, - flexDirection: 'row', - gap: 8, - marginBottom: 8, - marginHorizontal: 16, - paddingHorizontal: 14, - }, - button: { - alignItems: 'center', - backgroundColor: '#FDECEE', - borderRadius: 22, - height: 44, - justifyContent: 'center', - width: 44, - }, - cancel: { color: '#5C6370', fontSize: 15, fontWeight: '600' }, - pressed: { opacity: 0.6 }, - recording: { flex: 1, overflow: 'hidden' }, - send: { color: '#C8102E', fontSize: 15, fontWeight: '700' }, - sending: { alignItems: 'center', flexDirection: 'row', gap: 8, paddingVertical: 14 }, - sendingText: { color: '#5C6370', fontSize: 15 }, -}); diff --git a/examples/ExpoCrewAssistant/tsconfig.json b/examples/ExpoCrewAssistant/tsconfig.json deleted file mode 100644 index af8edb52a3..0000000000 --- a/examples/ExpoCrewAssistant/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "extends": ["expo/tsconfig.base", "@stream-io/typescript-config/base.json"], - "compilerOptions": { - "paths": { - // Same as ExpoMessaging: pins the whole program to this workspace's copy of React Native. - "react-native": ["./node_modules/react-native/types_generated/index.d.ts"], - "react-native/*": ["./node_modules/react-native/*"], - // The same for the state stores: AgentChat's and the SDK's are then one class to TypeScript. - "@stream-io/state-store": ["./node_modules/@stream-io/state-store"] - } - }, - "include": ["**/*.ts", "**/*.tsx"] -} diff --git a/package.json b/package.json index 0bc9616000..52fa17562d 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,7 @@ "package/expo-package", "examples/SampleApp", "examples/E2ETestApp", - "examples/ExpoMessaging", - "examples/ExpoChatGPTSample", - "examples/ExpoCrewAssistant" + "examples/ExpoMessaging" ], "resolutions": { "@types/react": "^19.3.0" @@ -73,7 +71,7 @@ "lint-fix": "prettier --write . && eslint . --fix --max-warnings 0", "prettier": "prettier --list-different .", "prettier-fix": "prettier --write .", - "typecheck": "yarn workspaces foreach -A --parallel --include 'stream-chat-react-native-core' --include 'stream-chat-react-native' --include 'stream-chat-expo' --include 'sampleapp' --include 'e2etestapp' --include 'ExpoMessaging' --include 'expochatgptsample' --include 'expocrewassistant' run typecheck", + "typecheck": "yarn workspaces foreach -A --parallel --include 'stream-chat-react-native-core' --include 'stream-chat-react-native' --include 'stream-chat-expo' --include 'sampleapp' --include 'e2etestapp' --include 'ExpoMessaging' run typecheck", "build": "yarn workspace stream-chat-react-native-core build", "test:coverage": "yarn workspace stream-chat-react-native-core test:coverage", "test:unit": "yarn workspace stream-chat-react-native-core test:unit", diff --git a/yarn.lock b/yarn.lock index 4cc4e02a9d..0554f7123c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6001,7 +6001,7 @@ __metadata: languageName: node linkType: hard -"@shopify/react-native-skia@npm:^2.13.0, @shopify/react-native-skia@npm:^2.14.0": +"@shopify/react-native-skia@npm:^2.13.0": version: 2.14.0 resolution: "@shopify/react-native-skia@npm:2.14.0" dependencies: @@ -6230,21 +6230,6 @@ __metadata: languageName: node linkType: hard -"@stream-io/stream-agents@npm:^0.4.2": - version: 0.4.2 - resolution: "@stream-io/stream-agents@npm:0.4.2" - peerDependencies: - "@stream-io/video-client": ^1.0.0 - stream-chat: ^9.0.0 || ^10.0.2 - peerDependenciesMeta: - "@stream-io/video-client": - optional: true - stream-chat: - optional: true - checksum: 10c0/58d398425d794c661190afc269de3704c9f7bdf0d95736a3004067049626719e986312d08da865ee30b8efff5909ba26179b73e509422b80bb98074655e549bf - languageName: node - linkType: hard - "@stream-io/typescript-config@workspace:^, @stream-io/typescript-config@workspace:configs/typescript-config": version: 0.0.0-use.local resolution: "@stream-io/typescript-config@workspace:configs/typescript-config" @@ -10928,15 +10913,6 @@ __metadata: languageName: node linkType: hard -"expo-speech@npm:~58.0.3": - version: 58.0.3 - resolution: "expo-speech@npm:58.0.3" - peerDependencies: - expo: "*" - checksum: 10c0/552c3a34a96210cd0405ee278f8065500203ae87881e5b43500a9251ca2d58b6956e14e37e922872b6480669eff7a0a1ad8ff5aca500eea912cd7ae6d71bcf4d - languageName: node - linkType: hard - "expo-splash-screen@npm:~58.0.8": version: 58.0.8 resolution: "expo-splash-screen@npm:58.0.8" @@ -11027,85 +11003,6 @@ __metadata: languageName: node linkType: hard -"expochatgptsample@workspace:examples/ExpoChatGPTSample": - version: 0.0.0-use.local - resolution: "expochatgptsample@workspace:examples/ExpoChatGPTSample" - dependencies: - "@babel/plugin-transform-export-namespace-from": "npm:^7.27.1" - "@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/state-store": "npm:^1.2.0" - "@stream-io/stream-agents": "npm:^0.4.2" - "@stream-io/typescript-config": "workspace:^" - "@types/react": "npm:~19.3.0" - expo: "npm:^58.0.8" - expo-clipboard: "npm:~58.0.3" - expo-constants: "npm:~58.0.11" - expo-image-manipulator: "npm:~58.0.14" - expo-image-picker: "npm:~58.0.10" - expo-linking: "npm:~58.0.12" - expo-router: "npm:~58.0.18" - expo-speech: "npm:~58.0.3" - react: "npm:19.3.0" - react-native: "npm:0.88.0" - react-native-gesture-handler: "npm:~3.4.0" - react-native-keyboard-controller: "npm:1.22.9" - react-native-reanimated: "npm:4.7.1" - react-native-safe-area-context: "npm:~5.10.1" - react-native-screens: "npm:~4.29.0" - react-native-svg: "npm:15.15.5" - react-native-teleport: "npm:^1.2.2" - react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^10.0.1" - stream-chat-expo: "workspace:^" - stream-chat-react-native-core: "workspace:^" - typescript: "npm:6.0.3" - use-sync-external-store: "npm:^1.7.0" - victory-native: "npm:^42.0.1" - languageName: unknown - linkType: soft - -"expocrewassistant@workspace:examples/ExpoCrewAssistant": - version: 0.0.0-use.local - resolution: "expocrewassistant@workspace:examples/ExpoCrewAssistant" - dependencies: - "@babel/plugin-transform-export-namespace-from": "npm:^7.27.1" - "@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/state-store": "npm:^1.2.0" - "@stream-io/stream-agents": "npm:^0.4.2" - "@stream-io/typescript-config": "workspace:^" - "@types/react": "npm:~19.3.0" - expo: "npm:^58.0.8" - expo-audio: "patch:expo-audio@npm%3A58.0.9#~/.yarn/patches/expo-audio-npm-58.0.9-5c313f7a3f.patch" - expo-clipboard: "npm:~58.0.3" - expo-constants: "npm:~58.0.11" - expo-image-manipulator: "npm:~58.0.14" - expo-image-picker: "npm:~58.0.10" - expo-linking: "npm:~58.0.12" - expo-router: "npm:~58.0.18" - expo-speech: "npm:~58.0.3" - react: "npm:19.3.0" - react-native: "npm:0.88.0" - react-native-gesture-handler: "npm:~3.4.0" - react-native-keyboard-controller: "npm:1.22.9" - react-native-reanimated: "npm:4.7.1" - react-native-safe-area-context: "npm:~5.10.1" - react-native-screens: "npm:~4.29.0" - react-native-svg: "npm:15.15.5" - react-native-teleport: "npm:^1.2.2" - react-native-worklets: "npm:0.13.0" - stream-chat: "npm:^10.0.1" - stream-chat-expo: "workspace:^" - stream-chat-react-native-core: "workspace:^" - typescript: "npm:6.0.3" - use-sync-external-store: "npm:^1.7.0" - victory-native: "npm:^42.0.1" - languageName: unknown - linkType: soft - "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -17900,19 +17797,6 @@ __metadata: languageName: node linkType: hard -"react-native-keyboard-controller@npm:1.22.9": - version: 1.22.9 - resolution: "react-native-keyboard-controller@npm:1.22.9" - dependencies: - react-native-is-edge-to-edge: "npm:^1.2.1" - peerDependencies: - react: "*" - react-native: "*" - react-native-reanimated: ">=3.0.0" - checksum: 10c0/f3bde65bb59404eca3662c374d565bb08c5f1e7f34c6e650e705a77aa9d866f9c89e9dda1a8bc398cd0248ce6fb731b7a7fcf2325b2a0fe03c25cbf547a28053 - languageName: node - linkType: hard - "react-native-keyboard-controller@npm:^1.22.7": version: 1.23.0 resolution: "react-native-keyboard-controller@npm:1.23.0"