@agentforge-io/chat-sdk 2.0.18 → 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,15 +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;
41
+ connectorName?: string;
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;
27
48
  } | {
28
49
  kind: 'tool_blocked';
29
50
  toolName: string;
30
51
  reason?: string;
52
+ connectorName?: string;
53
+ toolDescription?: string;
54
+ copy?: ChatApprovalCopy;
31
55
  } | {
32
56
  kind: string;
33
57
  [k: string]: unknown;
package/dist/react.d.ts CHANGED
@@ -34,6 +34,34 @@ 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
+ * 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.
46
+ *
47
+ * The shape mirrors `ApprovalCopyBundle` in `@agentforge-io/core`.
48
+ */
49
+ export interface ApprovalCopy {
50
+ title: string;
51
+ body: string;
52
+ approveLabel: string;
53
+ approveBusyLabel: string;
54
+ denyLabel: string;
55
+ denyBusyLabel: string;
56
+ approvedPill: string;
57
+ deniedPill: string;
58
+ readOnlyHint: string;
59
+ blockedTitle: 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;
64
+ }
37
65
  export interface ChatWidgetProps {
38
66
  /** Public chat token (`aft_*`) issued from the admin UI. */
39
67
  token: string;
package/dist/react.js CHANGED
@@ -153,6 +153,29 @@ function renderMarkdown(src) {
153
153
  }
154
154
  return out.join('');
155
155
  }
156
+ /**
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.
161
+ */
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
+ }
156
179
  /**
157
180
  * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
158
181
  * SDK event. Consumers don't need to read `session.getState()` themselves.
@@ -281,21 +304,18 @@ function ChatWidget(props) {
281
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: () => {
282
305
  // After a successful Approve, kick the next turn so the
283
306
  // 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.
307
+ // actually runs. `silent: true` keeps the literal
308
+ // "continue" prompt out of the visible transcript
309
+ // the visitor just sees the assistant's next reply
310
+ // appearing under the "Approved" pill.
286
311
  if (!session)
287
312
  return;
288
313
  setTimeout(() => {
289
- void session.send('continue');
314
+ void session.send('continue', { silent: true });
290
315
  }, 250);
291
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" })] })] }));
292
317
  }
293
318
  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
319
  const kind = message.metadata?.kind;
300
320
  if (kind === 'awaiting_approval') {
301
321
  return ((0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, onAction: onApprovalAction, onContinue: onContinue }));
@@ -333,7 +353,11 @@ function ApprovalBubble({ message, onAction, onContinue, }) {
333
353
  const [busy, setBusy] = (0, react_1.useState)(null);
334
354
  const [decided, setDecided] = (0, react_1.useState)(null);
335
355
  const [err, setErr] = (0, react_1.useState)(null);
336
- const remaining = useExpiresIn(meta?.expiresAt);
356
+ const copy = meta?.copy ?? fallbackCopy({
357
+ connectorName: meta?.connectorName,
358
+ toolName: meta?.toolName,
359
+ });
360
+ const remaining = useExpiresIn(meta?.expiresAt, copy.expiresPrefix);
337
361
  if (!meta)
338
362
  return null;
339
363
  const act = async (action) => {
@@ -354,20 +378,27 @@ function ApprovalBubble({ message, onAction, onContinue, }) {
354
378
  setBusy(null);
355
379
  }
356
380
  };
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 })] }));
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 })] }));
358
382
  }
359
383
  /**
360
384
  * Terminal "tool blocked" bubble. No action — just informs the visitor
361
385
  * that the tool the agent tried isn't available.
362
386
  */
363
- function BlockedBubble({ message }) {
387
+ function BlockedBubble({ message, }) {
364
388
  const meta = message.metadata;
365
389
  if (!meta)
366
390
  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 ?? ''] })] }));
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 })] }));
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 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) {
371
402
  const [, force] = (0, react_1.useState)(0);
372
403
  (0, react_1.useEffect)(() => {
373
404
  if (!iso)
@@ -383,9 +414,10 @@ 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
+ 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();
389
421
  }
390
422
  function ChatIcon() {
391
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.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,
217
+ copy: c.copy,
205
218
  };
206
- assistant.content =
207
- full || `(waiting for approval to run \`${c.toolName}\`)`;
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 ?? '');
208
223
  this.updateMessage(assistant);
209
224
  continue;
210
225
  }
@@ -214,9 +229,12 @@ 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,
234
+ copy: c.copy,
217
235
  };
218
236
  assistant.content =
219
- full || c.reason || `Tool "${c.toolName}" is blocked.`;
237
+ full || c.copy?.blockedBody || c.reason || (c.connectorName ?? '');
220
238
  this.updateMessage(assistant);
221
239
  continue;
222
240
  }
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.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",