@agentforge-io/chat-sdk 2.0.20 → 2.0.22

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.
@@ -131,8 +131,12 @@ export interface ChatSessionOptions {
131
131
  token: string;
132
132
  /**
133
133
  * Base URL of the AgentForge API, including scheme. Trailing slash is
134
- * stripped. Defaults to the origin the SDK was loaded from when running
135
- * inside a browser bundle of the widget; required everywhere else.
134
+ * stripped. Resolution order:
135
+ * 1. This option (when present).
136
+ * 2. `window.AGENTFORGE_API_BASE_URL` (runtime override, useful when
137
+ * embedding via a `<script>` tag without React props).
138
+ * 3. The baked default that ships with the current SDK version.
139
+ * Hosts embedding into their own site can leave this unset.
136
140
  */
137
141
  apiBaseUrl?: string;
138
142
  /** Stable id for this end-user's browser. Persist it (localStorage etc.)
package/dist/react.d.ts CHANGED
@@ -18,22 +18,18 @@
18
18
  import { type CSSProperties } from 'react';
19
19
  import type { ChatTheme } from './entities';
20
20
  /**
21
- * Optional callback invoked when the visitor clicks the Approve/Deny
22
- * button on a tool-approval bubble. When omitted, the bubble renders
23
- * as a read-only "waiting" hint the visitor can't decide from inside
24
- * the widget. When wired, the widget calls this with `{approvalId,
25
- * action}` and the host implementation typically POSTs to
26
- * `/public/chat/approvals/:id/{approve|deny}` with the visitor's
27
- * `browserSessionId` for ownership verification.
28
- *
29
- * Throwing or rejecting from the handler keeps the bubble in its
30
- * pending state and surfaces the error inline so the visitor can
31
- * retry.
21
+ * Optional observer fired after the SDK resolves a tool-approval bubble.
22
+ * The SDK owns the network call (it already has `apiBaseUrl` and the
23
+ * `browserSessionId`); the host only gets notified for analytics or
24
+ * audit logging. The hook is fire-and-forget throwing from it does
25
+ * not roll back the decision the server already committed.
32
26
  */
33
- export type ApprovalActionHandler = (args: {
27
+ export type ApprovalDecisionObserver = (args: {
34
28
  approvalId: string;
35
29
  action: 'approve' | 'deny';
36
- }) => Promise<void>;
30
+ ok: boolean;
31
+ error?: string;
32
+ }) => void;
37
33
  /**
38
34
  * Localizable copy for the approval / blocked bubbles. Pass via the
39
35
  * `approvalCopy` prop on `<ChatWidget>` to drop the defaults (Spanish,
@@ -65,8 +61,12 @@ export interface ApprovalCopy {
65
61
  export interface ChatWidgetProps {
66
62
  /** Public chat token (`aft_*`) issued from the admin UI. */
67
63
  token: string;
68
- /** AgentForge API origin. Defaults to `window.location.origin` in the
69
- * browser set this when your site and the API live on different hosts. */
64
+ /**
65
+ * AgentForge API origin. Optional the SDK ships with a built-in
66
+ * default that points at the hosted AgentForge deployment. Override
67
+ * this when you self-host the backend. A `window.AGENTFORGE_API_BASE_URL`
68
+ * global also overrides, useful for `<script>`-tag embeds.
69
+ */
70
70
  apiBaseUrl?: string;
71
71
  /** Render inline (fills the parent) instead of as a floating bubble. */
72
72
  inline?: boolean;
@@ -84,11 +84,19 @@ export interface ChatWidgetProps {
84
84
  /** Inline style on the root container. */
85
85
  style?: CSSProperties;
86
86
  /**
87
- * Handler for tool-approval bubble actions. See `ApprovalActionHandler`
88
- * for the contract. Omit to render approval messages as read-only
89
- * hints.
87
+ * Optional observer called after the SDK resolves an approval bubble.
88
+ * The SDK handles the network call internally pass this only if you
89
+ * want to record the decision for analytics. See
90
+ * `ApprovalDecisionObserver`.
91
+ */
92
+ onApprovalDecision?: ApprovalDecisionObserver;
93
+ /**
94
+ * When `false` (the default), the Approve/Deny buttons render on
95
+ * `awaiting_approval` bubbles. Set to `true` to render bubbles as
96
+ * read-only — useful for transcripts/embeds where the visitor cannot
97
+ * legitimately decide.
90
98
  */
91
- onApprovalAction?: ApprovalActionHandler;
99
+ readOnlyApprovals?: boolean;
92
100
  }
93
101
  /**
94
102
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
package/dist/react.js CHANGED
@@ -181,7 +181,7 @@ function fallbackCopy(ctx) {
181
181
  * SDK event. Consumers don't need to read `session.getState()` themselves.
182
182
  */
183
183
  function ChatWidget(props) {
184
- const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalAction, } = props;
184
+ const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalDecision, readOnlyApprovals = false, } = props;
185
185
  const [session, setSession] = (0, react_1.useState)(null);
