@agentforge-io/chat-sdk 2.0.17 → 2.0.18

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/dist/react.d.ts CHANGED
@@ -17,6 +17,23 @@
17
17
  */
18
18
  import { type CSSProperties } from 'react';
19
19
  import type { ChatTheme } from './entities';
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.
32
+ */
33
+ export type ApprovalActionHandler = (args: {
34
+ approvalId: string;
35
+ action: 'approve' | 'deny';
36
+ }) => Promise<void>;
20
37
  export interface ChatWidgetProps {
21
38
  /** Public chat token (`aft_*`) issued from the admin UI. */
22
39
  token: string;
@@ -38,6 +55,12 @@ export interface ChatWidgetProps {
38
55
  className?: string;
39
56
  /** Inline style on the root container. */
40
57
  style?: CSSProperties;
58
+ /**
59
+ * Handler for tool-approval bubble actions. See `ApprovalActionHandler`
60
+ * for the contract. Omit to render approval messages as read-only
61
+ * hints.
62
+ */
63
+ onApprovalAction?: ApprovalActionHandler;
41
64
  }
42
65
  /**
43
66
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
package/dist/react.js CHANGED
@@ -158,7 +158,7 @@ function renderMarkdown(src) {
158
158
  * SDK event. Consumers don't need to read `session.getState()` themselves.
159
159
  */
160
160
  function ChatWidget(props) {
161
- const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, } = props;
161
+ const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalAction, } = props;
162
162
  const [session, setSession] = (0, react_1.useState)(null);
163
163
  const [status, setStatus] = (0, react_1.useState)('idle');
164
164
  const [agent, setAgent] = (0, react_1.useState)();
@@ -278,9 +278,31 @@ function ChatWidget(props) {
278
278
  };
279
279
  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 ? (
280
280
  // eslint-disable-next-line @next/next/no-img-element
281
- (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 }, 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" })] })] }));
281
+ (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: () => {
282
+ // After a successful Approve, kick the next turn so the
283
+ // gate's fast-path consumes the approval and the tool
284
+ // actually runs. Delay = let the "Approved" pill flash
285
+ // for a beat before the assistant bubble appears.
286
+ if (!session)
287
+ return;
288
+ setTimeout(() => {
289
+ void session.send('continue');
290
+ }, 250);
291
+ } }, 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" })] })] }));
282
292
  }
283
- function MessageBubble({ message }) {
293
+ function MessageBubble({ message, onApprovalAction, onContinue, }) {
294
+ // Structured metadata takes precedence over plain text. The server
295
+ // tags messages with `kind: 'awaiting_approval' | 'tool_blocked'`
296
+ // and the widget renders a dedicated bubble for each — unknown
297
+ // kinds fall through to the markdown renderer with the plain text
298
+ // body the server also persists.
299
+ const kind = message.metadata?.kind;
300
+ if (kind === 'awaiting_approval') {
301
+ return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, onAction: onApprovalAction, onContinue: onContinue }));
302
+ }
303
+ if (kind === 'tool_blocked') {
304
+ return (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message });
305
+ }
284
306
  const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
285
307
  ? message.content
286
308
  ? ' af-msg-streaming'
@@ -299,6 +321,72 @@ function MessageBubble({ message }) {
299
321
  // User & system messages stay as plain text — they're typed verbatim.
300
322
  return (0, jsx_runtime_1.jsx)("div", { className: cls, children: message.content });
301
323
  }
