diff --git a/packages/contact-center/ai-assistant/bable.config.js b/packages/contact-center/ai-assistant/bable.config.js new file mode 100644 index 000000000..0eaef236c --- /dev/null +++ b/packages/contact-center/ai-assistant/bable.config.js @@ -0,0 +1,3 @@ +const baseConfig = require('../../../babel.config.js'); + +module.exports = baseConfig; diff --git a/packages/contact-center/ai-assistant/eslint.config.mjs b/packages/contact-center/ai-assistant/eslint.config.mjs new file mode 100644 index 000000000..ec394673b --- /dev/null +++ b/packages/contact-center/ai-assistant/eslint.config.mjs @@ -0,0 +1,20 @@ +import globals from 'globals'; +import pluginJs from '@eslint/js'; +import tseslint from 'typescript-eslint'; +import pluginReact from 'eslint-plugin-react'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import eslintConfigPrettier from 'eslint-config-prettier'; + +export default [ + {files: ['**/src/**/*.{js,mjs,cjs,ts,jsx,tsx}']}, + {ignores: ['.babelrc.js', '*config.{js,ts}', 'dist', 'node_modules', 'coverage']}, + {languageOptions: {globals: globals.browser}}, + pluginJs.configs.recommended, + ...tseslint.configs.recommended, + { + ...pluginReact.configs.flat.recommended, + settings: {react: {version: 'detect'}}, + }, + eslintPluginPrettierRecommended, + eslintConfigPrettier, +]; diff --git a/packages/contact-center/ai-assistant/jest.config.js b/packages/contact-center/ai-assistant/jest.config.js new file mode 100644 index 000000000..42ff9801e --- /dev/null +++ b/packages/contact-center/ai-assistant/jest.config.js @@ -0,0 +1,6 @@ +const jestConfig = require('../../../jest.config.js'); + +jestConfig.rootDir = '../../../'; +jestConfig.testMatch = ['**/ai-assistant/tests/**/*.ts', '**/ai-assistant/tests/**/*.tsx']; + +module.exports = jestConfig; diff --git a/packages/contact-center/ai-assistant/package.json b/packages/contact-center/ai-assistant/package.json new file mode 100644 index 000000000..fdf95ffcb --- /dev/null +++ b/packages/contact-center/ai-assistant/package.json @@ -0,0 +1,70 @@ +{ + "name": "@webex/cc-ai-assistant", + "description": "Webex Contact Center Widgets: AI Assistant", + "license": "Cisco's General Terms (https://www.cisco.com/site/us/en/about/legal/contract-experience/index.html)", + "version": "1.28.0-ccwidgets.126", + "main": "dist/index.js", + "types": "dist/types/index.d.ts", + "publishConfig": { + "access": "public" + }, + "files": [ + "dist/", + "package.json" + ], + "scripts": { + "clean": "rm -rf dist && rm -rf node_modules", + "clean:dist": "rm -rf dist", + "build": "yarn run -T tsc", + "build:src": "yarn run clean:dist && webpack", + "build:watch": "webpack --watch", + "test:unit": "tsc --project tsconfig.test.json && jest --coverage", + "test:styles": "eslint", + "deploy:npm": "yarn npm publish" + }, + "dependencies": { + "@webex/cc-components": "workspace:*", + "@webex/cc-store": "workspace:*", + "@webex/cc-ui-logging": "workspace:*", + "mobx-react-lite": "^4.1.0", + "react-error-boundary": "^6.0.0", + "typescript": "5.6.3" + }, + "devDependencies": { + "@babel/core": "7.25.2", + "@babel/preset-env": "7.25.4", + "@babel/preset-react": "7.24.7", + "@babel/preset-typescript": "7.25.9", + "@eslint/js": "^9.20.0", + "@testing-library/dom": "10.4.0", + "@testing-library/jest-dom": "6.6.2", + "@testing-library/react": "16.0.1", + "@types/jest": "29.5.14", + "@types/react-test-renderer": "18", + "@webex/test-fixtures": "workspace:*", + "babel-jest": "29.7.0", + "babel-loader": "9.2.1", + "eslint": "^9.20.1", + "eslint-config-prettier": "^10.0.1", + "eslint-config-standard": "^17.1.0", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", + "eslint-plugin-prettier": "^5.2.3", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react": "^7.37.4", + "file-loader": "6.2.0", + "globals": "^16.0.0", + "jest": "29.7.0", + "jest-environment-jsdom": "29.7.0", + "prettier": "^3.5.1", + "ts-loader": "9.5.1", + "typescript-eslint": "^8.24.1", + "webpack": "5.94.0", + "webpack-cli": "5.1.4", + "webpack-merge": "6.0.1" + }, + "peerDependencies": { + "react": ">=18.3.1", + "react-dom": ">=18.3.1" + } +} diff --git a/packages/contact-center/ai-assistant/src/ai-assistant.types.ts b/packages/contact-center/ai-assistant/src/ai-assistant.types.ts new file mode 100644 index 000000000..3e921fdee --- /dev/null +++ b/packages/contact-center/ai-assistant/src/ai-assistant.types.ts @@ -0,0 +1,26 @@ +import type {RealTimeAssistPayload} from '@webex/cc-store'; + +/** + * Public props for the `AIAssistant` widget. All callbacks are optional — + * the host can opt into any subset. + */ +export interface IAIAssistantProps { + /** Fired when the launcher is clicked and the panel opens. */ + onOpen?: () => void; + /** Fired when the agent minimizes the panel to its collapsed bar. */ + onMinimize?: () => void; + /** Fired when the minimized bar is restored to the full panel. */ + onRestore?: () => void; + /** Fired when the panel is closed back to the launcher. */ + onClose?: () => void; + /** Fired when the chat is reset (suggestions, drafts, session flags wiped). */ + onClearChat?: () => void; + /** Fired when the fullscreen affordance is toggled. Host owns layout. */ + onFullScreenToggle?: (isFullScreen: boolean) => void; + /** Fired each time a fresh real-time assist response arrives for the active task. */ + onRealTimeAssistReceived?: (payload: RealTimeAssistPayload) => void; + /** @deprecated Use `onRealTimeAssistReceived` instead. */ + onSuggestionReceived?: (payload: RealTimeAssistPayload) => void; + /** Optional extra class applied to the widget root. */ + className?: string; +} diff --git a/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx b/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx new file mode 100644 index 000000000..a0e9b8f74 --- /dev/null +++ b/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx @@ -0,0 +1,71 @@ +import React, {useCallback} from 'react'; +import store from '@webex/cc-store'; +import {observer} from 'mobx-react-lite'; +import {ErrorBoundary} from 'react-error-boundary'; + +import {AIAssistantComponent} from '@webex/cc-components'; +import type {AIAssistantFeedbackEvent} from '@webex/cc-components'; +import type {RealTimeAssistPayload} from '@webex/cc-store'; +import {useAiAssistant, REAL_TIME_ASSIST_FLAG} from '../helper'; +import {IAIAssistantProps} from '../ai-assistant.types'; + +const AIAssistantInternal: React.FunctionComponent = observer((props) => { + const {currentTask, agentId, featureFlags, realTimeAssist} = store; + const interactionId = currentTask?.data?.interactionId; + const isFeatureEnabled = Boolean(featureFlags?.[REAL_TIME_ASSIST_FLAG]); + const activeRealTimeAssist = interactionId ? realTimeAssist?.[interactionId] || [] : []; + + const hookProps = useAiAssistant({ + ...props, + interactionId, + agentId, + isFeatureEnabled, + realTimeAssist: activeRealTimeAssist, + }); + + const handleRealTimeAssistAction = useCallback( + (event: AIAssistantFeedbackEvent, assist: RealTimeAssistPayload) => { + const api = store.cc?.apiAIAssistant; + const adaptiveCardId = assist?.data?.adaptiveCardId; + if (!interactionId || !agentId || !adaptiveCardId || !api?.sendRealTimeAssistanceUserAction) return; + + void api + .sendRealTimeAssistanceUserAction({ + agentId, + interactionId, + adaptiveCardId, + actionId: event.actionId, + languageCode: typeof assist?.data?.languageCode === 'string' ? assist.data.languageCode : undefined, + }) + .catch((error) => { + store.logger?.error(`CC-Widgets: sendRealTimeAssistanceUserAction failed - ${error}`, { + module: 'ai-assistant/index.tsx', + method: 'handleRealTimeAssistAction', + }); + }); + }, + [agentId, interactionId] + ); + + return ( + + ); +}); + +const AIAssistant: React.FunctionComponent = (props) => ( + <>} + onError={(error: Error) => { + if (store.onErrorCallback) store.onErrorCallback('AIAssistant', error); + }} + > + + +); + +export {AIAssistant}; diff --git a/packages/contact-center/ai-assistant/src/helper.ts b/packages/contact-center/ai-assistant/src/helper.ts new file mode 100644 index 000000000..0f2288aa2 --- /dev/null +++ b/packages/contact-center/ai-assistant/src/helper.ts @@ -0,0 +1,243 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import store from '@webex/cc-store'; +import type {RealTimeAssistPayload} from '@webex/cc-store'; +import type {AIAssistantChatEntry, AIAssistantChromeState, AIAssistantRequestStatus} from '@webex/cc-components'; +import {IAIAssistantProps} from './ai-assistant.types'; + +const REAL_TIME_ASSIST_FLAG = 'isSuggestedResponsesEnabled'; +const GREETING_TEXT = "I'm here to help! I'll keep listening and suggest responses as the conversation evolves."; + +interface UseAiAssistantInput extends IAIAssistantProps { + interactionId?: string; + agentId: string; + isFeatureEnabled: boolean; + realTimeAssist: RealTimeAssistPayload[]; +} + +type UserMessage = {id: string; text: string; sentAt: number}; + +export const useAiAssistant = ({ + interactionId, + agentId, + isFeatureEnabled, + realTimeAssist, + onOpen, + onMinimize, + onRestore, + onClose, + onClearChat, + onFullScreenToggle, + onRealTimeAssistReceived, +}: UseAiAssistantInput) => { + const [chrome, setChrome] = useState('closed'); + const [isFullScreen, setIsFullScreen] = useState(false); + const [requestStatus, setRequestStatus] = useState('idle'); + const [errorMessage, setErrorMessage] = useState(undefined); + const [contextDraft, setContextDraft] = useState(''); + const [pendingRequest, setPendingRequest] = useState(false); + // ADD_SUGGESTIONS_EXTRA_CONTEXT only makes sense after a GET_SUGGESTIONS has fired. + const [hasFiredInitialRequest, setHasFiredInitialRequest] = useState(false); + const [userMessages, setUserMessages] = useState([]); + + const lastSeenCountRef = useRef(0); + // Stash these in refs so the response effect can read the latest values + // without re-running on every change (it only reacts to `realTimeAssist`). + const pendingRequestRef = useRef(pendingRequest); + const requestStatusRef = useRef(requestStatus); + const onRealTimeAssistReceivedRef = useRef(onRealTimeAssistReceived); + useEffect(() => { + pendingRequestRef.current = pendingRequest; + requestStatusRef.current = requestStatus; + onRealTimeAssistReceivedRef.current = onRealTimeAssistReceived; + }); + + useEffect(() => { + const len = realTimeAssist.length; + if (len === 0) { + lastSeenCountRef.current = 0; + return; + } + if (len <= lastSeenCountRef.current) return; + lastSeenCountRef.current = len; + + const latest = realTimeAssist[len - 1]; + if (pendingRequestRef.current) { + setPendingRequest(false); + setRequestStatus('ready'); + } else if (requestStatusRef.current !== 'ready') { + setRequestStatus('ready'); + } + onRealTimeAssistReceivedRef.current?.(latest); + }, [realTimeAssist]); + + const open = useCallback(() => { + setChrome('open'); + onOpen?.(); + }, [onOpen]); + + // close preserves chat state so reopening continues the session; clearChat resets it. + const close = useCallback(() => { + setChrome('closed'); + setIsFullScreen(false); + onClose?.(); + }, [onClose]); + + const minimize = useCallback(() => { + setChrome('minimized'); + onMinimize?.(); + }, [onMinimize]); + + const restore = useCallback(() => { + setChrome('open'); + onRestore?.(); + }, [onRestore]); + + const toggleFullScreen = useCallback(() => { + setIsFullScreen((prev) => { + const next = !prev; + onFullScreenToggle?.(next); + return next; + }); + }, [onFullScreenToggle]); + + const clearChat = useCallback(() => { + setRequestStatus('idle'); + setErrorMessage(undefined); + setContextDraft(''); + setPendingRequest(false); + setHasFiredInitialRequest(false); + setUserMessages([]); + lastSeenCountRef.current = 0; + if (interactionId) { + store.clearRealTimeAssist?.(interactionId); + } + onClearChat?.(); + }, [interactionId, onClearChat]); + + const requestRealTimeAssist = useCallback( + async (context?: string) => { + if (!isFeatureEnabled) { + setRequestStatus('error'); + setErrorMessage('AI real-time assist is not enabled for your profile.'); + return; + } + if (!interactionId || !agentId) { + setRequestStatus('error'); + setErrorMessage('No active interaction to request real-time assist for.'); + return; + } + const api = store.cc?.apiAIAssistant; + if (!api?.getRealTimeAssistance) { + setRequestStatus('error'); + setErrorMessage( + 'AI assistant API is not available in the loaded SDK build. Update @webex/contact-center to a build that exposes apiAIAssistant.getRealTimeAssistance.' + ); + return; + } + setRequestStatus('listening'); + setErrorMessage(undefined); + setPendingRequest(true); + setHasFiredInitialRequest(true); + const sentAt = Date.now(); + if (context) { + setUserMessages((prev) => [...prev, {id: `${sentAt}-${prev.length}`, text: context, sentAt}]); + } + try { + await api.getRealTimeAssistance({ + agentId, + interactionId, + actionTimeStamp: sentAt, + ...(context ? {context} : {}), + }); + } catch (err) { + setPendingRequest(false); + setRequestStatus('error'); + setErrorMessage((err as Error)?.message || 'Failed to request real-time assist.'); + } + }, + [agentId, interactionId, isFeatureEnabled] + ); + + const submitContext = useCallback(() => { + const draft = contextDraft.trim(); + if (!draft) return; + requestRealTimeAssist(draft); + setContextDraft(''); + }, [contextDraft, requestRealTimeAssist]); + + // Chronological transcript: optional greeting, then interleaved user/assistant entries. + const chatEntries = useMemo(() => { + const assistantEntries: Array<{ts: number; order: number; entry: AIAssistantChatEntry}> = realTimeAssist.map( + (assist, index) => { + const publishTimestamp = assist?.data?.publishTimestamp; + const ts = + typeof publishTimestamp === 'number' + ? publishTimestamp + : typeof publishTimestamp === 'string' + ? Number.parseInt(publishTimestamp, 10) || 0 + : 0; + const id = + (assist?.data?.adaptiveCardId as string | undefined) ?? + (assist?.data?.trackingId as string | undefined) ?? + `assistant-${index}`; + return {ts, order: 2, entry: {type: 'assistant', id, realTimeAssist: assist}}; + } + ); + + const userEntries: Array<{ts: number; order: number; entry: AIAssistantChatEntry}> = userMessages.map( + (message) => ({ + ts: message.sentAt, + order: 1, + entry: {type: 'user', id: message.id, text: message.text}, + }) + ); + + const sorted = [...userEntries, ...assistantEntries] + .sort((a, b) => (a.ts === b.ts ? a.order - b.order : a.ts - b.ts)) + .map(({entry}) => entry); + + if (!hasFiredInitialRequest) return sorted; + + return [{type: 'assistant-greeting', id: 'greeting-assistant', text: GREETING_TEXT}, ...sorted]; + }, [realTimeAssist, userMessages, hasFiredInitialRequest]); + + return useMemo( + () => ({ + chrome, + isFullScreen, + requestStatus, + errorMessage, + contextDraft, + hasFiredInitialRequest, + chatEntries, + open, + close, + minimize, + restore, + toggleFullScreen, + clearChat, + requestSuggestion: () => requestRealTimeAssist(), + setContextDraft, + submitContext, + }), + [ + chrome, + isFullScreen, + requestStatus, + errorMessage, + contextDraft, + hasFiredInitialRequest, + chatEntries, + open, + close, + minimize, + restore, + toggleFullScreen, + clearChat, + requestRealTimeAssist, + submitContext, + ] + ); +}; + +export {REAL_TIME_ASSIST_FLAG}; diff --git a/packages/contact-center/ai-assistant/src/index.ts b/packages/contact-center/ai-assistant/src/index.ts new file mode 100644 index 000000000..76ce32d8c --- /dev/null +++ b/packages/contact-center/ai-assistant/src/index.ts @@ -0,0 +1,4 @@ +import {AIAssistant} from './ai-assistant/index'; + +export {AIAssistant}; +export * from './ai-assistant.types'; diff --git a/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx b/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx new file mode 100644 index 000000000..3ac16ad15 --- /dev/null +++ b/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import {render, screen} from '@testing-library/react'; +import {AIAssistant} from '../../src'; +import * as helper from '../../src/helper'; +import store from '@webex/cc-store'; +import '@testing-library/jest-dom'; + +jest.mock('@webex/cc-store', () => { + return { + __esModule: true, + default: { + cc: { + apiAIAssistant: { + getRealTimeAssistance: jest.fn(), + sendRealTimeAssistanceUserAction: jest.fn(), + }, + }, + currentTask: {data: {interactionId: 'interaction-1'}}, + agentId: 'agent-1', + featureFlags: {isSuggestedResponsesEnabled: true}, + realTimeAssist: {}, + onErrorCallback: undefined, + clearRealTimeAssist: jest.fn(), + }, + }; +}); + +type StoreMock = { + cc: { + apiAIAssistant: { + getRealTimeAssistance: jest.Mock; + sendRealTimeAssistanceUserAction: jest.Mock; + }; + }; + currentTask: {data: {interactionId: string}}; + agentId: string; + featureFlags: {isSuggestedResponsesEnabled: boolean}; + realTimeAssist: Record; + onErrorCallback?: jest.Mock; + clearRealTimeAssist: jest.Mock; +}; +const storeMock = store as unknown as StoreMock; + +describe('AIAssistant widget', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + storeMock.realTimeAssist = {}; + storeMock.onErrorCallback = undefined; + }); + + it('renders launcher when chrome is closed', () => { + render(); + expect(screen.getByTestId('ai-assistant:launcher')).toBeInTheDocument(); + }); + + it('passes through className to root', () => { + const {container} = render(); + expect(container.querySelector('.my-host-class')).toBeInTheDocument(); + }); + + it('routes errors thrown in the hook to store.onErrorCallback', () => { + const onErrorCallback = jest.fn(); + storeMock.onErrorCallback = onErrorCallback; + jest.spyOn(helper, 'useAiAssistant').mockImplementation(() => { + throw new Error('Boom'); + }); + + const {container} = render(); + expect(container.firstChild).toBeNull(); + expect(onErrorCallback).toHaveBeenCalledWith('AIAssistant', expect.any(Error)); + }); +}); diff --git a/packages/contact-center/ai-assistant/tests/helper.ts b/packages/contact-center/ai-assistant/tests/helper.ts new file mode 100644 index 000000000..a20dd5727 --- /dev/null +++ b/packages/contact-center/ai-assistant/tests/helper.ts @@ -0,0 +1,243 @@ +import {renderHook, act, waitFor} from '@testing-library/react'; +import {useAiAssistant} from '../src/helper'; +import store from '@webex/cc-store'; + +jest.mock('@webex/cc-store', () => { + const clearRealTimeAssist = jest.fn(); + const getRealTimeAssistance = jest.fn().mockResolvedValue({}); + return { + __esModule: true, + default: { + cc: { + apiAIAssistant: { + getRealTimeAssistance, + }, + }, + clearRealTimeAssist, + }, + }; +}); + +type StoreMock = { + cc: {apiAIAssistant: {getRealTimeAssistance: jest.Mock}}; + clearRealTimeAssist: jest.Mock; +}; +const storeMock = store as unknown as StoreMock; + +const baseProps = { + agentId: 'agent-1', + interactionId: 'interaction-1', + isFeatureEnabled: true, + realTimeAssist: [] as Array<{data: {adaptiveCard: unknown; title?: string}}>, +}; + +describe('useAiAssistant', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('starts in the closed/idle state', () => { + const {result} = renderHook(() => useAiAssistant(baseProps)); + expect(result.current.chrome).toBe('closed'); + expect(result.current.isFullScreen).toBe(false); + expect(result.current.requestStatus).toBe('idle'); + }); + + it('open/close/minimize/restore flips chrome state and fires callbacks', () => { + const onOpen = jest.fn(); + const onClose = jest.fn(); + const onMinimize = jest.fn(); + const onRestore = jest.fn(); + const {result} = renderHook(() => useAiAssistant({...baseProps, onOpen, onClose, onMinimize, onRestore})); + + act(() => result.current.open()); + expect(result.current.chrome).toBe('open'); + expect(onOpen).toHaveBeenCalled(); + + act(() => result.current.minimize()); + expect(result.current.chrome).toBe('minimized'); + expect(onMinimize).toHaveBeenCalled(); + + act(() => result.current.restore()); + expect(result.current.chrome).toBe('open'); + expect(onRestore).toHaveBeenCalled(); + + act(() => result.current.close()); + expect(result.current.chrome).toBe('closed'); + expect(onClose).toHaveBeenCalled(); + }); + + it('toggleFullScreen flips state and reports the next value', () => { + const onFullScreenToggle = jest.fn(); + const {result} = renderHook(() => useAiAssistant({...baseProps, onFullScreenToggle})); + + act(() => result.current.toggleFullScreen()); + expect(result.current.isFullScreen).toBe(true); + expect(onFullScreenToggle).toHaveBeenLastCalledWith(true); + + act(() => result.current.toggleFullScreen()); + expect(result.current.isFullScreen).toBe(false); + expect(onFullScreenToggle).toHaveBeenLastCalledWith(false); + }); + + it('clearChat resets state, calls store.clearRealTimeAssist, and fires onClearChat', () => { + const onClearChat = jest.fn(); + const {result} = renderHook(() => useAiAssistant({...baseProps, onClearChat})); + + act(() => result.current.setContextDraft('hello')); + act(() => result.current.clearChat()); + + expect(result.current.contextDraft).toBe(''); + expect(result.current.requestStatus).toBe('idle'); + expect(storeMock.clearRealTimeAssist).toHaveBeenCalledWith('interaction-1'); + expect(onClearChat).toHaveBeenCalled(); + }); + + it('requestSuggestion sends the right shape and sets listening', async () => { + const {result} = renderHook(() => useAiAssistant(baseProps)); + + await act(async () => { + result.current.requestSuggestion(); + }); + + expect(storeMock.cc.apiAIAssistant.getRealTimeAssistance).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: 'agent-1', + interactionId: 'interaction-1', + actionTimeStamp: expect.any(Number), + }) + ); + expect(result.current.requestStatus).toBe('listening'); + }); + + it('flips status to ready and notifies host once a suggestion arrives', async () => { + const onRealTimeAssistReceived = jest.fn(); + const payload = {data: {adaptiveCard: {type: 'AdaptiveCard'}, title: 'Refund policy'}}; + + const {result, rerender} = renderHook((props: {realTimeAssist?: Array} = {}) => + useAiAssistant({ + ...baseProps, + ...props, + realTimeAssist: props.realTimeAssist ?? [], + onRealTimeAssistReceived, + }) + ); + + await act(async () => { + result.current.requestSuggestion(); + }); + expect(result.current.requestStatus).toBe('listening'); + + rerender({realTimeAssist: [payload]}); + + await waitFor(() => expect(result.current.requestStatus).toBe('ready')); + expect(onRealTimeAssistReceived).toHaveBeenCalledWith(payload); + }); + + it('appends additional suggestions and notifies the host on each', async () => { + const onRealTimeAssistReceived = jest.fn(); + const first = {data: {adaptiveCard: {type: 'AdaptiveCard'}, title: 'first'}}; + const second = {data: {adaptiveCard: {type: 'AdaptiveCard'}, title: 'second'}}; + + const {result, rerender} = renderHook((props: {realTimeAssist?: Array} = {}) => + useAiAssistant({ + ...baseProps, + ...props, + realTimeAssist: props.realTimeAssist ?? [], + onRealTimeAssistReceived, + }) + ); + + rerender({realTimeAssist: [first]}); + await waitFor(() => expect(onRealTimeAssistReceived).toHaveBeenLastCalledWith(first)); + + rerender({realTimeAssist: [first, second]}); + await waitFor(() => expect(onRealTimeAssistReceived).toHaveBeenLastCalledWith(second)); + expect(onRealTimeAssistReceived).toHaveBeenCalledTimes(2); + expect(result.current.requestStatus).toBe('ready'); + }); + + it('adds user context messages alongside assistant suggestions in chatEntries', async () => { + const first = { + data: {adaptiveCard: {type: 'AdaptiveCard'}, title: 'first', publishTimestamp: Date.now() + 100}, + }; + const second = { + data: {adaptiveCard: {type: 'AdaptiveCard'}, title: 'second', publishTimestamp: Date.now() + 2000}, + }; + + const {result, rerender} = renderHook((props: {realTimeAssist?: Array} = {}) => + useAiAssistant({...baseProps, ...props, realTimeAssist: props.realTimeAssist ?? []}) + ); + + // First Get Suggestions (no context) → first assistant response arrives. + // After the first request the chat is seeded with an assistant greeting. + await act(async () => { + result.current.requestSuggestion(); + }); + rerender({realTimeAssist: [first]}); + await waitFor(() => expect(result.current.requestStatus).toBe('ready')); + expect(result.current.chatEntries[0]).toMatchObject({type: 'assistant-greeting'}); + expect(result.current.chatEntries.some((e) => e.type === 'assistant')).toBe(true); + + // Agent types context and sends → user entry appears, then second + // assistant response arrives + act(() => result.current.setContextDraft('refunds question')); + await act(async () => { + result.current.submitContext(); + }); + expect(result.current.chatEntries.some((e) => e.type === 'user' && e.text === 'refunds question')).toBe(true); + + rerender({realTimeAssist: [first, second]}); + await waitFor(() => expect(result.current.chatEntries.filter((e) => e.type === 'assistant')).toHaveLength(2)); + + const types = result.current.chatEntries.map((e) => e.type); + expect(types).toContain('user'); + expect(types).toContain('assistant-greeting'); + expect(types.filter((t) => t === 'assistant')).toHaveLength(2); + }); + + it('errors out when feature flag is off', async () => { + const {result} = renderHook(() => useAiAssistant({...baseProps, isFeatureEnabled: false})); + + await act(async () => { + result.current.requestSuggestion(); + }); + + expect(result.current.requestStatus).toBe('error'); + expect(storeMock.cc.apiAIAssistant.getRealTimeAssistance).not.toHaveBeenCalled(); + }); + + it('submitContext requests with the trimmed draft and clears it', async () => { + const {result} = renderHook(() => useAiAssistant(baseProps)); + + act(() => result.current.setContextDraft(' refund policy ')); + await act(async () => { + result.current.submitContext(); + }); + + expect(storeMock.cc.apiAIAssistant.getRealTimeAssistance).toHaveBeenCalledWith( + expect.objectContaining({context: 'refund policy'}) + ); + expect(result.current.contextDraft).toBe(''); + }); + + it('submitContext is a no-op when draft is empty', async () => { + const {result} = renderHook(() => useAiAssistant(baseProps)); + await act(async () => { + result.current.submitContext(); + }); + expect(storeMock.cc.apiAIAssistant.getRealTimeAssistance).not.toHaveBeenCalled(); + }); + + it('records error path when SDK rejects', async () => { + storeMock.cc.apiAIAssistant.getRealTimeAssistance.mockRejectedValueOnce(new Error('boom')); + + const {result} = renderHook(() => useAiAssistant(baseProps)); + await act(async () => { + await result.current.requestSuggestion(); + }); + + expect(result.current.requestStatus).toBe('error'); + expect(result.current.errorMessage).toBe('boom'); + }); +}); diff --git a/packages/contact-center/ai-assistant/tsconfig.json b/packages/contact-center/ai-assistant/tsconfig.json new file mode 100644 index 000000000..59889bd6c --- /dev/null +++ b/packages/contact-center/ai-assistant/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.json", + "include": [ + "./src", + ], + "compilerOptions": { + "outDir": "./dist", + "declaration": true, + "declarationDir": "./dist/types" + }, +} diff --git a/packages/contact-center/ai-assistant/tsconfig.test.json b/packages/contact-center/ai-assistant/tsconfig.test.json new file mode 100644 index 000000000..179268a69 --- /dev/null +++ b/packages/contact-center/ai-assistant/tsconfig.test.json @@ -0,0 +1,9 @@ +// This config is to do type checking in our files while running tests. +{ + "extends": "./tsconfig.json", + "include": ["./tests"], + "exclude": ["**/node_modules/**"], + "compilerOptions": { + "noEmit": true + } +} diff --git a/packages/contact-center/ai-assistant/webpack.config.js b/packages/contact-center/ai-assistant/webpack.config.js new file mode 100644 index 000000000..653f11e97 --- /dev/null +++ b/packages/contact-center/ai-assistant/webpack.config.js @@ -0,0 +1,56 @@ +const {merge} = require('webpack-merge'); +const path = require('path'); + +const baseConfig = require('../../../webpack.config'); + +const resolveMonorepoRoot = (...segments) => path.resolve(__dirname, '../../../', ...segments); + +module.exports = merge(baseConfig, { + output: { + path: path.resolve(__dirname, 'dist'), + filename: 'index.js', + libraryTarget: 'commonjs2', + }, + externals: { + react: 'react', + 'react-dom': 'react-dom', + '@webex/cc-store': '@webex/cc-store', + '@momentum-ui/react-collaboration': '@momentum-ui/react-collaboration', + }, + module: { + rules: [ + { + test: /\.css$/, + use: ['style-loader', 'css-loader'], + include: [ + resolveMonorepoRoot('node_modules/@momentum-ui/react-collaboration'), + path.resolve(__dirname, 'packages'), + ], + }, + { + test: /\.scss$/, + use: ['style-loader', 'css-loader', 'sass-loader'], + include: [ + resolveMonorepoRoot('node_modules/@momentum-ui/react-collaboration'), + path.resolve(__dirname, 'packages'), + ], + }, + { + test: /\.(woff|woff2|eot|ttf|otf)$/, + include: [resolveMonorepoRoot('node_modules/@momentum-ui/react-collaboration')], + type: 'asset/resource', + generator: { + filename: 'fonts/[name][ext][query]', + }, + }, + { + test: /\.(png|jpg|gif|svg)$/, + include: [resolveMonorepoRoot('node_modules/@momentum-ui/react-collaboration')], + type: 'asset/resource', + generator: { + filename: 'images/[name][ext][query]', + }, + }, + ], + }, +}); diff --git a/packages/contact-center/cc-components/package.json b/packages/contact-center/cc-components/package.json index b4ffc878d..68a8fee96 100644 --- a/packages/contact-center/cc-components/package.json +++ b/packages/contact-center/cc-components/package.json @@ -38,7 +38,9 @@ "@momentum-ui/illustrations": "^1.24.0", "@r2wc/react-to-web-component": "2.0.3", "@webex/cc-store": "workspace:*", - "@webex/cc-ui-logging": "workspace:*" + "@webex/cc-ui-logging": "workspace:*", + "adaptivecards": "^3.0.0", + "swiper": "^12.1.2" }, "devDependencies": { "@babel/core": "7.25.2", diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.tsx new file mode 100644 index 000000000..7b7f11c95 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.tsx @@ -0,0 +1,215 @@ +import React, {useEffect, useRef, useState} from 'react'; +import * as AdaptiveCards from 'adaptivecards'; +import {AdaptiveCardRendererProps} from '../ai-assistant.types'; +import {buildHostConfig, prepareCardForRender} from './adaptive-card-renderer.utils'; + +const MOMENTUM_ICON_CDN = 'https://cdn.jsdelivr.net/npm/@momentum-design/icons/dist/svg/'; + +// Preload regular + filled variants so the like/dislike/copy toggles don't +// wait on a network roundtrip on the first click. +const PRELOAD_ICONS = [ + 'like-regular.svg', + 'like-filled.svg', + 'dislike-regular.svg', + 'dislike-filled.svg', + 'copy-regular.svg', + 'check-circle-filled.svg', +]; +let iconsPreloaded = false; +const preloadIcons = () => { + if (iconsPreloaded || typeof Image === 'undefined') return; + iconsPreloaded = true; + PRELOAD_ICONS.forEach((name) => { + const img = new Image(); + img.src = `${MOMENTUM_ICON_CDN}${name}`; + }); +}; + +/** Concatenate TextBlocks in the rendered card as a clipboard fallback. */ +const extractCardText = (container: HTMLElement): string => { + const blocks = container.querySelectorAll('.ac-textBlock, .ac-richTextBlock'); + const lines: string[] = []; + blocks.forEach((el) => { + const text = (el.textContent || '').trim(); + if (text && text !== 'Source') lines.push(text); + }); + return lines.join('\n'); +}; + +type IconKind = 'like' | 'dislike' | 'copy' | null; + +const ICON_NAME_RE = /([\w-]+)-(regular|bold|filled|light)\.svg(\?|$|#)/i; + +const detectIconKind = (element: Element): IconKind => { + const img = element.querySelector('img'); + const src = img?.getAttribute('src') || ''; + const m = src.match(ICON_NAME_RE); + const base = m ? m[1].toLowerCase() : ''; + const ariaLabel = (element.getAttribute('aria-label') || '').toLowerCase(); + const title = (element.getAttribute('title') || '').toLowerCase(); + const haystack = `${ariaLabel} ${title} ${base}`; + if (/\bcopy\b/.test(haystack) || base === 'copy') return 'copy'; + if (/\bdislike\b|thumbs-down/.test(haystack) || base === 'dislike') return 'dislike'; + if (/\blike\b|thumbs-up/.test(haystack) || base === 'like') return 'like'; + return null; +}; + +const AdaptiveCardRenderer: React.FC = ({ + card, + fallbackText, + publishTimestamp, + suggestionText, + onFeedback, + onAction, +}) => { + const containerRef = useRef(null); + const [renderFailed, setRenderFailed] = useState(false); + + // Stash callbacks in refs so the render effect only re-runs when the card + // itself changes — fresh inline parent callbacks would otherwise force a + // full re-parse and re-fetch of every image on each render. + const onFeedbackRef = useRef(onFeedback); + const onActionRef = useRef(onAction); + const suggestionTextRef = useRef(suggestionText); + useEffect(() => { + onFeedbackRef.current = onFeedback; + onActionRef.current = onAction; + suggestionTextRef.current = suggestionText; + }); + + useEffect(() => { + preloadIcons(); + const container = containerRef.current; + if (!container || !card) return undefined; + + setRenderFailed(false); + container.innerHTML = ''; + + const handleCopy = async (sourceEl?: Element) => { + const text = (suggestionTextRef.current || extractCardText(container)).trim(); + if (!text) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + // Legacy fallback for non-secure contexts. + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + document.execCommand('copy'); + document.body.removeChild(ta); + } + const img = sourceEl?.querySelector('img'); + if (sourceEl && img) { + const previousSrc = img.getAttribute('src'); + img.setAttribute('src', `${MOMENTUM_ICON_CDN}check-circle-filled.svg`); + sourceEl.setAttribute('data-copied', 'true'); + setTimeout(() => { + sourceEl.removeAttribute('data-copied'); + if (previousSrc) img.setAttribute('src', previousSrc); + }, 1500); + } + } catch (err) { + console.warn('[AIAssistant] copy to clipboard failed', err); + } + }; + + const toggleFeedback = (kind: 'like' | 'dislike', controls: Map) => { + controls.forEach((thisKind, el) => { + if (thisKind !== 'like' && thisKind !== 'dislike') return; + const active = el.getAttribute('data-active') === 'true'; + const isThis = thisKind === kind; + // Mutually exclusive: clicking the active one clears it; clicking the other flips. + const nextActive = isThis ? !active : false; + const img = el.querySelector('img'); + if (img) { + img.setAttribute('src', `${MOMENTUM_ICON_CDN}${thisKind}-${nextActive ? 'filled' : 'regular'}.svg`); + } + if (nextActive) { + el.setAttribute('data-active', 'true'); + } else { + el.removeAttribute('data-active'); + } + }); + }; + + try { + const adaptiveCard = new AdaptiveCards.AdaptiveCard(); + adaptiveCard.hostConfig = new AdaptiveCards.HostConfig(buildHostConfig()); + adaptiveCard.onExecuteAction = (action) => { + const a = action as {id?: string; title?: string}; + const label = `${a?.id || ''} ${a?.title || ''}`.toLowerCase(); + const actionId = a?.id || a?.title || ''; + if (label.includes('copy')) { + handleCopy(); + onFeedbackRef.current?.({type: 'copy', actionId}); + return; + } + if (label.includes('dislike')) { + onFeedbackRef.current?.({type: 'dislike', actionId}); + return; + } + if (label.includes('like')) { + onFeedbackRef.current?.({type: 'like', actionId}); + return; + } + onActionRef.current?.(action); + }; + adaptiveCard.parse(prepareCardForRender(card, publishTimestamp)); + const rendered = adaptiveCard.render(); + if (rendered) { + container.appendChild(rendered); + + // Visual-state wiring for the like/dislike/copy controls. + const controls = new Map(); + container.querySelectorAll('button, [role="button"], a').forEach((el) => { + const kind = detectIconKind(el); + if (!kind) return; + controls.set(el, kind); + (el as HTMLElement).style.cursor = 'pointer'; + const existingLabel = el.getAttribute('aria-label'); + if (!existingLabel) { + const labels: Record, string> = { + like: 'Like suggestion', + dislike: 'Dislike suggestion', + copy: 'Copy suggestion', + }; + el.setAttribute('aria-label', labels[kind]); + } + // Visual-only: telemetry fires via onExecuteAction above to avoid double-posting. + el.addEventListener('click', () => { + if (kind === 'copy') { + handleCopy(el); + } else { + toggleFeedback(kind, controls); + } + }); + }); + } else { + setRenderFailed(true); + } + } catch { + setRenderFailed(true); + } + + return () => { + if (container) container.innerHTML = ''; + }; + }, [card, publishTimestamp]); + + return ( +
+ {renderFailed && fallbackText ? ( +

+ {fallbackText} +

+ ) : null} +
+
+ ); +}; + +export default AdaptiveCardRenderer; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.utils.ts b/packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.utils.ts new file mode 100644 index 000000000..0be51f91b --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.utils.ts @@ -0,0 +1,114 @@ +const MOMENTUM_ICON_CDN = 'https://cdn.jsdelivr.net/npm/@momentum-design/icons/dist/svg/'; +const SOURCE_TIMESTAMP_PLACEHOLDER = 'SOURCE_TIMESTAMP_PLACEHOLDER'; + +/** Format an epoch (ms) into "HH:MM"; empty string on bad input. */ +const formatSourceTimestamp = (raw: number | string | undefined): string => { + if (raw === undefined || raw === null || raw === '') return ''; + const ms = typeof raw === 'number' ? raw : Number.parseInt(`${raw}`, 10); + if (Number.isNaN(ms)) return ''; + const date = new Date(ms); + if (Number.isNaN(date.getTime())) return ''; + const hh = `${date.getHours()}`.padStart(2, '0'); + const mm = `${date.getMinutes()}`.padStart(2, '0'); + return `${hh}:${mm}`; +}; + +// Backend ships filenames not published in @momentum-design/icons; alias them. +const MOMENTUM_ICON_ALIASES: Record = { + 'cisco-ai-assistant-color.svg': 'cisco-ai-assistant-solid-bold.svg', +}; + +/** Returns the trailing `name.svg` of a non-absolute path, else null. */ +const extractMomentumIconName = (value: string): string | null => { + const trimmed = value.trim(); + if (!trimmed) return null; + if (/^(https?:|data:|blob:)/i.test(trimmed)) return null; + const match = trimmed.match(/([\w-]+\.svg)$/i); + return match ? match[1].toLowerCase() : null; +}; + +/** + * Returns a clone of the card with bare `*.svg` URLs rewritten to the + * Momentum CDN and any `SOURCE_TIMESTAMP_PLACEHOLDER` substituted. + */ +export const prepareCardForRender = (card: T, publishTimestamp?: number | string): T => { + if (card === null || typeof card !== 'object') return card; + + const formattedTimestamp = formatSourceTimestamp(publishTimestamp); + + const visit = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(visit); + if (node && typeof node === 'object') { + const out: Record = {}; + for (const [key, value] of Object.entries(node as Record)) { + if (typeof value === 'string') { + const iconName = extractMomentumIconName(value); + if (iconName) { + const resolved = MOMENTUM_ICON_ALIASES[iconName] ?? iconName; + out[key] = `${MOMENTUM_ICON_CDN}${resolved}`; + continue; + } + if (value.includes(SOURCE_TIMESTAMP_PLACEHOLDER)) { + out[key] = value.split(SOURCE_TIMESTAMP_PLACEHOLDER).join(formattedTimestamp); + continue; + } + } + out[key] = visit(value); + } + return out; + } + return node; + }; + + return visit(card) as T; +}; + +/** HostConfig wired to Momentum CSS tokens so cards inherit the active theme. */ +export const buildHostConfig = () => ({ + fontFamily: 'inherit', + spacing: { + small: 4, + default: 8, + medium: 12, + large: 16, + extraLarge: 24, + padding: 12, + }, + separator: { + lineThickness: 1, + lineColor: 'var(--mds-color-theme-outline-secondary-normal)', + }, + containerStyles: (() => { + // Force every semantic foreground to the primary text color so card + // labels (e.g. "Source") don't read as alerts/links. + const primary = 'var(--mds-color-theme-text-primary-normal)'; + const subtle = 'var(--mds-color-theme-text-secondary-normal)'; + const flat = {default: primary, subtle: primary}; + const foregroundColors = { + default: {default: primary, subtle}, + accent: flat, + attention: flat, + good: flat, + warning: flat, + dark: flat, + light: flat, + }; + return { + default: { + backgroundColor: 'var(--mds-color-theme-background-primary-normal)', + foregroundColors, + }, + emphasis: { + backgroundColor: 'var(--mds-color-theme-background-secondary-normal)', + foregroundColors, + }, + }; + })(), + actions: { + maxActions: 5, + spacing: 'default', + buttonSpacing: 8, + actionsOrientation: 'horizontal', + actionAlignment: 'left', + }, +}); diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/CiscoAIAssistantColorIcon.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/CiscoAIAssistantColorIcon.tsx new file mode 100644 index 000000000..bf01e48aa --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/CiscoAIAssistantColorIcon.tsx @@ -0,0 +1,93 @@ +import React from 'react'; + +interface CiscoAIAssistantColorIconProps { + /** Square size in px. Defaults to 20. */ + size?: number; + className?: string; +} + +/** + * Inlined colored "Cisco AI Assistant" mark. Unique gradient IDs so + * multiple instances on the page don't collide. + */ +const CiscoAIAssistantColorIcon: React.FC = ({size = 20, className}) => ( + +); + +export default CiscoAIAssistantColorIcon; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/Launcher/launcher.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/Launcher/launcher.tsx new file mode 100644 index 000000000..49c7ca15b --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/Launcher/launcher.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import CiscoAIAssistantColorIcon from '../CiscoAIAssistantColorIcon'; +import {LauncherProps} from '../ai-assistant.types'; + +const Launcher: React.FC = ({onOpen, className}) => ( + +); + +export default Launcher; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/Panel/Header/header.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/Panel/Header/header.tsx new file mode 100644 index 000000000..5acf0402d --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/Panel/Header/header.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import {Button, Text} from '@momentum-design/components/dist/react'; +import {AIAssistantHeaderProps} from '../../ai-assistant.types'; + +const Header: React.FC = ({onMinimize, onToggleFullScreen, onClose, isFullScreen}) => ( +
+ + Cisco AI Assistant + +
+
+
+); + +export default Header; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/Panel/MinimizedBar/minimized-bar.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/Panel/MinimizedBar/minimized-bar.tsx new file mode 100644 index 000000000..b2f7c1bdf --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/Panel/MinimizedBar/minimized-bar.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {Button, Text} from '@momentum-design/components/dist/react'; +import {MinimizedBarProps} from '../../ai-assistant.types'; + +const MinimizedBar: React.FC = ({onRestore, onClose}) => ( +
+ + Cisco AI Assistant + +
+
+
+); + +export default MinimizedBar; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/Panel/panel.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/Panel/panel.tsx new file mode 100644 index 000000000..3623ed455 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/Panel/panel.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import Header from './Header/header'; +import MinimizedBar from './MinimizedBar/minimized-bar'; +import RealTimeAssist from '../SuggestedResponse/suggested-response'; +import ContextInput from '../SuggestedResponse/ContextInput/context-input'; +import {AIAssistantPanelProps} from '../ai-assistant.types'; + +const Panel: React.FC = ({ + chrome, + isFullScreen, + requestStatus, + errorMessage, + contextDraft, + chatEntries, + isFeatureEnabled, + hasFiredInitialRequest, + onMinimize, + onRestore, + onClose, + onToggleFullScreen, + onRequestSuggestion, + onContextDraftChange, + onSubmitContext, + onRealTimeAssistAction, +}) => { + if (chrome === 'minimized') { + return ( +
+ +
+ ); + } + + const panelClass = ['ai-assistant__panel', isFullScreen ? 'ai-assistant__panel--full-screen' : ''] + .filter(Boolean) + .join(' '); + + return ( +
+
+
+ +
+
+ +

+ I can make mistakes, so check my responses. +

+
+
+ ); +}; + +export default Panel; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/SuggestedResponse/ContextInput/context-input.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/SuggestedResponse/ContextInput/context-input.tsx new file mode 100644 index 000000000..f9c55d455 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/SuggestedResponse/ContextInput/context-input.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import {Button, Input} from '@momentum-design/components/dist/react'; +import {ContextInputProps} from '../../ai-assistant.types'; + +const ContextInput: React.FC = ({value, disabled, placeholder, onChange, onSubmit}) => { + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!value.trim()) return; + onSubmit(); + }; + + return ( +
+ & {target: HTMLInputElement}) => + onChange(e.detail?.value ?? e.target?.value ?? '') + } + /> + +
+ ); +}; + +export default ContextInput; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/SuggestedResponse/suggested-response.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/SuggestedResponse/suggested-response.tsx new file mode 100644 index 000000000..8bc698b4d --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/SuggestedResponse/suggested-response.tsx @@ -0,0 +1,155 @@ +import React, {useEffect, useRef} from 'react'; +import {Button, Text} from '@momentum-design/components/dist/react'; +import AdaptiveCardRenderer from '../AdaptiveCardRenderer/adaptive-card-renderer'; +import CiscoAIAssistantColorIcon from '../CiscoAIAssistantColorIcon'; +import {RealTimeAssistProps} from '../ai-assistant.types'; + +const EMPTY_TITLE = 'Ask the assistant for help'; +const EMPTY_DESCRIPTION = + 'Get a suggested response based on the live conversation. Add context below to refine the result.'; +const FLAG_OFF_MESSAGE = 'AI suggested responses are not enabled for your profile.'; + +const AssistantIcon: React.FC = () => ( + +); + +const RealTimeAssist: React.FC = ({ + status, + errorMessage, + chatEntries, + onRequestSuggestion, + isFeatureEnabled, + hasFiredInitialRequest, + onRealTimeAssistAction, +}) => { + const listRef = useRef(null); + + // Auto-scroll the chat list to the bottom whenever a new entry lands. + useEffect(() => { + const node = listRef.current; + if (!node) return; + node.scrollTop = node.scrollHeight; + }, [chatEntries.length, status]); + + if (!isFeatureEnabled) { + return ( +
+ + {FLAG_OFF_MESSAGE} + +
+ ); + } + + const hasEntries = chatEntries.length > 0; + + if (hasEntries) { + return ( +
+ {chatEntries.map((entry) => { + if (entry.type === 'user') { + return ( +
+
{entry.text}
+
+ ); + } + if (entry.type === 'assistant-greeting') { + return ( +
+
+ + + {entry.text} + +
+
+ ); + } + return ( +
+ {entry.realTimeAssist?.data?.title ? ( +
+ + + {entry.realTimeAssist.data.title} + +
+ ) : null} + entry.realTimeAssist && onRealTimeAssistAction?.(event, entry.realTimeAssist)} + /> +
+ ); + })} + {hasFiredInitialRequest && status !== 'error' ? ( +
+
+ ) : null} +
+ ); + } + + if (status === 'listening' || hasFiredInitialRequest) { + return ( +
+
+ ); + } + + if (status === 'error') { + return ( +
+ + {errorMessage || 'Something went wrong while requesting a suggestion.'} + + +
+ ); + } + + return ( +
+ + {EMPTY_TITLE} + + + {EMPTY_DESCRIPTION} + + +
+ ); +}; + +export default RealTimeAssist; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.styles.scss b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.styles.scss new file mode 100644 index 000000000..1977354e6 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.styles.scss @@ -0,0 +1,339 @@ +.ai-assistant { + // Inset the widget contents so the launcher and panel never sit flush + // against the host container's edge (e.g. behind a `` line). + box-sizing: border-box; + display: flex; + flex-direction: column; + padding: 0.75rem 0.5rem; + width: 100%; +} + +.ai-assistant__launcher { + align-items: center; + align-self: flex-start; + background: transparent; + border: none; + border-radius: 100px; + box-shadow: none; + cursor: pointer; + display: inline-flex; + flex-direction: row; + height: 50px; + isolation: isolate; + justify-content: center; + padding: 9px; + width: 50px; + + &:hover { + background: var(--mds-color-theme-background-secondary-hover, rgba(0, 0, 0, 0.04)); + } + + &:focus-visible { + outline: 2px solid var(--mds-color-theme-outline-focus-default, #0087ea); + outline-offset: 2px; + } +} + +// Default chrome: small floating card with border + shadow. +.ai-assistant__panel { + background: var(--mds-color-theme-background-primary-normal); + border: 1px solid var(--mds-color-theme-outline-secondary-normal); + border-radius: 0.75rem; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.12); + display: flex; + flex-direction: column; + height: 32rem; + max-height: 90vh; + max-width: 28rem; + overflow: hidden; + width: 100%; +} + +.ai-assistant__panel--minimized { + border: 1px solid var(--mds-color-theme-outline-secondary-normal); + border-radius: 0.5rem; + box-shadow: none; + height: auto; + max-height: none; + max-width: 22rem; + min-height: 0; + overflow: visible; +} + +.ai-assistant__header, +.ai-assistant__minimized-bar { + align-items: center; + border-bottom: 1px solid var(--mds-color-theme-outline-secondary-normal); + display: flex; + gap: 0.75rem; + justify-content: space-between; + padding: 0.75rem 1.125rem; +} + +.ai-assistant__minimized-bar { + border-bottom: none; +} + +.ai-assistant__title { + color: var(--mds-color-theme-text-primary-normal); + margin: 0; +} + +.ai-assistant__header-actions { + align-items: center; + display: flex; + gap: 0.25rem; +} + +.ai-assistant__body { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + overflow-y: auto; + padding: 1rem 1.125rem; + row-gap: 1.25rem; +} + +.ai-assistant__body-empty, +.ai-assistant__body-listening, +.ai-assistant__body-error, +.ai-assistant__body-ready { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +// "Get Suggestions" pill button — Figma Button/Pill spec: 134x28, rounded. +.ai-assistant__pill-button { + align-items: center; + align-self: flex-start; + background: transparent; + border: 1px solid rgba(0, 0, 0, 0.5); + border-radius: 100px; + box-sizing: border-box; + color: var(--mds-color-theme-text-primary-normal); + cursor: pointer; + display: inline-flex; + flex: none; + flex-direction: row; + font-size: 0.8125rem; + font-weight: 500; + gap: 6px; + height: 28px; + isolation: isolate; + padding: 4px 12px; + width: 134px; + + &:hover { + background: rgba(0, 0, 0, 0.04); + } + + &:focus-visible { + outline: 2px solid var(--mds-color-theme-outline-focus-default, #0087ea); + outline-offset: 2px; + } +} + +.ai-assistant__body-listening, +.ai-assistant__body-empty { + color: var(--mds-color-theme-text-secondary-normal); + font-size: 0.875rem; + line-height: 1.25rem; +} + +.ai-assistant__chat { + display: flex; + flex: 1; + flex-direction: column; + gap: 1rem; + overflow-y: auto; + scroll-behavior: smooth; +} + +.ai-assistant__chat-item { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.ai-assistant__chat-item--user { + align-items: flex-end; +} + +.ai-assistant__user-bubble { + background: #000; + border-radius: 1rem; + color: #fff; + font-size: 0.9375rem; + line-height: 1.4; + max-width: 85%; + padding: 0.625rem 1rem; + white-space: pre-wrap; + word-wrap: break-word; +} + +.ai-assistant__chat-item--assistant { + gap: 0.5rem; +} + +.ai-assistant__assistant-header { + align-items: center; + display: flex; + gap: 0.5rem; +} + +.ai-assistant__assistant-icon { + align-items: center; + display: inline-flex; + flex-shrink: 0; + height: 25px; + justify-content: center; + width: 25px; + + svg { + display: block; + height: 100%; + width: 100%; + } +} + +.ai-assistant__suggestion-updating { + color: var(--mds-color-theme-text-secondary-normal); + font-size: 0.875rem; + font-style: italic; + margin: 0; +} + +.ai-assistant__suggestion-title { + color: var(--mds-color-theme-text-primary-normal); + margin: 0; +} + +.ai-assistant__suggestion-summary { + color: var(--mds-color-theme-text-secondary-normal); + margin: 0; +} + +.ai-assistant__card { + background: #f1f3f5; + border-radius: 0.75rem; + padding: 0.875rem 1rem; +} + +// Scoped overrides for elements emitted by the adaptivecards renderer. +.ai-assistant__card-host { + // Force primary text color — backend ships accent/attention TextBlocks we don't want as alerts. + &, + .ac-textBlock, + .ac-richTextBlock { + color: var(--mds-color-theme-text-primary-normal) !important; + } + + button, + .ac-pushButton, + .ac-action-showCard, + .ac-actionSet button { + background: transparent; + border: none; + box-shadow: none; + padding: 0.25rem; + + &:hover { + background: rgba(0, 0, 0, 0.05); + border: none; + } + + &:focus-visible { + outline: 2px solid var(--mds-color-theme-outline-focus-default, #0087ea); + outline-offset: 2px; + } + + // Enlarge the like/dislike/copy glyphs from the card's default 16x16. + img { + height: 22px !important; + width: 22px !important; + } + } + + // Source disclosure row: flatten the toggle button. + .ac-action-toggleVisibility, + [aria-expanded] { + background: transparent; + border: none; + } + + // Copy confirmation pill — green circle, white check. + [data-copied='true'] { + background: var(--mds-color-theme-background-success-normal, #2eb872) !important; + border-radius: 999px !important; + transition: background 0.15s ease-out; + + img { + filter: brightness(0) invert(1); + } + } +} + +.ai-assistant__card-error { + color: var(--mds-color-theme-text-error-normal); + font-size: 0.875rem; +} + +.ai-assistant__card-fallback { + color: var(--mds-color-theme-text-primary-normal); + font-size: 0.9375rem; + line-height: 1.4; + margin: 0; + white-space: pre-wrap; +} + +.ai-assistant__chat-listening { + align-items: center; + color: var(--mds-color-theme-text-primary-normal); + display: flex; + font-weight: 600; + gap: 0.5rem; + padding: 0.5rem 0; +} + +.ai-assistant__chat-listening-dot { + background: linear-gradient(135deg, #4dbef0 0%, #5b6cff 100%); + border-radius: 50%; + height: 0.625rem; + width: 0.625rem; +} + +.ai-assistant__footer { + border-top: 1px solid var(--mds-color-theme-outline-secondary-normal); + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.75rem 1.125rem; +} + +.ai-assistant__disclaimer { + color: var(--mds-color-theme-text-secondary-normal); + font-size: 0.8125rem; + margin: 0; + text-align: center; +} + +.ai-assistant__context { + align-items: center; + display: flex; + gap: 0.5rem; +} + +.ai-assistant__context-input { + border-radius: 1.5rem; + flex: 1; + + &::part(mdc-input-base-container) { + border-radius: 1.5rem; + } + + &::part(mdc-input) { + border-radius: 1.5rem; + } +} \ No newline at end of file diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.tsx b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.tsx new file mode 100644 index 000000000..5dc7d847e --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import {withMetrics} from '@webex/cc-ui-logging'; +import Launcher from './Launcher/launcher'; +import Panel from './Panel/panel'; +import {AIAssistantComponentProps} from './ai-assistant.types'; +import './ai-assistant.styles.scss'; + +const AIAssistantComponent: React.FC = ({ + chrome, + isFullScreen, + requestStatus, + errorMessage, + contextDraft, + chatEntries, + isFeatureEnabled, + hasFiredInitialRequest, + open, + close, + minimize, + restore, + toggleFullScreen, + requestSuggestion, + setContextDraft, + submitContext, + onRealTimeAssistAction, + className, +}) => { + // Fullscreen is consumer-owned: we emit onFullScreenToggle; the host owns layout. + const rootClass = ['ai-assistant', className || ''].filter(Boolean).join(' '); + + return ( +
+ {chrome === 'closed' ? ( + + ) : ( + + )} +
+ ); +}; + +const AIAssistantComponentWithMetrics = withMetrics(AIAssistantComponent, 'AIAssistant'); + +export default AIAssistantComponentWithMetrics; diff --git a/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.types.ts b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.types.ts new file mode 100644 index 000000000..7bec564a4 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.types.ts @@ -0,0 +1,141 @@ +import type {RealTimeAssistPayload} from '@webex/cc-store'; + +/** Visual state of the AI Assistant panel chrome. */ +export type AIAssistantChromeState = 'closed' | 'open' | 'minimized'; + +/** Lifecycle state of a real-time assist request. */ +export type AIAssistantRequestStatus = 'idle' | 'listening' | 'ready' | 'error'; + +/** + * A single entry rendered in the chat transcript. User entries are agent + * input, assistant entries wrap a `RealTimeAssistPayload` from the SDK, + * and `assistant-greeting` is the seeded intro line. + */ +export type AIAssistantChatEntry = + | {type: 'user'; id: string; text: string} + | {type: 'assistant-greeting'; id: string; text: string} + | { + type: 'assistant'; + id: string; + realTimeAssist?: RealTimeAssistPayload; + }; + +/** Props for the top-level AIAssistant presentational component. */ +export interface AIAssistantComponentProps { + /** Current chrome visibility state. */ + chrome: AIAssistantChromeState; + /** Whether the host has the widget mounted in fullscreen layout. */ + isFullScreen: boolean; + /** Status of the in-flight real-time assist request. */ + requestStatus: AIAssistantRequestStatus; + /** Latest error message when `requestStatus === 'error'`. */ + errorMessage?: string; + /** Current value of the context input. */ + contextDraft: string; + /** Chronological list of chat entries to render. */ + chatEntries: AIAssistantChatEntry[]; + /** Whether `aiFeature.suggestedResponses.enable` is true on the agent profile. */ + isFeatureEnabled: boolean; + /** Whether `Get Suggestions` has been clicked at least once this session. */ + hasFiredInitialRequest: boolean; + /** Transition the chrome to `open`. */ + open: () => void; + /** Transition the chrome to `closed`; preserves chat state. */ + close: () => void; + /** Collapse the panel to its minimized bar. */ + minimize: () => void; + /** Restore the panel from minimized to open. */ + restore: () => void; + /** Toggle the fullscreen affordance; layout is host-owned. */ + toggleFullScreen: () => void; + /** Fire a no-context `GET_SUGGESTIONS` request. */ + requestSuggestion: () => void; + /** Update the context input value. */ + setContextDraft: (value: string) => void; + /** Fire an `ADD_SUGGESTIONS_EXTRA_CONTEXT` request using the current draft. */ + submitContext: () => void; + /** Notify the host when the agent acts on a real-time assist card. */ + onRealTimeAssistAction?: (event: AIAssistantFeedbackEvent, assist: RealTimeAssistPayload) => void; + /** Extra class applied to the widget root. */ + className?: string; +} + +export interface LauncherProps { + onOpen: () => void; + className?: string; +} + +export interface AIAssistantHeaderProps { + onMinimize: () => void; + onToggleFullScreen: () => void; + onClose: () => void; + isFullScreen: boolean; +} + +export interface MinimizedBarProps { + onRestore: () => void; + onClose: () => void; +} + +export interface AIAssistantPanelProps { + chrome: AIAssistantChromeState; + isFullScreen: boolean; + requestStatus: AIAssistantRequestStatus; + errorMessage?: string; + contextDraft: string; + chatEntries: AIAssistantChatEntry[]; + isFeatureEnabled: boolean; + hasFiredInitialRequest: boolean; + onMinimize: () => void; + onRestore: () => void; + onClose: () => void; + onToggleFullScreen: () => void; + onRequestSuggestion: () => void; + onContextDraftChange: (value: string) => void; + onSubmitContext: () => void; + onRealTimeAssistAction?: (event: AIAssistantFeedbackEvent, assist: RealTimeAssistPayload) => void; +} + +export interface RealTimeAssistProps { + status: AIAssistantRequestStatus; + errorMessage?: string; + chatEntries: AIAssistantChatEntry[]; + onRequestSuggestion: () => void; + isFeatureEnabled: boolean; + hasFiredInitialRequest: boolean; + /** Fires when the agent clicks the like / dislike / copy buttons on a card. */ + onRealTimeAssistAction?: (event: AIAssistantFeedbackEvent, assist: RealTimeAssistPayload) => void; +} + +export interface ContextInputProps { + value: string; + disabled?: boolean; + placeholder?: string; + onChange: (value: string) => void; + onSubmit: () => void; +} + +/** Kind of feedback the agent gave on a suggestion card. */ +export type AIAssistantFeedbackKind = 'like' | 'dislike' | 'copy'; + +/** Payload emitted by like / dislike / copy clicks inside a card. */ +export interface AIAssistantFeedbackEvent { + type: AIAssistantFeedbackKind; + /** The id of the underlying card action that fired this event. */ + actionId: string; +} + +export interface AdaptiveCardRendererProps { + card: unknown; + /** Plain-text fallback rendered when the Adaptive Card cannot be parsed/rendered. */ + fallbackText?: string; + /** Source timestamp (epoch ms, or stringified epoch) used to fill in any + * `SOURCE_TIMESTAMP_PLACEHOLDER` markers the backend ships in the card. */ + publishTimestamp?: number | string; + /** Plain-text version of the suggestion, copied to the clipboard when the + * user activates the card's copy action. */ + suggestionText?: string; + /** Fires when the user clicks the like / dislike / copy controls inside the card. */ + onFeedback?: (event: AIAssistantFeedbackEvent) => void; + onAction?: (action: unknown) => void; +} diff --git a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx index d4e960792..bcf4e07fd 100644 --- a/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx +++ b/packages/contact-center/cc-components/src/components/task/IncomingTask/incoming-task.tsx @@ -14,6 +14,7 @@ const IncomingTaskComponent: React.FunctionComponent // All incoming tasks (including campaign preview) render the standard Task // with Answer/Decline buttons. The CampaignTask UI (Accept/Skip/Remove) // is only shown in the TaskList after the agent has accepted. + // Extract all task data using the utility function const taskData = extractIncomingTaskData( incomingTask, logger, diff --git a/packages/contact-center/cc-components/src/index.ts b/packages/contact-center/cc-components/src/index.ts index fc43b8061..c66b4e43c 100644 --- a/packages/contact-center/cc-components/src/index.ts +++ b/packages/contact-center/cc-components/src/index.ts @@ -9,6 +9,7 @@ import CampaignErrorDialogComponent from './components/task/CampaignErrorDialog/ import CampaignCountdownComponent from './components/task/CampaignCountdown/campaign-countdown'; import CampaignTaskComponent from './components/task/CampaignTask/campaign-task'; import RealTimeTranscriptComponent from './components/task/RealTimeTranscript/real-time-transcript'; +import AIAssistantComponent from './components/AIAssistant/ai-assistant'; export { UserStateComponent, @@ -22,6 +23,7 @@ export { CampaignCountdownComponent, CampaignTaskComponent, RealTimeTranscriptComponent, + AIAssistantComponent, }; export * from './components/StationLogin/constants'; export * from './components/StationLogin/station-login.types'; @@ -29,3 +31,4 @@ export * from './components/UserState/user-state.types'; export * from './components/task/task.types'; export * from './components/task/CampaignErrorDialog/campaign-error-dialog.types'; export * from './components/task/CampaignCountdown/campaign-countdown.types'; +export * from './components/AIAssistant/ai-assistant.types'; diff --git a/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap b/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap index a6080f9ba..315256286 100644 --- a/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap +++ b/packages/contact-center/cc-components/tests/components/task/OutdialCall/__snapshots__/out-dial-call.snapshot.tsx.snap @@ -5767,3 +5767,262 @@ exports[`Outdial Call Component updates input value when typing directly 1`] = `
`; + +exports[`Outdial Call Component updates input value when typing directly 2`] = ` +
+
+ +
    +
  • + + 1 + +
  • +
  • + + 2 + +
  • +
  • + + 3 + +
  • +
  • + + 4 + +
  • +
  • + + 5 + +
  • +
  • + + 6 + +
  • +
  • + + 7 + +
  • +
  • + + 8 + +
  • +
  • + + 9 + +
  • +
  • + + * + +
  • +
  • + + 0 + +
  • +
  • + + # + +
  • +
