From 225c8221b373a5f419304be1b1edc63b73b1b6df Mon Sep 17 00:00:00 2001 From: lego0110 Date: Mon, 29 Jun 2026 16:04:16 +0530 Subject: [PATCH 1/2] feat(ai-assistant): add new AI Assistant Widget and implement suggested response feature --- .../ai-assistant/bable.config.js | 3 + .../ai-assistant/eslint.config.mjs | 20 ++ .../ai-assistant/jest.config.js | 6 + .../contact-center/ai-assistant/package.json | 70 ++++ .../ai-assistant/src/ai-assistant.types.ts | 24 ++ .../ai-assistant/src/ai-assistant/index.tsx | 61 ++++ .../contact-center/ai-assistant/src/helper.ts | 251 +++++++++++++ .../contact-center/ai-assistant/src/index.ts | 4 + .../ai-assistant/tests/ai-assistant/index.tsx | 67 ++++ .../ai-assistant/tests/helper.ts | 233 ++++++++++++ .../contact-center/ai-assistant/tsconfig.json | 11 + .../ai-assistant/tsconfig.test.json | 9 + .../ai-assistant/webpack.config.js | 56 +++ .../contact-center/cc-components/package.json | 4 +- .../adaptive-card-renderer.tsx | 229 ++++++++++++ .../adaptive-card-renderer.utils.ts | 114 ++++++ .../AIAssistant/CiscoAIAssistantColorIcon.tsx | 93 +++++ .../AIAssistant/Launcher/launcher.tsx | 17 + .../AIAssistant/Panel/Header/header.tsx | 42 +++ .../Panel/MinimizedBar/minimized-bar.tsx | 33 ++ .../components/AIAssistant/Panel/panel.tsx | 77 ++++ .../ContextInput/context-input.tsx | 39 ++ .../SuggestedResponse/suggested-response.tsx | 155 ++++++++ .../AIAssistant/ai-assistant.styles.scss | 339 ++++++++++++++++++ .../components/AIAssistant/ai-assistant.tsx | 61 ++++ .../AIAssistant/ai-assistant.types.ts | 137 +++++++ .../contact-center/cc-components/src/index.ts | 3 + .../contact-center/cc-widgets/package.json | 1 + .../contact-center/cc-widgets/src/index.ts | 2 + packages/contact-center/cc-widgets/src/wc.ts | 15 + packages/contact-center/store/package.json | 2 +- packages/contact-center/store/src/store.ts | 2 + .../contact-center/store/src/store.types.ts | 35 ++ .../store/src/storeEventsWrapper.ts | 145 ++++++++ packages/contact-center/store/src/util.ts | 25 ++ .../test-fixtures/src/fixtures.ts | 3 + .../cc/samples-cc-react-app/src/App.scss | 12 + .../cc/samples-cc-react-app/src/App.tsx | 97 +++-- yarn.lock | 76 +++- 39 files changed, 2532 insertions(+), 41 deletions(-) create mode 100644 packages/contact-center/ai-assistant/bable.config.js create mode 100644 packages/contact-center/ai-assistant/eslint.config.mjs create mode 100644 packages/contact-center/ai-assistant/jest.config.js create mode 100644 packages/contact-center/ai-assistant/package.json create mode 100644 packages/contact-center/ai-assistant/src/ai-assistant.types.ts create mode 100644 packages/contact-center/ai-assistant/src/ai-assistant/index.tsx create mode 100644 packages/contact-center/ai-assistant/src/helper.ts create mode 100644 packages/contact-center/ai-assistant/src/index.ts create mode 100644 packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx create mode 100644 packages/contact-center/ai-assistant/tests/helper.ts create mode 100644 packages/contact-center/ai-assistant/tsconfig.json create mode 100644 packages/contact-center/ai-assistant/tsconfig.test.json create mode 100644 packages/contact-center/ai-assistant/webpack.config.js create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.tsx create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.utils.ts create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/CiscoAIAssistantColorIcon.tsx create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/Launcher/launcher.tsx create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/Panel/Header/header.tsx create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/Panel/MinimizedBar/minimized-bar.tsx create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/Panel/panel.tsx create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/SuggestedResponse/ContextInput/context-input.tsx create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/SuggestedResponse/suggested-response.tsx create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.styles.scss create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.tsx create mode 100644 packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.types.ts 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..4740e7040 --- /dev/null +++ b/packages/contact-center/ai-assistant/src/ai-assistant.types.ts @@ -0,0 +1,24 @@ +import type {SuggestedResponsePayload} 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 assistant suggestion arrives for the active task. */ + onSuggestionReceived?: (payload: SuggestedResponsePayload) => 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..7a584e9c2 --- /dev/null +++ b/packages/contact-center/ai-assistant/src/ai-assistant/index.tsx @@ -0,0 +1,61 @@ +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 {SuggestedResponsePayload} from '@webex/cc-store'; +import {useAiAssistant, SUGGESTED_RESPONSES_FLAG} from '../helper'; +import {IAIAssistantProps} from '../ai-assistant.types'; + +const AIAssistantInternal: React.FunctionComponent = observer((props) => { + const {currentTask, agentId, featureFlags, suggestedResponses} = store; + const interactionId = currentTask?.data?.interactionId; + const isFeatureEnabled = Boolean(featureFlags?.[SUGGESTED_RESPONSES_FLAG]); + const suggestions = interactionId ? suggestedResponses?.[interactionId] || [] : []; + + const hookProps = useAiAssistant({ + ...props, + interactionId, + agentId, + isFeatureEnabled, + suggestions, + }); + + const handleSuggestionFeedback = useCallback( + (event: AIAssistantFeedbackEvent, suggestion: SuggestedResponsePayload) => { + if (!interactionId) return; + store.sendSuggestionFeedback?.({ + interactionId, + adaptiveCardId: suggestion?.data?.adaptiveCardId, + trackingId: suggestion?.data?.trackingId, + actionId: event.actionId, + actionType: 'Action.Submit', + }); + }, + [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..b42caeda5 --- /dev/null +++ b/packages/contact-center/ai-assistant/src/helper.ts @@ -0,0 +1,251 @@ +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import store from '@webex/cc-store'; +import type {SuggestedResponsePayload} from '@webex/cc-store'; +import type {AIAssistantChatEntry, AIAssistantChromeState, AIAssistantRequestStatus} from '@webex/cc-components'; +import {IAIAssistantProps} from './ai-assistant.types'; + +const SUGGESTED_RESPONSES_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; + suggestions: SuggestedResponsePayload[]; +} + +type UserMessage = {id: string; text: string; sentAt: number}; + +export const useAiAssistant = ({ + interactionId, + agentId, + isFeatureEnabled, + suggestions, + onOpen, + onMinimize, + onRestore, + onClose, + onClearChat, + onFullScreenToggle, + onSuggestionReceived, +}: 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); + // Refs let the effect read the latest values of these without re-running + // when they change — we only want to react to suggestions growing. + const pendingRequestRef = useRef(pendingRequest); + const requestStatusRef = useRef(requestStatus); + const onSuggestionReceivedRef = useRef(onSuggestionReceived); + useEffect(() => { + pendingRequestRef.current = pendingRequest; + }, [pendingRequest]); + useEffect(() => { + requestStatusRef.current = requestStatus; + }, [requestStatus]); + useEffect(() => { + onSuggestionReceivedRef.current = onSuggestionReceived; + }, [onSuggestionReceived]); + + useEffect(() => { + const len = suggestions.length; + if (len === 0) { + lastSeenCountRef.current = 0; + return; + } + if (len <= lastSeenCountRef.current) return; + lastSeenCountRef.current = len; + + const latest = suggestions[len - 1]; + if (pendingRequestRef.current) { + setPendingRequest(false); + setRequestStatus('ready'); + } else if (requestStatusRef.current !== 'ready') { + setRequestStatus('ready'); + } + onSuggestionReceivedRef.current?.(latest); + }, [suggestions]); + + const open = useCallback(() => { + setChrome('open'); + onOpen?.(); + }, [onOpen]); + + const resetSessionState = useCallback(() => { + setRequestStatus('idle'); + setErrorMessage(undefined); + setContextDraft(''); + setPendingRequest(false); + setHasFiredInitialRequest(false); + setUserMessages([]); + lastSeenCountRef.current = 0; + }, []); + + // 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(() => { + resetSessionState(); + if (interactionId) { + store.clearSuggestedResponse?.(interactionId); + } + onClearChat?.(); + }, [interactionId, onClearChat, resetSessionState]); + + const requestSuggestion = useCallback( + async (context?: string) => { + if (!isFeatureEnabled) { + setRequestStatus('error'); + setErrorMessage('AI suggested responses are not enabled for your profile.'); + return; + } + if (!interactionId || !agentId) { + setRequestStatus('error'); + setErrorMessage('No active interaction to request a suggestion for.'); + return; + } + const api = store.cc?.apiAIAssistant; + if (!api?.getSuggestedResponse) { + setRequestStatus('error'); + setErrorMessage( + 'AI assistant API is not available in the loaded SDK build. Update @webex/contact-center to a build that exposes apiAIAssistant.getSuggestedResponse.' + ); + 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.getSuggestedResponse({ + agentId, + interactionId, + actionTimeStamp: sentAt, + ...(context ? {context} : {}), + }); + } catch (err) { + setPendingRequest(false); + setRequestStatus('error'); + setErrorMessage((err as Error)?.message || 'Failed to request a suggestion.'); + } + }, + [agentId, interactionId, isFeatureEnabled] + ); + + const submitContext = useCallback(() => { + const draft = contextDraft.trim(); + if (!draft) return; + requestSuggestion(draft); + setContextDraft(''); + }, [contextDraft, requestSuggestion]); + + // Chronological transcript: optional greeting, then interleaved user/assistant entries. + const chatEntries = useMemo(() => { + const assistantEntries: Array<{ts: number; order: number; entry: AIAssistantChatEntry}> = suggestions.map( + (suggestion, index) => { + const publishTimestamp = suggestion?.data?.publishTimestamp; + const ts = + typeof publishTimestamp === 'number' + ? publishTimestamp + : typeof publishTimestamp === 'string' + ? Number.parseInt(publishTimestamp, 10) || 0 + : 0; + const id = + (suggestion?.data?.adaptiveCardId as string | undefined) ?? + (suggestion?.data?.trackingId as string | undefined) ?? + `assistant-${index}`; + return {ts, order: 2, entry: {type: 'assistant', id, suggestion}}; + } + ); + + 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]; + }, [suggestions, userMessages, hasFiredInitialRequest]); + + return useMemo( + () => ({ + chrome, + isFullScreen, + requestStatus, + errorMessage, + contextDraft, + hasFiredInitialRequest, + chatEntries, + open, + close, + minimize, + restore, + toggleFullScreen, + clearChat, + requestSuggestion: () => requestSuggestion(), + setContextDraft, + submitContext, + }), + [ + chrome, + isFullScreen, + requestStatus, + errorMessage, + contextDraft, + hasFiredInitialRequest, + chatEntries, + open, + close, + minimize, + restore, + toggleFullScreen, + clearChat, + requestSuggestion, + submitContext, + ] + ); +}; + +export {SUGGESTED_RESPONSES_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..617704931 --- /dev/null +++ b/packages/contact-center/ai-assistant/tests/ai-assistant/index.tsx @@ -0,0 +1,67 @@ +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: { + getSuggestedResponse: jest.fn(), + }, + }, + currentTask: {data: {interactionId: 'interaction-1'}}, + agentId: 'agent-1', + featureFlags: {isSuggestedResponsesEnabled: true}, + suggestedResponses: {}, + onErrorCallback: undefined, + clearSuggestedResponse: jest.fn(), + }, + }; +}); + +type StoreMock = { + cc: {apiAIAssistant: {getSuggestedResponse: jest.Mock}}; + currentTask: {data: {interactionId: string}}; + agentId: string; + featureFlags: {isSuggestedResponsesEnabled: boolean}; + suggestedResponses: Record; + onErrorCallback?: jest.Mock; + clearSuggestedResponse: jest.Mock; +}; +const storeMock = store as unknown as StoreMock; + +describe('AIAssistant widget', () => { + beforeEach(() => { + jest.restoreAllMocks(); + jest.spyOn(console, 'error').mockImplementation(() => {}); + storeMock.suggestedResponses = {}; + 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..86d41380c --- /dev/null +++ b/packages/contact-center/ai-assistant/tests/helper.ts @@ -0,0 +1,233 @@ +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 clearSuggestedResponse = jest.fn(); + const getSuggestedResponse = jest.fn().mockResolvedValue({}); + return { + __esModule: true, + default: { + cc: { + apiAIAssistant: { + getSuggestedResponse, + }, + }, + clearSuggestedResponse, + }, + }; +}); + +type StoreMock = { + cc: {apiAIAssistant: {getSuggestedResponse: jest.Mock}}; + clearSuggestedResponse: jest.Mock; +}; +const storeMock = store as unknown as StoreMock; + +const baseProps = { + agentId: 'agent-1', + interactionId: 'interaction-1', + isFeatureEnabled: true, + suggestions: [] 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.clearSuggestedResponse, 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.clearSuggestedResponse).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.getSuggestedResponse).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 onSuggestionReceived = jest.fn(); + const payload = {data: {adaptiveCard: {type: 'AdaptiveCard'}, title: 'Refund policy'}}; + + const {result, rerender} = renderHook((props: {suggestions?: Array} = {}) => + useAiAssistant({...baseProps, ...props, suggestions: props.suggestions ?? [], onSuggestionReceived}) + ); + + await act(async () => { + result.current.requestSuggestion(); + }); + expect(result.current.requestStatus).toBe('listening'); + + rerender({suggestions: [payload]}); + + await waitFor(() => expect(result.current.requestStatus).toBe('ready')); + expect(onSuggestionReceived).toHaveBeenCalledWith(payload); + }); + + it('appends additional suggestions and notifies the host on each', async () => { + const onSuggestionReceived = jest.fn(); + const first = {data: {adaptiveCard: {type: 'AdaptiveCard'}, title: 'first'}}; + const second = {data: {adaptiveCard: {type: 'AdaptiveCard'}, title: 'second'}}; + + const {result, rerender} = renderHook((props: {suggestions?: Array} = {}) => + useAiAssistant({...baseProps, ...props, suggestions: props.suggestions ?? [], onSuggestionReceived}) + ); + + rerender({suggestions: [first]}); + await waitFor(() => expect(onSuggestionReceived).toHaveBeenLastCalledWith(first)); + + rerender({suggestions: [first, second]}); + await waitFor(() => expect(onSuggestionReceived).toHaveBeenLastCalledWith(second)); + expect(onSuggestionReceived).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: {suggestions?: Array} = {}) => + useAiAssistant({...baseProps, ...props, suggestions: props.suggestions ?? []}) + ); + + // 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({suggestions: [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({suggestions: [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.getSuggestedResponse).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.getSuggestedResponse).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.getSuggestedResponse).not.toHaveBeenCalled(); + }); + + it('records error path when SDK rejects', async () => { + storeMock.cc.apiAIAssistant.getSuggestedResponse.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..1b4ac8ea7 --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/AdaptiveCardRenderer/adaptive-card-renderer.tsx @@ -0,0 +1,229 @@ +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 swapIcon = (element: Element, newName: string): string | null => { + const img = element.querySelector('img'); + if (!img) return null; + const previous = img.getAttribute('src'); + img.setAttribute('src', `${MOMENTUM_ICON_CDN}${newName}`); + return previous; +}; + +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 effect only re-runs when the card itself + // changes — not on every parent re-render that hands us a fresh inline + // function. Without this, re-renders re-parse the card and the browser + // re-fetches every image (including any failing icon). + const onFeedbackRef = useRef(onFeedback); + const onActionRef = useRef(onAction); + const suggestionTextRef = useRef(suggestionText); + useEffect(() => { + onFeedbackRef.current = onFeedback; + }, [onFeedback]); + useEffect(() => { + onActionRef.current = onAction; + }, [onAction]); + useEffect(() => { + suggestionTextRef.current = suggestionText; + }, [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); + } + if (sourceEl) { + const previousSrc = swapIcon(sourceEl, 'check-circle-filled.svg'); + sourceEl.setAttribute('data-copied', 'true'); + setTimeout(() => { + sourceEl.removeAttribute('data-copied'); + if (previousSrc) { + const img = sourceEl.querySelector('img'); + 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..15ee46ceb --- /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 SuggestedResponse 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, + onSuggestionFeedback, +}) => { + 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..ac09457c2 --- /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 {SuggestedResponseProps} 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 SuggestedResponse: React.FC = ({ + status, + errorMessage, + chatEntries, + onRequestSuggestion, + isFeatureEnabled, + hasFiredInitialRequest, + onSuggestionFeedback, +}) => { + 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.suggestion?.data?.title ? ( +
+ + + {entry.suggestion.data.title} + +
+ ) : null} + onSuggestionFeedback?.(event, entry.suggestion)} + /> +
+ ); + })} + {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 SuggestedResponse; 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..ef6d1485a --- /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, + onSuggestionFeedback, + 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..12f191acc --- /dev/null +++ b/packages/contact-center/cc-components/src/components/AIAssistant/ai-assistant.types.ts @@ -0,0 +1,137 @@ +import type {SuggestedResponsePayload} from '@webex/cc-store'; + +/** Visual state of the AI Assistant panel chrome. */ +export type AIAssistantChromeState = 'closed' | 'open' | 'minimized'; + +/** Lifecycle state of a suggested-response 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 `SuggestedResponsePayload` 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; suggestion: SuggestedResponsePayload}; + +/** 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 suggestion 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 likes / dislikes / copies a suggestion. */ + onSuggestionFeedback?: (event: AIAssistantFeedbackEvent, suggestion: SuggestedResponsePayload) => 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; + onSuggestionFeedback?: (event: AIAssistantFeedbackEvent, suggestion: SuggestedResponsePayload) => void; +} + +export interface SuggestedResponseProps { + 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. */ + onSuggestionFeedback?: (event: AIAssistantFeedbackEvent, suggestion: SuggestedResponsePayload) => 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/index.ts b/packages/contact-center/cc-components/src/index.ts index d4d692fdb..de27d22b9 100644 --- a/packages/contact-center/cc-components/src/index.ts +++ b/packages/contact-center/cc-components/src/index.ts @@ -5,6 +5,7 @@ import CallControlCADComponent from './components/task/CallControlCAD/call-contr import IncomingTaskComponent from './components/task/IncomingTask/incoming-task'; import TaskListComponent from './components/task/TaskList/task-list'; import OutdialCallComponent from './components/task/OutdialCall/outdial-call'; +import AIAssistantComponent from './components/AIAssistant/ai-assistant'; export { UserStateComponent, @@ -14,8 +15,10 @@ export { IncomingTaskComponent, TaskListComponent, OutdialCallComponent, + AIAssistantComponent, }; export * from './components/StationLogin/constants'; export * from './components/StationLogin/station-login.types'; export * from './components/UserState/user-state.types'; export * from './components/task/task.types'; +export * from './components/AIAssistant/ai-assistant.types'; 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 ea1562e11..26101fd54 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} 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'; @@ -14,5 +15,6 @@ export { TaskList, OutdialCall, DigitalChannels, + AIAssistant, store, }; diff --git a/packages/contact-center/cc-widgets/src/wc.ts b/packages/contact-center/cc-widgets/src/wc.ts index e1fde50ab..d5a87fbb0 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} from '@webex/cc-task'; import {DigitalChannels} from '@webex/cc-digital-channels'; +import {AIAssistant} from '@webex/cc-ai-assistant'; const WebUserState = r2wc(UserState, { props: { @@ -58,6 +59,19 @@ const WebOutdialCall = r2wc(OutdialCall, {}); 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 = [ @@ -69,6 +83,7 @@ const components = [ {name: 'widget-cc-outdial-call', component: WebOutdialCall}, {name: 'widget-cc-call-control-cad', component: WebCallControlCAD}, {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/package.json b/packages/contact-center/store/package.json index 454840364..fc96cb284 100644 --- a/packages/contact-center/store/package.json +++ b/packages/contact-center/store/package.json @@ -23,7 +23,7 @@ "deploy:npm": "yarn npm publish" }, "dependencies": { - "@webex/contact-center": "3.12.0-task-refactor.8", + "@webex/contact-center": "3.12.0-task-refactor.10", "mobx": "6.13.5", "typescript": "5.6.3" }, diff --git a/packages/contact-center/store/src/store.ts b/packages/contact-center/store/src/store.ts index e87e1a2dc..41a659fe5 100644 --- a/packages/contact-center/store/src/store.ts +++ b/packages/contact-center/store/src/store.ts @@ -13,6 +13,7 @@ import { AgentLoginProfile, LoginOptions, WithWebex, + SuggestedResponsePayload, } from './store.types'; import {getFeatureFlags} from './util'; @@ -52,6 +53,7 @@ class Store implements IStore { isMuted: boolean = false; isDigitalChannelsInitialized: boolean = false; dataCenter: string = ''; + suggestedResponses: 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 2acc72151..badd30e60 100644 --- a/packages/contact-center/store/src/store.types.ts +++ b/packages/contact-center/store/src/store.types.ts @@ -24,6 +24,7 @@ import { TaskUILeg, getDefaultUIControls, } from '@webex/contact-center'; +import type {SuggestedResponseParams} from 'node_modules/@webex/contact-center/dist/types/types'; import { OutdialAniEntriesResponse, OutdialAniParams, @@ -66,7 +67,29 @@ interface IContactCenter { setAgentState(data: StateChange): Promise; getOutdialAniEntries(params: OutdialAniParams): Promise; getAccessToken(): Promise; + apiAIAssistant?: { + getSuggestedResponse(params: SuggestedResponseParams & {actionTimeStamp?: number}): Promise; + }; } + +type SuggestedResponsePayload = { + 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; @@ -135,6 +158,7 @@ interface IStore { isAddressBookEnabled: boolean; isDigitalChannelsInitialized: boolean; dataCenter: string; + suggestedResponses: Record; init(params: InitParams, callback: (ccSDK: IContactCenter) => void): Promise; registerCC(webex?: WithWebex['webex']): Promise; } @@ -167,6 +191,15 @@ interface IStoreWrapper extends IStore { setOnError(callback: (widgetName: string, error: Error) => void): void; setDataCenter(value: string): void; getAccessToken(): Promise; + clearSuggestedResponse(interactionId: string): void; + sendSuggestionFeedback(params: { + interactionId: string; + adaptiveCardId?: string; + trackingId?: string; + languageCode?: string; + actionId: string; + actionType?: string; + }): Promise; } interface IWrapupCode { @@ -291,6 +324,8 @@ export type { TaskUIControlState, InteractionUIControls, TaskUILeg, + SuggestedResponsePayload, + SuggestedResponseParams, }; export { diff --git a/packages/contact-center/store/src/storeEventsWrapper.ts b/packages/contact-center/store/src/storeEventsWrapper.ts index 9eb586ee5..d006d3a8b 100644 --- a/packages/contact-center/store/src/storeEventsWrapper.ts +++ b/packages/contact-center/store/src/storeEventsWrapper.ts @@ -22,6 +22,7 @@ import { Profile, AgentLoginProfile, ERROR_TRIGGERING_IDLE_CODES, + SuggestedResponsePayload, } from './store.types'; import Store from './store'; import { @@ -33,6 +34,10 @@ import { import {runInAction} from 'mobx'; import {isIncomingTask} from './task-utils'; +// 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; onIncomingTask: ({task}: {task: ITask}) => void; @@ -41,6 +46,7 @@ class StoreWrapper implements IStoreWrapper { onTaskAssigned?: (task: ITask) => void; onTaskSelected?: (task: ITask, isClicked: boolean) => void; onErrorCallback?: (widgetName: string, error: Error) => void; + private suggestedResponseListeners: Record void> = {}; constructor() { this.store = Store.getInstance(); @@ -144,6 +150,10 @@ class StoreWrapper implements IStoreWrapper { return this.store.dataCenter; } + get suggestedResponses() { + return this.store.suggestedResponses; + } + setDataCenter = (value: string): void => { this.store.dataCenter = value; }; @@ -467,6 +477,19 @@ class StoreWrapper implements IStoreWrapper { taskToRemove.off(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); this.setCallControlAudio(null); } + + const taskId = taskToRemove.data?.interactionId; + if (taskId && this.suggestedResponseListeners[taskId]) { + taskToRemove.off(SUGGESTED_RESPONSE_EVENT, this.suggestedResponseListeners[taskId]); + delete this.suggestedResponseListeners[taskId]; + } + if (taskId && this.store.suggestedResponses && this.store.suggestedResponses[taskId]) { + runInAction(() => { + const next = {...this.store.suggestedResponses}; + delete next[taskId]; + this.store.suggestedResponses = next; + }); + } } runInAction(() => { @@ -514,6 +537,117 @@ class StoreWrapper implements IStoreWrapper { this.setCallControlAudio(new MediaStream([track])); }; + handleSuggestedResponse = (interactionId: string, payload: SuggestedResponsePayload) => { + if (!interactionId || !payload?.data) return; + runInAction(() => { + const current = (this.store.suggestedResponses && this.store.suggestedResponses[interactionId]) || []; + this.store.suggestedResponses = { + ...(this.store.suggestedResponses || {}), + [interactionId]: [...current, payload], + }; + }); + }; + + clearSuggestedResponse = (interactionId: string): void => { + if (!interactionId) return; + runInAction(() => { + if (this.store.suggestedResponses?.[interactionId]) { + const next = {...this.store.suggestedResponses}; + delete next[interactionId]; + this.store.suggestedResponses = next; + } + }); + }; + + /** POSTs a `SUGGESTED_RESPONSES_USER_ACTION` event for like/dislike/copy clicks. */ + sendSuggestionFeedback = async (params: { + interactionId: string; + adaptiveCardId?: string; + trackingId?: string; + languageCode?: string; + actionId: string; + actionType?: string; + }): Promise => { + const {interactionId, adaptiveCardId, trackingId, languageCode, actionId, actionType} = params; + if (!interactionId || !actionId) return; + try { + // @ts-expect-error - cc.webex not exposed on the typed IContactCenter + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const webex = this.store.cc?.webex as any; + if (!webex?.request) return; + + const baseUrl = this.resolveAiAssistantBaseUrl(); + if (!baseUrl) return; + + await webex.request({ + uri: `${baseUrl}/event`, + method: 'POST', + addAuthHeader: true, + body: { + agentId: this.agentId, + orgId: this.agentProfile?.orgId, + eventType: 'CUSTOM_EVENT', + eventName: 'SUGGESTED_RESPONSES_USER_ACTION', + eventDetails: { + data: { + interactionId, + adaptiveCardId, + trackingId, + actionTimeStamp: Date.now(), + languageCode: languageCode || 'en', + userAction: { + actionType: actionType || 'Action.Submit', + actionId, + }, + }, + }, + }, + }); + } catch (err) { + this.store.logger?.error(`CC-Widgets: sendSuggestionFeedback failed - ${err}`, { + module: 'storeEventsWrapper.ts', + method: 'sendSuggestionFeedback', + }); + } + }; + + /** Resolve the AI Assistant base URL from the same WCC gateway lookup the SDK uses. */ + private resolveAiAssistantBaseUrl(): string | null { + try { + // @ts-expect-error - cc.webex not exposed on the typed IContactCenter + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const webex = this.store.cc?.webex as any; + const gateway = webex?.internal?.services?.get?.('wcc-api-gateway') || ''; + if (!gateway) return null; + let hostname = ''; + try { + hostname = new URL(gateway).hostname.toLowerCase(); + } catch { + hostname = String(gateway).toLowerCase(); + } + // Mirrors AI_ASSISTANT_ENV_MAP from @webex/contact-center; keep in sync + // when the SDK adds regions until we route this event through a typed + // SDK method (see PR follow-up). + const map: Record = { + 'api.intgus1.ciscoccservice.com': 'intgus1', + 'api.qaus1.ciscoccservice.com': 'qaus1', + 'api.wxcc-us1.cisco.com': 'produs1', + 'api.wxcc-eu1.cisco.com': 'prodeu1', + 'api.wxcc-eu2.cisco.com': 'prodeu2', + 'api.wxcc-anz1.cisco.com': 'prodanz1', + 'api.wxcc-ca1.cisco.com': 'prodca1', + 'api.wxcc-jp1.cisco.com': 'prodjp1', + 'api.wxcc-sg1.cisco.com': 'prodsg1', + 'api.wxcc-in1.cisco.com': 'prodin1', + 'api.loadus1.cisco.com': 'loadus1', + }; + const env = map[hostname]; + return env ? `https://api-ai-assistant.${env}.ciscoccservice.com` : null; + } catch { + return null; + } + } + // Case to handle multi session handleConsultCreated = () => { this.refreshTaskList(); @@ -636,6 +770,15 @@ class StoreWrapper implements IStoreWrapper { if (this.deviceType === DEVICE_TYPE_BROWSER) { task.on(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); } + + const taskId = task.data?.interactionId; + // Guard against duplicate registration when this method is re-entered via + // task:hydrate / task:merged for the same interaction. + if (taskId && !this.suggestedResponseListeners[taskId]) { + const listener = (payload: SuggestedResponsePayload) => this.handleSuggestedResponse(taskId, payload); + this.suggestedResponseListeners[taskId] = listener; + task.on(SUGGESTED_RESPONSE_EVENT, listener); + } }; handleIncomingTask = (event) => { @@ -843,6 +986,8 @@ class StoreWrapper implements IStoreWrapper { this.setTeamId(''); this.setDigitalChannelsInitialized(false); this.setLastConsultDestination(null); + this.store.suggestedResponses = {}; + this.suggestedResponseListeners = {}; }); }; 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 70eebbd2d..0800538b5 100644 --- a/packages/contact-center/test-fixtures/src/fixtures.ts +++ b/packages/contact-center/test-fixtures/src/fixtures.ts @@ -512,6 +512,9 @@ const mockCC: IContactCenter = { setAgentState: jest.fn().mockResolvedValue({}), getOutdialAniEntries: jest.fn().mockResolvedValue({entries: []}), getAccessToken: jest.fn().mockResolvedValue('mock-access-token'), + apiAIAssistant: { + getSuggestedResponse: jest.fn().mockResolvedValue({}), + }, }; export { 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 7583180a3..b40f92f4f 100644 --- a/widgets-samples/cc/samples-cc-react-app/src/App.tsx +++ b/widgets-samples/cc/samples-cc-react-app/src/App.tsx @@ -8,6 +8,7 @@ import { CallControlCAD, store, OutdialCall, + AIAssistant, } from '@webex/cc-widgets'; import {StationLogoutResponse} from '@webex/contact-center'; import {ERROR_TRIGGERING_IDLE_CODES} from '@webex/cc-store'; @@ -39,6 +40,7 @@ const defaultWidgets = { callControl: true, callControlCAD: true, outdialCall: true, + aiAssistant: false, }; function App() { @@ -89,6 +91,9 @@ function App() { return savedAllowInternationalDn === 'true'; }); + // AI Assistant fullscreen state — the widget reports toggles, the host decides the layout. + const [isAIAssistantFullScreen, setIsAIAssistantFullScreen] = useState(false); + const handleSaveStart = () => { setShowLoader(true); setToast(null); @@ -517,40 +522,38 @@ function App() {  Select Widgets to Show 
{Object.keys(defaultWidgets).map((widget) => ( - <> - - + ))}
@@ -973,6 +976,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 eac50e33d..febc6aff4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9387,6 +9387,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" @@ -9406,6 +9453,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" @@ -9420,6 +9468,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" @@ -9544,7 +9593,7 @@ __metadata: "@testing-library/react": "npm:16.0.1" "@types/jest": "npm:29.5.14" "@types/react-test-renderer": "npm:18" - "@webex/contact-center": "npm:3.12.0-task-refactor.8" + "@webex/contact-center": "npm:3.12.0-task-refactor.10" "@webex/test-fixtures": "workspace:*" babel-jest: "npm:29.7.0" babel-loader: "npm:9.2.1" @@ -9703,6 +9752,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:*" @@ -9937,9 +9987,9 @@ __metadata: languageName: node linkType: hard -"@webex/contact-center@npm:3.12.0-task-refactor.8": - version: 3.12.0-task-refactor.8 - resolution: "@webex/contact-center@npm:3.12.0-task-refactor.8" +"@webex/contact-center@npm:3.12.0-task-refactor.10": + version: 3.12.0-task-refactor.10 + resolution: "@webex/contact-center@npm:3.12.0-task-refactor.10" dependencies: "@types/platform": "npm:1.3.4" "@webex/calling": "npm:3.12.0-task-refactor.1" @@ -9953,7 +10003,7 @@ __metadata: lodash: "npm:^4.17.21" uuid: "npm:^3.3.2" xstate: "npm:5.24.0" - checksum: 10c0/3bca6eccf570e65f27ef2a17f7574b871de23a975f2541943a69452a03b7eeddf7a9e529315586672e789b2a56e3a5936139ddc3931250d9febdd1daef569689 + checksum: 10c0/4728911d4c81d0e2d201fa8eaf3d61e7ead5b2a119e0148c138fa332971da1435b98576c11096ed2bb51a417c94294bd1103de599a04762af75cdd443ab5968b languageName: node linkType: hard @@ -13330,6 +13380,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" @@ -33536,6 +33595,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" From bb18632b8e3e284729e211f82e922b4190142db8 Mon Sep 17 00:00:00 2001 From: lego0110 Date: Mon, 29 Jun 2026 16:18:08 +0530 Subject: [PATCH 2/2] refactor(ai-assistant): collapse ref-sync effects and inline swapIcon helper --- .../contact-center/ai-assistant/src/helper.ts | 30 +++++++---------- .../adaptive-card-renderer.tsx | 32 ++++++------------- 2 files changed, 20 insertions(+), 42 deletions(-) diff --git a/packages/contact-center/ai-assistant/src/helper.ts b/packages/contact-center/ai-assistant/src/helper.ts index b42caeda5..ef241ace8 100644 --- a/packages/contact-center/ai-assistant/src/helper.ts +++ b/packages/contact-center/ai-assistant/src/helper.ts @@ -40,20 +40,16 @@ export const useAiAssistant = ({ const [userMessages, setUserMessages] = useState([]); const lastSeenCountRef = useRef(0); - // Refs let the effect read the latest values of these without re-running - // when they change — we only want to react to suggestions growing. + // Stash these in refs so the suggestion effect can read the latest values + // without re-running on every change (it only reacts to `suggestions`). const pendingRequestRef = useRef(pendingRequest); const requestStatusRef = useRef(requestStatus); const onSuggestionReceivedRef = useRef(onSuggestionReceived); useEffect(() => { pendingRequestRef.current = pendingRequest; - }, [pendingRequest]); - useEffect(() => { requestStatusRef.current = requestStatus; - }, [requestStatus]); - useEffect(() => { onSuggestionReceivedRef.current = onSuggestionReceived; - }, [onSuggestionReceived]); + }); useEffect(() => { const len = suggestions.length; @@ -79,16 +75,6 @@ export const useAiAssistant = ({ onOpen?.(); }, [onOpen]); - const resetSessionState = useCallback(() => { - setRequestStatus('idle'); - setErrorMessage(undefined); - setContextDraft(''); - setPendingRequest(false); - setHasFiredInitialRequest(false); - setUserMessages([]); - lastSeenCountRef.current = 0; - }, []); - // close preserves chat state so reopening continues the session; clearChat resets it. const close = useCallback(() => { setChrome('closed'); @@ -115,12 +101,18 @@ export const useAiAssistant = ({ }, [onFullScreenToggle]); const clearChat = useCallback(() => { - resetSessionState(); + setRequestStatus('idle'); + setErrorMessage(undefined); + setContextDraft(''); + setPendingRequest(false); + setHasFiredInitialRequest(false); + setUserMessages([]); + lastSeenCountRef.current = 0; if (interactionId) { store.clearSuggestedResponse?.(interactionId); } onClearChat?.(); - }, [interactionId, onClearChat, resetSessionState]); + }, [interactionId, onClearChat]); const requestSuggestion = useCallback( async (context?: string) => { 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 index 1b4ac8ea7..7b7f11c95 100644 --- 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 @@ -54,14 +54,6 @@ const detectIconKind = (element: Element): IconKind => { return null; }; -const swapIcon = (element: Element, newName: string): string | null => { - const img = element.querySelector('img'); - if (!img) return null; - const previous = img.getAttribute('src'); - img.setAttribute('src', `${MOMENTUM_ICON_CDN}${newName}`); - return previous; -}; - const AdaptiveCardRenderer: React.FC = ({ card, fallbackText, @@ -73,22 +65,17 @@ const AdaptiveCardRenderer: React.FC = ({ const containerRef = useRef(null); const [renderFailed, setRenderFailed] = useState(false); - // Stash callbacks in refs so the effect only re-runs when the card itself - // changes — not on every parent re-render that hands us a fresh inline - // function. Without this, re-renders re-parse the card and the browser - // re-fetches every image (including any failing icon). + // 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; - }, [onFeedback]); - useEffect(() => { onActionRef.current = onAction; - }, [onAction]); - useEffect(() => { suggestionTextRef.current = suggestionText; - }, [suggestionText]); + }); useEffect(() => { preloadIcons(); @@ -115,15 +102,14 @@ const AdaptiveCardRenderer: React.FC = ({ document.execCommand('copy'); document.body.removeChild(ta); } - if (sourceEl) { - const previousSrc = swapIcon(sourceEl, 'check-circle-filled.svg'); + 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) { - const img = sourceEl.querySelector('img'); - img?.setAttribute('src', previousSrc); - } + if (previousSrc) img.setAttribute('src', previousSrc); }, 1500); } } catch (err) {