@agentforge-io/chat-sdk 2.0.18 → 2.0.19

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.
@@ -24,10 +24,20 @@ export type ChatMessageMetadata = {
24
24
  approvalId: string;
25
25
  toolName: string;
26
26
  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
+ 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
+ toolDescription?: string;
27
35
  } | {
28
36
  kind: 'tool_blocked';
29
37
  toolName: string;
30
38
  reason?: string;
39
+ connectorName?: string;
40
+ toolDescription?: string;
31
41
  } | {
32
42
  kind: string;
33
43
  [k: string]: unknown;
package/dist/react.d.ts CHANGED
@@ -34,6 +34,42 @@ export type ApprovalActionHandler = (args: {
34
34
  approvalId: string;
35
35
  action: 'approve' | 'deny';
36
36
  }) => Promise<void>;
37
+ /**
38
+ * Localizable copy for the approval / blocked bubbles. Pass via the
39
+ * `approvalCopy` prop on `<ChatWidget>` to drop the defaults (Spanish,
40
+ * conversational) and tailor every line to your audience.
41
+ *
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
+ */
48
+ export interface ApprovalCopy {
49
+ title: string;
50
+ body: (ctx: {
51
+ connectorName?: string;
52
+ toolDescription?: string;
53
+ }) => string;
54
+ approveLabel: string;
55
+ approveBusyLabel: string;
56
+ denyLabel: string;
57
+ denyBusyLabel: string;
58
+ approvedPill: string;
59
+ 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
+ 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
+ blockedTitle: string;
68
+ blockedBody: (ctx: {
69
+ connectorName?: string;
70
+ reason?: string;
71
+ }) => string;
72
+ }
37
73
  export interface ChatWidgetProps {
38
74
  /** Public chat token (`aft_*`) issued from the admin UI. */
39
75
  token: string;
@@ -61,6 +97,12 @@ export interface ChatWidgetProps {
61
97
  * hints.
62
98
  */
63
99
  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>;
64
106
  }
65
107
  /**
66
108
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
package/dist/react.js CHANGED
@@ -153,12 +153,38 @@ function renderMarkdown(src) {
153
153
  }
154
154
  return out.join('');
155
155
  }
156
+ /**
157
+ * Defaults are Spanish + conversational. The host overrides individual
158
+ * keys via the `approvalCopy` prop; unset keys keep these values.
159
+ */
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
+ };
156
181
  /**
157
182
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
158
183
  * SDK event. Consumers don't need to read `session.getState()` themselves.
159
184
  */
160
185
  function ChatWidget(props) {
161
- const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalAction, } = props;
186
+ const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalAction, approvalCopy, } = props;
187
+ const copy = { ...DEFAULT_APPROVAL_COPY, ...(approvalCopy ?? {}) };
162
188
  const [session, setSession] = (0, react_1.useState)(null);
163
189
  const [status, setStatus] = (0, react_1.useState)('idle');
164
190
  const [agent, setAgent] = (0, react_1.useState)();
@@ -278,30 +304,27 @@ function ChatWidget(props) {
278
304
  };
279
305
  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
306
  // 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, onApprovalAction: onApprovalAction, onContinue: () => {
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: () => {
282
308
  // After a successful Approve, kick the next turn so the
283
309
  // 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.
310
+ // actually runs. `silent: true` keeps the literal
311
+ // "continue" prompt out of the visible transcript
312
+ // the visitor just sees the assistant's next reply
313
+ // appearing under the "Approved" pill.
286
314
  if (!session)
287
315
  return;
288
316
  setTimeout(() => {
289
- void session.send('continue');
317
+ void session.send('continue', { silent: true });
290
318
  }, 250);
291
319
  } }, 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" })] })] }));
292
320
  }
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.
321
+ function MessageBubble({ message, copy, onApprovalAction, onContinue, }) {
299
322
  const kind = message.metadata?.kind;
300
323
  if (kind === 'awaiting_approval') {
301
- return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, onAction: onApprovalAction, onContinue: onContinue }));
324
+ return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, copy: copy, onAction: onApprovalAction, onContinue: onContinue }));
302
325
  }
303
326
  if (kind === 'tool_blocked') {
304
- return (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message });
327
+ return (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message, copy: copy });
305
328
  }
306
329
  const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
307
330
  ? message.content
@@ -328,12 +351,12 @@ function MessageBubble({ message, onApprovalAction, onContinue, }) {
328
351
  * so the chat auto-sends "continue" and the gate's fast-path consumes
329
352
  * the row on the next turn.
330
353
  */
331
- function ApprovalBubble({ message, onAction, onContinue, }) {
354
+ function ApprovalBubble({ message, copy, onAction, onContinue, }) {
332
355
  const meta = message.metadata;
333
356
  const [busy, setBusy] = (0, react_1.useState)(null);
334
357
  const [decided, setDecided] = (0, react_1.useState)(null);
335
358
  const [err, setErr] = (0, react_1.useState)(null);
336
- const remaining = useExpiresIn(meta?.expiresAt);
359
+ const remaining = useExpiresIn(meta?.expiresAt, copy.expiresIn);
337
360
  if (!meta)
338
361
  return null;
339
362
  const act = async (action) => {
@@ -354,20 +377,28 @@ function ApprovalBubble({ message, onAction, onContinue, }) {
354
377
  setBusy(null);
355
378
  }
356
379
  };
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 })] }));
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 })] }));
358
384
  }
