├── .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 |
19 |
20 |
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 | window.location.reload()}
27 | className="text-sm text-red-600 hover:text-red-800"
28 | >
29 | Refresh widget
30 |
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 |
32 | );
33 | };
34 |
35 | export const LoadingContainer: React.FC = ({
36 | align = 'left'
37 | }) => {
38 | return (
39 |
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 |
setIsOpen(!isOpen)}
50 | className="w-full flex items-center justify-between p-4 text-left font-medium hover:bg-gray-50 rounded-lg transition-colors duration-200 bg-white focus:outline-none focus:ring-2 focus:ring-gray-200"
51 | aria-expanded={isOpen}
52 | >
53 | {title}
54 |
59 |
60 |
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 |
77 | {children}
78 |
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 | 
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 |
94 | );
95 | };
96 |
--------------------------------------------------------------------------------
/client/src/components/chat/ChatMessage.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactMarkdown from 'react-markdown';
3 | import remarkGfm from 'remark-gfm';
4 | import { ThumbsUp, ThumbsDown, Info } from 'lucide-react';
5 | import { Message } from '../../types';
6 | import { useTheme, themeConfig } from '../../hooks';
7 |
8 | export const ChatMessage: React.FC = ({ type, content, timestamp }) => {
9 | const { theme } = useTheme();
10 | const styles = themeConfig[theme];
11 |
12 | if (type === 'system') {
13 | return (
14 |
15 |
21 |
22 | {content}
23 |
24 |
25 | );
26 | }
27 |
28 | return (
29 |
30 |
35 |
36 |
(
40 |
51 | ),
52 | code: ({ ...props }) => (
53 |
62 | ),
63 | pre: ({ ...props }) => (
64 |
73 | )
74 | }}
75 | >
76 | {content}
77 |
78 |
79 |
80 |
81 | {timestamp.toLocaleTimeString([], {
82 | hour: '2-digit',
83 | minute: '2-digit'
84 | })}
85 |
86 | {type === 'ai' && (
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 | )}
96 |
97 |
98 |
99 | );
100 | };
--------------------------------------------------------------------------------
/server/src/handlers/chat-message-handler.ts:
--------------------------------------------------------------------------------
1 | import { FastifyRequest } from "fastify";
2 | import { BaseMessageRequest, MessageRequest, SalesforceConfig } from "../types";
3 | import axios from "axios";
4 |
5 | export async function handleSendMessage(
6 | salesforceConfig: SalesforceConfig,
7 | request: FastifyRequest
8 | ) {
9 | const conversationId = request.headers["x-conversation-id"];
10 | const token = request.headers.authorization.split(" ")[1];
11 |
12 | await axios.post(
13 | `https://${salesforceConfig.scrtUrl}/iamessage/api/v2/conversation/${conversationId}/message`,
14 | {
15 | message: {
16 | id: crypto.randomUUID(),
17 | messageType: "StaticContentMessage",
18 | staticContent: {
19 | formatType: "Text",
20 | text: request.body.message,
21 | },
22 | },
23 | esDeveloperName: salesforceConfig.esDeveloperName,
24 | isNewMessagingSession: false,
25 | language: "",
26 | },
27 | {
28 | headers: { Authorization: `Bearer ${token}` },
29 | }
30 | );
31 | }
32 |
33 | // We don't actually use this, but if you wanted to retrieve old conversation or existing ones,
34 | // you could using this handler. Note: the most recent message is at the start of the array.
35 | export async function handleGetMessages(
36 | salesforceConfig: SalesforceConfig,
37 | request: FastifyRequest
38 | ) {
39 | const token = request.headers.authorization.split(" ")[1];
40 | if (!token) {
41 | throw new Error("Missing authentication token");
42 | }
43 |
44 | const conversationId = request.headers["x-conversation-id"];
45 |
46 | const response = await axios.get(
47 | `https://${salesforceConfig.scrtUrl}/iamessage/api/v2/conversation/${conversationId}/entries`,
48 | {
49 | headers: { Authorization: `Bearer ${token}` },
50 | params: {
51 | limit: 50,
52 | direction: "FromEnd",
53 | },
54 | }
55 | );
56 |
57 | const unsortedEntries = parseMessageEntries(
58 | response.data.conversationEntries
59 | );
60 |
61 | const sortedEntries = unsortedEntries.sort(
62 | (a, b) => a.timestamp - b.timestamp
63 | );
64 |
65 | const entries = sortedEntries.map((entry) => ({
66 | id: entry.id,
67 | content: entry.content,
68 | sender: {
69 | role: entry.sender.role === "endUser" ? "user" : "ai",
70 | displayName: "",
71 | },
72 | timestamp: new Date(entry.timestamp),
73 | }));
74 |
75 | return { entries };
76 | }
77 |
78 | interface ParsedMessageEntry {
79 | id: string;
80 | content: string;
81 | sender: {
82 | role: "endUser" | "agent" | "system";
83 | displayName: string;
84 | };
85 | timestamp: number;
86 | }
87 |
88 | function parseMessageEntries(entries: any[]): ParsedMessageEntry[] {
89 | const filteredEntries = entries.filter((entry) => {
90 | return (
91 | entry.entryType === "Message" &&
92 | entry.entryPayload?.abstractMessage?.staticContent?.text
93 | );
94 | });
95 |
96 | const mappedEntries = filteredEntries.map((entry) => {
97 | const result = {
98 | id: entry.entryPayload.abstractMessage.id,
99 | content: entry.entryPayload.abstractMessage.staticContent.text,
100 | sender: {
101 | role: mapSenderRole(entry.sender.role),
102 | displayName: entry.senderDisplayName || "Unknown",
103 | },
104 | timestamp: entry.clientTimestamp,
105 | };
106 | return result;
107 | }) as ParsedMessageEntry[];
108 |
109 | return mappedEntries;
110 | }
111 |
112 | function mapSenderRole(role: string): string {
113 | const normalizedRole = role.toLowerCase();
114 | if (normalizedRole === "chatbot") return "agent";
115 | if (normalizedRole === "enduser") return "endUser";
116 | return "unknown";
117 | }
118 |
--------------------------------------------------------------------------------
/client/src/components/chat/ChatHeader.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Minimize2,
3 | X,
4 | MessageSquarePlus,
5 | MoreVertical,
6 | Sun,
7 | Moon,
8 | } from "lucide-react";
9 | import { Dropdown, DropdownItem, DropdownSeparator } from "../Dropdown";
10 | import { useTheme, themeConfig } from "../../hooks";
11 |
12 | interface ChatHeaderProps {
13 | agentName: string | null;
14 | agentRole: string;
15 | isConnected: boolean;
16 | onMinimize: () => void;
17 | onClose: () => void;
18 | onStartNewChat: () => void;
19 | }
20 |
21 | export const ChatHeader = ({
22 | agentName,
23 | agentRole,
24 | isConnected,
25 | onMinimize,
26 | onClose,
27 | onStartNewChat,
28 | }: ChatHeaderProps) => {
29 | const { theme, toggleTheme } = useTheme();
30 | const styles = themeConfig[theme];
31 | const isMobile = window.innerWidth < 768;
32 |
33 | return (
34 |
37 |
38 | {agentName === null ? (
39 |
40 |
41 |
44 | {isConnected ? "Connecting with AI concierge..." : "Disconnected"}
45 |
46 |
47 | ) : (
48 | <>
49 |
52 | {agentName}
53 |
54 |
57 | {agentRole}
58 |
59 | >
60 | )}
61 |
62 |
63 |
68 | {theme === "dark" ? (
69 |
70 | ) : (
71 |
72 | )}
73 |
74 |
75 |
80 |
83 |
84 |
85 |
88 |
91 |
92 | }
93 | align="right"
94 | >
95 |
96 |
97 |
98 | Start New Chat
99 |
100 |
101 |
102 |
103 |
104 |
108 |
109 |
110 | End Chat
111 |
112 |
113 |
114 |
115 |
116 | );
117 | };
118 |
--------------------------------------------------------------------------------
/client/src/components/ContentLayout.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { ExternalLink, BookOpen } from 'lucide-react';
3 | import { FaGithub, FaYoutube } from 'react-icons/fa'
4 | import { Collapsible } from './Collapsible';
5 |
6 | const ResourceCard = ({
7 | icon: Icon,
8 | title,
9 | description,
10 | link,
11 | children
12 | }: {
13 | icon: React.ElementType;
14 | title: string;
15 | description: string;
16 | link: string;
17 | children?: React.ReactNode;
18 | }) => (
19 |
38 | );
39 |
40 | const IMPLEMENTATION_FEATURES = [
41 | 'Real-time messaging with Server-Sent Events',
42 | 'Typing indicators and read receipts',
43 | 'Seamless agent handoff',
44 | 'React + TypeScript implementation'
45 | ];
46 |
47 | const ContentLayout = () => {
48 | return (
49 |
50 | {/* Introduction */}
51 |
52 | Experience a custom implementation of Salesforce's Messaging for In-App and Web APIs. This
53 | demo showcases a fully functional chat interface built with React and TypeScript.
54 |
55 |
56 |
57 | How to Use the Demo
58 |
59 | Click the chat bubble in the bottom right corner to open the chat window
60 | Wait for the AI agent to connect and greet you
61 | Type your message and press send or hit Enter
62 | Experience real-time messaging with simulated agent responses
63 | Use the minimize or close buttons to manage the chat window
64 |
65 |
66 |
67 | {/* Features */}
68 |
69 |
70 |
71 | {IMPLEMENTATION_FEATURES.map((feature, i) => (
72 |
76 |
81 |
86 |
87 |
{feature}
88 |
89 | ))}
90 |
91 |
92 |
93 |
94 | {/* Resources */}
95 |
96 |
97 |
98 |
104 |
105 |
Quick Start:
106 |
107 | Follow the tutorial
108 | Clone the repository
109 | Follow the README
110 | Start chatting!
111 |
112 |
113 |
114 |
115 |
121 |
122 |
128 |
129 |
130 |
131 |
132 | );
133 | };
134 |
135 | export default ContentLayout;
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Creative Commons Legal Code
2 |
3 | CC0 1.0 Universal
4 |
5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
12 | HEREUNDER.
13 |
14 | Statement of Purpose
15 |
16 | The laws of most jurisdictions throughout the world automatically confer
17 | exclusive Copyright and Related Rights (defined below) upon the creator
18 | and subsequent owner(s) (each and all, an "owner") of an original work of
19 | authorship and/or a database (each, a "Work").
20 |
21 | Certain owners wish to permanently relinquish those rights to a Work for
22 | the purpose of contributing to a commons of creative, cultural and
23 | scientific works ("Commons") that the public can reliably and without fear
24 | of later claims of infringement build upon, modify, incorporate in other
25 | works, reuse and redistribute as freely as possible in any form whatsoever
26 | and for any purposes, including without limitation commercial purposes.
27 | These owners may contribute to the Commons to promote the ideal of a free
28 | culture and the further production of creative, cultural and scientific
29 | works, or to gain reputation or greater distribution for their Work in
30 | part through the use and efforts of others.
31 |
32 | For these and/or other purposes and motivations, and without any
33 | expectation of additional consideration or compensation, the person
34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she
35 | is an owner of Copyright and Related Rights in the Work, voluntarily
36 | elects to apply CC0 to the Work and publicly distribute the Work under its
37 | terms, with knowledge of his or her Copyright and Related Rights in the
38 | Work and the meaning and intended legal effect of CC0 on those rights.
39 |
40 | 1. Copyright and Related Rights. A Work made available under CC0 may be
41 | protected by copyright and related or neighboring rights ("Copyright and
42 | Related Rights"). Copyright and Related Rights include, but are not
43 | limited to, the following:
44 |
45 | i. the right to reproduce, adapt, distribute, perform, display,
46 | communicate, and translate a Work;
47 | ii. moral rights retained by the original author(s) and/or performer(s);
48 | iii. publicity and privacy rights pertaining to a person's image or
49 | likeness depicted in a Work;
50 | iv. rights protecting against unfair competition in regards to a Work,
51 | subject to the limitations in paragraph 4(a), below;
52 | v. rights protecting the extraction, dissemination, use and reuse of data
53 | in a Work;
54 | vi. database rights (such as those arising under Directive 96/9/EC of the
55 | European Parliament and of the Council of 11 March 1996 on the legal
56 | protection of databases, and under any national implementation
57 | thereof, including any amended or successor version of such
58 | directive); and
59 | vii. other similar, equivalent or corresponding rights throughout the
60 | world based on applicable law or treaty, and any national
61 | implementations thereof.
62 |
63 | 2. Waiver. To the greatest extent permitted by, but not in contravention
64 | of, applicable law, Affirmer hereby overtly, fully, permanently,
65 | irrevocably and unconditionally waives, abandons, and surrenders all of
66 | Affirmer's Copyright and Related Rights and associated claims and causes
67 | of action, whether now known or unknown (including existing as well as
68 | future claims and causes of action), in the Work (i) in all territories
69 | worldwide, (ii) for the maximum duration provided by applicable law or
70 | treaty (including future time extensions), (iii) in any current or future
71 | medium and for any number of copies, and (iv) for any purpose whatsoever,
72 | including without limitation commercial, advertising or promotional
73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
74 | member of the public at large and to the detriment of Affirmer's heirs and
75 | successors, fully intending that such Waiver shall not be subject to
76 | revocation, rescission, cancellation, termination, or any other legal or
77 | equitable action to disrupt the quiet enjoyment of the Work by the public
78 | as contemplated by Affirmer's express Statement of Purpose.
79 |
80 | 3. Public License Fallback. Should any part of the Waiver for any reason
81 | be judged legally invalid or ineffective under applicable law, then the
82 | Waiver shall be preserved to the maximum extent permitted taking into
83 | account Affirmer's express Statement of Purpose. In addition, to the
84 | extent the Waiver is so judged Affirmer hereby grants to each affected
85 | person a royalty-free, non transferable, non sublicensable, non exclusive,
86 | irrevocable and unconditional license to exercise Affirmer's Copyright and
87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the
88 | maximum duration provided by applicable law or treaty (including future
89 | time extensions), (iii) in any current or future medium and for any number
90 | of copies, and (iv) for any purpose whatsoever, including without
91 | limitation commercial, advertising or promotional purposes (the
92 | "License"). The License shall be deemed effective as of the date CC0 was
93 | applied by Affirmer to the Work. Should any part of the License for any
94 | reason be judged legally invalid or ineffective under applicable law, such
95 | partial invalidity or ineffectiveness shall not invalidate the remainder
96 | of the License, and in such case Affirmer hereby affirms that he or she
97 | will not (i) exercise any of his or her remaining Copyright and Related
98 | Rights in the Work or (ii) assert any associated claims and causes of
99 | action with respect to the Work, in either case contrary to Affirmer's
100 | express Statement of Purpose.
101 |
102 | 4. Limitations and Disclaimers.
103 |
104 | a. No trademark or patent rights held by Affirmer are waived, abandoned,
105 | surrendered, licensed or otherwise affected by this document.
106 | b. Affirmer offers the Work as-is and makes no representations or
107 | warranties of any kind concerning the Work, express, implied,
108 | statutory or otherwise, including without limitation warranties of
109 | title, merchantability, fitness for a particular purpose, non
110 | infringement, or the absence of latent or other defects, accuracy, or
111 | the present or absence of errors, whether or not discoverable, all to
112 | the greatest extent permissible under applicable law.
113 | c. Affirmer disclaims responsibility for clearing rights of other persons
114 | that may apply to the Work or any use thereof, including without
115 | limitation any person's Copyright and Related Rights in the Work.
116 | Further, Affirmer disclaims responsibility for obtaining any necessary
117 | consents, permissions or other rights required for any use of the
118 | Work.
119 | d. Affirmer understands and acknowledges that Creative Commons is not a
120 | party to this document and has no duty or obligation with respect to
121 | this CC0 or use of the Work.
--------------------------------------------------------------------------------
/client/src/hooks/useChat.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef, useCallback } from "react";
2 | import { Entry, Message } from "../types";
3 | import { useSalesforceMessaging } from "./useSalesforceMessaging";
4 |
5 | const INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5 minutes
6 |
7 | export function useChat() {
8 | const [messages, setMessages] = useState([]);
9 | const [isConnected, setIsConnected] = useState(false);
10 | const [isLoading, setIsLoading] = useState(false);
11 | const [isTyping, setIsTyping] = useState(false);
12 | const [currentAgent, setCurrentAgent] = useState(null);
13 | const [error, setError] = useState(null);
14 |
15 | const eventSourceRef = useRef(null);
16 | const credsRef = useRef<{
17 | accessToken: string;
18 | conversationId: string;
19 | } | null>(null);
20 | const timeoutRef = useRef(null);
21 | const isInitializedRef = useRef(false);
22 |
23 | const {
24 | initialize,
25 | sendMessage: sendMessageToApi,
26 | closeChat: closeChatApi,
27 | setupEventSource,
28 | } = useSalesforceMessaging();
29 |
30 | const resetTimeout = useCallback(() => {
31 | if (timeoutRef.current) clearTimeout(timeoutRef.current);
32 |
33 | timeoutRef.current = setTimeout(async () => {
34 | if (!credsRef.current || !isConnected) return;
35 |
36 | try {
37 | await closeChatApi(
38 | credsRef.current.accessToken,
39 | credsRef.current.conversationId
40 | );
41 | setIsConnected(false);
42 | setMessages((prev) => [
43 | ...prev,
44 | {
45 | id: crypto.randomUUID(),
46 | type: "system",
47 | content: "Chat ended due to inactivity",
48 | timestamp: new Date(),
49 | },
50 | ]);
51 | } catch (err) {
52 | console.error("Failed to end chat:", err);
53 | }
54 | }, INACTIVITY_TIMEOUT);
55 | }, [isConnected, closeChatApi]);
56 |
57 | const handleMessage = useCallback(
58 | (event: MessageEvent) => {
59 | try {
60 | const data = JSON.parse(event.data);
61 | const sender = data.conversationEntry.sender.role.toLowerCase();
62 | if (sender === "chatbot") {
63 | setIsTyping(false);
64 | const payload = JSON.parse(data.conversationEntry.entryPayload);
65 | setMessages((prev) => [
66 | ...prev,
67 | {
68 | id: payload.abstractMessage.id,
69 | type: "ai",
70 | content: payload.abstractMessage.staticContent.text,
71 | timestamp: new Date(data.conversationEntry.clientTimestamp),
72 | },
73 | ]);
74 | setIsLoading(false);
75 | resetTimeout();
76 | }
77 | } catch (err) {
78 | console.error("Message parse error:", err);
79 | }
80 | },
81 | [resetTimeout]
82 | );
83 |
84 | const handleParticipantChange = useCallback((event: MessageEvent) => {
85 | const data = JSON.parse(event.data);
86 | const entries = JSON.parse(data.conversationEntry.entryPayload).entries;
87 |
88 | entries.forEach((entry: Entry) => {
89 | if (
90 | entry.operation === "add" &&
91 | entry.participant.role.toLowerCase() === "chatbot"
92 | ) {
93 | setCurrentAgent(entry.displayName);
94 | setMessages((prev) => [
95 | ...prev,
96 | {
97 | id: crypto.randomUUID(),
98 | type: "system",
99 | content: `${entry.displayName} has joined the chat`,
100 | timestamp: new Date(),
101 | },
102 | ]);
103 | }
104 | if (entry.operation === "remove" && entry.participant.role === "agent") {
105 | setCurrentAgent(null);
106 | setMessages((prev) => [
107 | ...prev,
108 | {
109 | id: crypto.randomUUID(),
110 | type: "system",
111 | content: `${entry.displayName} has left the chat`,
112 | timestamp: new Date(),
113 | },
114 | ]);
115 | }
116 | });
117 | }, []);
118 |
119 | const setupEventHandlers = useCallback(
120 | (events: EventSource) => {
121 | events.onopen = () => {
122 | setIsConnected(true);
123 | setError(null);
124 | resetTimeout();
125 | };
126 |
127 | events.onerror = () => setIsConnected(false);
128 |
129 | events.addEventListener("CONVERSATION_MESSAGE", handleMessage);
130 | events.addEventListener(
131 | "CONVERSATION_PARTICIPANT_CHANGED",
132 | handleParticipantChange
133 | );
134 | events.addEventListener("CONVERSATION_TYPING_STARTED_INDICATOR", () => {
135 | if (!isLoading) setIsTyping(true);
136 | resetTimeout();
137 | });
138 | events.addEventListener("CONVERSATION_TYPING_STOPPED_INDICATOR", () => {
139 | setIsTyping(false);
140 | });
141 | },
142 | [isLoading, resetTimeout, handleMessage, handleParticipantChange]
143 | );
144 |
145 | const startChat = useCallback(async () => {
146 | try {
147 | if (eventSourceRef.current) {
148 | eventSourceRef.current.close();
149 | }
150 |
151 | setMessages([]);
152 | setIsLoading(false);
153 | setIsTyping(false);
154 | setCurrentAgent(null);
155 | setError(null);
156 |
157 | const creds = await initialize();
158 | credsRef.current = creds;
159 |
160 | const events = setupEventSource(creds.accessToken);
161 | eventSourceRef.current = events;
162 | setupEventHandlers(events);
163 | } catch (err) {
164 | console.error("Chat initialization error:", err);
165 | setError("Failed to start chat");
166 | setIsConnected(false);
167 | }
168 | }, [initialize, setupEventSource, setupEventHandlers]);
169 |
170 | const sendMessage = async (content: string) => {
171 | if (!credsRef.current) return;
172 | resetTimeout();
173 |
174 | const message = {
175 | id: crypto.randomUUID(),
176 | type: "user" as const,
177 | content,
178 | timestamp: new Date(),
179 | };
180 |
181 | try {
182 | setMessages((prev) => [...prev, message]);
183 | setIsLoading(true);
184 |
185 | await sendMessageToApi(
186 | credsRef.current.accessToken,
187 | credsRef.current.conversationId,
188 | content
189 | );
190 | } catch (err) {
191 | console.error(err);
192 | setError("Failed to send message");
193 | setIsLoading(false);
194 | setMessages((prev) => prev.filter((m) => m.id !== message.id));
195 | }
196 | };
197 |
198 | const closeChat = async (onClosed: () => void) => {
199 | try {
200 | if (!credsRef.current) return;
201 |
202 | await closeChatApi(
203 | credsRef.current.accessToken,
204 | credsRef.current.conversationId
205 | );
206 |
207 | setIsConnected(false);
208 | setIsTyping(false);
209 | setCurrentAgent(null);
210 | setMessages([]);
211 | setIsLoading(false);
212 | setError(null);
213 | onClosed();
214 | } catch (err) {
215 | console.error("Failed to close chat:", err);
216 | setError("Failed to close chat");
217 | }
218 | };
219 |
220 | useEffect(() => {
221 | if (isInitializedRef.current) return;
222 | isInitializedRef.current = true;
223 |
224 | const cleanupEventSource = (eventSource: EventSource) => {
225 | eventSource.removeEventListener("CONVERSATION_MESSAGE", handleMessage);
226 | eventSource.removeEventListener(
227 | "HANDLE_PARTICIPANT_CHANGE",
228 | handleParticipantChange
229 | );
230 | eventSource.removeEventListener(
231 | "CONVERSATION_TYPING_STARTED_INDICATOR",
232 | () => {
233 | if (!isLoading) setIsTyping(true);
234 | resetTimeout();
235 | }
236 | );
237 | eventSource.removeEventListener(
238 | "CONVERSATION_TYPING_STOPPED_INDICATOR",
239 | () => {
240 | setIsTyping(false);
241 | }
242 | );
243 |
244 | eventSource.close();
245 | };
246 | startChat();
247 | return () => {
248 | if (eventSourceRef.current) cleanupEventSource(eventSourceRef.current);
249 | if (timeoutRef.current) clearTimeout(timeoutRef.current);
250 | };
251 | }, [
252 | startChat,
253 | isLoading,
254 | resetTimeout,
255 | handleMessage,
256 | handleParticipantChange,
257 | ]);
258 |
259 | return {
260 | messages,
261 | isConnected,
262 | isLoading,
263 | isTyping,
264 | currentAgent,
265 | error,
266 | sendMessage,
267 | closeChat,
268 | startNewChat: startChat,
269 | };
270 | }
271 |
--------------------------------------------------------------------------------
/server/pnpm-lock.yaml:
--------------------------------------------------------------------------------
1 | lockfileVersion: '6.1'
2 |
3 | settings:
4 | autoInstallPeers: true
5 | excludeLinksFromLockfile: false
6 |
7 | dependencies:
8 | '@fastify/cors':
9 | specifier: ^10.0.1
10 | version: 10.0.1
11 | '@fastify/static':
12 | specifier: ^8.0.3
13 | version: 8.0.3
14 | axios:
15 | specifier: ^1.7.9
16 | version: 1.7.9
17 | dotenv:
18 | specifier: ^16.4.7
19 | version: 16.4.7
20 | fastify:
21 | specifier: ^5.2.0
22 | version: 5.2.0
23 | uuid:
24 | specifier: ^11.0.3
25 | version: 11.0.3
26 |
27 | devDependencies:
28 | '@types/node':
29 | specifier: ^22.10.2
30 | version: 22.10.2
31 | '@types/uuid':
32 | specifier: ^10.0.0
33 | version: 10.0.0
34 | prettier:
35 | specifier: ^3.4.2
36 | version: 3.4.2
37 | tsx:
38 | specifier: ^4.19.2
39 | version: 4.19.2
40 | typescript:
41 | specifier: ^5.7.2
42 | version: 5.7.2
43 |
44 | packages:
45 |
46 | /@esbuild/aix-ppc64@0.23.1:
47 | resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==}
48 | engines: {node: '>=18'}
49 | cpu: [ppc64]
50 | os: [aix]
51 | requiresBuild: true
52 | dev: true
53 | optional: true
54 |
55 | /@esbuild/android-arm64@0.23.1:
56 | resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==}
57 | engines: {node: '>=18'}
58 | cpu: [arm64]
59 | os: [android]
60 | requiresBuild: true
61 | dev: true
62 | optional: true
63 |
64 | /@esbuild/android-arm@0.23.1:
65 | resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==}
66 | engines: {node: '>=18'}
67 | cpu: [arm]
68 | os: [android]
69 | requiresBuild: true
70 | dev: true
71 | optional: true
72 |
73 | /@esbuild/android-x64@0.23.1:
74 | resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==}
75 | engines: {node: '>=18'}
76 | cpu: [x64]
77 | os: [android]
78 | requiresBuild: true
79 | dev: true
80 | optional: true
81 |
82 | /@esbuild/darwin-arm64@0.23.1:
83 | resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==}
84 | engines: {node: '>=18'}
85 | cpu: [arm64]
86 | os: [darwin]
87 | requiresBuild: true
88 | dev: true
89 | optional: true
90 |
91 | /@esbuild/darwin-x64@0.23.1:
92 | resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==}
93 | engines: {node: '>=18'}
94 | cpu: [x64]
95 | os: [darwin]
96 | requiresBuild: true
97 | dev: true
98 | optional: true
99 |
100 | /@esbuild/freebsd-arm64@0.23.1:
101 | resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==}
102 | engines: {node: '>=18'}
103 | cpu: [arm64]
104 | os: [freebsd]
105 | requiresBuild: true
106 | dev: true
107 | optional: true
108 |
109 | /@esbuild/freebsd-x64@0.23.1:
110 | resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==}
111 | engines: {node: '>=18'}
112 | cpu: [x64]
113 | os: [freebsd]
114 | requiresBuild: true
115 | dev: true
116 | optional: true
117 |
118 | /@esbuild/linux-arm64@0.23.1:
119 | resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==}
120 | engines: {node: '>=18'}
121 | cpu: [arm64]
122 | os: [linux]
123 | requiresBuild: true
124 | dev: true
125 | optional: true
126 |
127 | /@esbuild/linux-arm@0.23.1:
128 | resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==}
129 | engines: {node: '>=18'}
130 | cpu: [arm]
131 | os: [linux]
132 | requiresBuild: true
133 | dev: true
134 | optional: true
135 |
136 | /@esbuild/linux-ia32@0.23.1:
137 | resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==}
138 | engines: {node: '>=18'}
139 | cpu: [ia32]
140 | os: [linux]
141 | requiresBuild: true
142 | dev: true
143 | optional: true
144 |
145 | /@esbuild/linux-loong64@0.23.1:
146 | resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==}
147 | engines: {node: '>=18'}
148 | cpu: [loong64]
149 | os: [linux]
150 | requiresBuild: true
151 | dev: true
152 | optional: true
153 |
154 | /@esbuild/linux-mips64el@0.23.1:
155 | resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==}
156 | engines: {node: '>=18'}
157 | cpu: [mips64el]
158 | os: [linux]
159 | requiresBuild: true
160 | dev: true
161 | optional: true
162 |
163 | /@esbuild/linux-ppc64@0.23.1:
164 | resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==}
165 | engines: {node: '>=18'}
166 | cpu: [ppc64]
167 | os: [linux]
168 | requiresBuild: true
169 | dev: true
170 | optional: true
171 |
172 | /@esbuild/linux-riscv64@0.23.1:
173 | resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==}
174 | engines: {node: '>=18'}
175 | cpu: [riscv64]
176 | os: [linux]
177 | requiresBuild: true
178 | dev: true
179 | optional: true
180 |
181 | /@esbuild/linux-s390x@0.23.1:
182 | resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==}
183 | engines: {node: '>=18'}
184 | cpu: [s390x]
185 | os: [linux]
186 | requiresBuild: true
187 | dev: true
188 | optional: true
189 |
190 | /@esbuild/linux-x64@0.23.1:
191 | resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==}
192 | engines: {node: '>=18'}
193 | cpu: [x64]
194 | os: [linux]
195 | requiresBuild: true
196 | dev: true
197 | optional: true
198 |
199 | /@esbuild/netbsd-x64@0.23.1:
200 | resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==}
201 | engines: {node: '>=18'}
202 | cpu: [x64]
203 | os: [netbsd]
204 | requiresBuild: true
205 | dev: true
206 | optional: true
207 |
208 | /@esbuild/openbsd-arm64@0.23.1:
209 | resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==}
210 | engines: {node: '>=18'}
211 | cpu: [arm64]
212 | os: [openbsd]
213 | requiresBuild: true
214 | dev: true
215 | optional: true
216 |
217 | /@esbuild/openbsd-x64@0.23.1:
218 | resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==}
219 | engines: {node: '>=18'}
220 | cpu: [x64]
221 | os: [openbsd]
222 | requiresBuild: true
223 | dev: true
224 | optional: true
225 |
226 | /@esbuild/sunos-x64@0.23.1:
227 | resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==}
228 | engines: {node: '>=18'}
229 | cpu: [x64]
230 | os: [sunos]
231 | requiresBuild: true
232 | dev: true
233 | optional: true
234 |
235 | /@esbuild/win32-arm64@0.23.1:
236 | resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==}
237 | engines: {node: '>=18'}
238 | cpu: [arm64]
239 | os: [win32]
240 | requiresBuild: true
241 | dev: true
242 | optional: true
243 |
244 | /@esbuild/win32-ia32@0.23.1:
245 | resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==}
246 | engines: {node: '>=18'}
247 | cpu: [ia32]
248 | os: [win32]
249 | requiresBuild: true
250 | dev: true
251 | optional: true
252 |
253 | /@esbuild/win32-x64@0.23.1:
254 | resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==}
255 | engines: {node: '>=18'}
256 | cpu: [x64]
257 | os: [win32]
258 | requiresBuild: true
259 | dev: true
260 | optional: true
261 |
262 | /@fastify/accept-negotiator@2.0.1:
263 | resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
264 | dev: false
265 |
266 | /@fastify/ajv-compiler@4.0.1:
267 | resolution: {integrity: sha512-DxrBdgsjNLP0YM6W5Hd6/Fmj43S8zMKiFJYgi+Ri3htTGAowPVG/tG1wpnWLMjufEnehRivUCKZ1pLDIoZdTuw==}
268 | dependencies:
269 | ajv: 8.17.1
270 | ajv-formats: 3.0.1(ajv@8.17.1)
271 | fast-uri: 3.0.3
272 | dev: false
273 |
274 | /@fastify/cors@10.0.1:
275 | resolution: {integrity: sha512-O8JIf6448uQbOgzSkCqhClw6gFTAqrdfeA6R3fc/3gwTJGUp7gl8/3tbNB+6INuu4RmgVOq99BmvdGbtu5pgOA==}
276 | dependencies:
277 | fastify-plugin: 5.0.1
278 | mnemonist: 0.39.8
279 | dev: false
280 |
281 | /@fastify/error@4.0.0:
282 | resolution: {integrity: sha512-OO/SA8As24JtT1usTUTKgGH7uLvhfwZPwlptRi2Dp5P4KKmJI3gvsZ8MIHnNwDs4sLf/aai5LzTyl66xr7qMxA==}
283 | dev: false
284 |
285 | /@fastify/fast-json-stringify-compiler@5.0.1:
286 | resolution: {integrity: sha512-f2d3JExJgFE3UbdFcpPwqNUEoHWmt8pAKf8f+9YuLESdefA0WgqxeT6DrGL4Yrf/9ihXNSKOqpjEmurV405meA==}
287 | dependencies:
288 | fast-json-stringify: 6.0.0
289 | dev: false
290 |
291 | /@fastify/merge-json-schemas@0.1.1:
292 | resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==}
293 | dependencies:
294 | fast-deep-equal: 3.1.3
295 | dev: false
296 |
297 | /@fastify/send@3.3.0:
298 | resolution: {integrity: sha512-hvrgPVG3oehn4wSPmRdqZcBCsEt7Lp6WOd6vsJ3Ms4hc5r5zouT9Ls9wq6R2tHMgJGHhNtsmd0CnhP7lmF7OTg==}
299 | dependencies:
300 | '@lukeed/ms': 2.0.2
301 | escape-html: 1.0.3
302 | fast-decode-uri-component: 1.0.1
303 | http-errors: 2.0.0
304 | mime: 3.0.0
305 | dev: false
306 |
307 | /@fastify/static@8.0.3:
308 | resolution: {integrity: sha512-GHSoOVDIxEYEeVR5l044bRCuAKDErD/+9VE+Z9fnaTRr+DDz0Avrm4kKai1mHbPx6C0U7BVNthjd/gcMquZZUA==}
309 | dependencies:
310 | '@fastify/accept-negotiator': 2.0.1
311 | '@fastify/send': 3.3.0
312 | content-disposition: 0.5.4
313 | fastify-plugin: 5.0.1
314 | fastq: 1.18.0
315 | glob: 11.0.0
316 | dev: false
317 |
318 | /@isaacs/cliui@8.0.2:
319 | resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
320 | engines: {node: '>=12'}
321 | dependencies:
322 | string-width: 5.1.2
323 | string-width-cjs: /string-width@4.2.3
324 | strip-ansi: 7.1.0
325 | strip-ansi-cjs: /strip-ansi@6.0.1
326 | wrap-ansi: 8.1.0
327 | wrap-ansi-cjs: /wrap-ansi@7.0.0
328 | dev: false
329 |
330 | /@lukeed/ms@2.0.2:
331 | resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
332 | engines: {node: '>=8'}
333 | dev: false
334 |
335 | /@types/node@22.10.2:
336 | resolution: {integrity: sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ==}
337 | dependencies:
338 | undici-types: 6.20.0
339 | dev: true
340 |
341 | /@types/uuid@10.0.0:
342 | resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==}
343 | dev: true
344 |
345 | /abstract-logging@2.0.1:
346 | resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
347 | dev: false
348 |
349 | /ajv-formats@3.0.1(ajv@8.17.1):
350 | resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
351 | peerDependencies:
352 | ajv: ^8.0.0
353 | peerDependenciesMeta:
354 | ajv:
355 | optional: true
356 | dependencies:
357 | ajv: 8.17.1
358 | dev: false
359 |
360 | /ajv@8.17.1:
361 | resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
362 | dependencies:
363 | fast-deep-equal: 3.1.3
364 | fast-uri: 3.0.3
365 | json-schema-traverse: 1.0.0
366 | require-from-string: 2.0.2
367 | dev: false
368 |
369 | /ansi-regex@5.0.1:
370 | resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
371 | engines: {node: '>=8'}
372 | dev: false
373 |
374 | /ansi-regex@6.1.0:
375 | resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==}
376 | engines: {node: '>=12'}
377 | dev: false
378 |
379 | /ansi-styles@4.3.0:
380 | resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
381 | engines: {node: '>=8'}
382 | dependencies:
383 | color-convert: 2.0.1
384 | dev: false
385 |
386 | /ansi-styles@6.2.1:
387 | resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
388 | engines: {node: '>=12'}
389 | dev: false
390 |
391 | /asynckit@0.4.0:
392 | resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
393 | dev: false
394 |
395 | /atomic-sleep@1.0.0:
396 | resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
397 | engines: {node: '>=8.0.0'}
398 | dev: false
399 |
400 | /avvio@9.1.0:
401 | resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
402 | dependencies:
403 | '@fastify/error': 4.0.0
404 | fastq: 1.18.0
405 | dev: false
406 |
407 | /axios@1.7.9:
408 | resolution: {integrity: sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==}
409 | dependencies:
410 | follow-redirects: 1.15.9
411 | form-data: 4.0.1
412 | proxy-from-env: 1.1.0
413 | transitivePeerDependencies:
414 | - debug
415 | dev: false
416 |
417 | /balanced-match@1.0.2:
418 | resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
419 | dev: false
420 |
421 | /brace-expansion@2.0.1:
422 | resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
423 | dependencies:
424 | balanced-match: 1.0.2
425 | dev: false
426 |
427 | /color-convert@2.0.1:
428 | resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
429 | engines: {node: '>=7.0.0'}
430 | dependencies:
431 | color-name: 1.1.4
432 | dev: false
433 |
434 | /color-name@1.1.4:
435 | resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
436 | dev: false
437 |
438 | /combined-stream@1.0.8:
439 | resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
440 | engines: {node: '>= 0.8'}
441 | dependencies:
442 | delayed-stream: 1.0.0
443 | dev: false
444 |
445 | /content-disposition@0.5.4:
446 | resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
447 | engines: {node: '>= 0.6'}
448 | dependencies:
449 | safe-buffer: 5.2.1
450 | dev: false
451 |
452 | /cookie@1.0.2:
453 | resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
454 | engines: {node: '>=18'}
455 | dev: false
456 |
457 | /cross-spawn@7.0.6:
458 | resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
459 | engines: {node: '>= 8'}
460 | dependencies:
461 | path-key: 3.1.1
462 | shebang-command: 2.0.0
463 | which: 2.0.2
464 | dev: false
465 |
466 | /delayed-stream@1.0.0:
467 | resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
468 | engines: {node: '>=0.4.0'}
469 | dev: false
470 |
471 | /depd@2.0.0:
472 | resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
473 | engines: {node: '>= 0.8'}
474 | dev: false
475 |
476 | /dotenv@16.4.7:
477 | resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==}
478 | engines: {node: '>=12'}
479 | dev: false
480 |
481 | /eastasianwidth@0.2.0:
482 | resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
483 | dev: false
484 |
485 | /emoji-regex@8.0.0:
486 | resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
487 | dev: false
488 |
489 | /emoji-regex@9.2.2:
490 | resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
491 | dev: false
492 |
493 | /esbuild@0.23.1:
494 | resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==}
495 | engines: {node: '>=18'}
496 | hasBin: true
497 | requiresBuild: true
498 | optionalDependencies:
499 | '@esbuild/aix-ppc64': 0.23.1
500 | '@esbuild/android-arm': 0.23.1
501 | '@esbuild/android-arm64': 0.23.1
502 | '@esbuild/android-x64': 0.23.1
503 | '@esbuild/darwin-arm64': 0.23.1
504 | '@esbuild/darwin-x64': 0.23.1
505 | '@esbuild/freebsd-arm64': 0.23.1
506 | '@esbuild/freebsd-x64': 0.23.1
507 | '@esbuild/linux-arm': 0.23.1
508 | '@esbuild/linux-arm64': 0.23.1
509 | '@esbuild/linux-ia32': 0.23.1
510 | '@esbuild/linux-loong64': 0.23.1
511 | '@esbuild/linux-mips64el': 0.23.1
512 | '@esbuild/linux-ppc64': 0.23.1
513 | '@esbuild/linux-riscv64': 0.23.1
514 | '@esbuild/linux-s390x': 0.23.1
515 | '@esbuild/linux-x64': 0.23.1
516 | '@esbuild/netbsd-x64': 0.23.1
517 | '@esbuild/openbsd-arm64': 0.23.1
518 | '@esbuild/openbsd-x64': 0.23.1
519 | '@esbuild/sunos-x64': 0.23.1
520 | '@esbuild/win32-arm64': 0.23.1
521 | '@esbuild/win32-ia32': 0.23.1
522 | '@esbuild/win32-x64': 0.23.1
523 | dev: true
524 |
525 | /escape-html@1.0.3:
526 | resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
527 | dev: false
528 |
529 | /fast-decode-uri-component@1.0.1:
530 | resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
531 | dev: false
532 |
533 | /fast-deep-equal@3.1.3:
534 | resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
535 | dev: false
536 |
537 | /fast-json-stringify@6.0.0:
538 | resolution: {integrity: sha512-FGMKZwniMTgZh7zQp9b6XnBVxUmKVahQLQeRQHqwYmPDqDhcEKZ3BaQsxelFFI5PY7nN71OEeiL47/zUWcYe1A==}
539 | dependencies:
540 | '@fastify/merge-json-schemas': 0.1.1
541 | ajv: 8.17.1
542 | ajv-formats: 3.0.1(ajv@8.17.1)
543 | fast-deep-equal: 3.1.3
544 | fast-uri: 2.4.0
545 | json-schema-ref-resolver: 1.0.1
546 | rfdc: 1.4.1
547 | dev: false
548 |
549 | /fast-querystring@1.1.2:
550 | resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
551 | dependencies:
552 | fast-decode-uri-component: 1.0.1
553 | dev: false
554 |
555 | /fast-redact@3.5.0:
556 | resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==}
557 | engines: {node: '>=6'}
558 | dev: false
559 |
560 | /fast-uri@2.4.0:
561 | resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==}
562 | dev: false
563 |
564 | /fast-uri@3.0.3:
565 | resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==}
566 | dev: false
567 |
568 | /fastify-plugin@5.0.1:
569 | resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==}
570 | dev: false
571 |
572 | /fastify@5.2.0:
573 | resolution: {integrity: sha512-3s+Qt5S14Eq5dCpnE0FxTp3z4xKChI83ZnMv+k0FwX+VUoZrgCFoLAxpfdi/vT4y6Mk+g7aAMt9pgXDoZmkefQ==}
574 | dependencies:
575 | '@fastify/ajv-compiler': 4.0.1
576 | '@fastify/error': 4.0.0
577 | '@fastify/fast-json-stringify-compiler': 5.0.1
578 | abstract-logging: 2.0.1
579 | avvio: 9.1.0
580 | fast-json-stringify: 6.0.0
581 | find-my-way: 9.1.0
582 | light-my-request: 6.4.0
583 | pino: 9.6.0
584 | process-warning: 4.0.0
585 | proxy-addr: 2.0.7
586 | rfdc: 1.4.1
587 | secure-json-parse: 3.0.1
588 | semver: 7.6.3
589 | toad-cache: 3.7.0
590 | dev: false
591 |
592 | /fastq@1.18.0:
593 | resolution: {integrity: sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==}
594 | dependencies:
595 | reusify: 1.0.4
596 | dev: false
597 |
598 | /find-my-way@9.1.0:
599 | resolution: {integrity: sha512-Y5jIsuYR4BwWDYYQ2A/RWWE6gD8a0FMgtU+HOq1WKku+Cwdz8M1v8wcAmRXXM1/iqtoqg06v+LjAxMYbCjViMw==}
600 | engines: {node: '>=14'}
601 | dependencies:
602 | fast-deep-equal: 3.1.3
603 | fast-querystring: 1.1.2
604 | safe-regex2: 4.0.1
605 | dev: false
606 |
607 | /follow-redirects@1.15.9:
608 | resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
609 | engines: {node: '>=4.0'}
610 | peerDependencies:
611 | debug: '*'
612 | peerDependenciesMeta:
613 | debug:
614 | optional: true
615 | dev: false
616 |
617 | /foreground-child@3.3.0:
618 | resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
619 | engines: {node: '>=14'}
620 | dependencies:
621 | cross-spawn: 7.0.6
622 | signal-exit: 4.1.0
623 | dev: false
624 |
625 | /form-data@4.0.1:
626 | resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==}
627 | engines: {node: '>= 6'}
628 | dependencies:
629 | asynckit: 0.4.0
630 | combined-stream: 1.0.8
631 | mime-types: 2.1.35
632 | dev: false
633 |
634 | /forwarded@0.2.0:
635 | resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
636 | engines: {node: '>= 0.6'}
637 | dev: false
638 |
639 | /fsevents@2.3.3:
640 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
641 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
642 | os: [darwin]
643 | requiresBuild: true
644 | dev: true
645 | optional: true
646 |
647 | /get-tsconfig@4.8.1:
648 | resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==}
649 | dependencies:
650 | resolve-pkg-maps: 1.0.0
651 | dev: true
652 |
653 | /glob@11.0.0:
654 | resolution: {integrity: sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==}
655 | engines: {node: 20 || >=22}
656 | hasBin: true
657 | dependencies:
658 | foreground-child: 3.3.0
659 | jackspeak: 4.0.2
660 | minimatch: 10.0.1
661 | minipass: 7.1.2
662 | package-json-from-dist: 1.0.1
663 | path-scurry: 2.0.0
664 | dev: false
665 |
666 | /http-errors@2.0.0:
667 | resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
668 | engines: {node: '>= 0.8'}
669 | dependencies:
670 | depd: 2.0.0
671 | inherits: 2.0.4
672 | setprototypeof: 1.2.0
673 | statuses: 2.0.1
674 | toidentifier: 1.0.1
675 | dev: false
676 |
677 | /inherits@2.0.4:
678 | resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
679 | dev: false
680 |
681 | /ipaddr.js@1.9.1:
682 | resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
683 | engines: {node: '>= 0.10'}
684 | dev: false
685 |
686 | /is-fullwidth-code-point@3.0.0:
687 | resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
688 | engines: {node: '>=8'}
689 | dev: false
690 |
691 | /isexe@2.0.0:
692 | resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
693 | dev: false
694 |
695 | /jackspeak@4.0.2:
696 | resolution: {integrity: sha512-bZsjR/iRjl1Nk1UkjGpAzLNfQtzuijhn2g+pbZb98HQ1Gk8vM9hfbxeMBP+M2/UUdwj0RqGG3mlvk2MsAqwvEw==}
697 | engines: {node: 20 || >=22}
698 | dependencies:
699 | '@isaacs/cliui': 8.0.2
700 | dev: false
701 |
702 | /json-schema-ref-resolver@1.0.1:
703 | resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==}
704 | dependencies:
705 | fast-deep-equal: 3.1.3
706 | dev: false
707 |
708 | /json-schema-traverse@1.0.0:
709 | resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
710 | dev: false
711 |
712 | /light-my-request@6.4.0:
713 | resolution: {integrity: sha512-U0UONITz4GVQodMPoygnqJan2RYfhyLsCzFBakJHWNfiQKyHzvp38YOxxLGs8lIDPwR6ngd4gmuZJQQJtRBu/A==}
714 | dependencies:
715 | cookie: 1.0.2
716 | process-warning: 4.0.0
717 | set-cookie-parser: 2.7.1
718 | dev: false
719 |
720 | /lru-cache@11.0.2:
721 | resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==}
722 | engines: {node: 20 || >=22}
723 | dev: false
724 |
725 | /mime-db@1.52.0:
726 | resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
727 | engines: {node: '>= 0.6'}
728 | dev: false
729 |
730 | /mime-types@2.1.35:
731 | resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
732 | engines: {node: '>= 0.6'}
733 | dependencies:
734 | mime-db: 1.52.0
735 | dev: false
736 |
737 | /mime@3.0.0:
738 | resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==}
739 | engines: {node: '>=10.0.0'}
740 | hasBin: true
741 | dev: false
742 |
743 | /minimatch@10.0.1:
744 | resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==}
745 | engines: {node: 20 || >=22}
746 | dependencies:
747 | brace-expansion: 2.0.1
748 | dev: false
749 |
750 | /minipass@7.1.2:
751 | resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
752 | engines: {node: '>=16 || 14 >=14.17'}
753 | dev: false
754 |
755 | /mnemonist@0.39.8:
756 | resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==}
757 | dependencies:
758 | obliterator: 2.0.4
759 | dev: false
760 |
761 | /obliterator@2.0.4:
762 | resolution: {integrity: sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==}
763 | dev: false
764 |
765 | /on-exit-leak-free@2.1.2:
766 | resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
767 | engines: {node: '>=14.0.0'}
768 | dev: false
769 |
770 | /package-json-from-dist@1.0.1:
771 | resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
772 | dev: false
773 |
774 | /path-key@3.1.1:
775 | resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
776 | engines: {node: '>=8'}
777 | dev: false
778 |
779 | /path-scurry@2.0.0:
780 | resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==}
781 | engines: {node: 20 || >=22}
782 | dependencies:
783 | lru-cache: 11.0.2
784 | minipass: 7.1.2
785 | dev: false
786 |
787 | /pino-abstract-transport@2.0.0:
788 | resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
789 | dependencies:
790 | split2: 4.2.0
791 | dev: false
792 |
793 | /pino-std-serializers@7.0.0:
794 | resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
795 | dev: false
796 |
797 | /pino@9.6.0:
798 | resolution: {integrity: sha512-i85pKRCt4qMjZ1+L7sy2Ag4t1atFcdbEt76+7iRJn1g2BvsnRMGu9p8pivl9fs63M2kF/A0OacFZhTub+m/qMg==}
799 | hasBin: true
800 | dependencies:
801 | atomic-sleep: 1.0.0
802 | fast-redact: 3.5.0
803 | on-exit-leak-free: 2.1.2
804 | pino-abstract-transport: 2.0.0
805 | pino-std-serializers: 7.0.0
806 | process-warning: 4.0.0
807 | quick-format-unescaped: 4.0.4
808 | real-require: 0.2.0
809 | safe-stable-stringify: 2.5.0
810 | sonic-boom: 4.2.0
811 | thread-stream: 3.1.0
812 | dev: false
813 |
814 | /prettier@3.4.2:
815 | resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==}
816 | engines: {node: '>=14'}
817 | hasBin: true
818 | dev: true
819 |
820 | /process-warning@4.0.0:
821 | resolution: {integrity: sha512-/MyYDxttz7DfGMMHiysAsFE4qF+pQYAA8ziO/3NcRVrQ5fSk+Mns4QZA/oRPFzvcqNoVJXQNWNAsdwBXLUkQKw==}
822 | dev: false
823 |
824 | /proxy-addr@2.0.7:
825 | resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
826 | engines: {node: '>= 0.10'}
827 | dependencies:
828 | forwarded: 0.2.0
829 | ipaddr.js: 1.9.1
830 | dev: false
831 |
832 | /proxy-from-env@1.1.0:
833 | resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
834 | dev: false
835 |
836 | /quick-format-unescaped@4.0.4:
837 | resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
838 | dev: false
839 |
840 | /real-require@0.2.0:
841 | resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
842 | engines: {node: '>= 12.13.0'}
843 | dev: false
844 |
845 | /require-from-string@2.0.2:
846 | resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
847 | engines: {node: '>=0.10.0'}
848 | dev: false
849 |
850 | /resolve-pkg-maps@1.0.0:
851 | resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
852 | dev: true
853 |
854 | /ret@0.5.0:
855 | resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
856 | engines: {node: '>=10'}
857 | dev: false
858 |
859 | /reusify@1.0.4:
860 | resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
861 | engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
862 | dev: false
863 |
864 | /rfdc@1.4.1:
865 | resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
866 | dev: false
867 |
868 | /safe-buffer@5.2.1:
869 | resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
870 | dev: false
871 |
872 | /safe-regex2@4.0.1:
873 | resolution: {integrity: sha512-goqsB+bSlOmVX+CiFX2PFc1OV88j5jvBqIM+DgqrucHnUguAUNtiNOs+aTadq2NqsLQ+TQ3UEVG3gtSFcdlkCg==}
874 | dependencies:
875 | ret: 0.5.0
876 | dev: false
877 |
878 | /safe-stable-stringify@2.5.0:
879 | resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
880 | engines: {node: '>=10'}
881 | dev: false
882 |
883 | /secure-json-parse@3.0.1:
884 | resolution: {integrity: sha512-9QR7G96th4QJ2+dJwvZB+JoXyt8PN+DbEjOr6kL2/JU4KH8Eb2sFdU+gt8EDdzWDWoWH0uocDdfCoFzdVSixUA==}
885 | dev: false
886 |
887 | /semver@7.6.3:
888 | resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==}
889 | engines: {node: '>=10'}
890 | hasBin: true
891 | dev: false
892 |
893 | /set-cookie-parser@2.7.1:
894 | resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==}
895 | dev: false
896 |
897 | /setprototypeof@1.2.0:
898 | resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
899 | dev: false
900 |
901 | /shebang-command@2.0.0:
902 | resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
903 | engines: {node: '>=8'}
904 | dependencies:
905 | shebang-regex: 3.0.0
906 | dev: false
907 |
908 | /shebang-regex@3.0.0:
909 | resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
910 | engines: {node: '>=8'}
911 | dev: false
912 |
913 | /signal-exit@4.1.0:
914 | resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
915 | engines: {node: '>=14'}
916 | dev: false
917 |
918 | /sonic-boom@4.2.0:
919 | resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
920 | dependencies:
921 | atomic-sleep: 1.0.0
922 | dev: false
923 |
924 | /split2@4.2.0:
925 | resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
926 | engines: {node: '>= 10.x'}
927 | dev: false
928 |
929 | /statuses@2.0.1:
930 | resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
931 | engines: {node: '>= 0.8'}
932 | dev: false
933 |
934 | /string-width@4.2.3:
935 | resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
936 | engines: {node: '>=8'}
937 | dependencies:
938 | emoji-regex: 8.0.0
939 | is-fullwidth-code-point: 3.0.0
940 | strip-ansi: 6.0.1
941 | dev: false
942 |
943 | /string-width@5.1.2:
944 | resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
945 | engines: {node: '>=12'}
946 | dependencies:
947 | eastasianwidth: 0.2.0
948 | emoji-regex: 9.2.2
949 | strip-ansi: 7.1.0
950 | dev: false
951 |
952 | /strip-ansi@6.0.1:
953 | resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
954 | engines: {node: '>=8'}
955 | dependencies:
956 | ansi-regex: 5.0.1
957 | dev: false
958 |
959 | /strip-ansi@7.1.0:
960 | resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
961 | engines: {node: '>=12'}
962 | dependencies:
963 | ansi-regex: 6.1.0
964 | dev: false
965 |
966 | /thread-stream@3.1.0:
967 | resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
968 | dependencies:
969 | real-require: 0.2.0
970 | dev: false
971 |
972 | /toad-cache@3.7.0:
973 | resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
974 | engines: {node: '>=12'}
975 | dev: false
976 |
977 | /toidentifier@1.0.1:
978 | resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
979 | engines: {node: '>=0.6'}
980 | dev: false
981 |
982 | /tsx@4.19.2:
983 | resolution: {integrity: sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==}
984 | engines: {node: '>=18.0.0'}
985 | hasBin: true
986 | dependencies:
987 | esbuild: 0.23.1
988 | get-tsconfig: 4.8.1
989 | optionalDependencies:
990 | fsevents: 2.3.3
991 | dev: true
992 |
993 | /typescript@5.7.2:
994 | resolution: {integrity: sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==}
995 | engines: {node: '>=14.17'}
996 | hasBin: true
997 | dev: true
998 |
999 | /undici-types@6.20.0:
1000 | resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
1001 | dev: true
1002 |
1003 | /uuid@11.0.3:
1004 | resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==}
1005 | hasBin: true
1006 | dev: false
1007 |
1008 | /which@2.0.2:
1009 | resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
1010 | engines: {node: '>= 8'}
1011 | hasBin: true
1012 | dependencies:
1013 | isexe: 2.0.0
1014 | dev: false
1015 |
1016 | /wrap-ansi@7.0.0:
1017 | resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
1018 | engines: {node: '>=10'}
1019 | dependencies:
1020 | ansi-styles: 4.3.0
1021 | string-width: 4.2.3
1022 | strip-ansi: 6.0.1
1023 | dev: false
1024 |
1025 | /wrap-ansi@8.1.0:
1026 | resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
1027 | engines: {node: '>=12'}
1028 | dependencies:
1029 | ansi-styles: 6.2.1
1030 | string-width: 5.1.2
1031 | strip-ansi: 7.1.0
1032 | dev: false
1033 |
--------------------------------------------------------------------------------