186
186
  const [status, setStatus] = (0, react_1.useState)('idle');
187
187
  const [agent, setAgent] = (0, react_1.useState)();
@@ -301,7 +301,7 @@ function ChatWidget(props) {
301
301
  };
302
302
  return ((0, jsx_runtime_1.jsxs)("div", { className: rootClass, style: rootStyle, "data-style-id": styleId, children: [!inline && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-toggle", "aria-label": open ? 'Close chat' : 'Open chat', "aria-expanded": open, onClick: () => setOpen((v) => !v), children: open ? (0, jsx_runtime_1.jsx)(CloseIcon, {}) : (0, jsx_runtime_1.jsx)(ChatIcon, {}) })), (0, jsx_runtime_1.jsxs)("div", { className: `af-panel ${open ? 'af-open' : ''}`, children: [(0, jsx_runtime_1.jsxs)("div", { className: "af-header", children: [theme?.avatarUrl ? (
303
303
  // eslint-disable-next-line @next/next/no-img-element
304
- (0, jsx_runtime_1.jsx)("img", { className: "af-header-avatar", src: theme.avatarUrl, alt: "" })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "af-header-info", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-header-title", children: theme?.title ?? agent?.name ?? 'Chat' }), (0, jsx_runtime_1.jsx)("div", { className: "af-header-subtitle", children: status === 'loading' ? 'Loading…' : agent?.description ?? '' })] }), !inline && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-close", onClick: () => setOpen(false), "aria-label": "Close chat", children: (0, jsx_runtime_1.jsx)(CloseIcon, {}) }))] }), (0, jsx_runtime_1.jsx)("div", { className: "af-messages", ref: messagesRef, children: messages.map((m) => ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m, onApprovalAction: onApprovalAction, onContinue: () => {
304
+ (0, jsx_runtime_1.jsx)("img", { className: "af-header-avatar", src: theme.avatarUrl, alt: "" })) : null, (0, jsx_runtime_1.jsxs)("div", { className: "af-header-info", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-header-title", children: theme?.title ?? agent?.name ?? 'Chat' }), (0, jsx_runtime_1.jsx)("div", { className: "af-header-subtitle", children: status === 'loading' ? 'Loading…' : agent?.description ?? '' })] }), !inline && ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-close", onClick: () => setOpen(false), "aria-label": "Close chat", children: (0, jsx_runtime_1.jsx)(CloseIcon, {}) }))] }), (0, jsx_runtime_1.jsx)("div", { className: "af-messages", ref: messagesRef, children: messages.map((m) => ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m, session: session, readOnly: readOnlyApprovals, onDecision: onApprovalDecision, onContinue: () => {
305
305
  // After a successful Approve, kick the next turn so the
306
306
  // gate's fast-path consumes the approval and the tool
307
307
  // actually runs. `silent: true` keeps the literal
@@ -315,10 +315,10 @@ function ChatWidget(props) {
315
315
  }, 250);
316
316
  } }, m.id))) }), lastError && status === 'error' && ((0, jsx_runtime_1.jsx)("div", { className: "af-error", children: lastError })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row", children: [(0, jsx_runtime_1.jsx)("textarea", { className: "af-input", value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: onKeyDown, placeholder: "Type a message\u2026", rows: 1, disabled: status === 'ended' || status === 'loading' || status === 'idle' }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-send", onClick: handleSend, disabled: sendDisabled, "aria-label": "Send message", children: (0, jsx_runtime_1.jsx)(SendIcon, {}) })] }), (0, jsx_runtime_1.jsx)("div", { className: "af-footer", children: "Powered by AgentForge" })] })] }));
