Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 48 additions & 17 deletions ai-docs/features/agent-wellness-break-intake.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,23 +110,54 @@ and `✍🏻 Smart summaries`, with their approved Desktop descriptions.
| Dark Lottie | `5e9a5eec6f7e370e6c386a79c0bc12b43d4b397e9a9ac94dedbe58f1a93c2f46` | `193052c64943fd94d993916936ef6cb2a2c9fc642c9856892c6ddda983ec92ac` | Removed the missing `audio_0` asset and its type-6 layer. |
| Desktop MP3 | `19e4474587759c98638ade310b8873ad957e03686c5a8af04c9a5d272c81699b` | same | Unmodified 65.097-second source. |

The renderer, theme JSON, and audio URL module are dynamic imports. The MP3 is emitted as
`assets/wellness/WellnessBreakSound.mp3`; `wellnessAudioUrl` overrides it for CDN/subpath deployments.

A production build followed by `npm pack --dry-run --json` shows the media boundary:

| Package | Lottie renderer chunk | Wellness MP3 |
| --- | --- | --- |
| `@webex/cc-components` | absent | absent |
| `@webex/cc-ai-assistant` | present (305,431 B) | present (2,603,884 B) |
| `@webex/cc-widgets` | present for its WC entry (305,431 B) | present for its WC entry (2,603,884 B) |

The React entry of `@webex/cc-widgets` does not import AI Assistant or reference its wellness media.
The WC entry preserves the existing AI Assistant registration and copies its lazy media into the
aggregate package. The assistant loads the renderer and audio for imminent playback. Hosts that consume
the prebuilt WC entry must serve its lazy chunks and MP3 from the expected public path; the React sample
builds the assistant from source and emits those files itself. The Web Component sample does not copy
the aggregate package's media into its output automatically.
The package build generates `.mjs` exports for the audio data URL and both animation objects.
The renderer, theme module, and audio URL module use standard dynamic imports. The media loader
contains no inline loader requests, `require.ensure()`, or Webpack public-path globals. Default media
does not require consumer MP3/JSON loaders or raw asset copying. JavaScript chunk paths and development
dependency prebundling remain the consumer's responsibility.

The original MP3 and theme JSON remain under `assets/wellness` with the checksums above.
`wellnessAudioUrl` overrides the data URL and bypasses the packaged audio import. CSP must permit
`data:` in `media-src` for default playback; hosts with a stricter policy can use an allowed hosted URL.
The 2,603,884-byte MP3 produces about 3.5 MB of encoded JavaScript before compression.

Both React import paths retain AI Assistant. The React and WC entries of `@webex/cc-widgets`
externalize `@webex/cc-ai-assistant`, which owns these media files. `@webex/cc-components` receives
an injected animation loader and includes no wellness media. Verify packed packages locally with
Webpack, Vite, Rollup, esbuild, and Parcel, with wellness unused and active, both themes, audio
decoding, and a URL subpath. Also verify the custom audio override under a hosted-media CSP.
The React sample compiles widgets from source and routes only the wellness media import to the
assistant's built loader. Build packages before starting that sample. The assistant watch build
tracks its raw media files and regenerates the JavaScript modules after a change.

### Local media compatibility checks

The packed release packages passed the following checks on 2026-10-12. A fresh
production build and package archive comparison on 2026-10-13 reproduced the same
SHA-256 hashes for all nine Contact Center packages.

| Consumer | Version | Production imports and playback | Development |
| --- | --- | --- | --- |
| Webpack | 5.111.1 | Passed | Passed |
| Vite | 8.3.4 | Passed | Passed |
| Rollup | 4.64.5 | Passed | Passed |
| esbuild | 0.28.2 | Passed | Passed |
| Parcel | 2.16.4 | Passed | Passed |

The checks covered React imports, Web Component registration, both animation
themes, MP3 decoding and playback, delayed sound loading, and deployment below
`/app/nested/`. Consumers used the required UI peers and normal CSS/SCSS support;
Parcel package exports were enabled. They used no MP3 loader, wellness JSON
loader, or raw media copy step. Browser checks used Chromium 156.0.8078.4 through
Playwright 1.64.0 and the existing host globals without an authenticated SDK session.

`media-src 'self'` blocked the default data audio and permitted the original MP3
served from the same origin. Hosts with this policy must supply an allowed
`wellnessAudioUrl`. The sound module contains about 3.5 MB before compression.
`tooling/tests/wellness-media.js` checks emission, raw bytes, import requests,
watch dependencies, and regeneration. The AI Assistant unit test also checks that
the host URL bypasses the default sound import. A live agent session and Amplify
validation remain release checks.

