├── .prettierignore ├── client ├── .env.example ├── src │ ├── index.css │ ├── hooks │ │ ├── index.ts │ │ ├── useViewport.tsx │ │ ├── useTheme.ts │ │ ├── useSalesforceMessaging.ts │ │ ├── useSpeechRecognition.ts │ │ └── useChat.ts │ ├── vite-env.d.ts │ ├── main.tsx │ ├── components │ │ ├── chat │ │ │ ├── index.ts │ │ │ ├── ChatMinimized.tsx │ │ │ ├── ChatMessageList.tsx │ │ │ ├── ChatErrorBoundary.tsx │ │ │ ├── LoadingContainer.tsx │ │ │ ├── AnimatedChatContainer.tsx │ │ │ ├── ChatWindow.tsx │ │ │ ├── ChatInput.tsx │ │ │ ├── ChatMessage.tsx │ │ │ └── ChatHeader.tsx │ │ ├── GitHubLink.tsx │ │ ├── Collapsible.tsx │ │ ├── Dropdown.tsx │ │ └── ContentLayout.tsx │ ├── contexts │ │ └── ThemeContext.tsx │ ├── types.ts │ └── App.tsx ├── postcss.config.js ├── tsconfig.json ├── vite.config.ts ├── tailwind.config.js ├── index.html ├── tsconfig.node.json ├── tsconfig.app.json ├── eslint.config.js ├── public │ └── salesforce.svg ├── package.json └── README.md ├── pnpm-workspace.yaml ├── server ├── .env.example ├── src │ ├── handlers │ │ ├── index.ts │ │ ├── chat-end-handler.ts │ │ ├── chat-typing-handler.ts │ │ ├── chat-initialize-handler.ts │ │ ├── chat-sse-handler.ts │ │ └── chat-message-handler.ts │ ├── types.ts │ ├── index.ts │ ├── routes.ts │ └── schema.ts ├── env.d.ts ├── tsconfig.json ├── package.json └── pnpm-lock.yaml ├── tsconfig.tsbuildinfo ├── media └── custom-client-demo.gif ├── tsconfig.json ├── prettier.config.js ├── .gitignore ├── package.json ├── README.md └── LICENSE /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /client/.env.example: -------------------------------------------------------------------------------- 1 | VITE_API_URL= -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "client" 3 | - "server" 4 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | SALESFORCE_SCRT_URL= 2 | SALESFORCE_ORG_ID= 3 | SALESFORCE_DEVELOPER_NAME= -------------------------------------------------------------------------------- /tsconfig.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"fileNames":[],"fileInfos":[],"root":[],"options":{"composite":true},"version":"5.7.2"} -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /client/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useChat"; 2 | export * from "./useSpeechRecognition"; 3 | export * from "./useTheme"; 4 | -------------------------------------------------------------------------------- /media/custom-client-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/charlesw-salesforce/sample-agentforce-custom-client/HEAD/media/custom-client-demo.gif -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // https://vite.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | }) 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "client/tsconfig.json" }, 5 | { "path": "server/tsconfig.json" } 6 | ], 7 | "compilerOptions": { 8 | "composite": true 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./chat-end-handler"; 2 | export * from "./chat-message-handler"; 3 | export * from "./chat-typing-handler"; 4 | export * from "./chat-initialize-handler"; 5 | export * from "./chat-sse-handler"; 6 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [require("@tailwindcss/typography")], 8 | }; 9 | -------------------------------------------------------------------------------- /server/env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | SALESFORCE_SCRT_URL: string; 5 | SALESFORCE_ORG_ID: string; 6 | SALESFORCE_DEVELOPER_NAME: string; 7 | } 8 | } 9 | } 10 | 11 | export {}; -------------------------------------------------------------------------------- /client/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | interface ImportMetaEnv { 3 | readonly VITE_API_URL: string; 4 | // Add other environment variables you're using 5 | } 6 | 7 | interface ImportMeta { 8 | readonly env: ImportMetaEnv; 9 | } 10 | -------------------------------------------------------------------------------- /client/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react' 2 | import { createRoot } from 'react-dom/client' 3 | import './index.css' 4 | import App from './App.tsx' 5 | 6 | createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("prettier").Config} */ 2 | module.exports = { 3 | semi: true, 4 | trailingComma: "es5", 5 | singleQuote: false, 6 | tabWidth: 2, 7 | useTabs: false, 8 | printWidth: 80, 9 | importOrder: ["^@/(.*)$", "^[./]"], 10 | importOrderSeparation: true, 11 | importOrderSortSpecifiers: true 12 | } -------------------------------------------------------------------------------- /client/src/components/chat/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./AnimatedChatContainer"; 2 | export * from "./ChatWindow"; 3 | export * from "./ChatInput"; 4 | export * from "./ChatHeader"; 5 | export * from "./ChatMessage"; 6 | export * from "./ChatMessageList"; 7 | export * from "./ChatMinimized"; 8 | export * from "./ChatErrorBoundary"; 9 | export * from "./LoadingContainer"; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .env 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2018", 4 | "module": "commonjs", 5 | "outDir": "./dist", 6 | "rootDir": "./src", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true 11 | }, 12 | "include": ["src/**/*"], 13 | "exclude": ["node_modules"] 14 | } 15 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Agentforce Custom Client Demo 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /client/src/contexts/ThemeContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Theme } from '../types' 3 | import { ThemeContext } from '../hooks'; 4 | 5 | const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { 6 | const [theme, setTheme] = useState('light'); 7 | 8 | const toggleTheme = () => { 9 | setTheme(prev => prev === 'dark' ? 'light' : 'dark'); 10 | }; 11 | 12 | return ( 13 | 14 | {children} 15 | 16 | ); 17 | }; 18 | 19 | export default ThemeProvider -------------------------------------------------------------------------------- /client/src/components/GitHubLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FaGithub } from 'react-icons/fa'; 3 | 4 | interface GitHubLinkProps { 5 | href: string; 6 | className?: string; 7 | } 8 | 9 | export const GitHubLink: React.FC = ({ 10 | href, 11 | className = '' 12 | }): JSX.Element => { 13 | return ( 14 | 21 | 22 | 23 | ); 24 | }; -------------------------------------------------------------------------------- /client/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | 16 | /* Linting */ 17 | "strict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noFallthroughCasesInSwitch": true, 21 | "noUncheckedSideEffectImports": true 22 | }, 23 | "include": ["vite.config.ts"] 24 | } 25 | -------------------------------------------------------------------------------- /server/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface SalesforceConfig { 2 | scrtUrl: string; 3 | orgId: string; 4 | esDeveloperName: string; 5 | } 6 | 7 | export interface MessagingHeaders { 8 | authorization: string; 9 | "x-conversation-id": string; 10 | } 11 | 12 | export interface BaseMessageRequest { 13 | Headers: MessagingHeaders; 14 | } 15 | 16 | export interface MessageRequest { 17 | Body: { message: string }; 18 | Headers: MessagingHeaders; 19 | } 20 | 21 | export interface ServerSentEventRequest { 22 | Querystring: { token: string }; 23 | Params: {}; 24 | Headers: {}; 25 | Body: {}; 26 | } 27 | 28 | export interface TypingRequest { 29 | Body: { isTyping: boolean }; 30 | Headers: MessagingHeaders; 31 | } 32 | -------------------------------------------------------------------------------- /server/src/handlers/chat-end-handler.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from "fastify"; 2 | import { BaseMessageRequest, SalesforceConfig } from "../types"; 3 | import axios from "axios"; 4 | 5 | export async function handleEndChat( 6 | salesforceConfig: SalesforceConfig, 7 | request: FastifyRequest 8 | ) { 9 | const token = request.headers.authorization.split(" ")[1]; 10 | const conversationId = request.headers["x-conversation-id"]; 11 | 12 | await axios.delete( 13 | `https://${salesforceConfig.scrtUrl}/iamessage/api/v2/conversation/${conversationId}?esDeveloperName=${salesforceConfig.esDeveloperName}`, 14 | { 15 | headers: { Authorization: `Bearer ${token}` }, 16 | } 17 | ); 18 | 19 | return { success: true }; 20 | } 21 | -------------------------------------------------------------------------------- /client/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | 10 | /* Bundler mode */ 11 | "moduleResolution": "bundler", 12 | "allowImportingTsExtensions": true, 13 | "isolatedModules": true, 14 | "moduleDetection": "force", 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true 24 | }, 25 | "include": ["src"] 26 | } 27 | -------------------------------------------------------------------------------- /client/src/components/chat/ChatMinimized.tsx: -------------------------------------------------------------------------------- 1 | import { MessageCircle } from "lucide-react"; 2 | import { useTheme, themeConfig } from '../../hooks'; 3 | 4 | export const ChatMinimized = ({ onMaximize }: { onMaximize: () => void }) => { 5 | const { theme } = useTheme(); 6 | const styles = themeConfig[theme]; 7 | 8 | return ( 9 |
10 | 21 |
22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /client/src/hooks/useViewport.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export function useViewport() { 4 | const [isMobile, setIsMobile] = useState(window.innerWidth < 768); 5 | 6 | useEffect(() => { 7 | let timeoutId: NodeJS.Timeout; 8 | 9 | const handleResize = () => { 10 | clearTimeout(timeoutId); 11 | 12 | timeoutId = setTimeout(() => { 13 | setIsMobile(window.innerWidth < 768); 14 | }, 100); 15 | }; 16 | 17 | window.addEventListener("resize", handleResize); 18 | window.addEventListener("orientationchange", handleResize); 19 | 20 | return () => { 21 | window.removeEventListener("resize", handleResize); 22 | window.removeEventListener("orientationchange", handleResize); 23 | }; 24 | }, []); 25 | return { isMobile }; 26 | } 27 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "build": "tsc", 8 | "dev": "tsx watch src/index.ts", 9 | "start": "node dist/index.js" 10 | }, 11 | "engines": { 12 | "node": ">=20.0.0" 13 | }, 14 | "volta": { 15 | "node": "20.x.x" 16 | }, 17 | "keywords": [], 18 | "author": "", 19 | "license": "ISC", 20 | "devDependencies": { 21 | "@types/node": "^22.10.2", 22 | "@types/uuid": "^10.0.0", 23 | "prettier": "^3.4.2", 24 | "tsx": "^4.19.2", 25 | "typescript": "^5.7.2" 26 | }, 27 | "dependencies": { 28 | "@fastify/cors": "^10.0.1", 29 | "@fastify/static": "^8.0.3", 30 | "axios": "^1.7.9", 31 | "dotenv": "^16.4.7", 32 | "fastify": "^5.2.0", 33 | "uuid": "^11.0.3" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /client/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /client/public/salesforce.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /client/src/components/chat/ChatMessageList.tsx: -------------------------------------------------------------------------------- 1 | import { Message } from "../../types"; 2 | import { LoadingContainer, ChatMessage } from "./index"; 3 | 4 | export const ChatMessageList = ({ 5 | messages, 6 | isLoading, 7 | error, 8 | messagesEndRef, 9 | }: { 10 | messages: Message[]; 11 | isLoading: boolean; 12 | error?: boolean; 13 | messagesEndRef: React.RefObject; 14 | }) => ( 15 |
16 | {error && ( 17 |
18 | Failed to load messages. Please try again. 19 |
20 | )} 21 | <> 22 | {messages.map((message) => ( 23 | 24 | ))} 25 | {isLoading && } 26 |
27 | 28 |
29 | ); 30 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "agentforce-custom-client", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "workspaces": [ 7 | "client", 8 | "server" 9 | ], 10 | "engines": { 11 | "node": ">=20.0.0" 12 | }, 13 | "volta": { 14 | "node": "20.x.x" 15 | }, 16 | "scripts": { 17 | "dev:client": "cd client && pnpm dev", 18 | "dev:server": "cd server && pnpm dev", 19 | "dev": "pnpm dev:server & pnpm dev:client", 20 | "build:client": "tsc && cd client && pnpm build && cp -r dist ../server/", 21 | "build:server": "cd server && tsc", 22 | "build": "pnpm build:client & pnpm build:server", 23 | "start": "cd server && node dist/index.js" 24 | }, 25 | "keywords": [], 26 | "author": "", 27 | "license": "ISC", 28 | "devDependencies": { 29 | "prettier": "^3.4.2" 30 | }, 31 | "dependencies": { 32 | "typescript": "^5.7.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/src/types.ts: -------------------------------------------------------------------------------- 1 | export interface Message { 2 | id: string; 3 | type: "user" | "ai" | "system"; 4 | content: string; 5 | timestamp: Date; 6 | } 7 | 8 | export interface Entry { 9 | operation: string; 10 | menuMetadata: object; 11 | participant: Participant; 12 | displayName: string; 13 | } 14 | 15 | export interface Participant { 16 | role: string; 17 | appType: string; 18 | subject: string; 19 | clientIdentifier: string; 20 | } 21 | 22 | export type Theme = "dark" | "light"; 23 | 24 | interface ThemeStyles { 25 | primary: string; 26 | primaryHover: string; 27 | primaryText: string; 28 | secondary: string; 29 | secondaryHover: string; 30 | secondaryText: string; 31 | border: string; 32 | inputBg: string; 33 | messageBubble: { 34 | user: string; 35 | ai: string; 36 | system: string; 37 | }; 38 | } 39 | 40 | export interface ThemeConfig { 41 | dark: ThemeStyles; 42 | light: ThemeStyles; 43 | } 44 | -------------------------------------------------------------------------------- /client/src/components/chat/ChatErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | children: React.ReactNode; 5 | } 6 | 7 | interface State { 8 | hasError: boolean; 9 | } 10 | 11 | export class ChatErrorBoundary extends React.Component { 12 | state: State = { 13 | hasError: false 14 | }; 15 | 16 | static getDerivedStateFromError(): State { 17 | return { hasError: true }; 18 | } 19 | 20 | render() { 21 | if (this.state.hasError) { 22 | return ( 23 |
24 |

Something went wrong

25 | 31 |
32 | ); 33 | } 34 | 35 | return this.props.children; 36 | } 37 | } -------------------------------------------------------------------------------- /server/src/handlers/chat-typing-handler.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest } from "fastify"; 2 | import { SalesforceConfig, TypingRequest } from "../types"; 3 | import axios from "axios"; 4 | 5 | export async function handleTypingIndicator( 6 | salesforceConfig: SalesforceConfig, 7 | request: FastifyRequest 8 | ) { 9 | async () => { 10 | const token = request.headers.authorization.split(" ")[1]; 11 | const conversationId = request.headers["x-conversation-id"]; 12 | const isTyping = request.body.isTyping; 13 | 14 | await axios.post( 15 | `https://${salesforceConfig.scrtUrl}/iamessage/api/v2/conversation/${conversationId}/entry`, 16 | { 17 | entryType: isTyping 18 | ? "TypingStartedIndicator" 19 | : "TypingStoppedIndicator", 20 | id: crypto.randomUUID(), 21 | }, 22 | { 23 | headers: { Authorization: `Bearer ${token}` }, 24 | } 25 | ); 26 | return { success: true }; 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /server/src/handlers/chat-initialize-handler.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { SalesforceConfig } from "../types"; 3 | 4 | export async function handleInitialize(salesforceConfig: SalesforceConfig) { 5 | const tokenResponse = await axios.post( 6 | `https://${salesforceConfig.scrtUrl}/iamessage/api/v2/authorization/unauthenticated/access-token`, 7 | { 8 | orgId: salesforceConfig.orgId, 9 | esDeveloperName: salesforceConfig.esDeveloperName, 10 | capabilitiesVersion: "1", 11 | platform: "Web", 12 | context: { 13 | appName: "DemoMessagingClient", 14 | clientVersion: "1.0.0", 15 | }, 16 | } 17 | ); 18 | 19 | const accessToken = tokenResponse.data.accessToken; 20 | const lastEventId = tokenResponse.data.lastEventId; 21 | const conversationId = crypto.randomUUID().toLowerCase(); 22 | 23 | await axios.post( 24 | `https://${salesforceConfig.scrtUrl}/iamessage/api/v2/conversation`, 25 | { 26 | conversationId, 27 | esDeveloperName: salesforceConfig.esDeveloperName, 28 | }, 29 | { 30 | headers: { 31 | Authorization: `Bearer ${accessToken}`, 32 | }, 33 | } 34 | ); 35 | 36 | return { accessToken, conversationId, lastEventId }; 37 | } 38 | -------------------------------------------------------------------------------- /client/src/components/chat/LoadingContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | type DotSize = 'small' | 'medium'; 4 | type Alignment = 'left' | 'right'; 5 | 6 | interface LoadingDotsProps { 7 | size?: DotSize; 8 | } 9 | 10 | interface LoadingContainerProps { 11 | align?: Alignment; 12 | } 13 | 14 | const dotSizes: Record = { 15 | small: 'w-2 h-2', 16 | medium: 'w-3 h-3' 17 | }; 18 | 19 | const LoadingDots: React.FC = ({ size = 'small' }) => { 20 | return ( 21 |
22 |
23 |
27 |
31 |
32 | ); 33 | }; 34 | 35 | export const LoadingContainer: React.FC = ({ 36 | align = 'left' 37 | }) => { 38 | return ( 39 |
44 |
45 | 46 |
47 |
48 | ); 49 | }; 50 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@tailwindcss/typography": "^0.5.15", 14 | "axios": "^1.7.9", 15 | "framer-motion": "^11.15.0", 16 | "lucide-react": "^0.469.0", 17 | "re-resizable": "^6.10.3", 18 | "react": "^18.3.1", 19 | "react-dom": "^18.3.1", 20 | "react-icons": "^5.4.0", 21 | "react-markdown": "^9.0.1", 22 | "react-router-dom": "^7.1.1", 23 | "remark-gfm": "^4.0.0" 24 | }, 25 | "devDependencies": { 26 | "@eslint/js": "^9.17.0", 27 | "@types/dom-speech-recognition": "^0.0.4", 28 | "@types/node": "^22.10.2", 29 | "@types/react": "^18.3.18", 30 | "@types/react-dom": "^18.3.5", 31 | "@vitejs/plugin-react": "^4.3.4", 32 | "autoprefixer": "^10.4.20", 33 | "eslint": "^9.17.0", 34 | "eslint-plugin-react-hooks": "^5.0.0", 35 | "eslint-plugin-react-refresh": "^0.4.16", 36 | "globals": "^15.14.0", 37 | "postcss": "^8.4.49", 38 | "prettier": "^3.4.2", 39 | "prettier-plugin-tailwindcss": "^0.6.9", 40 | "tailwindcss": "^3.4.17", 41 | "typescript": "~5.6.2", 42 | "typescript-eslint": "^8.18.2", 43 | "vite": "^6.0.5" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import fastify from "fastify"; 2 | import cors from "@fastify/cors"; 3 | import dotenv from "dotenv"; 4 | import path from "node:path"; 5 | import fastifyStatic from "@fastify/static"; 6 | import messagingRoutes from "./routes.js"; 7 | 8 | dotenv.config(); 9 | 10 | const server = fastify({ 11 | logger: true, 12 | }); 13 | 14 | async function start() { 15 | try { 16 | await server.register(fastifyStatic, { 17 | root: path.join(process.cwd(), "dist"), 18 | prefix: "/", 19 | }); 20 | 21 | await server.register(cors, { 22 | origin: ["http://localhost:5173"], 23 | methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD"], 24 | credentials: true, 25 | allowedHeaders: [ 26 | "Content-Type", 27 | "Authorization", 28 | "Accept", 29 | "Origin", 30 | "X-Requested-With", 31 | "x-conversation-id", 32 | ], 33 | exposedHeaders: ["*"], 34 | maxAge: 86400, 35 | }); 36 | 37 | await server.register(messagingRoutes, { prefix: "/api" }); 38 | 39 | server.setNotFoundHandler((request, reply) => { 40 | return reply.sendFile("index.html"); 41 | }); 42 | 43 | await server.listen({ port: 8080 }); 44 | console.log("Server running at http://localhost:8080"); 45 | } catch (err) { 46 | server.log.error(err); 47 | process.exit(1); 48 | } 49 | } 50 | 51 | start(); 52 | -------------------------------------------------------------------------------- /client/src/components/chat/AnimatedChatContainer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { motion, AnimatePresence } from "framer-motion"; 3 | 4 | interface AnimatedChatContainerProps { 5 | isOpen: boolean; 6 | isMobile: boolean; 7 | children: React.ReactNode; 8 | } 9 | 10 | export const AnimatedChatContainer: React.FC = ({ 11 | isOpen, 12 | isMobile, 13 | children, 14 | }) => { 15 | return ( 16 | 17 | {isOpen && ( 18 | 36 |
46 | {children} 47 |
48 |
49 | )} 50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { 3 | ChatWindow, 4 | ChatMinimized, 5 | ChatErrorBoundary, 6 | } from "./components/chat"; 7 | import { GitHubLink } from "./components/GitHubLink"; 8 | import ContentLayout from "./components/ContentLayout"; 9 | import ThemeProvider from "./contexts/ThemeContext"; 10 | 11 | const AppContent: React.FC = () => { 12 | const [isChatOpen, setIsChatOpen] = useState(false); 13 | 14 | return ( 15 |
16 | {/* Header */} 17 |
18 |
19 |

20 | Agentforce Custom Client Demo 21 |

22 | 23 |
24 |
25 | 26 | {/* Main Content */} 27 |
28 | 29 |
30 | 31 | {/* Chat Widget */} 32 | {!isChatOpen && setIsChatOpen(true)} />} 33 | {isChatOpen && ( 34 | 35 | setIsChatOpen(false)} 38 | /> 39 | 40 | )} 41 |
42 | ); 43 | }; 44 | 45 | const App: React.FC = () => { 46 | return ( 47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | export default App; 54 | -------------------------------------------------------------------------------- /client/src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import { Theme, ThemeConfig } from "../types"; 3 | 4 | interface ThemeContextType { 5 | theme: Theme; 6 | toggleTheme: () => void; 7 | } 8 | 9 | export const ThemeContext = createContext( 10 | undefined 11 | ); 12 | 13 | export const useTheme = () => { 14 | const context = useContext(ThemeContext); 15 | if (context === undefined) { 16 | throw new Error("useTheme must be used within a ThemeProvider"); 17 | } 18 | return context; 19 | }; 20 | 21 | export const themeConfig: ThemeConfig = { 22 | dark: { 23 | primary: "bg-black", 24 | primaryHover: "hover:bg-gray-800", 25 | primaryText: "text-white", 26 | secondary: "bg-gray-100", 27 | secondaryHover: "hover:bg-gray-200", 28 | secondaryText: "text-gray-800", 29 | border: "border-gray-200", 30 | inputBg: "bg-gray-50", 31 | messageBubble: { 32 | user: "bg-black text-gray-300 border border-transparent", 33 | ai: "bg-gray-100 text-gray-800 border border-gray-200", 34 | system: "bg-slate-200 text-slate-700 border border-gray-200", 35 | }, 36 | }, 37 | light: { 38 | primary: "bg-teal-600", 39 | primaryHover: "hover:bg-teal-700", 40 | primaryText: "text-white", 41 | secondary: "bg-teal-50", 42 | secondaryHover: "hover:bg-teal-100", 43 | secondaryText: "text-teal-900", 44 | border: "border-teal-100", 45 | inputBg: "bg-white", 46 | messageBubble: { 47 | user: "bg-teal-600 text-white border border-teal-500", 48 | ai: "bg-gray-100 text-gray-800 border border-gray-200", 49 | system: "bg-slate-200 text-slate-700 border border-gray-200", 50 | }, 51 | }, 52 | } as const; 53 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # React + TypeScript + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | 10 | ## Expanding the ESLint configuration 11 | 12 | If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: 13 | 14 | - Configure the top-level `parserOptions` property like this: 15 | 16 | ```js 17 | export default tseslint.config({ 18 | languageOptions: { 19 | // other options... 20 | parserOptions: { 21 | project: ['./tsconfig.node.json', './tsconfig.app.json'], 22 | tsconfigRootDir: import.meta.dirname, 23 | }, 24 | }, 25 | }) 26 | ``` 27 | 28 | - Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` 29 | - Optionally add `...tseslint.configs.stylisticTypeChecked` 30 | - Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: 31 | 32 | ```js 33 | // eslint.config.js 34 | import react from 'eslint-plugin-react' 35 | 36 | export default tseslint.config({ 37 | // Set the react version 38 | settings: { react: { version: '18.3' } }, 39 | plugins: { 40 | // Add the react plugin 41 | react, 42 | }, 43 | rules: { 44 | // other rules... 45 | // Enable its recommended rules 46 | ...react.configs.recommended.rules, 47 | ...react.configs['jsx-runtime'].rules, 48 | }, 49 | }) 50 | ``` 51 | -------------------------------------------------------------------------------- /server/src/handlers/chat-sse-handler.ts: -------------------------------------------------------------------------------- 1 | import { FastifyRequest, FastifyReply, RouteGenericInterface } from "fastify"; 2 | import { Readable } from "node:stream"; 3 | import { ReadableStream } from "node:stream/web"; 4 | import { SalesforceConfig, ServerSentEventRequest } from "../types"; 5 | 6 | export async function handleSSEConnection( 7 | salesforceConfig: SalesforceConfig, 8 | request: FastifyRequest, 9 | reply: FastifyReply 10 | ) { 11 | const { token } = request.query; 12 | const { esDeveloperName, scrtUrl, orgId } = salesforceConfig; 13 | 14 | const sseResponse = await fetch(`https://${scrtUrl}/eventrouter/v1/sse`, { 15 | headers: { 16 | Accept: "text/event-stream", 17 | Authorization: `Bearer ${token}`, 18 | "X-Org-Id": orgId, 19 | }, 20 | }); 21 | 22 | if (!sseResponse.ok) { 23 | throw new Error( 24 | `Failed to connect to Salesforce SSE: ${sseResponse.statusText}` 25 | ); 26 | } 27 | 28 | if (!sseResponse.body) { 29 | throw new Error("No response body received"); 30 | } 31 | 32 | const streamableResponse = { 33 | status: 200, 34 | headers: { 35 | "Content-Type": "text/event-stream", 36 | "Cache-Control": "no-cache", 37 | Connection: "keep-alive", 38 | "Access-Control-Allow-Origin": "http://localhost:5173", 39 | "Access-Control-Allow-Credentials": "true", 40 | }, 41 | body: sseResponse.body, 42 | }; 43 | 44 | const nodeStream = Readable.fromWeb( 45 | streamableResponse.body as unknown as ReadableStream 46 | ); 47 | 48 | reply.raw.writeHead(200, streamableResponse.headers); 49 | 50 | request.raw.on("close", () => nodeStream.destroy()); 51 | nodeStream.on("error", () => { 52 | nodeStream.destroy(); 53 | reply.raw.end(); 54 | }); 55 | 56 | nodeStream.pipe(reply.raw); 57 | } 58 | -------------------------------------------------------------------------------- /server/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { FastifyInstance } from "fastify"; 2 | import { 3 | messageSchema, 4 | typingSchema, 5 | messagesSchema, 6 | initializeSchema, 7 | endChatSchema, 8 | serverSentEventsSchema, 9 | } from "./schema"; 10 | import { 11 | handleEndChat, 12 | handleGetMessages, 13 | handleInitialize, 14 | handleSendMessage, 15 | handleSSEConnection, 16 | handleTypingIndicator, 17 | } from "./handlers"; 18 | import { MessageRequest, ServerSentEventRequest, TypingRequest } from "./types"; 19 | 20 | export default async function messagingRoutes(fastify: FastifyInstance) { 21 | if ( 22 | !process.env.SALESFORCE_SCRT_URL || 23 | !process.env.SALESFORCE_ORG_ID || 24 | !process.env.SALESFORCE_DEVELOPER_NAME 25 | ) { 26 | throw new Error("Missing required environment variables"); 27 | } 28 | 29 | const salesforceConfig = { 30 | scrtUrl: process.env.SALESFORCE_SCRT_URL, 31 | orgId: process.env.SALESFORCE_ORG_ID, 32 | esDeveloperName: process.env.SALESFORCE_DEVELOPER_NAME, 33 | }; 34 | 35 | fastify.get( 36 | "/chat/initialize", 37 | { 38 | schema: initializeSchema, 39 | }, 40 | () => handleInitialize(salesforceConfig) 41 | ); 42 | 43 | fastify.post( 44 | "/chat/message", 45 | { 46 | schema: messageSchema, 47 | }, 48 | async (request) => handleSendMessage(salesforceConfig, request) 49 | ); 50 | 51 | fastify.post( 52 | "/chat/typing", 53 | { 54 | schema: typingSchema, 55 | }, 56 | async (request) => handleTypingIndicator(salesforceConfig, request) 57 | ); 58 | 59 | fastify.get( 60 | "/chat/sse", 61 | { schema: serverSentEventsSchema }, 62 | async (request, reply) => 63 | handleSSEConnection(salesforceConfig, request, reply) 64 | ); 65 | 66 | fastify.get( 67 | "/chat/message", 68 | { 69 | schema: messagesSchema, 70 | }, 71 | async (request) => handleGetMessages(salesforceConfig, request) 72 | ); 73 | 74 | fastify.post( 75 | "/chat/end", 76 | { 77 | schema: endChatSchema, 78 | }, 79 | async (request) => handleEndChat(salesforceConfig, request) 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /client/src/components/Collapsible.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react"; 2 | import { ChevronDown } from "lucide-react"; 3 | 4 | interface CollapsibleProps { 5 | title: string; 6 | children: React.ReactNode; 7 | defaultOpen?: boolean; 8 | className?: string; 9 | } 10 | 11 | export const Collapsible: React.FC = ({ 12 | title, 13 | children, 14 | defaultOpen = true, 15 | className = "", 16 | }) => { 17 | const [isOpen, setIsOpen] = useState(defaultOpen); 18 | const contentRef = useRef(null); 19 | const [height, setHeight] = useState(undefined); 20 | 21 | useEffect(() => { 22 | if (!contentRef.current) return; 23 | 24 | const updateHeight = (node: HTMLElement) => { 25 | setHeight(isOpen ? node.scrollHeight : 0); 26 | }; 27 | 28 | try { 29 | const resizeObserver = new ResizeObserver(([entry]) => { 30 | if (entry && isOpen) { 31 | updateHeight(entry.target as HTMLElement); 32 | } 33 | }); 34 | 35 | const currentNode = contentRef.current; 36 | resizeObserver.observe(currentNode); 37 | updateHeight(currentNode); 38 | 39 | return () => resizeObserver.disconnect(); 40 | } catch (err) { 41 | console.error(err); 42 | updateHeight(contentRef.current); 43 | } 44 | }, [isOpen]); 45 | 46 | return ( 47 |
48 | 60 |
66 |
{children}
67 |
68 |
69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /client/src/components/Dropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useEffect, useState } from 'react'; 2 | 3 | interface DropdownProps { 4 | children: React.ReactNode; 5 | trigger: React.ReactNode; 6 | align?: 'left' | 'right'; 7 | } 8 | 9 | export const Dropdown: React.FC = ({ 10 | children, 11 | trigger, 12 | align = 'right' 13 | }) => { 14 | const [isOpen, setIsOpen] = useState(false); 15 | const dropdownRef = useRef(null); 16 | 17 | useEffect(() => { 18 | const handleClickOutside = (event: MouseEvent) => { 19 | if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { 20 | setIsOpen(false); 21 | } 22 | }; 23 | 24 | document.addEventListener('mousedown', handleClickOutside); 25 | return () => document.removeEventListener('mousedown', handleClickOutside); 26 | }, []); 27 | 28 | return ( 29 |
30 |
setIsOpen(!isOpen)}> 31 | {trigger} 32 |
33 | 34 | {isOpen && ( 35 |
42 |
43 | {children} 44 |
45 |
46 | )} 47 |
48 | ); 49 | }; 50 | 51 | interface DropdownItemProps { 52 | onClick?: () => void; 53 | children: React.ReactNode; 54 | disabled?: boolean; 55 | className?: string; 56 | } 57 | 58 | export const DropdownItem: React.FC = ({ 59 | onClick, 60 | children, 61 | disabled = false, 62 | className = '' 63 | }) => ( 64 | 79 | ); 80 | 81 | export const DropdownSeparator = () => ( 82 |
83 | ); -------------------------------------------------------------------------------- /client/src/hooks/useSalesforceMessaging.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | 3 | const API_BASE_URL = 4 | import.meta.env.VITE_API_URL || "http://localhost:8080/api"; 5 | 6 | interface MessagingCredentials { 7 | accessToken: string; 8 | conversationId: string; 9 | } 10 | 11 | interface MessagingHookReturn { 12 | initialize: () => Promise; 13 | sendMessage: ( 14 | token: string, 15 | conversationId: string, 16 | content: string 17 | ) => Promise; 18 | closeChat: (token: string, conversationId: string) => Promise; 19 | setupEventSource: (token: string) => EventSource; 20 | } 21 | 22 | export function useSalesforceMessaging(): MessagingHookReturn { 23 | const initialize = useCallback(async (): Promise => { 24 | const response = await fetch(`${API_BASE_URL}/chat/initialize`); 25 | if (!response.ok) throw new Error("Failed to initialize chat"); 26 | return response.json(); 27 | }, []); 28 | 29 | const sendMessage = useCallback( 30 | async ( 31 | token: string, 32 | conversationId: string, 33 | content: string 34 | ): Promise => { 35 | const response = await fetch(`${API_BASE_URL}/chat/message`, { 36 | method: "POST", 37 | headers: { 38 | Authorization: `Bearer ${token}`, 39 | "Content-Type": "application/json", 40 | "X-Conversation-Id": conversationId, 41 | }, 42 | body: JSON.stringify({ message: content }), 43 | }); 44 | 45 | if (!response.ok) throw new Error("Failed to send message"); 46 | }, 47 | [] 48 | ); 49 | 50 | const closeChat = useCallback( 51 | async (token: string, conversationId: string): Promise => { 52 | const response = await fetch(`${API_BASE_URL}/chat/end`, { 53 | method: "POST", 54 | headers: { 55 | Authorization: `Bearer ${token}`, 56 | "X-Conversation-Id": conversationId, 57 | }, 58 | }); 59 | 60 | if (!response.ok) throw new Error("Failed to close chat"); 61 | }, 62 | [] 63 | ); 64 | 65 | const setupEventSource = useCallback((token: string): EventSource => { 66 | return new EventSource(`${API_BASE_URL}/chat/sse?token=${token}`, { 67 | withCredentials: true, 68 | }); 69 | }, []); 70 | 71 | return { 72 | initialize, 73 | sendMessage, 74 | closeChat, 75 | setupEventSource, 76 | }; 77 | } 78 | -------------------------------------------------------------------------------- /server/src/schema.ts: -------------------------------------------------------------------------------- 1 | import { FastifySchema } from "fastify"; 2 | 3 | export const commonSchemas = { 4 | headers: { 5 | type: "object", 6 | required: ["authorization", "x-conversation-id"], 7 | properties: { 8 | authorization: { type: "string", pattern: "^Bearer " }, 9 | "x-conversation-id": { type: "string", format: "uuid" }, 10 | }, 11 | }, 12 | successResponse: { 13 | type: "object", 14 | properties: { 15 | success: { type: "boolean" }, 16 | }, 17 | }, 18 | } as const; 19 | 20 | // Route-specific schemas 21 | export const messageSchema: FastifySchema = { 22 | headers: commonSchemas.headers, 23 | body: { 24 | type: "object", 25 | required: ["message"], 26 | properties: { 27 | message: { type: "string", minLength: 1 }, 28 | }, 29 | }, 30 | response: { 31 | 200: commonSchemas.successResponse, 32 | }, 33 | }; 34 | 35 | export const typingSchema: FastifySchema = { 36 | headers: commonSchemas.headers, 37 | body: { 38 | type: "object", 39 | required: ["isTyping"], 40 | properties: { 41 | isTyping: { type: "boolean" }, 42 | }, 43 | }, 44 | response: { 45 | 200: commonSchemas.successResponse, 46 | }, 47 | }; 48 | 49 | export const serverSentEventsSchema: FastifySchema = { 50 | querystring: { 51 | type: "object", 52 | properties: { 53 | token: { type: "string" }, 54 | }, 55 | required: ["token"], 56 | }, 57 | }; 58 | 59 | export const messagesSchema: FastifySchema = { 60 | headers: commonSchemas.headers, 61 | response: { 62 | 200: { 63 | type: "object", 64 | properties: { 65 | entries: { 66 | type: "array", 67 | items: { 68 | type: "object", 69 | required: ["id", "content", "sender", "timestamp"], 70 | properties: { 71 | id: { type: "string" }, 72 | content: { type: "string" }, 73 | sender: { 74 | type: "object", 75 | required: ["role", "displayName"], 76 | properties: { 77 | role: { type: "string" }, 78 | displayName: { type: "string" }, 79 | }, 80 | }, 81 | timestamp: { type: "number" }, 82 | }, 83 | }, 84 | }, 85 | }, 86 | }, 87 | }, 88 | }; 89 | 90 | export const initializeSchema: FastifySchema = { 91 | response: { 92 | 200: { 93 | type: "object", 94 | properties: { 95 | accessToken: { type: "string" }, 96 | conversationId: { type: "string" }, 97 | }, 98 | }, 99 | }, 100 | }; 101 | 102 | export const endChatSchema: FastifySchema = { 103 | headers: commonSchemas.headers, 104 | response: { 105 | 200: commonSchemas.successResponse, 106 | }, 107 | }; 108 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Agentforce Custom Chat Client 2 | 3 | A modern React-based chat interface implementation for Salesforce's Messaging for In-App and Web APIs, designed to work with Agentforce Service Agents. Built with React, TypeScript, Tailwind CSS, and Fastify. 4 | 5 | [🎥 Watch the Demo Video](https://youtu.be/Ip2d_jay7H0?si=W3kMhn1-fKOKAn_X) 6 | 7 | ![Screenshot of chat interface](./media/custom-client-demo.gif) 8 | 9 | ## Features 10 | 11 | - 💬 Real-time messaging using Server-Sent Events (SSE) 12 | - 📝 Live typing indicators 13 | - 🎙️ Voice input support with speech recognition 14 | - 🌓 Light/dark theme support 15 | - 📱 Fully responsive design 16 | 17 | ## Prerequisites 18 | 19 | - Node.js >= 20.0.0 20 | - pnpm >= 8.x 21 | - A Salesforce org with Messaging for In-App and Web configured 22 | - An Agentforce Service Agent deployment 23 | 24 | ## Quick Start 25 | 26 | 1. Clone the repository: 27 | 28 | ```bash 29 | git clone https://github.com/charlesw-salesforce/agentforce-custom-client.git 30 | cd agentforce-custom-client 31 | ``` 32 | 33 | 2. Install dependencies: 34 | 35 | ```bash 36 | pnpm install 37 | ``` 38 | 39 | 3. Configure environment variables: 40 | 41 | ```bash 42 | cd server 43 | cp .env.example .env 44 | ``` 45 | 46 | Update `.env` with your Salesforce credentials: 47 | 48 | ```env 49 | SALESFORCE_SCRT_URL=your-scrt-url 50 | SALESFORCE_ORG_ID=your-org-id 51 | SALESFORCE_DEVELOPER_NAME=your-developer-name 52 | PORT=8080 53 | ``` 54 | 55 | 4. Start development servers: 56 | 57 | ```bash 58 | # Start both client and server 59 | pnpm dev 60 | 61 | # Or start individually: 62 | pnpm dev:client # Client at http://localhost:5173 63 | pnpm dev:server # Server at http://localhost:8080 64 | ``` 65 | 66 | ## Available Scripts 67 | 68 | - `pnpm dev` - Start both client and server in development mode 69 | - `pnpm dev:client` - Start client development server 70 | - `pnpm dev:server` - Start backend development server 71 | - `pnpm build` - Build both client and server 72 | - `pnpm start` - Start production server 73 | 74 | ## Environment Variables 75 | 76 | ### Server 77 | 78 | | Variable | Description | Required | 79 | | --------------------------- | --------------------------- | -------- | 80 | | `SALESFORCE_SCRT_URL` | Salesforce SCRT URL | Yes | 81 | | `SALESFORCE_ORG_ID` | Salesforce Organization ID | Yes | 82 | | `SALESFORCE_DEVELOPER_NAME` | Salesforce Developer Name | Yes | 83 | | `PORT` | Server port (default: 8080) | No | 84 | 85 | ### Client 86 | 87 | | Variable | Description | Required | 88 | | -------------- | --------------- | -------- | 89 | | `VITE_API_URL` | Backend API URL | Yes | 90 | 91 | ## Setting Up Your Salesforce Organization 92 | 93 | 1. Create and deploy an Agentforce Service Agent ([video tutorial](https://www.youtube.com/live/1vuZfPEtuUM?si=lQKYsVE9PQrEICNA)) 94 | 2. [Create a Custom Client](https://help.salesforce.com/s/articleView?id=service.miaw_deployment_custom.htm&type=5) deployment using your messaging channel 95 | 3. Copy the SCRT URL, Org ID, and Developer Name to your `.env` file 96 | 97 | For detailed setup instructions, follow the [Salesforce documentation](https://help.salesforce.com/s/articleView?id=service.miaw_deployment_custom.htm&type=5). 98 | -------------------------------------------------------------------------------- /client/src/hooks/useSpeechRecognition.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef, useCallback } from "react"; 2 | 3 | interface UseSpeechRecognitionProps { 4 | onTranscript: (text: string) => void; 5 | onFinalTranscript?: () => void; 6 | autoSendDelay?: number; 7 | } 8 | 9 | export function useSpeechRecognition({ 10 | onTranscript, 11 | onFinalTranscript, 12 | autoSendDelay = 1000, 13 | }: UseSpeechRecognitionProps) { 14 | const [isListening, setIsListening] = useState(false); 15 | const [isSupported, setIsSupported] = useState(false); 16 | const recognitionRef = useRef(null); 17 | const timeoutRef = useRef(); 18 | 19 | useEffect(() => { 20 | if ("webkitSpeechRecognition" in window || "SpeechRecognition" in window) { 21 | const SpeechRecognition = 22 | window.webkitSpeechRecognition || window.SpeechRecognition; 23 | recognitionRef.current = new SpeechRecognition(); 24 | recognitionRef.current.continuous = true; 25 | recognitionRef.current.interimResults = true; 26 | setIsSupported(true); 27 | } 28 | }, []); 29 | 30 | const stopListening = useCallback(() => { 31 | if (recognitionRef.current) { 32 | recognitionRef.current.stop(); 33 | setIsListening(false); 34 | } 35 | }, []); 36 | 37 | const startListening = useCallback(() => { 38 | if (recognitionRef.current) { 39 | try { 40 | recognitionRef.current.start(); 41 | setIsListening(true); 42 | } catch (error) { 43 | console.error("Failed to start speech recognition:", error); 44 | setIsListening(false); 45 | } 46 | } 47 | }, []); 48 | 49 | const toggleListening = useCallback(() => { 50 | if (!recognitionRef.current) return; 51 | if (isListening) { 52 | stopListening(); 53 | } else { 54 | startListening(); 55 | } 56 | }, [isListening, startListening, stopListening]); 57 | 58 | useEffect(() => { 59 | const recognition = recognitionRef.current; 60 | if (!recognition) return; 61 | 62 | const resetAutoSendTimeout = () => { 63 | if (timeoutRef.current) { 64 | clearTimeout(timeoutRef.current); 65 | } 66 | timeoutRef.current = setTimeout(() => { 67 | onFinalTranscript?.(); 68 | }, autoSendDelay); 69 | }; 70 | 71 | recognition.onresult = (event: SpeechRecognitionEvent) => { 72 | const result = event.results[event.resultIndex]; 73 | const transcript = result[0].transcript; 74 | 75 | onTranscript(transcript); 76 | 77 | if (result.isFinal) { 78 | resetAutoSendTimeout(); 79 | } 80 | }; 81 | 82 | recognition.onerror = (event: SpeechRecognitionErrorEvent) => { 83 | console.error("Speech recognition error:", event.error); 84 | setIsListening(false); 85 | }; 86 | 87 | recognition.onend = () => { 88 | setIsListening(false); 89 | }; 90 | 91 | return () => { 92 | if (timeoutRef.current) { 93 | clearTimeout(timeoutRef.current); 94 | } 95 | }; 96 | }, [onTranscript, onFinalTranscript, autoSendDelay]); 97 | 98 | return { 99 | isListening, 100 | isSupported, 101 | toggleListening, 102 | startListening, 103 | stopListening, 104 | }; 105 | } 106 | -------------------------------------------------------------------------------- /client/src/components/chat/ChatWindow.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef, useEffect } from "react"; 2 | import { 3 | AnimatedChatContainer, 4 | ChatInput, 5 | ChatHeader, 6 | ChatMessageList, 7 | ChatMinimized, 8 | } from "./index"; 9 | import { useChat } from "../../hooks"; 10 | import { useViewport } from "../../hooks/useViewport"; 11 | 12 | interface ChatWindowProps { 13 | onClose: () => void; 14 | agentRole: string; 15 | } 16 | 17 | type WindowState = "MAXIMIZED" | "MINIMIZED"; 18 | 19 | export const ChatWindow: React.FC = ({ 20 | onClose, 21 | agentRole, 22 | }) => { 23 | const [windowState, setWindowState] = useState("MAXIMIZED"); 24 | const [inputValue, setInputValue] = useState(""); 25 | const messagesEndRef = useRef(null); 26 | 27 | const { isMobile } = useViewport(); 28 | 29 | const { 30 | messages, 31 | isConnected, 32 | isLoading, 33 | isTyping, 34 | error, 35 | currentAgent, 36 | sendMessage, 37 | closeChat, 38 | startNewChat, 39 | } = useChat(); 40 | 41 | useEffect(() => { 42 | if (windowState === "MAXIMIZED" && messagesEndRef.current) { 43 | const behavior = messages.length <= 1 ? "auto" : "smooth"; 44 | messagesEndRef.current.scrollIntoView({ behavior, block: "end" }); 45 | } 46 | }, [messages, windowState]); 47 | 48 | // Send messages 49 | const handleSend = async () => { 50 | if (!inputValue.trim() || !isConnected) return; 51 | const messageText = inputValue; 52 | setInputValue(""); 53 | 54 | try { 55 | await sendMessage(messageText); 56 | } catch (error) { 57 | console.error("Failed to send message:", error); 58 | setInputValue(messageText); 59 | } 60 | }; 61 | 62 | // Handle window state 63 | const handleMinimize = () => { 64 | setWindowState("MINIMIZED"); 65 | }; 66 | 67 | const handleMaximize = () => { 68 | setWindowState("MAXIMIZED"); 69 | }; 70 | 71 | const handleClose = async () => { 72 | try { 73 | setWindowState("MINIMIZED"); 74 | await closeChat(onClose); 75 | } catch (error) { 76 | console.error("Error closing chat:", error); 77 | onClose(); 78 | } 79 | }; 80 | 81 | return ( 82 | <> 83 | {windowState === "MINIMIZED" && ( 84 |
85 | 86 |
87 | )} 88 | 92 | 100 | 101 | 107 | 108 | { 113 | if (e.key === "Enter" && !e.shiftKey) { 114 | e.preventDefault(); 115 | handleSend(); 116 | } 117 | }} 118 | isEnabled={isConnected} 119 | /> 120 | 121 | 122 | ); 123 | }; 124 | -------------------------------------------------------------------------------- /client/src/components/chat/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Send, Mic } from "lucide-react"; 3 | import { useSpeechRecognition, useTheme, themeConfig } from "../../hooks"; 4 | 5 | interface ChatInputProps { 6 | value: string; 7 | onChange: (value: string) => void; 8 | onSend: () => void; 9 | onKeyPress: (e: React.KeyboardEvent) => void; 10 | isEnabled: boolean; 11 | } 12 | 13 | export const ChatInput: React.FC = ({ 14 | value, 15 | onChange, 16 | onSend, 17 | onKeyPress, 18 | isEnabled, 19 | }) => { 20 | const { theme } = useTheme(); 21 | const styles = themeConfig[theme]; 22 | 23 | const { isListening, isSupported, toggleListening } = useSpeechRecognition({ 24 | onTranscript: onChange, 25 | onFinalTranscript: () => { 26 | if (value.trim()) { 27 | onSend(); 28 | } 29 | }, 30 | }); 31 | 32 | return ( 33 |
34 |
35 |
42 |