317
317
  }
318
- function MessageBubble({ message, onApprovalAction, onContinue, }) {
318
+ function MessageBubble({ message, session, readOnly, onDecision, onContinue, }) {
319
319
  const kind = message.metadata?.kind;
320
320
  if (kind === 'awaiting_approval') {
321
- return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, onAction: onApprovalAction, onContinue: onContinue }));
321
+ return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }));
322
322
  }
323
323
  if (kind === 'tool_blocked') {
324
324
  return (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message });
@@ -348,7 +348,7 @@ function MessageBubble({ message, onApprovalAction, onContinue, }) {
348
348
  * so the chat auto-sends "continue" and the gate's fast-path consumes
349
349
  * the row on the next turn.
350
350
  */
351
- function ApprovalBubble({ message, onAction, onContinue, }) {
351
+ function ApprovalBubble({ message, session, readOnly, onDecision, onContinue, }) {
352
352
  const meta = message.metadata;
353
353
  const [busy, setBusy] = (0, react_1.useState)(null);
354
354
  const [decided, setDecided] = (0, react_1.useState)(null);
@@ -361,24 +361,27 @@ function ApprovalBubble({ message, onAction, onContinue, }) {
361
361
  if (!meta)
362
362
  return null;
363
363
  const act = async (action) => {
364
- if (!onAction)
364
+ if (!session)
365
365
  return;
366
366
  setBusy(action);
367
367
  setErr(null);
368
368
  try {
369
- await onAction({ approvalId: meta.approvalId, action });
369
+ await session.resolveApproval(meta.approvalId, action);
370
370
  setDecided(action === 'approve' ? 'approved' : 'denied');
371
+ onDecision?.({ approvalId: meta.approvalId, action, ok: true });
371
372
  if (action === 'approve')
372
373
  onContinue();
373
374
  }
374
375
  catch (e) {
375
- setErr(e instanceof Error ? e.message : String(e));
376
+ const message = e instanceof Error ? e.message : String(e);
377
+ setErr(message);
378
+ onDecision?.({ approvalId: meta.approvalId, action, ok: false, error: message });
376
379
  }
377
380
  finally {
378
381
  setBusy(null);
379
382
  }
380
383
  };
381
- return ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg af-msg-approval", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-approval-title", children: copy.title }), (0, jsx_runtime_1.jsx)("div", { className: "af-approval-body", children: copy.body }), remaining && !decided && ((0, jsx_runtime_1.jsx)("div", { className: "af-approval-meta", children: remaining })), decided && ((0, jsx_runtime_1.jsx)("div", { className: `af-approval-pill af-approval-pill-${decided}`, children: decided === 'approved' ? copy.approvedPill : copy.deniedPill })), !decided && onAction && ((0, jsx_runtime_1.jsxs)("div", { className: "af-approval-actions", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-approval-btn af-approval-approve", disabled: busy !== null, onClick: () => act('approve'), children: busy === 'approve' ? copy.approveBusyLabel : copy.approveLabel }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-approval-btn af-approval-deny", disabled: busy !== null, onClick: () => act('deny'), children: busy === 'deny' ? copy.denyBusyLabel : copy.denyLabel })] })), !decided && !onAction && ((0, jsx_runtime_1.jsx)("div", { className: "af-approval-hint", children: copy.readOnlyHint })), err && (0, jsx_runtime_1.jsx)("div", { className: "af-approval-error", children: err })] }));
384
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg af-msg-approval", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-approval-title", children: copy.title }), (0, jsx_runtime_1.jsx)("div", { className: "af-approval-body", children: copy.body }), remaining && !decided && ((0, jsx_runtime_1.jsx)("div", { className: "af-approval-meta", children: remaining })), decided && ((0, jsx_runtime_1.jsx)("div", { className: `af-approval-pill af-approval-pill-${decided}`, children: decided === 'approved' ? copy.approvedPill : copy.deniedPill })), !decided && !readOnly && session && ((0, jsx_runtime_1.jsxs)("div", { className: "af-approval-actions", children: [(0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-approval-btn af-approval-approve", disabled: busy !== null, onClick: () => act('approve'), children: busy === 'approve' ? copy.approveBusyLabel : copy.approveLabel }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-approval-btn af-approval-deny", disabled: busy !== null, onClick: () => act('deny'), children: busy === 'deny' ? copy.denyBusyLabel : copy.denyLabel })] })), !decided && readOnly && ((0, jsx_runtime_1.jsx)("div", { className: "af-approval-hint", children: copy.readOnlyHint })), err && (0, jsx_runtime_1.jsx)("div", { className: "af-approval-error", children: err })] }));
382
385
  }