## Release gates

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,31 @@ Import `AIAssistant` from `@webex/cc-widgets` or directly from `@webex/cc-ai-ass
separate AI Assistant WC entry. This package owns `lottie-web` and the wellness
MP3; `cc-components` receives an animation loader as a prop and has no media dependency.

The CommonJS package includes the MP3, animation data, and its media loader under
`dist/assets/wellness`. Webpack emits the MP3 and lazy Lottie/animation chunks into
the consuming app without an asset rule or copy step. The browser fetches them
only when the wellness UI uses them. The React sample also supports this path
when it builds the assistant source.
The CommonJS package includes its media loader and generated `.mjs` modules under
`dist/assets/wellness`. The package build converts the MP3 to an audio data URL
and each theme's JSON to a JavaScript export. It retains the approved MP3 and JSON
files for hosts that serve them directly. The media loader uses standard dynamic
imports for the generated modules and `lottie-web`; it contains no inline asset
loader requests, `require.ensure()`, or Webpack public-path globals. Consumers do
not need an MP3/JSON loader or a copy step for the default media. Consumers that
support code splitting can fetch these JavaScript modules when the wellness UI
uses them. Development dependency prebundling can combine lazy modules.

The React sample compiles the widget TypeScript from source. Its Webpack config
routes the wellness media import to this package's built media loader. Run
`yarn build:dev` before building or serving the sample. The package build tracks
the original media files as dependencies, so its watch build regenerates modules
when these files change.

The default audio requires CSP `media-src` to permit `data:` URLs. Hosts that
permit only hosted audio must set the existing `wellnessAudioUrl` prop to an
allowed URL. The override bypasses the packaged audio import. Test the packed
package with Webpack, Vite, Rollup, esbuild, and Parcel, including delayed loading,
audio decoding, both animation themes, and an app deployed below a URL subpath.
The Lottie import accepts both a module default export and a direct CommonJS
export; Parcel and the other consumer bundlers can return different forms.
`tooling/tests/wellness-media.js` checks the emitted modules, unchanged raw media,
standard import requests, source dependencies, and regeneration after a source change.

The orchestration uses only the published wellness contract and legacy `setAgentState()` path from the
store. It does not consume an AI Assistant RTD status event.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export interface IAIAssistantProps {
onWellnessBreakEnded?: () => void;
/** Fired with a stable, non-PII lifecycle error shape. */
onWellnessBreakError?: (error: WellnessBreakError) => void;
/** Optional host/CDN audio URL. Defaults to the packaged wellness audio asset. */
/** Optional host/CDN audio URL. The packaged default is a data URL; use this override if CSP blocks data media. */
wellnessAudioUrl?: string;
/** Controls whether the break covers the viewport, assistant root, or a host element. */
wellnessBreakOverlayTarget?: WellnessBreakOverlayTarget;
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,27 +1,10 @@
// Keep these CommonJS requests visible to the consuming Webpack build. The
// assistant's package build leaves this file alongside its published assets.
exports.loadWellnessSoundUrl = () => Promise.resolve(require('!!./emit-wellness-asset.cjs!./WellnessBreakSound.mp3'));
// The package build generates JavaScript modules for the checked-in media.
// Consumers can load them without an MP3 or JSON loader.
exports.loadWellnessSoundUrl = () => import('./WellnessBreakSound.mjs').then((module) => module.default);

exports.loadWellnessAnimationData = (theme) =>
new Promise((resolve, reject) => {
if (theme === 'dark') {
require.ensure(
[],
(require) => resolve(require('./WellnessBreakAnimationDark.json')),
reject,
'wellness-animation-dark'
);
} else {
require.ensure(
[],
(require) => resolve(require('./WellnessBreakAnimationLight.json')),
reject,
'wellness-animation-light'
);
}
});
(theme === 'dark' ? import('./WellnessBreakAnimationDark.mjs') : import('./WellnessBreakAnimationLight.mjs')).then(
(module) => module.default
);

exports.loadLottie = () =>
new Promise((resolve, reject) => {
require.ensure([], (require) => resolve(require('lottie-web')), reject, 'lottie-web');
});
exports.loadLottie = () => import('lottie-web').then((module) => module.default ?? module);
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {act, renderHook, waitFor} from '@testing-library/react';
import type {UseWellnessBreakInput} from '../src/ai-assistant.types';
import {useWellnessBreak, WELLNESS_OFFER_TIMEOUT_MS, WELLNESS_RECOVERY_KEY} from '../src/wellness/useWellnessBreak';
import store from '@webex/cc-store';
import {loadWellnessSoundUrl} from '../src/wellness/assets/media.cjs';

jest.mock('../src/wellness/assets/media.cjs', () => ({
loadWellnessSoundUrl: jest.fn().mockResolvedValue('/wellness.mp3'),
Expand Down Expand Up @@ -753,6 +754,7 @@ describe('useWellnessBreak', () => {
await Promise.resolve();
});
expect(audioConstructor).toHaveBeenCalledWith('/wellness.mp3');
expect(loadWellnessSoundUrl).not.toHaveBeenCalled();
expect(audio.load).toHaveBeenCalledTimes(1);
expect(audio.play).not.toHaveBeenCalled();

Expand Down
22 changes: 15 additions & 7 deletions packages/contact-center/ai-assistant/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ const baseConfig = require('../../../webpack.config');

const wellnessAssets = [
'media.cjs',
'emit-wellness-asset.cjs',
'WellnessBreakSound.mp3',
'WellnessBreakAnimationDark.json',
'WellnessBreakAnimationLight.json',
Expand All @@ -23,12 +22,21 @@ class EmitWellnessAssetsPlugin {
() => {
for (const filename of wellnessAssets) {
const sourcePath = path.resolve(__dirname, 'src/wellness/assets', filename);
compilation.emitAsset(
`assets/wellness/${filename}`,
new compiler.webpack.sources.RawSource(fs.readFileSync(sourcePath)),
// A consuming Webpack build must still recognize require.ensure's require callback.
{minimized: filename.endsWith('.cjs')}
);
compilation.fileDependencies.add(sourcePath);
const source = fs.readFileSync(sourcePath);
compilation.emitAsset(`assets/wellness/${filename}`, new compiler.webpack.sources.RawSource(source), {
minimized: filename.endsWith('.cjs'),
});
if (filename.endsWith('.mp3') || filename.endsWith('.json')) {
const data = filename.endsWith('.mp3')
? `data:audio/mpeg;base64,${source.toString('base64')}`
: JSON.parse(source.toString('utf8'));
compilation.emitAsset(
`assets/wellness/${filename.replace(/\.(mp3|json)$/, '.mjs')}`,
new compiler.webpack.sources.RawSource(`export default ${JSON.stringify(data)};\n`),
{minimized: true}
);
}
}
}
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,15 @@ mount and `WIDGET_UNMOUNTED` on unmount via the store logger. Does NOT own the l
log persistence, or prop sanitization.

## Stack

The bundle uses the root Webpack mode from `NODE_ENV`. Production builds must
publish normal JavaScript exports rather than development `eval()` modules, so
consumer bundlers can read `withMetrics` and its dependencies. The default mode
remains development when `NODE_ENV` is not set.

TypeScript ^5.6.3, React (peer `>=18.3.1`, `react-dom >=18.3.1`) HOC + hooks. Tests: Jest 29.7.0 +
React Testing Library 16.0.1 + `@testing-library/jest-dom`, jsdom environment. Build: Webpack 5
(`webpack --mode=development`), output to `dist/`. Source of truth: `package.json`.
(`webpack`), output to `dist/`. Source of truth: `package.json`.

## Folder / Package Structure
```
Expand Down
2 changes: 1 addition & 1 deletion packages/contact-center/ui-logging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"package.json"
],
"scripts": {
"build:src": "webpack --mode=development",
"build:src": "webpack",
"clean": "rm -rf dist",
"clean:dist": "rm -rf dist",
"test:unit": "NODE_ENV=test jest --coverage",
Expand Down
104 changes: 104 additions & 0 deletions tooling/tests/wellness-media.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
const config = require('../../packages/contact-center/ai-assistant/webpack.config');

const sourceRoot = path.resolve(__dirname, '../../packages/contact-center/ai-assistant/src/wellness/assets');
const sourceFiles = [
'media.cjs',
'WellnessBreakSound.mp3',
'WellnessBreakAnimationDark.json',
'WellnessBreakAnimationLight.json',
];

const emitMedia = () => {
const assets = new Map();
const fileDependencies = new Set();
const compilation = {
fileDependencies,
emitAsset: (filename, source) => assets.set(filename, source.buffer()),
hooks: {processAssets: {tap: (_options, callback) => callback()}},
};
const compiler = {
webpack: {sources: webpack.sources, Compilation: {PROCESS_ASSETS_STAGE_ADDITIONS: 0}},
hooks: {thisCompilation: {tap: (_name, callback) => callback(compilation)}},
};
config.plugins.find((plugin) => plugin.constructor.name === 'EmitWellnessAssetsPlugin').apply(compiler);
return {assets, fileDependencies};
};

const readExport = (assets, filename) => {
const source = assets.get(`assets/wellness/${filename}`).toString('utf8');
expect(source.startsWith('export default ')).toBe(true);
return JSON.parse(source.slice('export default '.length, -2));
};

describe('wellness media packaging', () => {
let emitted;
beforeAll(() => {
emitted = emitMedia();
});

it('emits all portable modules and removes the inline Webpack loader', () => {
expect([...emitted.assets.keys()].sort()).toEqual(
[...sourceFiles, 'WellnessBreakSound.mjs', 'WellnessBreakAnimationDark.mjs', 'WellnessBreakAnimationLight.mjs']
.map((filename) => `assets/wellness/${filename}`)
.sort()
);
expect(emitted.assets.has('assets/wellness/emit-wellness-asset.cjs')).toBe(false);
});

it.each(sourceFiles)('retains the original bytes of %s', (filename) => {
expect(emitted.assets.get(`assets/wellness/${filename}`)).toEqual(fs.readFileSync(path.join(sourceRoot, filename)));
});

it('exports an audio data URL containing the original MP3', () => {
const url = readExport(emitted.assets, 'WellnessBreakSound.mjs');
expect(url.startsWith('data:audio/mpeg;base64,')).toBe(true);
expect(Buffer.from(url.slice(url.indexOf(',') + 1), 'base64')).toEqual(
fs.readFileSync(path.join(sourceRoot, 'WellnessBreakSound.mp3'))
);
});

it.each(['Dark', 'Light'])('exports the original %s animation as JavaScript data', (theme) => {
expect(readExport(emitted.assets, `WellnessBreakAnimation${theme}.mjs`)).toEqual(
JSON.parse(fs.readFileSync(path.join(sourceRoot, `WellnessBreakAnimation${theme}.json`), 'utf8'))
);
});

it('uses standard imports without a consumer MP3 or JSON loader', () => {
const loader = emitted.assets.get('assets/wellness/media.cjs').toString('utf8');
expect(loader).not.toMatch(/require\.ensure|__webpack_public_path__|emit-wellness-asset|!!\./);
expect(loader).not.toMatch(/(?:import|require)\([^)]*\.(?:mp3|json)/);
const imports = [...loader.matchAll(/import\('\.\/([^']+)'\)/g)].map((match) => match[1]);
expect(imports.sort()).toEqual(
['WellnessBreakSound.mjs', 'WellnessBreakAnimationDark.mjs', 'WellnessBreakAnimationLight.mjs'].sort()
);
imports.forEach((filename) => expect(emitted.assets.has(`assets/wellness/${filename}`)).toBe(true));
});

it('tracks the original media files for watch builds', () => {
expect([...emitted.fileDependencies].sort()).toEqual(
sourceFiles.map((filename) => path.join(sourceRoot, filename)).sort()
);
});

it('regenerates the audio module when the source bytes change', () => {
const readFile = fs.readFileSync;
const changedAudio = Buffer.from('changed audio bytes');
const readSpy = jest
.spyOn(fs, 'readFileSync')
.mockImplementation((filename, ...options) =>
filename === path.join(sourceRoot, 'WellnessBreakSound.mp3') ? changedAudio : readFile(filename, ...options)
);
try {
const updated = emitMedia();
expect(readExport(updated.assets, 'WellnessBreakSound.mjs')).toBe(
`data:audio/mpeg;base64,${changedAudio.toString('base64')}`
);
expect(updated.assets.get('assets/wellness/WellnessBreakSound.mp3')).toEqual(changedAudio);
} finally {
readSpy.mockRestore();
}
});
});
8 changes: 7 additions & 1 deletion widgets-samples/cc/samples-cc-react-app/webpack.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const fs = require('fs');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const {HotModuleReplacementPlugin, ProvidePlugin} = require('webpack');
const {HotModuleReplacementPlugin, NormalModuleReplacementPlugin, ProvidePlugin} = require('webpack');
// Helper function to resolve paths relative to the monorepo root
const resolveMonorepoRoot = (...segments) => path.resolve(__dirname, '../../../', ...segments);

Expand Down Expand Up @@ -143,6 +143,12 @@ module.exports = {
chunkFilename: 'assets/[name].[contenthash:8].js',
},
plugins: [
// The source sample uses media modules generated by the assistant package build.
new NormalModuleReplacementPlugin(/^\.\/assets\/media\.cjs$/, (resource) => {
if (resource.context === resolveMonorepoRoot('packages/contact-center/ai-assistant/src/wellness')) {
resource.request = resolveMonorepoRoot('packages/contact-center/ai-assistant/dist/assets/wellness/media.cjs');
}
}),
new HtmlWebpackPlugin({
template: './public/index.html', // Template HTML file
filename: 'index.html',
Expand Down
Loading