@agentforge-io/chat-sdk 2.0.16 → 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.
@@ -4,6 +4,34 @@
4
4
  * and easy to reason about.
5
5
  */
6
6
  export type ChatRole = 'user' | 'assistant' | 'system';
7
+ /**
8
+ * Structured payload attached to messages the server tagged with a
9
+ * non-text intent. Today's kinds:
10
+ *
11
+ * - `awaiting_approval`: a tool the agent wanted to run requires
12
+ * human approval. The View layer renders an Approve/Deny card
13
+ * using `approvalId` + `toolName`. After Approve the client should
14
+ * auto-send a "continue" follow-up so the gate's fast-path consumes
15
+ * the pending row and the turn finishes.
16
+ * - `tool_blocked`: a tool was hard-blocked by the gate. Terminal,
17
+ * no action — render as an error card.
18
+ *
19
+ * Future kinds extend this union without breaking back-compat: the
20
+ * View checks `kind` and falls back to plain text when it's unknown.
21
+ */
22
+ export type ChatMessageMetadata = {
23
+ kind: 'awaiting_approval';
24
+ approvalId: string;
25
+ toolName: string;
26
+ expiresAt: string;
27
+ } | {
28
+ kind: 'tool_blocked';
29
+ toolName: string;
30
+ reason?: string;
31
+ } | {
32
+ kind: string;
33
+ [k: string]: unknown;
34
+ };
7
35
  export interface ChatMessage {
8
36
  /** Stable client-side id so view layers can key off it. */
9
37
  id: string;
@@ -15,6 +43,10 @@ export interface ChatMessage {
15
43
  /** True while the assistant is still streaming this message. The UI uses
16
44
  * this to render a caret/cursor next to a half-written reply. */
17
45
  isStreaming?: boolean;
46
+ /** Structured payload for non-text messages (approval cards, blocked
47
+ * errors, …). View layers should branch on `metadata.kind` before
48
+ * falling back to `content`. */
49
+ metadata?: ChatMessageMetadata;
18
50
  }
19
51
  export interface ChatAgentSummary {
20
52
  slug: string;
package/dist/index.d.ts CHANGED
@@ -10,4 +10,4 @@
10
10
  export { ChatSession } from './session';
11
11
  export { HttpTransport } from './transport';
12
12
  export type { StreamEvent, ServerStreamChunk, TransportError, } from './transport';
13
- export type { ChatAgentSummary, ChatEvent, ChatMessage, ChatRole, ChatSessionOptions, ChatSessionState, ChatSessionStatus, ChatTheme, } from './entities';
13
+ export type { ChatAgentSummary, ChatEvent, ChatMessage, ChatMessageMetadata, ChatRole, ChatSessionOptions, ChatSessionState, ChatSessionStatus, ChatTheme, } from './entities';
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/dist/session.js CHANGED
@@ -80,6 +80,7 @@ class ChatSession {
80
80
  role: m.role,
81
81
  content: m.content,
82
82
  createdAt: new Date(m.createdAt),
83
+ metadata: m.metadata,
83
84
  });
84
85
  }
85
86
  if (history.status === 'completed') {
@@ -186,6 +187,39 @@ class ChatSession {
186
187
  }
187
188
  if (evt.kind === 'chunk') {
188
189
  sawAnyChunk = true;
190
+ // Approval gate intercepts. The server emits one of these as
191
+ // the final chunk before closing the stream; we mutate the
192
+ // in-flight assistant message with the structured payload so
193
+ // the View can swap the bubble for an Approve/Deny card. The
194
+ // text body falls back to a sensible plain-text version so a
195
+ // legacy widget (no metadata renderer) still shows something
196
+ // readable.
197
+ const chunkType = evt.chunk.type;
198
+ if (chunkType === 'awaiting_approval') {
199
+ const c = evt.chunk;
200
+ assistant.metadata = {
201
+ kind: 'awaiting_approval',
202
+ approvalId: c.approvalId,
203
+ toolName: c.toolName,
204
+ expiresAt: c.expiresAt,
205
+ };
206
+ assistant.content =
207
+ full || `(waiting for approval to run \`${c.toolName}\`)`;
208
+ this.updateMessage(assistant);
209
+ continue;
210
+ }
211
+ if (chunkType === 'tool_blocked') {
212
+ const c = evt.chunk;
213
+ assistant.metadata = {
214
+ kind: 'tool_blocked',
215
+ toolName: c.toolName,
216
+ reason: c.reason,
217
+ };
218
+ assistant.content =
219
+ full || c.reason || `Tool "${c.toolName}" is blocked.`;
220
+ this.updateMessage(assistant);
221
+ continue;
222
+ }
189
223
  const delta = extractDelta(evt.chunk);
190
224
  if (!delta)
191
225
  continue;
@@ -207,7 +241,12 @@ class ChatSession {
207
241
  // than leaving an empty assistant bubble. Most often this means the
208
242
  // backend swallowed an exception inside streamMessage (e.g. the
209
243
  // history query failed or the runner rejected the model).
210
- if (!full) {
244
+ //
245
+ // Exception: when the stream ended on an approval / blocked
246
+ // chunk, the bubble carries that structured payload and is a
247
+ // legitimate terminal state — not a missing reply. Skip the
248
+ // "empty response" guard for those.
249
+ if (!full && !assistant.metadata) {
211
250
  throw new Error(sawAnyChunk
212
251
  ? 'The model returned an empty response.'
213
252
  : 'No response received from the server.');
@@ -77,6 +77,10 @@ export declare class HttpTransport {
77
77
  role: 'user' | 'assistant' | 'system';
78
78
  content: string;
79
79
  createdAt: string;
80
+ /** Structured payload for non-text messages (approval cards,
81
+ * blocked errors). Passed through unchanged from the server's
82
+ * message row so a reload re-renders the same card. */
83
+ metadata?: Record<string, unknown>;
80
84
  }>;
81
85
  } | null>;
82
86
  endConversation(conversationId: string, browserSessionId: string): Promise<void>;
package/dist/transport.js CHANGED
@@ -107,6 +107,7 @@ class HttpTransport {
107
107
  role: m.role,
108
108
  content: m.content,
109
109
  createdAt: m.createdAt,
110
+ metadata: m.metadata,
110
111
  })),
111
112
  };
112
113
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.0.16",
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",