@agentforge-io/chat-sdk 2.0.25 → 2.1.1
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 +46 -0
- package/dist/react.d.ts +11 -1
- package/dist/react.js +178 -36
- package/dist/session.js +112 -19
- package/package.json +1 -1
package/dist/entities.d.ts
CHANGED
|
@@ -71,12 +71,43 @@ export interface ChatMessage {
|
|
|
71
71
|
* errors, …). View layers should branch on `metadata.kind` before
|
|
72
72
|
* falling back to `content`. */
|
|
73
73
|
metadata?: ChatMessageMetadata;
|
|
74
|
+
/** When set, this assistant bubble was produced by a delegated member
|
|
75
|
+
* agent (not the primary session agent). View layers resolve the
|
|
76
|
+
* member's avatar/name via the session's `members` roster. Absent
|
|
77
|
+
* on user messages and on bubbles spoken by the primary agent
|
|
78
|
+
* (the orchestrator itself, or any non-team agent). */
|
|
79
|
+
actingAgentId?: string;
|
|
74
80
|
}
|
|
75
81
|
export interface ChatAgentSummary {
|
|
76
82
|
slug: string;
|
|
77
83
|
name: string;
|
|
78
84
|
description?: string;
|
|
79
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* One entry in a Team session's member roster. The orchestrator delegates
|
|
88
|
+
* to these members; the SDK uses the roster to resolve `actingAgentId`
|
|
89
|
+
* → avatar/name when rendering per-bubble identity.
|
|
90
|
+
*
|
|
91
|
+
* For non-team sessions (a single agent), `members` is empty/undefined
|
|
92
|
+
* and the SDK falls back to the primary agent's identity on every
|
|
93
|
+
* assistant bubble — the legacy behaviour.
|
|
94
|
+
*/
|
|
95
|
+
export interface ChatTeamMember {
|
|
96
|
+
/** Agent id — matches `actingAgentId` on stream chunks and on
|
|
97
|
+
* rehydrated `ChatMessage.actingAgentId`. */
|
|
98
|
+
id: string;
|
|
99
|
+
name: string;
|
|
100
|
+
/** Optional avatar URL. When absent, view layers fall back to a
|
|
101
|
+
* gradient-with-initial placeholder. */
|
|
102
|
+
avatarUrl?: string;
|
|
103
|
+
/** Optional short description ("Handles billing and refunds"). Used
|
|
104
|
+
* for tooltip / member-strip rendering on the team channel page. */
|
|
105
|
+
description?: string;
|
|
106
|
+
/** Optional per-member routing hint inherited from the team config.
|
|
107
|
+
* Surfaced in tooltips so visitors understand the routing policy
|
|
108
|
+
* without opening the admin view. */
|
|
109
|
+
roleHint?: string;
|
|
110
|
+
}
|
|
80
111
|
/**
|
|
81
112
|
* Visual theme returned alongside the agent. Pure data — the SDK doesn't
|
|
82
113
|
* apply it; it just surfaces it so any View layer (widget renderer, React
|
|
@@ -110,6 +141,21 @@ export type ChatEvent = {
|
|
|
110
141
|
} | {
|
|
111
142
|
type: 'message_removed';
|
|
112
143
|
messageId: string;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Emitted when a Team orchestrator delegates a turn to one of its
|
|
147
|
+
* members. Carries the member identity so the View layer can render
|
|
148
|
+
* a subtle "Routing to <member>…" hint (typically as a low-contrast
|
|
149
|
+
* line between the orchestrator's wrapper bubble and the member's
|
|
150
|
+
* answer). Pure UX signal — the actual member output arrives via
|
|
151
|
+
* regular `message_added` / `message_updated` events with the
|
|
152
|
+
* matching `actingAgentId`.
|
|
153
|
+
*/
|
|
154
|
+
| {
|
|
155
|
+
type: 'delegation_routed';
|
|
156
|
+
memberId: string;
|
|
157
|
+
memberName?: string;
|
|
158
|
+
memberAvatarUrl?: string;
|
|
113
159
|
} | {
|
|
114
160
|
type: 'error';
|
|
115
161
|
message: string;
|
package/dist/react.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* runtime error.
|
|
17
17
|
*/
|
|
18
18
|
import { type CSSProperties } from 'react';
|
|
19
|
-
import type { ChatTheme } from './entities';
|
|
19
|
+
import type { ChatTeamMember, ChatTheme } from './entities';
|
|
20
20
|
/**
|
|
21
21
|
* Optional observer fired after the SDK resolves a tool-approval bubble.
|
|
22
22
|
* The SDK owns the network call (it already has `apiBaseUrl` and the
|
|
@@ -141,6 +141,16 @@ export interface ChatWidgetProps {
|
|
|
141
141
|
* Provide this hook to customise — e.g. send immediately, log the
|
|
142
142
|
* click, or open an external link instead. */
|
|
143
143
|
onShortcutClick?: (text: string, index: number) => void;
|
|
144
|
+
/**
|
|
145
|
+
* Team-member roster. When the chat is bound to a Team orchestrator,
|
|
146
|
+
* pass the team's members here so the SDK can render each delegated
|
|
147
|
+
* bubble with the right member's avatar / name. Each entry's `id`
|
|
148
|
+
* must match the `actingAgentId` the runtime emits on stream chunks
|
|
149
|
+
* and on rehydrated `ChatMessage`s. Empty/undefined ⇒ single-agent
|
|
150
|
+
* session, every bubble uses the primary agent identity (legacy
|
|
151
|
+
* behaviour).
|
|
152
|
+
*/
|
|
153
|
+
members?: ChatTeamMember[];
|
|
144
154
|
/**
|
|
145
155
|
* Imperative handle. The widget fills this ref on mount with a
|
|
146
156
|
* small command object the host can call to drive the session
|
package/dist/react.js
CHANGED
|
@@ -227,7 +227,17 @@ 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, } = 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;
|
|
231
|
+
// Build a lookup so MessageBubble can resolve actingAgentId → identity
|
|
232
|
+
// in O(1) per render without re-walking the members array. Stable
|
|
233
|
+
// identity per `members` prop change.
|
|
234
|
+
const membersById = (0, react_1.useMemo)(() => {
|
|
235
|
+
const m = new Map();
|
|
236
|
+
if (members)
|
|
237
|
+
for (const member of members)
|
|
238
|
+
m.set(member.id, member);
|
|
239
|
+
return m;
|
|
240
|
+
}, [members]);
|
|
231
241
|
const bare = variant === 'bare';
|
|
232
242
|
const [session, setSession] = (0, react_1.useState)(null);
|
|
233
243
|
const [status, setStatus] = (0, react_1.useState)('idle');
|
|
@@ -238,7 +248,40 @@ function ChatWidget(props) {
|
|
|
238
248
|
const [open, setOpen] = (0, react_1.useState)(inline);
|
|
239
249
|
const [draft, setDraft] = (0, react_1.useState)('');
|
|
240
250
|
const messagesRef = (0, react_1.useRef)(null);
|
|
251
|
+
const inputRef = (0, react_1.useRef)(null);
|
|
241
252
|
const styleId = (0, react_1.useId)();
|
|
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
|
+
// Auto-focus the composer once the session is ready.
|
|
263
|
+
//
|
|
264
|
+
// Landing (hasInteractedRef.current === false):
|
|
265
|
+
// • pointer:fine → focus (desktop user expects ready-to-type)
|
|
266
|
+
// • pointer:coarse → skip (iOS/Android opening the keyboard
|
|
267
|
+
// before the visitor reads anything
|
|
268
|
+
// is jarring)
|
|
269
|
+
//
|
|
270
|
+
// Returning from a send (hasInteractedRef.current === true):
|
|
271
|
+
// • always focus, including touch — the user just hit Send,
|
|
272
|
+
// they're in conversation mode, their next thought is the
|
|
273
|
+
// next message, the keyboard should stay alive.
|
|
274
|
+
(0, react_1.useEffect)(() => {
|
|
275
|
+
if (status !== 'ready')
|
|
276
|
+
return;
|
|
277
|
+
if (typeof window === 'undefined')
|
|
278
|
+
return;
|
|
279
|
+
if (!hasInteractedRef.current &&
|
|
280
|
+
!window.matchMedia('(pointer: fine)').matches) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
284
|
+
}, [status]);
|
|
242
285
|
// ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
|
|
243
286
|
(0, react_1.useEffect)(() => {
|
|
244
287
|
let cancelled = false;
|
|
@@ -282,11 +325,26 @@ function ChatWidget(props) {
|
|
|
282
325
|
};
|
|
283
326
|
}, [token, apiBaseUrl, browserSessionId, resumeConversationId, stream]);
|
|
284
327
|
// Auto-scroll on new tokens.
|
|
328
|
+
//
|
|
329
|
+
// We defer the scroll into a requestAnimationFrame so the DOM has
|
|
330
|
+
// actually grown by the time we read `scrollHeight`. Without that
|
|
331
|
+
// tick, a stream of small text_deltas can leave the scroll lagging
|
|
332
|
+
// 1–2 chunks behind because the effect runs synchronously after
|
|
333
|
+
// the React commit but BEFORE the browser paints the new rows.
|
|
334
|
+
// Result: the user sees the latest sentence half-cut at the bottom.
|
|
335
|
+
//
|
|
336
|
+
// `behavior: 'smooth'` doesn't help much during a fast stream (each
|
|
337
|
+
// rAF queues a new smooth-scroll that cancels the previous one) but
|
|
338
|
+
// it does make the FINAL settle look fluid — and that's the part
|
|
339
|
+
// the user notices when the stream stops.
|
|
285
340
|
(0, react_1.useEffect)(() => {
|
|
286
341
|
const el = messagesRef.current;
|
|
287
342
|
if (!el)
|
|
288
343
|
return;
|
|
289
|
-
|
|
344
|
+
const raf = requestAnimationFrame(() => {
|
|
345
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
346
|
+
});
|
|
347
|
+
return () => cancelAnimationFrame(raf);
|
|
290
348
|
}, [messages]);
|
|
291
349
|
// Inject the widget stylesheet exactly once per page. We key the <style>
|
|
292
350
|
// tag by a fixed id so multiple widget mounts share it.
|
|
@@ -307,6 +365,11 @@ function ChatWidget(props) {
|
|
|
307
365
|
const text = draft.trim();
|
|
308
366
|
if (!text)
|
|
309
367
|
return;
|
|
368
|
+
// Mark engagement BEFORE the status flip so the auto-focus
|
|
369
|
+
// effect (which watches `status`) sees the flag when it
|
|
370
|
+
// re-runs after `ready` returns. Subsequent renders will
|
|
371
|
+
// refocus the textarea on every send completion.
|
|
372
|
+
hasInteractedRef.current = true;
|
|
310
373
|
setDraft('');
|
|
311
374
|
void session.send(text);
|
|
312
375
|
}, [session, draft]);
|
|
@@ -349,45 +412,60 @@ function ChatWidget(props) {
|
|
|
349
412
|
};
|
|
350
413
|
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: [!bare && ((0, jsx_runtime_1.jsxs)("div", { className: "af-header", children: [theme?.avatarUrl ? (
|
|
351
414
|
// eslint-disable-next-line @next/next/no-img-element
|
|
352
|
-
(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: personaName ?? 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.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
415
|
+
(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: personaName ?? 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, i) => {
|
|
416
|
+
// Show the avatar only on the FIRST bubble in a run of
|
|
417
|
+
// assistant messages BY THE SAME speaker. A delegation
|
|
418
|
+
// boundary (orchestrator → member, or member → next
|
|
419
|
+
// member) counts as a new run so each speaker gets their
|
|
420
|
+
// avatar surfaced. User/system bubbles never get an avatar.
|
|
421
|
+
const prev = messages[i - 1];
|
|
422
|
+
const isAssistant = m.role === 'assistant';
|
|
423
|
+
const sameAssistantRun = prev?.role === 'assistant' &&
|
|
424
|
+
(prev?.actingAgentId ?? null) === (m.actingAgentId ?? null);
|
|
425
|
+
const showAvatar = bare && isAssistant && !sameAssistantRun;
|
|
426
|
+
// Per-bubble identity resolution. When the chunk carried
|
|
427
|
+
// an `actingAgentId` (orchestrator delegated to a member),
|
|
428
|
+
// we render the member's avatar/name instead of the
|
|
429
|
+
// primary agent's. Falling back to the primary identity
|
|
430
|
+
// keeps non-team sessions and orchestrator-self turns
|
|
431
|
+
// looking the same as before.
|
|
432
|
+
const member = m.actingAgentId
|
|
433
|
+
? membersById.get(m.actingAgentId)
|
|
434
|
+
: undefined;
|
|
435
|
+
const bubbleAvatarTheme = member
|
|
436
|
+
? { ...theme, avatarUrl: member.avatarUrl }
|
|
437
|
+
: theme;
|
|
438
|
+
const bubbleAvatarName = member?.name ?? personaName ?? agent?.name;
|
|
439
|
+
return ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m, session: session, readOnly: readOnlyApprovals, onDecision: onApprovalDecision, bare: bare, showAvatar: showAvatar, avatarTheme: bubbleAvatarTheme, avatarName: bubbleAvatarName, speakerLabel: member?.name, onContinue: () => {
|
|
440
|
+
// After a successful Approve, kick the next turn so the
|
|
441
|
+
// gate's fast-path consumes the approval and the tool
|
|
442
|
+
// actually runs. `silent: true` keeps the literal
|
|
443
|
+
// "continue" prompt out of the visible transcript —
|
|
444
|
+
// the visitor just sees the assistant's next reply
|
|
445
|
+
// appearing under the "Approved" pill.
|
|
446
|
+
if (!session)
|
|
447
|
+
return;
|
|
448
|
+
setTimeout(() => {
|
|
449
|
+
void session.send('continue', { silent: true });
|
|
450
|
+
}, 250);
|
|
451
|
+
} }, m.id));
|
|
452
|
+
}) }), 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: () => {
|
|
375
453
|
if (onShortcutClick)
|
|
376
454
|
onShortcutClick(text, i);
|
|
377
455
|
else
|
|
378
456
|
setDraft(text);
|
|
379
|
-
}, children: text }, `${i}-${text}`))) })), (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: 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" })] })] }));
|
|
457
|
+
}, 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" })] })] }));
|
|
380
458
|
}
|
|
381
|
-
function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, }) {
|
|
459
|
+
function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, speakerLabel, }) {
|
|
382
460
|
const kind = message.metadata?.kind;
|
|
383
461
|
if (kind === 'awaiting_approval') {
|
|
384
462
|
// Approval / blocked bubbles also count as "assistant-side" so we
|
|
385
463
|
// wrap them in the same row geometry — keeps the conversation
|
|
386
464
|
// aligned even when a tool dispatch interrupts the regular flow.
|
|
387
|
-
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }));
|
|
465
|
+
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }), speakerLabel);
|
|
388
466
|
}
|
|
389
467
|
if (kind === 'tool_blocked') {
|
|
390
|
-
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }));
|
|
468
|
+
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }), speakerLabel);
|
|
391
469
|
}
|
|
392
470
|
const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
|
|
393
471
|
? message.content
|
|
@@ -396,13 +474,13 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bar
|
|
|
396
474
|
: ''}`;
|
|
397
475
|
// Typing state (no content yet): render the three-dot indicator, no markdown.
|
|
398
476
|
if (message.role === 'assistant' && message.isStreaming && !message.content) {
|
|
399
|
-
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", {})] }) }));
|
|
477
|
+
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);
|
|
400
478
|
}
|
|
401
479
|
if (message.role === 'assistant') {
|
|
402
480
|
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)("div", { className: cls,
|
|
403
481
|
// Output is sanitized by escapeHtml + a fixed tag whitelist in
|
|
404
482
|
// renderMarkdown — safe to inject as HTML.
|
|
405
|
-
dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }));
|
|
483
|
+
dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }), speakerLabel);
|
|
406
484
|
}
|
|
407
485
|
// User & system messages stay as plain text — they're typed verbatim.
|
|
408
486
|
// No avatar column on the user side; they align right.
|
|
@@ -413,10 +491,14 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bar
|
|
|
413
491
|
* avatar column. Card variant skips the wrapper entirely so its
|
|
414
492
|
* historical layout is unchanged.
|
|
415
493
|
*/
|
|
416
|
-
function wrapAssistantRow(bare, showAvatar, theme, name, child
|
|
494
|
+
function wrapAssistantRow(bare, showAvatar, theme, name, child,
|
|
495
|
+
/** When set + this is the first bubble of a speaker run (showAvatar
|
|
496
|
+
* is true), render the speaker's display name just above the
|
|
497
|
+
* bubble. Used by Team chats so members are visually attributed. */
|
|
498
|
+
speakerLabel) {
|
|
417
499
|
if (!bare)
|
|
418
500
|
return child;
|
|
419
|
-
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 }), child] }));
|
|
501
|
+
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] })] }));
|
|
420
502
|
}
|
|
421
503
|
/**
|
|
422
504
|
* Small circular avatar for the assistant-side column. Prefers the
|
|
@@ -722,13 +804,14 @@ const WIDGET_CSS = `
|
|
|
722
804
|
width: 100%;
|
|
723
805
|
height: 100%;
|
|
724
806
|
min-height: 0;
|
|
807
|
+
flex: 1 1 0%;
|
|
725
808
|
}
|
|
726
809
|
.af-widget-root.af-variant-bare.af-inline .af-panel,
|
|
727
810
|
.af-widget-root.af-variant-bare .af-panel {
|
|
728
811
|
position: relative;
|
|
729
812
|
width: 100%;
|
|
730
|
-
height: 100
|
|
731
|
-
max-height: none;
|
|
813
|
+
height: 100% !important;
|
|
814
|
+
max-height: none !important;
|
|
732
815
|
background: transparent;
|
|
733
816
|
border-radius: 0;
|
|
734
817
|
box-shadow: none;
|
|
@@ -752,6 +835,15 @@ const WIDGET_CSS = `
|
|
|
752
835
|
/* Let the parent flexbox give us a min-height; we'll grow into it
|
|
753
836
|
and scroll internally when the transcript gets longer. */
|
|
754
837
|
min-height: 0;
|
|
838
|
+
/* Anchor messages to the bottom of the scroll-area when the
|
|
839
|
+
transcript is shorter than the available height — short
|
|
840
|
+
conversations sit just above the composer instead of floating
|
|
841
|
+
up to the top of a tall window. The 'margin-top: auto' on the
|
|
842
|
+
first child does the same job in pure flex without needing
|
|
843
|
+
justify-content. */
|
|
844
|
+
}
|
|
845
|
+
.af-widget-root.af-variant-bare .af-messages > :first-child {
|
|
846
|
+
margin-top: auto;
|
|
755
847
|
}
|
|
756
848
|
.af-widget-root.af-variant-bare .af-msg {
|
|
757
849
|
font-size: 15px;
|
|
@@ -859,7 +951,41 @@ const WIDGET_CSS = `
|
|
|
859
951
|
display: none;
|
|
860
952
|
}
|
|
861
953
|
}
|
|
862
|
-
|
|
954
|
+
/* Greeting slot — lives between the message scroller and the
|
|
955
|
+
* shortcut chips. The bubble itself animates in with a small upward
|
|
956
|
+
* slide + fade so the visitor's eye is pulled there instead of the
|
|
957
|
+
* static chip row underneath. Avatar slides in alongside it. The
|
|
958
|
+
* 120ms delay gives the chip row a beat to render first so the
|
|
959
|
+
* greeting reads as the assistant *responding* to the page loading,
|
|
960
|
+
* not part of the static UI. */
|
|
961
|
+
.af-widget-root.af-variant-bare .af-greeting-slot {
|
|
962
|
+
padding: 4px 0 6px;
|
|
963
|
+
}
|
|
964
|
+
.af-widget-root.af-variant-bare .af-msg-row-greeting {
|
|
965
|
+
opacity: 0;
|
|
966
|
+
animation: af-greeting-row-in 420ms cubic-bezier(0.2, 0.8, 0.2, 1) 120ms forwards;
|
|
967
|
+
}
|
|
968
|
+
.af-widget-root.af-variant-bare .af-msg-greeting {
|
|
969
|
+
opacity: 0;
|
|
970
|
+
transform: translateY(6px);
|
|
971
|
+
animation: af-greeting-in 480ms cubic-bezier(0.2, 0.8, 0.2, 1) 180ms forwards;
|
|
972
|
+
}
|
|
973
|
+
@keyframes af-greeting-row-in {
|
|
974
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
975
|
+
to { opacity: 1; transform: translateY(0); }
|
|
976
|
+
}
|
|
977
|
+
@keyframes af-greeting-in {
|
|
978
|
+
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
|
979
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
980
|
+
}
|
|
981
|
+
@media (prefers-reduced-motion: reduce) {
|
|
982
|
+
.af-widget-root.af-variant-bare .af-msg-row-greeting,
|
|
983
|
+
.af-widget-root.af-variant-bare .af-msg-greeting {
|
|
984
|
+
animation: none;
|
|
985
|
+
opacity: 1;
|
|
986
|
+
transform: none;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
863
989
|
|
|
864
990
|
/* Slack-style assistant rows: avatar column on the left, bubble on
|
|
865
991
|
* the right. The row IS the alignment container — align-self targets
|
|
@@ -880,6 +1006,22 @@ const WIDGET_CSS = `
|
|
|
880
1006
|
max-width: 100%;
|
|
881
1007
|
align-self: flex-end;
|
|
882
1008
|
}
|
|
1009
|
+
/* Column wrapper around the bubble so a Team speaker label can sit
|
|
1010
|
+
* above the bubble without disrupting the avatar's bottom-alignment. */
|
|
1011
|
+
.af-widget-root.af-variant-bare .af-msg-col {
|
|
1012
|
+
display: flex;
|
|
1013
|
+
flex-direction: column;
|
|
1014
|
+
min-width: 0;
|
|
1015
|
+
flex: 1;
|
|
1016
|
+
gap: 2px;
|
|
1017
|
+
}
|
|
1018
|
+
.af-widget-root.af-variant-bare .af-msg-speaker {
|
|
1019
|
+
font-size: 11px;
|
|
1020
|
+
font-weight: 600;
|
|
1021
|
+
color: var(--af-muted, #64748b);
|
|
1022
|
+
padding-left: 2px;
|
|
1023
|
+
letter-spacing: 0.01em;
|
|
1024
|
+
}
|
|
883
1025
|
.af-widget-root.af-variant-bare .af-msg-avatar {
|
|
884
1026
|
width: 26px;
|
|
885
1027
|
height: 26px;
|
package/dist/session.js
CHANGED
|
@@ -190,11 +190,23 @@ class ChatSession {
|
|
|
190
190
|
// ─── Internals ──────────────────────────────────────────────────────────
|
|
191
191
|
async runStream(text, assistant) {
|
|
192
192
|
this.setStatus('sending');
|
|
193
|
+
// The "active" message is the one we're currently appending
|
|
194
|
+
// text_deltas into. For non-team sessions it's always the initial
|
|
195
|
+
// `assistant` message we seeded before calling runStream. For team
|
|
196
|
+
// sessions, every `delegation_start` opens a NEW message (the
|
|
197
|
+
// member's bubble) and `delegation_result` closes it back to the
|
|
198
|
+
// orchestrator's bubble. Hoisted out of the try so the catch can
|
|
199
|
+
// reach it during cleanup.
|
|
200
|
+
let active = assistant;
|
|
201
|
+
let activeFull = ''; // running text of the active message only
|
|
202
|
+
// Aggregate full transcript across every speaker so the empty-
|
|
203
|
+
// response guard at the end still works when an orchestrator turn
|
|
204
|
+
// delegates and the member's text is the only content.
|
|
205
|
+
let totalFull = '';
|
|
193
206
|
try {
|
|
194
207
|
const generator = this.state.conversationId
|
|
195
208
|
? this.transport.streamSendMessage(this.state.conversationId, text, this.browserSessionId)
|
|
196
209
|
: this.transport.streamCreateConversation(text, this.browserSessionId);
|
|
197
|
-
let full = '';
|
|
198
210
|
let sawAnyChunk = false;
|
|
199
211
|
for await (const evt of generator) {
|
|
200
212
|
if (evt.kind === 'conversation') {
|
|
@@ -211,6 +223,61 @@ class ChatSession {
|
|
|
211
223
|
// legacy widget (no metadata renderer) still shows something
|
|
212
224
|
// readable.
|
|
213
225
|
const chunkType = evt.chunk.type;
|
|
226
|
+
// Team orchestrator → member handoff. Open a new assistant
|
|
227
|
+
// message tagged with the member's id; subsequent text_deltas
|
|
228
|
+
// (which the server tags with `actingAgentId` matching the
|
|
229
|
+
// delegation) flow into THIS bubble until delegation_result
|
|
230
|
+
// closes the handoff and we revert to the orchestrator.
|
|
231
|
+
if (chunkType === 'delegation_start') {
|
|
232
|
+
const c = evt.chunk;
|
|
233
|
+
// Close the orchestrator's wrapper (if any text accumulated).
|
|
234
|
+
if (activeFull) {
|
|
235
|
+
active.isStreaming = false;
|
|
236
|
+
this.updateMessage(active);
|
|
237
|
+
}
|
|
238
|
+
// Open the member's bubble.
|
|
239
|
+
const memberMsg = {
|
|
240
|
+
id: makeMessageId('a'),
|
|
241
|
+
role: 'assistant',
|
|
242
|
+
content: '',
|
|
243
|
+
createdAt: new Date(),
|
|
244
|
+
isStreaming: true,
|
|
245
|
+
actingAgentId: c.subAgentId,
|
|
246
|
+
};
|
|
247
|
+
this.appendMessage(memberMsg);
|
|
248
|
+
active = memberMsg;
|
|
249
|
+
activeFull = '';
|
|
250
|
+
// UX hint: surface the routing decision so view layers can
|
|
251
|
+
// render "Routing to <member>…" without parsing the stream.
|
|
252
|
+
this.emit({
|
|
253
|
+
type: 'delegation_routed',
|
|
254
|
+
memberId: c.subAgentId,
|
|
255
|
+
memberName: c.subAgentName,
|
|
256
|
+
memberAvatarUrl: c.subAgentAvatarUrl,
|
|
257
|
+
});
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (chunkType === 'delegation_result') {
|
|
261
|
+
// Close the member's bubble and re-open an orchestrator
|
|
262
|
+
// bubble in case the orchestrator wants to add a closing
|
|
263
|
+
// line ("Anything else?"). If no orchestrator text follows
|
|
264
|
+
// we drop the empty wrapper at end-of-stream.
|
|
265
|
+
if (activeFull) {
|
|
266
|
+
active.isStreaming = false;
|
|
267
|
+
this.updateMessage(active);
|
|
268
|
+
}
|
|
269
|
+
const wrapMsg = {
|
|
270
|
+
id: makeMessageId('a'),
|
|
271
|
+
role: 'assistant',
|
|
272
|
+
content: '',
|
|
273
|
+
createdAt: new Date(),
|
|
274
|
+
isStreaming: true,
|
|
275
|
+
};
|
|
276
|
+
this.appendMessage(wrapMsg);
|
|
277
|
+
active = wrapMsg;
|
|
278
|
+
activeFull = '';
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
214
281
|
if (chunkType === 'awaiting_approval') {
|
|
215
282
|
const c = evt.chunk;
|
|
216
283
|
assistant.metadata = {
|
|
@@ -225,7 +292,8 @@ class ChatSession {
|
|
|
225
292
|
// Plain-text fallback for legacy clients that don't render
|
|
226
293
|
// metadata.kind. Capable widgets ignore `content` and read
|
|
227
294
|
// `metadata.copy` directly.
|
|
228
|
-
assistant.content =
|
|
295
|
+
assistant.content =
|
|
296
|
+
totalFull || c.copy?.body || (c.connectorName ?? '');
|
|
229
297
|
this.updateMessage(assistant);
|
|
230
298
|
continue;
|
|
231
299
|
}
|
|
@@ -240,7 +308,10 @@ class ChatSession {
|
|
|
240
308
|
copy: c.copy,
|
|
241
309
|
};
|
|
242
310
|
assistant.content =
|
|
243
|
-
|
|
311
|
+
totalFull ||
|
|
312
|
+
c.copy?.blockedBody ||
|
|
313
|
+
c.reason ||
|
|
314
|
+
(c.connectorName ?? '');
|
|
244
315
|
this.updateMessage(assistant);
|
|
245
316
|
continue;
|
|
246
317
|
}
|
|
@@ -249,9 +320,10 @@ class ChatSession {
|
|
|
249
320
|
continue;
|
|
250
321
|
if (this.state.status !== 'streaming')
|
|
251
322
|
this.setStatus('streaming');
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
323
|
+
activeFull += delta;
|
|
324
|
+
totalFull += delta;
|
|
325
|
+
active.content = activeFull;
|
|
326
|
+
this.updateMessage(active);
|
|
255
327
|
continue;
|
|
256
328
|
}
|
|
257
329
|
if (evt.kind === 'error') {
|
|
@@ -270,26 +342,47 @@ class ChatSession {
|
|
|
270
342
|
// chunk, the bubble carries that structured payload and is a
|
|
271
343
|
// legitimate terminal state — not a missing reply. Skip the
|
|
272
344
|
// "empty response" guard for those.
|
|
273
|
-
if (!
|
|
345
|
+
if (!totalFull && !assistant.metadata) {
|
|
274
346
|
throw new Error(sawAnyChunk
|
|
275
347
|
? 'The model returned an empty response.'
|
|
276
348
|
: 'No response received from the server.');
|
|
277
349
|
}
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
catch (err) {
|
|
284
|
-
// Drop the half-built assistant message — leaving an empty bubble
|
|
285
|
-
// around is worse than no bubble at all.
|
|
286
|
-
if (!assistant.content) {
|
|
287
|
-
this.removeMessage(assistant.id);
|
|
350
|
+
// If we opened an orchestrator wrapper after the last delegation
|
|
351
|
+
// and the orchestrator didn't add a closing line, drop the empty
|
|
352
|
+
// wrapper instead of leaving a ghost bubble.
|
|
353
|
+
if (active !== assistant && !activeFull) {
|
|
354
|
+
this.removeMessage(active.id);
|
|
288
355
|
}
|
|
289
356
|
else {
|
|
290
|
-
|
|
291
|
-
this.updateMessage(
|
|
357
|
+
active.isStreaming = false;
|
|
358
|
+
this.updateMessage(active);
|
|
292
359
|
}
|
|
360
|
+
// The original `assistant` slot can also be empty if the first
|
|
361
|
+
// chunk we saw was a delegation_start (orchestrator chose to
|
|
362
|
+
// delegate immediately without prefacing). Drop it in that case.
|
|
363
|
+
if (assistant !== active && !assistant.content) {
|
|
364
|
+
this.removeMessage(assistant.id);
|
|
365
|
+
}
|
|
366
|
+
this.setStatus('ready');
|
|
367
|
+
return totalFull;
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
// Drop the half-built bubble — leaving empty bubbles around is
|
|
371
|
+
// worse than no bubble at all. We may have multiple in flight
|
|
372
|
+
// (orchestrator wrapper + member) when delegating; clean each
|
|
373
|
+
// independently.
|
|
374
|
+
const cleanup = (m) => {
|
|
375
|
+
if (!m.content) {
|
|
376
|
+
this.removeMessage(m.id);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
m.isStreaming = false;
|
|
380
|
+
this.updateMessage(m);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
cleanup(assistant);
|
|
384
|
+
if (active !== assistant)
|
|
385
|
+
cleanup(active);
|
|
293
386
|
this.handleError(err);
|
|
294
387
|
return assistant.content;
|
|
295
388
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentforge-io/chat-sdk",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
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",
|