@agentforge-io/chat-sdk 2.0.25 → 2.1.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 +46 -0
- package/dist/react.d.ts +11 -1
- package/dist/react.js +138 -35
- 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,21 @@ 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
|
+
// Auto-focus the composer on desktop (pointer:fine) once the session
|
|
254
|
+
// is ready. Skipped on touch devices — focusing a textarea on iOS /
|
|
255
|
+
// Android opens the on-screen keyboard, which is hostile on a public
|
|
256
|
+
// profile page where the visitor probably wants to read first.
|
|
257
|
+
(0, react_1.useEffect)(() => {
|
|
258
|
+
if (status !== 'ready')
|
|
259
|
+
return;
|
|
260
|
+
if (typeof window === 'undefined')
|
|
261
|
+
return;
|
|
262
|
+
if (!window.matchMedia('(pointer: fine)').matches)
|
|
263
|
+
return;
|
|
264
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
265
|
+
}, [status]);
|
|
242
266
|
// ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
|
|
243
267
|
(0, react_1.useEffect)(() => {
|
|
244
268
|
let cancelled = false;
|
|
@@ -349,45 +373,60 @@ function ChatWidget(props) {
|
|
|
349
373
|
};
|
|
350
374
|
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
375
|
// 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
|
-
|
|
376
|
+
(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) => {
|
|
377
|
+
// Show the avatar only on the FIRST bubble in a run of
|
|
378
|
+
// assistant messages BY THE SAME speaker. A delegation
|
|
379
|
+
// boundary (orchestrator → member, or member → next
|
|
380
|
+
// member) counts as a new run so each speaker gets their
|
|
381
|
+
// avatar surfaced. User/system bubbles never get an avatar.
|
|
382
|
+
const prev = messages[i - 1];
|
|
383
|
+
const isAssistant = m.role === 'assistant';
|
|
384
|
+
const sameAssistantRun = prev?.role === 'assistant' &&
|
|
385
|
+
(prev?.actingAgentId ?? null) === (m.actingAgentId ?? null);
|
|
386
|
+
const showAvatar = bare && isAssistant && !sameAssistantRun;
|
|
387
|
+
// Per-bubble identity resolution. When the chunk carried
|
|
388
|
+
// an `actingAgentId` (orchestrator delegated to a member),
|
|
389
|
+
// we render the member's avatar/name instead of the
|
|
390
|
+
// primary agent's. Falling back to the primary identity
|
|
391
|
+
// keeps non-team sessions and orchestrator-self turns
|
|
392
|
+
// looking the same as before.
|
|
393
|
+
const member = m.actingAgentId
|
|
394
|
+
? membersById.get(m.actingAgentId)
|
|
395
|
+
: undefined;
|
|
396
|
+
const bubbleAvatarTheme = member
|
|
397
|
+
? { ...theme, avatarUrl: member.avatarUrl }
|
|
398
|
+
: theme;
|
|
399
|
+
const bubbleAvatarName = member?.name ?? personaName ?? agent?.name;
|
|
400
|
+
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: () => {
|
|
401
|
+
// After a successful Approve, kick the next turn so the
|
|
402
|
+
// gate's fast-path consumes the approval and the tool
|
|
403
|
+
// actually runs. `silent: true` keeps the literal
|
|
404
|
+
// "continue" prompt out of the visible transcript —
|
|
405
|
+
// the visitor just sees the assistant's next reply
|
|
406
|
+
// appearing under the "Approved" pill.
|
|
407
|
+
if (!session)
|
|
408
|
+
return;
|
|
409
|
+
setTimeout(() => {
|
|
410
|
+
void session.send('continue', { silent: true });
|
|
411
|
+
}, 250);
|
|
412
|
+
} }, 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: () => {
|
|
375
414
|
if (onShortcutClick)
|
|
376
415
|
onShortcutClick(text, i);
|
|
377
416
|
else
|
|
378
417
|
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" })] })] }));
|
|
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" })] })] }));
|
|
380
419
|
}
|
|
381
|
-
function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, }) {
|
|
420
|
+
function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, speakerLabel, }) {
|
|
382
421
|
const kind = message.metadata?.kind;
|
|
383
422
|
if (kind === 'awaiting_approval') {
|
|
384
423
|
// Approval / blocked bubbles also count as "assistant-side" so we
|
|
385
424
|
// wrap them in the same row geometry — keeps the conversation
|
|
386
425
|
// 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 }));
|
|
426
|
+
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(ApprovalBubble, { message: message, session: session, readOnly: readOnly, onDecision: onDecision, onContinue: onContinue }), speakerLabel);
|
|
388
427
|
}
|
|
389
428
|
if (kind === 'tool_blocked') {
|
|
390
|
-
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }));
|
|
429
|
+
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }), speakerLabel);
|
|
391
430
|
}
|
|
392
431
|
const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
|
|
393
432
|
? message.content
|
|
@@ -396,13 +435,13 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bar
|
|
|
396
435
|
: ''}`;
|
|
397
436
|
// Typing state (no content yet): render the three-dot indicator, no markdown.
|
|
398
437
|
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", {})] }) }));
|
|
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);
|
|
400
439
|
}
|
|
401
440
|
if (message.role === 'assistant') {
|
|
402
441
|
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)("div", { className: cls,
|
|
403
442
|
// Output is sanitized by escapeHtml + a fixed tag whitelist in
|
|
404
443
|
// renderMarkdown — safe to inject as HTML.
|
|
405
|
-
dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }));
|
|
444
|
+
dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }), speakerLabel);
|
|
406
445
|
}
|
|
407
446
|
// User & system messages stay as plain text — they're typed verbatim.
|
|
408
447
|
// No avatar column on the user side; they align right.
|
|
@@ -413,10 +452,14 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, bar
|
|
|
413
452
|
* avatar column. Card variant skips the wrapper entirely so its
|
|
414
453
|
* historical layout is unchanged.
|
|
415
454
|
*/
|
|
416
|
-
function wrapAssistantRow(bare, showAvatar, theme, name, child
|
|
455
|
+
function wrapAssistantRow(bare, showAvatar, theme, name, child,
|
|
456
|
+
/** When set + this is the first bubble of a speaker run (showAvatar
|
|
457
|
+
* is true), render the speaker's display name just above the
|
|
458
|
+
* bubble. Used by Team chats so members are visually attributed. */
|
|
459
|
+
speakerLabel) {
|
|
417
460
|
if (!bare)
|
|
418
461
|
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] }));
|
|
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] })] }));
|
|
420
463
|
}
|
|
421
464
|
/**
|
|
422
465
|
* Small circular avatar for the assistant-side column. Prefers the
|
|
@@ -722,13 +765,14 @@ const WIDGET_CSS = `
|
|
|
722
765
|
width: 100%;
|
|
723
766
|
height: 100%;
|
|
724
767
|
min-height: 0;
|
|
768
|
+
flex: 1 1 0%;
|
|
725
769
|
}
|
|
726
770
|
.af-widget-root.af-variant-bare.af-inline .af-panel,
|
|
727
771
|
.af-widget-root.af-variant-bare .af-panel {
|
|
728
772
|
position: relative;
|
|
729
773
|
width: 100%;
|
|
730
|
-
height: 100
|
|
731
|
-
max-height: none;
|
|
774
|
+
height: 100% !important;
|
|
775
|
+
max-height: none !important;
|
|
732
776
|
background: transparent;
|
|
733
777
|
border-radius: 0;
|
|
734
778
|
box-shadow: none;
|
|
@@ -752,6 +796,15 @@ const WIDGET_CSS = `
|
|
|
752
796
|
/* Let the parent flexbox give us a min-height; we'll grow into it
|
|
753
797
|
and scroll internally when the transcript gets longer. */
|
|
754
798
|
min-height: 0;
|
|
799
|
+
/* Anchor messages to the bottom of the scroll-area when the
|
|
800
|
+
transcript is shorter than the available height — short
|
|
801
|
+
conversations sit just above the composer instead of floating
|
|
802
|
+
up to the top of a tall window. The 'margin-top: auto' on the
|
|
803
|
+
first child does the same job in pure flex without needing
|
|
804
|
+
justify-content. */
|
|
805
|
+
}
|
|
806
|
+
.af-widget-root.af-variant-bare .af-messages > :first-child {
|
|
807
|
+
margin-top: auto;
|
|
755
808
|
}
|
|
756
809
|
.af-widget-root.af-variant-bare .af-msg {
|
|
757
810
|
font-size: 15px;
|
|
@@ -859,7 +912,41 @@ const WIDGET_CSS = `
|
|
|
859
912
|
display: none;
|
|
860
913
|
}
|
|
861
914
|
}
|
|
862
|
-
|
|
915
|
+
/* Greeting slot — lives between the message scroller and the
|
|
916
|
+
* shortcut chips. The bubble itself animates in with a small upward
|
|
917
|
+
* slide + fade so the visitor's eye is pulled there instead of the
|
|
918
|
+
* static chip row underneath. Avatar slides in alongside it. The
|
|
919
|
+
* 120ms delay gives the chip row a beat to render first so the
|
|
920
|
+
* greeting reads as the assistant *responding* to the page loading,
|
|
921
|
+
* not part of the static UI. */
|
|
922
|
+
.af-widget-root.af-variant-bare .af-greeting-slot {
|
|
923
|
+
padding: 4px 0 6px;
|
|
924
|
+
}
|
|
925
|
+
.af-widget-root.af-variant-bare .af-msg-row-greeting {
|
|
926
|
+
opacity: 0;
|
|
927
|
+
animation: af-greeting-row-in 420ms cubic-bezier(0.2, 0.8, 0.2, 1) 120ms forwards;
|
|
928
|
+
}
|
|
929
|
+
.af-widget-root.af-variant-bare .af-msg-greeting {
|
|
930
|
+
opacity: 0;
|
|
931
|
+
transform: translateY(6px);
|
|
932
|
+
animation: af-greeting-in 480ms cubic-bezier(0.2, 0.8, 0.2, 1) 180ms forwards;
|
|
933
|
+
}
|
|
934
|
+
@keyframes af-greeting-row-in {
|
|
935
|
+
from { opacity: 0; transform: translateY(8px); }
|
|
936
|
+
to { opacity: 1; transform: translateY(0); }
|
|
937
|
+
}
|
|
938
|
+
@keyframes af-greeting-in {
|
|
939
|
+
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
|
940
|
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
|
941
|
+
}
|
|
942
|
+
@media (prefers-reduced-motion: reduce) {
|
|
943
|
+
.af-widget-root.af-variant-bare .af-msg-row-greeting,
|
|
944
|
+
.af-widget-root.af-variant-bare .af-msg-greeting {
|
|
945
|
+
animation: none;
|
|
946
|
+
opacity: 1;
|
|
947
|
+
transform: none;
|
|
948
|
+
}
|
|
949
|
+
}
|
|
863
950
|
|
|
864
951
|
/* Slack-style assistant rows: avatar column on the left, bubble on
|
|
865
952
|
* the right. The row IS the alignment container — align-self targets
|
|
@@ -880,6 +967,22 @@ const WIDGET_CSS = `
|
|
|
880
967
|
max-width: 100%;
|
|
881
968
|
align-self: flex-end;
|
|
882
969
|
}
|
|
970
|
+
/* Column wrapper around the bubble so a Team speaker label can sit
|
|
971
|
+
* above the bubble without disrupting the avatar's bottom-alignment. */
|
|
972
|
+
.af-widget-root.af-variant-bare .af-msg-col {
|
|
973
|
+
display: flex;
|
|
974
|
+
flex-direction: column;
|
|
975
|
+
min-width: 0;
|
|
976
|
+
flex: 1;
|
|
977
|
+
gap: 2px;
|
|
978
|
+
}
|
|
979
|
+
.af-widget-root.af-variant-bare .af-msg-speaker {
|
|
980
|
+
font-size: 11px;
|
|
981
|
+
font-weight: 600;
|
|
982
|
+
color: var(--af-muted, #64748b);
|
|
983
|
+
padding-left: 2px;
|
|
984
|
+
letter-spacing: 0.01em;
|
|
985
|
+
}
|
|
883
986
|
.af-widget-root.af-variant-bare .af-msg-avatar {
|
|
884
987
|
width: 26px;
|
|
885
988
|
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.0
|
|
3
|
+
"version": "2.1.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",
|