@oyehello/chat 0.5.1 → 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.
@@ -74,23 +74,35 @@ await chat.sendMessage('Hello');
74
74
 
75
75
  ## Client tools (browser actions)
76
76
 
77
- An agent can call browser-side tools your page executes. Register a handler per tool; a tool
78
- reaches the agent only when it is (a) enabled for that agent in the console AND (b) registered here:
77
+ The SDK supports two browser-side tools: `navigate` (go to a path on the current site) and `openUrl`
78
+ (open a URL, by default in a new tab). Once a tool is enabled for the agent in the console **and** the
79
+ target passes the agent's navigate allowlist (also set in the console), the SDK executes it itself —
80
+ **no host integration required**:
81
+ ```html
82
+ <script type="module">import '@oyehello/chat';</script>
83
+ <oyehello-chat api-key="ohk_pub_…" agent-id="agt_…" base-url="https://api.oyehello.ai"></oyehello-chat>
84
+ ```
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)` (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.
90
+
91
+ A host page MAY still register its own `navigate`/`openUrl` handler to override the default behavior
92
+ (e.g. push through a client-side router instead of a full navigation):
79
93
  ```js
80
94
  const chat = createChatClient({ apiKey, agentId, baseUrl });
81
95
  chat.registerTools({
82
96
  navigate: ({ path }) => router.push(path),
83
- fillField: ({ selector, value }) => { /* your DOM logic */ },
84
97
  });
85
98
  // or on the web component: document.querySelector('oyehello-chat').registerTools({ … })
86
99
  ```
87
- Register handlers BEFORE `start()` — only tools registered at open are offered to the agent that session.
100
+ Register handlers BEFORE `start()`. A host-registered handler always takes priority over the SDK's
101
+ built-in; any other tool name has no handler and fails cleanly.
88
102
 
89
- > **Security.** `navigate`/`openUrl` targets can be constrained to an allowlist in the agent's console
90
- > settings. The other tools (`click`, `fillField`, `readPageContext`, `custom`) receive
91
- > model-generated arguments with no server-side restriction: **your handler is the trust boundary.**
92
- > Because a visitor can influence the conversation, treat these as capable of arbitrary DOM action —
93
- > validate selectors/values and scope what each handler can do before enabling them.
103
+ > **Security.** Navigation targets (both the SDK's built-in execution and a host-overridden handler)
104
+ > are validated against the agent's navigate allowlist **server-side** before the tool call ever
105
+ > reaches the browser — the SDK/host is not the trust boundary for where a visitor can be sent.
94
106
 
95
107
  ## Requirements
96
108
  - `api-key` is a **publishable** key (`ohk_pub_…`) created in the oyehello console.
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",
@@ -314,6 +324,33 @@ function installSpaHooks(onNav) {
314
324
  // src/client.ts
315
325
  var seq = 0;
316
326
  var uid = (p) => `${p}_${Date.now().toString(36)}_${(seq++).toString(36)}`;
327
+ var isSafeRelativePath = (p) => p.startsWith("/") && !p.startsWith("//");
328
+ var isSafeHttpUrl = (u) => {
329
+ try {
330
+ const parsed = new URL(u, window.location.origin);
331
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
332
+ } catch {
333
+ return false;
334
+ }
335
+ };
336
+ var AFTER_RESULT = "__ohAfterResult";
337
+ var BUILTIN_TOOL_HANDLERS = {
338
+ navigate: (args) => {
339
+ const path = String(args.path ?? "");
340
+ if (!isSafeRelativePath(path)) return { ok: false, error: "unsafe navigate path" };
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) };
345
+ },
346
+ openUrl: (args) => {
347
+ const url = String(args.url ?? "");
348
+ if (!isSafeHttpUrl(url)) return { ok: false, error: "unsafe url" };
349
+ const sameHost = new URL(url, window.location.origin).hostname === window.location.hostname;
350
+ window.open(url, sameHost ? "_self" : "_blank", "noopener,noreferrer");
351
+ return { ok: true };
352
+ }
353
+ };
317
354
  function createChatClient(options) {
318
355
  const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
319
356
  const ES = options.eventSource ?? globalThis.EventSource;
@@ -422,6 +459,23 @@ function createChatClient(options) {
422
459
  } catch {
423
460
  }
424
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
+ };
425
479
  const emit = (e) => {
426
480
  for (const l of listeners) l(e);
427
481
  };
@@ -470,9 +524,10 @@ function createChatClient(options) {
470
524
  } else if (ev.type === "error") {
471
525
  emit({ type: "error", error: new Error(ev.message) });
472
526
  } else if (ev.type === "tool_call") {
473
- if (seenCallIds.has(ev.callId)) return;
527
+ if (seenCallIds.has(ev.callId) || handledCallIds().includes(ev.callId)) return;
474
528
  seenCallIds.add(ev.callId);
475
- const handler = toolHandlers.get(ev.name);
529
+ rememberHandledCall(ev.callId);
530
+ const handler = toolHandlers.get(ev.name) ?? BUILTIN_TOOL_HANDLERS[ev.name];
476
531
  void (async () => {
477
532
  if (!session) return;
478
533
  const base = { baseUrl: options.baseUrl, sessionId: session.sessionId, sessionToken: session.sessionToken, callId: ev.callId, fetchImpl };
@@ -481,8 +536,16 @@ function createChatClient(options) {
481
536
  return;
482
537
  }
483
538
  try {
484
- 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
+ }
485
547
  await postToolResult({ ...base, ok: true, result: typeof out === "string" ? out : JSON.stringify(out ?? { ok: true }) });
548
+ after?.();
486
549
  } catch (err) {
487
550
  await postToolResult({ ...base, ok: false, result: err.message });
488
551
  }
@@ -564,13 +627,24 @@ function createChatClient(options) {
564
627
  if (resumed) {
565
628
  session = resumed;
566
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
+ }
567
640
  await openStream();
568
641
  scheduleRefresh();
569
642
  flushJourney();
570
643
  return;
571
644
  }
572
645
  if (deviceFingerprint === void 0) deviceFingerprint = await computeDeviceFingerprint().catch(() => null);
573
- session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(toolHandlers.keys()), fingerprint: deviceFingerprint ?? void 0, fetchImpl });
646
+ const advertisedTools = /* @__PURE__ */ new Set([...toolHandlers.keys(), ...Object.keys(BUILTIN_TOOL_HANDLERS)]);
647
+ session = await openSession({ baseUrl: options.baseUrl, apiKey: options.apiKey, agentId: options.agentId, user: options.user, clientTools: Array.from(advertisedTools), fingerprint: deviceFingerprint ?? void 0, fetchImpl });
574
648
  persistSession(session);
575
649
  if (!warnedProtocol && typeof session.protocol === "number" && session.protocol !== CHAT_PROTOCOL_VERSION) {
576
650
  warnedProtocol = true;
@@ -703,6 +777,44 @@ function createChatClient(options) {
703
777
  }
704
778
  };
705
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
+ }
706
818
  marked.marked.setOptions({ gfm: true, breaks: true });
707
819
  var hookInstalled = false;
708
820
  function installLinkHook() {
@@ -785,11 +897,15 @@ var STYLES = `
785
897
  * body children (oyehello-chat { display:none }) can't silently kill the widget. Also pinned