383
386
  /**
384
387
  * Terminal "tool blocked" bubble. No action — just informs the visitor
package/dist/session.d.ts CHANGED
@@ -36,6 +36,13 @@ export declare class ChatSession {
36
36
  * continue chatting.
37
37
  */
38
38
  end(): Promise<void>;
39
+ /**
40
+ * Resolve a pending tool-approval bubble. The SDK owns the transport
41
+ * call so hosts don't have to know the approval endpoint or assemble
42
+ * the `browserSessionId` payload. Errors propagate so the calling View
43
+ * layer can keep the bubble in pending state and show the message.
44
+ */
45
+ resolveApproval(approvalId: string, action: 'approve' | 'deny'): Promise<void>;
39
46
  /** Tear down. Future sends throw. Useful for SPA unmount. */
40
47
  destroy(): void;
41
48
  /**
package/dist/session.js CHANGED
@@ -29,9 +29,6 @@ class ChatSession {
29
29
  if (!opts.token)
30
30
  throw new Error('ChatSession: token is required');
31
31
  const apiBaseUrl = opts.apiBaseUrl ?? defaultApiBase();
32
- if (!apiBaseUrl) {
33
- throw new Error('ChatSession: apiBaseUrl is required outside the browser');
34
- }
35
32
  this.transport = new transport_1.HttpTransport(apiBaseUrl, opts.token);
36
33
  this.stream = opts.stream ?? true;
37
34
  this.browserSessionId = opts.browserSessionId ?? generateBrowserSessionId();
@@ -129,6 +126,15 @@ class ChatSession {
129
126
  this.handleError(err);
130
127
  }
131
128
  }
129
+ /**
130
+ * Resolve a pending tool-approval bubble. The SDK owns the transport
131
+ * call so hosts don't have to know the approval endpoint or assemble
132
+ * the `browserSessionId` payload. Errors propagate so the calling View
133
+ * layer can keep the bubble in pending state and show the message.
134
+ */
135
+ async resolveApproval(approvalId, action) {
136
+ await this.transport.resolveApproval(approvalId, action, this.browserSessionId);
137
+ }
132
138
  /** Tear down. Future sends throw. Useful for SPA unmount. */