324
+ /**
325
+ * Awaiting-approval bubble. Renders the tool name + a countdown, and
326
+ * either the Approve/Deny buttons (when the host wired the handler)
327
+ * or a read-only hint. After a successful approve, fires `onContinue`
328
+ * so the chat auto-sends "continue" and the gate's fast-path consumes
329
+ * the row on the next turn.
330
+ */
331
+ function ApprovalBubble({ message, onAction, onContinue, }) {
332
+ const meta = message.metadata;
333
+ const [busy, setBusy] = (0, react_1.useState)(null);
334
+ const [decided, setDecided] = (0, react_1.useState)(null);
335
+ const [err, setErr] = (0, react_1.useState)(null);
336
+ const remaining = useExpiresIn(meta?.expiresAt);
337
+ if (!meta)
338
+ return null;
339
+ const act = async (action) => {
340
+ if (!onAction)
341
+ return;
342
+ setBusy(action);
343
+ setErr(null);
344
+ try {
345
+ await onAction({ approvalId: meta.approvalId, action });
346
+ setDecided(action === 'approve' ? 'approved' : 'denied');
347
+ if (action === 'approve')
348
+ onContinue();
349
+ }
350
+ catch (e) {
351
+ setErr(e instanceof Error ? e.message : String(e));
352
+ }
353
+ finally {
354
+ setBusy(null);
355
+ }
356
+ };
357
+ 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: "Awaiting approval" }), (0, jsx_runtime_1.jsxs)("div", { className: "af-approval-body", children: ["The agent wants to run", ' ', (0, jsx_runtime_1.jsx)("code", { className: "af-approval-tool", children: meta.toolName }), "."] }), 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' ? 'Approved — retrying…' : 'Denied' })), !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' ? 'Approving…' : 'Approve' }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-approval-btn af-approval-deny", disabled: busy !== null, onClick: () => act('deny'), children: busy === 'deny' ? 'Denying…' : 'Deny' })] })), !decided && !onAction && ((0, jsx_runtime_1.jsx)("div", { className: "af-approval-hint", children: "A workspace admin needs to review this." })), err && (0, jsx_runtime_1.jsx)("div", { className: "af-approval-error", children: err })] }));
358
+ }
359
+ /**
360
+ * Terminal "tool blocked" bubble. No action — just informs the visitor
361
+ * that the tool the agent tried isn't available.
362
+ */
363
+ function BlockedBubble({ message }) {
364
+ const meta = message.metadata;
365
+ if (!meta)
366
+ return null;
367
+ return ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg af-msg-blocked", children: [(0, jsx_runtime_1.jsx)("div", { className: "af-blocked-title", children: "Tool blocked" }), (0, jsx_runtime_1.jsxs)("div", { className: "af-blocked-body", children: [(0, jsx_runtime_1.jsx)("code", { className: "af-approval-tool", children: meta.toolName }), " is blocked by the workspace admin.", ' ', meta.reason ?? ''] })] }));
368
+ }
369
+ /** Live "expires in" countdown. Returns null after the deadline. */
370
+ function useExpiresIn(iso) {
371
+ const [, force] = (0, react_1.useState)(0);
372
+ (0, react_1.useEffect)(() => {
373
+ if (!iso)
374
+ return;
375
+ const t = setInterval(() => force((n) => n + 1), 1000);
376
+ return () => clearInterval(t);
377
+ }, [iso]);
378
+ if (!iso)
379
+ return null;
380
+ const ms = new Date(iso).getTime() - Date.now();
381
+ if (!Number.isFinite(ms) || ms <= 0)
382
+ return null;
383
+ const totalSeconds = Math.floor(ms / 1000);
384
+ const m = Math.floor(totalSeconds / 60);
385
+ const s = totalSeconds % 60;
386
+ if (m >= 60)
387
+ return `Expires in ${Math.floor(m / 60)}h ${m % 60}m`;
388
+ return `Expires in ${m}m ${s.toString().padStart(2, '0')}s`;
389
+ }
302
390
  function ChatIcon() {
303
391
  return ((0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", "aria-hidden": true, children: (0, jsx_runtime_1.jsx)("path", { d: "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" }) }));
304
392
  }
@@ -402,4 +490,25 @@ const WIDGET_CSS = `
402
490
  .af-send svg { width: 16px; height: 16px; }
403
491
  .af-error { padding: 10px 16px; font-size: 12px; color: #b91c1c; background: #fef2f2; border-top: 1px solid #fee2e2; }
404
492
  .af-footer { padding: 6px 12px; font-size: 10px; color: var(--af-muted); text-align: center; background: var(--af-bubble-bg); border-top: 1px solid var(--af-border); }
493
+ /* Approval and blocked bubbles. Amber for needs-decision, red for
494
+ * won't-happen. Same animation-in as the regular .af-msg so the
495
+ * transition feels uniform. */
496
+ .af-msg-approval { align-self: flex-start; max-width: 90%; padding: 12px 14px; border-radius: 14px; border-bottom-left-radius: 4px; font-size: 13px; line-height: 1.5; background: #fffbeb; border: 1px solid #fde68a; color: #7c2d12; animation: af-msg-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); }
497
+ .af-approval-title { font-weight: 600; font-size: 13px; margin-bottom: 4px; }
498
+ .af-approval-body { font-size: 13px; line-height: 1.5; }
499
+ .af-approval-tool { background: rgba(0,0,0,0.06); padding: 1px 6px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
500
+ .af-approval-meta { font-size: 11px; margin-top: 6px; color: #92400e; }
501
+ .af-approval-actions { display: flex; gap: 8px; margin-top: 10px; }
502
+ .af-approval-btn { padding: 6px 12px; font-size: 12px; font-weight: 600; border-radius: 8px; cursor: pointer; transition: opacity 0.15s ease; }
503
+ .af-approval-btn:disabled { opacity: 0.6; cursor: not-allowed; }
504
+ .af-approval-approve { background: var(--af-primary); color: white; border: none; }
505
+ .af-approval-deny { background: white; color: #7c2d12; border: 1px solid #fde68a; }
506
+ .af-approval-pill { display: inline-block; margin-top: 8px; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; }
507
+ .af-approval-pill-approved { background: #dcfce7; color: #166534; }
508
+ .af-approval-pill-denied { background: #fee2e2; color: #991b1b; }
509
+ .af-approval-hint { font-size: 12px; margin-top: 8px; color: #7c2d12; }
510
+ .af-approval-error { font-size: 11px; margin-top: 6px; color: #b91c1c; }
511
+ .af-msg-blocked { align-self: flex-start; max-width: 90%; padding: 10px 14px; border-radius: 14px; border-bottom-left-radius: 4px; font-size: 13px; line-height: 1.5; background: #fef2f2; border: 1px solid #fecaca; color: #7f1d1d; animation: af-msg-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); }
512
+ .af-blocked-title { font-weight: 600; font-size: 13px; margin-bottom: 4px; }
513
+ .af-blocked-body { font-size: 12px; line-height: 1.5; }
405
514
  `;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.0.17",
3
+ "version": "2.0.18",
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",