786
898
  * inline with !important at mount (see element.ts) to beat an external !important rule. */
787
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;
788
904
  font-family: 'Geist', system-ui, -apple-system, 'Helvetica Neue', sans-serif;
789
905
  -webkit-font-smoothing: antialiased;
790
906
  }
791
907
  @media (prefers-color-scheme: dark) {
792
- :host(:not([theme="light"])) {
908
+ :host(:not([theme="light"]):not([data-oc-page-theme="light"])) {
793
909
  --oc-bg: #000000; --oc-bg-sunk: #000000;
794
910
  --oc-fg: #FFFFFF; --oc-fg-muted: rgba(255,255,255,0.68); --oc-fg-dim: rgba(255,255,255,0.42);
795
911
  --oc-border: rgba(255,255,255,0.09);
@@ -799,7 +915,9 @@ var STYLES = `
799
915
  --oc-glass-border: rgba(255,255,255,0.12);
800
916
  }
801
917
  }
802
- :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"]) {
803
921
  --oc-bg: #000000; --oc-bg-sunk: #000000;
804
922
  --oc-fg: #FFFFFF; --oc-fg-muted: rgba(255,255,255,0.68); --oc-fg-dim: rgba(255,255,255,0.42);
805
923
  --oc-border: rgba(255,255,255,0.09);
@@ -811,7 +929,7 @@ var STYLES = `
811
929
  * { box-sizing: border-box; }
812
930
  button { font-family: inherit; }
813
931
 
814
- .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; }
815
933
 
816
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 */
817
935
  .launcher {
@@ -820,6 +938,7 @@ var STYLES = `
820
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);
821
939
  color: #fff; display: flex; align-items: center; justify-content: center; cursor: pointer;
822
940
  transition: transform .26s var(--oc-bounce), filter .2s var(--oc-ease); align-self: flex-end;
941
+ pointer-events: auto;
823
942
  }
824
943
  .launcher:hover { transform: scale(1.06); }
825
944
  .launcher:active { transform: scale(0.93); }
@@ -1187,6 +1306,7 @@ var OyehelloChatElement = class extends HTMLElement {
1187
1306
  // Uninstaller for the SPA history-navigation hooks (journey tracking), installed in
1188
1307
  // connectedCallback and torn down in disconnectedCallback alongside everything else.
1189
1308
  uninstallSpaHooks = null;
1309
+ unwatchPageTheme = null;
1190
1310
  // ── shadow refs ──
1191
1311
  oc;
1192
1312
  bodyEl;
@@ -1220,6 +1340,11 @@ var OyehelloChatElement = class extends HTMLElement {
1220
1340
  this.uninstallSpaHooks = null;
1221
1341
  }
1222
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
+ });
1223
1348
  this.style.setProperty("display", "block", "important");
1224
1349
  this.style.setProperty("visibility", "visible", "important");
1225
1350
  this.style.setProperty("opacity", "1", "important");
@@ -1330,6 +1455,8 @@ var OyehelloChatElement = class extends HTMLElement {
1330
1455
  this.teardownIdle();
1331
1456
  this.uninstallSpaHooks?.();
1332
1457
  this.uninstallSpaHooks = null;
1458
+ this.unwatchPageTheme?.();
1459
+ this.unwatchPageTheme = null;
1333
1460
  for (const t of this.timers) clearTimeout(t);
1334
1461
  for (const url of this.imagePreviewCache.values()) {
1335
1462
  if (typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(url);
@@ -1340,6 +1467,11 @@ var OyehelloChatElement = class extends HTMLElement {
1340
1467
  if (this.client) this.client.registerTools(handlers);
1341
1468
  else Object.assign(this.pendingTools, handlers);
1342
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
+ }
1343
1475
  key(k) {
1344
1476
  return `oyehello-chat:${k}:${this.getAttribute("agent-id") ?? ""}`;
1345
1477
  }