133
139
  destroy() {
134
140
  this.listeners.clear();
@@ -383,11 +389,21 @@ function generateBrowserSessionId() {
383
389
  return c.randomUUID();
384
390
  return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
385
391
  }
392
+ /**
393
+ * Build-time default API origin. Bumped together with the SDK when the
394
+ * hosted AgentForge deployment moves to a new domain. Hosts that
395
+ * self-host the backend override this via the `apiBaseUrl` constructor
396
+ * option, or at runtime via `window.AGENTFORGE_API_BASE_URL`.
397
+ */
398
+ const BAKED_API_BASE = 'https://api-agentforge.stupidmvp.com';
386
399
  function defaultApiBase() {
387
- if (typeof window === 'undefined')
388
- return undefined;
389
- // When the SDK is bundled into the AgentForge-served widget, it loads from
390
- // the API origin so the same origin is the natural default. Consumers in
391
- // their own app will pass `apiBaseUrl` explicitly.
392
- return window.location.origin;
400
+ // Runtime override for hosts that embed via a script tag and don't
401
+ // want to touch React props. Set once on `window` before the widget
402
+ // mounts and the SDK uses it for every transport call.
403
+ if (typeof window !== 'undefined') {
404
+ const override = window.AGENTFORGE_API_BASE_URL;
405
+ if (override)
406
+ return override;
407
+ }
408
+ return BAKED_API_BASE;
393
409
  }
@@ -83,6 +83,15 @@ export declare class HttpTransport {
83
83
  metadata?: Record<string, unknown>;
84
84
  }>;
85
85
  } | null>;
86
+ /**
87
+ * Resolve a pending tool-approval bubble. The server verifies the row
88
+ * is owned by `browser:<browserSessionId>` before mutating it, so the
89
+ * SDK forwards the same browser session it uses for messaging. The
90
+ * route lives under `/public/chat/approvals/*` — siblings of this
91
+ * transport's `/public/chat/:token/*` routes — and does not need the
92
+ * widget token (the approvalId is the capability).
93
+ */
94
+ resolveApproval(approvalId: string, action: 'approve' | 'deny', browserSessionId: string): Promise<void>;
86
95
  endConversation(conversationId: string, browserSessionId: string): Promise<void>;
87
96
  /** Parse the server's SSE response body into typed events. */
88
97
  private readSse;
package/dist/transport.js CHANGED
@@ -111,6 +111,26 @@ class HttpTransport {
111
111
  })),
112
112
  };
113
113
  }
114
+ /**
115
+ * Resolve a pending tool-approval bubble. The server verifies the row
116
+ * is owned by `browser:<browserSessionId>` before mutating it, so the
117
+ * SDK forwards the same browser session it uses for messaging. The
118
+ * route lives under `/public/chat/approvals/*` — siblings of this
119
+ * transport's `/public/chat/:token/*` routes — and does not need the
120
+ * widget token (the approvalId is the capability).
121
+ */
122
+ async resolveApproval(approvalId, action, browserSessionId) {
123
+ const base = this.apiBaseUrl.replace(/\/$/, '');
124
+ const res = await fetch(`${base}/public/chat/approvals/${encodeURIComponent(approvalId)}/${action}`, {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({ browserSessionId }),
128
+ });
129
+ if (!res.ok && res.status !== 204) {
130
+ const data = await safeJson(res);
131
+ throw makeTransportError(data?.message ?? `HTTP ${res.status}`, res.status, data?.error);
132
+ }
133
+ }
114
134
  async endConversation(conversationId, browserSessionId) {
115
135
  const res = await fetch(this.url(`/conversations/${encodeURIComponent(conversationId)}/end`), {
116
136
  method: 'POST',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.0.20",
3
+ "version": "2.0.22",
4
4
  "description": "Framework-free chat session SDK for AgentForge public chat tokens. Headless — no DOM. Drop into any frontend (React, Vue, Svelte, vanilla) and listen for events.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",