@agentforge-io/chat-sdk 2.0.19 → 2.0.20

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.
@@ -19,25 +19,39 @@ export type ChatRole = 'user' | 'assistant' | 'system';
19
19
  * Future kinds extend this union without breaking back-compat: the
20
20
  * View checks `kind` and falls back to plain text when it's unknown.
21
21
  */
22
+ export interface ChatApprovalCopy {
23
+ title: string;
24
+ body: string;
25
+ approveLabel: string;
26
+ approveBusyLabel: string;
27
+ denyLabel: string;
28
+ denyBusyLabel: string;
29
+ approvedPill: string;
30
+ deniedPill: string;
31
+ readOnlyHint: string;
32
+ blockedTitle: string;
33
+ blockedBody: string;
34
+ expiresPrefix: string;
35
+ }
22
36
  export type ChatMessageMetadata = {
23
37
  kind: 'awaiting_approval';
24
38
  approvalId: string;
25
39
  toolName: string;
26
40
  expiresAt: string;
27
- /** Friendly connector display name surfaced by the server when
28
- * available (e.g. `'Granola'`). Lets the View render a
29
- * natural-language card without the host having to map slugs
30
- * to product names. */
31
41
  connectorName?: string;
32
- /** First line of the tool's description from the connector
33
- * definition. Used as a one-line "what will happen" hint. */
34
42
  toolDescription?: string;
43
+ /** Server-authored microcopy for the approval bubble. Generated
44
+ * on every pause by an AI copywriter on the backend so language
45
+ * and tone match the live conversation. View layers render it
46
+ * verbatim; when missing, fall back to a minimal meta render. */
47
+ copy?: ChatApprovalCopy;
35
48
  } | {
36
49
  kind: 'tool_blocked';
37
50
  toolName: string;
38
51
  reason?: string;
39
52
  connectorName?: string;
40
53
  toolDescription?: string;
54
+ copy?: ChatApprovalCopy;
41
55
  } | {
42
56
  kind: string;
43
57
  [k: string]: unknown;
package/dist/react.d.ts CHANGED
@@ -37,38 +37,30 @@ export type ApprovalActionHandler = (args: {
37
37
  /**
38
38
  * Localizable copy for the approval / blocked bubbles. Pass via the
39
39
  * `approvalCopy` prop on `<ChatWidget>` to drop the defaults (Spanish,
40
- * conversational) and tailor every line to your audience.
40
+ * Server-authored approval microcopy. The bubble renders whatever the
41
+ * server places in `metadata.copy` — title, body, button labels, etc.
42
+ * That copy is generated by the AgentForge backend on every pause via a
43
+ * dedicated Haiku call, so its language and tone always match the
44
+ * conversation the user is having. There is no client-side dictionary
45
+ * to keep in sync.
41
46
  *
42
- * Functions receive whatever context the server enriched the chunk
43
- * with — typically a friendly connector name like `'Granola'`. When
44
- * the server didn't supply it, the function is called with `undefined`
45
- * and the implementation should pick a sensible generic line. The
46
- * widget itself never references the raw tool slug in copy.
47
+ * The shape mirrors `ApprovalCopyBundle` in `@agentforge-io/core`.
47
48
  */
48
49
  export interface ApprovalCopy {
49
50
  title: string;
50
- body: (ctx: {
51
- connectorName?: string;
52
- toolDescription?: string;
53
- }) => string;
51
+ body: string;
54
52
  approveLabel: string;
55
53
  approveBusyLabel: string;
56
54
  denyLabel: string;
57
55
  denyBusyLabel: string;
58
56
  approvedPill: string;
59
57
  deniedPill: string;
60
- /** Shown when the host didn't wire `onApprovalAction` — the visitor
61
- * can't decide from inside the widget so we tell them where to go. */
62
58
  readOnlyHint: string;
63
- /** Used by the `expires in …` countdown. Receives minutes/seconds
64
- * and returns the formatted string. */
65
- expiresIn: (mins: number, secs: number) => string;
66
- /** Block bubble title + body. */
67
59
  blockedTitle: string;
68
- blockedBody: (ctx: {
69
- connectorName?: string;
70
- reason?: string;
71
- }) => string;
60
+ blockedBody: string;
61
+ /** Prefix shown before the countdown, e.g. "Expires in". The seconds
62
+ * are appended client-side because they tick locally. */
63
+ expiresPrefix: string;
72
64
  }
73
65
  export interface ChatWidgetProps {
74
66
  /** Public chat token (`aft_*`) issued from the admin UI. */
@@ -97,12 +89,6 @@ export interface ChatWidgetProps {
97
89
  * hints.
98
90
  */
99
91
  onApprovalAction?: ApprovalActionHandler;
100
- /**
101
- * Override any subset of the approval-card copy. Keys you don't
102
- * provide fall back to the Spanish defaults in `DEFAULT_APPROVAL_COPY`.
103
- * Use this for translations or to match your product's voice.
104
- */
105
- approvalCopy?: Partial<ApprovalCopy>;
106
92
  }
107
93
  /**
108
94
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
package/dist/react.js CHANGED
@@ -154,37 +154,34 @@ function renderMarkdown(src) {
154
154
  return out.join('');
155
155
  }
156
156
  /**
157
- * Defaults are Spanish + conversational. The host overrides individual
158
- * keys via the `approvalCopy` prop; unset keys keep these values.
157
+ * Minimal fallback used when the server didn't supply `metadata.copy`
158
+ * (older backend, copywriter timeout, missing API key, etc.). Intentionally
159
+ * terse and meta-only — the goal is to avoid lying about language. The
160
+ * server is the source of truth for natural copy.
159
161
  */
160
- const DEFAULT_APPROVAL_COPY = {
161
- title: 'Necesito tu permiso',
162
- body: ({ connectorName }) => connectorName
163
- ? `¿Me das permiso para usar ${connectorName}?`
164
- : '¿Me das permiso para continuar?',
165
- approveLabel: 'Permitir',
166
- approveBusyLabel: 'Permitiendo…',
167
- denyLabel: 'No, gracias',
168
- denyBusyLabel: 'Cancelando…',
169
- approvedPill: 'Listo, continuando…',
170
- deniedPill: 'Cancelado',
171
- readOnlyHint: 'Pídele al administrador del workspace que lo apruebe.',
172
- expiresIn: (m, s) => m >= 60
173
- ? `Expira en ${Math.floor(m / 60)}h ${m % 60}m`
174
- : `Expira en ${m}m ${s.toString().padStart(2, '0')}s`,
175
- blockedTitle: 'No disponible',
176
- blockedBody: ({ connectorName, reason }) => reason ??
177
- (connectorName
178
- ? `No puedo usar ${connectorName} en esta cuenta.`
179
- : 'No puedo usar esa herramienta en esta cuenta.'),
180
- };
162
+ function fallbackCopy(ctx) {
163
+ const target = ctx.connectorName ?? ctx.toolName ?? '';
164
+ return {
165
+ title: target || 'Permission required',
166
+ body: target ? `${target} → ✓ / ✗` : 'Permission required',
167
+ approveLabel: '',
168
+ approveBusyLabel: '…',
169
+ denyLabel: '',
170
+ denyBusyLabel: '…',
171
+ approvedPill: '',
172
+ deniedPill: '',
173
+ readOnlyHint: '',
174
+ blockedTitle: target || 'Blocked',
175
+ blockedBody: ctx.reason ?? (target ? `${target} blocked` : 'Blocked'),
176
+ expiresPrefix: '',
177
+ };
178
+ }
181
179
  /**
182
180
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
183
181
  * SDK event. Consumers don't need to read `session.getState()` themselves.
184
182
  */
185
183
  function ChatWidget(props) {
186
- const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalAction, approvalCopy, } = props;
187
- const copy = { ...DEFAULT_APPROVAL_COPY, ...(approvalCopy ?? {}) };
184
+ const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalAction, } = props;
188
185
  const [session, setSession] = (0, react_1.useState)(null);
189
186
  const [status, setStatus] = (0, react_1.useState)('idle');
190
187
  const [agent, setAgent] = (0, react_1.useState)();
@@ -304,7 +301,7 @@ function ChatWidget(props) {
304
301
  };
305
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 ? (
306
303
  // eslint-disable-next-line @next/next/no-img-element
307
- (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, copy: copy, 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, onApprovalAction: onApprovalAction, onContinue: () => {
308
305
  // After a successful Approve, kick the next turn so the
309
306
  // gate's fast-path consumes the approval and the tool
310
307
  // actually runs. `silent: true` keeps the literal
@@ -318,13 +315,13 @@ function ChatWidget(props) {
318
315
  }, 250);
319
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" })] })] }));
320
317
  }
321
- function MessageBubble({ message, copy, onApprovalAction, onContinue, }) {
318
+ function MessageBubble({ message, onApprovalAction, onContinue, }) {
322
319
  const kind = message.metadata?.kind;
323
320
  if (kind === 'awaiting_approval') {
324
- return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, copy: copy, onAction: onApprovalAction, onContinue: onContinue }));
321
+ return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, onAction: onApprovalAction, onContinue: onContinue }));
325
322
  }