+
+ + + Enter Outdial ANI + + + name 1 + + + name 2 + + +
+ +
+
+`; diff --git a/packages/contact-center/cc-widgets/package.json b/packages/contact-center/cc-widgets/package.json index c92b7c04d..82b6a1ba3 100644 --- a/packages/contact-center/cc-widgets/package.json +++ b/packages/contact-center/cc-widgets/package.json @@ -36,6 +36,7 @@ }, "dependencies": { "@r2wc/react-to-web-component": "2.0.3", + "@webex/cc-ai-assistant": "workspace:*", "@webex/cc-digital-channels": "workspace:*", "@webex/cc-station-login": "workspace:*", "@webex/cc-store": "workspace:*", diff --git a/packages/contact-center/cc-widgets/src/index.ts b/packages/contact-center/cc-widgets/src/index.ts index 9d5afaffb..87f1c10e2 100644 --- a/packages/contact-center/cc-widgets/src/index.ts +++ b/packages/contact-center/cc-widgets/src/index.ts @@ -2,6 +2,7 @@ import {StationLogin} from '@webex/cc-station-login'; import {UserState} from '@webex/cc-user-state'; import {IncomingTask, TaskList, CallControl, CallControlCAD, OutdialCall, RealTimeTranscript} from '@webex/cc-task'; import {DigitalChannels} from '@webex/cc-digital-channels'; +import {AIAssistant} from '@webex/cc-ai-assistant'; import store from '@webex/cc-store'; import '@momentum-ui/core/css/momentum-ui.min.css'; @@ -15,5 +16,6 @@ export { OutdialCall, RealTimeTranscript, DigitalChannels, + AIAssistant, store, }; diff --git a/packages/contact-center/cc-widgets/src/wc.ts b/packages/contact-center/cc-widgets/src/wc.ts index d34f0564f..a19ad405c 100644 --- a/packages/contact-center/cc-widgets/src/wc.ts +++ b/packages/contact-center/cc-widgets/src/wc.ts @@ -4,6 +4,7 @@ import {UserState} from '@webex/cc-user-state'; import store from '@webex/cc-store'; import {TaskList, IncomingTask, CallControl, CallControlCAD, OutdialCall, RealTimeTranscript} from '@webex/cc-task'; import {DigitalChannels} from '@webex/cc-digital-channels'; +import {AIAssistant} from '@webex/cc-ai-assistant'; const WebUserState = r2wc(UserState, { props: { @@ -65,6 +66,19 @@ const WebRealTimeTranscript = r2wc(RealTimeTranscript, { const WebDigitalChannels = r2wc(DigitalChannels, {}); +const WebAIAssistant = r2wc(AIAssistant, { + props: { + onOpen: 'function', + onMinimize: 'function', + onRestore: 'function', + onClose: 'function', + onClearChat: 'function', + onFullScreenToggle: 'function', + onSuggestionReceived: 'function', + className: 'string', + }, +}); + // Whenever there is a new component, add the name of the component // and the web-component to the components object const components = [ @@ -77,6 +91,7 @@ const components = [ {name: 'widget-cc-call-control-cad', component: WebCallControlCAD}, {name: 'widget-cc-realtime-transcript', component: WebRealTimeTranscript}, {name: 'widget-cc-digital-channels', component: WebDigitalChannels}, + {name: 'widget-cc-ai-assistant', component: WebAIAssistant}, ]; components.forEach(({name, component}) => { diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index 69ab4bcfb..52654bbd7 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -14,6 +14,7 @@ import { LoginOptions, WithWebex, RealTimeTranscriptionData, + RealTimeAssistPayload, } from './store.types'; import {getFeatureFlags} from './util'; @@ -55,6 +56,7 @@ class Store implements IStore { dataCenter: string = ''; realtimeTranscriptionData: Partial[] = []; acceptedCampaignIds: Set = new Set(); + realTimeAssist: Record = {}; constructor() { makeAutoObservable(this, { diff --git a/packages/contact-center/store/src/store.types.ts b/packages/contact-center/store/src/store.types.ts index 8635c184a..99775ec2b 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -25,6 +25,7 @@ import { getDefaultUIControls, TaskResponse, } from '@webex/contact-center'; +import type {SuggestedResponseParams} from 'node_modules/@webex/contact-center/dist/types/types'; import { OutdialAniEntriesResponse, OutdialAniParams, @@ -73,7 +74,42 @@ interface IContactCenter { acceptPreviewContact(payload: PreviewContactPayload): Promise; skipPreviewContact(payload: PreviewContactPayload): Promise; removePreviewContact(payload: PreviewContactPayload): Promise; + apiAIAssistant?: { + getRealTimeAssistance(params: RealTimeAssistRequestParams & {actionTimeStamp?: number}): Promise; + sendRealTimeAssistanceUserAction(params: RealTimeAssistUserActionParams): Promise; + }; } + +type RealTimeAssistRequestParams = SuggestedResponseParams; + +type RealTimeAssistUserActionId = string; + +type RealTimeAssistUserActionParams = { + agentId: string; + interactionId: string; + adaptiveCardId: string; + actionId: RealTimeAssistUserActionId; + languageCode?: string; +}; + +type RealTimeAssistPayload = { + agentId?: string; + data: { + adaptiveCard: unknown; + adaptiveCardId?: string; + title?: string; + suggestion?: string; + conversationId?: string; + trackingId?: string; + publishTimestamp?: number | string; + [key: string]: unknown; + }; + notifDetails?: { + actionEvent?: string; + }; + notifType?: string; + orgId?: string; +}; // To be fixed in SDK - https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6762 type IWebex = { cc: IContactCenter; @@ -167,6 +203,7 @@ interface IStore { dataCenter: string; realtimeTranscriptionData: Partial[]; acceptedCampaignIds: Set; + realTimeAssist: Record; init(params: InitParams, callback: (ccSDK: IContactCenter) => void): Promise; registerCC(webex?: WithWebex['webex']): Promise; } @@ -201,6 +238,7 @@ interface IStoreWrapper extends IStore { getAccessToken(): Promise; addAcceptedCampaign(interactionId: string): void; removeAcceptedCampaign(interactionId: string): void; + clearRealTimeAssist(interactionId: string): void; } interface IWrapupCode { @@ -333,6 +371,10 @@ export type { TaskUILeg, RealTimeTranscriptionData, RealTimeTranscriptionEventPayload, + RealTimeAssistPayload, + RealTimeAssistRequestParams, + RealTimeAssistUserActionId, + RealTimeAssistUserActionParams, }; export { diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index fc12ff465..b2746f9b8 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -25,6 +25,7 @@ import { AgentLoginProfile, ERROR_TRIGGERING_IDLE_CODES, RealTimeTranscriptionEventPayload, + RealTimeAssistPayload, } from './store.types'; import Store from './store'; import { @@ -39,6 +40,9 @@ import {runInAction} from 'mobx'; import {isIncomingTask} from './task-utils'; const TASK_MULTI_LOGIN_HYDRATE = 'task:multiLoginHydrate'; +// Mirrored from the SDK's CC_TASK_EVENTS — importing the runtime const breaks +// our jest transform setup. +const SUGGESTED_RESPONSE_EVENT = 'SUGGESTED_RESPONSE'; class StoreWrapper implements IStoreWrapper { store: IStore; @@ -49,6 +53,7 @@ class StoreWrapper implements IStoreWrapper { onTaskSelected?: (task: ITask, isClicked: boolean) => void; onErrorCallback?: (widgetName: string, error: Error) => void; private realtimeTranscriptionListeners: Record void> = {}; + private realTimeAssistListeners: Record void> = {}; constructor() { this.store = Store.getInstance(); @@ -160,6 +165,10 @@ class StoreWrapper implements IStoreWrapper { return this.store.acceptedCampaignIds; } + get realTimeAssist() { + return this.store.realTimeAssist; + } + setDataCenter = (value: string): void => { this.store.dataCenter = value; }; @@ -517,6 +526,18 @@ class StoreWrapper implements IStoreWrapper { taskToRemove.off(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); this.setCallControlAudio(null); } + + if (taskId && this.realTimeAssistListeners[taskId]) { + taskToRemove.off(SUGGESTED_RESPONSE_EVENT, this.realTimeAssistListeners[taskId]); + delete this.realTimeAssistListeners[taskId]; + } + if (taskId && this.store.realTimeAssist && this.store.realTimeAssist[taskId]) { + runInAction(() => { + const next = {...this.store.realTimeAssist}; + delete next[taskId]; + this.store.realTimeAssist = next; + }); + } } runInAction(() => { @@ -639,6 +660,28 @@ class StoreWrapper implements IStoreWrapper { this.setCallControlAudio(new MediaStream([track])); }; + handleRealTimeAssist = (interactionId: string, payload: RealTimeAssistPayload) => { + if (!interactionId || !payload?.data) return; + runInAction(() => { + const current = (this.store.realTimeAssist && this.store.realTimeAssist[interactionId]) || []; + this.store.realTimeAssist = { + ...(this.store.realTimeAssist || {}), + [interactionId]: [...current, payload], + }; + }); + }; + + clearRealTimeAssist = (interactionId: string): void => { + if (!interactionId) return; + runInAction(() => { + if (this.store.realTimeAssist?.[interactionId]) { + const next = {...this.store.realTimeAssist}; + delete next[interactionId]; + this.store.realTimeAssist = next; + } + }); + }; + // Case to handle multi session handleConsultCreated = () => { this.refreshTaskList(); @@ -757,7 +800,6 @@ class StoreWrapper implements IStoreWrapper { task.on(TASK_EVENTS.TASK_PARTICIPANT_LEFT_FAILED, this.refreshTaskList); task.on(TASK_EVENTS.TASK_CONFERENCE_TRANSFERRED, this.refreshTaskList); task.on(TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED, this.refreshTaskList); - task.on(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, this.refreshTaskList); // Campaign preview: transition RESERVED → ENGAGED when the agent accepts task.on(TASK_EVENTS.TASK_CAMPAIGN_PREVIEW_RESERVATION, this.handleCampaignPreviewReservation); @@ -775,6 +817,14 @@ class StoreWrapper implements IStoreWrapper { if (this.deviceType === DEVICE_TYPE_BROWSER) { task.on(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); } + + // Guard against duplicate registration when this method is re-entered via + // task:hydrate / task:merged for the same interaction. + if (taskId && !this.realTimeAssistListeners[taskId]) { + const listener = (payload: RealTimeAssistPayload) => this.handleRealTimeAssist(taskId, payload); + this.realTimeAssistListeners[taskId] = listener; + task.on(SUGGESTED_RESPONSE_EVENT, listener); + } }; handleIncomingTask = (event) => { @@ -1115,6 +1165,9 @@ class StoreWrapper implements IStoreWrapper { this.store.acceptedCampaignIds = new Set(); this.realtimeTranscriptionListeners = {}; this.setLastConsultDestination(null); + this.setLastConsultDestination(null); + this.store.realTimeAssist = {}; + this.realTimeAssistListeners = {}; }); }; diff --git a/packages/contact-center/store/src/util.ts b/packages/contact-center/store/src/util.ts index c97351593..553157e7c 100644 --- a/packages/contact-center/store/src/util.ts +++ b/packages/contact-center/store/src/util.ts @@ -1,5 +1,16 @@ import {Profile} from './store.types'; +export const AI_FEATURE_SUGGESTED_RESPONSES_KEY = 'isSuggestedResponsesEnabled'; + +const getValueAtPath = (obj: unknown, path: string): unknown => { + return path.split('.').reduce((acc, segment) => { + if (acc && typeof acc === 'object' && segment in (acc as Record)) { + return (acc as Record)[segment]; + } + return undefined; + }, obj); +}; + export function getFeatureFlags(agentProfile: Profile) { const featureFlagkeys = [ 'isOutboundEnabledForTenant', @@ -32,5 +43,19 @@ export function getFeatureFlags(agentProfile: Profile) { return acc; }, {}); + // SDK surfaces this flag at one of several paths; project the first match onto a flat key. + const aiSuggestedResponsesPaths = [ + 'aiFeature.suggestedResponses.enable', + 'agentConfig.aiFeature.suggestedResponses.enable', + 'isSuggestedResponsesEnabled', + ]; + for (const path of aiSuggestedResponsesPaths) { + const value = getValueAtPath(agentProfile, path); + if (typeof value === 'boolean') { + keyValuePairs[AI_FEATURE_SUGGESTED_RESPONSES_KEY] = value; + break; + } + } + return keyValuePairs; } diff --git a/packages/contact-center/test-fixtures/src/fixtures.ts b/packages/contact-center/test-fixtures/src/fixtures.ts index fbba0fb58..e095af87f 100644 --- a/packages/contact-center/test-fixtures/src/fixtures.ts +++ b/packages/contact-center/test-fixtures/src/fixtures.ts @@ -626,6 +626,10 @@ const mockCC: IContactCenter = { acceptPreviewContact: jest.fn().mockResolvedValue({}), skipPreviewContact: jest.fn().mockResolvedValue({}), removePreviewContact: jest.fn().mockResolvedValue({}), + apiAIAssistant: { + getRealTimeAssistance: jest.fn().mockResolvedValue({}), + sendRealTimeAssistanceUserAction: jest.fn().mockResolvedValue({}), + }, }; const mockCallAssociatedData: Record< diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.scss b/widgets-samples/cc/samples-cc-react-app/src/App.scss index 29956662f..d5b3347a7 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.scss +++ b/widgets-samples/cc/samples-cc-react-app/src/App.scss @@ -213,3 +213,15 @@ iframe { .margin-bottom-1rem { margin-bottom: 1rem; } + +// AI Assistant fullscreen: drop the popup look so the panel fills the host fieldset. +.ai-assistant--host-full .ai-assistant__panel { + border: none; + border-radius: 0.5rem; + box-shadow: none; + height: auto; + max-height: none; + max-width: none; + min-height: 12rem; + width: 100%; +} diff --git a/widgets-samples/cc/samples-cc-react-app/src/App.tsx b/widgets-samples/cc/samples-cc-react-app/src/App.tsx index 7013b4f7c..df89d873d 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -9,6 +9,7 @@ import { store, OutdialCall, RealTimeTranscript, + AIAssistant, } from '@webex/cc-widgets'; import {StationLogoutResponse} from '@webex/contact-center'; import {ERROR_TRIGGERING_IDLE_CODES} from '@webex/cc-store'; @@ -42,6 +43,7 @@ const defaultWidgets = { callControlCAD: true, outdialCall: true, realtimeTranscript: true, + aiAssistant: false, }; function App() { @@ -105,6 +107,9 @@ function App() { const isWidgetDisabledByWebRTC = (widget: string) => isWebRTCWidgetSelectionLocked && WEBRTC_DEPENDENT_WIDGETS.includes(widget); + // AI Assistant fullscreen state — the widget reports toggles, the host decides the layout. + const [isAIAssistantFullScreen, setIsAIAssistantFullScreen] = useState(false); + const handleSaveStart = () => { setShowLoader(true); setToast(null); @@ -621,6 +626,39 @@ function App() { )} + + ))} @@ -1106,6 +1144,32 @@ function App() { )} + {selectedWidgets.aiAssistant && store.currentTask && ( +
+
+
+ AI Assistant + console.log('AIAssistant opened')} + onMinimize={() => console.log('AIAssistant minimized')} + onRestore={() => console.log('AIAssistant restored')} + onClose={() => { + setIsAIAssistantFullScreen(false); + console.log('AIAssistant closed'); + }} + onFullScreenToggle={(isFs) => { + setIsAIAssistantFullScreen(isFs); + console.log('AIAssistant fullScreen', isFs); + }} + onSuggestionReceived={(payload) => + console.log('AIAssistant suggestion', payload) + } + /> +
+
+
+ )} )} diff --git a/yarn.lock b/yarn.lock index 1177e4815..e0641db73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9608,6 +9608,53 @@ __metadata: languageName: node linkType: hard +"@webex/cc-ai-assistant@workspace:*, @webex/cc-ai-assistant@workspace:packages/contact-center/ai-assistant": + version: 0.0.0-use.local + resolution: "@webex/cc-ai-assistant@workspace:packages/contact-center/ai-assistant" + dependencies: + "@babel/core": "npm:7.25.2" + "@babel/preset-env": "npm:7.25.4" + "@babel/preset-react": "npm:7.24.7" + "@babel/preset-typescript": "npm:7.25.9" + "@eslint/js": "npm:^9.20.0" + "@testing-library/dom": "npm:10.4.0" + "@testing-library/jest-dom": "npm:6.6.2" + "@testing-library/react": "npm:16.0.1" + "@types/jest": "npm:29.5.14" + "@types/react-test-renderer": "npm:18" + "@webex/cc-components": "workspace:*" + "@webex/cc-store": "workspace:*" + "@webex/cc-ui-logging": "workspace:*" + "@webex/test-fixtures": "workspace:*" + babel-jest: "npm:29.7.0" + babel-loader: "npm:9.2.1" + eslint: "npm:^9.20.1" + eslint-config-prettier: "npm:^10.0.1" + eslint-config-standard: "npm:^17.1.0" + eslint-plugin-import: "npm:^2.25.2" + eslint-plugin-n: "npm:^15.0.0 || ^16.0.0 " + eslint-plugin-prettier: "npm:^5.2.3" + eslint-plugin-promise: "npm:^6.0.0" + eslint-plugin-react: "npm:^7.37.4" + file-loader: "npm:6.2.0" + globals: "npm:^16.0.0" + jest: "npm:29.7.0" + jest-environment-jsdom: "npm:29.7.0" + mobx-react-lite: "npm:^4.1.0" + prettier: "npm:^3.5.1" + react-error-boundary: "npm:^6.0.0" + ts-loader: "npm:9.5.1" + typescript: "npm:5.6.3" + typescript-eslint: "npm:^8.24.1" + webpack: "npm:5.94.0" + webpack-cli: "npm:5.1.4" + webpack-merge: "npm:6.0.1" + peerDependencies: + react: ">=18.3.1" + react-dom: ">=18.3.1" + languageName: unknown + linkType: soft + "@webex/cc-components@workspace:*, @webex/cc-components@workspace:packages/contact-center/cc-components": version: 0.0.0-use.local resolution: "@webex/cc-components@workspace:packages/contact-center/cc-components" @@ -9627,6 +9674,7 @@ __metadata: "@webex/cc-store": "workspace:*" "@webex/cc-ui-logging": "workspace:*" "@webex/test-fixtures": "workspace:*" + adaptivecards: "npm:^3.0.0" babel-loader: "npm:9.2.1" eslint: "npm:^9.20.1" eslint-config-prettier: "npm:^10.0.1" @@ -9641,6 +9689,7 @@ __metadata: jest: "npm:29.7.0" jest-environment-jsdom: "npm:29.7.0" prettier: "npm:^3.5.1" + swiper: "npm:^12.1.2" ts-loader: "npm:9.5.1" typescript: "npm:5.6.3" typescript-eslint: "npm:^8.24.1" @@ -9925,6 +9974,7 @@ __metadata: "@testing-library/react": "npm:16.0.1" "@types/jest": "npm:29.5.14" "@types/react-test-renderer": "npm:18" + "@webex/cc-ai-assistant": "workspace:*" "@webex/cc-digital-channels": "workspace:*" "@webex/cc-station-login": "workspace:*" "@webex/cc-store": "workspace:*" @@ -12866,6 +12916,15 @@ __metadata: languageName: node linkType: hard +"adaptivecards@npm:^3.0.0": + version: 3.0.6 + resolution: "adaptivecards@npm:3.0.6" + peerDependencies: + swiper: ^12.1.2 + checksum: 10c0/cbd9397c25c332b408b8f62a5af5b6276539c7316a01c7b989537c84c8363aa4f97a9296d1d8659769c13c6def53839a0929bbc7acb436515c28edfca0f75bb1 + languageName: node + linkType: hard + "agent-base@npm:6": version: 6.0.2 resolution: "agent-base@npm:6.0.2" @@ -33120,6 +33179,13 @@ __metadata: languageName: node linkType: hard +"swiper@npm:^12.1.2": + version: 12.2.0 + resolution: "swiper@npm:12.2.0" + checksum: 10c0/459c9621e170130c0a6874b05e24fefd0411529600a17b2d8afc4987dc2e803ab18d42983081b723423819d464568fdb541b6c3f517e558c60888c644dc4ead1 + languageName: node + linkType: hard + "symbol-tree@npm:^3.2.4": version: 3.2.4 resolution: "symbol-tree@npm:3.2.4"