diff --git a/.changeset/use-aurora-glass-widget.md b/.changeset/use-aurora-glass-widget.md new file mode 100644 index 0000000..ab5cffb --- /dev/null +++ b/.changeset/use-aurora-glass-widget.md @@ -0,0 +1,10 @@ +--- +"@smooai/smooth-operator": patch +--- + +local flavor: serve the canonical `@smooai/chat-widget` (Aurora Glass) bundle + +The local-flavor server now vendors and serves the published **`@smooai/chat-widget`** +(Aurora Glass) standalone bundle instead of a parallel copy of the widget. One canonical +public widget, consumed — not two. Same `` element + `endpoint`/`agent-id` +attributes, so it's a drop-in for the host page. diff --git a/rust/smooth-operator-server/assets/README.md b/rust/smooth-operator-server/assets/README.md index 1cae494..126196b 100644 --- a/rust/smooth-operator-server/assets/README.md +++ b/rust/smooth-operator-server/assets/README.md @@ -1,10 +1,17 @@ # Vendored widget assets (local deployment flavor) -`chat-widget.iife.js` is the prebuilt standalone widget bundle from the -published **`@smooai/smooth-operator`** npm package (the `` -web component, `dist/widget/chat-widget.iife.js`). It is vendored here so the -local deployment flavor can serve the official widget **offline**, with no Node -build step. +`chat-widget.iife.js` is the prebuilt standalone bundle of the **`@smooai/chat-widget`** +npm package — the canonical public **Aurora Glass** widget (the `` +web component, `dist/chat-widget.global.js`, with the smooth-operator protocol client +inlined). It is vendored here so the local deployment flavor can serve the official +widget **offline**, with no Node build step. + +> Why `@smooai/chat-widget` and not `@smooai/smooth-operator/widget`: `@smooai/chat-widget` +> is the single canonical public widget (Aurora Glass redesign + OTP/HITL UI). The +> `@smooai/smooth-operator` SDK previously shipped a parallel copy of the same web +> component; we consume the published Aurora Glass package directly so there is one +> widget, not two. Same `` element + `endpoint`/`agent-id` attributes, +> so it's a drop-in. `widget-index.html` is the host page the local flavor serves at `/`; it loads the bundle and points a `` at this server's own `/ws`, with @@ -16,12 +23,12 @@ widget routes. ## Keeping it current -Pinned to `@smooai/smooth-operator@1.2.0`. To refresh after a widget release: +Pinned to `@smooai/chat-widget@0.5.0`. To refresh after a widget release: ```sh -npm pack @smooai/smooth-operator -tar xzf smooai-smooth-operator-*.tgz -C /tmp package/dist/widget/chat-widget.iife.js -cp /tmp/package/dist/widget/chat-widget.iife.js chat-widget.iife.js +npm pack @smooai/chat-widget +tar xzf smooai-chat-widget-*.tgz -C /tmp package/dist/chat-widget.global.js +cp /tmp/package/dist/chat-widget.global.js chat-widget.iife.js ``` -(A CI step that does this on widget release would keep the two in lockstep.) +(A CI step that does this on `@smooai/chat-widget` release would keep the two in lockstep.) diff --git a/rust/smooth-operator-server/assets/chat-widget.iife.js b/rust/smooth-operator-server/assets/chat-widget.iife.js index 12b4b20..a8a37be 100644 --- a/rust/smooth-operator-server/assets/chat-widget.iife.js +++ b/rust/smooth-operator-server/assets/chat-widget.iife.js @@ -1,11 +1,15 @@ var SmoothAgentChat = (function(exports) { Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" }); - //#region src/widget/config.ts + //#region src/config.ts /** Resolve a partial config against the built-in defaults. */ function resolveConfig(config) { const theme = config.theme ?? {}; const primary = theme.primary ?? "#00a6a6"; const primaryText = theme.primaryText ?? "#f8fafc"; + const assistantBubble = theme.chatBubbleInbound ?? theme.assistantBubble ?? "#06134b"; + const assistantBubbleText = theme.chatBubbleInboundText ?? theme.assistantBubbleText ?? "#f8fafc"; + const userBubble = theme.chatBubbleOutbound ?? theme.userBubble ?? primary; + const userBubbleText = theme.chatBubbleOutboundText ?? theme.userBubbleText ?? primaryText; return { endpoint: config.endpoint, mode: config.mode ?? "popover", @@ -13,25 +17,47 @@ var SmoothAgentChat = (function(exports) { agentName: config.agentName ?? "Assistant", userName: config.userName, userEmail: config.userEmail, + userPhone: config.userPhone, placeholder: config.placeholder ?? "Type a message…", greeting: config.greeting ?? "Hi! How can I help you today?", connectionErrorMessage: config.connectionErrorMessage ?? "We couldn't reach the chat. Please try again in a moment.", startOpen: config.startOpen ?? false, + examplePrompts: (config.examplePrompts ?? []).filter((p) => p.trim().length > 0).slice(0, 5), + requireName: config.requireName ?? false, + requireEmail: config.requireEmail ?? false, + requirePhone: config.requirePhone ?? false, + allowAnonymous: config.allowAnonymous ?? false, theme: { text: theme.text ?? "#f8fafc", background: theme.background ?? "#040d30", primary, primaryText, - assistantBubble: theme.assistantBubble ?? "#06134b", - assistantBubbleText: theme.assistantBubbleText ?? "#f8fafc", - userBubble: theme.userBubble ?? primary, - userBubbleText: theme.userBubbleText ?? primaryText, - border: theme.border ?? "#0a1f7a" + secondary: theme.secondary ?? "#ff6b6c", + assistantBubble, + assistantBubbleText, + userBubble, + userBubbleText, + border: theme.border ?? "rgba(255, 255, 255, 0.1)" } }; } + /** + * Whether the pre-chat identity form should gate the conversation: at least one + * field is required and anonymous chat is not allowed. + */ + function needsUserInfo(resolved) { + return !resolved.allowAnonymous && (resolved.requireName || resolved.requireEmail || resolved.requirePhone); + } //#endregion - //#region src/transport.ts + //#region node_modules/.pnpm/@smooai+smooth-operator@0.2.0/node_modules/@smooai/smooth-operator/dist/transport.js + /** + * Transport abstraction for the client. + * + * The client is deliberately decoupled from any concrete WebSocket implementation + * so it can be unit-tested with a mock and run on Node, the browser, or a custom + * socket. A transport is anything that can send a string frame and surface + * incoming string frames + lifecycle events. + */ const WS_CONNECTING = 0; const WS_OPEN = 1; const WS_CLOSING = 2; @@ -148,7 +174,7 @@ var SmoothAgentChat = (function(exports) { } }; //#endregion - //#region src/types.ts + //#region node_modules/.pnpm/@smooai+smooth-operator@0.2.0/node_modules/@smooai/smooth-operator/dist/types.js /** Every server→client `type` discriminator value. */ const EVENT_TYPES = [ "immediate_response", @@ -169,7 +195,7 @@ var SmoothAgentChat = (function(exports) { return typeof frame === "object" && frame !== null && "type" in frame && typeof frame.type === "string" && EVENT_TYPES.includes(frame.type); } //#endregion - //#region src/client.ts + //#region node_modules/.pnpm/@smooai+smooth-operator@0.2.0/node_modules/@smooai/smooth-operator/dist/client.js /** * SmoothAgentClient — a minimal, idiomatic, transport-agnostic client for the * smooth-operator WebSocket protocol. @@ -507,7 +533,7 @@ var SmoothAgentChat = (function(exports) { throw new ProtocolError("UNEXPECTED_EVENT", `Expected immediate_response, got "${event.type}"`, event.requestId); } //#endregion - //#region src/widget/conversation.ts + //#region src/conversation.ts /** * ConversationController — the bridge between the widget UI and the * `@smooai/smooth-operator` protocol client. @@ -573,13 +599,53 @@ var SmoothAgentChat = (function(exports) { messages = []; status = "idle"; seq = 0; + /** Visitor identity, seeded from config and updated by the pre-chat form. */ + identity; + /** requestId of the in-flight turn — used to resume OTP / tool confirmations. */ + activeRequestId = null; + interrupt = null; constructor(config, events) { this.config = config; this.events = events; + this.identity = { + name: config.userName, + email: config.userEmail, + phone: config.userPhone + }; } get connectionStatus() { return this.status; } + /** Merge in visitor identity (from the pre-chat form). Applied on next connect. */ + setUserInfo(info) { + this.identity = { + ...this.identity, + ...info + }; + } + setInterrupt(interrupt) { + this.interrupt = interrupt; + this.events.onInterrupt?.(interrupt); + } + /** Submit an OTP code to resume the paused turn. No-op if not awaiting OTP. */ + verifyOtp(code) { + if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "otp") return; + this.client.verifyOtp({ + sessionId: this.sessionId, + requestId: this.activeRequestId, + code + }); + } + /** Approve or reject a pending tool write to resume the paused turn. */ + confirmTool(approved) { + if (!this.client || !this.sessionId || !this.activeRequestId || this.interrupt?.kind !== "confirm") return; + this.client.confirmToolAction({ + sessionId: this.sessionId, + requestId: this.activeRequestId, + approved + }); + this.setInterrupt(null); + } nextId(prefix) { this.seq += 1; return `${prefix}-${this.seq}-${Date.now().toString(36)}`; @@ -600,8 +666,9 @@ var SmoothAgentChat = (function(exports) { await this.client.connect(); const session = await this.client.createConversationSession({ agentId: this.config.agentId, - userName: this.config.userName, - userEmail: this.config.userEmail + userName: this.identity.name, + userEmail: this.identity.email, + ...this.identity.phone ? { metadata: { userPhone: this.identity.phone } } : {} }); this.sessionId = session.sessionId; this.setStatus("ready"); @@ -639,13 +706,14 @@ var SmoothAgentChat = (function(exports) { message: trimmed, stream: true }); + this.activeRequestId = turn.requestId; for await (const event of turn) if (event.type === "stream_token") { const token = event.token ?? event.data?.token ?? ""; if (token) { assistant.text += token; this.emitMessages(); } - } + } else this.handleTurnEvent(event); const inner = (await turn).data?.data; const finalText = extractFinalText(inner?.response); if (finalText && finalText.length > assistant.text.length) assistant.text = finalText; @@ -660,6 +728,55 @@ var SmoothAgentChat = (function(exports) { assistant.text = assistant.text ? `${assistant.text}\n\n${message}` : message; this.emitMessages(); this.setStatus("error", err instanceof Error ? err.message : String(err)); + } finally { + this.activeRequestId = null; + this.setInterrupt(null); + } + } + /** Map a non-token turn event (OTP / tool-confirmation lifecycle) to interrupt state. */ + handleTurnEvent(event) { + const inner = event.data?.data ?? {}; + const str = (v) => typeof v === "string" ? v : void 0; + const num = (v) => typeof v === "number" ? v : void 0; + switch (event.type) { + case "otp_verification_required": { + const channels = Array.isArray(inner.availableChannels) ? inner.availableChannels.filter((c) => c === "email" || c === "sms") : ["email"]; + this.setInterrupt({ + kind: "otp", + toolId: str(inner.toolId), + actionDescription: str(inner.actionDescription), + availableChannels: channels.length > 0 ? channels : ["email"] + }); + break; + } + case "otp_sent": + if (this.interrupt?.kind === "otp") this.setInterrupt({ + ...this.interrupt, + sent: { + channel: str(inner.channel), + maskedDestination: str(inner.maskedDestination) + }, + error: void 0 + }); + break; + case "otp_verified": + if (this.interrupt?.kind === "otp") this.setInterrupt(null); + break; + case "otp_invalid": + if (this.interrupt?.kind === "otp") this.setInterrupt({ + ...this.interrupt, + error: str(inner.message) ?? "That code was incorrect.", + attemptsRemaining: num(inner.attemptsRemaining) + }); + break; + case "write_confirmation_required": + this.setInterrupt({ + kind: "confirm", + toolId: str(inner.toolId), + actionDescription: str(inner.actionDescription) + }); + break; + default: break; } } /** Tear down the underlying client. */ @@ -667,11 +784,13 @@ var SmoothAgentChat = (function(exports) { this.client?.disconnect("widget closed"); this.client = null; this.sessionId = null; + this.activeRequestId = null; + this.setInterrupt(null); this.setStatus("closed"); } }; //#endregion - //#region src/widget/logo.ts + //#region src/logo.ts /** * The Smooth logo, inlined as an SVG string so the full-page header can render * it without a separate network fetch (the IIFE bundle is self-contained). @@ -681,15 +800,28 @@ var SmoothAgentChat = (function(exports) { */ const SMOOTH_LOGO_SVG = "\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n"; //#endregion - //#region src/widget/styles.ts + //#region src/styles.ts /** - * Render the widget's scoped stylesheet. All theme values are injected as CSS - * custom properties on `:host` so they can be overridden per-instance and so the - * styles below stay static. Kept deliberately framework-light — no Tailwind, no - * runtime CSS-in-JS; just a string the web component drops into its shadow root. + * Render the widget's scoped stylesheet — the "Aurora Glass" design system. + * + * Every brand value is injected as a CSS custom property on `:host` so a host + * page can override colors per-instance and the rules below stay static. Two + * extra tokens are *derived in CSS* from the brand vars so they adapt to any + * theme (light or dark) without the caller supplying them: * - * `mode` switches the host positioning + panel sizing between the floating - * popover (default) and the full-page layout (fills its container/viewport). + * --sac-primary-2 a darker shade of `primary`, used as the second stop of the + * launcher / send / user-bubble gradients (depth without a + * second brand input). + * --sac-surface-2 a faint wash derived from `text`, used for inset chrome + * (composer field, close button, source cards). On a dark + * panel it reads as a light overlay; on a light panel, dark. + * + * Deliberately framework-light: no Tailwind, no runtime CSS-in-JS — just a string + * the web component drops into its shadow root. Modern color features + * (`color-mix`) are used intentionally; the widget targets evergreen browsers. + * + * `mode` switches host positioning + panel sizing between the floating popover + * (default) and the full-page layout (fills its container/viewport). */ function buildStyles(theme, mode = "popover") { return ` @@ -704,56 +836,108 @@ var SmoothAgentChat = (function(exports) { --sac-user-bubble-text: ${theme.userBubbleText}; --sac-border: ${theme.border}; - ${mode === "fullpage" ? `/* Full-page: fill the host's box (the element should be sized by its - container, or it falls back to filling the viewport). */ + /* Derived tokens — adapt to any brand color without a second input. */ + --sac-primary-2: color-mix(in srgb, var(--sac-primary) 78%, #000 22%); + --sac-surface-2: color-mix(in srgb, var(--sac-text) 5%, transparent); + --sac-radius: 22px; + --sac-ease: cubic-bezier(.16, 1, .3, 1); + + ${mode === "fullpage" ? `/* Full-page: fill the host's box (sized by its container, else the viewport). */ display: block; position: relative; width: 100%; height: 100%; min-height: 100vh;` : `/* Popover: float in the bottom-right corner. */ position: fixed; - bottom: 20px; - right: 20px; + bottom: 24px; + right: 24px; z-index: 2147483000;`} font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; } * { box-sizing: border-box; } +/* ───────────────────────────── Launcher ───────────────────────────── */ .launcher { - width: 56px; - height: 56px; + position: relative; + width: 62px; + height: 62px; border-radius: 50%; border: none; cursor: pointer; - background: var(--sac-primary); + padding: 0; + background: radial-gradient(120% 120% at 30% 20%, + color-mix(in srgb, var(--sac-primary) 78%, #fff 22%) 0%, + var(--sac-primary) 42%, + var(--sac-primary-2) 130%); color: var(--sac-primary-text); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); display: flex; align-items: center; justify-content: center; - font-size: 24px; - transition: transform 0.15s ease; + box-shadow: + 0 1px 0 rgba(255, 255, 255, .25) inset, + 0 10px 24px -6px color-mix(in srgb, var(--sac-primary) 55%, transparent), + 0 18px 50px -12px rgba(0, 0, 0, .6); + transition: transform .45s var(--sac-ease), box-shadow .45s var(--sac-ease), opacity .3s ease; + isolation: isolate; +} +/* Breathing presence ring. */ +.launcher::before { + content: ''; + position: absolute; + inset: -6px; + border-radius: 50%; + z-index: -1; + background: radial-gradient(closest-side, color-mix(in srgb, var(--sac-primary) 45%, transparent), transparent 75%); + animation: sac-breathe 3.4s ease-in-out infinite; } -.launcher:hover { transform: scale(1.05); } +@keyframes sac-breathe { 0%, 100% { transform: scale(1); opacity: .55 } 50% { transform: scale(1.28); opacity: 0 } } +.launcher:hover { + transform: translateY(-3px) scale(1.06); + box-shadow: + 0 1px 0 rgba(255, 255, 255, .3) inset, + 0 16px 30px -6px color-mix(in srgb, var(--sac-primary) 60%, transparent), + 0 26px 60px -14px rgba(0, 0, 0, .7); +} +.launcher:active { transform: translateY(-1px) scale(.98); } +.launcher .ico { width: 27px; height: 27px; display: block; transition: transform .4s var(--sac-ease); } +.launcher:hover .ico { transform: rotate(-6deg) scale(1.04); } +.launcher.hidden { opacity: 0; transform: scale(.4) translateY(10px); pointer-events: none; } +/* ─────────────────────────────── Panel ────────────────────────────── */ .panel { - width: 360px; + width: 390px; max-width: calc(100vw - 40px); - height: 520px; - max-height: calc(100vh - 40px); + height: 600px; + max-height: calc(100vh - 56px); display: flex; flex-direction: column; - background: var(--sac-bg); - color: var(--sac-text); - border: 1px solid var(--sac-border); - border-radius: 14px; overflow: hidden; - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); + border-radius: var(--sac-radius); + background: linear-gradient(180deg, color-mix(in srgb, var(--sac-bg) 92%, #fff 8%) 0%, var(--sac-bg) 22%); + color: var(--sac-text); + border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent); + box-shadow: + 0 0 0 1px rgba(255, 255, 255, .03) inset, + 0 40px 80px -24px rgba(0, 0, 0, .65), + 0 16px 40px -20px rgba(0, 0, 0, .5); + transform-origin: bottom right; + animation: sac-panel-in .5s var(--sac-ease) both; + position: relative; } - -/* Full-page: the panel becomes the whole surface — no floating box, no shadow, - no rounded corners; it fills the host. */ +@keyframes sac-panel-in { from { opacity: 0; transform: translateY(16px) scale(.92) } to { opacity: 1; transform: none } } +.panel.hidden { display: none; } +/* Ambient brand glow bleeding from the top of the panel. */ +.panel::before { + content: ''; + position: absolute; + left: 0; right: 0; top: 0; + height: 140px; + pointer-events: none; + background: radial-gradient(120% 100% at 50% 0%, color-mix(in srgb, var(--sac-primary) 22%, transparent), transparent 70%); +} +/* Full-page: the panel becomes the whole surface. */ .panel.fullpage { width: 100%; height: 100%; @@ -763,186 +947,431 @@ var SmoothAgentChat = (function(exports) { border: none; border-radius: 0; box-shadow: none; + animation: none; } +/* ─────────────────────────────── Header ───────────────────────────── */ .header { + position: relative; + display: flex; + align-items: center; + gap: 12px; + padding: 16px 16px 14px; +} +.avatar { + width: 40px; + height: 40px; + border-radius: 13px; + flex: none; + background: linear-gradient(140deg, var(--sac-primary), var(--sac-primary-2)); display: flex; align-items: center; - justify-content: space-between; - padding: 12px 14px; - background: var(--sac-primary); + justify-content: center; color: var(--sac-primary-text); + box-shadow: + 0 6px 16px -6px color-mix(in srgb, var(--sac-primary) 60%, transparent), + 0 1px 0 rgba(255, 255, 255, .25) inset; } -.header .brand { display: flex; align-items: center; gap: 10px; min-width: 0; } -.header .logo { height: 24px; width: auto; display: block; } -.header .title { font-weight: 600; font-size: 15px; } -.header .status { font-size: 11px; opacity: 0.85; } -.header .powered { - font-size: 10px; - opacity: 0.7; - letter-spacing: 0.02em; +.avatar svg { width: 22px; height: 22px; } +.avatar .logo-wrap { display: flex; } +.avatar .logo { height: 22px; width: auto; display: block; } +.meta { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 2px; } +.title { font-weight: 650; font-size: 15.5px; letter-spacing: -.01em; line-height: 1.1; } +.status { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: color-mix(in srgb, var(--sac-text) 62%, transparent); } -.header .close { - background: transparent; +.dot { + width: 7px; height: 7px; + border-radius: 50%; + flex: none; + background: #34d399; + color: #34d399; + box-shadow: 0 0 0 0 rgba(52, 211, 153, .6); + animation: sac-pulse 2.4s ease-out infinite; +} +.dot.connecting { background: #fbbf24; color: #fbbf24; animation: sac-pulse 1.1s ease-out infinite; } +.dot.error { background: #f87171; color: #f87171; animation: none; } +.dot.off { background: #94a3b8; color: #94a3b8; animation: none; } +@keyframes sac-pulse { + 0% { box-shadow: 0 0 0 0 color-mix(in srgb, currentColor 55%, transparent) } + 70% { box-shadow: 0 0 0 6px transparent } + 100% { box-shadow: 0 0 0 0 transparent } +} +.close { + margin-left: auto; + width: 32px; height: 32px; + border-radius: 10px; border: none; - color: inherit; cursor: pointer; - font-size: 18px; - line-height: 1; - padding: 4px; + background: var(--sac-surface-2); + color: inherit; + display: flex; + align-items: center; + justify-content: center; + transition: background .2s ease, transform .2s ease; } +.close:hover { background: color-mix(in srgb, var(--sac-text) 12%, transparent); transform: translateY(1px); } +.close svg { width: 16px; height: 16px; opacity: .8; } +.powered { margin-left: auto; font-size: 10.5px; letter-spacing: .02em; opacity: .6; } +.header-sep { height: 1px; margin: 0 16px; background: linear-gradient(90deg, transparent, var(--sac-border), transparent); } -/* Full-page header: taller, logo-led, centered max-width content row. */ -.panel.fullpage .header { padding: 14px 20px; } -.panel.fullpage .logo { height: 30px; } +/* Full-page header: taller, logo-led, no close. */ +.panel.fullpage .header { padding: 18px 22px; } +.panel.fullpage .avatar { width: 44px; height: 44px; } +.panel.fullpage .avatar .logo { height: 26px; } +/* ────────────────────────────── Messages ──────────────────────────── */ .messages { flex: 1; overflow-y: auto; - padding: 14px; + padding: 18px 16px 8px; display: flex; flex-direction: column; - gap: 10px; + gap: 12px; + scroll-behavior: smooth; +} +.messages::-webkit-scrollbar { width: 8px; } +.messages::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--sac-text) 14%, transparent); + border-radius: 99px; + border: 2px solid transparent; + background-clip: padding-box; +} +.messages::-webkit-scrollbar-thumb:hover { + background: color-mix(in srgb, var(--sac-text) 24%, transparent); + background-clip: padding-box; } +.row { + display: flex; + gap: 9px; + max-width: 88%; + animation: sac-msg-in .42s var(--sac-ease) both; +} +@keyframes sac-msg-in { from { opacity: 0; transform: translateY(8px) } to { opacity: 1; transform: none } } +.row.user { align-self: flex-end; flex-direction: row-reverse; } +.row.assistant { align-self: flex-start; } +.mini { + width: 26px; height: 26px; + border-radius: 9px; + flex: none; + align-self: flex-end; + background: linear-gradient(140deg, var(--sac-primary), var(--sac-primary-2)); + display: flex; + align-items: center; + justify-content: center; + color: var(--sac-primary-text); +} +.mini svg { width: 15px; height: 15px; } + .bubble { - max-width: 80%; - padding: 9px 12px; - border-radius: 12px; + padding: 11px 14px; + border-radius: 16px; font-size: 14px; - line-height: 1.4; + line-height: 1.5; white-space: pre-wrap; word-break: break-word; + position: relative; } .bubble.assistant { - align-self: flex-start; - background: var(--sac-assistant-bubble); + background: linear-gradient(180deg, color-mix(in srgb, var(--sac-assistant-bubble) 86%, #fff 5%), var(--sac-assistant-bubble)); color: var(--sac-assistant-bubble-text); - border-bottom-left-radius: 4px; + border: 1px solid color-mix(in srgb, var(--sac-text) 8%, transparent); + border-bottom-left-radius: 5px; + box-shadow: 0 2px 8px -4px rgba(0, 0, 0, .4); } .bubble.user { - align-self: flex-end; - background: var(--sac-user-bubble); + background: linear-gradient(165deg, + color-mix(in srgb, var(--sac-user-bubble) 88%, #fff 12%), + var(--sac-user-bubble) 60%, + color-mix(in srgb, var(--sac-user-bubble) 80%, var(--sac-primary-2) 20%)); color: var(--sac-user-bubble-text); - border-bottom-right-radius: 4px; + border-bottom-right-radius: 5px; + box-shadow: 0 6px 16px -8px color-mix(in srgb, var(--sac-primary) 50%, transparent); +} +.bubble.greeting { + background: transparent; + border: 1px dashed color-mix(in srgb, var(--sac-text) 14%, transparent); + color: color-mix(in srgb, var(--sac-text) 80%, transparent); + box-shadow: none; } -.bubble.greeting { opacity: 0.85; font-style: italic; } -/* Full-page: center the conversation in a readable column and let bubbles - breathe a little wider. */ -.panel.fullpage .messages { - padding: 24px 20px; - align-items: stretch; +/* Typing indicator (assistant bubble with no text yet). */ +.bubble.typing { display: flex; gap: 4px; padding: 14px 15px; } +.bubble.typing i { + width: 7px; height: 7px; + border-radius: 50%; + background: color-mix(in srgb, var(--sac-assistant-bubble-text) 55%, transparent); + animation: sac-typing 1.3s ease-in-out infinite; } -.panel.fullpage .messages > * { - width: 100%; - max-width: 760px; - margin-left: auto; - margin-right: auto; +.bubble.typing i:nth-child(2) { animation-delay: .18s; } +.bubble.typing i:nth-child(3) { animation-delay: .36s; } +@keyframes sac-typing { 0%, 60%, 100% { transform: translateY(0); opacity: .4 } 30% { transform: translateY(-5px); opacity: 1 } } + +.cursor::after { + content: ''; + display: inline-block; + width: 2px; height: 1.05em; + margin-left: 2px; + vertical-align: -2px; + border-radius: 2px; + background: currentColor; + animation: sac-blink 1s steps(2, start) infinite; } -.panel.fullpage .bubble { max-width: 100%; } -.panel.fullpage .bubble.user { align-self: flex-end; max-width: 80%; margin-right: auto; } -.panel.fullpage .bubble.assistant { align-self: flex-start; max-width: 100%; } +@keyframes sac-blink { to { opacity: 0 } } + +/* Full-page: center the conversation in a readable column. */ +.panel.fullpage .messages { padding: 26px 20px; } +.panel.fullpage .row { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; } +.panel.fullpage .row.user { max-width: 80%; margin-right: 0; } -/* Sources panel — rendered under an assistant bubble whose terminal - eventual_response carried citations. */ +/* ───────────────── Sources (grounding citations) ──────────────────── */ .sources { align-self: flex-start; - max-width: 80%; - margin-top: -4px; - font-size: 12.5px; - color: var(--sac-text); + max-width: 88%; + margin: -4px 0 0 35px; } -.panel.fullpage .sources { max-width: 100%; } -.sources details { background: transparent; } +.panel.fullpage .sources { max-width: 760px; width: 100%; margin-left: auto; margin-right: auto; } .sources summary { cursor: pointer; - font-weight: 600; - opacity: 0.85; list-style: none; + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 12px; + font-weight: 600; + color: color-mix(in srgb, var(--sac-text) 70%, transparent); + padding: 5px 0; user-select: none; - padding: 2px 0; } .sources summary::-webkit-details-marker { display: none; } -.sources summary::before { - content: '▸'; - display: inline-block; - margin-right: 6px; - transition: transform 0.15s ease; -} -.sources details[open] summary::before { transform: rotate(90deg); } -.sources ol { - margin: 6px 0 0; - padding-left: 0; - list-style: none; - display: flex; - flex-direction: column; - gap: 8px; +.sources .chev { transition: transform .2s var(--sac-ease); flex: none; } +.sources details[open] .chev { transform: rotate(90deg); } +.sources .count { + background: color-mix(in srgb, var(--sac-primary) 18%, transparent); + color: color-mix(in srgb, var(--sac-primary) 92%, #fff); + font-size: 10.5px; + font-weight: 700; + padding: 1px 7px; + border-radius: 99px; } +.sources ol { list-style: none; margin: 6px 0 2px; padding: 0; display: flex; flex-direction: column; gap: 7px; } .sources li { + background: var(--sac-surface-2); + border: 1px solid color-mix(in srgb, var(--sac-border) 70%, transparent); border-left: 2px solid var(--sac-primary); - padding-left: 10px; + border-radius: 9px; + padding: 8px 10px; } .sources .src-title { - color: var(--sac-primary); - text-decoration: none; + color: color-mix(in srgb, var(--sac-primary) 92%, #fff); font-weight: 600; + font-size: 12.5px; + text-decoration: none; word-break: break-word; } .sources a.src-title:hover { text-decoration: underline; } -.sources span.src-title { color: var(--sac-text); opacity: 0.95; } +.sources span.src-title { color: var(--sac-text); opacity: .95; } .sources .src-snippet { display: block; - margin-top: 2px; - opacity: 0.7; - line-height: 1.4; + margin-top: 3px; + font-size: 11.5px; + line-height: 1.45; + color: color-mix(in srgb, var(--sac-text) 55%, transparent); white-space: normal; } -.cursor::after { - content: '▋'; - margin-left: 1px; - animation: sac-blink 1s steps(2, start) infinite; -} -@keyframes sac-blink { to { visibility: hidden; } } - +/* ────────────────────────────── Composer ──────────────────────────── */ +.composer-wrap { padding: 12px 14px calc(12px + env(safe-area-inset-bottom)); } .composer { display: flex; + align-items: flex-end; gap: 8px; - padding: 10px; - border-top: 1px solid var(--sac-border); + padding: 7px 7px 7px 14px; + border-radius: 18px; + background: var(--sac-surface-2); + border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent); + transition: border-color .25s ease, box-shadow .25s ease, background .25s ease; +} +.composer:focus-within { + border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent); } .composer textarea { flex: 1; resize: none; - border: 1px solid var(--sac-border); - border-radius: 8px; - padding: 8px 10px; + border: none; + background: transparent; + color: var(--sac-text); font-family: inherit; font-size: 14px; - background: transparent; + line-height: 1.45; + max-height: 120px; + padding: 6px 0; + outline: none; +} +.composer textarea::placeholder { color: color-mix(in srgb, var(--sac-text) 42%, transparent); } +.send { + width: 38px; height: 38px; + flex: none; + border: none; + border-radius: 13px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2)); + color: var(--sac-primary-text); + box-shadow: + 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent), + 0 1px 0 rgba(255, 255, 255, .25) inset; + transition: transform .2s var(--sac-ease), box-shadow .2s var(--sac-ease), opacity .2s ease; +} +.send svg { width: 18px; height: 18px; } +.send:hover { transform: translateY(-1px) scale(1.05); } +.send:active { transform: scale(.94); } +.send:disabled { opacity: .4; cursor: default; transform: none; box-shadow: none; } +.footer { + text-align: center; + margin-top: 9px; + font-size: 10.5px; + letter-spacing: .04em; + color: color-mix(in srgb, var(--sac-text) 38%, transparent); +} +.footer b { font-weight: 600; color: color-mix(in srgb, var(--sac-text) 55%, transparent); } + +/* ─────────────────── Pre-chat identity form ───────────────────────── */ +.prechat { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 18px; padding: 22px 20px; } +.pc-head { text-align: center; } +.pc-title { font-size: 17px; font-weight: 650; letter-spacing: -.01em; } +.pc-sub { margin-top: 4px; font-size: 13px; color: color-mix(in srgb, var(--sac-text) 60%, transparent); } +.pc-form { display: flex; flex-direction: column; gap: 12px; } +.pc-field { display: flex; flex-direction: column; gap: 5px; } +.pc-field span { font-size: 12px; font-weight: 600; color: color-mix(in srgb, var(--sac-text) 70%, transparent); } +.pc-field input { + border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent); + background: var(--sac-surface-2); color: var(--sac-text); - max-height: 96px; - line-height: 1.4; + border-radius: 12px; + padding: 11px 13px; + font-family: inherit; + font-size: 14px; + outline: none; + transition: border-color .2s ease, box-shadow .2s ease; +} +.pc-field input::placeholder { color: color-mix(in srgb, var(--sac-text) 42%, transparent); } +.pc-field input:focus { + border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent); } -.composer textarea:focus { outline: 1px solid var(--sac-primary); } -.composer button { +.pc-submit { + margin-top: 4px; border: none; - border-radius: 8px; - padding: 0 14px; + border-radius: 13px; + padding: 12px; cursor: pointer; - background: var(--sac-primary); + background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2)); color: var(--sac-primary-text); - font-weight: 600; + font-weight: 650; + font-size: 14px; + box-shadow: 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent), 0 1px 0 rgba(255, 255, 255, .25) inset; + transition: transform .2s var(--sac-ease); +} +.pc-submit:hover { transform: translateY(-1px); } +.pc-submit:active { transform: scale(.98); } + +/* ─────────────────── Starter-prompt chips ─────────────────────────── */ +.prompts { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 2px 35px; } +.panel.fullpage .prompts { margin-left: auto; margin-right: auto; max-width: 760px; width: 100%; } +.chip { + border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent); + background: var(--sac-surface-2); + color: var(--sac-text); + border-radius: 999px; + padding: 8px 13px; + font-family: inherit; + font-size: 12.5px; + cursor: pointer; + text-align: left; + transition: border-color .2s ease, background .2s ease, transform .2s ease; +} +.chip:hover { + border-color: color-mix(in srgb, var(--sac-primary) 50%, transparent); + background: color-mix(in srgb, var(--sac-primary) 10%, var(--sac-surface-2)); + transform: translateY(-1px); +} + +/* ─────────────── OTP / tool-confirmation interrupt ────────────────── */ +.interrupt { padding: 0 14px; } +.int-card { + border: 1px solid color-mix(in srgb, var(--sac-primary) 35%, var(--sac-border)); + background: color-mix(in srgb, var(--sac-primary) 8%, var(--sac-surface-2)); + border-radius: 14px; + padding: 12px 13px; + animation: sac-msg-in .3s var(--sac-ease) both; +} +.int-head { display: flex; align-items: center; gap: 8px; } +.int-ico { display: flex; color: var(--sac-primary); } +.int-ico svg { width: 17px; height: 17px; } +.int-title { font-size: 13.5px; font-weight: 650; } +.int-desc { margin-top: 5px; font-size: 12.5px; line-height: 1.45; color: color-mix(in srgb, var(--sac-text) 80%, transparent); } +.int-sent { margin-top: 6px; font-size: 11.5px; color: color-mix(in srgb, var(--sac-text) 60%, transparent); } +.int-row { display: flex; gap: 8px; margin-top: 10px; } +.int-input { + flex: 1; + min-width: 0; + border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent); + background: var(--sac-bg); + color: var(--sac-text); + border-radius: 10px; + padding: 9px 11px; + font-family: inherit; font-size: 14px; + letter-spacing: .14em; + outline: none; + transition: border-color .2s ease, box-shadow .2s ease; } -.composer button:disabled { opacity: 0.5; cursor: default; } +.int-input:focus { + border-color: color-mix(in srgb, var(--sac-primary) 60%, transparent); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--sac-primary) 14%, transparent); +} +.int-btn { + border: 1px solid color-mix(in srgb, var(--sac-border) 80%, transparent); + background: var(--sac-surface-2); + color: var(--sac-text); + border-radius: 10px; + padding: 9px 14px; + font-family: inherit; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: transform .2s var(--sac-ease), background .2s ease, border-color .2s ease; +} +.int-btn:hover { transform: translateY(-1px); } +.int-btn.primary { + border: none; + background: linear-gradient(150deg, var(--sac-primary), var(--sac-primary-2)); + color: var(--sac-primary-text); + box-shadow: 0 6px 14px -6px color-mix(in srgb, var(--sac-primary) 65%, transparent); +} +.int-row .int-btn { flex: 1; } +.int-row .int-input + .int-btn { flex: 0 0 auto; } +.int-error { margin-top: 8px; font-size: 12px; color: #f87171; } .hidden { display: none !important; } + +@media (prefers-reduced-motion: reduce) { + .launcher::before, .dot, .bubble.typing i { animation: none !important; } + .panel, .row, .launcher, .send, .close { animation: none !important; transition: none !important; } +} `; } //#endregion - //#region src/widget/element.ts + //#region src/element.ts const ELEMENT_TAG = "smooth-agent-chat"; const OBSERVED = [ "endpoint", @@ -954,6 +1383,26 @@ var SmoothAgentChat = (function(exports) { "mode" ]; /** + * Inline SVG icons (static, trusted strings — never interpolated with user data). + * Kept here so the IIFE bundle is self-contained: no icon-font or network fetch. + */ + const ICON = { + /** Launcher — a speech bubble carrying a spark (chat + AI). */ + spark: ``, + /** Small assistant avatar used beside each assistant message. */ + bot: ``, + /** Close (collapse panel) — a downward chevron. */ + close: ``, + /** Send — an upward arrow. */ + send: ``, + /** Sources disclosure caret. */ + chev: ``, + /** OTP interrupt — a padlock. */ + lock: ``, + /** Tool-confirmation interrupt — a shield. */ + shield: `` + }; + /** * Return `url` only if it is a valid absolute `http(s)` URL, else `null`. * * SECURITY: citation URLs originate from indexed content (web / GitHub @@ -982,10 +1431,20 @@ var SmoothAgentChat = (function(exports) { messages = []; status = "idle"; mounted = false; + /** True once the visitor has cleared the pre-chat identity gate (or it's not needed). */ + userInfoSatisfied = false; + /** True after the visitor has sent their first message (hides starter chips). */ + hasSent = false; + /** Starter prompts shown as chips in the empty state. */ + examplePrompts = []; + /** Current mid-turn interrupt (OTP / tool-confirmation), or null. */ + interrupt = null; + interruptEl = null; panelEl = null; launcherEl = null; messagesEl = null; statusEl = null; + dotEl = null; inputEl = null; sendBtn = null; constructor() { @@ -1043,10 +1502,16 @@ var SmoothAgentChat = (function(exports) { agentName: this.overrides.agentName ?? this.getAttribute("agent-name") ?? void 0, userName: this.overrides.userName, userEmail: this.overrides.userEmail, + userPhone: this.overrides.userPhone, placeholder: this.overrides.placeholder ?? this.getAttribute("placeholder") ?? void 0, greeting: this.overrides.greeting ?? this.getAttribute("greeting") ?? void 0, connectionErrorMessage: this.overrides.connectionErrorMessage, startOpen: this.overrides.startOpen ?? this.hasAttribute("start-open"), + examplePrompts: this.overrides.examplePrompts, + requireName: this.overrides.requireName, + requireEmail: this.overrides.requireEmail, + requirePhone: this.overrides.requirePhone, + allowAnonymous: this.overrides.allowAnonymous, theme }; } @@ -1067,6 +1532,10 @@ var SmoothAgentChat = (function(exports) { this.status = status; this.renderStatus(); this.renderComposerState(); + }, + onInterrupt: (interrupt) => { + this.interrupt = interrupt; + this.renderInterrupt(); } }); if (resolved.startOpen) this.open = true; @@ -1075,32 +1544,55 @@ var SmoothAgentChat = (function(exports) { if (fullpage) this.open = true; const style = document.createElement("style"); style.textContent = buildStyles(resolved.theme, resolved.mode); - const headerBrand = fullpage ? `
- ${SMOOTH_LOGO_SVG} -
-
${escapeHtml(resolved.agentName)}
-
+ const monogram = escapeHtml((resolved.agentName.trim().charAt(0) || "A").toUpperCase()); + const header = fullpage ? `
+
${SMOOTH_LOGO_SVG}
+
+ ${escapeHtml(resolved.agentName)} +
-
-
powered by smooth-operator
` : `
-
-
${escapeHtml(resolved.agentName)}
-
+ powered by smooth-operator +
` : `
+
${monogram}
+
+ ${escapeHtml(resolved.agentName)} +
+ +
`; + this.examplePrompts = resolved.examplePrompts; + const gating = needsUserInfo(resolved) && !this.userInfoSatisfied; + const field = (name, type, label, autocomplete) => ``; + const prechatHtml = ` +
+
+
Before we chat
+
A couple details so ${escapeHtml(resolved.agentName)} can help.
- `; +
+ ${resolved.requireName ? field("name", "text", "Name", "name") : ""} + ${resolved.requireEmail ? field("email", "email", "Email", "email") : ""} + ${resolved.requirePhone ? field("phone", "tel", "Phone", "tel") : ""} + +
+
`; + const chatHtml = ` +
+ +
+
+ + +
+ +
`; const container = document.createElement("div"); container.innerHTML = ` - ${fullpage ? "" : ""} + ${fullpage ? "" : ``}
-
- ${headerBrand} -
-
-
- - -
+ ${header} +
+ ${gating ? prechatHtml : chatHtml}
`; const logoSvg = container.querySelector(".logo-wrap svg"); @@ -1109,23 +1601,142 @@ var SmoothAgentChat = (function(exports) { this.launcherEl = container.querySelector(".launcher"); this.panelEl = container.querySelector(".panel"); this.messagesEl = container.querySelector(".messages"); - this.statusEl = container.querySelector(".status"); + this.statusEl = container.querySelector(".status-text"); + this.dotEl = container.querySelector(".dot"); this.inputEl = container.querySelector("textarea"); this.sendBtn = container.querySelector(".send"); + this.interruptEl = container.querySelector(".interrupt"); this.launcherEl?.addEventListener("click", () => this.openChat()); container.querySelector(".close")?.addEventListener("click", () => this.closeChat()); this.sendBtn?.addEventListener("click", () => this.submit()); + this.inputEl?.addEventListener("input", () => this.autosize()); this.inputEl?.addEventListener("keydown", (ev) => { if (ev.key === "Enter" && !ev.shiftKey) { ev.preventDefault(); this.submit(); } }); - if (fullpage) this.controller?.connect().catch(() => {}); + const pcForm = container.querySelector(".pc-form"); + pcForm?.addEventListener("submit", (ev) => { + ev.preventDefault(); + this.handlePrechatSubmit(pcForm); + }); + if (fullpage && !gating) this.controller?.connect().catch(() => {}); this.syncOpenState(); - this.renderMessages(resolved.greeting); + if (!gating) this.renderMessages(resolved.greeting); this.renderStatus(); this.renderComposerState(); + this.renderInterrupt(); + } + /** + * Render (or clear) the mid-turn interrupt overlay above the composer: + * an OTP code prompt or a tool-write confirmation. Server-supplied text is + * set via `textContent` (never innerHTML); only static icons use innerHTML. + */ + renderInterrupt() { + const el = this.interruptEl; + if (!el) return; + el.replaceChildren(); + const it = this.interrupt; + if (!it) { + el.classList.add("hidden"); + return; + } + el.classList.remove("hidden"); + const card = document.createElement("div"); + card.className = "int-card"; + const head = document.createElement("div"); + head.className = "int-head"; + const ico = document.createElement("span"); + ico.className = "int-ico"; + ico.innerHTML = it.kind === "otp" ? ICON.lock : ICON.shield; + const title = document.createElement("span"); + title.className = "int-title"; + title.textContent = it.kind === "otp" ? "Verification required" : "Confirm this action"; + head.append(ico, title); + card.appendChild(head); + if (it.actionDescription) { + const desc = document.createElement("div"); + desc.className = "int-desc"; + desc.textContent = it.actionDescription; + card.appendChild(desc); + } + if (it.kind === "otp") { + if (it.sent?.maskedDestination) { + const sent = document.createElement("div"); + sent.className = "int-sent"; + sent.textContent = `Code sent to ${it.sent.maskedDestination}${it.sent.channel ? ` via ${it.sent.channel}` : ""}.`; + card.appendChild(sent); + } + const row = document.createElement("div"); + row.className = "int-row"; + const input = document.createElement("input"); + input.className = "int-input"; + input.type = "text"; + input.inputMode = "numeric"; + input.autocomplete = "one-time-code"; + input.placeholder = "Enter code"; + const submit = () => { + const code = input.value.trim(); + if (code) this.controller?.verifyOtp(code); + }; + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + submit(); + } + }); + const verify = document.createElement("button"); + verify.className = "int-btn primary"; + verify.type = "button"; + verify.textContent = "Verify"; + verify.addEventListener("click", submit); + row.append(input, verify); + card.appendChild(row); + if (it.error) { + const err = document.createElement("div"); + err.className = "int-error"; + err.textContent = it.attemptsRemaining != null ? `${it.error} (${it.attemptsRemaining} left)` : it.error; + card.appendChild(err); + } + queueMicrotask(() => input.focus()); + } else { + const row = document.createElement("div"); + row.className = "int-row"; + const decline = document.createElement("button"); + decline.className = "int-btn"; + decline.type = "button"; + decline.textContent = "Decline"; + decline.addEventListener("click", () => this.controller?.confirmTool(false)); + const approve = document.createElement("button"); + approve.className = "int-btn primary"; + approve.type = "button"; + approve.textContent = "Approve"; + approve.addEventListener("click", () => this.controller?.confirmTool(true)); + row.append(decline, approve); + card.appendChild(row); + } + el.appendChild(card); + } + /** Collect identity from the pre-chat form, then drop into the chat view. */ + handlePrechatSubmit(form) { + if (!form.reportValidity()) return; + const data = new FormData(form); + const val = (k) => data.get(k)?.trim() || void 0; + this.controller?.setUserInfo({ + name: val("name"), + email: val("email"), + phone: val("phone") + }); + this.userInfoSatisfied = true; + this.render(); + this.controller?.connect().catch(() => {}); + } + /** Send a starter prompt (from a chip click). */ + submitPrompt(text) { + if (!this.inputEl) return; + this.inputEl.value = text; + this.submit(); } syncOpenState() { if (this.panelEl?.classList.contains("fullpage")) { @@ -1136,34 +1747,72 @@ var SmoothAgentChat = (function(exports) { this.launcherEl?.classList.toggle("hidden", this.open); if (this.open) this.inputEl?.focus(); } + /** Grow the textarea with its content, up to the CSS max-height. */ + autosize() { + const ta = this.inputEl; + if (!ta) return; + ta.style.height = "auto"; + ta.style.height = `${ta.scrollHeight}px`; + } renderMessages(greeting) { if (!this.messagesEl) return; this.messagesEl.replaceChildren(); - if (this.messages.length === 0 && greeting) { - const g = document.createElement("div"); - g.className = "bubble assistant greeting"; - g.textContent = greeting; - this.messagesEl.appendChild(g); + if (this.messages.length === 0 && greeting) this.messagesEl.appendChild(this.buildRow("assistant", this.greetingBubble(greeting))); + if (!this.hasSent && this.messages.length === 0 && this.examplePrompts.length > 0) { + const chips = document.createElement("div"); + chips.className = "prompts"; + for (const prompt of this.examplePrompts) { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "chip"; + chip.textContent = prompt; + chip.addEventListener("click", () => this.submitPrompt(prompt)); + chips.appendChild(chip); + } + this.messagesEl.appendChild(chips); } for (const msg of this.messages) { - const el = document.createElement("div"); - el.className = `bubble ${msg.role}`; - if (msg.streaming && !msg.text) el.classList.add("cursor"); - else if (msg.streaming) { - el.classList.add("cursor"); - el.textContent = msg.text; - } else el.textContent = msg.text; - this.messagesEl.appendChild(el); + const bubble = document.createElement("div"); + bubble.className = `bubble ${msg.role}`; + if (msg.role === "assistant" && msg.streaming && !msg.text) { + bubble.classList.add("typing"); + bubble.append(this.typingDot(), this.typingDot(), this.typingDot()); + } else if (msg.streaming) { + bubble.classList.add("cursor"); + bubble.textContent = msg.text; + } else bubble.textContent = msg.text; + this.messagesEl.appendChild(this.buildRow(msg.role, bubble)); if (msg.role === "assistant" && !msg.streaming && msg.citations && msg.citations.length > 0) this.messagesEl.appendChild(this.renderSources(msg.citations)); } this.messagesEl.scrollTop = this.messagesEl.scrollHeight; } + /** Wrap a bubble in a `.row`, prefixing assistant rows with a mini avatar. */ + buildRow(role, bubble) { + const row = document.createElement("div"); + row.className = `row ${role}`; + if (role === "assistant") { + const mini = document.createElement("div"); + mini.className = "mini"; + mini.innerHTML = ICON.bot; + row.appendChild(mini); + } + row.appendChild(bubble); + return row; + } + greetingBubble(greeting) { + const b = document.createElement("div"); + b.className = "bubble assistant greeting"; + b.textContent = greeting; + return b; + } + typingDot() { + return document.createElement("i"); + } /** * Build the collapsible "Sources (N)" block for an assistant message's - * citations. Each source renders its `title` (linked to `citation.url` when - * present — `target=_blank rel=noopener` — plain text otherwise) plus the - * grounding `snippet`. Built with DOM APIs (not innerHTML) so citation text - * can't inject markup. + * citations. Title/snippet are set via `textContent` (never innerHTML) so + * citation text can't inject markup; only the static chevron + numeric count + * use innerHTML. */ renderSources(citations) { const wrap = document.createElement("div"); @@ -1172,7 +1821,15 @@ var SmoothAgentChat = (function(exports) { const details = document.createElement("details"); details.open = true; const summary = document.createElement("summary"); - summary.textContent = `Sources (${citations.length})`; + const chev = document.createElement("span"); + chev.className = "chev"; + chev.innerHTML = ICON.chev; + const label = document.createElement("span"); + label.textContent = "Sources"; + const count = document.createElement("span"); + count.className = "count"; + count.textContent = String(citations.length); + summary.append(chev, label, count); details.appendChild(summary); const list = document.createElement("ol"); for (const c of citations) { @@ -1205,7 +1862,6 @@ var SmoothAgentChat = (function(exports) { return wrap; } renderStatus() { - if (!this.statusEl) return; const label = { idle: "", connecting: "Connecting…", @@ -1213,7 +1869,11 @@ var SmoothAgentChat = (function(exports) { error: "Connection issue", closed: "Disconnected" }; - this.statusEl.textContent = label[this.status]; + if (this.statusEl) this.statusEl.textContent = label[this.status]; + if (this.dotEl) { + const mod = this.status === "ready" ? "" : this.status === "connecting" ? " connecting" : this.status === "error" ? " error" : " off"; + this.dotEl.className = `dot${mod}`; + } } renderComposerState() { const busy = this.status === "connecting"; @@ -1225,6 +1885,8 @@ var SmoothAgentChat = (function(exports) { const text = this.inputEl.value; if (!text.trim()) return; this.inputEl.value = ""; + this.hasSent = true; + this.autosize(); this.controller.send(text); } }; @@ -1275,7 +1937,7 @@ var SmoothAgentChat = (function(exports) { }, target); } //#endregion - //#region src/widget/standalone.ts + //#region src/standalone.ts defineChatWidget(); /** Convenience alias matching the global API surface (`SmoothAgentChat.mount`). */ function mount(config, target) { @@ -1298,4 +1960,4 @@ var SmoothAgentChat = (function(exports) { return exports; })({}); -//# sourceMappingURL=chat-widget.iife.js.map \ No newline at end of file +//# sourceMappingURL=chat-widget.global.js.map \ No newline at end of file