326
323
  if (kind === 'tool_blocked') {
327
- return (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message, copy: copy });
324
+ return (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message });
328
325
  }
329
326
  const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
330
327
  ? message.content
@@ -351,12 +348,16 @@ function MessageBubble({ message, copy, onApprovalAction, onContinue, }) {
351
348
  * so the chat auto-sends "continue" and the gate's fast-path consumes
352
349
  * the row on the next turn.
353
350
  */
354
- function ApprovalBubble({ message, copy, onAction, onContinue, }) {
351
+ function ApprovalBubble({ message, onAction, onContinue, }) {
355
352
  const meta = message.metadata;
356
353
  const [busy, setBusy] = (0, react_1.useState)(null);
357
354
  const [decided, setDecided] = (0, react_1.useState)(null);
358
355
  const [err, setErr] = (0, react_1.useState)(null);
359
- const remaining = useExpiresIn(meta?.expiresAt, copy.expiresIn);
356
+ const copy = meta?.copy ?? fallbackCopy({
357
+ connectorName: meta?.connectorName,
358
+ toolName: meta?.toolName,
359
+ });
360
+ const remaining = useExpiresIn(meta?.expiresAt, copy.expiresPrefix);
360
361
  if (!meta)
361
362
  return null;
362
363
  const act = async (action) => {
@@ -377,28 +378,27 @@ function ApprovalBubble({ message, copy, onAction, onContinue, }) {
377
378
  setBusy(null);
378
379
  }
379
380
  };
380
- 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({
381
- connectorName: meta.connectorName,
382
- toolDescription: meta.toolDescription,
383
- }) }), 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 })] }));
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
382
  }
