@oyehello/chat 0.5.2 → 0.6.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.
package/LICENSE CHANGED
@@ -56,4 +56,4 @@ otherwise using the Software, You agree to be bound by this Agreement.
56
56
  subject matter. If any provision is held unenforceable, the remaining
57
57
  provisions remain in full force.
58
58
 
59
- For licensing enquiries, contact: legal@hotreloads.com
59
+ For licensing enquiries, contact: legal@oyehello.ai
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Framework-agnostic embeddable chat for oyehello agents — a `<oyehello-chat>` web component that renders a **floating chat launcher** (bottom-right; click to open a chat window), plus a headless `createChatClient()` for custom UIs. Zero framework dependencies.
4
4
 
5
- The widget connects lazily (a session opens the first time the visitor opens the panel), renders assistant replies as **markdown** (sanitized), and has a full launcher → panel experience: an unread badge, expand/maximize, an emoji picker, file attach, a jump-to-latest control, smart auto-scroll, an optional teaser nudge, quick-reply suggestions, and a privacy footer. It follows the visitor's OS light/dark theme by default. Theme the accent with the `--oc-accent` CSS variable. Just drop the tag anywhere on the page; it positions itself as a floating launcher.
5
+ The widget connects lazily (a session opens the first time the visitor opens the panel), renders assistant replies as **markdown** (sanitized), and has a full launcher → panel experience: an unread badge, expand/maximize, an emoji picker, file attach, a jump-to-latest control, smart auto-scroll, an optional teaser nudge, quick-reply suggestions, and a privacy footer. By default it follows your page's own light/dark theme (live, as your theme toggle changes it), and the visitor's OS theme when your page doesn't declare one. Theme the accent with the `--oc-accent` CSS variable. Just drop the tag anywhere on the page; it positions itself as a floating launcher.
6
6
 
7
7
  ### Attributes
8
8
 
@@ -14,7 +14,7 @@ The **accent/logo/name** (workspace brand), the **teaser** (a chat-agent setting
14
14
  | `agent-id` | yes | The chat agent (`agt_…`) |
15
15
  | `base-url` | yes | API origin, e.g. `https://api.oyehello.ai` |
16
16
  | `title` | no | Header title (defaults to the agent's name) |
17
- | `theme` | no | `auto` (default, follows the OS), `light`, or `dark` |
17
+ | `theme` | no | `auto` (default: follows your page's theme - a `data-theme` or `data-mode` of `dark`/`light`, a `dark`/`light` class on `<html>`, `<body>` or any parent of the widget, or `color-scheme` - and the visitor's OS when the page declares none), `light`, or `dark` |
18
18
  | `teaser` | no | Text for a one-time nudge bubble; omit to disable |
19
19
  | `suggestions` | no | Comma-separated quick-reply chips, e.g. `"Book a demo, Pricing"` |
20
20
  | `privacy-url` | no | Shows a Privacy-Policy footer linking here |
@@ -36,7 +36,7 @@ npm install @oyehello/chat
36
36
  ```
37
37
  Or via a plain script tag (no bundler):
38
38
  ```html
39
- <script src="https://unpkg.com/@oyehello/chat@0.5.0"></script>
39
+ <script src="https://unpkg.com/@oyehello/chat@0.6.0"></script>
40
40
  <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://api.oyehello.ai"></oyehello-chat>
41
41
  ```
42
42
  Pin a specific version (`@0.5.0` above) rather than the bare package name, so a customer's site loads the exact widget it was tested against instead of silently auto-upgrading on every publish.
@@ -83,8 +83,10 @@ target passes the agent's navigate allowlist (also set in the console), the SDK
83
83
  <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://api.oyehello.ai"></oyehello-chat>
84
84
  ```
85
85
  That's it — a plain embed with no JS wiring can navigate the visitor or open a link when the agent calls
86
- the tool. `navigate` calls `window.location.assign(path)`; `openUrl` calls `window.open(url, '_blank')`
87
- (pass `newTab: false` from the agent to open in the same tab instead).
86
+ the tool. `navigate` calls `window.location.assign(path)` (in-app path). `openUrl` opens the URL — which
87
+ is restricted server-side to the agent's allowlisted base domain(s) and their subdomains — choosing the
88
+ tab itself: the exact same host as the embedding page opens in the same tab, any allowed subdomain opens
89
+ in a new tab (`window.open(url, …, 'noopener,noreferrer')`). The agent has no say over the tab target.
88
90
 
89
91
  A host page MAY still register its own `navigate`/`openUrl` handler to override the default behavior
90
92
  (e.g. push through a client-side router instead of a full navigation):
package/dist/index.cjs CHANGED
@@ -67,6 +67,16 @@ async function postMessage(opts) {
67
67
  });
68
68
  if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
69
69
  }
