@syntrologie/adapt-chatbot 2.37.2 → 2.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/AdaptiveChatBarMountable.d.ts.map +1 -1
  2. package/dist/ChatSession.d.ts +23 -0
  3. package/dist/ChatSession.d.ts.map +1 -1
  4. package/dist/ChatTransport.d.ts.map +1 -1
  5. package/dist/LeaveAMessageLit.d.ts +112 -0
  6. package/dist/LeaveAMessageLit.d.ts.map +1 -0
  7. package/dist/LeaveAMessageLit.test.d.ts +2 -0
  8. package/dist/LeaveAMessageLit.test.d.ts.map +1 -0
  9. package/dist/LeaveAMessageMountable.test.d.ts +2 -0
  10. package/dist/LeaveAMessageMountable.test.d.ts.map +1 -0
  11. package/dist/cdn.d.ts +13 -2
  12. package/dist/cdn.d.ts.map +1 -1
  13. package/dist/runtime.d.ts +50 -2
  14. package/dist/runtime.d.ts.map +1 -1
  15. package/dist/runtime.js +420 -1
  16. package/dist/runtime.js.map +4 -4
  17. package/dist/schema.d.ts +36 -0
  18. package/dist/schema.d.ts.map +1 -1
  19. package/dist/schema.js +111 -0
  20. package/dist/schema.js.map +2 -2
  21. package/dist/support-chat/boot.d.ts +12 -0
  22. package/dist/support-chat/boot.d.ts.map +1 -0
  23. package/dist/support-chat/boot.test.d.ts +2 -0
  24. package/dist/support-chat/boot.test.d.ts.map +1 -0
  25. package/dist/support-chat/crisp.d.ts +4 -0
  26. package/dist/support-chat/crisp.d.ts.map +1 -0
  27. package/dist/support-chat/crisp.test.d.ts +2 -0
  28. package/dist/support-chat/crisp.test.d.ts.map +1 -0
  29. package/dist/support-chat/registry.d.ts +5 -0
  30. package/dist/support-chat/registry.d.ts.map +1 -0
  31. package/dist/support-chat/registry.test.d.ts +2 -0
  32. package/dist/support-chat/registry.test.d.ts.map +1 -0
  33. package/dist/support-chat/types.d.ts +19 -0
  34. package/dist/support-chat/types.d.ts.map +1 -0
  35. package/package.json +1 -1
package/dist/runtime.js CHANGED
@@ -23,7 +23,8 @@ import "./chunk-OIGVZGSX.js";
23
23
  import {
24
24
  DIVE_DEEPER_EVENT,
25
25
  TILE_MOUNTED_EVENT,
26
- TILE_UNMOUNTED_EVENT
26
+ TILE_UNMOUNTED_EVENT,
27
+ stripMountPlumbing
27
28
  } from "./chunk-EACGDSJI.js";
28
29
  import "./chunk-UVKRO5ER.js";
29
30
 
@@ -265,6 +266,33 @@ var ChatSession = class {
265
266
  msg.toolCalls = [...msg.toolCalls ?? [], { ...toolCall }];
266
267
  this.notify();
267
268
  }