385
383
  /**
386
384
  * Terminal "tool blocked" bubble. No action — just informs the visitor
387
385
  * that the tool the agent tried isn't available.
388
386
  */
389
- function BlockedBubble({ message, copy, }) {
387
+ function BlockedBubble({ message, }) {
390
388
  const meta = message.metadata;
391
389
  if (!meta)
392
390
  return null;
393
- 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: copy.blockedTitle }), (0, jsx_runtime_1.jsx)("div", { className: "af-blocked-body", children: copy.blockedBody({
394
- connectorName: meta.connectorName,
395
- reason: meta.reason,
396
- }) })] }));
391
+ const copy = meta.copy ?? fallbackCopy({
392
+ connectorName: meta.connectorName,
393
+ toolName: meta.toolName,
394
+ reason: meta.reason,
395
+ });
396
+ 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: copy.blockedTitle }), (0, jsx_runtime_1.jsx)("div", { className: "af-blocked-body", children: copy.blockedBody })] }));
397
397
  }
398
398
  /** Live "expires in" countdown. Returns null after the deadline.
399
- * The format function is host-supplied so the label stays localisable —
400
- * the hook itself doesn't hard-code any user-facing string. */
401
- function useExpiresIn(iso, format) {
399
+ * The prefix label is server-authored so the language matches the
400
+ * rest of the bubble; the hook just appends the localised digits. */
401
+ function useExpiresIn(iso, prefix) {
402
402
  const [, force] = (0, react_1.useState)(0);
403
403
  (0, react_1.useEffect)(() => {
404
404
  if (!iso)
@@ -414,7 +414,10 @@ function useExpiresIn(iso, format) {
414
414
  const totalSeconds = Math.floor(ms / 1000);
415
415
  const m = Math.floor(totalSeconds / 60);
416
416
  const s = totalSeconds % 60;
417
- return format(m, s);
417
+ const tail = m >= 60
418
+ ? `${Math.floor(m / 60)}h ${m % 60}m`
419
+ : `${m}m ${s.toString().padStart(2, '0')}s`;
420
+ return `${prefix} ${tail}`.trim();
418
421
  }
419
422
  function ChatIcon() {
420
423
  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" }) }));
package/dist/session.js CHANGED
@@ -214,12 +214,12 @@ class ChatSession {
214
214
  expiresAt: c.expiresAt,
215
215
  connectorName: c.connectorName,
216
216
  toolDescription: c.toolDescription,
217
+ copy: c.copy,
217
218
  };
218
- assistant.content =
219
- full ||
220
- (c.connectorName
221
- ? `Necesito tu permiso para usar ${c.connectorName}.`
222
- : `Necesito tu permiso para continuar.`);
219
+ // Plain-text fallback for legacy clients that don't render
220
+ // metadata.kind. Capable widgets ignore `content` and read
221
+ // `metadata.copy` directly.
222
+ assistant.content = full || c.copy?.body || (c.connectorName ?? '');
223
223
  this.updateMessage(assistant);
224
224
  continue;
225
225
  }
@@ -231,13 +231,10 @@ class ChatSession {
231
231
  reason: c.reason,
232
232
  connectorName: c.connectorName,
233
233
  toolDescription: c.toolDescription,
234
+ copy: c.copy,
234
235
  };
235
236
  assistant.content =
236
- full ||
237
- c.reason ||
238
- (c.connectorName
239
- ? `No puedo usar ${c.connectorName} en esta cuenta.`
240
- : `No puedo usar esa herramienta en esta cuenta.`);
237
+ full || c.copy?.blockedBody || c.reason || (c.connectorName ?? '');
241
238
  this.updateMessage(assistant);
242
239
  continue;
243
240
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.0.19",
3
+ "version": "2.0.20",
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",