70
+ async function getHistory(opts) {
71
+ const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/messages`, {
72
+ method: "GET",
73
+ credentials: "omit",
74
+ headers: { Authorization: `Bearer ${opts.sessionToken}` }
75
+ });
76
+ if (!res.ok) throw await asError(res, res.status === 401 ? "session_expired" : void 0);
77
+ const body = await res.json();
78
+ return body.messages;
79
+ }
70
80
  async function postPageViews(opts) {
71
81
  const res = await opts.fetchImpl(`${opts.baseUrl}/chat/sessions/${opts.sessionId}/page-views`, {
72
82
  method: "POST",
@@ -323,17 +333,21 @@ var isSafeHttpUrl = (u) => {
323
333
  return false;
324
334
  }
325
335
  };
336
+ var AFTER_RESULT = "__ohAfterResult";
326
337
  var BUILTIN_TOOL_HANDLERS = {
327
338
  navigate: (args) => {
328
339
  const path = String(args.path ?? "");
329
340
  if (!isSafeRelativePath(path)) return { ok: false, error: "unsafe navigate path" };
330
- window.location.assign(path);
331
- return { ok: true };
341
+ const here = window.location.pathname + window.location.search + window.location.hash;
342
+ const target = new URL(path, window.location.href);
343
+ if (target.pathname + target.search + target.hash === here) return { ok: true, note: "already on this page" };
344
+ return { ok: true, [AFTER_RESULT]: () => window.location.assign(path) };
332
345
  },
333
346
  openUrl: (args) => {
334
347
  const url = String(args.url ?? "");
335
348
  if (!isSafeHttpUrl(url)) return { ok: false, error: "unsafe url" };
336
- window.open(url, args.newTab === false ? "_self" : "_blank", "noopener,noreferrer");
349
+ const sameHost = new URL(url, window.location.origin).hostname === window.location.hostname;
350
+ window.open(url, sameHost ? "_self" : "_blank", "noopener,noreferrer");
337
351
  return { ok: true };
338
352
  }
339
353
  };
@@ -445,6 +459,23 @@ function createChatClient(options) {
445
459
  } catch {
446
460
  }
447
461
  };
462
+ const callsKey = `oyehello-chat-calls:${options.agentId}`;
463
+ const handledCallIds = () => {
464
+ try {
465
+ const raw = store2?.getItem(callsKey);
466
+ const v = raw ? JSON.parse(raw) : null;
467
+ return v && session && v.sessionId === session.sessionId && Array.isArray(v.ids) ? v.ids : [];
468
+ } catch {
469
+ return [];
470
+ }
471
+ };
472
+ const rememberHandledCall = (callId) => {
473
+ if (!session) return;
474
+ try {
475
+ store2?.setItem(callsKey, JSON.stringify({ sessionId: session.sessionId, ids: [...handledCallIds(), callId].slice(-50) }));
476
+ } catch {
477
+ }
478
+ };
448
479
  const emit = (e) => {
449
480
  for (const l of listeners) l(e);
450
481
  };
@@ -493,8 +524,9 @@ function createChatClient(options) {
493
524
  } else if (ev.type === "error") {
494
525
  emit({ type: "error", error: new Error(ev.message) });
495
526
  } else if (ev.type === "tool_call") {
496
- if (seenCallIds.has(ev.callId)) return;
527
+ if (seenCallIds.has(ev.callId) || handledCallIds().includes(ev.callId)) return;
497
528
  seenCallIds.add(ev.callId);
529
+ rememberHandledCall(ev.callId);
498
530
  const handler = toolHandlers.get(ev.name) ?? BUILTIN_TOOL_HANDLERS[ev.name];
499
531
  void (async () => {
500
532
  if (!session) return;
@@ -504,8 +536,16 @@ function createChatClient(options) {
504
536
  return;
505
537
  }
506
538
  try {
507
- const out = await handler(ev.args);
539
+ const raw = await handler(ev.args);
540
+ let after = null;
541
+ let out = raw;
542
+ if (raw && typeof raw === "object" && typeof raw[AFTER_RESULT] === "function") {
543
+ const { [AFTER_RESULT]: run, ...rest } = raw;
544
+ after = run;
545
+ out = rest;
546
+ }
508
547
  await postToolResult({ ...base, ok: true, result: typeof out === "string" ? out : JSON.stringify(out ?? { ok: true }) });
548
+ after?.();
509
549
  } catch (err) {
510
550
  await postToolResult({ ...base, ok: false, result: err.message });
511
551
  }
@@ -587,6 +627,16 @@ function createChatClient(options) {
587
627
  if (resumed) {
588
628
  session = resumed;
589
629
  emit({ type: "agent", agent: resumed.agent });
630
+ try {
631
+ const history2 = await getHistory({ baseUrl: options.baseUrl, sessionId: resumed.sessionId, sessionToken: resumed.sessionToken, fetchImpl });
632
+ for (const h of history2) {
633
+ const turnId = h.role === "assistant" || h.role === "human" ? h.turnId : void 0;
634
+ messages.push({ id: uid("m"), role: h.role, text: h.text, streaming: false, turnId });
635
+ if (h.turnId) seenTurnIds.add(h.turnId);
636
+ }
637
+ emitMessages();
638
+ } catch {
639
+ }
590
640
  await openStream();
591
641
  scheduleRefresh();
592
642
  flushJourney();
@@ -727,6 +777,44 @@ function createChatClient(options) {
727
777
  }
728
778
  };
729
779
  }
780
+
781
+ // src/page-theme.ts
782
+ var WATCHED = ["class", "data-theme", "data-mode", "style"];
783
+ function themeOf(node) {
784
+ for (const attr of ["data-theme", "data-mode"]) {
785
+ const v = node.getAttribute(attr);
786
+ if (v === "dark" || v === "light") return v;
787
+ }
788
+ if (node.classList.contains("dark")) return "dark";
789
+ if (node.classList.contains("light")) return "light";
790
+ return null;
791
+ }
792
+ function resolvePageTheme(el) {
793
+ for (let node = el.parentElement; node; node = node.parentElement) {
794
+ const t = themeOf(node);
795
+ if (t) return t;
796
+ }
797
+ const scheme = (document.documentElement.style.colorScheme || getComputedStyle(document.documentElement).colorScheme || "").trim();
798
+ return scheme === "dark" || scheme === "light" ? scheme : null;
799
+ }
800
+ function watchPageTheme(el, onChange) {
801
+ let last;
802
+ const check = () => {
803
+ const t = resolvePageTheme(el);
804
+ if (t !== last) {
805
+ last = t;
806
+ onChange(t);
807
+ }
808
+ };
809
+ check();
810
+ if (typeof MutationObserver === "undefined") return () => {
811
+ };
812
+ const mo = new MutationObserver(check);
813
+ for (let node = el.parentElement; node; node = node.parentElement) {
814
+ mo.observe(node, { attributes: true, attributeFilter: WATCHED });
815
+ }
816
+ return () => mo.disconnect();
817
+ }
730
818
  marked.marked.setOptions({ gfm: true, breaks: true });
731
819
  var hookInstalled = false;
732
820
  function installLinkHook() {
@@ -809,11 +897,15 @@ var STYLES = `
809
897
  * body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
810
898
  * inline with !important at mount (see element.ts) to beat an external !important rule. */
811
899
  display: block; visibility: visible; opacity: 1;
900
+ /* The closed panel still takes up its full size in the layout (it fades, it is not removed), so
901
+ * the host box covers ~384x620px of the page. The host and its wrapper must not catch clicks;
902
+ * only the visible parts opt back in (launcher, teaser while shown, panel while open). */
903
+ pointer-events: none;
812
904
  font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
813
905
  -webkit-font-smoothing: antialiased;
814
906
  }
