@agentforge-io/chat-sdk 2.4.0-dev.8 → 2.4.0
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 +20 -0
- package/dist/react.js +57 -5
- package/dist/session.d.ts +1 -0
- package/dist/session.js +51 -0
- package/package.json +1 -1
package/dist/react.d.ts
CHANGED
|
@@ -198,6 +198,26 @@ export interface ChatWidgetHandle {
|
|
|
198
198
|
* and the cursor lands at the end of the inserted text.
|
|
199
199
|
*/
|
|
200
200
|
insertText(text: string): void;
|
|
201
|
+
/**
|
|
202
|
+
* Move keyboard focus to the composer textarea. Use when the
|
|
203
|
+
* host UI surfaces the widget late (e.g. opening a drawer) and
|
|
204
|
+
* wants the on-screen keyboard up immediately. Safe no-op when
|
|
205
|
+
* the textarea hasn't mounted yet or is disabled.
|
|
206
|
+
*/
|
|
207
|
+
focus(): void;
|
|
208
|
+
/**
|
|
209
|
+
* Warm up the underlying session — fires the initial
|
|
210
|
+
* `GET /agent` (and optionally a `resumeConversation` lookup) so
|
|
211
|
+
* the first `send()` doesn't pay the round-trip. Idempotent:
|
|
212
|
+
* subsequent calls join the same in-flight promise.
|
|
213
|
+
*
|
|
214
|
+
* Hosts call this when the visitor signals INTENT to chat (e.g.
|
|
215
|
+
* tapping a fake-composer pill that opens a drawer) so the
|
|
216
|
+
* session boot overlaps with the visitor finding the keyboard
|
|
217
|
+
* and tapping out their first message. Safe no-op if the session
|
|
218
|
+
* is already past `loading`.
|
|
219
|
+
*/
|
|
220
|
+
warmup(): void;
|
|
201
221
|
}
|
|
202
222
|
/**
|
|
203
223
|
* Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
|
package/dist/react.js
CHANGED
|
@@ -291,14 +291,31 @@ function ChatWidget(props) {
|
|
|
291
291
|
el.focus({ preventScroll: true });
|
|
292
292
|
focusedOnceRef.current = true;
|
|
293
293
|
}, [status]);
|
|
294
|
-
// ── Session lifecycle. Recreate when token / apiBaseUrl
|
|
294
|
+
// ── Session lifecycle. Recreate when token / apiBaseUrl / browserSessionId
|
|
295
|
+
// / stream changes. NOTE: `resumeConversationId` is intentionally NOT a
|
|
296
|
+
// dependency.
|
|
297
|
+
//
|
|
298
|
+
// Why: hosts typically wire `resumeConversationId={conversationId}`
|
|
299
|
+
// and call `setConversationId(id)` in `onConversationStart`. The
|
|
300
|
+
// conversation_started event fires AFTER the SSE stream opens and
|
|
301
|
+
// the first chunks already started flowing. If we treated
|
|
302
|
+
// resumeConversationId as a reactive dep, this effect would
|
|
303
|
+
// re-run mid-stream, destroy the live ChatSession, and the
|
|
304
|
+
// remaining chunks would land in a corpse — the visitor sees
|
|
305
|
+
// an empty assistant bubble even though the backend completed
|
|
306
|
+
// the turn successfully.
|
|
307
|
+
//
|
|
308
|
+
// We capture the resume id at mount via a ref. Subsequent host
|
|
309
|
+
// updates to it are ignored — the SDK already owns the live
|
|
310
|
+
// conversation id internally via session.state.conversationId.
|
|
311
|
+
const resumeRef = (0, react_1.useRef)(resumeConversationId);
|
|
295
312
|
(0, react_1.useEffect)(() => {
|
|
296
313
|
let cancelled = false;
|
|
297
314
|
const s = new session_1.ChatSession({
|
|
298
315
|
token,
|
|
299
316
|
apiBaseUrl,
|
|
300
317
|
browserSessionId,
|
|
301
|
-
resumeConversationId,
|
|
318
|
+
resumeConversationId: resumeRef.current,
|
|
302
319
|
stream,
|
|
303
320
|
});
|
|
304
321
|
setSession(s);
|
|
@@ -336,7 +353,10 @@ function ChatWidget(props) {
|
|
|
336
353
|
unsubscribe();
|
|
337
354
|
s.destroy();
|
|
338
355
|
};
|
|
339
|
-
|
|
356
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- resumeConversationId
|
|
357
|
+
// is captured via resumeRef on first mount; reactive updates are
|
|
358
|
+
// ignored by design. See block comment above.
|
|
359
|
+
}, [token, apiBaseUrl, browserSessionId, stream]);
|
|
340
360
|
// Auto-scroll on new tokens.
|
|
341
361
|
//
|
|
342
362
|
// We defer the scroll into a requestAnimationFrame so the DOM has
|
|
@@ -442,6 +462,20 @@ function ChatWidget(props) {
|
|
|
442
462
|
void session.send(trimmed);
|
|
443
463
|
},
|
|
444
464
|
insertText,
|
|
465
|
+
focus: () => {
|
|
466
|
+
const el = inputRef.current;
|
|
467
|
+
if (!el || el.disabled)
|
|
468
|
+
return;
|
|
469
|
+
el.focus({ preventScroll: true });
|
|
470
|
+
},
|
|
471
|
+
warmup: () => {
|
|
472
|
+
// session.start() is idempotent — joins the in-flight
|
|
473
|
+
// promise if one exists, resolves immediately if start
|
|
474
|
+
// already completed.
|
|
475
|
+
if (!session)
|
|
476
|
+
return;
|
|
477
|
+
void session.start();
|
|
478
|
+
},
|
|
445
479
|
};
|
|
446
480
|
return () => {
|
|
447
481
|
// Drop the handle on unmount so a stale ref can't fire send
|
|
@@ -532,7 +566,9 @@ function ChatWidget(props) {
|
|
|
532
566
|
onShortcutClick(text, i);
|
|
533
567
|
else
|
|
534
568
|
setDraft(text);
|
|
535
|
-
}, children: text }, `${i}-${text}`))) })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row", children: [composerLeftSlot && ((0, jsx_runtime_1.jsx)("div", { className: "af-input-left", children: composerLeftSlot })), (0, jsx_runtime_1.jsx)("textarea", { ref: inputRef, className: "af-input", value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: onKeyDown, placeholder:
|
|
569
|
+
}, children: text }, `${i}-${text}`))) })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row", "data-loading": status === 'loading' || status === 'idle' ? '' : undefined, children: [composerLeftSlot && ((0, jsx_runtime_1.jsx)("div", { className: "af-input-left", children: composerLeftSlot })), (0, jsx_runtime_1.jsx)("textarea", { ref: inputRef, className: "af-input", value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: onKeyDown, placeholder: status === 'idle' || status === 'loading'
|
|
570
|
+
? 'Preparing chat…'
|
|
571
|
+
: inputPlaceholder ?? 'Type a message…', rows: 1,
|
|
536
572
|
// The textarea stays editable while the agent is
|
|
537
573
|
// streaming so the visitor can compose their next
|
|
538
574
|
// message without waiting. Only block when the
|
|
@@ -547,7 +583,14 @@ function ChatWidget(props) {
|
|
|
547
583
|
// before `handleSend` runs, the button then disables
|
|
548
584
|
// (sendDisabled flips true on status change), focus
|
|
549
585
|
// jumps to <body>, and the on-screen keyboard collapses.
|
|
550
|
-
onPointerDown: (e) => e.preventDefault(), onClick: handleSend, disabled: sendDisabled, "aria-label":
|
|
586
|
+
onPointerDown: (e) => e.preventDefault(), onClick: handleSend, disabled: sendDisabled, "aria-label": status === 'sending' || status === 'streaming'
|
|
587
|
+
? 'Sending message'
|
|
588
|
+
: status === 'idle' || status === 'loading'
|
|
589
|
+
? 'Preparing chat'
|
|
590
|
+
: 'Send message', children: status === 'sending' ||
|
|
591
|
+
status === 'streaming' ||
|
|
592
|
+
status === 'idle' ||
|
|
593
|
+
status === 'loading' ? ((0, jsx_runtime_1.jsx)(SpinnerIcon, {})) : ((0, jsx_runtime_1.jsx)(SendIcon, {})) })] }), !bare && (0, jsx_runtime_1.jsx)("div", { className: "af-footer", children: "Powered by AgentForge" })] })] }));
|
|
551
594
|
}
|
|
552
595
|
function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, avatarAgentId, speakerLabel, }) {
|
|
553
596
|
const kind = message.metadata?.kind;
|
|
@@ -720,6 +763,11 @@ function CloseIcon() {
|
|
|
720
763
|
function SendIcon() {
|
|
721
764
|
return ((0, jsx_runtime_1.jsxs)("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: [(0, jsx_runtime_1.jsx)("line", { x1: "22", y1: "2", x2: "11", y2: "13" }), (0, jsx_runtime_1.jsx)("polygon", { points: "22 2 15 22 11 13 2 9 22 2" })] }));
|
|
722
765
|
}
|
|
766
|
+
function SpinnerIcon() {
|
|
767
|
+
// Inline SVG spinner — no external dep. Stroke-dasharray
|
|
768
|
+
// arc rotates via the CSS keyframes injected by WIDGET_CSS.
|
|
769
|
+
return ((0, jsx_runtime_1.jsxs)("svg", { viewBox: "0 0 24 24", fill: "none", className: "af-spinner", "aria-hidden": true, children: [(0, jsx_runtime_1.jsx)("circle", { cx: "12", cy: "12", r: "9", stroke: "currentColor", strokeOpacity: "0.25", strokeWidth: "2.5" }), (0, jsx_runtime_1.jsx)("path", { d: "M21 12a9 9 0 0 0-9-9", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round" })] }));
|
|
770
|
+
}
|
|
723
771
|
// Stylesheet kept verbatim from the standalone widget.js so the React
|
|
724
772
|
// component is visually indistinguishable from the script-injected one.
|
|
725
773
|
const WIDGET_CSS = `
|
|
@@ -804,6 +852,8 @@ const WIDGET_CSS = `
|
|
|
804
852
|
to { opacity: 1; transform: translateY(0); }
|
|
805
853
|
}
|
|
806
854
|
.af-input-row { padding: 12px; border-top: 1px solid var(--af-border); background: var(--af-bg); display: flex; gap: 8px; align-items: flex-end; }
|
|
855
|
+
.af-input-row[data-loading] .af-input { cursor: progress; opacity: 0.7; }
|
|
856
|
+
.af-input-row[data-loading] .af-input::placeholder { font-style: italic; }
|
|
807
857
|
/* Composer left slot — hosts use this for affordance buttons that
|
|
808
858
|
scope the next turn (member picker, tools menu, attachments).
|
|
809
859
|
align-items: center keeps a single-line chip vertically centered
|
|
@@ -863,6 +913,8 @@ const WIDGET_CSS = `
|
|
|
863
913
|
.af-send:active:not(:disabled) { transform: translateY(0); }
|
|
864
914
|
.af-send:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
865
915
|
.af-send svg { width: 16px; height: 16px; }
|
|
916
|
+
.af-send .af-spinner { animation: af-spin 720ms linear infinite; }
|
|
917
|
+
@keyframes af-spin { to { transform: rotate(360deg); } }
|
|
866
918
|
.af-error { padding: 10px 16px; font-size: 12px; color: #b91c1c; background: #fef2f2; border-top: 1px solid #fee2e2; }
|
|
867
919
|
.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); }
|
|
868
920
|
/* Approval and blocked bubbles. Amber for needs-decision, red for
|
package/dist/session.d.ts
CHANGED
package/dist/session.js
CHANGED
|
@@ -172,6 +172,7 @@ class ChatSession {
|
|
|
172
172
|
const trimmed = text.trim();
|
|
173
173
|
if (!trimmed)
|
|
174
174
|
return '';
|
|
175
|
+
this.debug('send() called', { text: trimmed, status: this.state.status });
|
|
175
176
|
if (this.state.status === 'ended') {
|
|
176
177
|
throw new Error('Conversation has ended. Start a fresh chat to continue.');
|
|
177
178
|
}
|
|
@@ -180,6 +181,7 @@ class ChatSession {
|
|
|
180
181
|
// where the user taps Send while the initial agent/theme
|
|
181
182
|
// fetch is still in flight and conversationId is unset.
|
|
182
183
|
await this.start();
|
|
184
|
+
this.debug('send() start awaited, status=' + this.state.status);
|
|
183
185
|
if (!opts?.silent) {
|
|
184
186
|
this.appendMessage({
|
|
185
187
|
id: makeMessageId('u'),
|
|
@@ -203,6 +205,7 @@ class ChatSession {
|
|
|
203
205
|
}
|
|
204
206
|
// ─── Internals ──────────────────────────────────────────────────────────
|
|
205
207
|
async runStream(text, assistant) {
|
|
208
|
+
this.debug('runStream start', { hasConvId: !!this.state.conversationId });
|
|
206
209
|
this.setStatus('sending');
|
|
207
210
|
// The "active" message is the one we're currently appending
|
|
208
211
|
// text_deltas into. For non-team sessions it's always the initial
|
|
@@ -221,8 +224,11 @@ class ChatSession {
|
|
|
221
224
|
const generator = this.state.conversationId
|
|
222
225
|
? this.transport.streamSendMessage(this.state.conversationId, text, this.browserSessionId)
|
|
223
226
|
: this.transport.streamCreateConversation(text, this.browserSessionId);
|
|
227
|
+
this.debug('runStream got generator, awaiting events');
|
|
224
228
|
let sawAnyChunk = false;
|
|
229
|
+
let chunkCount = 0;
|
|
225
230
|
for await (const evt of generator) {
|
|
231
|
+
this.debug('SSE evt', evt.kind, chunkCount++);
|
|
226
232
|
if (evt.kind === 'conversation') {
|
|
227
233
|
this.state.conversationId = evt.id;
|
|
228
234
|
this.emit({ type: 'conversation_started', conversationId: evt.id });
|
|
@@ -398,6 +404,7 @@ class ChatSession {
|
|
|
398
404
|
cleanup(assistant);
|
|
399
405
|
if (active !== assistant)
|
|
400
406
|
cleanup(active);
|
|
407
|
+
this.debug('runStream THREW', err instanceof Error ? err.message : String(err));
|
|
401
408
|
this.handleError(err);
|
|
402
409
|
return assistant.content;
|
|
403
410
|
}
|
|
@@ -458,6 +465,50 @@ class ChatSession {
|
|
|
458
465
|
emitStateChange() {
|
|
459
466
|
this.emit({ type: 'state', state: this.getState() });
|
|
460
467
|
}
|
|
468
|
+
// Lightweight debug logger that BOTH logs to console AND appends
|
|
469
|
+
// to a visible on-screen overlay so mobile QA without remote
|
|
470
|
+
// devtools can still see what's happening. Enable by running
|
|
471
|
+
// `localStorage.setItem('af-chat-debug', '1')` in the browser
|
|
472
|
+
// console (or via the URL `?af-debug=1` once mounted) and
|
|
473
|
+
// reloading. Disabled by default so production ships clean.
|
|
474
|
+
debug(...args) {
|
|
475
|
+
try {
|
|
476
|
+
if (typeof window === 'undefined')
|
|
477
|
+
return;
|
|
478
|
+
const ls = window.localStorage;
|
|
479
|
+
let enabled = ls?.getItem('af-chat-debug') === '1';
|
|
480
|
+
if (!enabled && window.location?.search?.includes('af-debug=1')) {
|
|
481
|
+
ls?.setItem('af-chat-debug', '1');
|
|
482
|
+
enabled = true;
|
|
483
|
+
}
|
|
484
|
+
if (!enabled)
|
|
485
|
+
return;
|
|
486
|
+
const msg = args
|
|
487
|
+
.map((a) => (typeof a === 'string' ? a : JSON.stringify(a)))
|
|
488
|
+
.join(' ');
|
|
489
|
+
// eslint-disable-next-line no-console
|
|
490
|
+
console.log('[chat-sdk]', msg);
|
|
491
|
+
// Visible overlay (single shared element across all sessions).
|
|
492
|
+
let panel = document.getElementById('af-debug-panel');
|
|
493
|
+
if (!panel) {
|
|
494
|
+
panel = document.createElement('div');
|
|
495
|
+
panel.id = 'af-debug-panel';
|
|
496
|
+
panel.style.cssText =
|
|
497
|
+
'position:fixed;top:0;left:0;right:0;max-height:38vh;overflow:auto;' +
|
|
498
|
+
'background:rgba(0,0,0,0.85);color:#7CFC00;font:11px/1.3 monospace;' +
|
|
499
|
+
'padding:6px 8px;z-index:2147483647;pointer-events:auto;' +
|
|
500
|
+
'white-space:pre-wrap;word-break:break-all;';
|
|
501
|
+
document.body.appendChild(panel);
|
|
502
|
+
}
|
|
503
|
+
const line = document.createElement('div');
|
|
504
|
+
line.textContent = `${new Date().toISOString().slice(11, 23)} ${msg}`;
|
|
505
|
+
panel.appendChild(line);
|
|
506
|
+
panel.scrollTop = panel.scrollHeight;
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
/* localStorage can throw in private mode — ignore */
|
|
510
|
+
}
|
|
511
|
+
}
|
|
461
512
|
handleError(err) {
|
|
462
513
|
const message = err instanceof Error ? err.message : String(err);
|
|
463
514
|
const code = err && typeof err === 'object' && 'code' in err
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentforge-io/chat-sdk",
|
|
3
|
-
"version": "2.4.0
|
|
3
|
+
"version": "2.4.0",
|
|
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",
|