@agentforge-io/chat-sdk 2.1.0 → 2.2.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/entities.d.ts +11 -0
- package/dist/react.d.ts +28 -0
- package/dist/react.js +173 -24
- package/dist/session.js +2 -0
- package/package.json +1 -1
package/dist/entities.d.ts
CHANGED
|
@@ -160,6 +160,17 @@ export type ChatEvent = {
|
|
|
160
160
|
type: 'error';
|
|
161
161
|
message: string;
|
|
162
162
|
code?: string;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Emitted exactly once per session, the moment the server hands us
|
|
166
|
+
* back the new conversation id after the visitor sent the first
|
|
167
|
+
* message. View layers use this to write the id into URL query
|
|
168
|
+
* (`?c=<id>`), localStorage, etc. so a refresh / drawer-reopen
|
|
169
|
+
* resumes the same conversation instead of starting fresh.
|
|
170
|
+
*/
|
|
171
|
+
| {
|
|
172
|
+
type: 'conversation_started';
|
|
173
|
+
conversationId: string;
|
|
163
174
|
} | {
|
|
164
175
|
type: 'destroyed';
|
|
165
176
|
};
|
package/dist/react.d.ts
CHANGED
|
@@ -77,6 +77,14 @@ export interface ChatWidgetProps {
|
|
|
77
77
|
browserSessionId?: string;
|
|
78
78
|
/** Existing conversation id to resume. */
|
|
79
79
|
resumeConversationId?: string;
|
|
80
|
+
/**
|
|
81
|
+
* Fired exactly once per session, when the server hands back the
|
|
82
|
+
* new conversation id after the visitor sends their first message.
|
|
83
|
+
* Hosts use this to write the id to URL query, localStorage, etc.
|
|
84
|
+
* so a refresh or drawer-reopen resumes the same conversation.
|
|
85
|
+
* Not called when resuming an existing conversation (the host
|
|
86
|
+
* already has the id in that case). */
|
|
87
|
+
onConversationStart?: (conversationId: string) => void;
|
|
80
88
|
/** Disable SSE streaming and use plain POSTs. */
|
|
81
89
|
stream?: boolean;
|
|
82
90
|
/** Extra class on the root container. */
|
|
@@ -151,6 +159,17 @@ export interface ChatWidgetProps {
|
|
|
151
159
|
* behaviour).
|
|
152
160
|
*/
|
|
153
161
|
members?: ChatTeamMember[];
|
|
162
|
+
/**
|
|
163
|
+
* Slot rendered inside the composer row, BEFORE the textarea. Hosts
|
|
164
|
+
* use this for affordances that scope or augment the next turn —
|
|
165
|
+
* e.g. a Team chat's member-picker chip (the "@<agent>" affordance),
|
|
166
|
+
* a tools menu à la Gemini, an attachment button.
|
|
167
|
+
*
|
|
168
|
+
* Kept as a generic React slot rather than a typed prop list so the
|
|
169
|
+
* SDK doesn't have to learn every product surface that wants to put
|
|
170
|
+
* something there.
|
|
171
|
+
*/
|
|
172
|
+
composerLeftSlot?: React.ReactNode;
|
|
154
173
|
/**
|
|
155
174
|
* Imperative handle. The widget fills this ref on mount with a
|
|
156
175
|
* small command object the host can call to drive the session
|
|
@@ -170,6 +189,15 @@ export interface ChatWidgetHandle {
|
|
|
170
189
|
* entirely. No-op if the session isn't ready yet (status ===
|
|
171
190
|
* 'idle' / 'loading'). */
|
|
172
191
|
sendNow(text: string): void;
|
|
192
|
+
/**
|
|
193
|
+
* Insert `text` into the composer's draft at the current cursor
|
|
194
|
+
* position (replacing any selection). Used by mention pickers,
|
|
195
|
+
* suggested-fragment chips, or slash-menu commands — the user
|
|
196
|
+
* sees the text appear in the input as if they had typed it and
|
|
197
|
+
* can keep editing before sending. Focus stays on the textarea
|
|
198
|
+
* and the cursor lands at the end of the inserted text.
|
|
199
|
+
*/
|
|
200
|
+
insertText(text: string): void;
|
|
173
201
|
}
|
|
174
202
|
/**
|
|
175
203
|
* Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
|
package/dist/react.js
CHANGED
|
@@ -227,7 +227,7 @@ function fallbackCopy(ctx) {
|
|
|
227
227
|
* SDK event. Consumers don't need to read `session.getState()` themselves.
|
|
228
228
|
*/
|
|
229
229
|
function ChatWidget(props) {
|
|
230
|
-
const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalDecision, readOnlyApprovals = false, variant = 'card', greeting, personaName, shortcuts, onShortcutClick, inputPlaceholder, members, } = props;
|
|
230
|
+
const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, onConversationStart, stream, className, style, onApprovalDecision, readOnlyApprovals = false, variant = 'card', greeting, personaName, shortcuts, onShortcutClick, inputPlaceholder, members, composerLeftSlot, handleRef, } = props;
|
|
231
231
|
// Build a lookup so MessageBubble can resolve actingAgentId → identity
|
|
232
232
|
// in O(1) per render without re-walking the members array. Stable
|
|
233
233
|
// identity per `members` prop change.
|
|
@@ -250,17 +250,44 @@ function ChatWidget(props) {
|
|
|
250
250
|
const messagesRef = (0, react_1.useRef)(null);
|
|
251
251
|
const inputRef = (0, react_1.useRef)(null);
|
|
252
252
|
const styleId = (0, react_1.useId)();
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
253
|
+
// Track whether the user has already engaged with the widget. We
|
|
254
|
+
// start at false (just landed on the page) and flip to true the
|
|
255
|
+
// moment `handleSend` fires. The auto-focus effect below uses this
|
|
256
|
+
// to decide whether the impending `status = 'ready'` is the first
|
|
257
|
+
// landing (skip focus on touch — opening the keyboard unprompted
|
|
258
|
+
// is hostile) or a return from a send-streaming cycle (re-focus
|
|
259
|
+
// ALWAYS — the user is mid-conversation, losing the cursor
|
|
260
|
+
// breaks the typing-rhythm).
|
|
261
|
+
const hasInteractedRef = (0, react_1.useRef)(false);
|
|
262
|
+
// Hold the latest `onConversationStart` in a ref so the session
|
|
263
|
+
// effect doesn't recreate the ChatSession every time the parent
|
|
264
|
+
// passes a new function identity (typical with inline arrow props).
|
|
265
|
+
// Recreating the session would wipe the transcript on every render.
|
|
266
|
+
const onConversationStartRef = (0, react_1.useRef)(onConversationStart);
|
|
267
|
+
(0, react_1.useEffect)(() => {
|
|
268
|
+
onConversationStartRef.current = onConversationStart;
|
|
269
|
+
}, [onConversationStart]);
|
|
270
|
+
// Auto-focus the composer once the session is ready.
|
|
271
|
+
//
|
|
272
|
+
// Landing (hasInteractedRef.current === false):
|
|
273
|
+
// • pointer:fine → focus (desktop user expects ready-to-type)
|
|
274
|
+
// • pointer:coarse → skip (iOS/Android opening the keyboard
|
|
275
|
+
// before the visitor reads anything
|
|
276
|
+
// is jarring)
|
|
277
|
+
//
|
|
278
|
+
// Returning from a send (hasInteractedRef.current === true):
|
|
279
|
+
// • always focus, including touch — the user just hit Send,
|
|
280
|
+
// they're in conversation mode, their next thought is the
|
|
281
|
+
// next message, the keyboard should stay alive.
|
|
257
282
|
(0, react_1.useEffect)(() => {
|
|
258
283
|
if (status !== 'ready')
|
|
259
284
|
return;
|
|
260
285
|
if (typeof window === 'undefined')
|
|
261
286
|
return;
|
|
262
|
-
if (!
|
|
287
|
+
if (!hasInteractedRef.current &&
|
|
288
|
+
!window.matchMedia('(pointer: fine)').matches) {
|
|
263
289
|
return;
|
|
290
|
+
}
|
|
264
291
|
inputRef.current?.focus({ preventScroll: true });
|
|
265
292
|
}, [status]);
|
|
266
293
|
// ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
|
|
@@ -297,6 +324,10 @@ function ChatWidget(props) {
|
|
|
297
324
|
setLastError(evt.message);
|
|
298
325
|
return;
|
|
299
326
|
}
|
|
327
|
+
if (evt.type === 'conversation_started') {
|
|
328
|
+
onConversationStartRef.current?.(evt.conversationId);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
300
331
|
});
|
|
301
332
|
void s.start();
|
|
302
333
|
return () => {
|
|
@@ -306,11 +337,26 @@ function ChatWidget(props) {
|
|
|
306
337
|
};
|
|
307
338
|
}, [token, apiBaseUrl, browserSessionId, resumeConversationId, stream]);
|
|
308
339
|
// Auto-scroll on new tokens.
|
|
340
|
+
//
|
|
341
|
+
// We defer the scroll into a requestAnimationFrame so the DOM has
|
|
342
|
+
// actually grown by the time we read `scrollHeight`. Without that
|
|
343
|
+
// tick, a stream of small text_deltas can leave the scroll lagging
|
|
344
|
+
// 1–2 chunks behind because the effect runs synchronously after
|
|
345
|
+
// the React commit but BEFORE the browser paints the new rows.
|
|
346
|
+
// Result: the user sees the latest sentence half-cut at the bottom.
|
|
347
|
+
//
|
|
348
|
+
// `behavior: 'smooth'` doesn't help much during a fast stream (each
|
|
349
|
+
// rAF queues a new smooth-scroll that cancels the previous one) but
|
|
350
|
+
// it does make the FINAL settle look fluid — and that's the part
|
|
351
|
+
// the user notices when the stream stops.
|
|
309
352
|
(0, react_1.useEffect)(() => {
|
|
310
353
|
const el = messagesRef.current;
|
|
311
354
|
if (!el)
|
|
312
355
|
return;
|
|
313
|
-
|
|
356
|
+
const raf = requestAnimationFrame(() => {
|
|
357
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
358
|
+
});
|
|
359
|
+
return () => cancelAnimationFrame(raf);
|
|
314
360
|
}, [messages]);
|
|
315
361
|
// Inject the widget stylesheet exactly once per page. We key the <style>
|
|
316
362
|
// tag by a fixed id so multiple widget mounts share it.
|
|
@@ -331,6 +377,11 @@ function ChatWidget(props) {
|
|
|
331
377
|
const text = draft.trim();
|
|
332
378
|
if (!text)
|
|
333
379
|
return;
|
|
380
|
+
// Mark engagement BEFORE the status flip so the auto-focus
|
|
381
|
+
// effect (which watches `status`) sees the flag when it
|
|
382
|
+
// re-runs after `ready` returns. Subsequent renders will
|
|
383
|
+
// refocus the textarea on every send completion.
|
|
384
|
+
hasInteractedRef.current = true;
|
|
334
385
|
setDraft('');
|
|
335
386
|
void session.send(text);
|
|
336
387
|
}, [session, draft]);
|
|
@@ -340,6 +391,63 @@ function ChatWidget(props) {
|
|
|
340
391
|
handleSend();
|
|
341
392
|
}
|
|
342
393
|
}, [handleSend]);
|
|
394
|
+
// Insert `text` at the current cursor position of the composer
|
|
395
|
+
// textarea. Replaces any active selection, advances the cursor to
|
|
396
|
+
// the end of the inserted slice, and keeps focus on the input so
|
|
397
|
+
// the user can keep typing without a click. Falls back to "append
|
|
398
|
+
// at the end" when the textarea isn't mounted yet (e.g. the host
|
|
399
|
+
// calls insertText before the session has booted).
|
|
400
|
+
const insertText = (0, react_1.useCallback)((text) => {
|
|
401
|
+
if (!text)
|
|
402
|
+
return;
|
|
403
|
+
const el = inputRef.current;
|
|
404
|
+
if (!el) {
|
|
405
|
+
setDraft((prev) => prev + text);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const start = el.selectionStart ?? el.value.length;
|
|
409
|
+
const end = el.selectionEnd ?? el.value.length;
|
|
410
|
+
const before = el.value.slice(0, start);
|
|
411
|
+
const after = el.value.slice(end);
|
|
412
|
+
const next = before + text + after;
|
|
413
|
+
setDraft(next);
|
|
414
|
+
// Imperatively place the cursor after the commit. React's
|
|
415
|
+
// state update is async, so we wait one tick — by then the
|
|
416
|
+
// controlled value has flushed into the DOM and we can move
|
|
417
|
+
// the selection without it being clobbered.
|
|
418
|
+
queueMicrotask(() => {
|
|
419
|
+
const cursor = before.length + text.length;
|
|
420
|
+
el.focus({ preventScroll: true });
|
|
421
|
+
el.setSelectionRange(cursor, cursor);
|
|
422
|
+
});
|
|
423
|
+
}, []);
|
|
424
|
+
// Wire the imperative handle. The host's ref slot is filled in on
|
|
425
|
+
// every render so a late-binding consumer still gets the live
|
|
426
|
+
// closure (sendNow reads the latest session/state via the closure
|
|
427
|
+
// it captures here).
|
|
428
|
+
(0, react_1.useEffect)(() => {
|
|
429
|
+
if (!handleRef)
|
|
430
|
+
return;
|
|
431
|
+
handleRef.current = {
|
|
432
|
+
sendNow: (text) => {
|
|
433
|
+
if (!session)
|
|
434
|
+
return;
|
|
435
|
+
const trimmed = text.trim();
|
|
436
|
+
if (!trimmed)
|
|
437
|
+
return;
|
|
438
|
+
hasInteractedRef.current = true;
|
|
439
|
+
setDraft('');
|
|
440
|
+
void session.send(trimmed);
|
|
441
|
+
},
|
|
442
|
+
insertText,
|
|
443
|
+
};
|
|
444
|
+
return () => {
|
|
445
|
+
// Drop the handle on unmount so a stale ref can't fire send
|
|
446
|
+
// after the component is gone.
|
|
447
|
+
if (handleRef.current)
|
|
448
|
+
handleRef.current = null;
|
|
449
|
+
};
|
|
450
|
+
}, [handleRef, session, insertText]);
|
|
343
451
|
const sendDisabled = !session ||
|
|
344
452
|
status === 'idle' ||
|
|
345
453
|
status === 'loading' ||
|
|
@@ -397,7 +505,14 @@ function ChatWidget(props) {
|
|
|
397
505
|
? { ...theme, avatarUrl: member.avatarUrl }
|
|
398
506
|
: theme;
|
|
399
507
|
const bubbleAvatarName = member?.name ?? personaName ?? agent?.name;
|
|
400
|
-
|
|
508
|
+
// Seed for the deterministic-hue fallback avatar. Prefer
|
|
509
|
+
// the acting agent id (so each member in a team gets its
|
|
510
|
+
// own stable color) and fall back to the primary agent's
|
|
511
|
+
// slug for solo chats / orchestrator-self turns. The
|
|
512
|
+
// `agent` summary doesn't carry an id — slug is stable
|
|
513
|
+
// and unique, which is all hueFromSeed needs.
|
|
514
|
+
const bubbleAgentId = m.actingAgentId ?? agent?.slug;
|
|
515
|
+
return ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m, session: session, readOnly: readOnlyApprovals, onDecision: onApprovalDecision, bare: bare, showAvatar: showAvatar, avatarTheme: bubbleAvatarTheme, avatarName: bubbleAvatarName, avatarAgentId: bubbleAgentId, speakerLabel: member?.name, onContinue: () => {
|
|
401
516
|
// After a successful Approve, kick the next turn so the
|
|
402
517
|
// gate's fast-path consumes the approval and the tool
|
|
403
518
|
// actually runs. `silent: true` keeps the literal
|
|
@@ -410,23 +525,23 @@ function ChatWidget(props) {
|
|
|
410
525
|
void session.send('continue', { silent: true });
|
|
411
526
|
}, 250);
|
|
412
527
|
} }, m.id));
|
|
413
|
-
}) }), lastError && status === 'error' && ((0, jsx_runtime_1.jsx)("div", { className: "af-error", children: lastError })), bare && greeting && messages.length === 0 && status !== 'loading' && ((0, jsx_runtime_1.jsx)("div", { className: "af-greeting-slot", children: (0, jsx_runtime_1.jsxs)("div", { className: "af-msg-row af-msg-row-assistant af-msg-row-greeting", children: [(0, jsx_runtime_1.jsx)(AssistantAvatar, { theme: theme, name: personaName ?? agent?.name, show: true }), (0, jsx_runtime_1.jsx)("div", { className: "af-msg af-msg-assistant af-msg-greeting", dangerouslySetInnerHTML: { __html: renderMarkdown(greeting) } })] }) })), shortcuts && shortcuts.length > 0 && messages.length === 0 && ((0, jsx_runtime_1.jsx)("div", { className: "af-shortcut-row", children: shortcuts.map((text, i) => ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-shortcut", onClick: () => {
|
|
528
|
+
}) }), lastError && status === 'error' && ((0, jsx_runtime_1.jsx)("div", { className: "af-error", children: lastError })), bare && greeting && messages.length === 0 && status !== 'loading' && ((0, jsx_runtime_1.jsx)("div", { className: "af-greeting-slot", children: (0, jsx_runtime_1.jsxs)("div", { className: "af-msg-row af-msg-row-assistant af-msg-row-greeting", children: [(0, jsx_runtime_1.jsx)(AssistantAvatar, { theme: theme, name: personaName ?? agent?.name, agentId: agent?.slug, show: true }), (0, jsx_runtime_1.jsx)("div", { className: "af-msg af-msg-assistant af-msg-greeting", dangerouslySetInnerHTML: { __html: renderMarkdown(greeting) } })] }) })), shortcuts && shortcuts.length > 0 && messages.length === 0 && ((0, jsx_runtime_1.jsx)("div", { className: "af-shortcut-row", children: shortcuts.map((text, i) => ((0, jsx_runtime_1.jsx)("button", { type: "button", className: "af-shortcut", onClick: () => {
|
|
414
529
|
if (onShortcutClick)
|
|
415
530
|
onShortcutClick(text, i);
|
|
416
531
|
else
|
|
417
532
|
setDraft(text);
|
|
418
|
-
}, children: text }, `${i}-${text}`))) })), (0, jsx_runtime_1.jsxs)("div", { className: "af-input-row", children: [(0, jsx_runtime_1.jsx)("textarea", { ref: inputRef, className: "af-input", value: draft, onChange: (e) => setDraft(e.target.value), onKeyDown: onKeyDown, placeholder: inputPlaceholder ?? 'Type a message…', 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, {}) })] }), !bare && (0, jsx_runtime_1.jsx)("div", { className: "af-footer", children: "Powered by AgentForge" })] })] }));
|
|
533
|
+
}, 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: inputPlaceholder ?? 'Type a message…', 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, {}) })] }), !bare && (0, jsx_runtime_1.jsx)("div", { className: "af-footer", children: "Powered by AgentForge" })] })] }));
|
|
419
534
|
}
|
|
420
|
-
function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, speakerLabel, }) {
|
|
535
|
+
function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, avatarAgentId, speakerLabel, }) {
|
|
421
536
|
const kind = message.metadata?.kind;
|
|
422
537
|
if (kind === 'awaiting_approval') {
|
|
423
538
|
// Approval / blocked bubbles also count as "assistant-side" so we
|
|
424
539
|
// wrap them in the same row geometry — keeps the conversation
|
|
425
540
|
// aligned even when a tool dispatch interrupts the regular flow.
|
|
426
|
-
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }), speakerLabel);
|
|
541
|
+
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }), speakerLabel);
|
|
427
542
|
}
|
|
428
543
|
if (kind === 'tool_blocked') {
|
|
429
|
-
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }), speakerLabel);
|
|
544
|
+
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }), speakerLabel);
|
|
430
545
|
}
|
|
431
546
|
const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
|
|
432
547
|
? message.content
|
|
@@ -435,10 +550,10 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bar
|
|
|
435
550
|
: ''}`;
|
|
436
551
|
// Typing state (no content yet): render the three-dot indicator, no markdown.
|
|
437
552
|
if (message.role === 'assistant' && message.isStreaming && !message.content) {
|
|
438
|
-
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)("div", { className: cls, children: (0, jsx_runtime_1.jsxs)("span", { className: "af-typing-dots", "aria-label": "Assistant is typing", children: [(0, jsx_runtime_1.jsx)("span", {}), " ", (0, jsx_runtime_1.jsx)("span", {}), " ", (0, jsx_runtime_1.jsx)("span", {})] }) }), speakerLabel);
|
|
553
|
+
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)("div", { className: cls, children: (0, jsx_runtime_1.jsxs)("span", { className: "af-typing-dots", "aria-label": "Assistant is typing", children: [(0, jsx_runtime_1.jsx)("span", {}), " ", (0, jsx_runtime_1.jsx)("span", {}), " ", (0, jsx_runtime_1.jsx)("span", {})] }) }), speakerLabel);
|
|
439
554
|
}
|
|
440
555
|
if (message.role === 'assistant') {
|
|
441
|
-
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)("div", { className: cls,
|
|
556
|
+
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, avatarAgentId, (0, jsx_runtime_1.jsx)("div", { className: cls,
|
|
442
557
|
// Output is sanitized by escapeHtml + a fixed tag whitelist in
|
|
443
558
|
// renderMarkdown — safe to inject as HTML.
|
|
444
559
|
dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }), speakerLabel);
|
|
@@ -452,22 +567,27 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bar
|
|
|
452
567
|
* avatar column. Card variant skips the wrapper entirely so its
|
|
453
568
|
* historical layout is unchanged.
|
|
454
569
|
*/
|
|
455
|
-
function wrapAssistantRow(bare, showAvatar, theme, name, child,
|
|
570
|
+
function wrapAssistantRow(bare, showAvatar, theme, name, agentId, child,
|
|
456
571
|
/** When set + this is the first bubble of a speaker run (showAvatar
|
|
457
572
|
* is true), render the speaker's display name just above the
|
|
458
573
|
* bubble. Used by Team chats so members are visually attributed. */
|
|
459
574
|
speakerLabel) {
|
|
460
575
|
if (!bare)
|
|
461
576
|
return child;
|
|
462
|
-
return ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg-row af-msg-row-assistant", children: [(0, jsx_runtime_1.jsx)(AssistantAvatar, { theme: theme, name: name, show: showAvatar }), (0, jsx_runtime_1.jsxs)("div", { className: "af-msg-col", children: [showAvatar && speakerLabel ? ((0, jsx_runtime_1.jsx)("div", { className: "af-msg-speaker", children: speakerLabel })) : null, child] })] }));
|
|
577
|
+
return ((0, jsx_runtime_1.jsxs)("div", { className: "af-msg-row af-msg-row-assistant", children: [(0, jsx_runtime_1.jsx)(AssistantAvatar, { theme: theme, name: name, agentId: agentId, show: showAvatar }), (0, jsx_runtime_1.jsxs)("div", { className: "af-msg-col", children: [showAvatar && speakerLabel ? ((0, jsx_runtime_1.jsx)("div", { className: "af-msg-speaker", children: speakerLabel })) : null, child] })] }));
|
|
463
578
|
}
|
|
464
579
|
/**
|
|
465
580
|
* Small circular avatar for the assistant-side column. Prefers the
|
|
466
|
-
* agent's avatarUrl when set; otherwise renders a
|
|
467
|
-
*
|
|
468
|
-
*
|
|
581
|
+
* agent's avatarUrl when set; otherwise renders a SOLID circle with
|
|
582
|
+
* the first letter of `name` over a hue derived from `agentId`. The
|
|
583
|
+
* hash → HSL mapping means every agent in a team gets a stable,
|
|
584
|
+
* distinguishable color across renders without us needing a palette
|
|
585
|
+
* table or per-agent config.
|
|
586
|
+
*
|
|
587
|
+
* The slot reserves space even when `show` is false so consecutive
|
|
588
|
+
* bubbles stay column-aligned.
|
|
469
589
|
*/
|
|
470
|
-
function AssistantAvatar({ theme, name, show, }) {
|
|
590
|
+
function AssistantAvatar({ theme, name, agentId, show, }) {
|
|
471
591
|
const initial = (name ?? 'A').trim().charAt(0).toUpperCase() || 'A';
|
|
472
592
|
if (!show) {
|
|
473
593
|
return (0, jsx_runtime_1.jsx)("div", { className: "af-msg-avatar af-msg-avatar-spacer", "aria-hidden": true });
|
|
@@ -477,7 +597,21 @@ function AssistantAvatar({ theme, name, show, }) {
|
|
|
477
597
|
// eslint-disable-next-line @next/next/no-img-element -- vanilla widget; consumer can override host
|
|
478
598
|
(0, jsx_runtime_1.jsx)("img", { className: "af-msg-avatar af-msg-avatar-img", src: theme.avatarUrl, alt: "", "aria-hidden": true }));
|
|
479
599
|
}
|
|
480
|
-
|
|
600
|
+
const seed = agentId || name || 'agent';
|
|
601
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "af-msg-avatar af-msg-avatar-fallback", "aria-hidden": true, style: { backgroundColor: hueFromSeed(seed) }, children: (0, jsx_runtime_1.jsx)("span", { children: initial }) }));
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Deterministic per-agent hue. Hash the seed into the HSL hue space
|
|
605
|
+
* so an agent's avatar color stays stable across renders and looks
|
|
606
|
+
* varied across a roster. Saturation/lightness are tuned for both
|
|
607
|
+
* light + dark chat surfaces: mid-saturation + mid-lightness reads
|
|
608
|
+
* legibly with white text on either background.
|
|
609
|
+
*/
|
|
610
|
+
function hueFromSeed(seed) {
|
|
611
|
+
let h = 0;
|
|
612
|
+
for (let i = 0; i < seed.length; i++)
|
|
613
|
+
h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
|
614
|
+
return `hsl(${h % 360} 60% 52%)`;
|
|
481
615
|
}
|
|
482
616
|
/**
|
|
483
617
|
* Awaiting-approval bubble. Renders the tool name + a countdown, and
|
|
@@ -653,6 +787,11 @@ const WIDGET_CSS = `
|
|
|
653
787
|
to { opacity: 1; transform: translateY(0); }
|
|
654
788
|
}
|
|
655
789
|
.af-input-row { padding: 12px; border-top: 1px solid var(--af-border); background: var(--af-bg); display: flex; gap: 8px; align-items: flex-end; }
|
|
790
|
+
/* Composer left slot — hosts use this for affordance buttons that
|
|
791
|
+
scope the next turn (member picker, tools menu, attachments).
|
|
792
|
+
align-items: center keeps a single-line chip vertically centered
|
|
793
|
+
against the auto-growing textarea. */
|
|
794
|
+
.af-input-left { display: flex; align-items: center; padding-bottom: 4px; flex-shrink: 0; }
|
|
656
795
|
.af-shortcut-row {
|
|
657
796
|
display: flex;
|
|
658
797
|
gap: 6px;
|
|
@@ -776,7 +915,13 @@ const WIDGET_CSS = `
|
|
|
776
915
|
background: transparent;
|
|
777
916
|
border-radius: 0;
|
|
778
917
|
box-shadow: none;
|
|
779
|
-
overflow:
|
|
918
|
+
/* overflow:hidden so a long transcript (markdown tables, long
|
|
919
|
+
lists) scrolls inside af-messages instead of pushing the
|
|
920
|
+
af-input-row past the bottom of the host envelope. Was 'visible'
|
|
921
|
+
before, which let a tall message break out of the host sized
|
|
922
|
+
container and shove the composer off-screen. The messages region
|
|
923
|
+
itself carries overflow-y:auto so internal scroll keeps working. */
|
|
924
|
+
overflow: hidden;
|
|
780
925
|
display: flex;
|
|
781
926
|
flex-direction: column;
|
|
782
927
|
flex: 1;
|
|
@@ -1009,7 +1154,11 @@ const WIDGET_CSS = `
|
|
|
1009
1154
|
font-size: 11.5px;
|
|
1010
1155
|
font-weight: 600;
|
|
1011
1156
|
letter-spacing: 0.01em;
|
|
1012
|
-
|
|
1157
|
+
/* SOLID fill — the React component sets the actual color via an
|
|
1158
|
+
* inline style.backgroundColor (deterministic hash of agentId).
|
|
1159
|
+
* No gradient here on purpose: the gradient was masking the
|
|
1160
|
+
* inline color and made every agent avatar look the same. */
|
|
1161
|
+
background-color: var(--af-primary, #8b5cf6);
|
|
1013
1162
|
}
|
|
1014
1163
|
@media (min-width: 768px) {
|
|
1015
1164
|
.af-widget-root.af-variant-bare .af-msg-avatar { width: 30px; height: 30px; }
|
package/dist/session.js
CHANGED
|
@@ -211,6 +211,7 @@ class ChatSession {
|
|
|
211
211
|
for await (const evt of generator) {
|
|
212
212
|
if (evt.kind === 'conversation') {
|
|
213
213
|
this.state.conversationId = evt.id;
|
|
214
|
+
this.emit({ type: 'conversation_started', conversationId: evt.id });
|
|
214
215
|
continue;
|
|
215
216
|
}
|
|
216
217
|
if (evt.kind === 'chunk') {
|
|
@@ -398,6 +399,7 @@ class ChatSession {
|
|
|
398
399
|
else {
|
|
399
400
|
const res = await this.transport.createConversation(text, this.browserSessionId);
|
|
400
401
|
this.state.conversationId = res.conversationId;
|
|
402
|
+
this.emit({ type: 'conversation_started', conversationId: res.conversationId });
|
|
401
403
|
content = res.content;
|
|
402
404
|
}
|
|
403
405
|
assistant.content = content;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentforge-io/chat-sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.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",
|