815
907
  @media (prefers-color-scheme: dark) {
816
- :host(:not([theme="light"])) {
908
+ :host(:not([theme="light"]):not([data-oc-page-theme="light"])) {
817
909
  --oc-bg: #000000; --oc-bg-sunk: #000000;
818
910
  --oc-fg: #FFFFFF; --oc-fg-muted: rgba(255,255,255,0.68); --oc-fg-dim: rgba(255,255,255,0.42);
819
911
  --oc-border: rgba(255,255,255,0.09);
@@ -823,7 +915,9 @@ var STYLES = `
823
915
  --oc-glass-border: rgba(255,255,255,0.12);
824
916
  }
825
917
  }
826
- :host([theme="dark"]) {
918
+ /* Explicit dark, or auto on a page that declares dark (data-oc-page-theme, set by page-theme.ts). The OS rule above
919
+ * only applies when the page declares nothing; a page that declares light keeps the light base. */
920
+ :host([theme="dark"]), :host(:not([theme="light"])[data-oc-page-theme="dark"]) {
827
921
  --oc-bg: #000000; --oc-bg-sunk: #000000;
828
922
  --oc-fg: #FFFFFF; --oc-fg-muted: rgba(255,255,255,0.68); --oc-fg-dim: rgba(255,255,255,0.42);
829
923
  --oc-border: rgba(255,255,255,0.09);
@@ -835,7 +929,7 @@ var STYLES = `
835
929
  * { box-sizing: border-box; }
836
930
  button { font-family: inherit; }
837
931
 
838
- .oc { display: flex; flex-direction: column; align-items: flex-end; gap: 14px; }
932
+ .oc { display: flex; flex-direction: column; align-items: flex-end; gap: 14px; pointer-events: none; }
839
933
 
840
934
  /* \u2500\u2500 Launcher \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */
841
935
  .launcher {
@@ -844,6 +938,7 @@ var STYLES = `
844
938
  box-shadow: 0 8px 22px -8px color-mix(in oklab, var(--oc-ac) 70%, transparent), 0 18px 48px -18px rgba(0,0,0,0.6);
845
939
  color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer;
846
940
  transition: transform .26s var(--oc-bounce), filter .2s var(--oc-ease); align-self: flex-end;
941
+ pointer-events: auto;
847
942
  }
848
943
  .launcher:hover { transform: scale(1.06); }
849
944
  .launcher:active { transform: scale(0.93); }
@@ -1211,6 +1306,7 @@ var OyehelloChatElement = class extends HTMLElement {
1211
1306
  // Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
1212
1307
  // connectedCallback and torn down in disconnectedCallback alongside everything else.
1213
1308
  uninstallSpaHooks = null;
1309
+ unwatchPageTheme = null;
1214
1310
  // ── shadow refs ──
1215
1311
  oc;
1216
1312
  bodyEl;
@@ -1244,6 +1340,11 @@ var OyehelloChatElement = class extends HTMLElement {
1244
1340
  this.uninstallSpaHooks = null;
1245
1341
  }
1246
1342
  this.renderShell();
1343
+ this.unwatchPageTheme?.();
1344
+ this.unwatchPageTheme = watchPageTheme(this, (t) => {
1345
+ if (t) this.setAttribute("data-oc-page-theme", t);
1346
+ else this.removeAttribute("data-oc-page-theme");
1347
+ });
1247
1348
  this.style.setProperty("display", "block", "important");
1248
1349
  this.style.setProperty("visibility", "visible", "important");
1249
1350
  this.style.setProperty("opacity", "1", "important");
@@ -1354,6 +1455,8 @@ var OyehelloChatElement = class extends HTMLElement {
1354
1455
  this.teardownIdle();
1355
1456
  this.uninstallSpaHooks?.();
1356
1457
  this.uninstallSpaHooks = null;
1458
+ this.unwatchPageTheme?.();
1459
+ this.unwatchPageTheme = null;
1357
1460
  for (const t of this.timers) clearTimeout(t);
1358
1461
  for (const url of this.imagePreviewCache.values()) {
1359
1462
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -1364,6 +1467,11 @@ var OyehelloChatElement = class extends HTMLElement {
1364
1467
  if (this.client) this.client.registerTools(handlers);
1365
1468
  else Object.assign(this.pendingTools, handlers);
1366
1469
  }
1470
+ /** Open the chat window, exactly as a launcher click does. No-op if already open or not rendered. */
1471
+ open() {
1472
+ if (!this.oc || this.uiOpen) return;
1473
+ this.openPanel();
1474
+ }
1367
1475
  key(k) {
1368
1476
  return `oyehello-chat:${k}:${this.getAttribute("agent-id") ?? ""}`;
1369
1477
  }