@agentforge-io/chat-sdk 0.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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Pure entity types — no behavior, no transport, no DOM. Anything that holds
3
+ * data about a chat session lives here so the rest of the SDK can stay tiny
4
+ * and easy to reason about.
5
+ */
6
+ export type ChatRole = 'user' | 'assistant' | 'system';
7
+ export interface ChatMessage {
8
+ /** Stable client-side id so view layers can key off it. */
9
+ id: string;
10
+ role: ChatRole;
11
+ /** Plain text. The widget renders this as `white-space: pre-wrap`; if you
12
+ * want markdown rendering, do it in your own View layer. */
13
+ content: string;
14
+ createdAt: Date;
15
+ /** True while the assistant is still streaming this message. The UI uses
16
+ * this to render a caret/cursor next to a half-written reply. */
17
+ isStreaming?: boolean;
18
+ }
19
+ export interface ChatAgentSummary {
20
+ slug: string;
21
+ name: string;
22
+ description?: string;
23
+ }
24
+ /**
25
+ * Visual theme returned alongside the agent. Pure data — the SDK doesn't
26
+ * apply it; it just surfaces it so any View layer (widget renderer, React
27
+ * adapter, custom UI) can read the same source.
28
+ */
29
+ export interface ChatTheme {
30
+ primaryColor?: string;
31
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
32
+ title?: string;
33
+ greeting?: string;
34
+ avatarUrl?: string;
35
+ }
36
+ /**
37
+ * Discriminated union of everything `ChatSession` can emit. Subscribe via
38
+ * `session.onEvent(cb)` — the SDK is intentionally not tied to any specific
39
+ * event emitter library so it stays bundle-light.
40
+ */
41
+ export type ChatEvent = {
42
+ type: 'agent_loaded';
43
+ agent: ChatAgentSummary;
44
+ theme?: ChatTheme;
45
+ } | {
46
+ type: 'state';
47
+ state: ChatSessionState;
48
+ } | {
49
+ type: 'message_added';
50
+ message: ChatMessage;
51
+ } | {
52
+ type: 'message_updated';
53
+ message: ChatMessage;
54
+ } | {
55
+ type: 'message_removed';
56
+ messageId: string;
57
+ } | {
58
+ type: 'error';
59
+ message: string;
60
+ code?: string;
61
+ } | {
62
+ type: 'destroyed';
63
+ };
64
+ export type ChatSessionStatus = 'idle' | 'loading' | 'ready' | 'sending' | 'streaming' | 'ended' | 'error';
65
+ export interface ChatSessionState {
66
+ status: ChatSessionStatus;
67
+ agent?: ChatAgentSummary;
68
+ theme?: ChatTheme;
69
+ conversationId?: string;
70
+ messages: ChatMessage[];
71
+ lastError?: string;
72
+ }
73
+ export interface ChatSessionOptions {
74
+ /** Public chat token (`aft_*`) issued from the admin UI. */
75
+ token: string;
76
+ /**
77
+ * Base URL of the AgentForge API, including scheme. Trailing slash is
78
+ * stripped. Defaults to the origin the SDK was loaded from when running
79
+ * inside a browser bundle of the widget; required everywhere else.
80
+ */
81
+ apiBaseUrl?: string;
82
+ /** Stable id for this end-user's browser. Persist it (localStorage etc.)
83
+ * so usage rolls up across visits. If omitted, the SDK generates one and
84
+ * surfaces it via `session.browserSessionId` — caller can then store it. */
85
+ browserSessionId?: string;
86
+ /** When true, use SSE streaming endpoints (default). Set false to fall
87
+ * back to plain POSTs returning the final reply in one shot. */
88
+ stream?: boolean;
89
+ /**
90
+ * Existing conversation id to resume. When set, `start()` fetches the
91
+ * transcript and replays it as `message_added` events before going to
92
+ * `ready`. If the id no longer exists or doesn't belong to this browser
93
+ * session, the SDK silently falls back to a fresh conversation.
94
+ */
95
+ resumeConversationId?: string;
96
+ }
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ /**
3
+ * Pure entity types — no behavior, no transport, no DOM. Anything that holds
4
+ * data about a chat session lives here so the rest of the SDK can stay tiny
5
+ * and easy to reason about.
6
+ */
7
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @agentforge-io/chat-sdk — headless chat session SDK.
3
+ *
4
+ * Public surface (intentionally tiny):
5
+ * - `ChatSession` → the only class consumers create.
6
+ * - `HttpTransport` → exported for advanced cases (custom error handling,
7
+ * server-side rendering) but most apps never touch it directly.
8
+ * - All entity types so View layers can render strongly-typed state.
9
+ */
10
+ export { ChatSession } from './session';
11
+ export { HttpTransport } from './transport';
12
+ export type { StreamEvent, ServerStreamChunk, TransportError, } from './transport';
13
+ export type { ChatAgentSummary, ChatEvent, ChatMessage, ChatRole, ChatSessionOptions, ChatSessionState, ChatSessionStatus, ChatTheme, } from './entities';
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ /**
3
+ * @agentforge-io/chat-sdk — headless chat session SDK.
4
+ *
5
+ * Public surface (intentionally tiny):
6
+ * - `ChatSession` → the only class consumers create.
7
+ * - `HttpTransport` → exported for advanced cases (custom error handling,
8
+ * server-side rendering) but most apps never touch it directly.
9
+ * - All entity types so View layers can render strongly-typed state.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.HttpTransport = exports.ChatSession = void 0;
13
+ var session_1 = require("./session");
14
+ Object.defineProperty(exports, "ChatSession", { enumerable: true, get: function () { return session_1.ChatSession; } });
15
+ var transport_1 = require("./transport");
16
+ Object.defineProperty(exports, "HttpTransport", { enumerable: true, get: function () { return transport_1.HttpTransport; } });
@@ -0,0 +1,46 @@
1
+ /**
2
+ * React adapter for `@agentforge-io/chat-sdk`.
3
+ *
4
+ * Why this exists: the standalone `widget.js` (served by AgentForge) is a
5
+ * vanilla-JS bootstrap that injects a floating chat bubble. Customers who
6
+ * already render their site in React want the same widget without a
7
+ * separate <script> tag fighting their bundler — so we ship a React
8
+ * component that wraps `ChatSession` and renders the same look-and-feel
9
+ * with the framework already in the page.
10
+ *
11
+ * Public surface is one component: `<ChatWidget />`. Pass it a public
12
+ * chat token (`aft_*`) and an `apiBaseUrl`; everything else is optional.
13
+ *
14
+ * React is a `peerDependency` — we don't bundle it. Consumers always have
15
+ * their own copy, and pulling our own would risk the dreaded "two Reacts"
16
+ * runtime error.
17
+ */
18
+ import { type CSSProperties } from 'react';
19
+ import type { ChatTheme } from './entities';
20
+ export interface ChatWidgetProps {
21
+ /** Public chat token (`aft_*`) issued from the admin UI. */
22
+ token: string;
23
+ /** AgentForge API origin. Defaults to `window.location.origin` in the
24
+ * browser — set this when your site and the API live on different hosts. */
25
+ apiBaseUrl?: string;
26
+ /** Render inline (fills the parent) instead of as a floating bubble. */
27
+ inline?: boolean;
28
+ /** Override the launcher position when not inline. */
29
+ position?: NonNullable<ChatTheme['position']>;
30
+ /** Persistent id for this end-user's browser. Pass the same value across
31
+ * visits to let server-side usage aggregate. */
32
+ browserSessionId?: string;
33
+ /** Existing conversation id to resume. */
34
+ resumeConversationId?: string;
35
+ /** Disable SSE streaming and use plain POSTs. */
36
+ stream?: boolean;
37
+ /** Extra class on the root container. */
38
+ className?: string;
39
+ /** Inline style on the root container. */
40
+ style?: CSSProperties;
41
+ }
42
+ /**
43
+ * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
44
+ * SDK event. Consumers don't need to read `session.getState()` themselves.
45
+ */
46
+ export declare function ChatWidget(props: ChatWidgetProps): JSX.Element;
package/dist/react.js ADDED
@@ -0,0 +1,374 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChatWidget = ChatWidget;
4
+ const jsx_runtime_1 = require("react/jsx-runtime");
5
+ /**
6
+ * React adapter for `@agentforge-io/chat-sdk`.
7
+ *
8
+ * Why this exists: the standalone `widget.js` (served by AgentForge) is a
9
+ * vanilla-JS bootstrap that injects a floating chat bubble. Customers who
10
+ * already render their site in React want the same widget without a
11
+ * separate <script> tag fighting their bundler — so we ship a React
12
+ * component that wraps `ChatSession` and renders the same look-and-feel
13
+ * with the framework already in the page.
14
+ *
15
+ * Public surface is one component: `<ChatWidget />`. Pass it a public
16
+ * chat token (`aft_*`) and an `apiBaseUrl`; everything else is optional.
17
+ *
18
+ * React is a `peerDependency` — we don't bundle it. Consumers always have
19
+ * their own copy, and pulling our own would risk the dreaded "two Reacts"
20
+ * runtime error.
21
+ */
22
+ const react_1 = require("react");
23
+ const session_1 = require("./session");
24
+ // ─── Markdown rendering ─────────────────────────────────────────────────
25
+ // Same minimal renderer the standalone widget.js ships — paragraphs,
26
+ // headings, lists, blockquotes, fenced code, inline code, links, bold,
27
+ // italic. Input is HTML-escaped first so the resulting markup is safe to
28
+ // drop into dangerouslySetInnerHTML.
29
+ function escapeHtml(s) {
30
+ return s.replace(/[&<>"']/g, (c) => c === '&'
31
+ ? '&amp;'
32
+ : c === '<'
33
+ ? '&lt;'
34
+ : c === '>'
35
+ ? '&gt;'
36
+ : c === '"'
37
+ ? '&quot;'
38
+ : '&#39;');
39
+ }
40
+ function renderInline(s) {
41
+ const codes = [];
42
+ s = s.replace(/`([^`\n]+)`/g, (_m, body) => {
43
+ codes.push(`<code>${escapeHtml(body)}</code>`);
44
+ return `\x00CODE${codes.length - 1}\x00`;
45
+ });
46
+ s = escapeHtml(s);
47
+ s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g, (_m, text, href) => `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`);
48
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
49
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*/g, '$1<em>$2</em>');
50
+ s = s.replace(/\x00CODE(\d+)\x00/g, (_m, i) => codes[+i]);
51
+ return s;
52
+ }
53
+ function renderMarkdown(src) {
54
+ if (!src)
55
+ return '';
56
+ const lines = String(src).replace(/\r\n/g, '\n').split('\n');
57
+ const out = [];
58
+ let i = 0;
59
+ while (i < lines.length) {
60
+ const line = lines[i];
61
+ const fence = line.match(/^```(\w*)\s*$/);
62
+ if (fence) {
63
+ const buf = [];
64
+ i++;
65
+ while (i < lines.length && !/^```\s*$/.test(lines[i])) {
66
+ buf.push(lines[i]);
67
+ i++;
68
+ }
69
+ i++;
70
+ out.push(`<pre><code>${escapeHtml(buf.join('\n'))}</code></pre>`);
71
+ continue;
72
+ }
73
+ if (/^\s*(---+|\*\*\*+|___+)\s*$/.test(line)) {
74
+ out.push('<hr>');
75
+ i++;
76
+ continue;
77
+ }
78
+ const h = line.match(/^(#{1,3})\s+(.*)$/);
79
+ if (h) {
80
+ const level = h[1].length;
81
+ out.push(`<h${level}>${renderInline(h[2])}</h${level}>`);
82
+ i++;
83
+ continue;
84
+ }
85
+ if (/^\s*[-*]\s+/.test(line)) {
86
+ const items = [];
87
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
88
+ items.push(`<li>${renderInline(lines[i].replace(/^\s*[-*]\s+/, ''))}</li>`);
89
+ i++;
90
+ }
91
+ out.push(`<ul>${items.join('')}</ul>`);
92
+ continue;
93
+ }
94
+ if (/^\s*\d+\.\s+/.test(line)) {
95
+ const items = [];
96
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
97
+ items.push(`<li>${renderInline(lines[i].replace(/^\s*\d+\.\s+/, ''))}</li>`);
98
+ i++;
99
+ }
100
+ out.push(`<ol>${items.join('')}</ol>`);
101
+ continue;
102
+ }
103
+ if (/^\s*>\s?/.test(line)) {
104
+ const qbuf = [];
105
+ while (i < lines.length && /^\s*>\s?/.test(lines[i])) {
106
+ qbuf.push(lines[i].replace(/^\s*>\s?/, ''));
107
+ i++;
108
+ }
109
+ out.push(`<blockquote>${renderInline(qbuf.join(' '))}</blockquote>`);
110
+ continue;
111
+ }
112
+ if (/^\s*$/.test(line)) {
113
+ i++;
114
+ continue;
115
+ }
116
+ const pbuf = [];
117
+ while (i < lines.length &&
118
+ !/^\s*$/.test(lines[i]) &&
119
+ !/^```/.test(lines[i]) &&
120
+ !/^(#{1,3})\s+/.test(lines[i]) &&
121
+ !/^\s*[-*]\s+/.test(lines[i]) &&
122
+ !/^\s*\d+\.\s+/.test(lines[i]) &&
123
+ !/^\s*>\s?/.test(lines[i]) &&
124
+ !/^\s*(---+|\*\*\*+|___+)\s*$/.test(lines[i])) {
125
+ pbuf.push(lines[i]);
126
+ i++;
127
+ }
128
+ out.push(`<p>${renderInline(pbuf.join('\n')).replace(/\n/g, '<br>')}</p>`);
129
+ }
130
+ return out.join('');
131
+ }
132
+ /**
133
+ * Drop-in chat widget. Owns its own `ChatSession` and re-renders on every
134
+ * SDK event. Consumers don't need to read `session.getState()` themselves.
135
+ */
136
+ function ChatWidget(props) {
137
+ const { token, apiBaseUrl, inline = false, position, browserSessionId, resumeConversationId, stream, className, style, } = props;
138
+ const [session, setSession] = (0, react_1.useState)(null);
139
+ const [status, setStatus] = (0, react_1.useState)('idle');
140
+ const [agent, setAgent] = (0, react_1.useState)();
141
+ const [theme, setTheme] = (0, react_1.useState)();
142
+ const [messages, setMessages] = (0, react_1.useState)([]);
143
+ const [lastError, setLastError] = (0, react_1.useState)(null);
144
+ const [open, setOpen] = (0, react_1.useState)(inline);
145
+ const [draft, setDraft] = (0, react_1.useState)('');
146
+ const messagesRef = (0, react_1.useRef)(null);
147
+ const styleId = (0, react_1.useId)();
148
+ // ── Session lifecycle. Recreate when token / apiBaseUrl changes. ──────
149
+ (0, react_1.useEffect)(() => {
150
+ let cancelled = false;
151
+ const s = new session_1.ChatSession({
152
+ token,
153
+ apiBaseUrl,
154
+ browserSessionId,
155
+ resumeConversationId,
156
+ stream,
157
+ });
158
+ setSession(s);
159
+ setMessages([]);
160
+ setStatus('idle');
161
+ setLastError(null);
162
+ const unsubscribe = s.onEvent((evt) => {
163
+ if (cancelled)
164
+ return;
165
+ if (evt.type === 'agent_loaded') {
166
+ setAgent(evt.agent);
167
+ setTheme(evt.theme);
168
+ return;
169
+ }
170
+ if (evt.type === 'state') {
171
+ setStatus(evt.state.status);
172
+ setLastError(evt.state.lastError ?? null);
173
+ // Replace the message list wholesale — the SDK is the source of
174
+ // truth, and message_added/_updated already covered single mutations.
175
+ setMessages(evt.state.messages.map((m) => ({ ...m })));
176
+ return;
177
+ }
178
+ if (evt.type === 'error') {
179
+ setLastError(evt.message);
180
+ return;
181
+ }
182
+ });
183
+ void s.start();
184
+ return () => {
185
+ cancelled = true;
186
+ unsubscribe();
187
+ s.destroy();
188
+ };
189
+ }, [token, apiBaseUrl, browserSessionId, resumeConversationId, stream]);
190
+ // Auto-scroll on new tokens.
191
+ (0, react_1.useEffect)(() => {
192
+ const el = messagesRef.current;
193
+ if (!el)
194
+ return;
195
+ el.scrollTop = el.scrollHeight;
196
+ }, [messages]);
197
+ // Inject the widget stylesheet exactly once per page. We key the <style>
198
+ // tag by a fixed id so multiple widget mounts share it.
199
+ (0, react_1.useEffect)(() => {
200
+ if (typeof document === 'undefined')
201
+ return;
202
+ const id = 'af-chat-widget-style';
203
+ if (document.getElementById(id))
204
+ return;
205
+ const el = document.createElement('style');
206
+ el.id = id;
207
+ el.textContent = WIDGET_CSS;
208
+ document.head.appendChild(el);
209
+ }, []);
210
+ const handleSend = (0, react_1.useCallback)(() => {
211
+ if (!session)
212
+ return;
213
+ const text = draft.trim();
214
+ if (!text)
215
+ return;
216
+ setDraft('');
217
+ void session.send(text);
218
+ }, [session, draft]);
219
+ const onKeyDown = (0, react_1.useCallback)((e) => {
220
+ if (e.key === 'Enter' && !e.shiftKey) {
221
+ e.preventDefault();
222
+ handleSend();
223
+ }
224
+ }, [handleSend]);
225
+ const sendDisabled = !session ||
226
+ status === 'idle' ||
227
+ status === 'loading' ||
228
+ status === 'sending' ||
229
+ status === 'streaming' ||
230
+ status === 'ended' ||
231
+ !draft.trim();
232
+ const resolvedPosition = position ?? theme?.position ?? 'bottom-right';
233
+ const rootClass = [
234
+ 'af-widget-root',
235
+ `af-pos-${resolvedPosition}`,
236
+ inline ? 'af-inline' : '',
237
+ className ?? '',
238
+ ]
239
+ .filter(Boolean)
240
+ .join(' ');
241
+ const rootStyle = {
242
+ ...style,
243
+ // Surface the theme primary color so the bubble + accents follow it.
244
+ ...(theme?.primaryColor
245
+ ? { ['--af-primary']: theme.primaryColor }
246
+ : {}),
247
+ };
248
+ 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: [(0, jsx_runtime_1.jsxs)("div", { className: "af-header", children: [theme?.avatarUrl ? (
249
+ // eslint-disable-next-line @next/next/no-img-element
250
+ (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: 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) => ((0, jsx_runtime_1.jsx)(MessageBubble, { message: m }, m.id))) }), lastError && status === 'error' && ((0, jsx_runtime_1.jsx)("div", { className: "af-error", children: lastError })), (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: "Type a message\u2026", 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, {}) })] }), (0, jsx_runtime_1.jsx)("div", { className: "af-footer", children: "Powered by AgentForge" })] })] }));
251
+ }
252
+ function MessageBubble({ message }) {
253
+ const cls = `af-msg af-msg-${message.role}${message.role === 'assistant' && message.isStreaming
254
+ ? message.content
255
+ ? ' af-msg-streaming'
256
+ : ' af-msg-typing'
257
+ : ''}`;
258
+ // Typing state (no content yet): render the three-dot indicator, no markdown.
259
+ if (message.role === 'assistant' && message.isStreaming && !message.content) {
260
+ 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", {})] }) }));
261
+ }
262
+ if (message.role === 'assistant') {
263
+ return ((0, jsx_runtime_1.jsx)("div", { className: cls,
264
+ // Output is sanitized by escapeHtml + a fixed tag whitelist in
265
+ // renderMarkdown — safe to inject as HTML.
266
+ dangerouslySetInnerHTML: { __html: renderMarkdown(message.content) } }));
267
+ }
268
+ // User & system messages stay as plain text — they're typed verbatim.
269
+ return (0, jsx_runtime_1.jsx)("div", { className: cls, children: message.content });
270
+ }
271
+ function ChatIcon() {
272
+ return ((0, jsx_runtime_1.jsx)("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", "aria-hidden": true, children: (0, jsx_runtime_1.jsx)("path", { d: "M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" }) }));
273
+ }
274
+ function CloseIcon() {
275
+ return ((0, jsx_runtime_1.jsxs)("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", "aria-hidden": true, children: [(0, jsx_runtime_1.jsx)("line", { x1: "18", y1: "6", x2: "6", y2: "18" }), (0, jsx_runtime_1.jsx)("line", { x1: "6", y1: "6", x2: "18", y2: "18" })] }));
276
+ }
277
+ function SendIcon() {
278
+ return ((0, jsx_runtime_1.jsxs)("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: [(0, jsx_runtime_1.jsx)("line", { x1: "22", y1: "2", x2: "11", y2: "13" }), (0, jsx_runtime_1.jsx)("polygon", { points: "22 2 15 22 11 13 2 9 22 2" })] }));
279
+ }
280
+ // Stylesheet kept verbatim from the standalone widget.js so the React
281
+ // component is visually indistinguishable from the script-injected one.
282
+ const WIDGET_CSS = `
283
+ .af-widget-root {
284
+ --af-primary: #7c5cff;
285
+ --af-primary-soft: rgba(124, 92, 255, 0.35);
286
+ --af-bg: #ffffff;
287
+ --af-fg: #0f172a;
288
+ --af-muted: #64748b;
289
+ --af-border: #e2e8f0;
290
+ --af-bubble-bg: #f8fafc;
291
+ position: fixed; z-index: 2147483646;
292
+ font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
293
+ }
294
+ .af-widget-root.af-pos-bottom-right { bottom: 24px; right: 24px; }
295
+ .af-widget-root.af-pos-bottom-left { bottom: 24px; left: 24px; }
296
+ .af-widget-root.af-pos-top-right { top: 24px; right: 24px; }
297
+ .af-widget-root.af-pos-top-left { top: 24px; left: 24px; }
298
+ .af-widget-root *, .af-widget-root *::before, .af-widget-root *::after { box-sizing: border-box; }
299
+ .af-widget-root.af-inline { position: relative; top: auto; bottom: auto; left: auto; right: auto; }
300
+ .af-toggle { width: 56px; height: 56px; border-radius: 50%; border: none; cursor: pointer;
301
+ background: var(--af-primary); color: white;
302
+ box-shadow: 0 10px 30px var(--af-primary-soft); display: grid; place-items: center; transition: transform .15s ease; }
303
+ .af-toggle:hover { transform: scale(1.05); }
304
+ .af-toggle svg { width: 24px; height: 24px; stroke: currentColor; fill: none; stroke-width: 2; }
305
+ .af-panel { position: absolute; width: 360px; max-width: calc(100vw - 32px);
306
+ height: 540px; max-height: calc(100vh - 96px); background: var(--af-bg); color: var(--af-fg); border-radius: 16px;
307
+ box-shadow: 0 20px 50px rgba(15, 23, 42, 0.25); display: none; flex-direction: column; overflow: hidden; }
308
+ .af-widget-root.af-pos-bottom-right .af-panel,
309
+ .af-widget-root.af-pos-bottom-left .af-panel { bottom: 72px; }
310
+ .af-widget-root.af-pos-top-right .af-panel,
311
+ .af-widget-root.af-pos-top-left .af-panel { top: 72px; }
312
+ .af-widget-root.af-pos-bottom-right .af-panel,
313
+ .af-widget-root.af-pos-top-right .af-panel { right: 0; }
314
+ .af-widget-root.af-pos-bottom-left .af-panel,
315
+ .af-widget-root.af-pos-top-left .af-panel { left: 0; }
316
+ .af-widget-root.af-inline .af-panel { position: relative; top: auto; bottom: auto; left: auto; right: auto; width: 100%; height: 540px; box-shadow: 0 1px 3px rgba(15,23,42,0.08); display: flex; }
317
+ .af-panel.af-open { display: flex; }
318
+ .af-header { padding: 14px 16px; background: var(--af-fg); color: white; display: flex; align-items: center; gap: 10px; }
319
+ .af-header-info { flex: 1; min-width: 0; }
320
+ .af-header-avatar { width: 28px; height: 28px; border-radius: 50%; object-fit: cover; background: rgba(255,255,255,0.1); }
321
+ .af-header-title { font-weight: 600; font-size: 14px; }
322
+ .af-header-subtitle { font-size: 11px; opacity: 0.7; margin-top: 2px; }
323
+ .af-close { background: transparent; border: none; color: white; cursor: pointer; padding: 4px; line-height: 0; }
324
+ .af-close svg { width: 18px; height: 18px; }
325
+ .af-messages { flex: 1; overflow-y: auto; padding: 16px; background: var(--af-bubble-bg); display: flex; flex-direction: column; gap: 10px; scroll-behavior: smooth; }
326
+ .af-msg { max-width: 80%; padding: 10px 12px; border-radius: 14px; font-size: 14px; line-height: 1.5; word-wrap: break-word; overflow-wrap: anywhere;
327
+ animation: af-msg-in 220ms cubic-bezier(0.2, 0.8, 0.2, 1); }
328
+ .af-msg-user { align-self: flex-end; background: var(--af-primary); color: white; border-bottom-right-radius: 4px; white-space: pre-wrap; }
329
+ .af-msg-assistant { align-self: flex-start; background: var(--af-bg); color: var(--af-fg); border: 1px solid var(--af-border); border-bottom-left-radius: 4px; }
330
+ .af-msg-assistant p { margin: 0; }
331
+ .af-msg-assistant p + p { margin-top: 8px; }
332
+ .af-msg-assistant ul, .af-msg-assistant ol { margin: 6px 0; padding-left: 20px; }
333
+ .af-msg-assistant li { margin: 2px 0; }
334
+ .af-msg-assistant h1, .af-msg-assistant h2, .af-msg-assistant h3 { margin: 8px 0 4px; font-weight: 600; line-height: 1.3; }
335
+ .af-msg-assistant h1 { font-size: 16px; }
336
+ .af-msg-assistant h2 { font-size: 15px; }
337
+ .af-msg-assistant h3 { font-size: 14px; }
338
+ .af-msg-assistant a { color: var(--af-primary); text-decoration: underline; word-break: break-all; }
339
+ .af-msg-assistant code { background: rgba(15,23,42,0.06); padding: 1px 5px; border-radius: 4px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
340
+ .af-msg-assistant pre { margin: 6px 0; padding: 10px 12px; background: #0f172a; color: #f1f5f9; border-radius: 8px; overflow-x: auto; font-size: 12.5px; line-height: 1.45; }
341
+ .af-msg-assistant pre code { background: transparent; padding: 0; color: inherit; font-size: inherit; }
342
+ .af-msg-assistant blockquote { margin: 4px 0; padding-left: 10px; border-left: 3px solid var(--af-border); color: var(--af-muted); }
343
+ .af-msg-assistant hr { border: none; border-top: 1px solid var(--af-border); margin: 8px 0; }
344
+ .af-msg-assistant strong { font-weight: 600; }
345
+ .af-msg-assistant em { font-style: italic; }
346
+ .af-msg-assistant.af-msg-streaming::after { content: '▍'; display: inline-block; margin-left: 2px; animation: af-blink 1s infinite; color: #94a3b8; font-weight: 300; }
347
+ .af-msg-system { align-self: center; font-size: 12px; color: var(--af-muted); background: transparent; border: none; padding: 4px 8px; }
348
+ .af-msg-assistant.af-msg-typing { padding: 14px 14px; min-width: 56px; }
349
+ .af-msg-assistant.af-msg-typing::after { content: none; }
350
+ .af-typing-dots { display: inline-flex; gap: 4px; align-items: center; }
351
+ .af-typing-dots span { width: 7px; height: 7px; border-radius: 50%; background: var(--af-muted); display: inline-block; animation: af-bounce 1.2s infinite ease-in-out; }
352
+ .af-typing-dots span:nth-child(2) { animation-delay: 0.15s; }
353
+ .af-typing-dots span:nth-child(3) { animation-delay: 0.3s; }
354
+ @keyframes af-bounce {
355
+ 0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
356
+ 30% { transform: translateY(-4px); opacity: 1; }
357
+ }
358
+ @keyframes af-blink { 0%, 80%, 100% { opacity: 0.3; } 40% { opacity: 1; } }
359
+ @keyframes af-msg-in {
360
+ from { opacity: 0; transform: translateY(6px); }
361
+ to { opacity: 1; transform: translateY(0); }
362
+ }
363
+ .af-input-row { padding: 12px; border-top: 1px solid var(--af-border); background: var(--af-bg); display: flex; gap: 8px; align-items: flex-end; }
364
+ .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; }
365
+ .af-input:focus { border-color: var(--af-primary); box-shadow: 0 0 0 3px var(--af-primary-soft); }
366
+ .af-input:disabled { background: var(--af-bubble-bg); cursor: not-allowed; }
367
+ .af-send { background: var(--af-primary); color: white; border: none; border-radius: 10px; padding: 0 14px; min-height: 40px; cursor: pointer; font-weight: 500; font-size: 14px; display: inline-flex; align-items: center; justify-content: center; transition: opacity .15s ease, transform .1s ease; }
368
+ .af-send:hover:not(:disabled) { transform: translateY(-1px); }
369
+ .af-send:active:not(:disabled) { transform: translateY(0); }
370
+ .af-send:disabled { opacity: 0.5; cursor: not-allowed; }
371
+ .af-send svg { width: 16px; height: 16px; }
372
+ .af-error { padding: 10px 16px; font-size: 12px; color: #b91c1c; background: #fef2f2; border-top: 1px solid #fee2e2; }
373
+ .af-footer { padding: 6px 12px; font-size: 10px; color: var(--af-muted); text-align: center; background: var(--af-bubble-bg); border-top: 1px solid var(--af-border); }
374
+ `;
@@ -0,0 +1,57 @@
1
+ import { type ChatEvent, type ChatSessionOptions, type ChatSessionState } from './entities';
2
+ type Listener = (event: ChatEvent) => void;
3
+ /**
4
+ * Headless chat session. Owns all conversation state and the network calls
5
+ * that mutate it; emits events for any View to consume. Use this directly
6
+ * from React/Vue/Svelte/vanilla — it has no DOM dependency.
7
+ *
8
+ * Lifecycle:
9
+ * const session = new ChatSession({ token, apiBaseUrl });
10
+ * const unsubscribe = session.onEvent(handler);
11
+ * await session.start();
12
+ * await session.send('Hello');
13
+ * session.destroy();
14
+ *
15
+ * Errors mid-stream don't kill the session — `state.status` flips to 'error'
16
+ * and the consumer can call `send()` again. Hard errors (bad token, 403)
17
+ * leave `lastError` set and `status='error'` so the UI can surface them.
18
+ */
19
+ export declare class ChatSession {
20
+ readonly browserSessionId: string;
21
+ private readonly transport;
22
+ private readonly stream;
23
+ private readonly resumeId?;
24
+ private listeners;
25
+ private state;
26
+ private started;
27
+ constructor(opts: ChatSessionOptions);
28
+ /** Returns an unsubscribe function. Listeners are called synchronously. */
29
+ onEvent(listener: Listener): () => void;
30
+ getState(): ChatSessionState;
31
+ start(): Promise<void>;
32
+ /**
33
+ * Mark the active conversation as ended on the server and lock the
34
+ * session locally. After this, sends throw — the consumer should drop
35
+ * the persisted conversationId and start a fresh ChatSession to
36
+ * continue chatting.
37
+ */
38
+ end(): Promise<void>;
39
+ /** Tear down. Future sends throw. Useful for SPA unmount. */
40
+ destroy(): void;
41
+ /**
42
+ * Send a user message. Opens the conversation on first call. Returns the
43
+ * final assistant content for callers that want to await it; the same
44
+ * content is also emitted via `message_added` / `message_updated`.
45
+ */
46
+ send(text: string): Promise<string>;
47
+ private runStream;
48
+ private runNonStream;
49
+ private appendMessage;
50
+ private updateMessage;
51
+ private removeMessage;
52
+ private setStatus;
53
+ private emitStateChange;
54
+ private handleError;
55
+ private emit;
56
+ }
57
+ export {};
@@ -0,0 +1,336 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ChatSession = void 0;
4
+ const transport_1 = require("./transport");
5
+ /**
6
+ * Headless chat session. Owns all conversation state and the network calls
7
+ * that mutate it; emits events for any View to consume. Use this directly
8
+ * from React/Vue/Svelte/vanilla — it has no DOM dependency.
9
+ *
10
+ * Lifecycle:
11
+ * const session = new ChatSession({ token, apiBaseUrl });
12
+ * const unsubscribe = session.onEvent(handler);
13
+ * await session.start();
14
+ * await session.send('Hello');
15
+ * session.destroy();
16
+ *
17
+ * Errors mid-stream don't kill the session — `state.status` flips to 'error'
18
+ * and the consumer can call `send()` again. Hard errors (bad token, 403)
19
+ * leave `lastError` set and `status='error'` so the UI can surface them.
20
+ */
21
+ class ChatSession {
22
+ constructor(opts) {
23
+ this.listeners = new Set();
24
+ this.state = {
25
+ status: 'idle',
26
+ messages: [],
27
+ };
28
+ this.started = false;
29
+ if (!opts.token)
30
+ throw new Error('ChatSession: token is required');
31
+ const apiBaseUrl = opts.apiBaseUrl ?? defaultApiBase();
32
+ if (!apiBaseUrl) {
33
+ throw new Error('ChatSession: apiBaseUrl is required outside the browser');
34
+ }
35
+ this.transport = new transport_1.HttpTransport(apiBaseUrl, opts.token);
36
+ this.stream = opts.stream ?? true;
37
+ this.browserSessionId = opts.browserSessionId ?? generateBrowserSessionId();
38
+ this.resumeId = opts.resumeConversationId;
39
+ }
40
+ // ─── Subscriptions ──────────────────────────────────────────────────────
41
+ /** Returns an unsubscribe function. Listeners are called synchronously. */
42
+ onEvent(listener) {
43
+ this.listeners.add(listener);
44
+ return () => this.listeners.delete(listener);
45
+ }
46
+ getState() {
47
+ // Defensive copy — consumers shouldn't be able to mutate internal state.
48
+ return {
49
+ ...this.state,
50
+ messages: this.state.messages.map((m) => ({ ...m })),
51
+ };
52
+ }
53
+ // ─── Lifecycle ──────────────────────────────────────────────────────────
54
+ async start() {
55
+ if (this.started)
56
+ return;
57
+ this.started = true;
58
+ this.setStatus('loading');
59
+ try {
60
+ const { agent, theme } = await this.transport.getAgent();
61
+ this.state.agent = agent;
62
+ this.state.theme = theme;
63
+ this.emit({ type: 'agent_loaded', agent, theme });
64
+ // Try to resume a previous conversation if the caller handed us an id.
65
+ // Failure is non-fatal — the server may have GC'd the conv, the
66
+ // browser session may have rotated, or the row could belong to a
67
+ // different agent. In any of those cases we silently fall through to
68
+ // "fresh start" so the widget never gets stuck.
69
+ let resumed = false;
70
+ if (this.resumeId) {
71
+ try {
72
+ const history = await this.transport.getConversation(this.resumeId, this.browserSessionId);
73
+ if (history) {
74
+ this.state.conversationId = history.id;
75
+ for (const m of history.messages) {
76
+ // Replay each persisted message as if it had been streamed in
77
+ // — view layers already render `message_added` events.
78
+ this.appendMessage({
79
+ id: m.id,
80
+ role: m.role,
81
+ content: m.content,
82
+ createdAt: new Date(m.createdAt),
83
+ });
84
+ }
85
+ if (history.status === 'completed') {
86
+ this.setStatus('ended');
87
+ resumed = true;
88
+ return;
89
+ }
90
+ resumed = true;
91
+ }
92
+ }
93
+ catch {
94
+ // Treat all errors as "couldn't resume, start fresh".
95
+ }
96
+ }
97
+ // Only show the canned greeting when we DIDN'T resume — otherwise
98
+ // it would appear at the top of an existing chat every page reload.
99
+ if (!resumed && theme?.greeting) {
100
+ this.appendMessage({
101
+ id: makeMessageId('s'),
102
+ role: 'system',
103
+ content: theme.greeting,
104
+ createdAt: new Date(),
105
+ });
106
+ }
107
+ this.setStatus('ready');
108
+ }
109
+ catch (err) {
110
+ this.handleError(err);
111
+ }
112
+ }
113
+ /**
114
+ * Mark the active conversation as ended on the server and lock the
115
+ * session locally. After this, sends throw — the consumer should drop
116
+ * the persisted conversationId and start a fresh ChatSession to
117
+ * continue chatting.
118
+ */
119
+ async end() {
120
+ const convId = this.state.conversationId;
121
+ if (!convId)
122
+ return;
123
+ try {
124
+ await this.transport.endConversation(convId, this.browserSessionId);
125
+ this.setStatus('ended');
126
+ }
127
+ catch (err) {
128
+ this.handleError(err);
129
+ }
130
+ }
131
+ /** Tear down. Future sends throw. Useful for SPA unmount. */
132
+ destroy() {
133
+ this.listeners.clear();
134
+ this.emit({ type: 'destroyed' });
135
+ // Drop everything — a destroyed session shouldn't be reused.
136
+ this.state = { status: 'idle', messages: [] };
137
+ this.started = false;
138
+ }
139
+ // ─── User actions ───────────────────────────────────────────────────────
140
+ /**
141
+ * Send a user message. Opens the conversation on first call. Returns the
142
+ * final assistant content for callers that want to await it; the same
143
+ * content is also emitted via `message_added` / `message_updated`.
144
+ */
145
+ async send(text) {
146
+ const trimmed = text.trim();
147
+ if (!trimmed)
148
+ return '';
149
+ if (this.state.status === 'ended') {
150
+ throw new Error('Conversation has ended. Start a fresh chat to continue.');
151
+ }
152
+ if (!this.started)
153
+ await this.start();
154
+ this.appendMessage({
155
+ id: makeMessageId('u'),
156
+ role: 'user',
157
+ content: trimmed,
158
+ createdAt: new Date(),
159
+ });
160
+ const assistant = {
161
+ id: makeMessageId('a'),
162
+ role: 'assistant',
163
+ content: '',
164
+ createdAt: new Date(),
165
+ isStreaming: true,
166
+ };
167
+ this.appendMessage(assistant);
168
+ if (this.stream) {
169
+ return this.runStream(trimmed, assistant);
170
+ }
171
+ return this.runNonStream(trimmed, assistant);
172
+ }
173
+ // ─── Internals ──────────────────────────────────────────────────────────
174
+ async runStream(text, assistant) {
175
+ this.setStatus('sending');
176
+ try {
177
+ const generator = this.state.conversationId
178
+ ? this.transport.streamSendMessage(this.state.conversationId, text, this.browserSessionId)
179
+ : this.transport.streamCreateConversation(text, this.browserSessionId);
180
+ let full = '';
181
+ let sawAnyChunk = false;
182
+ for await (const evt of generator) {
183
+ if (evt.kind === 'conversation') {
184
+ this.state.conversationId = evt.id;
185
+ continue;
186
+ }
187
+ if (evt.kind === 'chunk') {
188
+ sawAnyChunk = true;
189
+ const delta = extractDelta(evt.chunk);
190
+ if (!delta)
191
+ continue;
192
+ if (this.state.status !== 'streaming')
193
+ this.setStatus('streaming');
194
+ full += delta;
195
+ assistant.content = full;
196
+ this.updateMessage(assistant);
197
+ continue;
198
+ }
199
+ if (evt.kind === 'error') {
200
+ throw new Error(evt.message);
201
+ }
202
+ if (evt.kind === 'done') {
203
+ break;
204
+ }
205
+ }
206
+ // Stream closed with no usable content — surface as an error rather
207
+ // than leaving an empty assistant bubble. Most often this means the
208
+ // backend swallowed an exception inside streamMessage (e.g. the
209
+ // history query failed or the runner rejected the model).
210
+ if (!full) {
211
+ throw new Error(sawAnyChunk
212
+ ? 'The model returned an empty response.'
213
+ : 'No response received from the server.');
214
+ }
215
+ assistant.isStreaming = false;
216
+ this.updateMessage(assistant);
217
+ this.setStatus('ready');
218
+ return full;
219
+ }
220
+ catch (err) {
221
+ // Drop the half-built assistant message — leaving an empty bubble
222
+ // around is worse than no bubble at all.
223
+ if (!assistant.content) {
224
+ this.removeMessage(assistant.id);
225
+ }
226
+ else {
227
+ assistant.isStreaming = false;
228
+ this.updateMessage(assistant);
229
+ }
230
+ this.handleError(err);
231
+ return assistant.content;
232
+ }
233
+ }
234
+ async runNonStream(text, assistant) {
235
+ this.setStatus('sending');
236
+ try {
237
+ let content;
238
+ if (this.state.conversationId) {
239
+ const res = await this.transport.sendMessage(this.state.conversationId, text, this.browserSessionId);
240
+ content = res.content;
241
+ }
242
+ else {
243
+ const res = await this.transport.createConversation(text, this.browserSessionId);
244
+ this.state.conversationId = res.conversationId;
245
+ content = res.content;
246
+ }
247
+ assistant.content = content;
248
+ assistant.isStreaming = false;
249
+ this.updateMessage(assistant);
250
+ this.setStatus('ready');
251
+ return content;
252
+ }
253
+ catch (err) {
254
+ if (!assistant.content) {
255
+ this.removeMessage(assistant.id);
256
+ }
257
+ else {
258
+ assistant.isStreaming = false;
259
+ this.updateMessage(assistant);
260
+ }
261
+ this.handleError(err);
262
+ return '';
263
+ }
264
+ }
265
+ appendMessage(message) {
266
+ this.state.messages = [...this.state.messages, message];
267
+ this.emit({ type: 'message_added', message });
268
+ this.emitStateChange();
269
+ }
270
+ updateMessage(message) {
271
+ this.state.messages = this.state.messages.map((m) => m.id === message.id ? message : m);
272
+ this.emit({ type: 'message_updated', message });
273
+ this.emitStateChange();
274
+ }
275
+ removeMessage(messageId) {
276
+ this.state.messages = this.state.messages.filter((m) => m.id !== messageId);
277
+ this.emit({ type: 'message_removed', messageId });
278
+ this.emitStateChange();
279
+ }
280
+ setStatus(status) {
281
+ if (this.state.status === status)
282
+ return;
283
+ this.state.status = status;
284
+ this.emitStateChange();
285
+ }
286
+ emitStateChange() {
287
+ this.emit({ type: 'state', state: this.getState() });
288
+ }
289
+ handleError(err) {
290
+ const message = err instanceof Error ? err.message : String(err);
291
+ const code = err && typeof err === 'object' && 'code' in err
292
+ ? String(err.code)
293
+ : undefined;
294
+ this.state.lastError = message;
295
+ this.state.status = 'error';
296
+ this.emit({ type: 'error', message, code });
297
+ this.emitStateChange();
298
+ }
299
+ emit(event) {
300
+ for (const listener of this.listeners) {
301
+ try {
302
+ listener(event);
303
+ }
304
+ catch {
305
+ // A bad subscriber shouldn't take the whole session down.
306
+ }
307
+ }
308
+ }
309
+ }
310
+ exports.ChatSession = ChatSession;
311
+ /** Extract the text delta out of a server stream chunk. */
312
+ function extractDelta(chunk) {
313
+ if (typeof chunk.delta === 'string')
314
+ return chunk.delta;
315
+ if (typeof chunk.text === 'string')
316
+ return chunk.text;
317
+ return '';
318
+ }
319
+ function makeMessageId(prefix) {
320
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
321
+ }
322
+ function generateBrowserSessionId() {
323
+ // Web crypto in browser/node 19+; fallback for ancient environments.
324
+ const c = globalThis.crypto;
325
+ if (c?.randomUUID)
326
+ return c.randomUUID();
327
+ return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
328
+ }
329
+ function defaultApiBase() {
330
+ if (typeof window === 'undefined')
331
+ return undefined;
332
+ // When the SDK is bundled into the AgentForge-served widget, it loads from
333
+ // the API origin — so the same origin is the natural default. Consumers in
334
+ // their own app will pass `apiBaseUrl` explicitly.
335
+ return window.location.origin;
336
+ }
@@ -0,0 +1,85 @@
1
+ import type { ChatAgentSummary, ChatTheme } from './entities';
2
+ /**
3
+ * Raw chunk shape produced by AgentForge's streaming endpoint. Mirrors
4
+ * @agentforge-io/core StreamChunk loosely — we only consume the fields that
5
+ * actually move state on the client.
6
+ */
7
+ export interface ServerStreamChunk {
8
+ type: string;
9
+ delta?: string;
10
+ text?: string;
11
+ usage?: {
12
+ inputTokens?: number;
13
+ outputTokens?: number;
14
+ totalTokens?: number;
15
+ };
16
+ [key: string]: unknown;
17
+ }
18
+ export type StreamEvent = {
19
+ kind: 'conversation';
20
+ id: string;
21
+ } | {
22
+ kind: 'chunk';
23
+ chunk: ServerStreamChunk;
24
+ } | {
25
+ kind: 'done';
26
+ } | {
27
+ kind: 'error';
28
+ message: string;
29
+ };
30
+ export interface TransportError extends Error {
31
+ status: number;
32
+ code?: string;
33
+ }
34
+ /**
35
+ * Thin HTTP layer over the public chat endpoints. Knows nothing about state —
36
+ * it just hits the wire and yields whatever the server sends. The session
37
+ * layer above turns these events into entities + UI state.
38
+ */
39
+ export declare class HttpTransport {
40
+ private readonly apiBaseUrl;
41
+ private readonly token;
42
+ constructor(apiBaseUrl: string, token: string);
43
+ private url;
44
+ getAgent(): Promise<{
45
+ agent: ChatAgentSummary;
46
+ theme?: ChatTheme;
47
+ }>;
48
+ /**
49
+ * Open a conversation and stream the first reply. Yields a `conversation`
50
+ * event first (with the new id), then assistant chunks, then `done`. Maps
51
+ * server-side `event: error` frames to a thrown `TransportError`.
52
+ */
53
+ streamCreateConversation(content: string, browserSessionId: string): AsyncGenerator<StreamEvent>;
54
+ streamSendMessage(conversationId: string, content: string, browserSessionId: string): AsyncGenerator<StreamEvent>;
55
+ /**
56
+ * Non-streaming fallbacks. Useful when the consumer disabled `stream`
57
+ * (e.g. running in environments without `ReadableStream`) or as a manual
58
+ * retry path after a streaming error.
59
+ */
60
+ createConversation(content: string, browserSessionId: string): Promise<{
61
+ conversationId: string;
62
+ content: string;
63
+ }>;
64
+ sendMessage(conversationId: string, content: string, browserSessionId: string): Promise<{
65
+ content: string;
66
+ }>;
67
+ /**
68
+ * Fetch a previously-opened conversation's transcript. Returns null when
69
+ * the conversation has been deleted, never existed, or belongs to another
70
+ * browser session — the SDK treats that as "start fresh".
71
+ */
72
+ getConversation(conversationId: string, browserSessionId: string): Promise<{
73
+ id: string;
74
+ status: string;
75
+ messages: Array<{
76
+ id: string;
77
+ role: 'user' | 'assistant' | 'system';
78
+ content: string;
79
+ createdAt: string;
80
+ }>;
81
+ } | null>;
82
+ endConversation(conversationId: string, browserSessionId: string): Promise<void>;
83
+ /** Parse the server's SSE response body into typed events. */
84
+ private readSse;
85
+ }
@@ -0,0 +1,235 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HttpTransport = void 0;
4
+ function makeTransportError(message, status, code) {
5
+ const err = new Error(message);
6
+ err.status = status;
7
+ err.code = code;
8
+ return err;
9
+ }
10
+ /**
11
+ * Thin HTTP layer over the public chat endpoints. Knows nothing about state —
12
+ * it just hits the wire and yields whatever the server sends. The session
13
+ * layer above turns these events into entities + UI state.
14
+ */
15
+ class HttpTransport {
16
+ constructor(apiBaseUrl, token) {
17
+ this.apiBaseUrl = apiBaseUrl;
18
+ this.token = token;
19
+ }
20
+ url(path) {
21
+ const base = this.apiBaseUrl.replace(/\/+$/, '');
22
+ return `${base}/public/chat/${encodeURIComponent(this.token)}${path}`;
23
+ }
24
+ async getAgent() {
25
+ const res = await fetch(this.url('/agent'), { method: 'GET' });
26
+ const data = await safeJson(res);
27
+ if (!res.ok) {
28
+ throw makeTransportError((data && data.message) ?? `HTTP ${res.status}`, res.status, data?.error);
29
+ }
30
+ return {
31
+ agent: data.agent,
32
+ theme: data.theme ?? undefined,
33
+ };
34
+ }
35
+ /**
36
+ * Open a conversation and stream the first reply. Yields a `conversation`
37
+ * event first (with the new id), then assistant chunks, then `done`. Maps
38
+ * server-side `event: error` frames to a thrown `TransportError`.
39
+ */
40
+ async *streamCreateConversation(content, browserSessionId) {
41
+ const res = await fetch(this.url('/conversations/stream'), {
42
+ method: 'POST',
43
+ headers: { 'Content-Type': 'application/json' },
44
+ body: JSON.stringify({ content, browserSessionId }),
45
+ });
46
+ yield* this.readSse(res);
47
+ }
48
+ async *streamSendMessage(conversationId, content, browserSessionId) {
49
+ const res = await fetch(this.url(`/conversations/${encodeURIComponent(conversationId)}/messages/stream`), {
50
+ method: 'POST',
51
+ headers: { 'Content-Type': 'application/json' },
52
+ body: JSON.stringify({ content, browserSessionId }),
53
+ });
54
+ yield* this.readSse(res);
55
+ }
56
+ /**
57
+ * Non-streaming fallbacks. Useful when the consumer disabled `stream`
58
+ * (e.g. running in environments without `ReadableStream`) or as a manual
59
+ * retry path after a streaming error.
60
+ */
61
+ async createConversation(content, browserSessionId) {
62
+ const res = await fetch(this.url('/conversations'), {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json' },
65
+ body: JSON.stringify({ initialMessage: content, browserSessionId }),
66
+ });
67
+ const data = await safeJson(res);
68
+ if (!res.ok) {
69
+ throw makeTransportError(data?.message ?? `HTTP ${res.status}`, res.status, data?.error);
70
+ }
71
+ return {
72
+ conversationId: data.conversation.id,
73
+ content: extractAssistantText(data.firstResponse),
74
+ };
75
+ }
76
+ async sendMessage(conversationId, content, browserSessionId) {
77
+ const res = await fetch(this.url(`/conversations/${encodeURIComponent(conversationId)}/messages`), {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ content, browserSessionId }),
81
+ });
82
+ const data = await safeJson(res);
83
+ if (!res.ok) {
84
+ throw makeTransportError(data?.message ?? `HTTP ${res.status}`, res.status, data?.error);
85
+ }
86
+ return { content: extractAssistantText(data) };
87
+ }
88
+ /**
89
+ * Fetch a previously-opened conversation's transcript. Returns null when
90
+ * the conversation has been deleted, never existed, or belongs to another
91
+ * browser session — the SDK treats that as "start fresh".
92
+ */
93
+ async getConversation(conversationId, browserSessionId) {
94
+ const qs = new URLSearchParams({ browserSessionId });
95
+ const res = await fetch(this.url(`/conversations/${encodeURIComponent(conversationId)}?${qs}`), { method: 'GET' });
96
+ if (res.status === 404)
97
+ return null;
98
+ const data = await safeJson(res);
99
+ if (!res.ok) {
100
+ throw makeTransportError(data?.message ?? `HTTP ${res.status}`, res.status, data?.error);
101
+ }
102
+ return {
103
+ id: data.conversationId,
104
+ status: data.status,
105
+ messages: (data.messages ?? []).map((m) => ({
106
+ id: m.id,
107
+ role: m.role,
108
+ content: m.content,
109
+ createdAt: m.createdAt,
110
+ })),
111
+ };
112
+ }
113
+ async endConversation(conversationId, browserSessionId) {
114
+ const res = await fetch(this.url(`/conversations/${encodeURIComponent(conversationId)}/end`), {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({ browserSessionId }),
118
+ });
119
+ if (!res.ok && res.status !== 204) {
120
+ const data = await safeJson(res);
121
+ throw makeTransportError(data?.message ?? `HTTP ${res.status}`, res.status, data?.error);
122
+ }
123
+ }
124
+ /** Parse the server's SSE response body into typed events. */
125
+ async *readSse(res) {
126
+ if (!res.ok) {
127
+ const data = await safeJson(res);
128
+ throw makeTransportError(data?.message ?? `HTTP ${res.status}`, res.status, data?.error);
129
+ }
130
+ if (!res.body) {
131
+ throw makeTransportError('Streaming not supported in this environment', 500);
132
+ }
133
+ const reader = res.body.getReader();
134
+ const decoder = new TextDecoder();
135
+ let buffer = '';
136
+ try {
137
+ while (true) {
138
+ const { value, done } = await reader.read();
139
+ if (done)
140
+ break;
141
+ buffer += decoder.decode(value, { stream: true });
142
+ // SSE messages are separated by a blank line. Pull complete messages
143
+ // off the buffer; leave anything trailing for the next chunk.
144
+ let sepIdx;
145
+ while ((sepIdx = buffer.indexOf('\n\n')) !== -1) {
146
+ const raw = buffer.slice(0, sepIdx);
147
+ buffer = buffer.slice(sepIdx + 2);
148
+ const evt = parseSseMessage(raw);
149
+ if (!evt)
150
+ continue;
151
+ if (evt.event === 'conversation') {
152
+ yield { kind: 'conversation', id: evt.data.id };
153
+ }
154
+ else if (evt.event === 'error') {
155
+ yield { kind: 'error', message: evt.data?.message ?? 'stream error' };
156
+ }
157
+ else if (evt.event === 'done') {
158
+ yield { kind: 'done' };
159
+ }
160
+ else {
161
+ yield { kind: 'chunk', chunk: evt.data };
162
+ }
163
+ }
164
+ }
165
+ }
166
+ finally {
167
+ try {
168
+ reader.releaseLock();
169
+ }
170
+ catch {
171
+ /* ignore */
172
+ }
173
+ }
174
+ }
175
+ }
176
+ exports.HttpTransport = HttpTransport;
177
+ /**
178
+ * Parse one SSE frame (lines separated by `\n` inside the frame, frames
179
+ * separated by `\n\n`). Tolerates the `id:` line we emit and ignores
180
+ * comment lines. Returns null on unparseable / empty frames.
181
+ */
182
+ function parseSseMessage(raw) {
183
+ if (!raw.trim())
184
+ return null;
185
+ let event = 'message';
186
+ const dataLines = [];
187
+ for (const line of raw.split('\n')) {
188
+ if (line.startsWith(':'))
189
+ continue; // SSE comment
190
+ const colon = line.indexOf(':');
191
+ if (colon === -1)
192
+ continue;
193
+ const field = line.slice(0, colon);
194
+ const value = line.slice(colon + 1).trimStart();
195
+ if (field === 'event')
196
+ event = value;
197
+ else if (field === 'data')
198
+ dataLines.push(value);
199
+ // `id` and `retry` are valid SSE fields but we don't use them client-side.
200
+ }
201
+ if (dataLines.length === 0)
202
+ return null;
203
+ const dataStr = dataLines.join('\n');
204
+ try {
205
+ return { event, data: JSON.parse(dataStr) };
206
+ }
207
+ catch {
208
+ // Server should never emit non-JSON, but if it does, surface as a string.
209
+ return { event, data: dataStr };
210
+ }
211
+ }
212
+ async function safeJson(res) {
213
+ try {
214
+ return await res.json();
215
+ }
216
+ catch {
217
+ return null;
218
+ }
219
+ }
220
+ /**
221
+ * Anthropic-style responses sometimes return content as a string and
222
+ * sometimes as an array of content blocks. Normalize to a single string.
223
+ */
224
+ function extractAssistantText(response) {
225
+ if (!response)
226
+ return '';
227
+ if (typeof response.content === 'string')
228
+ return response.content;
229
+ if (Array.isArray(response.content)) {
230
+ return response.content
231
+ .map((c) => (typeof c === 'string' ? c : c?.text ?? ''))
232
+ .join('');
233
+ }
234
+ return '';
235
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@agentforge-io/chat-sdk",
3
+ "version": "0.1.0",
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
+ "license": "MIT",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "default": "./dist/index.js"
12
+ },
13
+ "./react": {
14
+ "types": "./dist/react.d.ts",
15
+ "default": "./dist/react.js"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "build:watch": "tsc -p tsconfig.build.json --watch",
24
+ "clean": "rm -rf dist *.tgz"
25
+ },
26
+ "peerDependencies": {
27
+ "react": ">=18.0.0"
28
+ },
29
+ "peerDependenciesMeta": {
30
+ "react": {
31
+ "optional": true
32
+ }
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^20.0.0",
36
+ "@types/react": "^18.3.28",
37
+ "react": "^18.3.1",
38
+ "typescript": "^5.0.0"
39
+ }
40
+ }