diff --git a/ui/README.md b/ui/README.md index 3fa42d64..f56fb79f 100644 --- a/ui/README.md +++ b/ui/README.md @@ -38,3 +38,4 @@ Drop a Copilot Studio chat widget into an existing platform: | [pcf-canvas-app/](./embed/pcf-canvas-app/) | PCF control to embed a chat agent in Power Apps canvas apps | | [servicenow-widget/](./embed/servicenow-widget/) | Floating chat widget for ServiceNow Service Portal | | [sharepoint-customizer/](./embed/sharepoint-customizer/) | SharePoint app customizer with SSO | +| [zendesk-widget/](./embed/zendesk-widget/) | Floating chat widget for Zendesk Help Center | diff --git a/ui/embed/README.md b/ui/embed/README.md index 3a336443..3676dec9 100644 --- a/ui/embed/README.md +++ b/ui/embed/README.md @@ -20,5 +20,6 @@ Plug a Copilot Studio chat agent into an existing platform. | [servicenow-widget/](./servicenow-widget/) | Floating chat widget for ServiceNow Service Portal | | [sharepoint-customizer/](./sharepoint-customizer/) | SharePoint app customizer with SSO | | [typeahead-suggestions/](./typeahead-suggestions/) | Typeahead suggestions for WebChat | +| [zendesk-widget/](./zendesk-widget/) | Floating chat widget for Zendesk Help Center | | [WebChat React](../custom-ui/webchat-react/) | WebChat React client with auth (Node) — *M365 Agents SDK repo* | | [Web Client](../custom-ui/webclient/) | Web client with auth (Node) — *M365 Agents SDK repo* | diff --git a/ui/embed/zendesk-widget/.gitignore b/ui/embed/zendesk-widget/.gitignore new file mode 100644 index 00000000..5671b3f7 --- /dev/null +++ b/ui/embed/zendesk-widget/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +test-page/config.js +*.tsbuildinfo diff --git a/ui/embed/zendesk-widget/README.md b/ui/embed/zendesk-widget/README.md new file mode 100644 index 00000000..cb07a0a0 --- /dev/null +++ b/ui/embed/zendesk-widget/README.md @@ -0,0 +1,311 @@ +# Zendesk Widget for Copilot Studio + +Embed a Microsoft Copilot Studio agent as a floating chat widget in a Zendesk Help Center. + +![Chat widget running on Zendesk Help Center](docs/images/chat-widget.png) + +The widget uses [BotFramework WebChat](https://github.com/microsoft/BotFramework-WebChat) for rendering and the [M365 Agents SDK](https://www.npmjs.com/package/@microsoft/agents-copilotstudio-client) to connect to Copilot Studio. It authenticates users via MSAL (popup or silent SSO) and renders a floating chat bubble with a slide-up panel — no iframes. + +## Architecture + +``` +┌──────────────────────────────────────────────────────────┐ +│ Zendesk Help Center (Theme) │ +│ │ +│ document_head.hbs: │ +│ ┌─────────────┐ ┌───────────────┐ │ +│ │ MSAL Browser│ │ WebChat CDN │ │ +│ └─────────────┘ └───────────────┘ │ +│ │ +│ footer.hbs: │ +│ ┌───────────────┐ ┌──────────────────────────────┐ │ +│ │copilot-chat.js│ │ CopilotChat.init({config}) │ │ +│ │ (theme asset) │ │ │ │ +│ └───────┬───────┘ └──────────────┬───────────────┘ │ +│ │ │ │ +│ ┌───────▼─────────────────────────▼───────────────────┐ │ +│ │ Floating Chat Widget │ │ +│ │ 1. User clicks chat bubble │ │ +│ │ 2. MSAL authenticates (silent SSO or popup) │ │ +│ │ 3. Agents SDK connects to Copilot Studio │ │ +│ │ 4. WebChat renders conversation │ │ +│ └─────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +## Prerequisites + +- A **Zendesk** Help Center with admin access (Guide theme editor) +- A **Microsoft Copilot Studio** agent with authentication set to **"Authenticate with Microsoft"** +- A **Microsoft Entra ID** app registration (SPA) — see [Step 1](#step-1-configure-entra-id-app-registration) +- **Node.js** 18+ (to build the bundle) + +--- + +## Setup Guide + +### Step 1: Configure Entra ID App Registration + +Create an app registration in the [Azure Portal](https://portal.azure.com): + +1. Go to **App registrations** → **New registration** +2. Set: + - **Name:** `Zendesk Copilot Widget` (or any name) + - **Account type:** Single tenant + - **Redirect URI:** Platform = **Single-page application (SPA)**, URI = `http://localhost:5500` +3. On the **Overview** page, note: + - **Application (client) ID** — this is your `appClientId` + - **Directory (tenant) ID** — this is your `tenantId` +4. Go to **Authentication** → add a second redirect URI: `https://.zendesk.com` +5. Under **Implicit grant and hybrid flows**, check: + - ✅ **Access tokens** + - ✅ **ID tokens** +6. Go to **API Permissions** → **Add a permission** → **APIs my organization uses** → search for `Power Platform API` + - Select **Delegated permissions** → **CopilotStudio** → check `CopilotStudio.Copilots.Invoke` + - Click **Grant admin consent** + +> **Note:** If `Power Platform API` doesn't appear, you need to register the service principal in your tenant first. See [Power Platform API Authentication](https://learn.microsoft.com/power-platform/admin/programmability-authentication-v2#step-2-configure-api-permissions). + +### Step 2: Configure Copilot Studio Agent + +1. Open [Copilot Studio](https://copilotstudio.microsoft.com) → select your agent +2. Go to **Settings** → **Security** → **Authentication** +3. Select **"Authenticate with Microsoft"** +4. **Publish** the agent + +Note the following from your agent's connection string or settings: + +![agent creds](docs/images/agentscreds.png) + +Setting → Advance → Metadata +- **Environment ID** (include the `Default-` prefix, e.g., `Default-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`) +- **Agent identifier** (schema name, e.g., `crXXX_myAgent`) + +### Step 3: Build the Widget Bundle + +```bash +npm install +npm run build +``` + +This produces `dist/copilot-chat.js` (~148 KB), a self-contained IIFE bundle that exposes `window.CopilotChat`. + +### Step 4: Open the Zendesk Theme Code Editor + +1. Log into Zendesk as an admin +2. Navigate to **Guide** → **Customize design** + - Direct URL: `https://.zendesk.com/theming` +3. Click **Customize** on your active theme (typically "Copenhagen") +4. Click **Edit code** (bottom-left of the customizer) + +You'll see the theme file structure: +``` +assets/ ← JS/CSS/image files +templates/ + ├── document_head.hbs + ├── header.hbs + ├── footer.hbs + ├── home_page.hbs + ├── article_page.hbs + └── ... +script.js +style.css +``` + +### Step 5: Upload the Bundle as a Theme Asset + +1. In the code editor, look at the **left sidebar** under the file list +2. Click the **"Add"** dropdown (near the top of the file list or next to "Files") +3. Select **"Add asset"** or **"Upload file"** +4. Upload `dist/copilot-chat.js` from your local build + +After upload, you should see `copilot-chat.js` listed under the **assets** section. + +### Step 6: Edit `document_head.hbs` + +Click on `document_head.hbs` in the templates list. Add these lines at the **very end** of the file: + +```html + + + + +``` + +This loads the WebChat and MSAL libraries from CDN. + +> **Production tip:** For zero CDN dependency, download these two JS files and upload them as theme assets too. Then reference them as `{{asset 'webchat.js'}}` and `{{asset 'msal-browser.js'}}`. + +### Step 7: Edit `footer.hbs` + +Click on `footer.hbs` in the templates list. Add this block at the **very end** of the file, **after** the closing `` tag: + +```html + + + + +``` + +**Replace** the placeholder values with your actual agent configuration: + +| Placeholder | Replace with | +|-------------|-------------| +| `Default-xxxxxxxx-...` | Your Copilot Studio Environment ID | +| `crXXX_myAgent` | Your agent's schema name | +| `tenantId: 'xxxxxxxx-...'` | Your Entra tenant ID | +| `appClientId: 'xxxxxxxx-...'` | Your Entra app client ID | +| `your-subdomain` | Your Zendesk subdomain | + +> **Important:** The `redirectUri` must exactly match one of the redirect URIs registered in your Entra ID app registration. + +### Step 8: Save and Publish + +1. Click **Save** in the code editor (top-right) +2. Click **Publish** to make the theme live +3. Visit your Help Center: `https://.zendesk.com/hc/en-us` +4. You should see a chat bubble in the bottom-right corner +5. Click it — a sign-in popup appears (first time only), then the agent connects + +--- + +## Local Development + +For testing without deploying to Zendesk: + +```bash +# Copy sample config +cp test-page/config.sample.js test-page/config.js +# Edit test-page/config.js with your agent settings + +# Build and serve +npm run build +npm run serve +``` + +Open `http://localhost:5500/test-page/` to test the widget locally. + +--- + +## Customization + +See [docs/CUSTOMIZATION.md](docs/CUSTOMIZATION.md) for all available config options, WebChat style overrides, and theming details. + +--- + +## Updating the Widget + +To update the widget after a code change: + +1. Rebuild: `npm run build` +2. In the Zendesk theme editor, delete the old `copilot-chat.js` asset +3. Upload the new `dist/copilot-chat.js` +4. Save and publish the theme + +--- + +## Removing the Widget + +To remove the widget from your Help Center: + +1. Open the theme code editor +2. Remove the block between `` and `` from `document_head.hbs` +3. Remove the block between `` and `` from `footer.hbs` +4. Delete the `copilot-chat.js` asset +5. Save and publish + +--- + +## Project Structure + +``` +Zendesk-Widget/ +├── README.md ← This file (manual setup guide) +├── package.json +├── esbuild.config.mjs ← Build config (produces dist/copilot-chat.js) +├── tsconfig.json +├── src/ +│ ├── index.ts ← Entry point — CopilotChat.init() +│ ├── config.ts ← Config types and defaults +│ ├── auth.ts ← MSAL authentication (silent SSO / popup) +│ ├── chat.ts ← WebChat + Agents SDK connection +│ └── bubble.ts ← Floating bubble and panel UI +├── test-page/ +│ ├── index.html ← Local test page (simulated Help Center) +│ └── config.sample.js +└── docs/ + ├── CUSTOMIZATION.md ← Config options and styling + └── images/ +``` + +--- + +## Content Security Policy (CSP) + +If your Zendesk Help Center enforces a strict CSP, ensure the following domains are allowed: + +| Directive | Domain | +|-----------|--------| +| `script-src` | `https://unpkg.com` | +| `connect-src` | `https://login.microsoftonline.com` `https://*.botframework.com` `https://default*.environment.api.powerplatform.com` | +| `frame-src` | `https://login.microsoftonline.com` | +| `style-src` | `'unsafe-inline'` (required by WebChat) | + +--- + +## Troubleshooting + +| Issue | Cause | Fix | +|-------|-------|-----| +| Chat bubble doesn't appear | Bundle not loaded | Check browser console for 404 on `copilot-chat.js`. Verify the asset was uploaded. | +| `ERR_NAME_NOT_RESOLVED` | DNS can't resolve Power Platform API | Check your network/VPN. Try a different DNS (e.g., 8.8.8.8) or disconnect from corporate VPN. | +| MSAL popup blocked | Browser blocks popups | Allow popups for your Zendesk domain, or ensure users have an active Entra session for silent SSO. | +| `AADSTS50011` redirect URI mismatch | App registration redirect URI doesn't match | Add `https://.zendesk.com` as a SPA redirect URI in the Entra app registration. | +| `Failed to fetch` on token | CORS or network issue | Ensure the Copilot Studio agent is published and auth is set to "Authenticate with Microsoft". | +| Agent responds with "usage limit" | Copilot Studio trial/capacity limit | This is a licensing issue, not a widget issue. Check your Copilot Studio plan. | + +--- + +## References + +This widget is adapted from the official Microsoft Copilot Studio embedding samples: + +- **Source repository:** [microsoft/CopilotStudioSamples](https://github.com/microsoft/CopilotStudioSamples) +- **ServiceNow widget sample:** [ui/embed/servicenow-widget](https://github.com/microsoft/CopilotStudioSamples/tree/main/ui/embed/servicenow-widget) — the reference implementation this widget is based on +- **M365 Agents SDK:** [@microsoft/agents-copilotstudio-client](https://www.npmjs.com/package/@microsoft/agents-copilotstudio-client) (v1.2.3+) +- **BotFramework WebChat:** [microsoft/BotFramework-WebChat](https://github.com/microsoft/BotFramework-WebChat) (v4.18.0) +- **MSAL Browser:** [@azure/msal-browser](https://www.npmjs.com/package/@azure/msal-browser) (v4.13.1) +- **Copilot Studio Authentication:** [Power Platform API Authentication](https://learn.microsoft.com/power-platform/admin/programmability-authentication-v2) +- **Zendesk Guide Theming:** [Theme code editor](https://support.zendesk.com/hc/en-us/articles/4408828867098) + +### Differences from ServiceNow Widget Sample + +The core source files that produce `copilot-chat.js` are shared between the ServiceNow and Zendesk versions. The table below summarizes the differences: + +| File | Status | Notes | +|------|--------|-------| +| `src/index.ts` | **Identical** | Same entry point, same config validation | +| `src/config.ts` | **Identical** | Same `CopilotChatConfig` interface and defaults | +| `src/bubble.ts` | **Identical** | Same floating bubble/panel UI code | +| `src/chat.ts` | **Identical** | Same WebChat + Agents SDK initialization | +| `src/auth.ts` | **Enhanced** | Adds `timeout: 2000` to `ssoSilent()` call — ensures the browser's user-gesture window (~5s in Chrome) is still valid for the `loginPopup` fallback if SSO fails. The ServiceNow version calls `ssoSilent(loginRequest)` without a timeout. | +| `esbuild.config.mjs` | **Identical** | Same IIFE build config | +| `tsconfig.json` | **Identical** | Same TypeScript settings | +| `package.json` | **Modified** | Different name (`zendesk-copilot-chat`), no deploy script | + +**Bottom line:** The built `dist/copilot-chat.js` bundle is functionally equivalent to the ServiceNow version, with one improvement — the SSO timeout in `auth.ts` prevents a stalled silent auth from consuming the browser's user-gesture window, ensuring the popup fallback works reliably. + +The only differences are in the **deployment method** (Zendesk theme editor vs. ServiceNow REST API) and the **host integration** (Handlebars templates vs. ServiceNow Widget Dependencies). diff --git a/ui/embed/zendesk-widget/docs/CUSTOMIZATION.md b/ui/embed/zendesk-widget/docs/CUSTOMIZATION.md new file mode 100644 index 00000000..79ee9aef --- /dev/null +++ b/ui/embed/zendesk-widget/docs/CUSTOMIZATION.md @@ -0,0 +1,59 @@ +# Customization Guide + +## Config Options + +All options are passed via the config object — from the `CopilotChat.init()` call in `footer.hbs` (Zendesk), or `window.__COPILOT_CONFIG__` in the local test page. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `environmentId` | string | *(required)* | Copilot Studio environment ID (include `Default-` prefix) | +| `agentIdentifier` | string | *(required)* | Agent schema name | +| `tenantId` | string | *(required)* | Entra tenant ID | +| `appClientId` | string | *(required)* | Entra app client ID | +| `cloud` | string | `'Prod'` | Cloud environment (`Prod`, `Gov`, `High`, `DoD`) | +| `headerTitle` | string | `'Chat with us'` | Panel header text | +| `bubbleColor` | string | `'#1b3e4f'` | Bubble button color | +| `headerColor` | string | `'#1b3e4f'` | Panel header background | +| `panelWidth` | string | `'420px'` | Chat panel width | +| `panelHeight` | string | `'600px'` | Chat panel height | +| `position` | string | `'bottom-right'` | Bubble position (`bottom-right` or `bottom-left`) | +| `zIndex` | number | `9999` | CSS z-index for the widget | +| `debug` | boolean | `false` | Enable SDK console logging | +| `styleOptions` | object | `{}` | WebChat [styleOptions](https://github.com/microsoft/BotFramework-WebChat/blob/main/packages/api/src/StyleOptions.ts) passthrough | +| `redirectUri` | string | `window.location.origin` | MSAL redirect URI (set to your Zendesk domain) | + +## WebChat Style Overrides + +Pass any [WebChat styleOptions](https://github.com/microsoft/BotFramework-WebChat/blob/main/packages/api/src/StyleOptions.ts) via the `styleOptions` config field. These are merged on top of the built-in theme: + +```javascript +// Example: override bubble colors +CopilotChat.init(document.body, { + // ... required fields ... + styleOptions: { + bubbleFromUserBackground: '#0078d4', + bubbleFromUserTextColor: '#ffffff', + } +}); +``` + +## Changing Config in Zendesk + +Edit the `CopilotChat.init()` call in `footer.hbs` via the Zendesk theme code editor. Changes take effect after **Save → Publish**. + +## Styling the Bubble and Panel + +The bubble and panel colors can be customized without rebuilding the bundle: + +```javascript +CopilotChat.init(document.body, { + // ... required fields ... + bubbleColor: '#0078d4', // Blue bubble + headerColor: '#0078d4', // Blue header + panelWidth: '450px', // Wider panel + panelHeight: '650px', // Taller panel + position: 'bottom-left', // Left-side placement +}); +``` + +To make deeper UI changes (icons, layout), modify the source in `src/bubble.ts` and rebuild with `npm run build`. diff --git a/ui/embed/zendesk-widget/docs/images/agentscreds.png b/ui/embed/zendesk-widget/docs/images/agentscreds.png new file mode 100644 index 00000000..df465c39 Binary files /dev/null and b/ui/embed/zendesk-widget/docs/images/agentscreds.png differ diff --git a/ui/embed/zendesk-widget/docs/images/chat-widget.png b/ui/embed/zendesk-widget/docs/images/chat-widget.png new file mode 100644 index 00000000..9935afb7 Binary files /dev/null and b/ui/embed/zendesk-widget/docs/images/chat-widget.png differ diff --git a/ui/embed/zendesk-widget/esbuild.config.mjs b/ui/embed/zendesk-widget/esbuild.config.mjs new file mode 100644 index 00000000..a8d8bf84 --- /dev/null +++ b/ui/embed/zendesk-widget/esbuild.config.mjs @@ -0,0 +1,33 @@ +import { build, context } from 'esbuild' + +const isWatch = process.argv.includes('--watch') + +const options = { + entryPoints: ['src/index.ts'], + bundle: true, + format: 'iife', + globalName: 'CopilotChat', + outfile: 'dist/copilot-chat.js', + platform: 'browser', + target: ['es2020'], + sourcemap: true, + minify: !isWatch, + conditions: ['browser', 'import'], + define: { + 'process.env.NODE_ENV': '"production"', + }, + footer: { + js: 'if(typeof window!=="undefined")window.CopilotChat=CopilotChat;', + }, +} + +if (isWatch) { + const ctx = await context(options) + await ctx.watch() + console.log('Watching for changes...') +} else { + const result = await build(options) + const fs = await import('fs') + const stat = fs.statSync('dist/copilot-chat.js') + console.log(`Build complete: dist/copilot-chat.js (${(stat.size / 1024).toFixed(1)} KB)`) +} diff --git a/ui/embed/zendesk-widget/package.json b/ui/embed/zendesk-widget/package.json new file mode 100644 index 00000000..bfc2e2da --- /dev/null +++ b/ui/embed/zendesk-widget/package.json @@ -0,0 +1,18 @@ +{ + "name": "zendesk-copilot-chat", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "node esbuild.config.mjs", + "dev": "node esbuild.config.mjs --watch", + "serve": "npx serve . -l 5500" + }, + "dependencies": { + "@microsoft/agents-copilotstudio-client": "^1.2.3" + }, + "devDependencies": { + "esbuild": "^0.25.0", + "typescript": "^5.7.0" + } +} \ No newline at end of file diff --git a/ui/embed/zendesk-widget/src/auth.ts b/ui/embed/zendesk-widget/src/auth.ts new file mode 100644 index 00000000..bb07551e --- /dev/null +++ b/ui/embed/zendesk-widget/src/auth.ts @@ -0,0 +1,71 @@ +import { CopilotStudioClient } from '@microsoft/agents-copilotstudio-client' +import type { ConnectionSettings } from '@microsoft/agents-copilotstudio-client' + +// MSAL is loaded via CDN — reference as global +declare const msal: { + PublicClientApplication: new (config: unknown) => MsalInstance + InteractionRequiredAuthError: new (...args: unknown[]) => Error +} + +interface MsalInstance { + initialize(): Promise + getAllAccounts(): Promise> + acquireTokenSilent(request: unknown): Promise<{ accessToken: string }> + ssoSilent(request: unknown): Promise<{ accessToken: string }> + loginPopup(request: unknown): Promise<{ accessToken: string }> +} + +let cachedMsalInstance: MsalInstance | null = null + +export async function acquireToken( + settings: ConnectionSettings, + redirectUri?: string +): Promise { + if (!cachedMsalInstance) { + cachedMsalInstance = new msal.PublicClientApplication({ + auth: { + clientId: settings.appClientId, + authority: `https://login.microsoftonline.com/${settings.tenantId}`, + }, + }) + await cachedMsalInstance.initialize() + } + + const loginRequest = { + scopes: [CopilotStudioClient.scopeFromSettings(settings)], + redirectUri: redirectUri || window.location.origin, + } + + // 1. Try cached refresh token (no UI, instant) + try { + const accounts = await cachedMsalInstance.getAllAccounts() + if (accounts.length > 0) { + const response = await cachedMsalInstance.acquireTokenSilent({ + ...loginRequest, + account: accounts[0], + }) + return response.accessToken + } + } catch (e: unknown) { + if (!(e instanceof msal.InteractionRequiredAuthError)) { + throw e + } + } + + // 2. Try SSO via hidden iframe (no UI, uses existing Entra ID session) + // Use a short timeout so the browser's user-gesture window (~5s in Chrome) + // is still valid for the loginPopup fallback if ssoSilent fails. + try { + const response = await cachedMsalInstance.ssoSilent({ + ...loginRequest, + timeout: 2000, + }) + return response.accessToken + } catch { + // Expected to fail if no active session — fall through to popup + } + + // 3. Last resort: interactive popup (triggered from user click) + const response = await cachedMsalInstance.loginPopup(loginRequest) + return response.accessToken +} diff --git a/ui/embed/zendesk-widget/src/bubble.ts b/ui/embed/zendesk-widget/src/bubble.ts new file mode 100644 index 00000000..e52fc29a --- /dev/null +++ b/ui/embed/zendesk-widget/src/bubble.ts @@ -0,0 +1,201 @@ +import type { CopilotChatConfig } from './config' +import { DEFAULT_CONFIG } from './config' +import { initChat, type ChatHandle } from './chat' + +const CHAT_ICON = `` +const CLOSE_ICON = `` +const REFRESH_ICON = `` + +function headerButton(innerHTML: string, ariaLabel: string): HTMLButtonElement { + const btn = document.createElement('button') + btn.innerHTML = innerHTML + btn.setAttribute('aria-label', ariaLabel) + Object.assign(btn.style, { + background: 'none', + border: 'none', + color: 'white', + cursor: 'pointer', + padding: '4px', + display: 'flex', + alignItems: 'center', + borderRadius: '4px', + }) + btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(255,255,255,0.15)' }) + btn.addEventListener('mouseleave', () => { btn.style.background = 'none' }) + return btn +} + +export function createBubble(container: HTMLElement, config: CopilotChatConfig): void { + const c = { ...DEFAULT_CONFIG, ...config } + + // Wrapper — fixed position, holds bubble + panel + const wrapper = document.createElement('div') + wrapper.setAttribute('data-copilot-chat', 'root') + Object.assign(wrapper.style, { + position: 'fixed', + [c.position === 'bottom-left' ? 'left' : 'right']: '20px', + bottom: '20px', + zIndex: String(c.zIndex), + fontFamily: `'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif`, + }) + + // Panel + const panel = document.createElement('div') + Object.assign(panel.style, { + display: 'none', + flexDirection: 'column', + width: c.panelWidth, + height: c.panelHeight, + maxHeight: 'calc(100vh - 120px)', + borderRadius: '8px', + overflow: 'hidden', + boxShadow: '0 4px 16px rgba(0,0,0,0.12)', + background: '#fff', + }) + + // Header + const header = document.createElement('div') + Object.assign(header.style, { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '12px 16px', + background: c.headerColor, + color: 'white', + fontSize: '14px', + fontWeight: '600', + flexShrink: '0', + }) + + const title = document.createElement('span') + title.textContent = c.headerTitle! + + const headerActions = document.createElement('div') + Object.assign(headerActions.style, { display: 'flex', gap: '4px', alignItems: 'center' }) + + const refreshBtn = headerButton(REFRESH_ICON, 'New conversation') + const closeBtn = headerButton(CLOSE_ICON, 'Close chat') + + headerActions.append(refreshBtn, closeBtn) + header.append(title, headerActions) + + // Status bar + const statusBar = document.createElement('div') + Object.assign(statusBar.style, { + display: 'none', + padding: '8px 16px', + fontSize: '12px', + color: '#666', + background: '#f8f8f8', + borderBottom: '1px solid #eee', + flexShrink: '0', + }) + + // WebChat container + const webchatContainer = document.createElement('div') + Object.assign(webchatContainer.style, { + flex: '1', + overflow: 'hidden', + minHeight: '0', + }) + + panel.append(header, statusBar, webchatContainer) + + // Bubble button + const bubble = document.createElement('button') + bubble.innerHTML = CHAT_ICON + bubble.setAttribute('aria-label', 'Open chat') + Object.assign(bubble.style, { + width: '56px', + height: '56px', + borderRadius: '50%', + background: c.bubbleColor, + color: 'white', + border: 'none', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + boxShadow: '0 2px 8px rgba(0,0,0,0.15)', + transition: 'transform 0.2s, box-shadow 0.2s', + }) + bubble.addEventListener('mouseenter', () => { + bubble.style.transform = 'scale(1.05)' + bubble.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)' + }) + bubble.addEventListener('mouseleave', () => { + bubble.style.transform = 'scale(1)' + bubble.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)' + }) + + wrapper.append(panel, bubble) + container.appendChild(wrapper) + + // State + let isOpen = false + let chatHandle: ChatHandle | null = null + + const statusMessages: Record = { + authenticating: 'Signing in...', + connecting: 'Connecting to agent...', + rendering: 'Loading chat...', + connected: '', + error: '', + } + + function onStatus(status: string) { + statusBar.textContent = statusMessages[status] || status + if (status === 'connected') { + statusBar.style.display = 'none' + const child = webchatContainer.firstElementChild as HTMLElement | null + if (child) child.style.height = '100%' + } + } + + function startChat() { + statusBar.style.display = 'block' + statusBar.style.color = '#666' + statusBar.textContent = 'Connecting...' + + initChat(webchatContainer, config, onStatus) + .then((handle) => { + chatHandle = handle + }) + .catch((err: Error) => { + console.error('CopilotChat: initialization failed', err) + statusBar.textContent = `Error: ${err.message}` + statusBar.style.color = '#d32f2f' + }) + } + + function toggle() { + isOpen = !isOpen + panel.style.display = isOpen ? 'flex' : 'none' + bubble.style.display = isOpen ? 'none' : 'flex' + + if (isOpen && !chatHandle) { + startChat() + } + } + + function refreshConversation() { + if (!chatHandle) return + statusBar.style.display = 'block' + statusBar.style.color = '#666' + statusBar.textContent = 'Starting new conversation...' + + chatHandle.refresh() + .then(() => { + statusBar.style.display = 'none' + }) + .catch((err: Error) => { + console.error('CopilotChat: refresh failed', err) + statusBar.textContent = `Error: ${err.message}` + statusBar.style.color = '#d32f2f' + }) + } + + bubble.addEventListener('click', toggle) + closeBtn.addEventListener('click', toggle) + refreshBtn.addEventListener('click', refreshConversation) +} diff --git a/ui/embed/zendesk-widget/src/chat.ts b/ui/embed/zendesk-widget/src/chat.ts new file mode 100644 index 00000000..d6c63b03 --- /dev/null +++ b/ui/embed/zendesk-widget/src/chat.ts @@ -0,0 +1,118 @@ +import { + ConnectionSettings, + CopilotStudioClient, + CopilotStudioWebChat, +} from '@microsoft/agents-copilotstudio-client' +import { acquireToken } from './auth' +import type { CopilotChatConfig } from './config' + +// WebChat is loaded via CDN — reference as global +declare const WebChat: { + renderWebChat: (options: Record, element: HTMLElement) => void + createStore: ( + initialState: Record, + middleware: (store: unknown) => (next: (action: unknown) => unknown) => (action: unknown) => unknown + ) => unknown +} + +export type StatusCallback = (status: 'authenticating' | 'connecting' | 'rendering' | 'connected' | 'error') => void + +export interface ChatHandle { + end: () => void + refresh: () => Promise +} + +export async function initChat( + container: HTMLElement, + config: CopilotChatConfig, + onStatus?: StatusCallback +): Promise { + const settings = new ConnectionSettings({ + environmentId: config.environmentId, + agentIdentifier: config.agentIdentifier, + tenantId: config.tenantId, + appClientId: config.appClientId, + cloud: config.cloud || 'Prod', + }) + + if (config.debug) { + window.localStorage.debug = 'copilot-studio:*' + } + + onStatus?.('authenticating') + const token = await acquireToken(settings, config.redirectUri) + + onStatus?.('connecting') + const client = new CopilotStudioClient(settings, token) + + const styleOptions = { + // Hide upload + hideUploadButton: true, + + // Avatars + botAvatarInitials: 'AI', + botAvatarBackgroundColor: '#1b3e4f', + userAvatarInitials: 'Me', + userAvatarBackgroundColor: '#62717b', + + // Bubble styles — bot messages + bubbleBackground: '#f1f3f5', + bubbleBorderColor: 'transparent', + bubbleBorderRadius: 8, + bubbleTextColor: '#2e3338', + + // Bubble styles — user messages + bubbleFromUserBackground: '#1b3e4f', + bubbleFromUserBorderColor: 'transparent', + bubbleFromUserBorderRadius: 8, + bubbleFromUserTextColor: '#ffffff', + + // Typography + primaryFont: "'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", + + // Send box + sendBoxBackground: '#ffffff', + sendBoxTextColor: '#2e3338', + sendBoxBorderTop: '1px solid #e0e0e0', + sendBoxButtonColor: '#1b3e4f', + sendBoxButtonColorOnHover: '#294d5e', + sendBoxPlaceholderColor: '#8c959a', + + // Suggested actions + suggestedActionBackgroundColor: '#ffffff', + suggestedActionBorderColor: '#1b3e4f', + suggestedActionTextColor: '#1b3e4f', + suggestedActionBorderRadius: 16, + + // General + rootHeight: '100%', + backgroundColor: '#ffffff', + timestampColor: '#8c959a', + + // User overrides + ...config.styleOptions, + } + + function render() { + const directLine = CopilotStudioWebChat.createConnection(client, { + showTyping: true, + }) + const store = WebChat.createStore({}, () => (next) => (action) => next(action)) + + WebChat.renderWebChat({ directLine, store, styleOptions }, container) + onStatus?.('connected') + return directLine + } + + let directLine = render() + + return { + end() { + directLine.end() + }, + async refresh() { + directLine.end() + directLine = render() + }, + } +} diff --git a/ui/embed/zendesk-widget/src/config.ts b/ui/embed/zendesk-widget/src/config.ts new file mode 100644 index 00000000..06fa8759 --- /dev/null +++ b/ui/embed/zendesk-widget/src/config.ts @@ -0,0 +1,36 @@ +export interface CopilotChatConfig { + // Agent connection (required) + environmentId: string + agentIdentifier: string + tenantId: string + appClientId: string + cloud?: string + + // UI customization (optional) + headerTitle?: string + bubbleColor?: string + headerColor?: string + panelWidth?: string + panelHeight?: string + position?: 'bottom-right' | 'bottom-left' + zIndex?: number + debug?: boolean + + // WebChat styleOptions passthrough + styleOptions?: Record + + // Auth + redirectUri?: string +} + +export const DEFAULT_CONFIG = { + cloud: 'Prod', + headerTitle: 'Chat with us', + bubbleColor: '#1b3e4f', + headerColor: '#1b3e4f', + panelWidth: '420px', + panelHeight: '600px', + position: 'bottom-right' as const, + zIndex: 9999, + debug: false, +} diff --git a/ui/embed/zendesk-widget/src/index.ts b/ui/embed/zendesk-widget/src/index.ts new file mode 100644 index 00000000..e488efba --- /dev/null +++ b/ui/embed/zendesk-widget/src/index.ts @@ -0,0 +1,33 @@ +import { createBubble } from './bubble' +import type { CopilotChatConfig } from './config' + +export type { CopilotChatConfig } from './config' + +let initialized = false + +/** + * Initialize the Copilot Chat floating bubble. + * @param container - DOM element to attach the bubble to (typically document.body) + * @param config - Agent connection settings and UI options + */ +export function init(container: HTMLElement, config: CopilotChatConfig): void { + if (initialized) { + console.warn('CopilotChat.init() already called — ignoring duplicate') + return + } + + const required: (keyof CopilotChatConfig)[] = [ + 'environmentId', + 'agentIdentifier', + 'tenantId', + 'appClientId', + ] + for (const key of required) { + if (!config[key]) { + throw new Error(`CopilotChat.init: missing required config "${key}"`) + } + } + + initialized = true + createBubble(container, config) +} diff --git a/ui/embed/zendesk-widget/test-page/config.sample.js b/ui/embed/zendesk-widget/test-page/config.sample.js new file mode 100644 index 00000000..dad36a80 --- /dev/null +++ b/ui/embed/zendesk-widget/test-page/config.sample.js @@ -0,0 +1,10 @@ +// Copy this file to config.js and fill in your agent settings. +// config.js is gitignored — do not commit credentials. +window.__COPILOT_CONFIG__ = { + environmentId: 'Default-00000000-0000-0000-0000-000000000000', + agentIdentifier: 'cr000_myAgent', + tenantId: '00000000-0000-0000-0000-000000000000', + appClientId: '00000000-0000-0000-0000-000000000000', + headerTitle: 'Chat with us', + // debug: true, // Enables SDK console logging +}; diff --git a/ui/embed/zendesk-widget/test-page/index.html b/ui/embed/zendesk-widget/test-page/index.html new file mode 100644 index 00000000..7854d167 --- /dev/null +++ b/ui/embed/zendesk-widget/test-page/index.html @@ -0,0 +1,108 @@ + + + + + + + Copilot Chat - Local Test + + + + + + + + + + + + +
Zendesk Help Center (Simulated)
+ +
+
+

Help Center

+

Welcome to the Help Center. Browse articles below or use the + chat assistant in the bottom-right corner for help.

+
+ +
+

Knowledge Base

+

Search our knowledge base for solutions to common issues. Topics include + account setup, billing, product features, and more.

+
+ +
+

Submit a Request

+

Can't find what you're looking for? Submit a support request and our + team will get back to you.

+
+
+ + + + + + \ No newline at end of file diff --git a/ui/embed/zendesk-widget/tsconfig.json b/ui/embed/zendesk-widget/tsconfig.json new file mode 100644 index 00000000..539b8faf --- /dev/null +++ b/ui/embed/zendesk-widget/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": [ + "ES2020", + "DOM" + ], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": true + }, + "include": [ + "src/**/*" + ] +} \ No newline at end of file