@agentforge-io/chat-sdk 2.0.24 → 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 +44 -1
- package/dist/react.js +479 -34
- 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
|
|
@@ -127,6 +127,49 @@ export interface ChatWidgetProps {
|
|
|
127
127
|
/** Input placeholder. Defaults to "Type a message…" — override when
|
|
128
128
|
* the persona speaks a different language. */
|
|
129
129
|
inputPlaceholder?: string;
|
|
130
|
+
/**
|
|
131
|
+
* One-click conversation starters rendered as chips ABOVE the input
|
|
132
|
+
* (and only while the conversation is empty — they fade away after
|
|
133
|
+
* the first turn). Click pre-fills the input with the chip text so
|
|
134
|
+
* the visitor can review / edit before sending. Use this for
|
|
135
|
+
* persona-specific openings ("Schedule a call", "Get a quote") on
|
|
136
|
+
* the same surface that owns the chat. */
|
|
137
|
+
shortcuts?: string[];
|
|
138
|
+
/**
|
|
139
|
+
* Called when the visitor clicks a shortcut chip. When omitted the
|
|
140
|
+
* default behaviour is to populate the input with the chip text.
|
|
141
|
+
* Provide this hook to customise — e.g. send immediately, log the
|
|
142
|
+
* click, or open an external link instead. */
|
|
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[];
|
|
154
|
+
/**
|
|
155
|
+
* Imperative handle. The widget fills this ref on mount with a
|
|
156
|
+
* small command object the host can call to drive the session
|
|
157
|
+
* without owning it. Today exposes `sendNow(text)` — sends the
|
|
158
|
+
* given text as a user turn, bypassing the textarea/draft state.
|
|
159
|
+
* Use this for chips, suggested replies, programmatic kicks, etc.
|
|
160
|
+
*
|
|
161
|
+
* `null` while the session is still booting. Becomes available
|
|
162
|
+
* once `agent_loaded` has fired internally.
|
|
163
|
+
*/
|
|
164
|
+
handleRef?: React.MutableRefObject<ChatWidgetHandle | null>;
|
|
165
|
+
}
|
|
166
|
+
/** Imperative surface exposed via ChatWidgetProps.handleRef. Narrow
|
|
167
|
+
* on purpose — hosts get a verb, not the whole session. */
|
|
168
|
+
export interface ChatWidgetHandle {
|
|
169
|
+
/** Send `text` immediately as a user turn. Skips the textarea
|
|
170
|
+
* entirely. No-op if the session isn't ready yet (status ===
|
|
171
|
+
* 'idle' / 'loading'). */
|
|
172
|
+
sendNow(text: string): void;
|
|
130
173
|
}
|
|
131
174
|
/**
|
|
132
175
|
* Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
|
package/dist/react.js
CHANGED
|
@@ -133,6 +133,47 @@ function renderMarkdown(src) {
|
|
|
133
133
|
out.push(`<blockquote>${renderInline(qbuf.join(' '))}</blockquote>`);
|
|
134
134
|
continue;
|
|
135
135
|
}
|
|
136
|
+
// GFM-style table: a row of `| col | col |`, then a separator row
|
|
137
|
+
// `|---|---|` (any number of dashes / optional :), then body rows.
|
|
138
|
+
// Detection looks two lines ahead so a stray "| pipe |" sentence
|
|
139
|
+
// in prose doesn't get promoted to a table.
|
|
140
|
+
if (/^\s*\|.*\|\s*$/.test(line) &&
|
|
141
|
+
i + 1 < lines.length &&
|
|
142
|
+
/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[i + 1])) {
|
|
143
|
+
const splitRow = (raw) => {
|
|
144
|
+
// Trim outer pipes then split, preserving empty cells. GFM
|
|
145
|
+
// tolerates escaped pipes (\|) inside cells — we don't, the
|
|
146
|
+
// server-side markdown the agent emits never includes them.
|
|
147
|
+
const trimmed = raw.trim().replace(/^\|/, '').replace(/\|$/, '');
|
|
148
|
+
return trimmed.split('|').map((c) => c.trim());
|
|
149
|
+
};
|
|
150
|
+
const header = splitRow(line);
|
|
151
|
+
i += 2; // skip header + separator
|
|
152
|
+
const bodyRows = [];
|
|
153
|
+
while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i])) {
|
|
154
|
+
bodyRows.push(splitRow(lines[i]));
|
|
155
|
+
i++;
|
|
156
|
+
}
|
|
157
|
+
// We stamp each <td> with a `data-label` attribute so the
|
|
158
|
+
// mobile stylesheet can show a label before the cell value
|
|
159
|
+
// when the table collapses to a stacked layout. Cheap on the
|
|
160
|
+
// desktop side (the CSS just hides the label) and gives mobile
|
|
161
|
+
// a real card-per-row read instead of a horizontal scroll.
|
|
162
|
+
const thead = '<thead><tr>' +
|
|
163
|
+
header.map((c) => `<th>${renderInline(c)}</th>`).join('') +
|
|
164
|
+
'</tr></thead>';
|
|
165
|
+
const tbody = '<tbody>' +
|
|
166
|
+
bodyRows
|
|
167
|
+
.map((r) => '<tr>' +
|
|
168
|
+
r
|
|
169
|
+
.map((c, idx) => `<td data-label="${escapeHtml(header[idx] ?? '')}">${renderInline(c)}</td>`)
|
|
170
|
+
.join('') +
|
|
171
|
+
'</tr>')
|
|
172
|
+
.join('') +
|
|
173
|
+
'</tbody>';
|
|
174
|
+
out.push(`<table>${thead}${tbody}</table>`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
136
177
|
if (/^\s*$/.test(line)) {
|
|
137
178
|
i++;
|
|
138
179
|
continue;
|
|
@@ -145,7 +186,12 @@ function renderMarkdown(src) {
|
|
|
145
186
|
!/^\s*[-*]\s+/.test(lines[i]) &&
|
|
146
187
|
!/^\s*\d+\.\s+/.test(lines[i]) &&
|
|
147
188
|
!/^\s*>\s?/.test(lines[i]) &&
|
|
148
|
-
!/^\s*(---+|\*\*\*+|___+)\s*$/.test(lines[i])
|
|
189
|
+
!/^\s*(---+|\*\*\*+|___+)\s*$/.test(lines[i]) &&
|
|
190
|
+
// Stop on a likely table start (header + separator next line) so
|
|
191
|
+
// the paragraph collector doesn't swallow the table rows.
|
|
192
|
+
!(/^\s*\|.*\|\s*$/.test(lines[i]) &&
|
|
193
|
+
i + 1 < lines.length &&
|
|
194
|
+
/^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[i + 1]))) {
|
|
149
195
|
pbuf.push(lines[i]);
|
|
150
196
|
i++;
|
|
151
197
|
}
|
|
@@ -181,7 +227,17 @@ function fallbackCopy(ctx) {
|
|
|
181
227
|
* SDK event. Consumers don't need to read `session.getState()` themselves.
|
|
182
228
|
*/
|
|
183
229
|
function ChatWidget(props) {
|
|
184
|
-
const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, onApprovalDecision, readOnlyApprovals = false, variant = 'card', greeting, personaName, 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]);
|
|
185
241
|
const bare = variant === 'bare';
|
|
186
242
|
const [session, setSession] = (0, react_1.useState)(null);
|
|
187
243
|
const [status, setStatus] = (0, react_1.useState)('idle');
|
|
@@ -192,7 +248,21 @@ function ChatWidget(props) {
|
|
|
192
248
|
const [open, setOpen] = (0, react_1.useState)(inline);
|
|
193
249
|
const [draft, setDraft] = (0, react_1.useState)('');
|
|
194
250
|
const messagesRef = (0, react_1.useRef)(null);
|
|
251
|
+
const inputRef = (0, react_1.useRef)(null);
|
|
195
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]);
|
|
196
266
|
// ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
|
|
197
267
|
(0, react_1.useEffect)(() => {
|
|
198
268
|
let cancelled = false;
|
|
@@ -303,7 +373,31 @@ function ChatWidget(props) {
|
|
|
303
373
|
};
|
|
304
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 ? (
|
|
305
375
|
// eslint-disable-next-line @next/next/no-img-element
|
|
306
|
-
(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.
|
|
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: () => {
|
|
307
401
|
// After a successful Approve, kick the next turn so the
|
|
308
402
|
// gate's fast-path consumes the approval and the tool
|
|
309
403
|
// actually runs. `silent: true` keeps the literal
|
|
@@ -315,15 +409,24 @@ function ChatWidget(props) {
|
|
|
315
409
|
setTimeout(() => {
|
|
316
410
|
void session.send('continue', { silent: true });
|
|
317
411
|
}, 250);
|
|
318
|
-
} }, m.id))
|
|
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: () => {
|
|
414
|
+
if (onShortcutClick)
|
|
415
|
+
onShortcutClick(text, i);
|
|
416
|
+
else
|
|
417
|
+
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" })] })] }));
|
|
319
419
|
}
|
|
320
|
-
function MessageBubble({ message, session, readOnly, onDecision, onContinue, }) {
|
|
420
|
+
function MessageBubble({ message, session, readOnly, onDecision, onContinue, bare = false, showAvatar = false, avatarTheme, avatarName, speakerLabel, }) {
|
|
321
421
|
const kind = message.metadata?.kind;
|
|
322
422
|
if (kind === 'awaiting_approval') {
|
|
323
|
-
|
|
423
|
+
// Approval / blocked bubbles also count as "assistant-side" so we
|
|
424
|
+
// wrap them in the same row geometry — keeps the conversation
|
|
425
|
+
// 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);
|
|
324
427
|
}
|
|
325
428
|
if (kind === 'tool_blocked') {
|
|
326
|
-
return (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message });
|
|
429
|
+
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)(BlockedBubble, { message: message }), speakerLabel);
|
|
327
430
|
}
|
|
328
431
|
const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
|
|
329
432
|
? message.content
|
|
@@ -332,17 +435,50 @@ function MessageBubble({ message, session, readOnly, onDecision, onContinue, })
|
|
|
332
435
|
: ''}`;
|
|
333
436
|
// Typing state (no content yet): render the three-dot indicator, no markdown.
|
|
334
437
|
if (message.role === 'assistant' && message.isStreaming && !message.content) {
|
|
335
|
-
return ((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);
|
|
336
439
|
}
|
|
337
440
|
if (message.role === 'assistant') {
|
|
338
|
-
return ((0, jsx_runtime_1.jsx)("div", { className: cls,
|
|
441
|
+
return wrapAssistantRow(bare, showAvatar, avatarTheme, avatarName, (0, jsx_runtime_1.jsx)("div", { className: cls,
|
|
339
442
|
// Output is sanitized by escapeHtml + a fixed tag whitelist in
|
|
340
443
|
// renderMarkdown — safe to inject as HTML.
|
|
341
|
-
dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }));
|
|
444
|
+
dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }), speakerLabel);
|
|
342
445
|
}
|
|
343
446
|
// User & system messages stay as plain text — they're typed verbatim.
|
|
447
|
+
// No avatar column on the user side; they align right.
|
|
344
448
|
return (0, jsx_runtime_1.jsx)("div", { className: cls, children: message.content });
|
|
345
449
|
}
|
|
450
|
+
/**
|
|
451
|
+
* Wrap an assistant-side bubble in the row geometry that reserves an
|
|
452
|
+
* avatar column. Card variant skips the wrapper entirely so its
|
|
453
|
+
* historical layout is unchanged.
|
|
454
|
+
*/
|
|
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) {
|
|
460
|
+
if (!bare)
|
|
461
|
+
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] })] }));
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Small circular avatar for the assistant-side column. Prefers the
|
|
466
|
+
* agent's avatarUrl when set; otherwise renders a gradient circle
|
|
467
|
+
* with the first letter of `name`. The slot reserves space even when
|
|
468
|
+
* `show` is false so consecutive bubbles stay column-aligned.
|
|
469
|
+
*/
|
|
470
|
+
function AssistantAvatar({ theme, name, show, }) {
|
|
471
|
+
const initial = (name ?? 'A').trim().charAt(0).toUpperCase() || 'A';
|
|
472
|
+
if (!show) {
|
|
473
|
+
return (0, jsx_runtime_1.jsx)("div", { className: "af-msg-avatar af-msg-avatar-spacer", "aria-hidden": true });
|
|
474
|
+
}
|
|
475
|
+
if (theme?.avatarUrl) {
|
|
476
|
+
return (
|
|
477
|
+
// eslint-disable-next-line @next/next/no-img-element -- vanilla widget; consumer can override host
|
|
478
|
+
(0, jsx_runtime_1.jsx)("img", { className: "af-msg-avatar af-msg-avatar-img", src: theme.avatarUrl, alt: "", "aria-hidden": true }));
|
|
479
|
+
}
|
|
480
|
+
return ((0, jsx_runtime_1.jsx)("div", { className: "af-msg-avatar af-msg-avatar-fallback", "aria-hidden": true, children: (0, jsx_runtime_1.jsx)("span", { children: initial }) }));
|
|
481
|
+
}
|
|
346
482
|
/**
|
|
347
483
|
* Awaiting-approval bubble. Renders the tool name + a countdown, and
|
|
348
484
|
* either the Approve/Deny buttons (when the host wired the handler)
|
|
@@ -517,6 +653,52 @@ const WIDGET_CSS = `
|
|
|
517
653
|
to { opacity: 1; transform: translateY(0); }
|
|
518
654
|
}
|
|
519
655
|
.af-input-row { padding: 12px; border-top: 1px solid var(--af-border); background: var(--af-bg); display: flex; gap: 8px; align-items: flex-end; }
|
|
656
|
+
.af-shortcut-row {
|
|
657
|
+
display: flex;
|
|
658
|
+
gap: 6px;
|
|
659
|
+
padding: 10px 12px 6px 12px;
|
|
660
|
+
overflow-x: auto;
|
|
661
|
+
scrollbar-width: none;
|
|
662
|
+
background: var(--af-bg);
|
|
663
|
+
animation: af-msg-in 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
|
664
|
+
}
|
|
665
|
+
.af-shortcut-row::-webkit-scrollbar { display: none; }
|
|
666
|
+
/* When the shortcut row sits directly above the composer, drop the
|
|
667
|
+
composer's top padding + border so the two read as a single
|
|
668
|
+
stacked block. Without this the 12px input-row padding + 1px
|
|
669
|
+
border-top create a visible gutter between the chips and the
|
|
670
|
+
textarea. */
|
|
671
|
+
.af-shortcut-row + .af-input-row {
|
|
672
|
+
padding-top: 4px;
|
|
673
|
+
border-top: 0;
|
|
674
|
+
}
|
|
675
|
+
.af-shortcut {
|
|
676
|
+
flex-shrink: 0;
|
|
677
|
+
display: inline-flex;
|
|
678
|
+
align-items: center;
|
|
679
|
+
gap: 6px;
|
|
680
|
+
padding: 6px 12px;
|
|
681
|
+
font-size: 12.5px;
|
|
682
|
+
font-weight: 500;
|
|
683
|
+
font-family: inherit;
|
|
684
|
+
line-height: 1.2;
|
|
685
|
+
color: var(--af-fg);
|
|
686
|
+
background: var(--af-input-bg, rgba(148, 163, 184, 0.10));
|
|
687
|
+
border: 1px solid var(--af-input-border, rgba(148, 163, 184, 0.22));
|
|
688
|
+
border-radius: 999px;
|
|
689
|
+
cursor: pointer;
|
|
690
|
+
transition: background-color .15s ease, border-color .15s ease, transform .15s ease;
|
|
691
|
+
white-space: nowrap;
|
|
692
|
+
max-width: 280px;
|
|
693
|
+
overflow: hidden;
|
|
694
|
+
text-overflow: ellipsis;
|
|
695
|
+
}
|
|
696
|
+
.af-shortcut:hover {
|
|
697
|
+
background: var(--af-primary-soft, rgba(99, 102, 241, 0.10));
|
|
698
|
+
border-color: var(--af-primary, #6366f1);
|
|
699
|
+
transform: translateY(-1px);
|
|
700
|
+
}
|
|
701
|
+
.af-shortcut:active { transform: translateY(0); }
|
|
520
702
|
.af-input { flex: 1; border: 1px solid var(--af-border); border-radius: 10px; padding: 10px 12px; font-size: 14px; font-family: inherit; outline: none; resize: none; min-height: 40px; max-height: 120px; color: var(--af-fg); background: var(--af-bg); transition: border-color .15s ease, box-shadow .15s ease; }
|
|
521
703
|
.af-input:focus { border-color: var(--af-primary); box-shadow: 0 0 0 3px var(--af-primary-soft); }
|
|
522
704
|
.af-input:disabled { background: var(--af-bubble-bg); cursor: not-allowed; }
|
|
@@ -567,26 +749,62 @@ const WIDGET_CSS = `
|
|
|
567
749
|
* --af-gap spacing between messages (default: 12px)
|
|
568
750
|
*/
|
|
569
751
|
.af-widget-root.af-variant-bare { font-family: inherit; color: inherit; }
|
|
752
|
+
/* Make the bare-inline root fill its flex parent so the panel +
|
|
753
|
+
* its sticky input dock to the parent's bottom edge instead of
|
|
754
|
+
* collapsing to content height. Required for hosts that nest the
|
|
755
|
+
* widget inside a constrained drawer / column.
|
|
756
|
+
*
|
|
757
|
+
* width:100% is mandatory: the base af-widget-root ships
|
|
758
|
+
* position:fixed which leaves the root with intrinsic-content
|
|
759
|
+
* width by default. af-inline only resets position, not the width
|
|
760
|
+
* — so without this the bare-inline widget collapses to the width
|
|
761
|
+
* of its tallest child (usually the input pill). */
|
|
762
|
+
.af-widget-root.af-variant-bare.af-inline {
|
|
763
|
+
display: flex;
|
|
764
|
+
flex-direction: column;
|
|
765
|
+
width: 100%;
|
|
766
|
+
height: 100%;
|
|
767
|
+
min-height: 0;
|
|
768
|
+
flex: 1 1 0%;
|
|
769
|
+
}
|
|
570
770
|
.af-widget-root.af-variant-bare.af-inline .af-panel,
|
|
571
771
|
.af-widget-root.af-variant-bare .af-panel {
|
|
572
772
|
position: relative;
|
|
573
773
|
width: 100%;
|
|
574
|
-
height: 100
|
|
575
|
-
max-height: none;
|
|
774
|
+
height: 100% !important;
|
|
775
|
+
max-height: none !important;
|
|
576
776
|
background: transparent;
|
|
577
777
|
border-radius: 0;
|
|
578
778
|
box-shadow: none;
|
|
579
779
|
overflow: visible;
|
|
580
780
|
display: flex;
|
|
581
781
|
flex-direction: column;
|
|
782
|
+
flex: 1;
|
|
783
|
+
min-height: 0;
|
|
582
784
|
}
|
|
583
785
|
.af-widget-root.af-variant-bare .af-messages {
|
|
584
786
|
background: transparent;
|
|
585
|
-
|
|
586
|
-
|
|
787
|
+
/* Vertical only. Side gutters belong to the host so messages can
|
|
788
|
+
align with whatever surrounding column the page renders.
|
|
789
|
+
Bottom padding creates breathing room between the last bubble
|
|
790
|
+
and the composer pill — without it the assistant's reply
|
|
791
|
+
visually collides with the input border. */
|
|
792
|
+
padding: 6px 0 14px;
|
|
793
|
+
gap: var(--af-gap, 8px);
|
|
794
|
+
width: 100%;
|
|
795
|
+
box-sizing: border-box;
|
|
587
796
|
/* Let the parent flexbox give us a min-height; we'll grow into it
|
|
588
797
|
and scroll internally when the transcript gets longer. */
|
|
589
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;
|
|
590
808
|
}
|
|
591
809
|
.af-widget-root.af-variant-bare .af-msg {
|
|
592
810
|
font-size: 15px;
|
|
@@ -613,18 +831,247 @@ const WIDGET_CSS = `
|
|
|
613
831
|
background: rgba(148, 163, 184, 0.2);
|
|
614
832
|
color: inherit;
|
|
615
833
|
}
|
|
616
|
-
|
|
834
|
+
/* Tables inside an assistant bubble. We use @media on viewport
|
|
835
|
+
(not @container) because the bubble row uses max-width 92% and
|
|
836
|
+
any container-type:inline-size ancestor breaks the
|
|
837
|
+
percentage-against-flex-parent sizing, collapsing the bubble to
|
|
838
|
+
one character wide. Trade-off: the editor preview iframe (which
|
|
839
|
+
is 420px inside a desktop browser) wont show the stacked layout
|
|
840
|
+
— fine, the preview is for design checks and the layout
|
|
841
|
+
activates correctly on a real phone. */
|
|
842
|
+
.af-widget-root.af-variant-bare .af-msg-assistant table {
|
|
843
|
+
display: table;
|
|
844
|
+
width: 100%;
|
|
845
|
+
max-width: 100%;
|
|
846
|
+
margin: 6px 0;
|
|
847
|
+
font-size: 13px;
|
|
848
|
+
border-collapse: collapse;
|
|
849
|
+
table-layout: auto;
|
|
850
|
+
}
|
|
851
|
+
.af-widget-root.af-variant-bare .af-msg-assistant thead th {
|
|
852
|
+
text-align: left;
|
|
853
|
+
font-weight: 600;
|
|
854
|
+
padding: 6px 10px;
|
|
855
|
+
border-bottom: 1px solid rgba(148, 163, 184, 0.35);
|
|
856
|
+
white-space: nowrap;
|
|
857
|
+
}
|
|
858
|
+
.af-widget-root.af-variant-bare .af-msg-assistant tbody td {
|
|
859
|
+
padding: 5px 10px;
|
|
860
|
+
border-bottom: 1px solid rgba(148, 163, 184, 0.15);
|
|
861
|
+
vertical-align: top;
|
|
862
|
+
}
|
|
863
|
+
.af-widget-root.af-variant-bare .af-msg-assistant tbody tr:last-child td {
|
|
864
|
+
border-bottom: none;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/* Stacked card layout for tables on narrow viewports. Each row
|
|
868
|
+
becomes a small card; each cell shows its column label inline
|
|
869
|
+
via the data-label attribute we stamped on render. No horizontal
|
|
870
|
+
scroll, no truncation. Activates on real mobile (the editor's
|
|
871
|
+
preview iframe stays in the desktop look since it sits inside a
|
|
872
|
+
wide browser viewport — that's intentional). */
|
|
873
|
+
@media (max-width: 480px) {
|
|
874
|
+
.af-widget-root.af-variant-bare .af-msg-assistant table,
|
|
875
|
+
.af-widget-root.af-variant-bare .af-msg-assistant thead,
|
|
876
|
+
.af-widget-root.af-variant-bare .af-msg-assistant tbody,
|
|
877
|
+
.af-widget-root.af-variant-bare .af-msg-assistant tr,
|
|
878
|
+
.af-widget-root.af-variant-bare .af-msg-assistant td,
|
|
879
|
+
.af-widget-root.af-variant-bare .af-msg-assistant th {
|
|
880
|
+
display: block;
|
|
881
|
+
width: 100%;
|
|
882
|
+
}
|
|
883
|
+
.af-widget-root.af-variant-bare .af-msg-assistant thead {
|
|
884
|
+
position: absolute;
|
|
885
|
+
left: -9999px;
|
|
886
|
+
}
|
|
887
|
+
.af-widget-root.af-variant-bare .af-msg-assistant tbody tr {
|
|
888
|
+
border: 1px solid rgba(148, 163, 184, 0.18);
|
|
889
|
+
border-radius: 10px;
|
|
890
|
+
padding: 8px 10px;
|
|
891
|
+
margin: 6px 0;
|
|
892
|
+
}
|
|
893
|
+
.af-widget-root.af-variant-bare .af-msg-assistant tbody td {
|
|
894
|
+
border-bottom: none;
|
|
895
|
+
padding: 3px 0;
|
|
896
|
+
display: flex;
|
|
897
|
+
gap: 8px;
|
|
898
|
+
align-items: baseline;
|
|
899
|
+
flex-wrap: wrap;
|
|
900
|
+
}
|
|
901
|
+
.af-widget-root.af-variant-bare .af-msg-assistant tbody td::before {
|
|
902
|
+
content: attr(data-label);
|
|
903
|
+
flex-shrink: 0;
|
|
904
|
+
min-width: 72px;
|
|
905
|
+
font-weight: 600;
|
|
906
|
+
opacity: 0.65;
|
|
907
|
+
font-size: 11px;
|
|
908
|
+
text-transform: uppercase;
|
|
909
|
+
letter-spacing: 0.04em;
|
|
910
|
+
}
|
|
911
|
+
.af-widget-root.af-variant-bare .af-msg-assistant tbody td[data-label='']::before {
|
|
912
|
+
display: none;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
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
|
+
}
|
|
950
|
+
|
|
951
|
+
/* Slack-style assistant rows: avatar column on the left, bubble on
|
|
952
|
+
* the right. The row IS the alignment container — align-self targets
|
|
953
|
+
* the row, not the bubble, so user/assistant rows still push opposite
|
|
954
|
+
* edges. */
|
|
955
|
+
.af-widget-root.af-variant-bare .af-msg-row {
|
|
956
|
+
display: flex;
|
|
957
|
+
align-items: flex-end;
|
|
958
|
+
gap: 8px;
|
|
959
|
+
max-width: 92%;
|
|
960
|
+
}
|
|
961
|
+
.af-widget-root.af-variant-bare .af-msg-row-assistant {
|
|
962
|
+
align-self: flex-start;
|
|
963
|
+
}
|
|
964
|
+
.af-widget-root.af-variant-bare .af-msg-row .af-msg {
|
|
965
|
+
/* The bubble is now a flex child of the row. Reset the max-width
|
|
966
|
+
* the row already enforces — otherwise we'd stack two caps. */
|
|
967
|
+
max-width: 100%;
|
|
968
|
+
align-self: flex-end;
|
|
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
|
+
}
|
|
986
|
+
.af-widget-root.af-variant-bare .af-msg-avatar {
|
|
987
|
+
width: 26px;
|
|
988
|
+
height: 26px;
|
|
989
|
+
flex-shrink: 0;
|
|
990
|
+
border-radius: 50%;
|
|
991
|
+
overflow: hidden;
|
|
992
|
+
/* Stick to the bubble's bottom edge so multi-line replies still
|
|
993
|
+
* show the avatar next to the last line — same as iMessage. */
|
|
994
|
+
align-self: flex-end;
|
|
995
|
+
margin-bottom: 2px;
|
|
996
|
+
}
|
|
997
|
+
.af-widget-root.af-variant-bare .af-msg-avatar-spacer {
|
|
998
|
+
background: transparent;
|
|
999
|
+
}
|
|
1000
|
+
.af-widget-root.af-variant-bare .af-msg-avatar-img {
|
|
1001
|
+
object-fit: cover;
|
|
1002
|
+
display: block;
|
|
1003
|
+
}
|
|
1004
|
+
.af-widget-root.af-variant-bare .af-msg-avatar-fallback {
|
|
1005
|
+
display: flex;
|
|
1006
|
+
align-items: center;
|
|
1007
|
+
justify-content: center;
|
|
1008
|
+
color: #fff;
|
|
1009
|
+
font-size: 11.5px;
|
|
1010
|
+
font-weight: 600;
|
|
1011
|
+
letter-spacing: 0.01em;
|
|
1012
|
+
background-image: linear-gradient(135deg, var(--af-primary, #8b5cf6), color-mix(in srgb, var(--af-primary, #8b5cf6) 60%, #6366f1));
|
|
1013
|
+
}
|
|
1014
|
+
@media (min-width: 768px) {
|
|
1015
|
+
.af-widget-root.af-variant-bare .af-msg-avatar { width: 30px; height: 30px; }
|
|
1016
|
+
.af-widget-root.af-variant-bare .af-msg-avatar-fallback { font-size: 13px; }
|
|
1017
|
+
}
|
|
617
1018
|
.af-widget-root.af-variant-bare .af-input-row {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
1019
|
+
/* Plain flex item. af-messages carries flex:1 so it eats the
|
|
1020
|
+
remaining space and naturally pushes the input row to the
|
|
1021
|
+
bottom of the panel. Sticky positioning across host containers
|
|
1022
|
+
was unreliable (different ancestors had different overflow
|
|
1023
|
+
semantics); flex layout is deterministic.
|
|
1024
|
+
width:100% + box-sizing:border-box so the row fills its
|
|
1025
|
+
parent cross-axis instead of shrinking to its children
|
|
1026
|
+
intrinsic width — without these, certain ancestor combos
|
|
1027
|
+
(Radix portals, Sheet content) left the row offset from the
|
|
1028
|
+
edges and pushed the send button out of view. */
|
|
1029
|
+
flex-shrink: 0;
|
|
1030
|
+
width: 100%;
|
|
1031
|
+
box-sizing: border-box;
|
|
1032
|
+
/* Vertical only — horizontal padding lives on the host wrapper so
|
|
1033
|
+
the input pill can stretch edge-to-edge when the host wants
|
|
1034
|
+
(e.g. a sheet drawer). Templates that want side gutters add
|
|
1035
|
+
their own padding above the widget. */
|
|
1036
|
+
padding: 10px 0 14px;
|
|
621
1037
|
border-top: none;
|
|
622
|
-
|
|
1038
|
+
/* Soft fade so messages scroll behind the input without bleeding
|
|
1039
|
+
through. Host can override via --af-input-row-bg (e.g. solid
|
|
1040
|
+
color) for templates that want a hard band instead. */
|
|
1041
|
+
background: var(
|
|
1042
|
+
--af-input-row-bg,
|
|
1043
|
+
linear-gradient(to top, rgba(255, 255, 255, 0.92) 60%, rgba(255, 255, 255, 0))
|
|
1044
|
+
);
|
|
1045
|
+
backdrop-filter: saturate(140%) blur(8px);
|
|
1046
|
+
-webkit-backdrop-filter: saturate(140%) blur(8px);
|
|
623
1047
|
gap: 8px;
|
|
624
1048
|
}
|
|
1049
|
+
@media (prefers-color-scheme: dark) {
|
|
1050
|
+
.af-widget-root.af-variant-bare .af-input-row {
|
|
1051
|
+
background: var(
|
|
1052
|
+
--af-input-row-bg,
|
|
1053
|
+
linear-gradient(to top, rgba(15, 23, 42, 0.85) 60%, rgba(15, 23, 42, 0))
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
/* In the bare variant the shortcut row also lives directly above
|
|
1058
|
+
the composer. Tighten the gap so the chips read as a continuous
|
|
1059
|
+
stack with the input. */
|
|
1060
|
+
.af-widget-root.af-variant-bare .af-shortcut-row {
|
|
1061
|
+
padding: 0 12px 6px;
|
|
1062
|
+
background: transparent;
|
|
1063
|
+
}
|
|
1064
|
+
.af-widget-root.af-variant-bare .af-shortcut-row + .af-input-row {
|
|
1065
|
+
padding-top: 2px;
|
|
1066
|
+
}
|
|
625
1067
|
.af-widget-root.af-variant-bare .af-input {
|
|
626
1068
|
background: var(--af-input-bg, rgba(148, 163, 184, 0.10));
|
|
627
|
-
|
|
1069
|
+
/* Foreground is overridable. Defaults to currentColor so the
|
|
1070
|
+
input inherits the page text color (light text in dark mode,
|
|
1071
|
+
dark text in light mode) instead of getting frozen at the
|
|
1072
|
+
bg's "neutral" value, which was causing low-contrast text on
|
|
1073
|
+
dark backgrounds when the host overrode --af-input-bg. */
|
|
1074
|
+
color: var(--af-input-fg, currentColor);
|
|
628
1075
|
border: 1px solid var(--af-input-border, rgba(148, 163, 184, 0.25));
|
|
629
1076
|
border-radius: var(--af-input-radius, 24px);
|
|
630
1077
|
padding: 12px 16px;
|
|
@@ -632,13 +1079,21 @@ const WIDGET_CSS = `
|
|
|
632
1079
|
min-height: 48px;
|
|
633
1080
|
line-height: 1.4;
|
|
634
1081
|
}
|
|
1082
|
+
.af-widget-root.af-variant-bare .af-input::placeholder {
|
|
1083
|
+
color: var(--af-input-placeholder, currentColor);
|
|
1084
|
+
opacity: 0.5;
|
|
1085
|
+
}
|
|
635
1086
|
.af-widget-root.af-variant-bare .af-input:focus {
|
|
636
1087
|
border-color: var(--af-primary);
|
|
637
1088
|
box-shadow: 0 0 0 3px var(--af-primary-soft);
|
|
638
1089
|
}
|
|
639
1090
|
.af-widget-root.af-variant-bare .af-input:disabled { background: rgba(148, 163, 184, 0.06); }
|
|
640
1091
|
.af-widget-root.af-variant-bare .af-send {
|
|
641
|
-
|
|
1092
|
+
/* Host overrides via --af-send-bg; otherwise the primary color
|
|
1093
|
+
drives the send button. Same fallback pattern used elsewhere
|
|
1094
|
+
so templates can opt into different palettes per surface
|
|
1095
|
+
without redeclaring the primary. */
|
|
1096
|
+
background: var(--af-send-bg, var(--af-primary));
|
|
642
1097
|
color: #fff;
|
|
643
1098
|
border-radius: 50%;
|
|
644
1099
|
width: 44px;
|
|
@@ -655,23 +1110,13 @@ const WIDGET_CSS = `
|
|
|
655
1110
|
}
|
|
656
1111
|
.af-widget-root.af-variant-bare .af-typing-dots span { background: currentColor; opacity: 0.5; }
|
|
657
1112
|
|
|
658
|
-
/*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
* sticky behaviour scopes to the chat container, which is enough. */
|
|
1113
|
+
/* On narrow viewports add safe-area padding so the input clears the
|
|
1114
|
+
* home indicator on iPhones. Base sticky + backdrop styles above
|
|
1115
|
+
* apply at every viewport — no override needed. */
|
|
662
1116
|
@media (max-width: 640px) {
|
|
663
1117
|
.af-widget-root.af-variant-bare .af-input-row {
|
|
664
|
-
position: sticky;
|
|
665
1118
|
bottom: env(safe-area-inset-bottom, 0);
|
|
666
1119
|
padding-bottom: calc(14px + env(safe-area-inset-bottom, 0px));
|
|
667
|
-
background: var(--af-input-row-bg, rgba(255, 255, 255, 0.85));
|
|
668
|
-
backdrop-filter: saturate(140%) blur(8px);
|
|
669
|
-
-webkit-backdrop-filter: saturate(140%) blur(8px);
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
@media (max-width: 640px) and (prefers-color-scheme: dark) {
|
|
673
|
-
.af-widget-root.af-variant-bare .af-input-row {
|
|
674
|
-
background: var(--af-input-row-bg, rgba(15, 23, 42, 0.7));
|
|
675
1120
|
}
|
|
676
1121
|
}
|
|
677
1122
|
`;
|
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",
|