269
+ /**
270
+ * Create an empty, already-settled assistant message at `id` if one
271
+ * doesn't already exist. Idempotent — safe to call on every tool-call
272
+ * sighting.
273
+ *
274
+ * Exists for TOOL-ONLY turns: pydantic-ai's AG-UI adapter mints a fresh
275
+ * `parent_message_id` per model response regardless of content
276
+ * (`before_response` → `self.new_message_id()`) and stamps it on every
277
+ * `TOOL_CALL_START`, even when the response has no text. When the
278
+ * model's entire action is one or more tool calls with no narration
279
+ * (a parallel `mount_*` + `suggest_replies` batch, most commonly), no
280
+ * `TEXT_MESSAGE_START` is ever emitted for that id, so nothing would
281
+ * otherwise create the host message `addToolCall` needs — and the tool
282
+ * call (and any chips/receipts derived from it) silently vanishes
283
+ * (BUG-1786654199). Call this before `addToolCall` on a tool call's
284
+ * first sighting so it always has a message to land on.
285
+ *
286
+ * Deliberately status:'complete' (not 'streaming' via `receiveStart`):
287
+ * a tool-only message has no text to stream, so there's nothing for a
288
+ * later `receiveEnd` to settle — leaving it 'streaming' would paint
289
+ * the trail's streaming/typing visual state forever.
290
+ */
291
+ ensureAssistantMessage(id) {
292
+ if (this._messages.some((m) => m.id === id)) return;
293
+ this._messages.push({ id, role: "assistant", text: "", status: "complete" });
294
+ this.notify();
295
+ }
268
296
  /**
269
297
  * Partially update a tool call by id. Used by the transport to
270
298
  * advance status (args-streaming → running → done) as AG-UI events
@@ -1221,6 +1249,7 @@ var ChatTransport = class {
1221
1249
  if (!targetMessageId) return;
1222
1250
  const parsedArgs = parseToolCallArgs(event.toolCall);
1223
1251
  if (!existing) {
1252
+ chatSession.ensureAssistantMessage(targetMessageId);
1224
1253
  chatSession.addToolCall(targetMessageId, {
1225
1254
  id: event.toolCall.id,
1226
1255
  name: event.toolCall.name,
@@ -1770,6 +1799,70 @@ function busEventToRawEvent(e) {
1770
1799
  return { event: e.name, timestamp: ts, properties: { ...e.props ?? {} } };
1771
1800
  }
1772
1801
 
1802
+ // src/support-chat/registry.ts
1803
+ var bridges = /* @__PURE__ */ new Map();
1804
+ function registerSupportChatBridge(bridge) {
1805
+ bridges.set(bridge.vendor, bridge);
1806
+ }
1807
+ function getSupportChatBridge(vendor) {
1808
+ return bridges.get(vendor);
1809
+ }
1810
+
1811
+ // src/support-chat/crisp.ts
1812
+ function crisp() {
1813
+ if (typeof window === "undefined") return void 0;
1814
+ return window.$crisp;
1815
+ }
1816
+ function push(cmd) {
1817
+ try {
1818
+ crisp()?.push(cmd);
1819
+ } catch (err) {
1820
+ console.warn("supportChat.bridge: crisp push failed", cmd[0], cmd[1], err);
1821
+ }
1822
+ }
1823
+ var crispBridge = {
1824
+ vendor: "crisp",
1825
+ detect: () => crisp() !== void 0,
1826
+ hideNative: () => push(["do", "chat:hide"]),
1827
+ setVisitorEmail: (email) => push(["set", "user:email", [email]]),
1828
+ sendMessage: (text) => push(["do", "message:send", ["text", text]]),
1829
+ onOperatorReply: (cb) => {
1830
+ let active = true;
1831
+ push([
1832
+ "on",
1833
+ "message:received",
1834
+ (msg) => {
1835
+ if (!active) return;
1836
+ push(["do", "chat:hide"]);
1837
+ if (msg?.from !== "operator") return;
1838
+ if (typeof msg?.content === "string") cb(msg.content);
1839
+ }
1840
+ ]);
1841
+ return () => {
1842
+ active = false;
1843
+ };
1844
+ }
1845
+ };
1846
+ function registerCrispBridge() {
1847
+ registerSupportChatBridge(crispBridge);
1848
+ }
1849
+ registerCrispBridge();
1850
+
1851
+ // src/support-chat/boot.ts
1852
+ function bootSupportChat(cfg) {
1853
+ if (!cfg?.vendor) return void 0;
1854
+ registerCrispBridge();
1855
+ const bridge = getSupportChatBridge(cfg.vendor);
1856
+ if (!bridge || !bridge.detect()) {
1857
+ console.warn(
1858
+ `supportChat.bridge: vendor '${cfg.vendor}' not detected on this page \u2014 handoff disabled`
1859
+ );
1860
+ return void 0;
1861
+ }
1862
+ if (cfg.suppressNative) bridge.hideNative();
1863
+ return bridge;
1864
+ }
1865
+
1773
1866
  // src/AdaptiveChatBarMountable.ts
1774
1867
  var STATE_KEY = "__syntroChatBarMount";