359
385
  /**
360
386
  * Terminal "tool blocked" bubble. No action — just informs the visitor
361
387
  * that the tool the agent tried isn't available.
362
388
  */
363
- function BlockedBubble({ message }) {
389
+ function BlockedBubble({ message, copy, }) {
364
390
  const meta = message.metadata;
365
391
  if (!meta)
366
392
  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 ?? ''] })] }));
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
+ }) })] }));
368
397
  }
369
- /** Live "expires in" countdown. Returns null after the deadline. */
370
- function useExpiresIn(iso) {
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) {
371
402
  const [, force] = (0, react_1.useState)(0);
372
403
  (0, react_1.useEffect)(() => {
373
404
  if (!iso)
@@ -383,9 +414,7 @@ function useExpiresIn(iso) {
383
414
  const totalSeconds = Math.floor(ms / 1000);
384
415
  const m = Math.floor(totalSeconds / 60);
385
416
  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`;
417
+ return format(m, s);
389
418
  }
390
419
  function ChatIcon() {
391
420
  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.d.ts CHANGED
@@ -42,8 +42,18 @@ export declare class ChatSession {
42
42
  * Send a user message. Opens the conversation on first call. Returns the
43
43
  * final assistant content for callers that want to await it; the same
44
44
  * content is also emitted via `message_added` / `message_updated`.
45
+ *
46
+ * `silent: true` suppresses the user-message bubble that would normally
47
+ * appear in the view layer. Used by the tool-approval auto-continue
48
+ * flow: after the visitor clicks Approve, the widget fires a hidden
49
+ * `send('continue', { silent: true })` so the gate's fast-path
50
+ * triggers without leaking a literal "continue" message into the
51
+ * conversation. The server still sees a regular user message and the
52
+ * agent's reply comes back through the normal stream.
45
53
  */
46
- send(text: string): Promise<string>;
54
+ send(text: string, opts?: {
55
+ silent?: boolean;
56
+ }): Promise<string>;
47
57
  private runStream;
48
58
  private runNonStream;
49
59
  private appendMessage;
package/dist/session.js CHANGED
@@ -142,8 +142,16 @@ class ChatSession {
142
142
  * Send a user message. Opens the conversation on first call. Returns the
143
143
  * final assistant content for callers that want to await it; the same
144
144
  * content is also emitted via `message_added` / `message_updated`.
145
+ *
146
+ * `silent: true` suppresses the user-message bubble that would normally
147
+ * appear in the view layer. Used by the tool-approval auto-continue
148
+ * flow: after the visitor clicks Approve, the widget fires a hidden
149
+ * `send('continue', { silent: true })` so the gate's fast-path
150
+ * triggers without leaking a literal "continue" message into the
151
+ * conversation. The server still sees a regular user message and the
152
+ * agent's reply comes back through the normal stream.
145
153
  */
146
- async send(text) {
154
+ async send(text, opts) {
147
155
  const trimmed = text.trim();
148
156
  if (!trimmed)
149
157
  return '';
@@ -152,12 +160,14 @@ class ChatSession {
152
160
  }
153
161
  if (!this.started)
154
162
  await this.start();
155
- this.appendMessage({
156
- id: makeMessageId('u'),
157
- role: 'user',
158
- content: trimmed,
159
- createdAt: new Date(),
160
- });
163
+ if (!opts?.silent) {
164
+ this.appendMessage({
165
+ id: makeMessageId('u'),
166
+ role: 'user',
167
+ content: trimmed,
168
+ createdAt: new Date(),
169
+ });
170
+ }
161
171
  const assistant = {
162
172
  id: makeMessageId('a'),
163
173
  role: 'assistant',
@@ -202,9 +212,14 @@ class ChatSession {
202
212
  approvalId: c.approvalId,
203
213
  toolName: c.toolName,
204
214
  expiresAt: c.expiresAt,
215
+ connectorName: c.connectorName,
216
+ toolDescription: c.toolDescription,
205
217
  };
206
218
  assistant.content =
207
- full || `(waiting for approval to run \`${c.toolName}\`)`;
219
+ full ||
220
+ (c.connectorName
221
+ ? `Necesito tu permiso para usar ${c.connectorName}.`
222
+ : `Necesito tu permiso para continuar.`);
208
223
  this.updateMessage(assistant);
209
224
  continue;
210
225
  }
@@ -214,9 +229,15 @@ class ChatSession {
214
229
  kind: 'tool_blocked',
215
230
  toolName: c.toolName,
216
231
  reason: c.reason,
232
+ connectorName: c.connectorName,
233
+ toolDescription: c.toolDescription,
217
234
  };
218
235
  assistant.content =
219
- full || c.reason || `Tool "${c.toolName}" is blocked.`;
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.`);
220
241
  this.updateMessage(assistant);
221
242
  continue;
222
243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentforge-io/chat-sdk",
3
- "version": "2.0.18",
3
+ "version": "2.0.19",
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",