diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e26a770ef6..cc288b4910 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,9 @@ jobs: - name: Validate ESM bundle with Node ${{ env.NODE_VERSION }} run: yarn validate-esm + - name: Validate main entry does not pull in ai-components + run: yarn validate-entry-isolation + - name: Validate translations are in sync with source # `yarn build` above already ran build-translations, which regenerates keys.ts from the # t() call sites and runtimeDefaults.ts. A diff here means the author changed a call site diff --git a/.prettierignore b/.prettierignore index 4b02ec540d..4713c58482 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,8 @@ node_modules/ CHANGELOG.md examples/**/serviceWorker.ts +examples/*/.next/ +examples/*/next-env.d.ts ./dist/ # Vendored agent skills (third-party content from vercel-labs/agent-skills) diff --git a/AGENTS.md b/AGENTS.md index 57b6fadf66..1404893e9e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,9 +36,9 @@ Respect repo-specific rules. Do not suppress lint rules broadly; justify and sco ## Project layout - `src/` — library source: `components/`, `context/`, `hooks/`, `store/`, `i18n/`, `styling/`, `a11y/`, `plugins/`, `utils/`, `types/`, `constants/`, `mock-builders/` -- `src/plugins/` — separately exported entry points: `ChannelDetail`, `Emojis`, `SlotGeometry`, `SlotLayout`, `encoders` +- `src/plugins/` — separately exported entry points: `AIComponents`, `ChannelDetail`, `Emojis`, `SlotGeometry`, `SlotLayout`, `encoders` - `scripts/` — build/validation scripts -- `examples/` — private example workspaces: `examples/tutorial` (one folder per tutorial step), `examples/vite` (complete app) +- `examples/` — private example workspaces: `examples/tutorial` (one folder per tutorial step), `examples/vite` (complete app), `examples/ai-chatbot` (Next.js AI chatbot) - `ai-docs/` — integrator-facing migration guides (`ai-migration-v14-v15.md`, `i18n-v15-migration.md`, `instance-configuration.md`, …) and the hand-reviewed `i18n-v15-key-map.json` - `developers/` — dev notes (`BRANCHES.md`, `COMMIT.md`, `DEPRECATIONS.md`, `DEVELOPMENT.md`, `DOCUMENTATION.md`, `PR.md`, `RELEASE.md`) @@ -79,10 +79,12 @@ yarn i18n:export # write an en.json on demand (for a translator or TMS) # Bundle smoke tests (run in CI after build) yarn validate-cjs # loads dist/cjs in Node + a browser-like context yarn validate-esm # imports dist/es in Node +yarn validate-entry-isolation # fails if the main entry's ESM graph reaches ai-components or its deps # Examples yarn start:tutorial # @stream-io/stream-chat-react-tutorial dev server yarn start:vite # @stream-io/stream-chat-react-vite dev server +yarn start:ai-chatbot # @stream-io/stream-chat-react-ai-chatbot dev server (run yarn build first) yarn examples:build # build all example workspaces ``` @@ -312,13 +314,14 @@ Mock modules with `vi.mock('../../EmptyStateIndicator', () => ({ … }))`; use ` 1. **`build-translations`** — regenerates `src/i18n/keys.ts` from the `t()` call sites 2. **`vite build`** — bundles the entry points as ESM (`dist/es/*.mjs`, one file per module) + CJS (`dist/cjs/*.js`) 3. **`tsc -p tsconfig.lib.json`** — `.d.ts` only → `dist/types/` -4. **`build-styling`** — Sass → `dist/css/index.css`, `emoji-replacement.css`, `emoji-picker.css`, `channel-detail.css`, plus `cp -r src/styling/assets dist/css/assets` +4. **`build-styling`** — Sass → `dist/css/index.css`, `emoji-replacement.css`, `emoji-picker.css`, `channel-detail.css`, `ai-components.css`, plus `cp -r src/styling/assets dist/css/assets` **Entry points** (`package.json` exports ↔ `vite.config.ts` `lib.entry`): | Import path | Source | | ---------------------------------- | ---------------------------------- | | `stream-chat-react` | `src/index.ts` | +| `stream-chat-react/ai-components` | `src/plugins/AIComponents/` | | `stream-chat-react/channel-detail` | `src/plugins/ChannelDetail/` | | `stream-chat-react/emojis` | `src/plugins/Emojis/` | | `stream-chat-react/mp3-encoder` | `src/plugins/encoders/mp3.ts` | @@ -454,7 +457,7 @@ Follow `.github/pull_request_template.md` (Goal / Implementation details / UI Ch - [ ] Screenshots (before/after) for UI changes - [ ] Public API changes documented -**CI** (`.github/workflows/ci.yml`, on every PR): lint (`yarn lint`, `yarn types`, `yarn types:scripts`) · build + `validate-cjs` + `validate-esm` + `keys.ts` drift check · `yarn coverage` → Codecov · deploy `examples/vite` to Vercel. `pr-check.yml` lints the PR title; `size.yml` reports bundle size. +**CI** (`.github/workflows/ci.yml`, on every PR): lint (`yarn lint`, `yarn types`, `yarn types:scripts`) · build + `validate-cjs` + `validate-esm` + `validate-entry-isolation` + `keys.ts` drift check · `yarn coverage` → Codecov · deploy `examples/vite` to Vercel. `pr-check.yml` lints the PR title; `size.yml` reports bundle size. **Release:** automated via semantic-release (`.releaserc.json`) from commit messages. diff --git a/ai-docs/ai-migration-v14-v15.md b/ai-docs/ai-migration-v14-v15.md index e05cb03254..f5df4de380 100644 --- a/ai-docs/ai-migration-v14-v15.md +++ b/ai-docs/ai-migration-v14-v15.md @@ -300,7 +300,7 @@ An unrenamed key does **not** throw — it simply never matches, and the English instead. Do not assume the absence of an error means the app is migrated. **Renaming:** every old key maps to exactly one new key. The complete table is -[`i18n-v15-key-map.json`](./i18n-v15-key-map.json) (603 rows, `{ "": { "key": "", +[`i18n-v15-key-map.json`](./i18n-v15-key-map.json) (621 rows, `{ "": { "key": "", "prose": bool, "plural"?: bool } }`). Entries with `"prose": false` hold formatter expressions rather than copy. Four of them nonetheless carry English words inside their `calendarFormats` argument — `timestamp.DateSeparator`, `timestamp.ReminderNotification`, diff --git a/ai-docs/i18n-v15-key-map.json b/ai-docs/i18n-v15-key-map.json index 2b384cbaf2..a6dc6f693e 100644 --- a/ai-docs/i18n-v15-key-map.json +++ b/ai-docs/i18n-v15-key-map.json @@ -1,6 +1,6 @@ { "$comment": "Migration table: natural-language translation key (v14) -> namespaced key (v15). Hand-reviewed; nothing regenerates it. Integrators who passed translationsForLanguage/registerTranslation dictionaries keyed on the old strings should use this to rename their keys. See ai-docs/i18n-v15-migration.md.", - "count": 603, + "count": 621, "keys": { "aria/Active": { "key": "a11y.accessibleLabel.active.ariaLabel", @@ -114,6 +114,78 @@ "key": "a11y.interactionAnnouncements.voiceRecordingAttached.ariaLabel", "prose": true }, + "Delete attachment": { + "key": "aiComponents.attachmentPreview.deleteAttachment.ariaLabel", + "prose": true + }, + "Unknown file name": { + "key": "aiComponents.attachmentPreview.unknownFileName.text", + "prose": true + }, + "Loading chart...": { + "key": "aiComponents.chart.loading.text", + "prose": true + }, + "Unknown chart type": { + "key": "aiComponents.chart.unknownType.text", + "prose": true + }, + "aria/Start voice input": { + "key": "aiComponents.messageComposer.speechToText.ariaLabel", + "prose": true + }, + "Ask a question...": { + "key": "aiComponents.messageComposer.textInput.placeholder", + "prose": true + }, + "Brewing up an answer": { + "key": "aiComponents.stateIndicator.brewingUpAnAnswer.text", + "prose": true + }, + "Channeling my inner Einstein": { + "key": "aiComponents.stateIndicator.channelingMyInnerEinstein.text", + "prose": true + }, + "Connecting the dots": { + "key": "aiComponents.stateIndicator.connectingTheDots.text", + "prose": true + }, + "Consulting the AI gods": { + "key": "aiComponents.stateIndicator.consultingTheAiGods.text", + "prose": true + }, + "Cooking up something good": { + "key": "aiComponents.stateIndicator.cookingUpSomethingGood.text", + "prose": true + }, + "Crunching the numbers": { + "key": "aiComponents.stateIndicator.crunchingTheNumbers.text", + "prose": true + }, + "Firing up the neurons": { + "key": "aiComponents.stateIndicator.firingUpTheNeurons.text", + "prose": true + }, + "Putting on my thinking cap": { + "key": "aiComponents.stateIndicator.puttingOnMyThinkingCap.text", + "prose": true + }, + "Reading the digital tea leaves": { + "key": "aiComponents.stateIndicator.readingTheDigitalTeaLeaves.text", + "prose": true + }, + "Summoning my inner genius": { + "key": "aiComponents.stateIndicator.summoningMyInnerGenius.text", + "prose": true + }, + "Thinking really hard": { + "key": "aiComponents.stateIndicator.thinkingReallyHard.text", + "prose": true + }, + "Working my magic": { + "key": "aiComponents.stateIndicator.workingMyMagic.text", + "prose": true + }, "Generating...": { "key": "aiState.indicator.generating.label", "prose": true diff --git a/ai-docs/i18n-v15-migration.md b/ai-docs/i18n-v15-migration.md index c8a10017d7..ad2375c67e 100644 --- a/ai-docs/i18n-v15-migration.md +++ b/ai-docs/i18n-v15-migration.md @@ -30,7 +30,7 @@ imported and copied into `Streami18n` at construction, so they shipped even if y ## Renaming your keys -Every old key maps to exactly one new key. The full table (603 rows) is +Every old key maps to exactly one new key. The full table (621 rows, including the 18 `aiComponents.*` keys of `stream-chat-react/ai-components`) is [`i18n-v15-key-map.json`](./i18n-v15-key-map.json): ```json diff --git a/eslint.config.mjs b/eslint.config.mjs index 793cec55be..a9bd95feb4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -38,6 +38,7 @@ export default tseslint.config( '.yarn', 'examples/*/dist', 'examples/*/node_modules', + 'examples/*/.next', 'examples/*/docs-playwright', ], }, diff --git a/examples/ai-chatbot/.env.example b/examples/ai-chatbot/.env.example new file mode 100644 index 0000000000..fc67d3c525 --- /dev/null +++ b/examples/ai-chatbot/.env.example @@ -0,0 +1,4 @@ +STREAM_API_KEY=YOUR_STREAM_KEY +STREAM_API_SECRET=YOUR_STREAM_SECRET +# Optional: point at your own AI agent server +# NEXT_PUBLIC_AI_SERVER_URL=http://localhost:3000 diff --git a/examples/ai-chatbot/.gitignore b/examples/ai-chatbot/.gitignore new file mode 100644 index 0000000000..4574d8af70 --- /dev/null +++ b/examples/ai-chatbot/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env +.env*.local +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/ai-chatbot/README.md b/examples/ai-chatbot/README.md new file mode 100644 index 0000000000..87328f7305 --- /dev/null +++ b/examples/ai-chatbot/README.md @@ -0,0 +1,36 @@ +# AI chatbot example + +A Next.js chatbot built with `stream-chat-react` and its `stream-chat-react/ai-components` entry point +(streaming messages, AI state indicator, AI message composer). Styles come from +`stream-chat-react/css/ai-components.css`. + +## Getting started + +From the repository root: + +```bash +yarn install +yarn build # the SDK must be built first: the example consumes `dist` through package `exports` +cp examples/ai-chatbot/.env.example examples/ai-chatbot/.env.local +# fill in STREAM_API_KEY and STREAM_API_SECRET in .env.local +yarn start:ai-chatbot +``` + +Open [http://localhost:3000](http://localhost:3000). + +## Environment variables + +- `STREAM_API_KEY`, `STREAM_API_SECRET` - credentials of your Stream app (server-side only, never commit them). +- `NEXT_PUBLIC_AI_SERVER_URL` (optional) - URL of your own AI agent server; defaults to Stream's hosted demo server. + +The hosted demo server only answers for channels of Stream's demo app. With your own `STREAM_API_KEY` / +`STREAM_API_SECRET` the AI agent never joins your channels, so run your own AI agent server (it must +expose `POST /start-ai-agent` and `POST /summarize`, as called from `src/components/api.ts`) and point `NEXT_PUBLIC_AI_SERVER_URL` at it. + +## v15 notes + +- **Token signing.** The server signs the user token itself, in `src/app/createUserToken.ts` (HS256), because `stream-chat` v10 is a client-side SDK with no server-side `createToken`. For production, use one of Stream's server SDKs instead. +- **One copy of `@stream-io/state-store`.** The example keeps `installConfig.hoistingLimits: workspaces`, so the app installs its own `stream-chat` and `@stream-io/state-store` while the linked `stream-chat-react` workspace resolves the copies at the repository root. `stream-chat`, `@stream-io/i18n` and `stream-chat-react` hand each other store instances, so exactly one copy of `@stream-io/state-store` must be loaded. The app dedupes `stream-chat` and `@stream-io/state-store` through the Turbopack `resolveAlias` in `next.config.ts` plus the matching `paths` in `tsconfig.json`. +- **Sidebar pagination.** The sidebar uses infinite scroll: v15's `ChannelList` has no load-more button. + +See [implementation.md](./implementation.md) for implementation notes. diff --git a/examples/ai-chatbot/implementation.md b/examples/ai-chatbot/implementation.md new file mode 100644 index 0000000000..0b5b6fa73d --- /dev/null +++ b/examples/ai-chatbot/implementation.md @@ -0,0 +1,563 @@ +# Implementation: ChatGPT 4/Plus UI Redesign (Next.js) + +## ✅ Completed Features + +### Design Goals + +- ✅ Match ChatGPT 4/Plus UI +- ✅ Next.js App Router architecture with Server Components +- ✅ Component-based architecture with isolated SCSS modules +- ✅ Light/dark theme toggle with localStorage persistence +- ✅ Mobile-responsive navigation with top bar +- ✅ Bookmarkable conversations with URL parameters +- ✅ Browser back/forward button support +- ✅ Override Stream Chat React styles via CSS variables +- ✅ Automatic conversation titles (summarized from the first message) +- ✅ Server-side token generation for secure authentication +- ✅ Rate limiting: 10 messages per conversation per 4 hours +- ✅ UUID-based random user generation with localStorage persistence +- ✅ Animated loading screen with fade-out transition and minimum display time + +--- + +## Project Structure + +``` +examples/ai-chatbot/ +├── src/app/ +│ ├── layout.tsx # ✅ Root layout with metadata +│ ├── page.tsx # ✅ Server Component - generates user token, renders AIChatApp +│ └── createUserToken.ts # ✅ Server-only HS256 user-token signing (node:crypto) +├── src/components/ +│ ├── AIChatApp/ +│ │ ├── AIChatApp.tsx # ✅ Client Component - Chat wrapper with URL state management +│ │ └── AIChatApp.scss # ✅ App layout styles with responsive grid +│ ├── LoadingScreen/ +│ │ ├── LoadingScreen.tsx # ✅ Animated loading screen with fade-out +│ │ ├── LoadingScreen.scss # ✅ Loading animations and theme-aware styles +│ │ └── index.ts +│ ├── Sidebar/ +│ │ ├── Sidebar.tsx # ✅ Channel list sidebar with collapse +│ │ ├── Sidebar.scss # ✅ Dark/light sidebar styles +│ │ ├── SidebarHeader.tsx # ✅ New chat button + logo +│ │ ├── SidebarHeader.scss +│ │ ├── SidebarFooter.tsx # ✅ Theme toggle (desktop) +│ │ ├── SidebarFooter.scss +│ │ ├── ChannelPreviewItem.tsx # ✅ Custom channel preview +│ │ └── ChannelPreviewItem.scss +│ ├── TopNavBar/ +│ │ ├── TopNavBar.tsx # ✅ Mobile navigation with hamburger + theme toggle +│ │ └── TopNavBar.scss +│ ├── ChatContainer/ +│ │ ├── ChatContainer.tsx # ✅ Main chat area wrapper +│ │ └── ChatContainer.scss # ✅ Flexbox layout with overflow management +│ ├── MessageBubble/ +│ │ ├── MessageBubble.tsx # ✅ Custom message component +│ │ └── MessageBubble.scss # ✅ ChatGPT-style bubbles with dynamic sizing +│ ├── MessageInputBar/ +│ │ ├── MessageInputBar.tsx # ✅ AIMessageComposer wrapper +│ │ └── MessageInputBar.scss # ✅ Theme-aware input & model selector +│ ├── AIStateIndicator/ +│ │ ├── AIStateIndicator.tsx # ✅ Custom thinking indicator with random messages +│ │ └── AIStateIndicator.scss +│ ├── EmptyState/ +│ │ ├── EmptyState.tsx # ✅ Empty placeholder +│ │ └── EmptyState.scss # ✅ Theme-aware styling +│ ├── ActiveChannelContext.tsx # ✅ The conversation shown in the chat area (app-owned) +│ ├── ThemeContext.tsx # ✅ Client-side theme state with localStorage +│ ├── UserProvider.tsx # ✅ Client-side user initialization and URL sync +│ ├── rateLimitUtils.ts # ✅ Rate limiting utilities for message tracking +│ ├── api.ts # ✅ API functions (startAiAgent, summarizeConversation) +│ ├── stream-chat.d.ts # ✅ Custom data typing (channel `summary`, message `ai_generated`) +│ └── index.scss # ✅ Global styles + CSS variables for both themes +├── public/ # ✅ Static assets +├── next.config.ts # ✅ Next.js configuration (single copy of stream-chat / state-store) +├── package.json # ✅ Dependencies (Next.js 16, React 19) +└── tsconfig.json # ✅ TypeScript configuration +``` + +--- + +## Color Scheme + +**CSS Variables in `index.scss`:** + +```scss +:root, +:root[data-theme='dark'] { + /* Dark theme colors matching ChatGPT 4 */ + --ai-demo-bg-primary: #212121; // Main background + --ai-demo-bg-secondary: #171717; // Sidebar background + --ai-demo-bg-tertiary: #2f2f2f; // Hover states & inputs + --ai-demo-border: #4a4a4a; // Borders + + /* Text colors */ + --ai-demo-text-primary: #ececec; // Main text + --ai-demo-text-secondary: #b4b4b4; // Secondary text + --ai-demo-text-tertiary: #8e8e8e; // Muted text + + /* Message colors */ + --ai-demo-user-bg: #2f2f2f; // User message background + --ai-demo-ai-bg: #2f2f2f; // AI message background + + /* Accent colors */ + --ai-demo-accent: #10a37f; // ChatGPT green + --ai-demo-accent-hover: #0d8968; + + /* Font */ + --ai-demo-font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', + sans-serif; +} + +:root[data-theme='light'] { + /* Light theme colors */ + --ai-demo-bg-primary: #ffffff; + --ai-demo-bg-secondary: #f7f7f8; + --ai-demo-bg-tertiary: #ececf1; + --ai-demo-border: #d9d9e3; + + --ai-demo-text-primary: #353740; + --ai-demo-text-secondary: #565869; + --ai-demo-text-tertiary: #8e8ea0; + + --ai-demo-user-bg: #ececf1; + --ai-demo-ai-bg: #ececf1; + + --ai-demo-accent: #10a37f; + --ai-demo-accent-hover: #0d8968; +} +``` + +--- + +## Component Details + +### 1. **app/page.tsx (Server Component)** + +✅ Implemented features: + +- **Server-side token generation**: Signs a Stream user token (HS256 JWT, `{ user_id }`) with `STREAM_API_SECRET` in `createUserToken.ts`; `stream-chat` v10 is client-only and has no `createToken()` +- **UUID-based user generation**: Generates random UUID for each user (replaces hardcoded 'jane') +- **User ID override**: Supports `?user_id=` URL parameter for testing/debugging +- Defines the conversation list's filters, sort (`SortParamRequest[]`), page size and request options +- Extracts `conversation_id` from URL params +- Renders `AIChatApp` wrapped in `ThemeProvider` and `UserProvider` +- Passes authentication and configuration props to client component + +### 2. **LoadingScreen (Client Component)** + +✅ Implemented features: + +- **Animated AI icon**: Material Symbols "auto_awesome" with floating animation +- **Pulsing effect**: Circular pulse animation behind the icon +- **Professional copy**: "Stream AI Chat" title with "Initializing Stream AI assistant..." subtitle +- **Loading dots**: Three dots with staggered bounce animation +- **Theme-aware styling**: Uses CSS variables for seamless dark/light mode support +- **Smooth fade-out**: 400ms opacity transition when unmounting +- **Minimum display time**: Guaranteed 750ms display time to prevent jarring flashes +- **Absolute positioning**: Overlays on top of content with z-index: 1000 +- **Controlled unmount**: Three-stage state management (minTime → fadeOut → unmount) + +**Animations:** + +- `float`: Icon moves up and down smoothly (3s loop) +- `pulse`: Background circle expands and fades (2s loop) +- `bounce`: Three dots bounce with 0.2s stagger delays + +### 3. **AIChatApp.tsx (Client Component)** + +✅ Implemented features: + +- Marked as `'use client'` directive for client-side interactivity +- Sets up `Chat` provider with `isMessageAIGenerated` (reads `message.custom.ai_generated`) +- Split into wrapper component and `ChatContent` (uses `useChatContext`) +- Wraps the app in `ActiveChannelProvider`: v15 has no `ChatContext.setActiveChannel`, so the app owns which channel `` renders +- Registers the conversation list as a `ChannelPaginator` on `client.channelManager` +- **Loading Screen Management**: + - Shows `LoadingScreen` until chat client is ready AND 750ms minimum time has elapsed + - Three-stage fade-out: ready state → fade animation (400ms) → unmount + - Prevents jarring flashes when client initializes quickly +- **URL State Management**: Updates URL with `?conversation_id=` when switching channels +- **Browser Navigation**: Handles popstate events for back/forward button support +- **Initial Channel Loading**: Watches the channel from the URL with `getChannel` on mount (`Channel` no longer queries) +- Layout: CSS Grid with collapsible sidebar +- Renders `Sidebar` + `ChatContainer` + +### 4. **Sidebar** + +✅ Implemented features: + +- Theme-aware sidebar with `--ai-demo-bg-secondary` +- Contains `SidebarHeader`, `ChannelLists` (renders the paginator registered on the channel manager), and `SidebarFooter` +- Mobile: Overlay with backdrop, toggle via TopNavBar hamburger +- Desktop: Fixed sidebar with theme toggle in footer +- Width: 260px (desktop), full-width overlay (mobile) +- Smooth transitions for collapse/expand + +### 5. **SidebarHeader** + +✅ Implemented features: + +- "New Chat" button with Material Symbols icon +- Creates new channel with `ai-${nanoId()}` pattern +- Styled as button with hover effect +- ChatGPT logo/branding area + +### 6. **SidebarFooter** + +✅ Implemented features: + +- Theme toggle button (desktop only) +- Material Symbols icons (light_mode/dark_mode) +- Smooth transitions + +### 7. **TopNavBar** + +✅ Implemented features: + +- Mobile navigation bar with hamburger menu +- Shows current conversation title (with ellipsis for long titles) +- Theme toggle button (mobile) +- Material Symbols rounded icons +- Fixed positioning at top + +### 8. **ChannelPreviewItem** + +✅ Implemented features: + +- Shows `channel.data.custom.summary`, falling back to "New Chat" (read reactively from `channel.state`) +- Hover background: `--ai-demo-bg-tertiary` +- Active state: background color change +- Truncate long text with ellipsis +- Theme-aware colors + +### 9. **ChatContainer** + +✅ Implemented features: + +- Wraps `Channel` component (rendered once a channel is active) +- Contains `TopNavBar`, `MessageList`, `AIStateIndicator` and `MessageComposer` (with the `MessageInputBar` UI) in the channel column +- Max-width: 900px, centered +- **Flexbox layout with overflow management** to ensure message list scrolls while keeping indicator and input visible +- Mobile-responsive with top padding for TopNavBar + +### 10. **MessageBubble** + +✅ Implemented features: + +- User messages: right-aligned with `--ai-demo-user-bg`, rounded bubbles (18px) +- AI messages: left-aligned with `--ai-demo-ai-bg`, rounded bubbles (18px) +- **Dynamic sizing**: bubbles grow with content +- Compact padding (0.5rem 0.75rem) matching ChatGPT +- Min-width: 50% for AI messages (better streaming UX) +- Max-width: 70% (user), 80% (AI) +- Avatar hidden + +### 11. **AIStateIndicator** + +✅ Implemented features: + +- Custom thinking indicator with animated dots +- **Random cheesy messages**: "Consulting the AI gods", "Brewing up an answer", etc. +- Messages change every 2 seconds during thinking state +- Theme-aware styling + +### 12. **MessageInputBar** + +✅ Implemented features: + +- Wraps `AIMessageComposer` +- Creates the conversation on first send (`getChannel`), starts the AI agent, sends via `channel.sendMessageWithLocalUpdate` +- Titles a new conversation from its first message (`/summarize`), stored with `channel.update({ data: { custom: { summary } } })` +- Rounded input box (1.5rem border-radius) +- **Theme-aware model selector**: overrides `.str-chat__ai-message-composer__select` +- Theme-aware input field and submit button +- Focus states with accent color +- Responsive padding +- **Rate limiting**: 10 messages per conversation in 4-hour window + - Tracks message count per conversation in localStorage + - Disables all inputs when limit reached + - Shows informative banner with reset countdown + - Automatically resets after 4 hours + - Updates countdown every minute + +### 13. **EmptyState** + +✅ Implemented features: + +- Centered content with welcome message +- Auto-creates channel (existing behavior preserved) +- Theme-aware styling with CSS variables +- Material Symbols icon + +### 14. **ThemeContext (Client Component)** + +✅ Implemented features: + +- React Context for theme state ('light' | 'dark') +- localStorage persistence with key 'ai-demo-theme' +- Respects system preference (prefers-color-scheme) on first load +- Sets `data-theme` attribute on document root +- `useTheme` hook for accessing theme state and toggle function + +### 15. **UserProvider (Client Component)** + +✅ Implemented features: + +- Client-side user initialization and URL synchronization +- Gets or generates UUID for user on first load +- Syncs user ID with URL parameters (`?user_id=`) +- Persists user ID to localStorage for session continuity +- Shows loading state during initialization + +### 16. **rateLimitUtils.ts** + +✅ Implemented features: + +- **getUserId()**: Gets or creates random UUID user ID + - Checks URL params first for `?user_id=` override + - Falls back to localStorage for persistence + - Generates new UUID if neither exists +- **checkRateLimit()**: Checks if conversation has hit rate limit + - Returns `isLimited`, `resetTime`, and `remainingMessages` + - Automatically resets after 4-hour window expires +- **recordMessage()**: Records message sent to conversation + - Tracks message count and first message timestamp + - Stores data in localStorage per conversation + - Resets counter when 4-hour window expires +- **formatTimeRemaining()**: Formats reset time as human-readable string + - Returns format like "3h 45m" or "25m" + - Used in rate limit banner message + +### 17. **api.ts** + +✅ Implemented features: + +- **startAiAgent**: Initiates AI agent for a channel + - Accepts channel, model, and platform parameters + - Posts to `/start-ai-agent` endpoint +- **summarizeConversation**: Generates conversation summaries + - Accepts conversation text + - Posts to `/summarize` endpoint with OpenAI platform + - Returns summary string from API response + +--- + +## Next.js Architecture + +### App Router Structure + +The application uses Next.js App Router with a hybrid Server/Client Component architecture: + +**Server Components:** + +- `app/layout.tsx` - Root layout with metadata +- `src/app/page.tsx` - Main page component that handles server-side token generation + +**Client Components:** + +- `src/components/AIChatApp/AIChatApp.tsx` - Marked with `'use client'` directive +- `src/components/ThemeContext.tsx` - Uses React Context and browser APIs +- All interactive UI components (Sidebar, ChatContainer, etc.) + +**Benefits:** + +- **Security**: Stream API credentials never exposed to client +- **Performance**: Token generation happens on server +- **SEO**: Better metadata handling with Next.js metadata API + +### Environment Variables + +Required in `.env.local`: + +``` +STREAM_API_KEY=your_api_key_here +STREAM_API_SECRET=your_api_secret_here +``` + +These are accessed server-side only in `src/app/page.tsx` using `process.env`. + +### localStorage Keys + +The app uses the following localStorage keys for client-side persistence: + +- `ai-demo-theme` - Stores user's theme preference ('light' | 'dark') +- `user_id` - Stores randomly generated UUID for user identification +- `rate_limit_{conversationId}` - Stores rate limit data per conversation (message count, first message timestamp) + +--- + +## Stream Chat React CSS Variable Overrides + +Implemented globally in `src/components/index.scss`: + +```scss +:root { + /* Override Stream Chat React variables */ + --str-chat__primary-color: var(--ai-demo-accent); + --str-chat__background-color: var(--ai-demo-bg-primary); + --str-chat__channel-background-color: var(--ai-demo-bg-primary); + --str-chat__secondary-background-color: var(--ai-demo-bg-secondary); + --str-chat__border-color: var(--ai-demo-border); + --str-chat__text-color: var(--ai-demo-text-primary); + --str-chat__text-low-emphasis-color: var(--ai-demo-text-secondary); + + /* Channel list overrides */ + --str-chat__channel-preview-hover-background: var(--ai-demo-bg-tertiary); + --str-chat__channel-preview-active-background: var(--ai-demo-bg-tertiary); + + /* Message overrides */ + --str-chat__message-bubble-color: var(--ai-demo-user-bg); + --str-chat__font-family: var(--ai-demo-font-family); +} +``` + +--- + +## Mobile Responsiveness + +✅ **Breakpoint:** 768px + +- ✅ Sidebar overlay with backdrop on mobile +- ✅ TopNavBar with hamburger menu, conversation title, and theme toggle +- ✅ ChatContainer takes full width with top padding for fixed TopNavBar +- ✅ Responsive padding and spacing throughout +- ✅ Smooth transitions for sidebar collapse/expand +- ✅ Touch-friendly button sizes + +--- + +## Technical Highlights + +### URL State Management + +The app uses `window.history.pushState()` to update the URL when switching conversations, and listens to `popstate` events to handle browser back/forward navigation. This makes conversations shareable and bookmarkable. + +### Theme System + +Theme state is managed via React Context and persisted to localStorage. The theme is applied by setting a `data-theme` attribute on the document root, which allows CSS variables to be scoped appropriately. + +### Loading Screen System + +The app implements a sophisticated loading screen with smooth transitions: + +- **Minimum display time**: LoadingScreen always shows for at least 750ms to prevent jarring flashes +- **Three-stage state management**: + 1. `minTimeElapsed` - Tracks whether minimum 750ms has passed + 2. `isFadingOut` - Triggers fade animation when client ready AND time elapsed + 3. `showLoadingScreen` - Controls unmount after 400ms fade completes +- **Smooth fade-out**: CSS transition on opacity (400ms ease-out) +- **Absolute positioning**: z-index: 1000 ensures overlay on top of content +- **Animation polish**: + - Floating AI icon (3s loop) + - Pulsing glow effect (2s loop) + - Staggered bouncing dots (0.2s delays) +- **Theme-aware**: Inherits all color variables for seamless light/dark mode + +**Implementation details:** + +- Timer cleanup on unmount prevents memory leaks +- Fade-out only triggers once via `isFadingOut` guard +- Chat content loads in parallel, hidden behind loading screen until ready +- Prevents "already loaded" flicker on fast connections + +### Rate Limiting System + +The app implements client-side rate limiting to control message sending: + +- **Per-conversation limits**: Each conversation tracks its own message count independently +- **4-hour rolling window**: Limits reset automatically 4 hours after the first message +- **localStorage persistence**: Rate limit data persists across page refreshes +- **Real-time updates**: Countdown updates every minute to show time remaining +- **Graceful UI**: + - Disabled fieldset prevents all input interactions + - Amber warning banner shows informative message with countdown + - Material Symbols info icon for visual clarity +- **UUID-based users**: Each user gets a random UUID stored in localStorage +- **Override support**: `?user_id=` URL parameter allows testing with specific user IDs + +**Implementation details:** + +- Rate limit state checked on mount and when channel changes +- Message recorded after successful send +- Form submission blocked when limit reached +- All inputs (text, file, speech-to-text, model selector, submit) disabled via fieldset + +### Automatic Conversation Summarization + +The app titles a conversation from its first message: + +- **Triggering**: `MessageInputBar` checks after sending whether the channel has a summary yet +- **API integration**: Calls `/summarize` endpoint with OpenAI platform +- **Channel update**: Stores returned summary in `channel.data.custom.summary` + +This summary is displayed in the sidebar's `ChannelPreviewItem` and the `TopNavBar`, falling back to "New Chat" if no summary exists. + +### Flexbox Overflow Management + +The ChatContainer uses a careful flexbox layout to ensure the message list scrolls while the AIStateIndicator and MessageInputBar remain visible: + +### Dynamic Message Bubble Sizing + +Message bubbles grow dynamically based on content, with different constraints for user vs AI messages: + +### SDK Style Overrides + +To properly override the AI Message Composer styles, we target the correct class (`.str-chat__ai-message-composer__select`) which has `all: unset` and `background-color: transparent` set by default: + +--- + +## SCSS Architecture + +Each component has its own isolated SCSS file: + +```tsx +import './ComponentName.scss'; +``` + +**Global styles** in `src/components/index.scss`: + +- CSS variables for both themes +- CSS layers +- Reset styles +- Font imports (Material Symbols Rounded) +- Stream Chat React CSS variable overrides +- Imported once in `src/app/page.tsx` + +**Component styles** follow BEM-like naming: `ai-demo-component__element--modifier` + +**Next.js Integration:** + +- Global styles imported in server component (`src/app/page.tsx`) +- Component-level SCSS imported directly in each component file +- Next.js automatically handles SCSS compilation via built-in support + +--- + +## Build and Development + +### Commands + +Run from the repository root (Yarn workspaces; build the SDK first with `yarn build`): + +```bash +# Development server with hot reload +yarn start:ai-chatbot + +# Production build +yarn workspace @stream-io/stream-chat-react-ai-chatbot build + +# Start production server +yarn workspace @stream-io/stream-chat-react-ai-chatbot start +``` + +### Key Differences from Vite + +- No `vite.config.ts` - configuration in `next.config.ts` +- No separate `index.html` entry point - uses App Router +- Built-in SCSS support without additional plugins +- Server-side rendering capabilities +- Automatic code splitting and optimization + +--- diff --git a/examples/ai-chatbot/next.config.ts b/examples/ai-chatbot/next.config.ts new file mode 100644 index 0000000000..6fba0e46b0 --- /dev/null +++ b/examples/ai-chatbot/next.config.ts @@ -0,0 +1,24 @@ +import type { NextConfig } from 'next'; + +/** + * `installConfig.hoistingLimits: workspaces` installs this app's dependencies — `stream-chat` and + * its `@stream-io/state-store` among them — in its own `node_modules`, while the linked + * `stream-chat-react` workspace resolves the copies at the repository root. The SDK and the client + * hand each other store instances, so the app must run exactly one copy of each: point every + * import, the SDK's included, at the app's own. + * + * `turbopack.resolveAlias` applies to Turbopack only (the `next dev` / `next build` default). + * Running with `--webpack` ignores it and would load duplicate copies of these packages. + */ +const singleCopyPackages = { + '@stream-io/state-store': './node_modules/@stream-io/state-store', + 'stream-chat': './node_modules/stream-chat', +}; + +const nextConfig: NextConfig = { + turbopack: { + resolveAlias: singleCopyPackages, + }, +}; + +export default nextConfig; diff --git a/examples/ai-chatbot/package.json b/examples/ai-chatbot/package.json new file mode 100644 index 0000000000..ceb249cf1b --- /dev/null +++ b/examples/ai-chatbot/package.json @@ -0,0 +1,31 @@ +{ + "name": "@stream-io/stream-chat-react-ai-chatbot", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "installConfig": { + "hoistingLimits": "workspaces" + }, + "dependencies": { + "@stream-io/state-store": "^1.1.6", + "clsx": "^2.1.1", + "material-symbols": "^0.43.0", + "nanoid": "^5.1.7", + "next": "^16.2.4", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "stream-chat": "10.0.0-rc.15", + "stream-chat-react": "workspace:^" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", + "sass": "^1.100.0", + "typescript": "^6.0.3" + } +} diff --git a/examples/ai-chatbot/public/.gitkeep b/examples/ai-chatbot/public/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/examples/ai-chatbot/src/app/createUserToken.ts b/examples/ai-chatbot/src/app/createUserToken.ts new file mode 100644 index 0000000000..9da631e534 --- /dev/null +++ b/examples/ai-chatbot/src/app/createUserToken.ts @@ -0,0 +1,21 @@ +import { createHmac } from 'node:crypto'; + +const base64Url = (value: string | Buffer) => Buffer.from(value).toString('base64url'); + +/** + * Signs a Stream Chat user token (an HS256 JWT whose payload is `{ user_id }`) with the app's API + * secret. Server-only: the secret must never reach the browser. + * + * `stream-chat` v10 is a client-side SDK — its constructor takes no secret and it has no + * `createToken()` — so the example signs the token itself instead of pulling in a server SDK. + */ +export const createUserToken = (userId: string, secret: string) => { + const signingInput = [ + base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })), + base64Url(JSON.stringify({ user_id: userId })), + ].join('.'); + + const signature = createHmac('sha256', secret).update(signingInput).digest(); + + return `${signingInput}.${base64Url(signature)}`; +}; diff --git a/examples/ai-chatbot/src/app/favicon.ico b/examples/ai-chatbot/src/app/favicon.ico new file mode 100644 index 0000000000..718d6fea48 Binary files /dev/null and b/examples/ai-chatbot/src/app/favicon.ico differ diff --git a/examples/ai-chatbot/src/app/layout.tsx b/examples/ai-chatbot/src/app/layout.tsx new file mode 100644 index 0000000000..bd39ac93de --- /dev/null +++ b/examples/ai-chatbot/src/app/layout.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Stream AI Components React Chatbot Demo', + description: 'AI Chat demo with Stream AI Components', +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + {children} + + ); +} diff --git a/examples/ai-chatbot/src/app/page.tsx b/examples/ai-chatbot/src/app/page.tsx new file mode 100644 index 0000000000..c73acb40ea --- /dev/null +++ b/examples/ai-chatbot/src/app/page.tsx @@ -0,0 +1,63 @@ +import type { + ChannelFilters, + ChannelPaginatorRequestOptions, + SortParamRequest, +} from 'stream-chat'; +import { AIChatApp } from '@/components/AIChatApp'; +import { ThemeProvider } from '@/components/ThemeContext'; +import { UserProvider } from '@/components/UserProvider'; +import { createUserToken } from './createUserToken'; + +import '../components/index.scss'; + +const generateUserToken = (userId: string) => { + const apiKey = process.env.STREAM_API_KEY as string | undefined; + const secret = process.env.STREAM_API_SECRET as string | undefined; + if (!apiKey || !secret) { + throw new Error('Stream API key and secret are required'); + } + + const token = createUserToken(userId, secret); + return { apiKey, token }; +}; + +export default async function Home(props: { + searchParams: Promise<{ conversation_id?: string; user_id?: string }>; +}) { + const { conversation_id, user_id } = await props.searchParams; + + // If no user_id provided, generate a random UUID + // The client will persist this in localStorage + const userId = user_id || crypto.randomUUID(); + const { apiKey, token } = generateUserToken(userId); + + const filters: ChannelFilters = { + members: { $in: [userId] }, + type: 'messaging', + archived: false, + }; + const pageSize = 15; + const requestOptions: ChannelPaginatorRequestOptions = { presence: true, state: true }; + const sort: SortParamRequest[] = [ + { field: 'pinned_at', direction: 1 }, + { field: 'last_message_at', direction: -1 }, + { field: 'updated_at', direction: -1 }, + ]; + + return ( + + + + + + ); +} diff --git a/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.scss b/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.scss new file mode 100644 index 0000000000..61f8400b45 --- /dev/null +++ b/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.scss @@ -0,0 +1,7 @@ +.ai-demo-app { + width: 100%; + height: 100%; + display: flex; + background-color: var(--ai-demo-bg-primary); + overflow: hidden; +} diff --git a/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx b/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx new file mode 100644 index 0000000000..ed7ecb627c --- /dev/null +++ b/examples/ai-chatbot/src/components/AIChatApp/AIChatApp.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import type { + ChannelFilters, + ChannelPaginatorRequestOptions, + LocalMessage, + SortParamRequest, +} from 'stream-chat'; +import { ChannelPaginator } from 'stream-chat'; +import { Chat, getChannel, useChatContext, useCreateChatClient } from 'stream-chat-react'; +import { ActiveChannelProvider, useActiveChannel } from '../ActiveChannelContext'; +import { Sidebar } from '../Sidebar'; +import { ChatContainer } from '../ChatContainer'; +import { LoadingScreen } from '../LoadingScreen'; +import './AIChatApp.scss'; + +interface ChannelListQuery { + filters: ChannelFilters; + pageSize: number; + requestOptions: ChannelPaginatorRequestOptions; + sort: SortParamRequest[]; +} + +interface AIChatAppProps extends ChannelListQuery { + apiKey: string; + userToken: string; + userId: string; + initialChannelId?: string; +} + +const CHANNEL_LIST_ID = 'ai-chatbot:conversations'; + +// stream-chat v10 nests custom message fields under `custom`. +const isMessageAIGenerated = (message: LocalMessage) => !!message?.custom?.ai_generated; + +/** + * Registers the sidebar's conversation list on the client's `ChannelManager`, which `ChannelLists` + * renders and keeps in sync with WebSocket events. The query arrives from the server component as + * fresh objects on every navigation, so it is keyed by value rather than by reference: a new + * paginator would reload the list each time the conversation changes. + */ +const useConversationListPaginator = (query: ChannelListQuery) => { + const { client } = useChatContext(); + const queryKey = JSON.stringify(query); + + useEffect(() => { + const { filters, pageSize, requestOptions, sort } = JSON.parse( + queryKey, + ) as ChannelListQuery; + const paginator = new ChannelPaginator({ + client, + filters, + id: CHANNEL_LIST_ID, + paginatorOptions: { pageSize }, + requestOptions, + sort, + }); + client.channelManager.insertPaginator({ paginator }); + + return () => { + client.channelManager.removePaginator(paginator); + }; + }, [client, queryKey]); +}; + +const ChatContent = ({ + initialChannelId, + ...query +}: ChannelListQuery & { initialChannelId?: string }) => { + const [isSidebarOpen, setIsSidebarOpen] = useState(false); + const { client } = useChatContext(); + const { activeChannel: channel, setActiveChannel } = useActiveChannel(); + const router = useRouter(); + const searchParams = useSearchParams(); + + useConversationListPaginator(query); + + // Update URL when channel changes using Next.js router + useEffect(() => { + if (channel?.id) { + const currentConversationId = searchParams.get('conversation_id'); + + // Only push if the conversation_id actually changed + if (currentConversationId !== channel.id) { + const params = new URLSearchParams(searchParams.toString()); + params.set('conversation_id', channel.id); + router.push(`?${params.toString()}`, { scroll: false }); + } + } + }, [channel?.id, searchParams, router]); + + // Load initial channel from URL on mount. `Channel` no longer queries the channel it is given, + // so it is watched here first (`getChannel` de-duplicates concurrent watches). + useEffect(() => { + if (initialChannelId && client && !channel) { + const loadChannel = async () => { + const targetChannel = await getChannel({ + client, + id: initialChannelId, + type: 'messaging', + }); + setActiveChannel(targetChannel); + }; + loadChannel().catch((err) => { + console.error('Failed to load channel', err); + }); + } + }, [initialChannelId, client, channel, setActiveChannel]); + + const toggleSidebar = () => setIsSidebarOpen((prev) => !prev); + const closeSidebar = () => setIsSidebarOpen(false); + + return ( + <> + + + + ); +}; + +export const AIChatApp = ({ + apiKey, + userToken, + userId, + initialChannelId, + ...query +}: AIChatAppProps) => { + const [minTimeElapsed, setMinTimeElapsed] = useState(false); + const [isFadingOut, setIsFadingOut] = useState(false); + const [showLoadingScreen, setShowLoadingScreen] = useState(true); + + const chatClient = useCreateChatClient({ + apiKey, + tokenOrProvider: userToken, + userData: { id: userId }, + }); + + // Ensure loading screen shows for at least 750ms + useEffect(() => { + const timer = setTimeout(() => { + setMinTimeElapsed(true); + }, 750); + + return () => clearTimeout(timer); + }, []); + + // Handle fade-out when both conditions are met + useEffect(() => { + if (!minTimeElapsed) return; + setIsFadingOut(true); + // Remove loading screen after animation completes (400ms) + const fadeOutTimer = setTimeout(() => { + setShowLoadingScreen(false); + }, 400); + + return () => clearTimeout(fadeOutTimer); + }, [minTimeElapsed]); + + return ( +
+ {chatClient && ( + + + + + + )} + {showLoadingScreen && } +
+ ); +}; diff --git a/examples/ai-chatbot/src/components/AIChatApp/index.ts b/examples/ai-chatbot/src/components/AIChatApp/index.ts new file mode 100644 index 0000000000..45ec40b5bb --- /dev/null +++ b/examples/ai-chatbot/src/components/AIChatApp/index.ts @@ -0,0 +1 @@ +export { AIChatApp } from './AIChatApp'; diff --git a/examples/ai-chatbot/src/components/AIStateIndicator/AIStateIndicator.scss b/examples/ai-chatbot/src/components/AIStateIndicator/AIStateIndicator.scss new file mode 100644 index 0000000000..3d52eb1611 --- /dev/null +++ b/examples/ai-chatbot/src/components/AIStateIndicator/AIStateIndicator.scss @@ -0,0 +1,11 @@ +:root { + --str-chat__ai-bg-primary: var(--ai-demo-bg-primary); + --str-chat__ai-bg-secondary: var(--ai-demo-bg-secondary); + --str-chat__ai-bg-tertiary: var(--ai-demo-bg-tertiary); + --str-chat__ai-text-primary: var(--ai-demo-text-primary); + --str-chat__ai-text-secondary: var(--ai-demo-text-secondary); +} + +.str-chat__ai-state-indicator__content { + background-color: var(--ai-demo-bg-tertiary); +} diff --git a/examples/ai-chatbot/src/components/AIStateIndicator/AIStateIndicator.tsx b/examples/ai-chatbot/src/components/AIStateIndicator/AIStateIndicator.tsx new file mode 100644 index 0000000000..05e738aee9 --- /dev/null +++ b/examples/ai-chatbot/src/components/AIStateIndicator/AIStateIndicator.tsx @@ -0,0 +1,29 @@ +'use client'; + +import type { AIState, MessagePaginatorAggregateState } from 'stream-chat'; +import { AIStates } from 'stream-chat'; +import { useAIState, useChannel, useStateStore } from 'stream-chat-react'; +import { AIStateIndicator as StateIndicator } from 'stream-chat-react/ai-components'; +import './AIStateIndicator.scss'; + +// `AIStates` is a literal-typed `as const` in stream-chat v10, so the list is widened to `AIState` +// to accept the value `useAIState` returns. +const VISIBLE_STATES: readonly AIState[] = [AIStates.Generating, AIStates.Thinking]; + +const lastMessageIdSelector = ({ lastMessage }: MessagePaginatorAggregateState) => ({ + lastMessageId: lastMessage?.id, +}); + +export const AIStateIndicator = () => { + const channel = useChannel(); + const { aiState } = useAIState(channel); + const { lastMessageId } = useStateStore( + channel.messagePaginator.aggregateState, + lastMessageIdSelector, + ); + + if (!VISIBLE_STATES.includes(aiState)) return null; + + // Remount (restarting the animation) whenever a new message lands in the conversation. + return ; +}; diff --git a/examples/ai-chatbot/src/components/AIStateIndicator/index.ts b/examples/ai-chatbot/src/components/AIStateIndicator/index.ts new file mode 100644 index 0000000000..d5864c1da3 --- /dev/null +++ b/examples/ai-chatbot/src/components/AIStateIndicator/index.ts @@ -0,0 +1 @@ +export { AIStateIndicator } from './AIStateIndicator'; diff --git a/examples/ai-chatbot/src/components/ActiveChannelContext.tsx b/examples/ai-chatbot/src/components/ActiveChannelContext.tsx new file mode 100644 index 0000000000..21bbba120b --- /dev/null +++ b/examples/ai-chatbot/src/components/ActiveChannelContext.tsx @@ -0,0 +1,39 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { createContext, useContext, useMemo, useState } from 'react'; +import type { Channel } from 'stream-chat'; + +interface ActiveChannelContextType { + activeChannel: Channel | undefined; + setActiveChannel: (channel: Channel) => void; +} + +const ActiveChannelContext = createContext( + undefined, +); + +/** + * The conversation shown in the chat area. stream-chat-react v15 has no `setActiveChannel` on + * `ChatContext`: whoever renders `` owns which channel that is, so the app + * keeps it here and the sidebar, "New chat" button and URL loader write to it. + */ +export const useActiveChannel = () => { + const context = useContext(ActiveChannelContext); + if (!context) { + throw new Error('useActiveChannel must be used within ActiveChannelProvider'); + } + return context; +}; + +export const ActiveChannelProvider = ({ children }: { children: ReactNode }) => { + const [activeChannel, setActiveChannel] = useState(); + + const value = useMemo(() => ({ activeChannel, setActiveChannel }), [activeChannel]); + + return ( + + {children} + + ); +}; diff --git a/examples/ai-chatbot/src/components/ChatContainer/ChatContainer.scss b/examples/ai-chatbot/src/components/ChatContainer/ChatContainer.scss new file mode 100644 index 0000000000..46d914e881 --- /dev/null +++ b/examples/ai-chatbot/src/components/ChatContainer/ChatContainer.scss @@ -0,0 +1,83 @@ +.ai-demo-chat-container { + flex: 1; + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + position: relative; + + /* Override Stream Chat Channel styles */ + .str-chat__channel { + flex: 1; + width: 100%; + max-width: 100%; + background-color: var(--ai-demo-bg-primary); + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + + .str-chat__li { + margin: 8px 0; + } + + /* Message list panel, laid out in the channel column above the AI state indicator and composer */ + .str-chat__thread, + .str-chat__main-panel-inner { + display: flex; + flex-direction: column; + overflow: hidden; + overflow-y: scroll; + min-height: 0; + flex: 1; + } + + .str-chat__virtual-list { + background-color: var(--ai-demo-bg-primary); + padding: 1rem 0; + flex: 1; + overflow-y: scroll; + display: flex; + flex-direction: column; + min-height: 0; + } + + /* Override message list styles */ + .str-chat__message-list { + background-color: var(--ai-demo-bg-primary); + width: 100%; + flex: 1; + overflow-y: auto; + overflow-x: hidden; + } + + .str-chat__message-list-scroll { + background-color: var(--ai-demo-bg-primary); + max-width: 900px; + width: 100%; + margin: 0 auto; + padding: 0 1rem; + } + + /* Override AI state indicator */ + .str-chat__ai-state-indicator { + padding: 0.75rem 1.5rem; + color: var(--ai-demo-text-secondary); + font-size: 0.875rem; + } + + /* Override message composer */ + .str-chat__message-composer { + background-color: transparent; + border: none; + } + + /* Mobile responsiveness */ + @media (max-width: 768px) { + .str-chat__message-list-scroll { + max-width: 100%; + padding: 0 0.5rem; + } + } +} diff --git a/examples/ai-chatbot/src/components/ChatContainer/ChatContainer.tsx b/examples/ai-chatbot/src/components/ChatContainer/ChatContainer.tsx new file mode 100644 index 0000000000..f4985985af --- /dev/null +++ b/examples/ai-chatbot/src/components/ChatContainer/ChatContainer.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useEffect } from 'react'; +import { + Channel, + MessageComposer, + MessageList, + useChatContext, + WithComponents, +} from 'stream-chat-react'; +import { useActiveChannel } from '../ActiveChannelContext'; +import { createDraftConversation } from '../createDraftConversation'; +import { EmptyState } from '../EmptyState'; +import { MessageBubble } from '../MessageBubble'; +import { MessageInputBar } from '../MessageInputBar'; +import { AIStateIndicator } from '../AIStateIndicator'; +import { TopNavBar } from '../TopNavBar'; +import './ChatContainer.scss'; + +interface ChatContainerProps { + onToggleSidebar: () => void; +} + +const NoOp = () => null; + +export const ChatContainer = ({ onToggleSidebar }: ChatContainerProps) => { + const { client } = useChatContext(); + const { activeChannel: channel, setActiveChannel } = useActiveChannel(); + + useEffect(() => { + if (!channel) { + setActiveChannel(createDraftConversation(client)); + } + }, [channel, client, setActiveChannel]); + + return ( +
+ + {channel && ( + + + + + + + )} + +
+ ); +}; diff --git a/examples/ai-chatbot/src/components/ChatContainer/index.ts b/examples/ai-chatbot/src/components/ChatContainer/index.ts new file mode 100644 index 0000000000..16361bf56e --- /dev/null +++ b/examples/ai-chatbot/src/components/ChatContainer/index.ts @@ -0,0 +1 @@ +export { ChatContainer } from './ChatContainer'; diff --git a/examples/ai-chatbot/src/components/EmptyState/EmptyState.scss b/examples/ai-chatbot/src/components/EmptyState/EmptyState.scss new file mode 100644 index 0000000000..242a0e5082 --- /dev/null +++ b/examples/ai-chatbot/src/components/EmptyState/EmptyState.scss @@ -0,0 +1,30 @@ +.ai-demo-empty-state { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + + &__content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 2rem; + max-width: 600px; + } + + &__title { + font-size: 2rem; + font-weight: 600; + color: var(--ai-demo-text-primary); + margin: 0 0 0.75rem 0; + } + + &__subtitle { + font-size: 1rem; + color: var(--ai-demo-text-secondary); + margin: 0; + line-height: 1.5; + } +} diff --git a/examples/ai-chatbot/src/components/EmptyState/EmptyState.tsx b/examples/ai-chatbot/src/components/EmptyState/EmptyState.tsx new file mode 100644 index 0000000000..0304c7f3cb --- /dev/null +++ b/examples/ai-chatbot/src/components/EmptyState/EmptyState.tsx @@ -0,0 +1,14 @@ +'use client'; + +import './EmptyState.scss'; + +export const EmptyState = () => ( +
+
+

Start a conversation

+

+ Ask me anything, and I'll do my best to help. +

+
+
+); diff --git a/examples/ai-chatbot/src/components/EmptyState/index.ts b/examples/ai-chatbot/src/components/EmptyState/index.ts new file mode 100644 index 0000000000..89798dbd68 --- /dev/null +++ b/examples/ai-chatbot/src/components/EmptyState/index.ts @@ -0,0 +1 @@ +export { EmptyState } from './EmptyState'; diff --git a/examples/ai-chatbot/src/components/LoadingScreen/LoadingScreen.scss b/examples/ai-chatbot/src/components/LoadingScreen/LoadingScreen.scss new file mode 100644 index 0000000000..fa3717970b --- /dev/null +++ b/examples/ai-chatbot/src/components/LoadingScreen/LoadingScreen.scss @@ -0,0 +1,131 @@ +.ai-demo-loading { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: var(--ai-demo-bg-primary); + color: var(--ai-demo-text-primary); + z-index: 1000; + opacity: 1; + transition: opacity 400ms ease-out; + + &--fading-out { + opacity: 0; + } +} + +.ai-demo-loading__content { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.5rem; + padding: 2rem; + max-width: 400px; + text-align: center; +} + +.ai-demo-loading__icon-container { + position: relative; + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; +} + +.ai-demo-loading__icon { + font-size: 48px; + color: var(--ai-demo-accent); + z-index: 1; + animation: float 3s ease-in-out infinite; +} + +.ai-demo-loading__pulse { + position: absolute; + width: 80px; + height: 80px; + border-radius: 50%; + background-color: var(--ai-demo-accent); + opacity: 0.2; + animation: pulse 2s ease-in-out infinite; +} + +.ai-demo-loading__title { + font-size: 2rem; + font-weight: 600; + margin: 0; + color: var(--ai-demo-text-primary); + letter-spacing: -0.02em; +} + +.ai-demo-loading__subtitle { + font-size: 1rem; + margin: 0; + color: var(--ai-demo-text-secondary); + font-weight: 400; +} + +.ai-demo-loading__dots { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.ai-demo-loading__dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: var(--ai-demo-accent); + animation: bounce 1.4s ease-in-out infinite; + + &:nth-child(1) { + animation-delay: 0s; + } + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } +} + +@keyframes pulse { + 0%, + 100% { + transform: scale(1); + opacity: 0.2; + } + 50% { + transform: scale(1.3); + opacity: 0.1; + } +} + +@keyframes float { + 0%, + 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +@keyframes bounce { + 0%, + 80%, + 100% { + transform: scale(0.8); + opacity: 0.5; + } + 40% { + transform: scale(1.2); + opacity: 1; + } +} diff --git a/examples/ai-chatbot/src/components/LoadingScreen/LoadingScreen.tsx b/examples/ai-chatbot/src/components/LoadingScreen/LoadingScreen.tsx new file mode 100644 index 0000000000..023d236629 --- /dev/null +++ b/examples/ai-chatbot/src/components/LoadingScreen/LoadingScreen.tsx @@ -0,0 +1,25 @@ +import './LoadingScreen.scss'; + +interface LoadingScreenProps { + isFadingOut?: boolean; +} + +export const LoadingScreen = ({ isFadingOut = false }: LoadingScreenProps) => ( +
+
+
+ + auto_awesome + +
+
+

Stream AI Chat

+

Initializing Stream AI assistant...

+
+ + + +
+
+
+); diff --git a/examples/ai-chatbot/src/components/LoadingScreen/index.ts b/examples/ai-chatbot/src/components/LoadingScreen/index.ts new file mode 100644 index 0000000000..72fc0af4a1 --- /dev/null +++ b/examples/ai-chatbot/src/components/LoadingScreen/index.ts @@ -0,0 +1 @@ +export { LoadingScreen } from './LoadingScreen'; diff --git a/examples/ai-chatbot/src/components/MessageBubble/MessageBubble.scss b/examples/ai-chatbot/src/components/MessageBubble/MessageBubble.scss new file mode 100644 index 0000000000..ffab0f0a0f --- /dev/null +++ b/examples/ai-chatbot/src/components/MessageBubble/MessageBubble.scss @@ -0,0 +1,92 @@ +.ai-demo-message { + display: flex; + width: 100%; + + &--user { + justify-content: flex-end; + + .ai-demo-message__bubble { + background-color: var(--ai-demo-user-bg); + border-radius: 18px; + max-width: 100%; + } + } + + &--ai { + justify-content: flex-start; + + .ai-demo-message__bubble { + background-color: transparent; + border-radius: 0; + padding: 0; + min-width: 50%; + width: auto; + } + } + + &__inner { + display: flex; + flex-direction: column; + max-width: 100%; + } + + &__bubble { + color: var(--ai-demo-text-primary); + font-size: 0.9375rem; + line-height: 1.6; + word-wrap: break-word; + overflow-wrap: break-word; + + /* Override Stream Chat attachment styles */ + .str-chat__attachment-list { + margin: 0; + } + + .str-chat__message-attachment { + border-radius: 8px; + overflow: hidden; + } + + /* Code blocks in AI messages */ + pre { + background-color: var(--ai-demo-bg-tertiary); + border-radius: 8px; + padding: 1rem; + margin: 0.5rem 0; + overflow-x: auto; + } + + code { + font-family: + 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', + monospace; + font-size: 0.875rem; + } + + /* Inline code */ + p code, + li code { + background-color: var(--ai-demo-bg-tertiary); + padding: 0.2em 0.4em; + border-radius: 4px; + } + } + + &--highlighted { + background-color: rgba(16, 163, 127, 0.1); + } + + &--failed { + opacity: 0.7; + } + + /* Mobile responsiveness */ + @media (max-width: 768px) { + padding: 0.25rem 0; + + &--ai .ai-demo-message__bubble { + min-width: 60%; + padding: 0; + } + } +} diff --git a/examples/ai-chatbot/src/components/MessageBubble/MessageBubble.tsx b/examples/ai-chatbot/src/components/MessageBubble/MessageBubble.tsx new file mode 100644 index 0000000000..1445f22430 --- /dev/null +++ b/examples/ai-chatbot/src/components/MessageBubble/MessageBubble.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useMemo } from 'react'; +import clsx from 'clsx'; +import { StreamingMessage } from 'stream-chat-react/ai-components'; +import { Attachment, messageHasAttachments, useMessageContext } from 'stream-chat-react'; +import './MessageBubble.scss'; + +export const MessageBubble = () => { + const { message, isMyMessage, highlighted, handleAction } = useMessageContext(); + + const hasAttachment = messageHasAttachments(message); + const finalAttachments = useMemo( + () => + !message.shared_location && !message.attachments + ? [] + : !message.shared_location + ? message.attachments + : [message.shared_location, ...(message.attachments ?? [])], + [message], + ); + + const rootClassName = clsx( + 'ai-demo-message', + `ai-demo-message--${message.type}`, + `ai-demo-message--${message.status}`, + { + 'ai-demo-message--user': isMyMessage(), + 'ai-demo-message--ai': !isMyMessage(), + 'ai-demo-message--has-text': !!message.text, + 'ai-demo-message--has-attachment': hasAttachment, + 'ai-demo-message--highlighted': highlighted, + 'ai-demo-message--can-retry': + message?.status === 'failed' && message?.error?.status !== 403, + }, + ); + + return ( +
+
+
+ {finalAttachments?.length ? ( + + ) : null} + + +
+
+
+ ); +}; diff --git a/examples/ai-chatbot/src/components/MessageBubble/index.ts b/examples/ai-chatbot/src/components/MessageBubble/index.ts new file mode 100644 index 0000000000..fd86462dc4 --- /dev/null +++ b/examples/ai-chatbot/src/components/MessageBubble/index.ts @@ -0,0 +1 @@ +export { MessageBubble } from './MessageBubble'; diff --git a/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.scss b/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.scss new file mode 100644 index 0000000000..1fed67589b --- /dev/null +++ b/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.scss @@ -0,0 +1,139 @@ +.ai-demo-message-input-bar { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 1.5rem; + background-color: var(--ai-demo-bg-primary); + color: var(--ai-demo-text-primary); + border-top: 1px solid var(--ai-demo-border); + + .ai-demo-rate-limit-message { + width: 100%; + max-width: 900px; + padding: 1rem 1.25rem; + margin-bottom: 1rem; + background-color: rgba(255, 193, 7, 0.1); + border: 1px solid rgba(255, 193, 7, 0.3); + border-radius: 12px; + color: var(--ai-demo-text-primary); + font-size: 0.9375rem; + line-height: 1.5; + + display: flex; + gap: 8px; + } + + [data-theme='dark'] & .rate-limit-message { + background-color: rgba(255, 193, 7, 0.15); + } + + /* Disabled state styles */ + fieldset:disabled { + opacity: 0.5; + cursor: not-allowed; + + * { + pointer-events: none; + } + } + + /* Override AIMessageComposer styles */ + .str-chat__ai-message-composer__form { + width: 100%; + max-width: 900px; + } + + .str-chat__ai-message-composer__form { + background-color: var(--ai-demo-bg-secondary); + border: 1px solid var(--ai-demo-border); + border-radius: 24px; + padding: 0.75rem 1rem; + transition: all 0.2s ease; + box-shadow: 0 0 0 0 transparent; + + &:focus-within { + border-color: var(--ai-demo-accent); + box-shadow: 0 0 0 1px rgba(16, 163, 127, 0.2); + } + } + + .str-chat__ai-message-composer__form__textarea { + background-color: transparent !important; + color: var(--ai-demo-text-primary); + font-family: var(--ai-demo-font-family); + font-size: 0.9375rem; + border: none; + outline: none; + resize: none; + max-height: 200px; + line-height: 1.5; + width: 100%; + + &::placeholder { + color: var(--ai-demo-text-secondary); + opacity: 0.8; + } + } + + /* Additional specificity for textarea */ + textarea.str-chat__ai-message-composer__form__textarea, + input.str-chat__ai-message-composer__form__textarea { + color: var(--ai-demo-text-primary) !important; + } + + .str-chat__ai-message-composer__select { + color: var(--ai-demo-text-primary); + border-color: var(--ai-demo-border); + border-radius: 8px; + // padding: 0.5rem 0.75rem; + font-family: var(--ai-demo-font-family); + font-size: 0.875rem; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: var(--ai-demo-accent); + } + + &:focus { + outline: none; + border-color: var(--ai-demo-accent); + box-shadow: 0 0 0 1px rgba(16, 163, 127, 0.2); + } + + option { + background-color: var(--ai-demo-bg-primary); + color: var(--ai-demo-text-primary); + } + } + + /* Button hover states */ + .str-chat__ai-message-composer__round-button { + &:hover:not(:disabled) { + background-color: var(--ai-demo-bg-tertiary) !important; + } + + &[aria-pressed='true'] { + background-color: rgba(0, 95, 255, 0.2) !important; + } + } + + /* Mobile responsiveness */ + @media (max-width: 768px) { + padding: 1rem; + + .ai-message-composer { + max-width: 100%; + } + + .ai-message-composer__form { + border-radius: 16px; + padding: 0.5rem 0.75rem; + } + } +} + +[data-theme='dark'] .str-chat__ai-message-composer__select { + background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' version='1.1' height='10px' width='15px'%3E%3Ctext x='0' y='10' fill='white'%3E%E2%96%BE%3C/text%3E%3C/svg%3E"); +} diff --git a/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx b/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx new file mode 100644 index 0000000000..5098d665a9 --- /dev/null +++ b/examples/ai-chatbot/src/components/MessageInputBar/MessageInputBar.tsx @@ -0,0 +1,236 @@ +'use client'; + +import { AIMessageComposer } from 'stream-chat-react/ai-components'; +import { useEffect, useState } from 'react'; +import { + type Channel, + isImageFile, + type LocalUploadAttachment, + type UploadRequestFn, +} from 'stream-chat'; +import { + getChannel, + useAttachmentsForPreview, + useChannel, + useChatContext, + useMessageComposerController, +} from 'stream-chat-react'; +import { startAiAgent, summarizeConversation } from '@/components/api'; +import { + checkRateLimit, + formatTimeRemaining, + recordMessage, +} from '@/components/rateLimitUtils'; +import './MessageInputBar.scss'; + +const isWatchedByAI = (channel: Channel) => + Object.keys(channel.state.watchers).some((watcher) => watcher.startsWith('ai-bot')); + +const availableModels = [ + { platform: 'openai', value: 'gpt-5.4-mini', label: 'GPT-5.4 mini' }, + { platform: 'openai', value: 'gpt-5.4-nano', label: 'GPT-5.4 nano' }, + { + platform: 'gemini', + value: 'gemini-flash-latest', + label: 'Gemini Flash (latest)', + }, +] as const; + +export const MessageInputBar = () => { + const { client } = useChatContext(); + const channel = useChannel(); + const composer = useMessageComposerController(); + + const { attachments } = useAttachmentsForPreview(); + const [selectedPlatformModel, setSelectedPlatformModel] = useState(); + const [rateLimitState, setRateLimitState] = useState<{ + isLimited: boolean; + resetTime: number | null; + remainingMessages: number; + }>({ + isLimited: false, + resetTime: null, + remainingMessages: 10, + }); + + // Check rate limit when channel changes or on mount + useEffect(() => { + if (!channel?.id) return; + + const updateRateLimit = () => { + const state = checkRateLimit(channel.id!); + setRateLimitState(state); + }; + + updateRateLimit(); + + const interval = setInterval(updateRateLimit, 60000); + return () => clearInterval(interval); + }, [channel?.id]); + + useEffect(() => { + if (!composer) return; + + const upload: UploadRequestFn = async (fileLike) => { + const request = { file: fileLike as File }; + const { file, thumb_url } = isImageFile(fileLike) + ? await client.uploadImage(request) + : await client.uploadFile(request); + + if (!file) throw new Error('The upload succeeded but returned no file URL'); + + return { file, thumb_url }; + }; + + const previousDefault = composer.attachmentManager.doDefaultUploadRequest; + + composer.attachmentManager.setCustomUploadFn(upload); + + return () => composer.attachmentManager.setCustomUploadFn(previousDefault); + }, [client, composer]); + + return ( +
+ {rateLimitState.isLimited && rateLimitState.resetTime && ( +
+ info + + Limit reached, 10 messages per conversation. Resets in{' '} + {formatTimeRemaining(rateLimitState.resetTime)}. + +
+ )} + { + const input = e.currentTarget.elements.namedItem( + 'attachments', + ) as HTMLInputElement | null; + + const files = input?.files ?? null; + + if (files) { + composer.attachmentManager.uploadFiles(files); + } + }} + onSubmit={async (e) => { + const event = e; + event.preventDefault(); + + // Check rate limit before processing + if (rateLimitState.isLimited) return; + + const target = event.currentTarget; + + const formData = new FormData(target); + + const message = formData.get('message'); + const platformModel = formData.get('platform-model'); + setSelectedPlatformModel(platformModel as string); + + composer.textComposer.setText(message as string); + + const composedData = await composer.compose(); + + if (!composedData) return; + + target.reset(); + composer.clear(); + + const { localMessage, message: messageRequest, sendOptions } = composedData; + + // Show the message right away: creating the conversation and starting the agent + // below take a round trip each before the send itself. + channel.messagePaginator.ingestItem(localMessage); + + // A new conversation exists only locally until now; `Channel` no longer creates it. + if (!channel.initialized) { + await getChannel({ channel, client }); + } + + const [platform, model] = (platformModel as string).split('|'); + + if (!isWatchedByAI(channel)) { + await startAiAgent(channel, model, platform); + } + + await channel.sendMessageWithLocalUpdate({ + localMessage, + message: messageRequest, + options: sendOptions, + }); + + // Record message after successful send + recordMessage(channel.id!); + + // Update rate limit state + const newState = checkRateLimit(channel.id!); + setRateLimitState(newState); + + if (!channel.data?.custom?.summary) { + const summary = await summarizeConversation(message as string).catch(() => { + console.warn('Failed to summarize conversation'); + return null; + }); + + if (typeof summary === 'string' && summary.length > 0) { + await channel.update({ data: { custom: { summary } } }); + } + } + }} + > + + {attachments.map((attachment) => ( + { + composer.attachmentManager.removeAttachments([ + attachment.localMetadata.id, + ]); + }} + onRetry={() => { + composer.attachmentManager.uploadAttachment( + attachment as LocalUploadAttachment, + ); + }} + /> + ))} + + +
+
+ + + + {availableModels.map((model) => ( + + ))} + + } + /> +
+ + 0} /> +
+
+
+ ); +}; diff --git a/examples/ai-chatbot/src/components/MessageInputBar/index.ts b/examples/ai-chatbot/src/components/MessageInputBar/index.ts new file mode 100644 index 0000000000..534e0c1000 --- /dev/null +++ b/examples/ai-chatbot/src/components/MessageInputBar/index.ts @@ -0,0 +1 @@ +export { MessageInputBar } from './MessageInputBar'; diff --git a/examples/ai-chatbot/src/components/Sidebar/ChannelPreviewItem.scss b/examples/ai-chatbot/src/components/Sidebar/ChannelPreviewItem.scss new file mode 100644 index 0000000000..39791a74a8 --- /dev/null +++ b/examples/ai-chatbot/src/components/Sidebar/ChannelPreviewItem.scss @@ -0,0 +1,36 @@ +.ai-demo-channel-preview { + padding: 0.75rem 1rem; + cursor: pointer; + border-radius: 8px; + margin: 0.25rem 0.5rem; + transition: background-color 0.2s ease; + position: relative; + + &:hover { + background-color: var(--ai-demo-bg-tertiary); + } + + &--active { + background-color: var(--ai-demo-bg-tertiary); + + &::before { + content: ''; + position: absolute; + left: 0; + top: 8px; + bottom: 8px; + width: 3px; + background-color: var(--ai-demo-accent); + border-radius: 0 2px 2px 0; + } + } + + &__text { + color: var(--ai-demo-text-primary); + font-size: 0.875rem; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/examples/ai-chatbot/src/components/Sidebar/ChannelPreviewItem.tsx b/examples/ai-chatbot/src/components/Sidebar/ChannelPreviewItem.tsx new file mode 100644 index 0000000000..e28c3ff8d6 --- /dev/null +++ b/examples/ai-chatbot/src/components/Sidebar/ChannelPreviewItem.tsx @@ -0,0 +1,27 @@ +'use client'; + +import type { ChannelStateData } from 'stream-chat'; +import type { ChannelListItemUIProps } from 'stream-chat-react'; +import { useStateStore } from 'stream-chat-react'; +import { useActiveChannel } from '@/components/ActiveChannelContext'; +import './ChannelPreviewItem.scss'; + +const summarySelector = ({ data }: ChannelStateData) => ({ + summary: data?.custom?.summary, +}); + +export const ChannelPreviewItem = (props: ChannelListItemUIProps) => { + const { channel } = props; + const { activeChannel, setActiveChannel } = useActiveChannel(); + const { summary } = useStateStore(channel.state, summarySelector); + const isActive = activeChannel?.id === channel.id; + + return ( +
setActiveChannel(channel)} + > +
{summary ?? 'New Chat'}
+
+ ); +}; diff --git a/examples/ai-chatbot/src/components/Sidebar/Sidebar.scss b/examples/ai-chatbot/src/components/Sidebar/Sidebar.scss new file mode 100644 index 0000000000..2f9132647d --- /dev/null +++ b/examples/ai-chatbot/src/components/Sidebar/Sidebar.scss @@ -0,0 +1,52 @@ +.ai-demo-sidebar { + width: 260px; + height: 100%; + background-color: var(--ai-demo-bg-secondary); + border-right: 1px solid var(--ai-demo-border); + display: flex; + flex-direction: column; + flex-shrink: 0; + transition: transform 0.3s ease; + + &__list { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + + /* The channel list scrolls itself, so it loads the next page when scrolled to the bottom */ + .str-chat__infinite-scroll-paginator { + flex: 1; + min-height: 0; + padding: 0.5rem 0; + } + } + + /* Mobile styles */ + @media (max-width: 768px) { + position: fixed; + top: 0; + left: 0; + z-index: 1000; + transform: translateX(-100%); + + &--open { + transform: translateX(0); + } + } +} + +.ai-demo-sidebar-backdrop { + display: none; + + @media (max-width: 768px) { + display: block; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 999; + } +} diff --git a/examples/ai-chatbot/src/components/Sidebar/Sidebar.tsx b/examples/ai-chatbot/src/components/Sidebar/Sidebar.tsx new file mode 100644 index 0000000000..dfeb876060 --- /dev/null +++ b/examples/ai-chatbot/src/components/Sidebar/Sidebar.tsx @@ -0,0 +1,33 @@ +'use client'; +import { ChannelLists, WithComponents } from 'stream-chat-react'; +import { SidebarHeader } from './SidebarHeader'; +import { SidebarFooter } from './SidebarFooter'; +import { ChannelPreviewItem } from './ChannelPreviewItem'; +import './Sidebar.scss'; + +interface SidebarProps { + isOpen: boolean; + onClose: () => void; +} + +const NoOp = () => null; + +export const Sidebar = ({ isOpen, onClose }: SidebarProps) => ( + <> + {/* Backdrop for mobile */} + {isOpen &&
} + +
+ +
+ + {/* Renders the conversation list `AIChatApp` registers on the channel manager. */} + + +
+ +
+ +); diff --git a/examples/ai-chatbot/src/components/Sidebar/SidebarFooter.scss b/examples/ai-chatbot/src/components/Sidebar/SidebarFooter.scss new file mode 100644 index 0000000000..3c8f556c97 --- /dev/null +++ b/examples/ai-chatbot/src/components/Sidebar/SidebarFooter.scss @@ -0,0 +1,44 @@ +.ai-demo-sidebar-footer { + padding: 1rem; + border-top: 1px solid var(--ai-demo-border); + background-color: var(--ai-demo-bg-secondary); + + &__theme-btn { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--ai-demo-border); + border-radius: 8px; + background-color: transparent; + color: var(--ai-demo-text-primary); + font-family: var(--ai-demo-font-family); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.75rem; + transition: background-color 0.2s ease; + + &:hover { + background-color: var(--ai-demo-bg-tertiary); + } + + &:active { + background-color: var(--ai-demo-border); + } + + .material-symbols-rounded { + font-size: 1.25rem; + font-variation-settings: + 'FILL' 0, + 'wght' 400, + 'GRAD' 0, + 'opsz' 24; + } + } + + &__theme-text { + flex: 1; + text-align: left; + } +} diff --git a/examples/ai-chatbot/src/components/Sidebar/SidebarFooter.tsx b/examples/ai-chatbot/src/components/Sidebar/SidebarFooter.tsx new file mode 100644 index 0000000000..bcfe5db80f --- /dev/null +++ b/examples/ai-chatbot/src/components/Sidebar/SidebarFooter.tsx @@ -0,0 +1,25 @@ +'use client'; +import { useTheme } from '@/components/ThemeContext'; +import './SidebarFooter.scss'; + +export const SidebarFooter = () => { + const { theme, toggleTheme } = useTheme(); + + return ( +
+ +
+ ); +}; diff --git a/examples/ai-chatbot/src/components/Sidebar/SidebarHeader.scss b/examples/ai-chatbot/src/components/Sidebar/SidebarHeader.scss new file mode 100644 index 0000000000..f056d04cbc --- /dev/null +++ b/examples/ai-chatbot/src/components/Sidebar/SidebarHeader.scss @@ -0,0 +1,38 @@ +.ai-demo-sidebar-header { + padding: 1rem; + border-bottom: 1px solid var(--ai-demo-border); + + &__new-chat-btn { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--ai-demo-border); + border-radius: 8px; + background-color: transparent; + color: var(--ai-demo-text-primary); + font-family: var(--ai-demo-font-family); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + transition: background-color 0.2s ease; + + &:hover { + background-color: var(--ai-demo-bg-tertiary); + } + + &:active { + background-color: var(--ai-demo-border); + } + + .material-symbols-rounded { + font-size: 1.25rem; + font-variation-settings: + 'FILL' 0, + 'wght' 400, + 'GRAD' 0, + 'opsz' 24; + } + } +} diff --git a/examples/ai-chatbot/src/components/Sidebar/SidebarHeader.tsx b/examples/ai-chatbot/src/components/Sidebar/SidebarHeader.tsx new file mode 100644 index 0000000000..616cc309ec --- /dev/null +++ b/examples/ai-chatbot/src/components/Sidebar/SidebarHeader.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { useChatContext } from 'stream-chat-react'; +import { useActiveChannel } from '@/components/ActiveChannelContext'; +import { createDraftConversation } from '@/components/createDraftConversation'; +import './SidebarHeader.scss'; + +export const SidebarHeader = () => { + const { client } = useChatContext(); + const { setActiveChannel } = useActiveChannel(); + + const handleNewChat = () => { + // Check if there's unsent text in the composer + // We'll check the text input element directly + const textInput = document.querySelector( + '.str-chat__ai-message-composer__text-input', + ); + const hasUnsentText = textInput?.value?.trim(); + + if (hasUnsentText) { + const confirmed = window.confirm( + 'You have unsent text. Are you sure you want to start a new chat?', + ); + if (!confirmed) return; + } + + setActiveChannel(createDraftConversation(client)); + }; + + return ( +
+ +
+ ); +}; diff --git a/examples/ai-chatbot/src/components/Sidebar/index.ts b/examples/ai-chatbot/src/components/Sidebar/index.ts new file mode 100644 index 0000000000..a983e07b91 --- /dev/null +++ b/examples/ai-chatbot/src/components/Sidebar/index.ts @@ -0,0 +1,4 @@ +export { Sidebar } from './Sidebar'; +export { SidebarHeader } from './SidebarHeader'; +export { SidebarFooter } from './SidebarFooter'; +export { ChannelPreviewItem } from './ChannelPreviewItem'; diff --git a/examples/ai-chatbot/src/components/ThemeContext.tsx b/examples/ai-chatbot/src/components/ThemeContext.tsx new file mode 100644 index 0000000000..2ffd3fa0f5 --- /dev/null +++ b/examples/ai-chatbot/src/components/ThemeContext.tsx @@ -0,0 +1,59 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { createContext, useContext, useEffect, useState } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + toggleTheme: () => void; +} + +const ThemeContext = createContext(undefined); + +export const useTheme = () => { + const context = useContext(ThemeContext); + if (!context) { + throw new Error('useTheme must be used within ThemeProvider'); + } + return context; +}; + +interface ThemeProviderProps { + children: ReactNode; +} + +export const ThemeProvider = ({ children }: ThemeProviderProps) => { + const [theme, setTheme] = useState(() => { + if (typeof window === 'undefined') return 'light'; + + // Check localStorage first + const saved = localStorage.getItem('ai-demo-theme') as Theme; + if (saved) return saved; + + // Check system preference + if (window.matchMedia('(prefers-color-scheme: light)').matches) { + return 'light'; + } + + return 'dark'; + }); + + useEffect(() => { + // Update the data-theme attribute on the root element + document.documentElement.setAttribute('data-theme', theme); + // Save to localStorage + localStorage.setItem('ai-demo-theme', theme); + }, [theme]); + + const toggleTheme = () => { + setTheme((prev) => (prev === 'dark' ? 'light' : 'dark')); + }; + + return ( + + {children} + + ); +}; diff --git a/examples/ai-chatbot/src/components/TopNavBar/TopNavBar.scss b/examples/ai-chatbot/src/components/TopNavBar/TopNavBar.scss new file mode 100644 index 0000000000..0e8bbe5eb3 --- /dev/null +++ b/examples/ai-chatbot/src/components/TopNavBar/TopNavBar.scss @@ -0,0 +1,87 @@ +.ai-demo-top-nav { + display: none; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background-color: var(--ai-demo-bg-secondary); + border-bottom: 1px solid var(--ai-demo-border); + gap: 1rem; + position: sticky; + top: 0; + z-index: 10; + + &__menu-btn { + background-color: transparent; + border: none; + color: var(--ai-demo-text-primary); + cursor: pointer; + padding: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + transition: background-color 0.2s ease; + flex-shrink: 0; + min-width: 40px; + height: 40px; + + &:hover { + background-color: var(--ai-demo-bg-tertiary); + } + + &:active { + background-color: var(--ai-demo-border); + } + + .material-symbols-rounded { + font-size: 1.5rem; + line-height: 1; + } + } + + &__title { + flex: 1; + font-size: 1rem; + font-weight: 600; + color: var(--ai-demo-text-primary); + margin: 0; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + } + + &__theme-btn { + background-color: transparent; + border: none; + color: var(--ai-demo-text-primary); + cursor: pointer; + padding: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + transition: background-color 0.2s ease; + flex-shrink: 0; + min-width: 40px; + height: 40px; + + &:hover { + background-color: var(--ai-demo-bg-tertiary); + } + + &:active { + background-color: var(--ai-demo-border); + } + + .material-symbols-rounded { + font-size: 1.5rem; + line-height: 1; + } + } + + @media (max-width: 768px) { + display: flex; + } +} diff --git a/examples/ai-chatbot/src/components/TopNavBar/TopNavBar.tsx b/examples/ai-chatbot/src/components/TopNavBar/TopNavBar.tsx new file mode 100644 index 0000000000..ca3f894d2c --- /dev/null +++ b/examples/ai-chatbot/src/components/TopNavBar/TopNavBar.tsx @@ -0,0 +1,49 @@ +'use client'; + +import type { ChannelStateData } from 'stream-chat'; +import { useChannel, useStateStore } from 'stream-chat-react'; +import { useTheme } from '@/components/ThemeContext'; + +import './TopNavBar.scss'; + +interface TopNavBarProps { + onToggleSidebar: () => void; +} + +const summarySelector = ({ data }: ChannelStateData) => ({ + summary: data?.custom?.summary, +}); + +export const TopNavBar = ({ onToggleSidebar }: TopNavBarProps) => { + const channel = useChannel(); + const { summary } = useStateStore(channel.state, summarySelector); + const { theme, toggleTheme } = useTheme(); + + const conversationTitle = summary ?? 'New Chat'; + + return ( +
+ + +

{conversationTitle}

+ + +
+ ); +}; diff --git a/examples/ai-chatbot/src/components/TopNavBar/index.ts b/examples/ai-chatbot/src/components/TopNavBar/index.ts new file mode 100644 index 0000000000..f855f5c525 --- /dev/null +++ b/examples/ai-chatbot/src/components/TopNavBar/index.ts @@ -0,0 +1 @@ +export { TopNavBar } from './TopNavBar'; diff --git a/examples/ai-chatbot/src/components/UserProvider.tsx b/examples/ai-chatbot/src/components/UserProvider.tsx new file mode 100644 index 0000000000..81e8ea9a19 --- /dev/null +++ b/examples/ai-chatbot/src/components/UserProvider.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { getUserId } from './rateLimitUtils'; + +/** + * Client component that handles user initialization and URL synchronization + */ +export const UserProvider = ({ children }: { children: React.ReactNode }) => { + const router = useRouter(); + const searchParams = useSearchParams(); + + useEffect(() => { + const urlUserId = searchParams.get('user_id'); + const currentUserId = getUserId(); + + // If no user_id in URL, add it + if (!urlUserId) { + const params = new URLSearchParams(searchParams.toString()); + params.set('user_id', currentUserId); + router.replace(`?${params.toString()}`); + } + }, [router, searchParams]); + + return children; +}; diff --git a/examples/ai-chatbot/src/components/ai-demo.scss b/examples/ai-chatbot/src/components/ai-demo.scss new file mode 100644 index 0000000000..40b523d259 --- /dev/null +++ b/examples/ai-chatbot/src/components/ai-demo.scss @@ -0,0 +1,46 @@ +/* Stream Chat React CSS Variable Overrides for AI Demo */ + +:root { + /* Override Stream Chat React variables */ + --str-chat__primary-color: var(--ai-demo-accent); + --str-chat__background-color: var(--ai-demo-bg-primary); + --str-chat__secondary-background-color: var(--ai-demo-bg-secondary); + --str-chat__border-color: var(--ai-demo-border); + --str-chat__text-color: var(--ai-demo-text-primary); + --str-chat__text-low-emphasis-color: var(--ai-demo-text-secondary); + + /* Channel overrides */ + --str-chat__channel-background-color: var(--ai-demo-bg-primary); + --str-chat__channel-preview-hover-background: var(--ai-demo-bg-tertiary); + --str-chat__channel-preview-active-background: var(--ai-demo-bg-tertiary); + + /* Message overrides */ + --str-chat__message-bubble-color: var(--ai-demo-user-bg); + --str-chat__font-family: var(--ai-demo-font-family); +} + +/* Additional Stream Chat component overrides */ +.str-chat { + background-color: var(--ai-demo-bg-primary); + + &__channel-list { + background-color: var(--ai-demo-bg-secondary); + border-right: 1px solid var(--ai-demo-border); + } + + &__empty-channel { + background-color: var(--ai-demo-bg-primary); + } + + &__channel { + background-color: var(--ai-demo-bg-primary); + } + + &__message-list { + background-color: var(--ai-demo-bg-primary); + } + + &__input { + background-color: var(--ai-demo-bg-primary); + } +} diff --git a/examples/ai-chatbot/src/components/api.ts b/examples/ai-chatbot/src/components/api.ts new file mode 100644 index 0000000000..14684b71fa --- /dev/null +++ b/examples/ai-chatbot/src/components/api.ts @@ -0,0 +1,30 @@ +import type { Channel } from 'stream-chat'; + +const baseApiUrl = + process.env.NEXT_PUBLIC_AI_SERVER_URL ?? + 'https://ai-sdk-server-0f347d455e2e.herokuapp.com'; + +export const startAiAgent = async ( + channel: Channel, + model: string | File | null, + platform: 'openai' | 'anthropic' | 'gemini' | 'xai' | (string & {}) = 'openai', +) => + await fetch(`${baseApiUrl}/start-ai-agent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + channel_id: channel.id, + channel_type: channel.type, + platform, + model, + }), + }); + +export const summarizeConversation = (text: string): Promise => + fetch(`${baseApiUrl}/summarize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, platform: 'openai' }), + }) + .then((res) => res.json()) + .then((json) => json.summary); diff --git a/examples/ai-chatbot/src/components/createDraftConversation.ts b/examples/ai-chatbot/src/components/createDraftConversation.ts new file mode 100644 index 0000000000..7ff941afe1 --- /dev/null +++ b/examples/ai-chatbot/src/components/createDraftConversation.ts @@ -0,0 +1,19 @@ +import type { StreamChat } from 'stream-chat'; +import { customAlphabet } from 'nanoid'; + +const nanoId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 10); + +/** + * Creates the local (not yet server-side) channel for a new AI conversation. + * + * The conversation is created on the server only when its first message is sent, so the composer + * would see no `upload-file` capability until then. Seeding it locally lets the custom upload + * function run before that; the server's capabilities replace it on watch. + */ +export const createDraftConversation = (client: StreamChat) => { + const channel = client.channel('messaging', `ai-${nanoId()}`, { + members: [{ user_id: client.userID as string }], + }); + channel.data = { ...channel.data, own_capabilities: ['upload-file'] }; + return channel; +}; diff --git a/examples/ai-chatbot/src/components/index.scss b/examples/ai-chatbot/src/components/index.scss new file mode 100644 index 0000000000..73a79dadcd --- /dev/null +++ b/examples/ai-chatbot/src/components/index.scss @@ -0,0 +1,175 @@ +@layer material-rounded, stream, stream-overrides, ai-chat, ai-demo; + +@import 'stream-chat-react/css/index.css' layer(stream); +@import 'material-symbols/rounded.css' layer(material-rounded); +@import 'stream-chat-react/css/ai-components.css' layer(ai-chat); +@import './ai-demo.scss' layer(ai-demo); + +@layer stream-overrides { + .str-chat__channel { + flex-grow: 1; + } + + .str-chat__message--other { + .str-chat__message-inner { + margin-inline-end: 0; + } + } + .str-chat__message--other { + .str-chat__message-bubble { + max-width: unset; + background-color: transparent; + } + } + + .str-chat__message--me { + .str-chat__message-inner { + margin-inline-start: 0; + } + } +} + +.material-symbols-outlined { + font-variation-settings: + 'FILL' 1, + 'wght' 300, + 'GRAD' 0, + 'opsz' 40; +} + +:root { + margin: 0; + padding: 0; + + /* Font */ + --ai-demo-font-family: + -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica', 'Arial', + sans-serif; + + font-family: var(--ai-demo-font-family); + line-height: 1.5; + font-weight: 400; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Dark Theme (default) */ +:root, +:root[data-theme='dark'] { + /* ChatGPT 4/Plus Dark Theme Colors */ + --ai-demo-bg-primary: #212121; + --ai-demo-bg-secondary: #171717; + --ai-demo-bg-tertiary: #2f2f2f; + --ai-demo-border: #4a4a4a; + + /* Text colors */ + --ai-demo-text-primary: #ececec; + --ai-demo-text-secondary: #b4b4b4; + --ai-demo-text-tertiary: #8e8e8e; + + /* Message colors */ + --ai-demo-user-bg: #343541; + --ai-demo-ai-bg: transparent; + + /* Accent colors */ + --ai-demo-accent: #005fff; + --ai-demo-accent-hover: #0d8968; + + --str-chat__ai-syntax-text: #e5e7eb; + --str-chat__ai-syntax-selection-bg: #4a4a4a; + --str-chat__ai-syntax-comment: #9ca3af; + --str-chat__ai-syntax-punctuation: #d1d5db; + --str-chat__ai-syntax-number: #fbbf24; + --str-chat__ai-syntax-keyword: #a78bfa; + --str-chat__ai-syntax-tag: #f87171; + --str-chat__ai-syntax-string: #34d399; + --str-chat__ai-syntax-function: #60a5fa; + --str-chat__ai-syntax-cyan: #22d3ee; + --str-chat__ai-syntax-error: #f87171; + --str-chat__ai-syntax-whitespace: hsla(0, 0%, 100%, 0.1); + + color-scheme: dark; + color: var(--ai-demo-text-primary); + background-color: var(--ai-demo-bg-primary); +} + +/* Light Theme */ +:root[data-theme='light'] { + /* ChatGPT Light Theme Colors */ + --ai-demo-bg-primary: #ffffff; + --ai-demo-bg-secondary: #f7f7f8; + --ai-demo-bg-tertiary: #ececf1; + --ai-demo-border: #d9d9e3; + + /* Text colors */ + --ai-demo-text-primary: #353740; + --ai-demo-text-secondary: #565869; + --ai-demo-text-tertiary: #8e8ea0; + + /* Message colors */ + --ai-demo-user-bg: #f4f4f4; + --ai-demo-ai-bg: transparent; + + /* Accent colors */ + --ai-demo-accent: #005fff; + --ai-demo-accent-hover: #0d8968; + + --str-chat__ai-syntax-text: #2e3440; + --str-chat__ai-syntax-selection-bg: #d8dee9; + --str-chat__ai-syntax-comment: #6b7280; + --str-chat__ai-syntax-punctuation: #4b5563; + --str-chat__ai-syntax-number: #d97706; + --str-chat__ai-syntax-keyword: #8b5cf6; + --str-chat__ai-syntax-tag: #dc2626; + --str-chat__ai-syntax-string: #059669; + --str-chat__ai-syntax-function: #2563eb; + --str-chat__ai-syntax-cyan: #0891b2; + --str-chat__ai-syntax-error: #ef4444; + --str-chat__ai-syntax-whitespace: hsla(220, 14%, 30%, 0.15); + + color-scheme: light; + color: var(--ai-demo-text-primary); + background-color: var(--ai-demo-bg-primary); +} + +body { + margin: 0; + padding: 0; + width: 100vw; + height: 100vh; +} + +#root { + width: 100%; + height: 100%; + display: flex; +} + +/* Custom ChatGPT-style scrollbar */ +* { + scrollbar-width: thin; + scrollbar-color: var(--ai-demo-border) transparent; +} + +*::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background-color: var(--ai-demo-border); + border-radius: 4px; + border: 2px solid transparent; + background-clip: padding-box; +} + +*::-webkit-scrollbar-thumb:hover { + background-color: var(--ai-demo-bg-tertiary); +} diff --git a/examples/ai-chatbot/src/components/rateLimitUtils.ts b/examples/ai-chatbot/src/components/rateLimitUtils.ts new file mode 100644 index 0000000000..7e60f11962 --- /dev/null +++ b/examples/ai-chatbot/src/components/rateLimitUtils.ts @@ -0,0 +1,174 @@ +/** + * Rate limiting utilities for managing user message limits + * Limits: 10 messages per conversation in a 4-hour interval + */ + +const RATE_LIMIT_KEY_PREFIX = 'rate_limit_'; +const USER_ID_KEY = 'user_id'; +const MESSAGE_LIMIT = 10; +const TIME_WINDOW_MS = 4 * 60 * 60 * 1000; // 4 hours in milliseconds + +interface RateLimitData { + messageCount: number; + firstMessageTimestamp: number; + conversationId: string; +} + +/** + * Get or create user ID + * Checks for user_id in URL params first, then localStorage + * If neither exists, generates a new UUID + */ +export const getUserId = (): string => { + // Check URL params first + if (typeof window !== 'undefined') { + const params = new URLSearchParams(window.location.search); + const urlUserId = params.get('user_id'); + if (urlUserId) { + // Store in localStorage for persistence + localStorage.setItem(USER_ID_KEY, urlUserId); + return urlUserId; + } + + // Check localStorage + const storedUserId = localStorage.getItem(USER_ID_KEY); + if (storedUserId) { + return storedUserId; + } + + // Generate new UUID + const newUserId = crypto.randomUUID(); + localStorage.setItem(USER_ID_KEY, newUserId); + return newUserId; + } + + // Fallback for server-side rendering + return crypto.randomUUID(); +}; + +/** + * Get rate limit data for a conversation + */ +export const getRateLimitData = (conversationId: string): RateLimitData | null => { + if (typeof window === 'undefined') return null; + + const key = `${RATE_LIMIT_KEY_PREFIX}${conversationId}`; + const stored = localStorage.getItem(key); + + if (!stored) return null; + + try { + return JSON.parse(stored) as RateLimitData; + } catch { + return null; + } +}; + +/** + * Save rate limit data for a conversation + */ +const saveRateLimitData = (data: RateLimitData): void => { + if (typeof window === 'undefined') return; + + const key = `${RATE_LIMIT_KEY_PREFIX}${data.conversationId}`; + localStorage.setItem(key, JSON.stringify(data)); +}; + +/** + * Check if rate limit has been exceeded + * Returns an object with isLimited flag and resetTime if limited + */ +export const checkRateLimit = ( + conversationId: string, +): { + isLimited: boolean; + resetTime: number | null; + remainingMessages: number; +} => { + const data = getRateLimitData(conversationId); + + if (!data) { + return { + isLimited: false, + resetTime: null, + remainingMessages: MESSAGE_LIMIT, + }; + } + + const now = Date.now(); + const timeElapsed = now - data.firstMessageTimestamp; + + // If time window has passed, reset the limit + if (timeElapsed >= TIME_WINDOW_MS) { + return { + isLimited: false, + resetTime: null, + remainingMessages: MESSAGE_LIMIT, + }; + } + + // Check if limit exceeded + const isLimited = data.messageCount >= MESSAGE_LIMIT; + const resetTime = isLimited ? data.firstMessageTimestamp + TIME_WINDOW_MS : null; + const remainingMessages = Math.max(0, MESSAGE_LIMIT - data.messageCount); + + return { + isLimited, + resetTime, + remainingMessages, + }; +}; + +/** + * Record a new message sent in the conversation + */ +export const recordMessage = (conversationId: string): void => { + const data = getRateLimitData(conversationId); + const now = Date.now(); + + if (!data) { + // First message in this conversation + saveRateLimitData({ + messageCount: 1, + firstMessageTimestamp: now, + conversationId, + }); + return; + } + + const timeElapsed = now - data.firstMessageTimestamp; + + if (timeElapsed >= TIME_WINDOW_MS) { + // Time window has passed, reset the counter + saveRateLimitData({ + messageCount: 1, + firstMessageTimestamp: now, + conversationId, + }); + } else { + // Increment the counter + saveRateLimitData({ + ...data, + messageCount: data.messageCount + 1, + }); + } +}; + +/** + * Format time remaining until reset + */ +export const formatTimeRemaining = (resetTime: number): string => { + const now = Date.now(); + const msRemaining = resetTime - now; + + if (msRemaining <= 0) return '0m'; + + const hours = Math.floor(msRemaining / (60 * 60 * 1000)); + const minutes = Math.floor((msRemaining % (60 * 60 * 1000)) / (60 * 1000)); + + if (hours > 0) { + return `${hours}h ${minutes}m`; + } + + return `${minutes}m`; +}; diff --git a/examples/ai-chatbot/src/components/stream-chat.d.ts b/examples/ai-chatbot/src/components/stream-chat.d.ts new file mode 100644 index 0000000000..3059adccb4 --- /dev/null +++ b/examples/ai-chatbot/src/components/stream-chat.d.ts @@ -0,0 +1,18 @@ +import 'stream-chat'; + +/** + * stream-chat v10 nests app-specific fields under `custom` (`channel.data.custom`, + * `message.custom`, …) and types them through these interfaces. This example declares the fields + * it reads via module augmentation. + */ +declare module 'stream-chat' { + interface CustomChannelData { + /** Conversation title, generated from the first message and shown in the sidebar and header. */ + summary?: string; + } + + interface CustomMessageData { + /** Set by the AI agent server on the messages it writes. */ + ai_generated?: boolean; + } +} diff --git a/examples/ai-chatbot/tsconfig.json b/examples/ai-chatbot/tsconfig.json new file mode 100644 index 0000000000..bbe1f03b46 --- /dev/null +++ b/examples/ai-chatbot/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"], + "@stream-io/state-store": ["./node_modules/@stream-io/state-store"], + "stream-chat": ["./node_modules/stream-chat"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/examples/vite/src/i18n/de.ts b/examples/vite/src/i18n/de.ts index c63b3feaf3..b49836d561 100644 --- a/examples/vite/src/i18n/de.ts +++ b/examples/vite/src/i18n/de.ts @@ -55,6 +55,29 @@ export const deTranslations = { 'a11y.interactionAnnouncements.voiceMessageSent.ariaLabel': 'Sprachnachricht gesendet', 'a11y.interactionAnnouncements.voiceRecordingAttached.ariaLabel': 'Sprachaufnahme angehängt', + 'aiComponents.attachmentPreview.deleteAttachment.ariaLabel': 'Anhang löschen', + 'aiComponents.attachmentPreview.unknownFileName.text': 'Unbekannter Dateiname', + 'aiComponents.chart.loading.text': 'Diagramm wird geladen...', + 'aiComponents.chart.unknownType.text': 'Unbekannter Diagrammtyp', + 'aiComponents.messageComposer.speechToText.ariaLabel': 'Spracheingabe starten', + 'aiComponents.messageComposer.textInput.placeholder': 'Stelle eine Frage...', + 'aiComponents.stateIndicator.brewingUpAnAnswer.text': 'Ich braue eine Antwort zusammen', + 'aiComponents.stateIndicator.channelingMyInnerEinstein.text': + 'Ich channele meinen inneren Einstein', + 'aiComponents.stateIndicator.connectingTheDots.text': 'Ich verbinde die Punkte', + 'aiComponents.stateIndicator.consultingTheAiGods.text': 'Ich befrage die KI-Götter', + 'aiComponents.stateIndicator.cookingUpSomethingGood.text': + 'Ich koche etwas Feines zusammen', + 'aiComponents.stateIndicator.crunchingTheNumbers.text': 'Ich zerkaue die Zahlen', + 'aiComponents.stateIndicator.firingUpTheNeurons.text': 'Ich werfe die Neuronen an', + 'aiComponents.stateIndicator.puttingOnMyThinkingCap.text': + 'Ich setze meine Denkerkappe auf', + 'aiComponents.stateIndicator.readingTheDigitalTeaLeaves.text': + 'Ich lese die digitalen Teeblätter', + 'aiComponents.stateIndicator.summoningMyInnerGenius.text': + 'Ich beschwöre mein inneres Genie', + 'aiComponents.stateIndicator.thinkingReallyHard.text': 'Ich denke angestrengt nach', + 'aiComponents.stateIndicator.workingMyMagic.text': 'Ich lasse meine Magie wirken', 'aiState.indicator.generating.label': 'Wird erstellt...', 'aiState.indicator.thinking.label': 'Denkt nach...', 'attachment.actions.giphyActions.ariaLabel': 'Giphy-Aktionen', diff --git a/examples/vite/src/i18n/it.ts b/examples/vite/src/i18n/it.ts index a895f04a7d..ee46d3032f 100644 --- a/examples/vite/src/i18n/it.ts +++ b/examples/vite/src/i18n/it.ts @@ -46,6 +46,29 @@ export const itTranslations = { 'a11y.interactionAnnouncements.voiceMessageSent.ariaLabel': 'Messaggio vocale inviato', 'a11y.interactionAnnouncements.voiceRecordingAttached.ariaLabel': 'Registrazione vocale allegata', + 'aiComponents.attachmentPreview.deleteAttachment.ariaLabel': 'Elimina allegato', + 'aiComponents.attachmentPreview.unknownFileName.text': 'Nome file sconosciuto', + 'aiComponents.chart.loading.text': 'Caricamento del grafico...', + 'aiComponents.chart.unknownType.text': 'Tipo di grafico sconosciuto', + 'aiComponents.messageComposer.speechToText.ariaLabel': 'Avvia input vocale', + 'aiComponents.messageComposer.textInput.placeholder': 'Fai una domanda...', + 'aiComponents.stateIndicator.brewingUpAnAnswer.text': 'Sto preparando una risposta', + 'aiComponents.stateIndicator.channelingMyInnerEinstein.text': + 'Evoco il mio Einstein interiore', + 'aiComponents.stateIndicator.connectingTheDots.text': 'Collego i puntini', + 'aiComponents.stateIndicator.consultingTheAiGods.text': "Consulto gli dei dell'IA", + 'aiComponents.stateIndicator.cookingUpSomethingGood.text': + 'Sto cucinando qualcosa di buono', + 'aiComponents.stateIndicator.crunchingTheNumbers.text': 'Sto elaborando i numeri', + 'aiComponents.stateIndicator.firingUpTheNeurons.text': 'Accendo i neuroni', + 'aiComponents.stateIndicator.puttingOnMyThinkingCap.text': + 'Mi metto il cappello del pensatore', + 'aiComponents.stateIndicator.readingTheDigitalTeaLeaves.text': + 'Leggo i fondi di tè digitali', + 'aiComponents.stateIndicator.summoningMyInnerGenius.text': + 'Evoco il mio genio interiore', + 'aiComponents.stateIndicator.thinkingReallyHard.text': 'Sto pensando intensamente', + 'aiComponents.stateIndicator.workingMyMagic.text': 'Faccio la mia magia', 'aiState.indicator.generating.label': 'Generazione in corso...', 'aiState.indicator.thinking.label': 'Sto pensando...', 'attachment.actions.giphyActions.ariaLabel': 'Azioni Giphy', diff --git a/package.json b/package.json index ffd1a693fa..aaee70bd0b 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,12 @@ "require": "./dist/cjs/slot-layout.js", "default": "./dist/cjs/slot-layout.js" }, + "./ai-components": { + "types": "./dist/types/plugins/AIComponents/index.d.ts", + "import": "./dist/es/ai-components.mjs", + "require": "./dist/cjs/ai-components.js", + "default": "./dist/cjs/ai-components.js" + }, "./dist/css/*": { "default": "./dist/css/*" }, @@ -75,11 +81,15 @@ ], "slot-layout": [ "./dist/types/plugins/SlotLayout/index.d.ts" + ], + "ai-components": [ + "./dist/types/plugins/AIComponents/index.d.ts" ] } }, "sideEffects": [ - "*.css" + "*.css", + "./dist/es/plugins/AIComponents/AIMarkdown/tools/charts/Chart.mjs" ], "keywords": [ "chat", @@ -96,6 +106,7 @@ "@react-aria/focus": "^3.22.0", "@stream-io/i18n": "^1.0.2", "@stream-io/state-store": "^1.1.6", + "chart.js": "^4.5.1", "clsx": "^2.1.1", "emoji-regex": "^9.2.2", "fix-webm-duration": "^1.0.6", @@ -107,10 +118,12 @@ "lodash.uniqby": "^4.7.0", "mdast-util-to-string": "^4.0.0", "nanoid": "^3.3.12", + "react-chartjs-2": "^5.3.1", "react-dropzone": "^14.4.1", "react-fast-compare": "^3.2.2", "react-markdown": "^9.1.0", "react-player": "2.10.1", + "react-syntax-highlighter": "^16.1.0", "react-textarea-autosize": "^8.5.9", "react-virtuoso": "^2.19.1", "remark-gfm": "^4.0.1", @@ -118,7 +131,8 @@ "unified": "^11.0.5", "unist-builder": "^4.0.0", "unist-util-visit": "^5.1.0", - "use-sync-external-store": "^1.7.0" + "use-sync-external-store": "^1.7.0", + "zod": "^4.1.12" }, "optionalDependencies": { "@stream-io/transliterate": "^1.5.5" @@ -174,6 +188,7 @@ "@types/node": "^24.13.3", "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", + "@types/react-syntax-highlighter": "^15.5.13", "@types/use-sync-external-store": "^1.5.0", "@vitest/coverage-v8": "^4.1.9", "@vitest/eslint-plugin": "^1.6.20", @@ -205,7 +220,7 @@ "scripts": { "clean": "rm -rf dist", "build": "yarn clean && concurrently 'yarn build-translations' 'vite build' 'tsc --project tsconfig.lib.json' 'yarn build-styling'", - "build-styling": "sass src/styling/index.scss:dist/css/index.css src/styling/_emoji-replacement.scss:dist/css/emoji-replacement.css src/plugins/Emojis/styling/index.scss:dist/css/emoji-picker.css src/plugins/ChannelDetail/styling/index.scss:dist/css/channel-detail.css; cp -r src/styling/assets dist/css/assets", + "build-styling": "sass src/styling/index.scss:dist/css/index.css src/styling/_emoji-replacement.scss:dist/css/emoji-replacement.css src/plugins/Emojis/styling/index.scss:dist/css/emoji-picker.css src/plugins/ChannelDetail/styling/index.scss:dist/css/channel-detail.css src/plugins/AIComponents/styling/index.scss:dist/css/ai-components.css; cp -r src/styling/assets dist/css/assets", "build-translations": "node scripts/generate-i18n-keys.mts && prettier --write --log-level warn src/i18n/keys.ts", "coverage": "vitest run --coverage", "i18n:export": "node scripts/generate-i18n-keys.mts --json en.json", @@ -218,6 +233,7 @@ "fix-staged": "lint-staged --config .lintstagedrc.fix.json --concurrent 1", "start": "tsc -p tsconfig.lib.json -w", "start:css": "node scripts/watch-styling.mjs", + "start:ai-chatbot": "yarn workspace @stream-io/stream-chat-react-ai-chatbot dev", "start:tutorial": "yarn workspace @stream-io/stream-chat-react-tutorial dev", "start:vite": "yarn workspace @stream-io/stream-chat-react-vite dev", "postinstall": "node -e \"require('fs').existsSync('scripts/install-husky.mjs') && import('./scripts/install-husky.mjs')\"", @@ -228,6 +244,7 @@ "validate-translations": "yarn build-translations && git diff --exit-code -- src/i18n/keys.ts", "validate-cjs": "concurrently 'node scripts/validate-cjs-node-bundle.cjs' 'node scripts/validate-cjs-browser-bundle.cjs'", "validate-esm": "node scripts/validate-esm-node-bundle.mjs", + "validate-entry-isolation": "node scripts/validate-entry-isolation.mts", "semantic-release": "semantic-release", "prepack": "yarn build", "examples:build": "yarn workspaces foreach -A --include '@stream-io/stream-chat-react-*' run build", diff --git a/scripts/validate-cjs-browser-bundle.cjs b/scripts/validate-cjs-browser-bundle.cjs index 71ab567ecb..426fec31cf 100644 --- a/scripts/validate-cjs-browser-bundle.cjs +++ b/scripts/validate-cjs-browser-bundle.cjs @@ -15,3 +15,4 @@ for (const key of Object.keys(window)) { } require('../dist/cjs/index.js'); +require('../dist/cjs/ai-components.js'); diff --git a/scripts/validate-cjs-node-bundle.cjs b/scripts/validate-cjs-node-bundle.cjs index 753c2c8c9e..9e2c826135 100644 --- a/scripts/validate-cjs-node-bundle.cjs +++ b/scripts/validate-cjs-node-bundle.cjs @@ -2,3 +2,4 @@ // This smoke test can help to detect this early. require('../dist/cjs/index.js'); +require('../dist/cjs/ai-components.js'); diff --git a/scripts/validate-entry-isolation.mts b/scripts/validate-entry-isolation.mts new file mode 100644 index 0000000000..9aa55138b3 --- /dev/null +++ b/scripts/validate-entry-isolation.mts @@ -0,0 +1,35 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +const FORBIDDEN_BARE = ['chart.js', 'react-chartjs-2', 'react-syntax-highlighter', 'zod']; +const FORBIDDEN_PATH = '/plugins/AIComponents/'; +const IMPORT_RE = /(?:import|export)\s*(?:[^'"]*?from\s*)?['"]([^'"]+)['"]/g; +// lazy chunks: `import('./x.mjs')` must be walked too +const DYNAMIC_IMPORT_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g; + +const entry = resolve(import.meta.dirname, '../dist/es/index.mjs'); +const seen = new Set(); +const violations: string[] = []; +const queue = [entry]; + +while (queue.length) { + const file = queue.pop() as string; + if (seen.has(file)) continue; + seen.add(file); + if (file.includes(FORBIDDEN_PATH)) violations.push(`reaches ${file}`); + const source = readFileSync(file, 'utf8'); + const specs = [...source.matchAll(IMPORT_RE), ...source.matchAll(DYNAMIC_IMPORT_RE)]; + for (const [, spec] of specs) { + if (spec.startsWith('.')) queue.push(resolve(dirname(file), spec)); + else if (FORBIDDEN_BARE.some((b) => spec === b || spec.startsWith(`${b}/`))) + violations.push(`${file} imports ${spec}`); + } +} + +if (seen.size < 50) + throw new Error(`walked only ${seen.size} modules — graph walk is broken`); +if (violations.length) { + console.error(violations.join('\n')); + process.exit(1); +} +console.log(`main entry isolated from ai-components (${seen.size} modules checked)`); diff --git a/scripts/validate-esm-node-bundle.mjs b/scripts/validate-esm-node-bundle.mjs index f2fab770e9..a78820be6b 100644 --- a/scripts/validate-esm-node-bundle.mjs +++ b/scripts/validate-esm-node-bundle.mjs @@ -9,3 +9,4 @@ // that class of breakage before it ships. import '../dist/es/index.mjs'; +import '../dist/es/ai-components.mjs'; diff --git a/scripts/watch-styling.mjs b/scripts/watch-styling.mjs index 3d9b211446..20bd6d815f 100644 --- a/scripts/watch-styling.mjs +++ b/scripts/watch-styling.mjs @@ -23,6 +23,10 @@ const STYLE_ENTRYPOINTS = [ entryFile: path.join(SRC_DIR, 'plugins/ChannelDetail/styling/index.scss'), outputFile: path.resolve('dist/css/channel-detail.css'), }, + { + entryFile: path.join(SRC_DIR, 'plugins/AIComponents/styling/index.scss'), + outputFile: path.resolve('dist/css/ai-components.css'), + }, ]; const SCSS_EXTENSION = '.scss'; const BUILD_DELAY_MS = 150; diff --git a/src/i18n/__tests__/catalog.fixture.json b/src/i18n/__tests__/catalog.fixture.json index e59b721427..884f9ffd1b 100644 --- a/src/i18n/__tests__/catalog.fixture.json +++ b/src/i18n/__tests__/catalog.fixture.json @@ -30,6 +30,24 @@ "a11y.interactionAnnouncements.userSelected.ariaLabel": "User selected: {{ user }}", "a11y.interactionAnnouncements.voiceMessageSent.ariaLabel": "Voice message sent", "a11y.interactionAnnouncements.voiceRecordingAttached.ariaLabel": "Voice recording attached", + "aiComponents.attachmentPreview.deleteAttachment.ariaLabel": "Delete attachment", + "aiComponents.attachmentPreview.unknownFileName.text": "Unknown file name", + "aiComponents.chart.loading.text": "Loading chart...", + "aiComponents.chart.unknownType.text": "Unknown chart type", + "aiComponents.messageComposer.speechToText.ariaLabel": "Start voice input", + "aiComponents.messageComposer.textInput.placeholder": "Ask a question...", + "aiComponents.stateIndicator.brewingUpAnAnswer.text": "Brewing up an answer", + "aiComponents.stateIndicator.channelingMyInnerEinstein.text": "Channeling my inner Einstein", + "aiComponents.stateIndicator.connectingTheDots.text": "Connecting the dots", + "aiComponents.stateIndicator.consultingTheAiGods.text": "Consulting the AI gods", + "aiComponents.stateIndicator.cookingUpSomethingGood.text": "Cooking up something good", + "aiComponents.stateIndicator.crunchingTheNumbers.text": "Crunching the numbers", + "aiComponents.stateIndicator.firingUpTheNeurons.text": "Firing up the neurons", + "aiComponents.stateIndicator.puttingOnMyThinkingCap.text": "Putting on my thinking cap", + "aiComponents.stateIndicator.readingTheDigitalTeaLeaves.text": "Reading the digital tea leaves", + "aiComponents.stateIndicator.summoningMyInnerGenius.text": "Summoning my inner genius", + "aiComponents.stateIndicator.thinkingReallyHard.text": "Thinking really hard", + "aiComponents.stateIndicator.workingMyMagic.text": "Working my magic", "aiState.indicator.generating.label": "Generating...", "aiState.indicator.thinking.label": "Thinking...", "attachment.actions.giphyActions.ariaLabel": "Giphy actions", diff --git a/src/i18n/keys.ts b/src/i18n/keys.ts index 76cd2f34bc..5619d6254c 100644 --- a/src/i18n/keys.ts +++ b/src/i18n/keys.ts @@ -41,6 +41,24 @@ export type TranslationCatalog = { 'a11y.interactionAnnouncements.userSelected.ariaLabel': 'User selected: {{ user }}'; 'a11y.interactionAnnouncements.voiceMessageSent.ariaLabel': 'Voice message sent'; 'a11y.interactionAnnouncements.voiceRecordingAttached.ariaLabel': 'Voice recording attached'; + 'aiComponents.attachmentPreview.deleteAttachment.ariaLabel': 'Delete attachment'; + 'aiComponents.attachmentPreview.unknownFileName.text': 'Unknown file name'; + 'aiComponents.chart.loading.text': 'Loading chart...'; + 'aiComponents.chart.unknownType.text': 'Unknown chart type'; + 'aiComponents.messageComposer.speechToText.ariaLabel': 'Start voice input'; + 'aiComponents.messageComposer.textInput.placeholder': 'Ask a question...'; + 'aiComponents.stateIndicator.brewingUpAnAnswer.text': 'Brewing up an answer'; + 'aiComponents.stateIndicator.channelingMyInnerEinstein.text': 'Channeling my inner Einstein'; + 'aiComponents.stateIndicator.connectingTheDots.text': 'Connecting the dots'; + 'aiComponents.stateIndicator.consultingTheAiGods.text': 'Consulting the AI gods'; + 'aiComponents.stateIndicator.cookingUpSomethingGood.text': 'Cooking up something good'; + 'aiComponents.stateIndicator.crunchingTheNumbers.text': 'Crunching the numbers'; + 'aiComponents.stateIndicator.firingUpTheNeurons.text': 'Firing up the neurons'; + 'aiComponents.stateIndicator.puttingOnMyThinkingCap.text': 'Putting on my thinking cap'; + 'aiComponents.stateIndicator.readingTheDigitalTeaLeaves.text': 'Reading the digital tea leaves'; + 'aiComponents.stateIndicator.summoningMyInnerGenius.text': 'Summoning my inner genius'; + 'aiComponents.stateIndicator.thinkingReallyHard.text': 'Thinking really hard'; + 'aiComponents.stateIndicator.workingMyMagic.text': 'Working my magic'; 'aiState.indicator.generating.label': 'Generating...'; 'aiState.indicator.thinking.label': 'Thinking...'; 'attachment.actions.giphyActions.ariaLabel': 'Giphy actions'; diff --git a/src/plugins/AIComponents/AIMarkdown/AIMarkdown.tsx b/src/plugins/AIComponents/AIMarkdown/AIMarkdown.tsx new file mode 100644 index 0000000000..494f37186a --- /dev/null +++ b/src/plugins/AIComponents/AIMarkdown/AIMarkdown.tsx @@ -0,0 +1,217 @@ +import React, { + Children, + type ComponentProps, + type ComponentType, + type ElementType, + isValidElement, + useContext, + useMemo, +} from 'react'; +import ReactMarkdown, { type Components, type ExtraProps } from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { Prism, type SyntaxHighlighterProps } from 'react-syntax-highlighter'; +import clsx from 'clsx'; + +import { JsonChart } from './tools/charts/JsonChart'; +import { SuspendedChart } from './tools/charts/SuspendedChart'; + +type CodeElementProps = { + children?: React.ReactNode; + className?: string; + node?: { tagName?: string }; +}; + +const SyntaxHighlighter = Prism as unknown as ComponentType; + +const getToolOrLanguage = (className: string = '') => + className.match(/language-(?[\w-]+)/)?.groups?.['tool']; + +type ToolComponents = { + [key in string]?: ComponentType<{ + data: string; + fallback: React.ReactNode; + }>; +}; + +type ToolComponent = NonNullable; + +export type ToolComponentProps = ToolComponent extends ComponentType ? P : never; + +type MarkdownComponents = Components; + +const AIMarkdownContext = React.createContext<{ + toolComponents: ToolComponents; +}>({ toolComponents: {} }); + +type BaseDefaultPreProps = ComponentProps<'pre'> & ExtraProps; + +type DefaultPreProps = BaseDefaultPreProps & { + Pre?: ComponentType | ElementType; +}; + +// true inside a plain (language-less)
: lets DefaultCode tell a fenced
+// block without a language from inline code (react-markdown v9 has no `inline`)
+const InsidePreContext = React.createContext(false);
+
+const DefaultPre = (props: DefaultPreProps) => {
+  const { children, className, node, Pre = 'pre', ...restProps } = props;
+
+  const { toolComponents } = useContext(AIMarkdownContext);
+
+  const [codeElement] = Children.toArray(children);
+
+  if (
+    isValidElement(codeElement) &&
+    codeElement.props.node?.tagName === 'code'
+  ) {
+    const toolOrLanguage = getToolOrLanguage(codeElement.props.className);
+
+    const fallback = <>{children};
+
+    // grab from pre-registered component set and render
+    const Component =
+      typeof toolOrLanguage === 'string' ? toolComponents[toolOrLanguage] : null;
+
+    if (Component) {
+      // TODO: forward metadata
+
+      return (
+        
+      );
+    }
+
+    // render just a fragment with the code content
+    // which gets replaced by SyntaxHighlighter (it itself renders pre too)
+    if (toolOrLanguage) {
+      return fallback;
+    }
+  }
+
+  // treat as regular pre/code block if there's no tool/language
+  return (
+    
+      {children}
+    
+ ); +}; + +const Code = ({ + children, + className, + // eslint-disable-next-line @typescript-eslint/no-unused-vars -- strip `style` so it is not forwarded to + style: _style, + ...restProps +}: ComponentProps<'code'>) => ( + + {children} + +); + +const Pre = ({ children, className, ...restProps }: ComponentProps<'pre'>) => ( +
+    {children}
+  
+); + +const DefaultSyntaxHighlighter = ({ children, language }: BaseDefaultCodeProps) => ( + + {children as string} + +); + +type BaseDefaultCodeProps = ComponentProps<'code'> & + ExtraProps & { language?: string; inline?: boolean }; + +type DefaultCodeProps = BaseDefaultCodeProps & { + Code?: ComponentType | ElementType; + SyntaxHighlighter?: ComponentType; +}; + +const DefaultCode = (props: DefaultCodeProps) => { + const { + children, + className, + Code = 'code', + node, + SyntaxHighlighter = DefaultSyntaxHighlighter, + ...restProps + } = props; + + const insidePre = useContext(InsidePreContext); + const language = getToolOrLanguage(className); + const inline = !language && !insidePre; + + const Component = language ? SyntaxHighlighter : Code; + + return ( + + {children} + + ); +}; + +const DefaultComponents = { + code: DefaultCode, + pre: DefaultPre, +} as const; + +interface AIMarkdown { + (props: { + children: string; + toolComponents?: ToolComponents; + markdownComponents?: MarkdownComponents; + }): React.JSX.Element; + default: typeof DefaultComponents; +} + +export const AIMarkdown: AIMarkdown = (props) => { + const mergedMarkdownComponents: MarkdownComponents = useMemo( + () => ({ + ...DefaultComponents, + ...props.markdownComponents, + }), + [props.markdownComponents], + ); + + const mergedToolComponents: ToolComponents = useMemo( + () => ({ + chartjs: SuspendedChart, + json: JsonChart, + ...props.toolComponents, + }), + [props.toolComponents], + ); + + return ( + + + {props.children} + + + ); +}; + +AIMarkdown.default = DefaultComponents; diff --git a/src/plugins/AIComponents/AIMarkdown/index.ts b/src/plugins/AIComponents/AIMarkdown/index.ts new file mode 100644 index 0000000000..8e2ccedf89 --- /dev/null +++ b/src/plugins/AIComponents/AIMarkdown/index.ts @@ -0,0 +1 @@ +export * from './AIMarkdown'; diff --git a/src/plugins/AIComponents/AIMarkdown/tools/charts/Chart.tsx b/src/plugins/AIComponents/AIMarkdown/tools/charts/Chart.tsx new file mode 100644 index 0000000000..5f71ea27df --- /dev/null +++ b/src/plugins/AIComponents/AIMarkdown/tools/charts/Chart.tsx @@ -0,0 +1,89 @@ +import { type ComponentProps, useMemo } from 'react'; +import { + ArcElement, + BarElement, + CategoryScale, + Chart as ChartJS, + Legend, + LinearScale, + LineElement, + PointElement, + Title as TitlePlugin, + Tooltip, +} from 'chart.js'; +import { + Bar, + Bubble, + Doughnut, + Line, + Pie, + PolarArea, + Radar, + Scatter, +} from 'react-chartjs-2'; +import type { ToolComponentProps } from '../../AIMarkdown'; +import { useTranslationContext } from '../../../../../context/TranslationContext'; +import { chartJsSchema } from './chartJsSchema'; + +ChartJS.register( + ArcElement, + Tooltip, + Legend, + CategoryScale, + LinearScale, + BarElement, + LineElement, + PointElement, + TitlePlugin, +); + +const UnknownChart = () => { + const { t } = useTranslationContext(); + return
{t('aiComponents.chart.unknownType.text', 'Unknown chart type')}
; +}; + +const components = { + bar: Bar, + bubble: Bubble, + doughnut: Doughnut, + line: Line, + pie: Pie, + polarArea: PolarArea, + radar: Radar, + scatter: Scatter, + unknown: UnknownChart, +} as const; + +const Chart = ({ data, fallback }: ToolComponentProps) => { + const parsedDataOrError = useMemo(() => { + try { + return chartJsSchema.parse(JSON.parse(data)); + } catch (error) { + if (error instanceof Error) { + error.message = `Error occured while parsing Chart.js data: ${error.message}`; + return error; + } + return new Error('Unknown error occured while parsing Chart.js data'); + } + }, [data]); + + if (parsedDataOrError instanceof Error) { + return fallback; + } + + const Component = + components[parsedDataOrError.type as keyof typeof components] ?? components.unknown; + + return ( +
+ +
+ ); +}; + +export type ChartProps = ComponentProps; + +export default Chart; + +// Context for usage: +// the application rendering these messages is capable of rendering charts using Chart.js, to render a chart return a markdown code block with language "chartjs" which contains JSON of Chart.js compatible data diff --git a/src/plugins/AIComponents/AIMarkdown/tools/charts/JsonChart.tsx b/src/plugins/AIComponents/AIMarkdown/tools/charts/JsonChart.tsx new file mode 100644 index 0000000000..249ef9368c --- /dev/null +++ b/src/plugins/AIComponents/AIMarkdown/tools/charts/JsonChart.tsx @@ -0,0 +1,16 @@ +import React from 'react'; + +import type { ToolComponentProps } from '../../AIMarkdown'; +import { looksLikeChartJsConfig } from './chartTypes'; +import { SuspendedChart } from './SuspendedChart'; + +/** + * `json` fences render as a chart only when they hold a Chart.js config; any other JSON + * (an API response, a config file, a partial payload mid-stream) stays highlighted code. + */ +export const JsonChart = (props: ToolComponentProps) => + looksLikeChartJsConfig(props.data) ? ( + + ) : ( + <>{props.fallback} + ); diff --git a/src/plugins/AIComponents/AIMarkdown/tools/charts/SuspendedChart.tsx b/src/plugins/AIComponents/AIMarkdown/tools/charts/SuspendedChart.tsx new file mode 100644 index 0000000000..90485817be --- /dev/null +++ b/src/plugins/AIComponents/AIMarkdown/tools/charts/SuspendedChart.tsx @@ -0,0 +1,55 @@ +import { Component, lazy, type ReactNode, Suspense } from 'react'; +import { useTranslationContext } from '../../../../../context/TranslationContext'; +import type { ToolComponentProps } from '../../AIMarkdown'; + +const Chart = lazy(() => import('./Chart')); + +type ChartErrorBoundaryProps = { + children: ReactNode; + fallback: ReactNode; + resetKey: string; +}; + +/** + * Isolates chart failures (chunk load errors, chart.js throwing on render) so + * they render the raw-code fallback instead of unmounting the message. + * Resets when the chart data changes (e.g. while a message is streaming). + */ +class ChartErrorBoundary extends Component< + ChartErrorBoundaryProps, + { hasError: boolean } +> { + state = { hasError: false }; + + static getDerivedStateFromError() { + return { hasError: true }; + } + + componentDidUpdate(prevProps: ChartErrorBoundaryProps) { + if (this.state.hasError && prevProps.resetKey !== this.props.resetKey) { + this.setState({ hasError: false }); + } + } + + render() { + return this.state.hasError ? this.props.fallback : this.props.children; + } +} + +export const SuspendedChart = (props: ToolComponentProps) => { + const { t } = useTranslationContext(); + + return ( + + + {t('aiComponents.chart.loading.text', 'Loading chart...')} +
+ } + > + + + + ); +}; diff --git a/src/plugins/AIComponents/AIMarkdown/tools/charts/chartJsSchema.ts b/src/plugins/AIComponents/AIMarkdown/tools/charts/chartJsSchema.ts new file mode 100644 index 0000000000..e62dc2060f --- /dev/null +++ b/src/plugins/AIComponents/AIMarkdown/tools/charts/chartJsSchema.ts @@ -0,0 +1,40 @@ +import * as zod from 'zod'; + +import { CHART_JS_TYPES } from './chartTypes'; + +export const chartJsSchema = zod.object({ + data: zod.object({ + datasets: zod.array( + zod.object({ + backgroundColor: zod.union([zod.string(), zod.array(zod.string())]).optional(), + borderColor: zod.union([zod.string(), zod.array(zod.string())]).optional(), + borderWidth: zod.number().optional(), + data: zod.array( + zod.union([ + zod.number(), + zod.object({ + r: zod.number().optional(), // for bubble charts + x: zod.number(), + y: zod.number(), + }), + ]), + ), + fill: zod.boolean().optional(), + hoverBackgroundColor: zod + .union([zod.string(), zod.array(zod.string())]) + .optional(), + hoverBorderColor: zod.union([zod.string(), zod.array(zod.string())]).optional(), + label: zod.string().optional(), + pointBackgroundColor: zod + .union([zod.string(), zod.array(zod.string())]) + .optional(), + pointRadius: zod.number().optional(), + tension: zod.number().optional(), // for line charts + }), + ), + labels: zod.array(zod.union([zod.string(), zod.number()])).optional(), + xLabels: zod.array(zod.union([zod.string(), zod.number()])).optional(), + yLabels: zod.array(zod.union([zod.string(), zod.number()])).optional(), + }), + type: zod.enum(CHART_JS_TYPES), +}); diff --git a/src/plugins/AIComponents/AIMarkdown/tools/charts/chartTypes.ts b/src/plugins/AIComponents/AIMarkdown/tools/charts/chartTypes.ts new file mode 100644 index 0000000000..e1d0391664 --- /dev/null +++ b/src/plugins/AIComponents/AIMarkdown/tools/charts/chartTypes.ts @@ -0,0 +1,33 @@ +export const CHART_JS_TYPES = [ + 'pie', + 'bar', + 'line', + 'bubble', + 'doughnut', + 'polarArea', + 'radar', + 'scatter', +] as const; + +const chartJsTypes = new Set(CHART_JS_TYPES); + +/** + * Cheap structural check (no zod, no chart.js) for whether a `json` fence holds a Chart.js + * config. Full validation still happens in the lazily loaded chart chunk. + */ +export const looksLikeChartJsConfig = (data: string): boolean => { + try { + const parsed: unknown = JSON.parse(data); + if (!parsed || typeof parsed !== 'object') return false; + const { data: chartData, type } = parsed as { data?: unknown; type?: unknown }; + return ( + typeof type === 'string' && + chartJsTypes.has(type) && + !!chartData && + typeof chartData === 'object' && + Array.isArray((chartData as { datasets?: unknown }).datasets) + ); + } catch { + return false; + } +}; diff --git a/src/plugins/AIComponents/AIMessageComposer/AIMessageComposer.tsx b/src/plugins/AIComponents/AIMessageComposer/AIMessageComposer.tsx new file mode 100644 index 0000000000..fec75ba6ab --- /dev/null +++ b/src/plugins/AIComponents/AIMessageComposer/AIMessageComposer.tsx @@ -0,0 +1,518 @@ +import { + type ComponentPropsWithoutRef, + createContext, + type CSSProperties, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import type React from 'react'; +import { customAlphabet } from 'nanoid'; +import clsx from 'clsx'; +import { StateStore } from '@stream-io/state-store'; + +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { useTranslationContext } from '../../../context/TranslationContext'; +import { useStateStore } from '../../../store'; +import { useStableCallback } from '../../../utils/useStableCallback'; +import { useStableId } from '../../../components/UtilityComponents/useStableId'; +import { AttachmentPreview } from './AttachmentPreview'; +import { useSpeechToText, type UseSpeechToTextOptions } from './hooks/useSpeechToText'; + +const nanoId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 15); + +// Visually hidden but still focusable: the native file input is the keyboard +// target (Enter/Space open the picker, it is announced with its aria-label), +// while the label is the visual/pointer target. +const visuallyHiddenInputStyle: CSSProperties = { + border: 0, + clip: 'rect(0, 0, 0, 0)', + height: '1px', + margin: '-1px', + overflow: 'hidden', + padding: 0, + position: 'absolute', + whiteSpace: 'nowrap', + width: '1px', +}; + +const FileInput = ({ + className, + labelProps, + style, + ...restProps +}: ComponentPropsWithoutRef<'input'> & { + labelProps?: ComponentPropsWithoutRef<'label'>; +}) => { + const { disabled } = useIsDisabled(); + const { t } = useTranslationContext(); + const { IconPlus } = useComponentContextIcons(); + return ( + + {({ id }) => ( + <> + + + + )} + + ); +}; + +const WithStableId = ({ + children, +}: { + children?: ReactNode | (({ id }: { id: string }) => ReactNode); +}) => { + // SSR/hydration-safe on React 18+ (React.useId), client-only id on React 17 + const id = `file-input-${useStableId()}`; + + return <>{typeof children === 'function' ? children({ id }) : children}; +}; + +FileInput.WithStableId = WithStableId; + +export type AIMessageComposerStore = { + attachments: { + file: File; + id: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- public type kept identical to @stream-io/chat-react-ai@0.2.0 for drop-in migration + meta?: Record; + }[]; + text: string; + disabled?: boolean; +}; + +const initialStoreState: AIMessageComposerStore = { + attachments: [], + disabled: false, + text: '', +}; + +const AIMessageComposerContext = createContext>( + new StateStore(initialStoreState), +); + +export const useAIMessageComposerContext = () => useContext(AIMessageComposerContext); + +export const useAttachments = () => { + const store = useAIMessageComposerContext(); + + const removeAttachment = useCallback( + (idOrFile: string | File) => { + store.next((currentState) => ({ + ...currentState, + attachments: currentState.attachments.filter((attachment) => { + if (typeof idOrFile === 'string') { + return attachment.id !== idOrFile; + } + + return attachment.file !== idOrFile; + }), + })); + }, + [store], + ); + + const updateAttachments = useCallback( + ( + idsOrAttachments: (string | AIMessageComposerStore['attachments'][number])[], + update: ( + attachment: AIMessageComposerStore['attachments'][number], + ) => AIMessageComposerStore['attachments'][number], + ) => { + store.next((currentState) => { + let hasChanges = false; + const newAttachments = [...currentState.attachments]; + + for (const idOrAttachment of idsOrAttachments) { + const attachmentIndex = + typeof idOrAttachment === 'string' + ? currentState.attachments.findIndex((a) => a.id === idOrAttachment) + : currentState.attachments.indexOf(idOrAttachment); + + if (attachmentIndex === -1) { + continue; + } + + const currentAttachment = currentState.attachments[attachmentIndex]; + if (!currentAttachment) continue; + + const updatedAttachment = update(currentAttachment); + + if (updatedAttachment !== currentAttachment) { + newAttachments[attachmentIndex] = updatedAttachment; + hasChanges = true; + } + } + + if (!hasChanges) { + return currentState; + } + + return { + ...currentState, + attachments: newAttachments, + }; + }); + }, + [store], + ); + + const selector = useCallback( + (currentState: AIMessageComposerStore) => ({ + attachments: currentState.attachments, + }), + [], + ); + + const { attachments } = useStateStore(store, selector); + + return useMemo( + () => ({ attachments, removeAttachment, updateAttachments }), + [attachments, removeAttachment, updateAttachments], + ); +}; + +export const useText = () => { + const store = useAIMessageComposerContext(); + + const selector = useCallback( + (currentState: AIMessageComposerStore) => ({ + text: currentState.text, + }), + [], + ); + + const setText = useCallback( + (text: string) => { + store.next((currentState) => { + if (currentState.text === text) { + return currentState; + } + + return { + ...currentState, + text, + }; + }); + }, + [store], + ); + + const { text } = useStateStore(store, selector); + + return { setText, text }; +}; + +export const useIsDisabled = () => { + const store = useAIMessageComposerContext(); + const selector = useCallback( + (currentState: AIMessageComposerStore) => ({ + disabled: currentState.disabled, + }), + [], + ); + + const setDisabled = useCallback( + (disabled: boolean) => store.partialNext({ disabled }), + [store], + ); + + const { disabled } = useStateStore(store, selector); + return { disabled, setDisabled }; +}; + +type AIMessageComposerProps = ComponentPropsWithoutRef<'form'> & { + /** + * Resets a value of an input with name `attachments` and of type `file` when user selects files so that + * they can select the same file again if needed. + * + * @default true + */ + resetAttachmentsOnSelect?: boolean; + nameMapping?: { + message?: string; + attachments?: string; + }; + + /** + * Disables the composer. + */ + disabled?: boolean; +}; + +interface AIMessageComposer { + (props: AIMessageComposerProps): React.JSX.Element; + FileInput: typeof FileInput; + TextInput: typeof TextInput; + SpeechToTextButton: typeof SpeechToTextButton; + SubmitButton: typeof SubmitButton; + ModelSelect: typeof ModelSelect; + AttachmentPreview: typeof AttachmentPreview; +} + +export const AIMessageComposer: AIMessageComposer = ({ + children, + disabled, + nameMapping, + onChange, + onReset, + resetAttachmentsOnSelect = true, + ...restProps +}) => { + const [stateStore] = useState( + () => new StateStore(initialStoreState), + ); + + useEffect(() => { + stateStore.partialNext({ disabled }); + }, [disabled, stateStore]); + + const handleChange = useStableCallback((e: React.ChangeEvent) => { + onChange?.(e); + + const inputElement = e.target as unknown as HTMLInputElement; + + const messageName = nameMapping?.message ?? 'message'; + const attachmentsName = nameMapping?.attachments ?? 'attachments'; + + const files = inputElement.name === attachmentsName ? inputElement.files : null; + const text = inputElement.name === messageName ? inputElement.value : null; + + stateStore.next((currentState) => { + const newState = { ...currentState }; + + if (files && files.length > 0) { + const newFiles = Array.from(files).map( + (file) => + ({ + file, + id: nanoId(), + }) satisfies AIMessageComposerStore['attachments'][number], + ); + + newState.attachments = newState.attachments.concat(newFiles); + } + + if (text !== null) { + newState.text = text; + } + + if ( + newState.attachments !== currentState.attachments || + newState.text !== currentState.text + ) { + return newState; + } + + return currentState; + }); + + if ( + resetAttachmentsOnSelect && + inputElement.type === 'file' && + inputElement.name === attachmentsName + ) { + inputElement.value = ''; + } + }); + + const handleReset = useStableCallback((e: React.FormEvent) => { + onReset?.(e); + // a reset clears the draft but must not re-enable a composer disabled via props + stateStore.next({ ...initialStoreState, disabled }); + }); + + return ( + +
+ {children} +
+
+ ); +}; + +const noop = () => undefined; + +const useHasMounted = () => { + const [hasMounted, setHasMounted] = useState(false); + useEffect(() => setHasMounted(true), []); + return hasMounted; +}; + +const TextInput = (props: ComponentPropsWithoutRef<'input'>) => { + const { text } = useText(); + const { disabled } = useIsDisabled(); + const { t } = useTranslationContext(); + + return ( + + ); +}; + +const SpeechToTextButton = ({ + options, + ...restProps +}: ComponentPropsWithoutRef<'button'> & { + options?: UseSpeechToTextOptions; +}) => { + const { setText } = useText(); + const { disabled } = useIsDisabled(); + const { t } = useTranslationContext(); + const { IconMicrophoneSolid } = useComponentContextIcons(); + const hasMounted = useHasMounted(); + + // stable identities: the recognizer is only re-created when a primitive option changes + const onTranscript = useStableCallback((text: string) => { + setText(text); + options?.onTranscript?.(text); + }); + const onError = useStableCallback((error: string) => { + if (options?.onError) { + options.onError(error); + } else { + console.error(error); + } + }); + + const { isListening, isSupported, startListening, stopListening } = useSpeechToText({ + ...options, + onError, + onTranscript, + }); + + // Render nothing without the Web Speech API. Support is only known in the + // browser, so wait for mount to keep the server and hydration markup equal. + if (!hasMounted || !isSupported) return null; + + return ( + + ); +}; + +const SubmitButton = ({ + active, + ...restProps +}: ComponentPropsWithoutRef<'button'> & { active?: boolean }) => { + const { disabled } = useIsDisabled(); + const { t } = useTranslationContext(); + const { IconSend } = useComponentContextIcons(); + return ( + + ); +}; + +const availableModels = [ + { label: 'GPT-4o mini', platform: 'openai', value: 'gpt-4o-mini' }, + { label: 'GPT-4o', platform: 'openai', value: 'gpt-4o' }, +] as const; + +const [defaultModel] = availableModels; + +const defaultPlatformModel = `${defaultModel.platform}|${defaultModel.value}`; + +const ModelSelect = ( + props: ComponentPropsWithoutRef<'select'> & { options?: ReactNode }, +) => { + const { + options = ( + <> + {availableModels.map((model) => ( + + ))} + + ), + ...restProps + } = props; + + const { disabled } = useIsDisabled(); + return ( + + ); +}; + +AIMessageComposer.FileInput = FileInput; +AIMessageComposer.TextInput = TextInput; +AIMessageComposer.SpeechToTextButton = SpeechToTextButton; +AIMessageComposer.SubmitButton = SubmitButton; +AIMessageComposer.ModelSelect = ModelSelect; +AIMessageComposer.AttachmentPreview = AttachmentPreview; diff --git a/src/plugins/AIComponents/AIMessageComposer/AttachmentPreview.tsx b/src/plugins/AIComponents/AIMessageComposer/AttachmentPreview.tsx new file mode 100644 index 0000000000..bac788265d --- /dev/null +++ b/src/plugins/AIComponents/AIMessageComposer/AttachmentPreview.tsx @@ -0,0 +1,126 @@ +import { type ComponentPropsWithoutRef, type MouseEvent } from 'react'; +import { useAttachments } from './AIMessageComposer'; +import clsx from 'clsx'; +import { useComponentContextIcons } from '../../../context/useComponentContextIcons'; +import { useTranslationContext } from '../../../context/TranslationContext'; + +const byteValueNumberFormatter = Intl.NumberFormat('en', { + notation: 'compact', + style: 'unit', + unit: 'byte', + unitDisplay: 'narrow', +}); + +export const Item = ({ + file, + imagePreviewSource, + onDelete, + onRetry, + state, + title, +}: { + file: File; + state?: 'uploading' | 'finished' | 'failed' | 'pending' | (string & {}); + title?: string; + imagePreviewSource?: string; + onDelete?: (_: MouseEvent) => void; + onRetry?: (_: MouseEvent) => void; +}) => { + const { t } = useTranslationContext(); + const { IconFile, IconRetry, IconXmark } = useComponentContextIcons(); + const fileName = + title || + file.name || + t('aiComponents.attachmentPreview.unknownFileName.text', 'Unknown file name'); + const readableFileSize = byteValueNumberFormatter.format(file.size); + const isImage = file.type.startsWith('image/'); + + return ( +
+ + + {state === 'failed' && ( +
+ +
+ )} + + {!isImage && ( +
+ +
+
+ {fileName} +
+
+ {readableFileSize} +
+
+
+ )} + {isImage && ( + {fileName} + )} +
+ ); +}; + +export const AttachmentPreview = ({ + children, + ...restProps +}: ComponentPropsWithoutRef<'div'> & { + children?: + | React.ReactNode + | ((_: ReturnType) => React.ReactNode); +}) => { + const _ = useAttachments(); + + if (!children) { + return null; + } + + return ( +
+ {typeof children === 'function' ? children(_) : children} +
+ ); +}; + +AttachmentPreview.Item = Item; + +export const chunk = (array: T, size: number) => { + const chunkCount = Math.ceil(array.length / size); + + return Array.from( + { length: chunkCount }, + (_, index) => array.slice(size * index, size * index + size) as T, + ); +}; diff --git a/src/plugins/AIComponents/AIMessageComposer/hooks/useSpeechToText.ts b/src/plugins/AIComponents/AIMessageComposer/hooks/useSpeechToText.ts new file mode 100644 index 0000000000..459677e78e --- /dev/null +++ b/src/plugins/AIComponents/AIMessageComposer/hooks/useSpeechToText.ts @@ -0,0 +1,222 @@ +import { useEffect, useRef, useState } from 'react'; +import { useStableCallback } from '../../../../utils/useStableCallback'; + +// TypeScript declarations for Web Speech API +interface SpeechRecognition extends EventTarget { + lang: string; + continuous: boolean; + interimResults: boolean; + maxAlternatives: number; + start(): void; + stop(): void; + abort(): void; + onerror: ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => unknown) | null; + onresult: ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => unknown) | null; + onstart: ((this: SpeechRecognition, ev: Event) => unknown) | null; + onend: ((this: SpeechRecognition, ev: Event) => unknown) | null; +} + +interface SpeechRecognitionErrorEvent extends Event { + error: + | 'no-speech' + | 'aborted' + | 'audio-capture' + | 'network' + | 'not-allowed' + | 'service-not-allowed' + | 'bad-grammar' + | 'language-not-supported'; + message: string; +} + +interface SpeechRecognitionEvent extends Event { + resultIndex: number; + results: SpeechRecognitionResultList; +} + +interface SpeechRecognitionResultList { + length: number; + item(index: number): SpeechRecognitionResult; + [index: number]: SpeechRecognitionResult; +} + +interface SpeechRecognitionResult { + isFinal: boolean; + length: number; + item(index: number): SpeechRecognitionAlternative; + [index: number]: SpeechRecognitionAlternative; +} + +interface SpeechRecognitionAlternative { + transcript: string; + confidence: number; +} + +type SpeechRecognitionConstructor = new () => SpeechRecognition; + +// Kept module-local on purpose: augmenting the global `Window` interface would +// leak into every consumer's type environment through the shipped .d.ts files. +type WindowWithSpeechRecognition = Window & { + SpeechRecognition?: SpeechRecognitionConstructor; + webkitSpeechRecognition?: SpeechRecognitionConstructor; +}; + +const getSpeechRecognition = (): SpeechRecognitionConstructor | undefined => { + if (typeof window === 'undefined') return undefined; + const speechWindow = window as WindowWithSpeechRecognition; + return speechWindow.SpeechRecognition ?? speechWindow.webkitSpeechRecognition; +}; + +export type UseSpeechToTextOptions = { + /** + * Language for speech recognition (e.g., 'en-US', 'es-ES') + * @default 'en-US' + */ + lang?: string; + /** + * Whether to return interim (partial) results + * @default true + */ + interimResults?: boolean; + /** + * Maximum number of alternative transcriptions + * @default 1 + */ + maxAlternatives?: number; + /** + * Whether recognition should continue after user stops speaking + * @default false + */ + continuous?: boolean; + /** + * Callback when transcription text changes + */ + onTranscript?: (text: string) => void; + /** + * Callback when an error occurs + */ + onError?: (error: string) => void; +}; + +export const useSpeechToText = (options: UseSpeechToTextOptions = {}) => { + const { + continuous = false, + interimResults = true, + lang = 'en-US', + maxAlternatives = 1, + onError, + onTranscript, + } = options; + + const [isListening, setIsListening] = useState(false); + // stable wrappers so inline callbacks do not re-create the recognizer on every render + const emitTranscript = useStableCallback((text: string) => onTranscript?.(text)); + const emitError = useStableCallback((error: string) => onError?.(error)); + const recognitionRef = useRef(null); + + // Check if Web Speech API is supported + const isSupported = !!getSpeechRecognition(); + + // Initialize speech recognition + useEffect(() => { + const SpeechRecognition = getSpeechRecognition(); + if (!SpeechRecognition) { + return; + } + + const recognition = new SpeechRecognition(); + recognitionRef.current = recognition; + recognition.lang = lang; + recognition.interimResults = interimResults; + recognition.maxAlternatives = maxAlternatives; + recognition.continuous = continuous; + + recognition.onresult = (event: SpeechRecognitionEvent) => { + const results = Array.from(event.results); + + const text = results.reduce((accumulatedText, result) => { + const transcript = result[0]?.transcript || ''; + + if (result.isFinal) { + accumulatedText = transcript; + } else { + accumulatedText += transcript; + } + + return accumulatedText; + }, ''); + + emitTranscript(text); + }; + + recognition.onerror = (event: SpeechRecognitionErrorEvent) => { + let errorMessage = 'Speech recognition error'; + + switch (event.error) { + case 'no-speech': + errorMessage = 'No speech detected. Please try again.'; + break; + case 'audio-capture': + errorMessage = 'No microphone found. Please ensure a microphone is connected.'; + break; + case 'not-allowed': + errorMessage = + 'Microphone access denied. Please grant permission to use the microphone.'; + break; + case 'network': + errorMessage = 'Network error occurred during speech recognition.'; + break; + case 'aborted': + errorMessage = 'Speech recognition was aborted.'; + break; + default: + errorMessage = `Speech recognition error: ${event.error}`; + } + + emitError(errorMessage); + }; + + recognition.onend = () => { + setIsListening(false); + }; + + recognition.onstart = () => { + setIsListening(true); + }; + + return () => { + recognition.stop(); + recognition.onresult = null; + recognition.onerror = null; + recognition.onend = null; + recognition.onstart = null; + }; + }, [lang, interimResults, maxAlternatives, continuous, emitTranscript, emitError]); + + const startListening = useStableCallback(() => { + if (!isSupported) { + throw new Error('Speech recognition is not supported in this browser'); + } + + if (recognitionRef.current && !isListening) { + try { + recognitionRef.current.start(); + } catch { + emitError('Failed to start speech recognition'); + } + } + }); + + const stopListening = useStableCallback(() => { + if (recognitionRef.current && isListening) { + recognitionRef.current.stop(); + } + }); + + return { + isListening, + isSupported, + startListening, + stopListening, + } as const; +}; diff --git a/src/plugins/AIComponents/AIMessageComposer/index.ts b/src/plugins/AIComponents/AIMessageComposer/index.ts new file mode 100644 index 0000000000..aa746f8543 --- /dev/null +++ b/src/plugins/AIComponents/AIMessageComposer/index.ts @@ -0,0 +1,2 @@ +export * from './AIMessageComposer'; +export * from './hooks/useSpeechToText'; diff --git a/src/plugins/AIComponents/AIStateIndicator/AIStateIndicator.tsx b/src/plugins/AIComponents/AIStateIndicator/AIStateIndicator.tsx new file mode 100644 index 0000000000..b3d10a4d00 --- /dev/null +++ b/src/plugins/AIComponents/AIStateIndicator/AIStateIndicator.tsx @@ -0,0 +1,57 @@ +import { useMemo } from 'react'; +import { useTranslationContext } from '../../../context/TranslationContext'; + +export const AIStateIndicator = ({ text }: { text?: string }) => { + const { t } = useTranslationContext(); + const messages = useMemo( + () => [ + t('aiComponents.stateIndicator.thinkingReallyHard.text', 'Thinking really hard'), + t( + 'aiComponents.stateIndicator.puttingOnMyThinkingCap.text', + 'Putting on my thinking cap', + ), + t('aiComponents.stateIndicator.consultingTheAiGods.text', 'Consulting the AI gods'), + t('aiComponents.stateIndicator.brewingUpAnAnswer.text', 'Brewing up an answer'), + t('aiComponents.stateIndicator.crunchingTheNumbers.text', 'Crunching the numbers'), + t( + 'aiComponents.stateIndicator.readingTheDigitalTeaLeaves.text', + 'Reading the digital tea leaves', + ), + t('aiComponents.stateIndicator.firingUpTheNeurons.text', 'Firing up the neurons'), + t( + 'aiComponents.stateIndicator.summoningMyInnerGenius.text', + 'Summoning my inner genius', + ), + t('aiComponents.stateIndicator.connectingTheDots.text', 'Connecting the dots'), + t('aiComponents.stateIndicator.workingMyMagic.text', 'Working my magic'), + t( + 'aiComponents.stateIndicator.channelingMyInnerEinstein.text', + 'Channeling my inner Einstein', + ), + t( + 'aiComponents.stateIndicator.cookingUpSomethingGood.text', + 'Cooking up something good', + ), + ], + [t], + ); + const messageIndex = useMemo( + () => Math.floor(Math.random() * messages.length), + [messages.length], + ); + + return ( +
+
+
+ + + +
+ + {typeof text === 'string' ? text : messages[messageIndex]} + +
+
+ ); +}; diff --git a/src/plugins/AIComponents/README.md b/src/plugins/AIComponents/README.md new file mode 100644 index 0000000000..272f643077 --- /dev/null +++ b/src/plugins/AIComponents/README.md @@ -0,0 +1,659 @@ +# AI components (`stream-chat-react/ai-components`) + +AI-first building blocks for Stream Chat: a message composer with attachments and speech-to-text, a Markdown renderer with syntax highlighting and Chart.js support, a typewriter-style streaming message and an "AI is thinking" indicator. + +These components used to ship as the separate package `@stream-io/chat-react-ai`. They now live in `stream-chat-react` behind the optional entry point `stream-chat-react/ai-components`, with a stylesheet at `stream-chat-react/css/ai-components.css`. The main entry point (`stream-chat-react`) never imports from this plugin, so apps that do not use it pay nothing for it. + +## Installation + +Requires the first `stream-chat-react` **v15** release that includes `ai-components` (expected `15.0.0-rc.N`; the exact number is set by semantic-release). On v14 the entry ships from 14.13.0. + +```bash +yarn add stream-chat-react@rc stream-chat@rc +``` + +The `rc` dist-tag resolves to the v15 release candidate of `stream-chat-react` and to the matching `stream-chat` v10 release candidate; plain `stream-chat-react` currently resolves to v14. + +The runtime dependencies of the components (`chart.js`, `react-chartjs-2`, `react-syntax-highlighter`, `zod`) come with `stream-chat-react`; you do not need to install them. + +```tsx +import { + AIMarkdown, + AIMessageComposer, + StreamingMessage, +} from 'stream-chat-react/ai-components'; +import 'stream-chat-react/css/ai-components.css'; +``` + +Load `ai-components.css` in addition to `stream-chat-react/css/index.css`. + +## What is exported + +From `stream-chat-react/ai-components`: + +- Components: `AIMarkdown`, `AIMessageComposer` (with the static sub-components `FileInput`, `TextInput`, `SpeechToTextButton`, `SubmitButton`, `ModelSelect`, `AttachmentPreview`, `AttachmentPreview.Item`), `StreamingMessage`, `AIStateIndicator` +- Hooks: `useAttachments`, `useText`, `useIsDisabled`, `useAIMessageComposerContext`, `useSpeechToText` +- Types: `AIMessageComposerStore`, `StreamingMessageRef`, `UseMessageTextStreamingProps`, `ToolComponentProps`, `UseSpeechToTextOptions` + +`useMessageTextStreaming` is not exported from here; import it from the main entry point (`stream-chat-react`). + +## Migrating from `@stream-io/chat-react-ai` + +### 1. Dependencies + +Remove `@stream-io/chat-react-ai`. Remove `material-symbols` too if it was only installed for these components (they use `stream-chat-react` icons now). Upgrade `stream-chat-react` to the first release that includes `ai-components`. + +### 2. Imports + +| Before | After | +| ------------------------------------------- | ----------------------------------------- | +| `@stream-io/chat-react-ai` | `stream-chat-react/ai-components` | +| `@stream-io/chat-react-ai/styles/index.css` | `stream-chat-react/css/ai-components.css` | + +```diff +-import { AIMarkdown, AIMessageComposer } from '@stream-io/chat-react-ai'; +-import '@stream-io/chat-react-ai/styles/index.css'; ++import { AIMarkdown, AIMessageComposer } from 'stream-chat-react/ai-components'; ++import 'stream-chat-react/css/ai-components.css'; +``` + +### 3. Class names and CSS custom properties + +All `aicr__` prefixes were renamed to the `str-chat__ai-` namespace. The rule: `aicr__ai-…` becomes `str-chat__ai-…`; every other `aicr__…` becomes `str-chat__ai-…`; custom properties `--aicr__…` become `--str-chat__ai-…`. If you target these classes in your own CSS, update them with the table below. + +Classes (and the two keyframes, `thinking` and `text-fade`): + +| Old (`@stream-io/chat-react-ai`) | New (`stream-chat-react/ai-components`) | +| ------------------------------------------------- | ------------------------------------------------------- | +| `aicr__ai-message-composer__form` | `str-chat__ai-message-composer__form` | +| `aicr__ai-message-composer__round-button` | `str-chat__ai-message-composer__round-button` | +| `aicr__ai-message-composer__round-button--active` | `str-chat__ai-message-composer__round-button--active` | +| `aicr__ai-message-composer__select` | `str-chat__ai-message-composer__select` | +| `aicr__ai-message-composer__text-input` | `str-chat__ai-message-composer__text-input` | +| `aicr__attachment-preview` | `str-chat__ai-attachment-preview` | +| `aicr__attachment-preview__delete-button` | `str-chat__ai-attachment-preview__delete-button` | +| `aicr__attachment-preview__failed-state-overlay` | `str-chat__ai-attachment-preview__failed-state-overlay` | +| `aicr__attachment-preview__file-metadata` | `str-chat__ai-attachment-preview__file-metadata` | +| `aicr__attachment-preview__file-name` | `str-chat__ai-attachment-preview__file-name` | +| `aicr__attachment-preview__file-size` | `str-chat__ai-attachment-preview__file-size` | +| `aicr__attachment-preview__image` | `str-chat__ai-attachment-preview__image` | +| `aicr__attachment-preview__item` | `str-chat__ai-attachment-preview__item` | +| `aicr__attachment-preview__item--failed` | `str-chat__ai-attachment-preview__item--failed` | +| `aicr__attachment-preview__item--pending` | `str-chat__ai-attachment-preview__item--pending` | +| `aicr__attachment-preview__item--uploaded` | `str-chat__ai-attachment-preview__item--uploaded` | +| `aicr__attachment-preview__item--uploading` | `str-chat__ai-attachment-preview__item--uploading` | +| `aicr__attachment-preview__item-content` | `str-chat__ai-attachment-preview__item-content` | +| `aicr__attachment-preview__retry-button` | `str-chat__ai-attachment-preview__retry-button` | +| `aicr__chart` | `str-chat__ai-chart` | +| `aicr__chart--loading` | `str-chat__ai-chart--loading` | +| `aicr__code` | `str-chat__ai-code` | +| `aicr__pre` | `str-chat__ai-pre` | +| `aicr__state-indicator` | `str-chat__ai-state-indicator` | +| `aicr__state-indicator__content` | `str-chat__ai-state-indicator__content` | +| `aicr__state-indicator__dot` | `str-chat__ai-state-indicator__dot` | +| `aicr__state-indicator__dots` | `str-chat__ai-state-indicator__dots` | +| `aicr__state-indicator__text` | `str-chat__ai-state-indicator__text` | +| `aicr__streaming-message` | `str-chat__ai-streaming-message` | +| `aicr__syntax-highlighter-code` | `str-chat__ai-syntax-highlighter-code` | +| `aicr__syntax-highlighter-pre` | `str-chat__ai-syntax-highlighter-pre` | +| `aicr__text-fade` | `str-chat__ai-text-fade` | +| `aicr__thinking` | `str-chat__ai-thinking` | + +Custom properties: + +| Old | New | +| ----------------------------- | ------------------------------------ | +| `--aicr__bg-primary` | `--str-chat__ai-bg-primary` | +| `--aicr__bg-secondary` | `--str-chat__ai-bg-secondary` | +| `--aicr__bg-tertiary` | `--str-chat__ai-bg-tertiary` | +| `--aicr__syntax-comment` | `--str-chat__ai-syntax-comment` | +| `--aicr__syntax-cyan` | `--str-chat__ai-syntax-cyan` | +| `--aicr__syntax-error` | `--str-chat__ai-syntax-error` | +| `--aicr__syntax-function` | `--str-chat__ai-syntax-function` | +| `--aicr__syntax-keyword` | `--str-chat__ai-syntax-keyword` | +| `--aicr__syntax-number` | `--str-chat__ai-syntax-number` | +| `--aicr__syntax-punctuation` | `--str-chat__ai-syntax-punctuation` | +| `--aicr__syntax-selection-bg` | `--str-chat__ai-syntax-selection-bg` | +| `--aicr__syntax-string` | `--str-chat__ai-syntax-string` | +| `--aicr__syntax-tag` | `--str-chat__ai-syntax-tag` | +| `--aicr__syntax-text` | `--str-chat__ai-syntax-text` | +| `--aicr__syntax-whitespace` | `--str-chat__ai-syntax-whitespace` | +| `--aicr__text-primary` | `--str-chat__ai-text-primary` | +| `--aicr__text-secondary` | `--str-chat__ai-text-secondary` | + +### 4. `useMessageTextStreaming` + +The hook is no longer exported from the AI package. Import it from `stream-chat-react` and rename the `letterIntervalMs` option to `streamingLetterIntervalMs`: + +```diff +-import { useMessageTextStreaming } from '@stream-io/chat-react-ai'; ++import { useMessageTextStreaming } from 'stream-chat-react'; + + const { streamedMessageText } = useMessageTextStreaming({ + text, +- letterIntervalMs: 30, ++ streamingLetterIntervalMs: 30, + }); +``` + +`UseMessageTextStreamingProps` is the `StreamingMessage` props type (the name is kept for compatibility). The component props are unchanged (`text`, `letterIntervalMs`, `renderingLetterCount`, and a ref exposing `skipAnimation`). + +### 5. Two `AIStateIndicator` components + +- `AIStateIndicator` from `stream-chat-react` is channel-bound: it reads the AI state events of the current channel and renders accordingly. +- `AIStateIndicator` from `stream-chat-react/ai-components` is presentational: it takes an optional `text` prop and renders the animated indicator. Without `text` it shows a random "thinking" phrase. + +Use the first one to react to channel AI state, the second when you drive the text yourself. + +### 6. Icons + +The components now render `stream-chat-react` icons (`IconPlus`, `IconXmark`, `IconRetry`, `IconMicrophoneSolid`, `IconSend`, `IconFile`) instead of Material Symbols. Override them like any other SDK icon: + +```tsx +import { WithComponents } from 'stream-chat-react'; + + + {/* … */} +; +``` + +### 7. Internationalization + +All strings go through `t()` with namespaced keys and inline English defaults, like the rest of `stream-chat-react` v15. English is the only bundled language. To translate the components, register a dictionary on your `Streami18n` instance (`i18n.registerTranslation(lang, dict)`), exactly as for the core SDK keys (see `ai-docs/i18n-v15-migration.md`). Inside `` the active language is used. + +Outside `` (or a `TranslationProvider`), `t()` is the default translator, which renders each call site's inline English copy. There are no raw keys and nothing to wrap: the labels read `Send`, `File upload` and so on. + +Every key the components use: + +| Key | English | v14 key | +| ------------------------------------------------------------- | ------------------------------ | -------------------------------- | +| `aiComponents.attachmentPreview.deleteAttachment.ariaLabel` | Delete attachment | `Delete attachment` | +| `aiComponents.attachmentPreview.unknownFileName.text` | Unknown file name | `Unknown file name` | +| `aiComponents.chart.loading.text` | Loading chart... | `Loading chart...` | +| `aiComponents.chart.unknownType.text` | Unknown chart type | `Unknown chart type` | +| `aiComponents.messageComposer.speechToText.ariaLabel` | Start voice input | `aria/Start voice input` | +| `aiComponents.messageComposer.textInput.placeholder` | Ask a question... | `Ask a question...` | +| `aiComponents.stateIndicator.brewingUpAnAnswer.text` | Brewing up an answer | `Brewing up an answer` | +| `aiComponents.stateIndicator.channelingMyInnerEinstein.text` | Channeling my inner Einstein | `Channeling my inner Einstein` | +| `aiComponents.stateIndicator.connectingTheDots.text` | Connecting the dots | `Connecting the dots` | +| `aiComponents.stateIndicator.consultingTheAiGods.text` | Consulting the AI gods | `Consulting the AI gods` | +| `aiComponents.stateIndicator.cookingUpSomethingGood.text` | Cooking up something good | `Cooking up something good` | +| `aiComponents.stateIndicator.crunchingTheNumbers.text` | Crunching the numbers | `Crunching the numbers` | +| `aiComponents.stateIndicator.firingUpTheNeurons.text` | Firing up the neurons | `Firing up the neurons` | +| `aiComponents.stateIndicator.puttingOnMyThinkingCap.text` | Putting on my thinking cap | `Putting on my thinking cap` | +| `aiComponents.stateIndicator.readingTheDigitalTeaLeaves.text` | Reading the digital tea leaves | `Reading the digital tea leaves` | +| `aiComponents.stateIndicator.summoningMyInnerGenius.text` | Summoning my inner genius | `Summoning my inner genius` | +| `aiComponents.stateIndicator.thinkingReallyHard.text` | Thinking really hard | `Thinking really hard` | +| `aiComponents.stateIndicator.workingMyMagic.text` | Working my magic | `Working my magic` | +| `messageComposer.sendButton.send.ariaLabel` (core) | Send | `aria/Send` | +| `fileUpload.uploadButton.fileUpload.ariaLabel` (core) | File upload | `aria/File upload` | +| `common.retryUpload.ariaLabel` (core) | Retry upload | `aria/Retry upload` | + +The last three are core `stream-chat-react` keys that the components reuse, so a translation you already registered for them applies here too. `ai-docs/i18n-v15-key-map.json` maps each v14 key to its v15 key. + +## Migrating from `stream-chat-react` v14 + +If you already use `stream-chat-react/ai-components` on v14 (shipped from 14.13.0), the component API is identical on v15: same exports, props, class names and theming contract. Only the i18n keys changed. v14 used the English sentence (or an `aria/…` string) as the key; v15 uses namespaced keys. If you registered translations for these components, rename them with the table above or with `ai-docs/i18n-v15-key-map.json`. If you never translated them, there is nothing to do. + +v15 bundles English only. If your app runs in another language and relied on the translations 14.13 shipped for these components, those strings now fall back to English on v15 until you register translations yourself with `i18n.registerTranslation(lang, dict)`. The v14 values live in the 14.x `src/i18n/*.json` files of the 14.13.0 release (for example `git show v14.13.0:src/i18n/de.json`); map the keys with the table above or `ai-docs/i18n-v15-key-map.json`. + +## Theming + +The library never declares the `--str-chat__ai-*` custom properties. Each rule reads them with fallbacks: + +```css +var(--str-chat__ai-bg-primary, var(--str-chat__background-core-elevation-0, #ffffff)) +``` + +Precedence for every token except the code palette is: your `--str-chat__ai-*` override, then the `stream-chat-react` theme token (inside `.str-chat`, so the SDK light/dark themes apply automatically), then a built-in light literal (outside `.str-chat`). The `--str-chat__ai-syntax-*` palette has no SDK theme token: it falls back directly from your override to a built-in literal. Because nothing is declared by the library, overrides work at any scope, including `:root`. + +| Token (`--str-chat__ai-…`) | Used for | SDK theme token (`--str-chat__…`) | Literal fallback | +| -------------------------- | ------------------------------------------------------- | --------------------------------- | ---------------- | +| `bg-primary` | code block background | `background-core-elevation-0` | `#ffffff` | +| `bg-secondary` | not read by the built-in rules (kept for compatibility) | `background-core-surface-default` | `#f7f7f8` | +| `bg-tertiary` | not read by the built-in rules (kept for compatibility) | `background-core-surface-strong` | `#ececf1` | +| `text-primary` | not read by the built-in rules (kept for compatibility) | `text-primary` | `#353740` | +| `text-secondary` | AI state indicator text and dots | `text-secondary` | `#565869` | +| `border` | composer, model select and attachment borders | `border-core-default` | `#ccc` | +| `border-subtle` | table row separators | `border-core-subtle` | `#e9ecef` | +| `surface-hover` | composer round-button hover | `background-utility-hover` | `#e8e8e8` | +| `surface-pressed` | pressed (listening) speech-to-text button | `background-utility-selected` | `#d1eaff` | +| `button-bg` | attachment delete/retry buttons | `background-core-surface-strong` | `#e8e8e8` | +| `inline-code-bg` | inline code in `StreamingMessage` | `background-core-surface-default` | `#f5f5f5` | +| `table-border` | table header borders | `border-core-default` | `#dee2e6` | +| `table-header-bg` | table header and row hover background | `background-core-surface-subtle` | `#f8f9fa` | +| `table-header-text` | table header text | `text-primary` | `#212529` | +| `table-text` | table cell text | `text-secondary` | `#495057` | +| `scrollbar-track` | attachment list scrollbar track | `background-core-surface-subtle` | `#f1f1f1` | +| `scrollbar-thumb` | attachment list scrollbar thumb | `border-core-default` | `#ddd` | +| `syntax-*` | code palette (see below) | none | built-in palette | + +The code palette tokens are `--str-chat__ai-syntax-text`, `-selection-bg`, `-comment`, `-punctuation`, `-number`, `-keyword`, `-tag`, `-string`, `-function`, `-cyan`, `-error` and `-whitespace`. + +Focus rings use the SDK focus token `--str-chat__border-utility-focused` (falling back to `#005fff` outside `.str-chat`). Icons inside the composer and attachment previews are sized and coloured (`currentColor`) by `ai-components.css` itself. + +```css +:root { + --str-chat__ai-bg-secondary: #101828; + --str-chat__ai-text-primary: #f2f4f7; + --str-chat__ai-syntax-keyword: #c084fc; +} +``` + +## Notes + +- Server-side rendering: the components render under SSR (for example Next.js). This relies on a fix in the SDK's `useStateStore`, which now provides `getServerSnapshot`. +- The Chart.js renderer is lazy-loaded; a fallback ("Loading chart...") is shown while it loads. If the chunk fails to load or Chart.js throws, the block falls back to the raw code instead of breaking the message. +- `AIMessageComposer` attachment `meta` remains `Record`. + +## Example app + +`examples/ai-chatbot` in the `stream-chat-react` repository is a Next.js app built on these components. Copy `examples/ai-chatbot/.env.example` to `examples/ai-chatbot/.env.local`, fill in the Stream credentials, then run `yarn build` followed by `yarn start:ai-chatbot` from the repository root. + +# Component reference + +## Components + +### `AIMessageComposer` + +The `AIMessageComposer` gives users a complete message composer component with support for text input, file attachments, speech-to-text, and model selection. + +```tsx +import { AIMessageComposer } from 'stream-chat-react/ai-components'; + +function ChatComposer({ attachments }: ChatComposerProps) { + const handleSubmit = (e) => { + e.preventDefault(); + const formData = new FormData(e.target); + // Handle submission + }; + + return ( + + + + + + + + {attachments.map((attachment) => ( + + ))} + + + ); +} +``` + +#### Sub-components + +- **`AIMessageComposer.FileInput`** - File input button for attaching files. Supports multiple file selection. The native `` is visually hidden but stays focusable (it is the keyboard stop, labelled with the `fileUpload.uploadButton.fileUpload.ariaLabel` string, "File upload", and opens the picker on Enter/Space); the round `