1775
1868
  function getState(container) {
@@ -2060,6 +2153,7 @@ var AdaptiveChatBarMountable = {
2060
2153
  mount(container, mountConfig) {
2061
2154
  const cfg = mountConfig ?? {};
2062
2155
  configureTransportIfPossible(cfg);
2156
+ bootSupportChat(cfg.supportChat);
2063
2157
  wireDiveDeeperListener();
2064
2158
  const bar = document.createElement("adaptive-chat-bar");
2065
2159
  applyPlaceholder(bar, cfg);
@@ -2196,6 +2290,266 @@ var AdaptiveChipsStripMountable = {
2196
2290
  }
2197
2291
  };
2198
2292
 
2293
+ // src/LeaveAMessageLit.ts
2294
+ import { html, LitElement, nothing } from "lit";
2295
+ import { styleMap } from "lit/directives/style-map.js";
2296
+ var EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
2297
+ var box = {
2298
+ display: "flex",
2299
+ flexDirection: "column",
2300
+ gap: "var(--sc-content-item-gap, 10px)",
2301
+ fontFamily: "var(--sc-font-family, system-ui)",
2302
+ fontSize: "var(--sc-content-body-font-size, 13px)"
2303
+ };
2304
+ var fieldStyles = {
2305
+ padding: "var(--sc-content-item-padding, 10px 12px)",
2306
+ borderRadius: "var(--sc-content-border-radius, 8px)",
2307
+ border: "var(--sc-content-bubble-border, 1px solid currentColor)",
2308
+ background: "var(--sc-content-bubble-background, transparent)",
2309
+ color: "inherit",
2310
+ font: "inherit",
2311
+ // The browser's default focus ring is a loud blue on a black-and-white
2312
+ // brand — the one saturated pixel on the card. Suppress it and paint our
2313
+ // own from the theme in the focus/blur handlers below.
2314
+ outline: "none",
2315
+ width: "100%",
2316
+ boxSizing: "border-box",
2317
+ // The drag grabber pokes out of the card's bottom-right corner and lets
2318
+ // the visitor break the tile frame — the card owns its own geometry.
2319
+ resize: "none"
2320
+ };
2321
+ var buttonStyles = {
2322
+ padding: "11px 14px",
2323
+ borderRadius: "var(--sc-border-radius, 8px)",
2324
+ border: "none",
2325
+ background: "var(--sc-color-primary, #000000)",
2326
+ color: "var(--sc-accent-foreground, #ffffff)",
2327
+ fontWeight: "600",
2328
+ cursor: "pointer",
2329
+ font: "inherit",
2330
+ width: "100%",
2331
+ boxSizing: "border-box"
2332
+ };
2333
+ var textStyles = {
2334
+ margin: "0"
2335
+ };
2336
+ var mutedTextStyles = {
2337
+ margin: "0",
2338
+ // No opacity token exists in any publishable section — plain constant.
2339
+ opacity: "0.8"
2340
+ };
2341
+ var PERSIST_NAMESPACE = "supportchat";
2342
+ var PERSIST_KEY = "leave-a-message";
2343
+ var LeaveAMessageLit = class extends LitElement {
2344
+ constructor() {
2345
+ super(...arguments);
2346
+ this.message = "";
2347
+ this.contextSummary = "";
2348
+ this.state = "form";
2349
+ this._email = "";
2350
+ this._reply = "";
2351
+ this._operatorMessages = [];
2352
+ /** Paint our own focus ring from the theme — see fieldStyles.outline. */
2353
+ this._focusOn = (e) => {
2354
+ const el = e.target;
2355
+ el.style.outline = "2px solid var(--sc-color-primary, currentColor)";
2356
+ el.style.outlineOffset = "1px";
2357
+ };
2358
+ this._focusOff = (e) => {
2359
+ const el = e.target;
2360
+ el.style.outline = "none";
2361
+ };
2362
+ this._onReply = (text) => {
2363
+ this._operatorMessages = [...this._operatorMessages, text];
2364
+ if (this.state !== "flipped") {
2365
+ this.state = "mail";
2366
+ this.runtime?.events.publish("supportchat:reply_received", { text });
2367
+ this._toastReplyArrived();
2368
+ this.dispatchEvent(
2369
+ new CustomEvent("syntro:supportchat-reply", {
2370
+ bubbles: true,
2371
+ composed: true,
2372
+ detail: { text }
2373
+ })
2374
+ );
2375
+ }
2376
+ this._persist();
2377
+ this.requestUpdate();
2378
+ };
2379
+ this._submit = () => {
2380
+ if (!this.bridge || !EMAIL_RE.test(this._email)) return;
2381
+ this.bridge.setVisitorEmail(this._email);
2382
+ const context = this.contextSummary ? `[Site assistant context: ${this.contextSummary}]
2383
+ ` : "";
2384
+ this.bridge.sendMessage(`${context}${this.message}`);
2385
+ this.state = "sent";
2386
+ this._persist();
2387
+ };
2388
+ this._flip = () => {
2389
+ this.state = "flipped";
2390
+ this._persist();
2391
+ };
2392
+ this._sendReply = () => {
2393
+ if (!this.bridge || !this._reply.trim()) return;
2394
+ this.bridge.sendMessage(this._reply.trim());
2395
+ this._reply = "";
2396
+ this.requestUpdate();
2397
+ };
2398
+ }
2399
+ createRenderRoot() {
2400
+ return this;
2401
+ }
2402
+ connectedCallback() {
2403
+ super.connectedCallback();
2404
+ this._loadPersisted();
2405
+ }
2406
+ updated() {
2407
+ if (this.state !== "form" && this.bridge && this.bridge.detect() && !this._unsubscribe) {
2408
+ this._unsubscribe = this.bridge.onOperatorReply(this._onReply);
2409
+ }
2410
+ }
2411
+ disconnectedCallback() {
2412
+ this._unsubscribe?.();
2413
+ this._unsubscribe = void 0;
2414
+ super.disconnectedCallback();
2415
+ }
2416
+ /** Read the injected persistence store, if any (I2). */
2417
+ _persistStore() {
2418
+ return this.runtime?.state?.user?.ns?.(PERSIST_NAMESPACE);
2419
+ }
2420
+ /** Hydrate {state, operatorMessages, email} from a prior page's mount. */
2421
+ _loadPersisted() {
2422
+ const store = this._persistStore();
2423
+ if (!store) return;
2424
+ const saved = store.get(PERSIST_KEY);
2425
+ if (!saved) return;
2426
+ if (saved.state) this.state = saved.state;
2427
+ if (Array.isArray(saved.operatorMessages)) this._operatorMessages = saved.operatorMessages;
2428
+ if (typeof saved.email === "string") this._email = saved.email;
2429
+ }
2430
+ /** Snapshot {state, operatorMessages, email} so the NEXT mount (a fresh
2431
+ * page in the same session) can pick the thread back up. */
2432
+ _persist() {
2433
+ const store = this._persistStore();
2434
+ if (!store) return;
2435
+ const state = this.state;
2436
+ const operatorMessages = this._operatorMessages;
2437
+ const email = this._email;
2438
+ const snapshot = { state, operatorMessages, email };
2439
+ store.set(PERSIST_KEY, snapshot);
2440
+ }
2441
+ /**
2442
+ * Raise the reply toast through the canvas element. Pierces shadow roots to
2443
+ * find `<smart-canvas>` (same resolution AdaptiveChatBarMountable uses), and
2444
+ * no-ops when no canvas is present — the tile's own "You got mail!" face is
2445
+ * always the durable signal.
2446
+ */
2447
+ _toastReplyArrived() {
2448
+ if (typeof document === "undefined") return;
2449
+ const title = this.replyToast?.title?.trim();
2450
+ if (!title) return;
2451
+ const find = (root) => {
2452
+ const direct = root.querySelector("smart-canvas");
2453
+ if (direct) return direct;
2454
+ for (const el of root.querySelectorAll("*")) {
2455
+ const sr = el.shadowRoot;
2456
+ if (sr) {
2457
+ const found = find(sr);
2458
+ if (found) return found;
2459
+ }
2460
+ }
2461
+ return null;
2462
+ };
2463
+ try {
2464
+ const canvas = find(document);
2465
+ canvas?.previewNotification?.({ title, body: this.replyToast?.body });
2466
+ } catch (err) {
2467
+ console.warn("supportChat.widget: reply toast failed", err);
2468
+ }
2469
+ }
2470
+ render() {
2471
+ if (!this.bridge || !this.bridge.detect()) return nothing;
2472
+ if (this.state === "form") {
2473
+ return html`<div data-lam style=${styleMap(box)}>
2474
+ <input
2475
+ data-lam-email
2476
+ type="email"
2477
+ placeholder="Your email — so the team can reach you"
2478
+ style=${styleMap(fieldStyles)}
2479
+ @focus=${this._focusOn}
2480
+ @blur=${this._focusOff}
2481
+ @input=${(e) => {
2482
+ this._email = e.target.value;
2483
+ this.requestUpdate();
2484
+ }}
2485
+ />
2486
+ <textarea
2487
+ data-lam-message
2488
+ rows="4"
2489
+ style=${styleMap(fieldStyles)}
2490
+ @focus=${this._focusOn}
2491
+ @blur=${this._focusOff}
2492
+ .value=${this.message}
2493
+ @input=${(e) => {
2494
+ this.message = e.target.value;
2495
+ }}
2496
+ ></textarea>
2497
+ <button type="button" data-lam-send style=${styleMap(buttonStyles)} @click=${this._submit}>
2498
+ Send to the team
2499
+ </button>
2500
+ </div>`;
2501
+ }
2502
+ if (this.state === "sent") {
2503
+ return html`<div data-lam style=${styleMap(box)}>
2504
+ <p style=${styleMap(mutedTextStyles)}>
2505
+ Sent. The team will reply here — or by email if you step away.
2506
+ </p>
2507
+ </div>`;
2508
+ }
2509
+ if (this.state === "mail") {
2510
+ return html`<div data-lam style=${styleMap(box)}>
2511
+ <button type="button" data-lam-mail style=${styleMap(buttonStyles)} @click=${this._flip}>
2512
+ 📬 You got mail! — tap to read
2513
+ </button>
2514
+ </div>`;
2515
+ }
2516
+ return html`<div data-lam style=${styleMap(box)}>
2517
+ ${this._operatorMessages.map(
2518
+ (m) => html`<p data-lam-operator style=${styleMap(textStyles)}>${m}</p>`
2519
+ )}
2520
+ <input
2521
+ data-lam-reply
2522
+ type="text"
2523
+ placeholder="Reply to the team…"
2524
+ style=${styleMap(fieldStyles)}
2525
+ @focus=${this._focusOn}
2526
+ @blur=${this._focusOff}
2527
+ .value=${this._reply}
2528
+ @input=${(e) => {
2529
+ this._reply = e.target.value;
2530
+ }}
2531
+ />
2532
+ <button type="button" data-lam-reply-send style=${styleMap(buttonStyles)} @click=${this._sendReply}>
2533
+ Send
2534
+ </button>
2535
+ </div>`;
2536
+ }
2537
+ };
2538
+ LeaveAMessageLit.properties = {
2539
+ bridge: { attribute: false },
2540
+ message: { type: String },
2541
+ contextSummary: { attribute: false },
2542
+ state: { type: String },
2543
+ runtime: { attribute: false },
2544
+ replyToast: { attribute: false }
2545
+ };
2546
+ function registerLeaveAMessageLit() {
2547
+ if (typeof customElements !== "undefined" && !customElements.get("syntro-leave-a-message")) {
2548
+ customElements.define("syntro-leave-a-message", LeaveAMessageLit);
2549
+ }
2550
+ }
2551
+ registerLeaveAMessageLit();
2552
+
2199
2553
  // src/NavLinkMountable.ts
2200
2554
  var STATE_KEY3 = "__syntroNavLinkMount";
2201
2555
  var SAFE_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
@@ -2317,6 +2671,58 @@ var TextAnswerMountable = {
2317
2671
  };
2318
2672
 
2319
2673
  // src/runtime.ts
2674
+ var LEAVE_A_MESSAGE_STATE_KEY = "__syntroLeaveAMessageMount";
2675
+ function getLeaveAMessageState(container) {
2676
+ return container[LEAVE_A_MESSAGE_STATE_KEY] ?? null;
2677
+ }
2678
+ function setLeaveAMessageState(container, state) {
2679
+ container[LEAVE_A_MESSAGE_STATE_KEY] = state;
2680
+ }
2681
+ var LeaveAMessageMountable = {
2682
+ mount(container, config) {
2683
+ registerLeaveAMessageLit();
2684
+ const el = document.createElement("syntro-leave-a-message");
2685
+ const incoming = config ?? null;
2686
+ const stripped = stripMountPlumbing(incoming) ?? {};
2687
+ const supportChat = stripped.supportChat;
2688
+ const runtime2 = incoming?.runtime;
2689
+ Object.assign(el, {
2690
+ bridge: bootSupportChat(supportChat),
2691
+ message: stripped.message ?? "",
2692
+ contextSummary: stripped.context_summary ?? "",
2693
+ // Visitor-facing toast copy from the workspace config — see
2694
+ // LeaveAMessageLit#replyToast (presentation is configuration).
2695
+ replyToast: stripped.replyToast,
2696
+ runtime: runtime2
2697
+ });
2698
+ container.appendChild(el);
2699
+ setLeaveAMessageState(container, { el });
2700
+ return () => {
2701
+ setLeaveAMessageState(container, null);
2702
+ el.remove();
2703
+ };
2704
+ },
2705
+ /**
2706
+ * I1 (final-review fixes 2026-08-13): without this, `WidgetRegistry`
2707
+ * falls back to unmount+remount on every config patch (e.g. the agent
2708
+ * editing the prefilled `message` mid-conversation), which destroys the
2709
+ * live element and with it the visitor's in-flight escalation state
2710
+ * (email already captured, an operator reply already sitting in
2711
+ * `mail`/`flipped`). Mirrors `AdaptiveChatBarMountable.update`'s
2712
+ * pattern: patch the presentational props in place, touch nothing
2713
+ * else. `state`, `bridge`, and the operator-message thread are NEVER
2714
+ * reset here.
2715
+ */
2716
+ update(container, config) {
2717
+ const mounted = getLeaveAMessageState(container);
2718
+ if (!mounted) return;
2719
+ const stripped = stripMountPlumbing(config ?? null) ?? {};
2720
+ if (typeof stripped.message === "string") mounted.el.message = stripped.message;
2721
+ if (typeof stripped.context_summary === "string") {
2722
+ mounted.el.contextSummary = stripped.context_summary;
2723
+ }
2724
+ }
2725
+ };
2320
2726
  var runtime = {
2321
2727
  id: "adaptive-chatbot",
2322
2728
  version: "2.0.0",
@@ -2387,12 +2793,25 @@ var runtime = {
2387
2793
  description: "Same-origin navigation button \u2014 used as a chip payload for LLM-authored nav suggestions.",
2388
2794
  icon: "\u2192"
2389
2795
  }
2796
+ },
2797
+ // Escalation-only async human handoff: email + message into the site's
2798
+ // own support-chat inbox (Crisp first); operator replies flip the tile
2799
+ // ("You got mail!"). See docs/superpowers/specs/2026-08-13-async-handoff-crisp-design.md.
2800
+ {
2801
+ id: "adaptive-chatbot:leave-a-message",
2802
+ component: LeaveAMessageMountable,
2803
+ metadata: {
2804
+ name: "Leave a Message",
2805
+ description: `Escalation-only async human handoff: email + message into the site's own support-chat inbox; operator replies flip the tile ("You got mail!")`,
2806
+ icon: "\u{1F4EC}"
2807
+ }
2390
2808
  }
2391
2809
  ]
2392
2810
  };
2393
2811
  var runtime_default = runtime;
2394
2812
  export {
2395
2813
  ElementInstanceStore,
2814
+ LeaveAMessageMountable,
2396
2815
  TileHandler,
2397
2816
  runtime_default as default,
2398
2817
  getElementInstanceStore,