@runtypelabs/persona 1.36.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/README.md +1080 -0
- package/dist/index.cjs +140 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2626 -0
- package/dist/index.d.ts +2626 -0
- package/dist/index.global.js +1843 -0
- package/dist/index.global.js.map +1 -0
- package/dist/index.js +140 -0
- package/dist/index.js.map +1 -0
- package/dist/install.global.js +2 -0
- package/dist/install.global.js.map +1 -0
- package/dist/widget.css +1627 -0
- package/package.json +79 -0
- package/src/@types/idiomorph.d.ts +37 -0
- package/src/client.test.ts +387 -0
- package/src/client.ts +1589 -0
- package/src/components/composer-builder.ts +530 -0
- package/src/components/feedback.ts +379 -0
- package/src/components/forms.ts +170 -0
- package/src/components/header-builder.ts +455 -0
- package/src/components/header-layouts.ts +303 -0
- package/src/components/launcher.ts +193 -0
- package/src/components/message-bubble.ts +528 -0
- package/src/components/messages.ts +54 -0
- package/src/components/panel.ts +204 -0
- package/src/components/reasoning-bubble.ts +144 -0
- package/src/components/registry.ts +87 -0
- package/src/components/suggestions.ts +97 -0
- package/src/components/tool-bubble.ts +288 -0
- package/src/defaults.ts +321 -0
- package/src/index.ts +175 -0
- package/src/install.ts +284 -0
- package/src/plugins/registry.ts +77 -0
- package/src/plugins/types.ts +95 -0
- package/src/postprocessors.ts +194 -0
- package/src/runtime/init.ts +162 -0
- package/src/session.ts +376 -0
- package/src/styles/tailwind.css +20 -0
- package/src/styles/widget.css +1627 -0
- package/src/types.ts +1635 -0
- package/src/ui.ts +3341 -0
- package/src/utils/actions.ts +227 -0
- package/src/utils/attachment-manager.ts +384 -0
- package/src/utils/code-generators.test.ts +500 -0
- package/src/utils/code-generators.ts +1806 -0
- package/src/utils/component-middleware.ts +137 -0
- package/src/utils/component-parser.ts +119 -0
- package/src/utils/constants.ts +16 -0
- package/src/utils/content.ts +306 -0
- package/src/utils/dom.ts +25 -0
- package/src/utils/events.ts +41 -0
- package/src/utils/formatting.test.ts +166 -0
- package/src/utils/formatting.ts +470 -0
- package/src/utils/icons.ts +92 -0
- package/src/utils/message-id.ts +37 -0
- package/src/utils/morph.ts +36 -0
- package/src/utils/positioning.ts +17 -0
- package/src/utils/storage.ts +72 -0
- package/src/utils/theme.ts +105 -0
- package/src/widget.css +1 -0
- package/widget.css +1 -0
package/src/client.ts
ADDED
|
@@ -0,0 +1,1589 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AgentWidgetConfig,
|
|
3
|
+
AgentWidgetMessage,
|
|
4
|
+
AgentWidgetEvent,
|
|
5
|
+
AgentWidgetStreamParser,
|
|
6
|
+
AgentWidgetContextProvider,
|
|
7
|
+
AgentWidgetRequestMiddleware,
|
|
8
|
+
AgentWidgetRequestPayload,
|
|
9
|
+
AgentWidgetCustomFetch,
|
|
10
|
+
AgentWidgetSSEEventParser,
|
|
11
|
+
AgentWidgetHeadersFunction,
|
|
12
|
+
AgentWidgetSSEEventResult,
|
|
13
|
+
ClientSession,
|
|
14
|
+
ClientInitResponse,
|
|
15
|
+
ClientChatRequest,
|
|
16
|
+
ClientFeedbackRequest,
|
|
17
|
+
ClientFeedbackType
|
|
18
|
+
} from "./types";
|
|
19
|
+
import {
|
|
20
|
+
extractTextFromJson,
|
|
21
|
+
createPlainTextParser,
|
|
22
|
+
createJsonStreamParser,
|
|
23
|
+
createRegexJsonParser,
|
|
24
|
+
createXmlParser
|
|
25
|
+
} from "./utils/formatting";
|
|
26
|
+
|
|
27
|
+
type DispatchOptions = {
|
|
28
|
+
messages: AgentWidgetMessage[];
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
/** Pre-generated ID for the expected assistant response (for feedback tracking) */
|
|
31
|
+
assistantMessageId?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type SSEHandler = (event: AgentWidgetEvent) => void;
|
|
35
|
+
|
|
36
|
+
const DEFAULT_ENDPOINT = "https://api.travrse.ai/v1/dispatch";
|
|
37
|
+
const DEFAULT_CLIENT_API_BASE = "https://api.travrse.ai";
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if a message has valid (non-empty) content for sending to the API.
|
|
41
|
+
* Filters out messages with empty content that would cause validation errors.
|
|
42
|
+
*
|
|
43
|
+
* @see https://github.com/anthropics/claude-code/issues/XXX - Empty assistant messages from failed requests
|
|
44
|
+
*/
|
|
45
|
+
const hasValidContent = (message: AgentWidgetMessage): boolean => {
|
|
46
|
+
// Check contentParts (multi-modal content)
|
|
47
|
+
if (message.contentParts && message.contentParts.length > 0) {
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
// Check rawContent
|
|
51
|
+
if (message.rawContent && message.rawContent.trim().length > 0) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
// Check content
|
|
55
|
+
if (message.content && message.content.trim().length > 0) {
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Maps parserType string to the corresponding parser factory function
|
|
63
|
+
*/
|
|
64
|
+
function getParserFromType(parserType?: "plain" | "json" | "regex-json" | "xml"): () => AgentWidgetStreamParser {
|
|
65
|
+
switch (parserType) {
|
|
66
|
+
case "json":
|
|
67
|
+
return createJsonStreamParser;
|
|
68
|
+
case "regex-json":
|
|
69
|
+
return createRegexJsonParser;
|
|
70
|
+
case "xml":
|
|
71
|
+
return createXmlParser;
|
|
72
|
+
case "plain":
|
|
73
|
+
default:
|
|
74
|
+
return createPlainTextParser;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export class AgentWidgetClient {
|
|
79
|
+
private readonly apiUrl: string;
|
|
80
|
+
private readonly headers: Record<string, string>;
|
|
81
|
+
private readonly debug: boolean;
|
|
82
|
+
private readonly createStreamParser: () => AgentWidgetStreamParser;
|
|
83
|
+
private readonly contextProviders: AgentWidgetContextProvider[];
|
|
84
|
+
private readonly requestMiddleware?: AgentWidgetRequestMiddleware;
|
|
85
|
+
private readonly customFetch?: AgentWidgetCustomFetch;
|
|
86
|
+
private readonly parseSSEEvent?: AgentWidgetSSEEventParser;
|
|
87
|
+
private readonly getHeaders?: AgentWidgetHeadersFunction;
|
|
88
|
+
|
|
89
|
+
// Client token mode properties
|
|
90
|
+
private clientSession: ClientSession | null = null;
|
|
91
|
+
private sessionInitPromise: Promise<ClientSession> | null = null;
|
|
92
|
+
|
|
93
|
+
constructor(private config: AgentWidgetConfig = {}) {
|
|
94
|
+
this.apiUrl = config.apiUrl ?? DEFAULT_ENDPOINT;
|
|
95
|
+
this.headers = {
|
|
96
|
+
"Content-Type": "application/json",
|
|
97
|
+
...config.headers
|
|
98
|
+
};
|
|
99
|
+
this.debug = Boolean(config.debug);
|
|
100
|
+
// Use custom stream parser if provided, otherwise use parserType, or fall back to plain text parser
|
|
101
|
+
this.createStreamParser = config.streamParser ?? getParserFromType(config.parserType);
|
|
102
|
+
this.contextProviders = config.contextProviders ?? [];
|
|
103
|
+
this.requestMiddleware = config.requestMiddleware;
|
|
104
|
+
this.customFetch = config.customFetch;
|
|
105
|
+
this.parseSSEEvent = config.parseSSEEvent;
|
|
106
|
+
this.getHeaders = config.getHeaders;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Check if running in client token mode
|
|
111
|
+
*/
|
|
112
|
+
public isClientTokenMode(): boolean {
|
|
113
|
+
return !!this.config.clientToken;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get the appropriate API URL based on mode
|
|
118
|
+
*/
|
|
119
|
+
private getClientApiUrl(endpoint: 'init' | 'chat'): string {
|
|
120
|
+
const baseUrl = this.config.apiUrl?.replace(/\/+$/, '').replace(/\/v1\/dispatch$/, '') || DEFAULT_CLIENT_API_BASE;
|
|
121
|
+
return endpoint === 'init'
|
|
122
|
+
? `${baseUrl}/v1/client/init`
|
|
123
|
+
: `${baseUrl}/v1/client/chat`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get the current client session (if any)
|
|
128
|
+
*/
|
|
129
|
+
public getClientSession(): ClientSession | null {
|
|
130
|
+
return this.clientSession;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Initialize session for client token mode.
|
|
135
|
+
* Called automatically on first message if not already initialized.
|
|
136
|
+
*/
|
|
137
|
+
public async initSession(): Promise<ClientSession> {
|
|
138
|
+
if (!this.isClientTokenMode()) {
|
|
139
|
+
throw new Error('initSession() only available in client token mode');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Return existing session if valid
|
|
143
|
+
if (this.clientSession && new Date() < this.clientSession.expiresAt) {
|
|
144
|
+
return this.clientSession;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Deduplicate concurrent init calls
|
|
148
|
+
if (this.sessionInitPromise) {
|
|
149
|
+
return this.sessionInitPromise;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this.sessionInitPromise = this._doInitSession();
|
|
153
|
+
try {
|
|
154
|
+
const session = await this.sessionInitPromise;
|
|
155
|
+
this.clientSession = session;
|
|
156
|
+
this.config.onSessionInit?.(session);
|
|
157
|
+
return session;
|
|
158
|
+
} finally {
|
|
159
|
+
this.sessionInitPromise = null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async _doInitSession(): Promise<ClientSession> {
|
|
164
|
+
// Get stored session_id if available (for session resumption)
|
|
165
|
+
const storedSessionId = this.config.getStoredSessionId?.() || null;
|
|
166
|
+
|
|
167
|
+
const requestBody: Record<string, unknown> = {
|
|
168
|
+
token: this.config.clientToken,
|
|
169
|
+
...(this.config.flowId && { flow_id: this.config.flowId }),
|
|
170
|
+
...(storedSessionId && { session_id: storedSessionId }),
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const response = await fetch(this.getClientApiUrl('init'), {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: {
|
|
176
|
+
'Content-Type': 'application/json',
|
|
177
|
+
},
|
|
178
|
+
body: JSON.stringify(requestBody),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!response.ok) {
|
|
182
|
+
const error = await response.json().catch(() => ({ error: 'Session initialization failed' }));
|
|
183
|
+
if (response.status === 401) {
|
|
184
|
+
throw new Error(`Invalid client token: ${error.hint || error.error}`);
|
|
185
|
+
}
|
|
186
|
+
if (response.status === 403) {
|
|
187
|
+
throw new Error(`Origin not allowed: ${error.hint || error.error}`);
|
|
188
|
+
}
|
|
189
|
+
throw new Error(error.error || 'Failed to initialize session');
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const data: ClientInitResponse = await response.json();
|
|
193
|
+
|
|
194
|
+
// Store the new session_id for future resumption
|
|
195
|
+
if (this.config.setStoredSessionId) {
|
|
196
|
+
this.config.setStoredSessionId(data.session_id);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
sessionId: data.session_id,
|
|
201
|
+
expiresAt: new Date(data.expires_at),
|
|
202
|
+
flow: data.flow,
|
|
203
|
+
config: {
|
|
204
|
+
welcomeMessage: data.config.welcome_message,
|
|
205
|
+
placeholder: data.config.placeholder,
|
|
206
|
+
theme: data.config.theme,
|
|
207
|
+
},
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Clear the current client session
|
|
213
|
+
*/
|
|
214
|
+
public clearClientSession(): void {
|
|
215
|
+
this.clientSession = null;
|
|
216
|
+
this.sessionInitPromise = null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Get the feedback API URL
|
|
221
|
+
*/
|
|
222
|
+
private getFeedbackApiUrl(): string {
|
|
223
|
+
const baseUrl = this.config.apiUrl?.replace(/\/+$/, '').replace(/\/v1\/dispatch$/, '') || DEFAULT_CLIENT_API_BASE;
|
|
224
|
+
return `${baseUrl}/v1/client/feedback`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Send feedback for a message (client token mode only).
|
|
229
|
+
* Supports upvote, downvote, copy, csat, and nps feedback types.
|
|
230
|
+
*
|
|
231
|
+
* @param feedback - The feedback request payload
|
|
232
|
+
* @returns Promise that resolves when feedback is sent successfully
|
|
233
|
+
* @throws Error if not in client token mode or if session is invalid
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* ```typescript
|
|
237
|
+
* // Message feedback (upvote/downvote/copy)
|
|
238
|
+
* await client.sendFeedback({
|
|
239
|
+
* session_id: sessionId,
|
|
240
|
+
* message_id: messageId,
|
|
241
|
+
* type: 'upvote'
|
|
242
|
+
* });
|
|
243
|
+
*
|
|
244
|
+
* // CSAT feedback (1-5 rating)
|
|
245
|
+
* await client.sendFeedback({
|
|
246
|
+
* session_id: sessionId,
|
|
247
|
+
* type: 'csat',
|
|
248
|
+
* rating: 5,
|
|
249
|
+
* comment: 'Great experience!'
|
|
250
|
+
* });
|
|
251
|
+
*
|
|
252
|
+
* // NPS feedback (0-10 rating)
|
|
253
|
+
* await client.sendFeedback({
|
|
254
|
+
* session_id: sessionId,
|
|
255
|
+
* type: 'nps',
|
|
256
|
+
* rating: 9
|
|
257
|
+
* });
|
|
258
|
+
* ```
|
|
259
|
+
*/
|
|
260
|
+
public async sendFeedback(feedback: ClientFeedbackRequest): Promise<void> {
|
|
261
|
+
if (!this.isClientTokenMode()) {
|
|
262
|
+
throw new Error('sendFeedback() only available in client token mode');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const session = this.getClientSession();
|
|
266
|
+
if (!session) {
|
|
267
|
+
throw new Error('No active session. Please initialize session first.');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Validate message_id is provided for message-level feedback types
|
|
271
|
+
const messageFeedbackTypes: ClientFeedbackType[] = ['upvote', 'downvote', 'copy'];
|
|
272
|
+
if (messageFeedbackTypes.includes(feedback.type) && !feedback.message_id) {
|
|
273
|
+
throw new Error(`message_id is required for ${feedback.type} feedback type`);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Validate rating is provided for csat/nps feedback types
|
|
277
|
+
if (feedback.type === 'csat') {
|
|
278
|
+
if (feedback.rating === undefined || feedback.rating < 1 || feedback.rating > 5) {
|
|
279
|
+
throw new Error('CSAT rating must be between 1 and 5');
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (feedback.type === 'nps') {
|
|
283
|
+
if (feedback.rating === undefined || feedback.rating < 0 || feedback.rating > 10) {
|
|
284
|
+
throw new Error('NPS rating must be between 0 and 10');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (this.debug) {
|
|
289
|
+
// eslint-disable-next-line no-console
|
|
290
|
+
console.debug("[AgentWidgetClient] sending feedback", feedback);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const response = await fetch(this.getFeedbackApiUrl(), {
|
|
294
|
+
method: 'POST',
|
|
295
|
+
headers: {
|
|
296
|
+
'Content-Type': 'application/json',
|
|
297
|
+
},
|
|
298
|
+
body: JSON.stringify(feedback),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (!response.ok) {
|
|
302
|
+
const errorData = await response.json().catch(() => ({ error: 'Feedback submission failed' }));
|
|
303
|
+
|
|
304
|
+
if (response.status === 401) {
|
|
305
|
+
this.clientSession = null;
|
|
306
|
+
this.config.onSessionExpired?.();
|
|
307
|
+
throw new Error('Session expired. Please refresh to continue.');
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
throw new Error(errorData.error || 'Failed to submit feedback');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Submit message feedback (upvote, downvote, or copy).
|
|
316
|
+
* Convenience method for sendFeedback with message-level feedback.
|
|
317
|
+
*
|
|
318
|
+
* @param messageId - The ID of the message to provide feedback for
|
|
319
|
+
* @param type - The feedback type: 'upvote', 'downvote', or 'copy'
|
|
320
|
+
*/
|
|
321
|
+
public async submitMessageFeedback(
|
|
322
|
+
messageId: string,
|
|
323
|
+
type: 'upvote' | 'downvote' | 'copy'
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
const session = this.getClientSession();
|
|
326
|
+
if (!session) {
|
|
327
|
+
throw new Error('No active session. Please initialize session first.');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
return this.sendFeedback({
|
|
331
|
+
session_id: session.sessionId,
|
|
332
|
+
message_id: messageId,
|
|
333
|
+
type,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Submit CSAT (Customer Satisfaction) feedback.
|
|
339
|
+
* Convenience method for sendFeedback with CSAT feedback.
|
|
340
|
+
*
|
|
341
|
+
* @param rating - Rating from 1 to 5
|
|
342
|
+
* @param comment - Optional comment
|
|
343
|
+
*/
|
|
344
|
+
public async submitCSATFeedback(rating: number, comment?: string): Promise<void> {
|
|
345
|
+
const session = this.getClientSession();
|
|
346
|
+
if (!session) {
|
|
347
|
+
throw new Error('No active session. Please initialize session first.');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return this.sendFeedback({
|
|
351
|
+
session_id: session.sessionId,
|
|
352
|
+
type: 'csat',
|
|
353
|
+
rating,
|
|
354
|
+
comment,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Submit NPS (Net Promoter Score) feedback.
|
|
360
|
+
* Convenience method for sendFeedback with NPS feedback.
|
|
361
|
+
*
|
|
362
|
+
* @param rating - Rating from 0 to 10
|
|
363
|
+
* @param comment - Optional comment
|
|
364
|
+
*/
|
|
365
|
+
public async submitNPSFeedback(rating: number, comment?: string): Promise<void> {
|
|
366
|
+
const session = this.getClientSession();
|
|
367
|
+
if (!session) {
|
|
368
|
+
throw new Error('No active session. Please initialize session first.');
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
return this.sendFeedback({
|
|
372
|
+
session_id: session.sessionId,
|
|
373
|
+
type: 'nps',
|
|
374
|
+
rating,
|
|
375
|
+
comment,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Send a message - handles both proxy and client token modes
|
|
381
|
+
*/
|
|
382
|
+
public async dispatch(options: DispatchOptions, onEvent: SSEHandler) {
|
|
383
|
+
if (this.isClientTokenMode()) {
|
|
384
|
+
return this.dispatchClientToken(options, onEvent);
|
|
385
|
+
}
|
|
386
|
+
return this.dispatchProxy(options, onEvent);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Client token mode dispatch
|
|
391
|
+
*/
|
|
392
|
+
private async dispatchClientToken(options: DispatchOptions, onEvent: SSEHandler) {
|
|
393
|
+
const controller = new AbortController();
|
|
394
|
+
if (options.signal) {
|
|
395
|
+
options.signal.addEventListener("abort", () => controller.abort());
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
onEvent({ type: "status", status: "connecting" });
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
// Ensure session is initialized
|
|
402
|
+
const session = await this.initSession();
|
|
403
|
+
|
|
404
|
+
// Check if session is about to expire (within 1 minute)
|
|
405
|
+
if (new Date() >= new Date(session.expiresAt.getTime() - 60000)) {
|
|
406
|
+
// Session expired or expiring soon
|
|
407
|
+
this.clientSession = null;
|
|
408
|
+
this.config.onSessionExpired?.();
|
|
409
|
+
const error = new Error('Session expired. Please refresh to continue.');
|
|
410
|
+
onEvent({ type: "error", error });
|
|
411
|
+
throw error;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Build the standard payload to get context/metadata from middleware
|
|
415
|
+
const basePayload = await this.buildPayload(options.messages);
|
|
416
|
+
|
|
417
|
+
// Build the chat request payload with message IDs for feedback tracking
|
|
418
|
+
// Filter out session_id from metadata if present (it's only for local storage)
|
|
419
|
+
const sanitizedMetadata = basePayload.metadata
|
|
420
|
+
? Object.fromEntries(
|
|
421
|
+
Object.entries(basePayload.metadata).filter(([key]) => key !== 'session_id')
|
|
422
|
+
)
|
|
423
|
+
: undefined;
|
|
424
|
+
|
|
425
|
+
const chatRequest: ClientChatRequest = {
|
|
426
|
+
session_id: session.sessionId,
|
|
427
|
+
// Filter out messages with empty content to prevent validation errors
|
|
428
|
+
messages: options.messages.filter(hasValidContent).map(m => ({
|
|
429
|
+
id: m.id, // Include message ID for tracking
|
|
430
|
+
role: m.role,
|
|
431
|
+
// Use contentParts for multi-modal messages, otherwise fall back to string content
|
|
432
|
+
content: m.contentParts ?? m.rawContent ?? m.content,
|
|
433
|
+
})),
|
|
434
|
+
// Include pre-generated assistant message ID if provided
|
|
435
|
+
...(options.assistantMessageId && { assistant_message_id: options.assistantMessageId }),
|
|
436
|
+
// Include metadata/context from middleware if present (excluding session_id)
|
|
437
|
+
...(sanitizedMetadata && Object.keys(sanitizedMetadata).length > 0 && { metadata: sanitizedMetadata }),
|
|
438
|
+
...(basePayload.context && { context: basePayload.context }),
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
if (this.debug) {
|
|
442
|
+
// eslint-disable-next-line no-console
|
|
443
|
+
console.debug("[AgentWidgetClient] client token dispatch", chatRequest);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const response = await fetch(this.getClientApiUrl('chat'), {
|
|
447
|
+
method: 'POST',
|
|
448
|
+
headers: {
|
|
449
|
+
'Content-Type': 'application/json',
|
|
450
|
+
},
|
|
451
|
+
body: JSON.stringify(chatRequest),
|
|
452
|
+
signal: controller.signal,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
if (!response.ok) {
|
|
456
|
+
const errorData = await response.json().catch(() => ({ error: 'Chat request failed' }));
|
|
457
|
+
|
|
458
|
+
if (response.status === 401) {
|
|
459
|
+
// Session expired
|
|
460
|
+
this.clientSession = null;
|
|
461
|
+
this.config.onSessionExpired?.();
|
|
462
|
+
const error = new Error('Session expired. Please refresh to continue.');
|
|
463
|
+
onEvent({ type: "error", error });
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
if (response.status === 429) {
|
|
468
|
+
const error = new Error(errorData.hint || 'Message limit reached for this session.');
|
|
469
|
+
onEvent({ type: "error", error });
|
|
470
|
+
throw error;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const error = new Error(errorData.error || 'Failed to send message');
|
|
474
|
+
onEvent({ type: "error", error });
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (!response.body) {
|
|
479
|
+
const error = new Error('No response body received');
|
|
480
|
+
onEvent({ type: "error", error });
|
|
481
|
+
throw error;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
onEvent({ type: "status", status: "connected" });
|
|
485
|
+
|
|
486
|
+
// Stream the response (same SSE handling as proxy mode)
|
|
487
|
+
try {
|
|
488
|
+
await this.streamResponse(response.body, onEvent, options.assistantMessageId);
|
|
489
|
+
} finally {
|
|
490
|
+
onEvent({ type: "status", status: "idle" });
|
|
491
|
+
}
|
|
492
|
+
} catch (error) {
|
|
493
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
494
|
+
// Only emit error if it wasn't already emitted
|
|
495
|
+
if (!err.message.includes('Session expired') && !err.message.includes('Message limit')) {
|
|
496
|
+
onEvent({ type: "error", error: err });
|
|
497
|
+
}
|
|
498
|
+
throw err;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Proxy mode dispatch (original implementation)
|
|
504
|
+
*/
|
|
505
|
+
private async dispatchProxy(options: DispatchOptions, onEvent: SSEHandler) {
|
|
506
|
+
const controller = new AbortController();
|
|
507
|
+
if (options.signal) {
|
|
508
|
+
options.signal.addEventListener("abort", () => controller.abort());
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
onEvent({ type: "status", status: "connecting" });
|
|
512
|
+
|
|
513
|
+
const payload = await this.buildPayload(options.messages);
|
|
514
|
+
|
|
515
|
+
if (this.debug) {
|
|
516
|
+
// eslint-disable-next-line no-console
|
|
517
|
+
console.debug("[AgentWidgetClient] dispatch payload", payload);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// Build headers - merge static headers with dynamic headers if provided
|
|
521
|
+
let headers = { ...this.headers };
|
|
522
|
+
if (this.getHeaders) {
|
|
523
|
+
try {
|
|
524
|
+
const dynamicHeaders = await this.getHeaders();
|
|
525
|
+
headers = { ...headers, ...dynamicHeaders };
|
|
526
|
+
} catch (error) {
|
|
527
|
+
if (typeof console !== "undefined") {
|
|
528
|
+
// eslint-disable-next-line no-console
|
|
529
|
+
console.error("[AgentWidget] getHeaders error:", error);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Use customFetch if provided, otherwise use default fetch
|
|
535
|
+
let response: Response;
|
|
536
|
+
if (this.customFetch) {
|
|
537
|
+
try {
|
|
538
|
+
response = await this.customFetch(
|
|
539
|
+
this.apiUrl,
|
|
540
|
+
{
|
|
541
|
+
method: "POST",
|
|
542
|
+
headers,
|
|
543
|
+
body: JSON.stringify(payload),
|
|
544
|
+
signal: controller.signal
|
|
545
|
+
},
|
|
546
|
+
payload
|
|
547
|
+
);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
550
|
+
onEvent({ type: "error", error: err });
|
|
551
|
+
throw err;
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
response = await fetch(this.apiUrl, {
|
|
555
|
+
method: "POST",
|
|
556
|
+
headers,
|
|
557
|
+
body: JSON.stringify(payload),
|
|
558
|
+
signal: controller.signal
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
if (!response.ok || !response.body) {
|
|
563
|
+
const error = new Error(
|
|
564
|
+
`Chat backend request failed: ${response.status} ${response.statusText}`
|
|
565
|
+
);
|
|
566
|
+
onEvent({ type: "error", error });
|
|
567
|
+
throw error;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
onEvent({ type: "status", status: "connected" });
|
|
571
|
+
try {
|
|
572
|
+
await this.streamResponse(response.body, onEvent);
|
|
573
|
+
} finally {
|
|
574
|
+
onEvent({ type: "status", status: "idle" });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
private async buildPayload(
|
|
579
|
+
messages: AgentWidgetMessage[]
|
|
580
|
+
): Promise<AgentWidgetRequestPayload> {
|
|
581
|
+
// Filter out messages with empty content to prevent validation errors
|
|
582
|
+
const normalizedMessages = messages
|
|
583
|
+
.slice()
|
|
584
|
+
.filter(hasValidContent)
|
|
585
|
+
.sort((a, b) => {
|
|
586
|
+
const timeA = new Date(a.createdAt).getTime();
|
|
587
|
+
const timeB = new Date(b.createdAt).getTime();
|
|
588
|
+
return timeA - timeB;
|
|
589
|
+
})
|
|
590
|
+
.map((message) => ({
|
|
591
|
+
role: message.role,
|
|
592
|
+
// Use contentParts for multi-modal messages, otherwise fall back to string content
|
|
593
|
+
content: message.contentParts ?? message.rawContent ?? message.content,
|
|
594
|
+
createdAt: message.createdAt
|
|
595
|
+
}));
|
|
596
|
+
|
|
597
|
+
const payload: AgentWidgetRequestPayload = {
|
|
598
|
+
messages: normalizedMessages,
|
|
599
|
+
...(this.config.flowId && { flowId: this.config.flowId })
|
|
600
|
+
};
|
|
601
|
+
|
|
602
|
+
if (this.contextProviders.length) {
|
|
603
|
+
const contextAggregate: Record<string, unknown> = {};
|
|
604
|
+
await Promise.all(
|
|
605
|
+
this.contextProviders.map(async (provider) => {
|
|
606
|
+
try {
|
|
607
|
+
const result = await provider({
|
|
608
|
+
messages,
|
|
609
|
+
config: this.config
|
|
610
|
+
});
|
|
611
|
+
if (result && typeof result === "object") {
|
|
612
|
+
Object.assign(contextAggregate, result);
|
|
613
|
+
}
|
|
614
|
+
} catch (error) {
|
|
615
|
+
if (typeof console !== "undefined") {
|
|
616
|
+
// eslint-disable-next-line no-console
|
|
617
|
+
console.warn("[AgentWidget] Context provider failed:", error);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
})
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
if (Object.keys(contextAggregate).length) {
|
|
624
|
+
payload.context = contextAggregate;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (this.requestMiddleware) {
|
|
629
|
+
try {
|
|
630
|
+
const result = await this.requestMiddleware({
|
|
631
|
+
payload: { ...payload },
|
|
632
|
+
config: this.config
|
|
633
|
+
});
|
|
634
|
+
if (result && typeof result === "object") {
|
|
635
|
+
return result as AgentWidgetRequestPayload;
|
|
636
|
+
}
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (typeof console !== "undefined") {
|
|
639
|
+
// eslint-disable-next-line no-console
|
|
640
|
+
console.error("[AgentWidget] Request middleware error:", error);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return payload;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Handle custom SSE event parsing via parseSSEEvent callback
|
|
650
|
+
* Returns true if event was handled, false otherwise
|
|
651
|
+
*/
|
|
652
|
+
private async handleCustomSSEEvent(
|
|
653
|
+
payload: unknown,
|
|
654
|
+
onEvent: SSEHandler,
|
|
655
|
+
assistantMessageRef: { current: AgentWidgetMessage | null },
|
|
656
|
+
emitMessage: (msg: AgentWidgetMessage) => void,
|
|
657
|
+
nextSequence: () => number
|
|
658
|
+
): Promise<boolean> {
|
|
659
|
+
if (!this.parseSSEEvent) return false;
|
|
660
|
+
|
|
661
|
+
try {
|
|
662
|
+
const result = await this.parseSSEEvent(payload);
|
|
663
|
+
if (result === null) return false; // Event should be ignored
|
|
664
|
+
|
|
665
|
+
const ensureAssistant = () => {
|
|
666
|
+
if (assistantMessageRef.current) return assistantMessageRef.current;
|
|
667
|
+
const msg: AgentWidgetMessage = {
|
|
668
|
+
id: `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
669
|
+
role: "assistant",
|
|
670
|
+
content: "",
|
|
671
|
+
createdAt: new Date().toISOString(),
|
|
672
|
+
streaming: true,
|
|
673
|
+
variant: "assistant",
|
|
674
|
+
sequence: nextSequence()
|
|
675
|
+
};
|
|
676
|
+
assistantMessageRef.current = msg;
|
|
677
|
+
emitMessage(msg);
|
|
678
|
+
return msg;
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
if (result.text !== undefined) {
|
|
682
|
+
const assistant = ensureAssistant();
|
|
683
|
+
assistant.content += result.text;
|
|
684
|
+
emitMessage(assistant);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (result.done) {
|
|
688
|
+
if (assistantMessageRef.current) {
|
|
689
|
+
assistantMessageRef.current.streaming = false;
|
|
690
|
+
emitMessage(assistantMessageRef.current);
|
|
691
|
+
}
|
|
692
|
+
onEvent({ type: "status", status: "idle" });
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
if (result.error) {
|
|
696
|
+
onEvent({
|
|
697
|
+
type: "error",
|
|
698
|
+
error: new Error(result.error)
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return true; // Event was handled
|
|
703
|
+
} catch (error) {
|
|
704
|
+
if (typeof console !== "undefined") {
|
|
705
|
+
// eslint-disable-next-line no-console
|
|
706
|
+
console.error("[AgentWidget] parseSSEEvent error:", error);
|
|
707
|
+
}
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
private async streamResponse(
|
|
713
|
+
body: ReadableStream<Uint8Array>,
|
|
714
|
+
onEvent: SSEHandler,
|
|
715
|
+
assistantMessageId?: string
|
|
716
|
+
) {
|
|
717
|
+
const reader = body.getReader();
|
|
718
|
+
const decoder = new TextDecoder();
|
|
719
|
+
let buffer = "";
|
|
720
|
+
|
|
721
|
+
const baseSequence = Date.now();
|
|
722
|
+
let sequenceCounter = 0;
|
|
723
|
+
const nextSequence = () => baseSequence + sequenceCounter++;
|
|
724
|
+
|
|
725
|
+
const cloneMessage = (msg: AgentWidgetMessage): AgentWidgetMessage => {
|
|
726
|
+
const reasoning = msg.reasoning
|
|
727
|
+
? {
|
|
728
|
+
...msg.reasoning,
|
|
729
|
+
chunks: [...msg.reasoning.chunks]
|
|
730
|
+
}
|
|
731
|
+
: undefined;
|
|
732
|
+
const toolCall = msg.toolCall
|
|
733
|
+
? {
|
|
734
|
+
...msg.toolCall,
|
|
735
|
+
chunks: msg.toolCall.chunks ? [...msg.toolCall.chunks] : undefined
|
|
736
|
+
}
|
|
737
|
+
: undefined;
|
|
738
|
+
const tools = msg.tools
|
|
739
|
+
? msg.tools.map((tool) => ({
|
|
740
|
+
...tool,
|
|
741
|
+
chunks: tool.chunks ? [...tool.chunks] : undefined
|
|
742
|
+
}))
|
|
743
|
+
: undefined;
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
...msg,
|
|
747
|
+
reasoning,
|
|
748
|
+
toolCall,
|
|
749
|
+
tools
|
|
750
|
+
};
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
const emitMessage = (msg: AgentWidgetMessage) => {
|
|
754
|
+
onEvent({
|
|
755
|
+
type: "message",
|
|
756
|
+
message: cloneMessage(msg)
|
|
757
|
+
});
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
let assistantMessage: AgentWidgetMessage | null = null;
|
|
761
|
+
// Reference to track assistant message for custom event handler
|
|
762
|
+
const assistantMessageRef = { current: null as AgentWidgetMessage | null };
|
|
763
|
+
const reasoningMessages = new Map<string, AgentWidgetMessage>();
|
|
764
|
+
const toolMessages = new Map<string, AgentWidgetMessage>();
|
|
765
|
+
const reasoningContext = {
|
|
766
|
+
lastId: null as string | null,
|
|
767
|
+
byStep: new Map<string, string>()
|
|
768
|
+
};
|
|
769
|
+
const toolContext = {
|
|
770
|
+
lastId: null as string | null,
|
|
771
|
+
byCall: new Map<string, string>()
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
const normalizeKey = (value: unknown): string | null => {
|
|
775
|
+
if (value === null || value === undefined) return null;
|
|
776
|
+
try {
|
|
777
|
+
return String(value);
|
|
778
|
+
} catch (error) {
|
|
779
|
+
return null;
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
const getStepKey = (payload: Record<string, any>) =>
|
|
784
|
+
normalizeKey(
|
|
785
|
+
payload.stepId ??
|
|
786
|
+
payload.step_id ??
|
|
787
|
+
payload.step ??
|
|
788
|
+
payload.parentId ??
|
|
789
|
+
payload.flowStepId ??
|
|
790
|
+
payload.flow_step_id
|
|
791
|
+
);
|
|
792
|
+
|
|
793
|
+
const getToolCallKey = (payload: Record<string, any>) =>
|
|
794
|
+
normalizeKey(
|
|
795
|
+
payload.callId ??
|
|
796
|
+
payload.call_id ??
|
|
797
|
+
payload.requestId ??
|
|
798
|
+
payload.request_id ??
|
|
799
|
+
payload.toolCallId ??
|
|
800
|
+
payload.tool_call_id ??
|
|
801
|
+
payload.stepId ??
|
|
802
|
+
payload.step_id
|
|
803
|
+
);
|
|
804
|
+
|
|
805
|
+
const ensureAssistantMessage = () => {
|
|
806
|
+
if (assistantMessage) return assistantMessage;
|
|
807
|
+
assistantMessage = {
|
|
808
|
+
// Use pre-generated ID if provided, otherwise generate one
|
|
809
|
+
id: assistantMessageId ?? `assistant-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
810
|
+
role: "assistant",
|
|
811
|
+
content: "",
|
|
812
|
+
createdAt: new Date().toISOString(),
|
|
813
|
+
streaming: true,
|
|
814
|
+
sequence: nextSequence()
|
|
815
|
+
};
|
|
816
|
+
emitMessage(assistantMessage);
|
|
817
|
+
return assistantMessage;
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
const trackReasoningId = (stepKey: string | null, id: string) => {
|
|
821
|
+
reasoningContext.lastId = id;
|
|
822
|
+
if (stepKey) {
|
|
823
|
+
reasoningContext.byStep.set(stepKey, id);
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const resolveReasoningId = (
|
|
828
|
+
payload: Record<string, any>,
|
|
829
|
+
allowCreate: boolean
|
|
830
|
+
): string | null => {
|
|
831
|
+
const rawId = payload.reasoningId ?? payload.id;
|
|
832
|
+
const stepKey = getStepKey(payload);
|
|
833
|
+
if (rawId) {
|
|
834
|
+
const resolved = String(rawId);
|
|
835
|
+
trackReasoningId(stepKey, resolved);
|
|
836
|
+
return resolved;
|
|
837
|
+
}
|
|
838
|
+
if (stepKey) {
|
|
839
|
+
const existing = reasoningContext.byStep.get(stepKey);
|
|
840
|
+
if (existing) {
|
|
841
|
+
reasoningContext.lastId = existing;
|
|
842
|
+
return existing;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
if (reasoningContext.lastId && !allowCreate) {
|
|
846
|
+
return reasoningContext.lastId;
|
|
847
|
+
}
|
|
848
|
+
if (!allowCreate) {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
const generated = `reason-${nextSequence()}`;
|
|
852
|
+
trackReasoningId(stepKey, generated);
|
|
853
|
+
return generated;
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
const ensureReasoningMessage = (reasoningId: string) => {
|
|
857
|
+
const existing = reasoningMessages.get(reasoningId);
|
|
858
|
+
if (existing) {
|
|
859
|
+
return existing;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const message: AgentWidgetMessage = {
|
|
863
|
+
id: `reason-${reasoningId}`,
|
|
864
|
+
role: "assistant",
|
|
865
|
+
content: "",
|
|
866
|
+
createdAt: new Date().toISOString(),
|
|
867
|
+
streaming: true,
|
|
868
|
+
variant: "reasoning",
|
|
869
|
+
sequence: nextSequence(),
|
|
870
|
+
reasoning: {
|
|
871
|
+
id: reasoningId,
|
|
872
|
+
status: "streaming",
|
|
873
|
+
chunks: []
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
reasoningMessages.set(reasoningId, message);
|
|
878
|
+
emitMessage(message);
|
|
879
|
+
return message;
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const trackToolId = (callKey: string | null, id: string) => {
|
|
883
|
+
toolContext.lastId = id;
|
|
884
|
+
if (callKey) {
|
|
885
|
+
toolContext.byCall.set(callKey, id);
|
|
886
|
+
}
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
const resolveToolId = (
|
|
890
|
+
payload: Record<string, any>,
|
|
891
|
+
allowCreate: boolean
|
|
892
|
+
): string | null => {
|
|
893
|
+
const rawId = payload.toolId ?? payload.id;
|
|
894
|
+
const callKey = getToolCallKey(payload);
|
|
895
|
+
if (rawId) {
|
|
896
|
+
const resolved = String(rawId);
|
|
897
|
+
trackToolId(callKey, resolved);
|
|
898
|
+
return resolved;
|
|
899
|
+
}
|
|
900
|
+
if (callKey) {
|
|
901
|
+
const existing = toolContext.byCall.get(callKey);
|
|
902
|
+
if (existing) {
|
|
903
|
+
toolContext.lastId = existing;
|
|
904
|
+
return existing;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (toolContext.lastId && !allowCreate) {
|
|
908
|
+
return toolContext.lastId;
|
|
909
|
+
}
|
|
910
|
+
if (!allowCreate) {
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
const generated = `tool-${nextSequence()}`;
|
|
914
|
+
trackToolId(callKey, generated);
|
|
915
|
+
return generated;
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
const ensureToolMessage = (toolId: string) => {
|
|
919
|
+
const existing = toolMessages.get(toolId);
|
|
920
|
+
if (existing) {
|
|
921
|
+
return existing;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const message: AgentWidgetMessage = {
|
|
925
|
+
id: `tool-${toolId}`,
|
|
926
|
+
role: "assistant",
|
|
927
|
+
content: "",
|
|
928
|
+
createdAt: new Date().toISOString(),
|
|
929
|
+
streaming: true,
|
|
930
|
+
variant: "tool",
|
|
931
|
+
sequence: nextSequence(),
|
|
932
|
+
toolCall: {
|
|
933
|
+
id: toolId,
|
|
934
|
+
status: "pending"
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
toolMessages.set(toolId, message);
|
|
939
|
+
emitMessage(message);
|
|
940
|
+
return message;
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
const resolveTimestamp = (value: unknown) => {
|
|
944
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
945
|
+
return value;
|
|
946
|
+
}
|
|
947
|
+
if (typeof value === "string") {
|
|
948
|
+
const parsed = Number(value);
|
|
949
|
+
if (!Number.isNaN(parsed) && Number.isFinite(parsed)) {
|
|
950
|
+
return parsed;
|
|
951
|
+
}
|
|
952
|
+
const dateParsed = Date.parse(value);
|
|
953
|
+
if (!Number.isNaN(dateParsed)) {
|
|
954
|
+
return dateParsed;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
return Date.now();
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const ensureStringContent = (value: unknown): string => {
|
|
961
|
+
if (typeof value === "string") {
|
|
962
|
+
return value;
|
|
963
|
+
}
|
|
964
|
+
if (value === null || value === undefined) {
|
|
965
|
+
return "";
|
|
966
|
+
}
|
|
967
|
+
// Convert objects/arrays to JSON string
|
|
968
|
+
try {
|
|
969
|
+
return JSON.stringify(value);
|
|
970
|
+
} catch {
|
|
971
|
+
return String(value);
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
// Maintain stateful stream parsers per message for incremental parsing
|
|
976
|
+
const streamParsers = new Map<string, AgentWidgetStreamParser>();
|
|
977
|
+
// Track accumulated raw content for structured formats (JSON, XML, etc.)
|
|
978
|
+
const rawContentBuffers = new Map<string, string>();
|
|
979
|
+
|
|
980
|
+
while (true) {
|
|
981
|
+
const { done, value } = await reader.read();
|
|
982
|
+
if (done) break;
|
|
983
|
+
|
|
984
|
+
buffer += decoder.decode(value, { stream: true });
|
|
985
|
+
const events = buffer.split("\n\n");
|
|
986
|
+
buffer = events.pop() ?? "";
|
|
987
|
+
|
|
988
|
+
for (const event of events) {
|
|
989
|
+
const lines = event.split("\n");
|
|
990
|
+
let eventType = "message";
|
|
991
|
+
let data = "";
|
|
992
|
+
|
|
993
|
+
for (const line of lines) {
|
|
994
|
+
if (line.startsWith("event:")) {
|
|
995
|
+
eventType = line.replace("event:", "").trim();
|
|
996
|
+
} else if (line.startsWith("data:")) {
|
|
997
|
+
data += line.replace("data:", "").trim();
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
if (!data) continue;
|
|
1002
|
+
let payload: any;
|
|
1003
|
+
try {
|
|
1004
|
+
payload = JSON.parse(data);
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
onEvent({
|
|
1007
|
+
type: "error",
|
|
1008
|
+
error:
|
|
1009
|
+
error instanceof Error
|
|
1010
|
+
? error
|
|
1011
|
+
: new Error("Failed to parse chat stream payload")
|
|
1012
|
+
});
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
const payloadType =
|
|
1017
|
+
eventType !== "message" ? eventType : payload.type ?? "message";
|
|
1018
|
+
|
|
1019
|
+
// If custom SSE event parser is provided, try it first
|
|
1020
|
+
if (this.parseSSEEvent) {
|
|
1021
|
+
// Keep assistant message ref in sync
|
|
1022
|
+
assistantMessageRef.current = assistantMessage;
|
|
1023
|
+
const handled = await this.handleCustomSSEEvent(
|
|
1024
|
+
payload,
|
|
1025
|
+
onEvent,
|
|
1026
|
+
assistantMessageRef,
|
|
1027
|
+
emitMessage,
|
|
1028
|
+
nextSequence
|
|
1029
|
+
);
|
|
1030
|
+
// Update assistantMessage from ref (in case it was created)
|
|
1031
|
+
if (assistantMessageRef.current && !assistantMessage) {
|
|
1032
|
+
assistantMessage = assistantMessageRef.current;
|
|
1033
|
+
}
|
|
1034
|
+
if (handled) continue; // Skip default handling if custom handler processed it
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
if (payloadType === "reason_start") {
|
|
1038
|
+
const reasoningId =
|
|
1039
|
+
resolveReasoningId(payload, true) ?? `reason-${nextSequence()}`;
|
|
1040
|
+
const reasoningMessage = ensureReasoningMessage(reasoningId);
|
|
1041
|
+
reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
|
|
1042
|
+
id: reasoningId,
|
|
1043
|
+
status: "streaming",
|
|
1044
|
+
chunks: []
|
|
1045
|
+
};
|
|
1046
|
+
reasoningMessage.reasoning.startedAt =
|
|
1047
|
+
reasoningMessage.reasoning.startedAt ??
|
|
1048
|
+
resolveTimestamp(payload.startedAt ?? payload.timestamp);
|
|
1049
|
+
reasoningMessage.reasoning.completedAt = undefined;
|
|
1050
|
+
reasoningMessage.reasoning.durationMs = undefined;
|
|
1051
|
+
reasoningMessage.streaming = true;
|
|
1052
|
+
reasoningMessage.reasoning.status = "streaming";
|
|
1053
|
+
emitMessage(reasoningMessage);
|
|
1054
|
+
} else if (payloadType === "reason_chunk") {
|
|
1055
|
+
const reasoningId =
|
|
1056
|
+
resolveReasoningId(payload, false) ??
|
|
1057
|
+
resolveReasoningId(payload, true) ??
|
|
1058
|
+
`reason-${nextSequence()}`;
|
|
1059
|
+
const reasoningMessage = ensureReasoningMessage(reasoningId);
|
|
1060
|
+
reasoningMessage.reasoning = reasoningMessage.reasoning ?? {
|
|
1061
|
+
id: reasoningId,
|
|
1062
|
+
status: "streaming",
|
|
1063
|
+
chunks: []
|
|
1064
|
+
};
|
|
1065
|
+
reasoningMessage.reasoning.startedAt =
|
|
1066
|
+
reasoningMessage.reasoning.startedAt ??
|
|
1067
|
+
resolveTimestamp(payload.startedAt ?? payload.timestamp);
|
|
1068
|
+
const chunk =
|
|
1069
|
+
payload.reasoningText ??
|
|
1070
|
+
payload.text ??
|
|
1071
|
+
payload.delta ??
|
|
1072
|
+
"";
|
|
1073
|
+
if (chunk && payload.hidden !== true) {
|
|
1074
|
+
reasoningMessage.reasoning.chunks.push(String(chunk));
|
|
1075
|
+
}
|
|
1076
|
+
reasoningMessage.reasoning.status = payload.done ? "complete" : "streaming";
|
|
1077
|
+
if (payload.done) {
|
|
1078
|
+
reasoningMessage.reasoning.completedAt = resolveTimestamp(
|
|
1079
|
+
payload.completedAt ?? payload.timestamp
|
|
1080
|
+
);
|
|
1081
|
+
const start = reasoningMessage.reasoning.startedAt ?? Date.now();
|
|
1082
|
+
reasoningMessage.reasoning.durationMs = Math.max(
|
|
1083
|
+
0,
|
|
1084
|
+
(reasoningMessage.reasoning.completedAt ?? Date.now()) - start
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
reasoningMessage.streaming = reasoningMessage.reasoning.status !== "complete";
|
|
1088
|
+
emitMessage(reasoningMessage);
|
|
1089
|
+
} else if (payloadType === "reason_complete") {
|
|
1090
|
+
const reasoningId =
|
|
1091
|
+
resolveReasoningId(payload, false) ??
|
|
1092
|
+
resolveReasoningId(payload, true) ??
|
|
1093
|
+
`reason-${nextSequence()}`;
|
|
1094
|
+
const reasoningMessage = reasoningMessages.get(reasoningId);
|
|
1095
|
+
if (reasoningMessage?.reasoning) {
|
|
1096
|
+
reasoningMessage.reasoning.status = "complete";
|
|
1097
|
+
reasoningMessage.reasoning.completedAt = resolveTimestamp(
|
|
1098
|
+
payload.completedAt ?? payload.timestamp
|
|
1099
|
+
);
|
|
1100
|
+
const start = reasoningMessage.reasoning.startedAt ?? Date.now();
|
|
1101
|
+
reasoningMessage.reasoning.durationMs = Math.max(
|
|
1102
|
+
0,
|
|
1103
|
+
(reasoningMessage.reasoning.completedAt ?? Date.now()) - start
|
|
1104
|
+
);
|
|
1105
|
+
reasoningMessage.streaming = false;
|
|
1106
|
+
emitMessage(reasoningMessage);
|
|
1107
|
+
}
|
|
1108
|
+
const stepKey = getStepKey(payload);
|
|
1109
|
+
if (stepKey) {
|
|
1110
|
+
reasoningContext.byStep.delete(stepKey);
|
|
1111
|
+
}
|
|
1112
|
+
} else if (payloadType === "tool_start") {
|
|
1113
|
+
const toolId =
|
|
1114
|
+
resolveToolId(payload, true) ?? `tool-${nextSequence()}`;
|
|
1115
|
+
const toolMessage = ensureToolMessage(toolId);
|
|
1116
|
+
const tool = toolMessage.toolCall ?? {
|
|
1117
|
+
id: toolId,
|
|
1118
|
+
status: "pending"
|
|
1119
|
+
};
|
|
1120
|
+
tool.name = payload.toolName ?? tool.name;
|
|
1121
|
+
tool.status = "running";
|
|
1122
|
+
if (payload.args !== undefined) {
|
|
1123
|
+
tool.args = payload.args;
|
|
1124
|
+
}
|
|
1125
|
+
tool.startedAt =
|
|
1126
|
+
tool.startedAt ??
|
|
1127
|
+
resolveTimestamp(payload.startedAt ?? payload.timestamp);
|
|
1128
|
+
tool.completedAt = undefined;
|
|
1129
|
+
tool.durationMs = undefined;
|
|
1130
|
+
toolMessage.toolCall = tool;
|
|
1131
|
+
toolMessage.streaming = true;
|
|
1132
|
+
emitMessage(toolMessage);
|
|
1133
|
+
} else if (payloadType === "tool_chunk") {
|
|
1134
|
+
const toolId =
|
|
1135
|
+
resolveToolId(payload, false) ??
|
|
1136
|
+
resolveToolId(payload, true) ??
|
|
1137
|
+
`tool-${nextSequence()}`;
|
|
1138
|
+
const toolMessage = ensureToolMessage(toolId);
|
|
1139
|
+
const tool = toolMessage.toolCall ?? {
|
|
1140
|
+
id: toolId,
|
|
1141
|
+
status: "running"
|
|
1142
|
+
};
|
|
1143
|
+
tool.startedAt =
|
|
1144
|
+
tool.startedAt ??
|
|
1145
|
+
resolveTimestamp(payload.startedAt ?? payload.timestamp);
|
|
1146
|
+
const chunkText =
|
|
1147
|
+
payload.text ?? payload.delta ?? payload.message ?? "";
|
|
1148
|
+
if (chunkText) {
|
|
1149
|
+
tool.chunks = tool.chunks ?? [];
|
|
1150
|
+
tool.chunks.push(String(chunkText));
|
|
1151
|
+
}
|
|
1152
|
+
tool.status = "running";
|
|
1153
|
+
toolMessage.toolCall = tool;
|
|
1154
|
+
toolMessage.streaming = true;
|
|
1155
|
+
emitMessage(toolMessage);
|
|
1156
|
+
} else if (payloadType === "tool_complete") {
|
|
1157
|
+
const toolId =
|
|
1158
|
+
resolveToolId(payload, false) ??
|
|
1159
|
+
resolveToolId(payload, true) ??
|
|
1160
|
+
`tool-${nextSequence()}`;
|
|
1161
|
+
const toolMessage = ensureToolMessage(toolId);
|
|
1162
|
+
const tool = toolMessage.toolCall ?? {
|
|
1163
|
+
id: toolId,
|
|
1164
|
+
status: "running"
|
|
1165
|
+
};
|
|
1166
|
+
tool.status = "complete";
|
|
1167
|
+
if (payload.result !== undefined) {
|
|
1168
|
+
tool.result = payload.result;
|
|
1169
|
+
}
|
|
1170
|
+
if (typeof payload.duration === "number") {
|
|
1171
|
+
tool.duration = payload.duration;
|
|
1172
|
+
}
|
|
1173
|
+
tool.completedAt = resolveTimestamp(
|
|
1174
|
+
payload.completedAt ?? payload.timestamp
|
|
1175
|
+
);
|
|
1176
|
+
if (typeof payload.duration === "number") {
|
|
1177
|
+
tool.durationMs = payload.duration;
|
|
1178
|
+
} else {
|
|
1179
|
+
const start = tool.startedAt ?? Date.now();
|
|
1180
|
+
tool.durationMs = Math.max(
|
|
1181
|
+
0,
|
|
1182
|
+
(tool.completedAt ?? Date.now()) - start
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
toolMessage.toolCall = tool;
|
|
1186
|
+
toolMessage.streaming = false;
|
|
1187
|
+
emitMessage(toolMessage);
|
|
1188
|
+
const callKey = getToolCallKey(payload);
|
|
1189
|
+
if (callKey) {
|
|
1190
|
+
toolContext.byCall.delete(callKey);
|
|
1191
|
+
}
|
|
1192
|
+
} else if (payloadType === "step_chunk") {
|
|
1193
|
+
// Only process chunks for prompt steps, not tool/context steps
|
|
1194
|
+
const stepType = (payload as any).stepType;
|
|
1195
|
+
const executionType = (payload as any).executionType;
|
|
1196
|
+
if (stepType === "tool" || executionType === "context") {
|
|
1197
|
+
// Skip tool-related chunks - they're handled by tool_start/tool_complete
|
|
1198
|
+
continue;
|
|
1199
|
+
}
|
|
1200
|
+
const assistant = ensureAssistantMessage();
|
|
1201
|
+
// Support various field names: text, delta, content, chunk (Travrse uses 'chunk')
|
|
1202
|
+
const chunk = payload.text ?? payload.delta ?? payload.content ?? payload.chunk ?? "";
|
|
1203
|
+
if (chunk) {
|
|
1204
|
+
// Accumulate raw content for structured format parsing
|
|
1205
|
+
const rawBuffer = rawContentBuffers.get(assistant.id) ?? "";
|
|
1206
|
+
const accumulatedRaw = rawBuffer + chunk;
|
|
1207
|
+
// Store raw content for action parsing, but NEVER set assistant.content to raw JSON
|
|
1208
|
+
assistant.rawContent = accumulatedRaw;
|
|
1209
|
+
|
|
1210
|
+
// Use stream parser to parse
|
|
1211
|
+
if (!streamParsers.has(assistant.id)) {
|
|
1212
|
+
streamParsers.set(assistant.id, this.createStreamParser());
|
|
1213
|
+
}
|
|
1214
|
+
const parser = streamParsers.get(assistant.id)!;
|
|
1215
|
+
|
|
1216
|
+
// Check if content looks like JSON
|
|
1217
|
+
const looksLikeJson = accumulatedRaw.trim().startsWith('{') || accumulatedRaw.trim().startsWith('[');
|
|
1218
|
+
|
|
1219
|
+
// Store raw buffer before processing (needed for step_complete handler)
|
|
1220
|
+
if (looksLikeJson) {
|
|
1221
|
+
rawContentBuffers.set(assistant.id, accumulatedRaw);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Check if this is a plain text parser (marked with __isPlainTextParser)
|
|
1225
|
+
const isPlainTextParser = (parser as any).__isPlainTextParser === true;
|
|
1226
|
+
|
|
1227
|
+
// If plain text parser, just append the chunk directly
|
|
1228
|
+
if (isPlainTextParser) {
|
|
1229
|
+
assistant.content += chunk;
|
|
1230
|
+
// Clear any raw buffer/parser since we're in plain text mode
|
|
1231
|
+
rawContentBuffers.delete(assistant.id);
|
|
1232
|
+
streamParsers.delete(assistant.id);
|
|
1233
|
+
assistant.rawContent = undefined;
|
|
1234
|
+
emitMessage(assistant);
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
// Try to parse with the parser (for structured parsers)
|
|
1239
|
+
const parsedResult = parser.processChunk(accumulatedRaw);
|
|
1240
|
+
|
|
1241
|
+
// Handle async parser result
|
|
1242
|
+
if (parsedResult instanceof Promise) {
|
|
1243
|
+
parsedResult.then((result) => {
|
|
1244
|
+
// Extract text from result (could be string or object)
|
|
1245
|
+
const text = typeof result === 'string' ? result : result?.text ?? null;
|
|
1246
|
+
|
|
1247
|
+
if (text !== null && text.trim() !== "") {
|
|
1248
|
+
// Parser successfully extracted text
|
|
1249
|
+
// Update the message content with extracted text
|
|
1250
|
+
const currentAssistant = assistantMessage;
|
|
1251
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
1252
|
+
currentAssistant.content = text;
|
|
1253
|
+
emitMessage(currentAssistant);
|
|
1254
|
+
}
|
|
1255
|
+
} else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
|
|
1256
|
+
// Not a structured format - show as plain text
|
|
1257
|
+
const currentAssistant = assistantMessage;
|
|
1258
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
1259
|
+
currentAssistant.content += chunk;
|
|
1260
|
+
rawContentBuffers.delete(currentAssistant.id);
|
|
1261
|
+
streamParsers.delete(currentAssistant.id);
|
|
1262
|
+
currentAssistant.rawContent = undefined;
|
|
1263
|
+
emitMessage(currentAssistant);
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
// Otherwise wait for more chunks (incomplete structured format)
|
|
1267
|
+
// Don't emit message if parser hasn't extracted text yet
|
|
1268
|
+
}).catch(() => {
|
|
1269
|
+
// On error, treat as plain text
|
|
1270
|
+
assistant.content += chunk;
|
|
1271
|
+
rawContentBuffers.delete(assistant.id);
|
|
1272
|
+
streamParsers.delete(assistant.id);
|
|
1273
|
+
assistant.rawContent = undefined;
|
|
1274
|
+
emitMessage(assistant);
|
|
1275
|
+
});
|
|
1276
|
+
} else {
|
|
1277
|
+
// Synchronous parser result
|
|
1278
|
+
// Extract text from result (could be string, null, or object)
|
|
1279
|
+
const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
|
|
1280
|
+
|
|
1281
|
+
if (text !== null && text.trim() !== "") {
|
|
1282
|
+
// Parser successfully extracted text
|
|
1283
|
+
// Buffer is already set above
|
|
1284
|
+
assistant.content = text;
|
|
1285
|
+
emitMessage(assistant);
|
|
1286
|
+
} else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
|
|
1287
|
+
// Not a structured format - show as plain text
|
|
1288
|
+
assistant.content += chunk;
|
|
1289
|
+
// Clear any raw buffer/parser if we were in structured format mode
|
|
1290
|
+
rawContentBuffers.delete(assistant.id);
|
|
1291
|
+
streamParsers.delete(assistant.id);
|
|
1292
|
+
assistant.rawContent = undefined;
|
|
1293
|
+
emitMessage(assistant);
|
|
1294
|
+
}
|
|
1295
|
+
// Otherwise wait for more chunks (incomplete structured format)
|
|
1296
|
+
// Don't emit message if parser hasn't extracted text yet
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// IMPORTANT: Don't call getExtractedText() and emit messages here
|
|
1300
|
+
// This was causing raw JSON to be displayed because getExtractedText()
|
|
1301
|
+
// wasn't extracting the "text" field correctly during streaming
|
|
1302
|
+
}
|
|
1303
|
+
if (payload.isComplete) {
|
|
1304
|
+
const finalContent = payload.result?.response ?? assistant.content;
|
|
1305
|
+
if (finalContent) {
|
|
1306
|
+
// Check if we have raw content buffer that needs final processing
|
|
1307
|
+
const rawBuffer = rawContentBuffers.get(assistant.id);
|
|
1308
|
+
const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
|
|
1309
|
+
assistant.rawContent = contentToProcess;
|
|
1310
|
+
|
|
1311
|
+
// Try to extract text from final structured content
|
|
1312
|
+
const parser = streamParsers.get(assistant.id);
|
|
1313
|
+
let extractedText: string | null = null;
|
|
1314
|
+
let asyncPending = false;
|
|
1315
|
+
|
|
1316
|
+
if (parser) {
|
|
1317
|
+
// First check if parser already has extracted text
|
|
1318
|
+
extractedText = parser.getExtractedText();
|
|
1319
|
+
|
|
1320
|
+
if (extractedText === null) {
|
|
1321
|
+
// Try extracting with regex
|
|
1322
|
+
extractedText = extractTextFromJson(contentToProcess);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
if (extractedText === null) {
|
|
1326
|
+
// Try parser.processChunk as last resort
|
|
1327
|
+
const parsedResult = parser.processChunk(contentToProcess);
|
|
1328
|
+
if (parsedResult instanceof Promise) {
|
|
1329
|
+
asyncPending = true;
|
|
1330
|
+
parsedResult.then((result) => {
|
|
1331
|
+
// Extract text from result (could be string or object)
|
|
1332
|
+
const text = typeof result === 'string' ? result : result?.text ?? null;
|
|
1333
|
+
if (text !== null) {
|
|
1334
|
+
const currentAssistant = assistantMessage;
|
|
1335
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
1336
|
+
currentAssistant.content = text;
|
|
1337
|
+
currentAssistant.streaming = false;
|
|
1338
|
+
// Clean up
|
|
1339
|
+
streamParsers.delete(currentAssistant.id);
|
|
1340
|
+
rawContentBuffers.delete(currentAssistant.id);
|
|
1341
|
+
emitMessage(currentAssistant);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
} else {
|
|
1346
|
+
// Extract text from synchronous result
|
|
1347
|
+
extractedText = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
// Skip sync emit if we're waiting on async parser
|
|
1353
|
+
if (!asyncPending) {
|
|
1354
|
+
// Set content: use extracted text if available, otherwise use raw content
|
|
1355
|
+
if (extractedText !== null && extractedText.trim() !== "") {
|
|
1356
|
+
assistant.content = extractedText;
|
|
1357
|
+
} else if (!rawContentBuffers.has(assistant.id)) {
|
|
1358
|
+
// Only use raw final content if we didn't accumulate chunks
|
|
1359
|
+
assistant.content = ensureStringContent(finalContent);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Clean up parser and buffer
|
|
1363
|
+
const parserToClose = streamParsers.get(assistant.id);
|
|
1364
|
+
if (parserToClose) {
|
|
1365
|
+
const closeResult = parserToClose.close?.();
|
|
1366
|
+
if (closeResult instanceof Promise) {
|
|
1367
|
+
closeResult.catch(() => {});
|
|
1368
|
+
}
|
|
1369
|
+
streamParsers.delete(assistant.id);
|
|
1370
|
+
}
|
|
1371
|
+
rawContentBuffers.delete(assistant.id);
|
|
1372
|
+
assistant.streaming = false;
|
|
1373
|
+
emitMessage(assistant);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
} else if (payloadType === "step_complete") {
|
|
1378
|
+
// Only process completions for prompt steps, not tool/context steps
|
|
1379
|
+
const stepType = (payload as any).stepType;
|
|
1380
|
+
const executionType = (payload as any).executionType;
|
|
1381
|
+
if (stepType === "tool" || executionType === "context") {
|
|
1382
|
+
// Skip tool-related completions - they're handled by tool_complete
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
const finalContent = payload.result?.response;
|
|
1386
|
+
const assistant = ensureAssistantMessage();
|
|
1387
|
+
if (finalContent !== undefined && finalContent !== null) {
|
|
1388
|
+
// Check if we already have extracted text from streaming
|
|
1389
|
+
const parser = streamParsers.get(assistant.id);
|
|
1390
|
+
let hasExtractedText = false;
|
|
1391
|
+
let asyncPending = false;
|
|
1392
|
+
|
|
1393
|
+
if (parser) {
|
|
1394
|
+
// First check if parser already extracted text during streaming
|
|
1395
|
+
const currentExtractedText = parser.getExtractedText();
|
|
1396
|
+
const rawBuffer = rawContentBuffers.get(assistant.id);
|
|
1397
|
+
const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
|
|
1398
|
+
|
|
1399
|
+
// Always set rawContent so action parsers can access the raw JSON
|
|
1400
|
+
assistant.rawContent = contentToProcess;
|
|
1401
|
+
|
|
1402
|
+
if (currentExtractedText !== null && currentExtractedText.trim() !== "") {
|
|
1403
|
+
// We already have extracted text from streaming - use it
|
|
1404
|
+
assistant.content = currentExtractedText;
|
|
1405
|
+
hasExtractedText = true;
|
|
1406
|
+
} else {
|
|
1407
|
+
// No extracted text yet - try to extract from final content
|
|
1408
|
+
|
|
1409
|
+
// Try fast path first
|
|
1410
|
+
const extractedText = extractTextFromJson(contentToProcess);
|
|
1411
|
+
if (extractedText !== null) {
|
|
1412
|
+
assistant.content = extractedText;
|
|
1413
|
+
hasExtractedText = true;
|
|
1414
|
+
} else {
|
|
1415
|
+
// Try parser
|
|
1416
|
+
const parsedResult = parser.processChunk(contentToProcess);
|
|
1417
|
+
if (parsedResult instanceof Promise) {
|
|
1418
|
+
asyncPending = true;
|
|
1419
|
+
parsedResult.then((result) => {
|
|
1420
|
+
// Extract text from result (could be string or object)
|
|
1421
|
+
const text = typeof result === 'string' ? result : result?.text ?? null;
|
|
1422
|
+
|
|
1423
|
+
if (text !== null && text.trim() !== "") {
|
|
1424
|
+
const currentAssistant = assistantMessage;
|
|
1425
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
1426
|
+
currentAssistant.content = text;
|
|
1427
|
+
currentAssistant.streaming = false;
|
|
1428
|
+
// Clean up
|
|
1429
|
+
streamParsers.delete(currentAssistant.id);
|
|
1430
|
+
rawContentBuffers.delete(currentAssistant.id);
|
|
1431
|
+
emitMessage(currentAssistant);
|
|
1432
|
+
}
|
|
1433
|
+
} else {
|
|
1434
|
+
// No extracted text - check if we should show raw content
|
|
1435
|
+
const finalExtractedText = parser.getExtractedText();
|
|
1436
|
+
const currentAssistant = assistantMessage;
|
|
1437
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
1438
|
+
if (finalExtractedText !== null && finalExtractedText.trim() !== "") {
|
|
1439
|
+
currentAssistant.content = finalExtractedText;
|
|
1440
|
+
} else if (!rawContentBuffers.has(currentAssistant.id)) {
|
|
1441
|
+
// Only show raw content if we never had any extracted text
|
|
1442
|
+
currentAssistant.content = ensureStringContent(finalContent);
|
|
1443
|
+
}
|
|
1444
|
+
currentAssistant.streaming = false;
|
|
1445
|
+
// Clean up
|
|
1446
|
+
streamParsers.delete(currentAssistant.id);
|
|
1447
|
+
rawContentBuffers.delete(currentAssistant.id);
|
|
1448
|
+
emitMessage(currentAssistant);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
} else {
|
|
1453
|
+
// Extract text from synchronous result
|
|
1454
|
+
const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
|
|
1455
|
+
|
|
1456
|
+
if (text !== null && text.trim() !== "") {
|
|
1457
|
+
assistant.content = text;
|
|
1458
|
+
hasExtractedText = true;
|
|
1459
|
+
} else {
|
|
1460
|
+
// Check stub one more time
|
|
1461
|
+
const finalExtractedText = parser.getExtractedText();
|
|
1462
|
+
if (finalExtractedText !== null && finalExtractedText.trim() !== "") {
|
|
1463
|
+
assistant.content = finalExtractedText;
|
|
1464
|
+
hasExtractedText = true;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
// Skip sync emit if we're waiting on async parser
|
|
1473
|
+
if (!asyncPending) {
|
|
1474
|
+
// Ensure rawContent is set even if there's no parser (for action parsing)
|
|
1475
|
+
if (!assistant.rawContent) {
|
|
1476
|
+
const rawBuffer = rawContentBuffers.get(assistant.id);
|
|
1477
|
+
assistant.rawContent = rawBuffer ?? ensureStringContent(finalContent);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// Only show raw content if we never extracted any text and no buffer was used
|
|
1481
|
+
if (!hasExtractedText && !rawContentBuffers.has(assistant.id)) {
|
|
1482
|
+
// No extracted text and no streaming happened - show raw content
|
|
1483
|
+
assistant.content = ensureStringContent(finalContent);
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// Clean up parser and buffer
|
|
1487
|
+
if (parser) {
|
|
1488
|
+
const closeResult = parser.close?.();
|
|
1489
|
+
if (closeResult instanceof Promise) {
|
|
1490
|
+
closeResult.catch(() => {});
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
streamParsers.delete(assistant.id);
|
|
1494
|
+
rawContentBuffers.delete(assistant.id);
|
|
1495
|
+
assistant.streaming = false;
|
|
1496
|
+
emitMessage(assistant);
|
|
1497
|
+
}
|
|
1498
|
+
} else {
|
|
1499
|
+
// No final content, just mark as complete and clean up
|
|
1500
|
+
streamParsers.delete(assistant.id);
|
|
1501
|
+
rawContentBuffers.delete(assistant.id);
|
|
1502
|
+
assistant.streaming = false;
|
|
1503
|
+
emitMessage(assistant);
|
|
1504
|
+
}
|
|
1505
|
+
} else if (payloadType === "flow_complete") {
|
|
1506
|
+
const finalContent = payload.result?.response;
|
|
1507
|
+
if (finalContent !== undefined && finalContent !== null) {
|
|
1508
|
+
const assistant = ensureAssistantMessage();
|
|
1509
|
+
// Check if we have raw content buffer that needs final processing
|
|
1510
|
+
const rawBuffer = rawContentBuffers.get(assistant.id);
|
|
1511
|
+
const stringContent = rawBuffer ?? ensureStringContent(finalContent);
|
|
1512
|
+
assistant.rawContent = stringContent;
|
|
1513
|
+
// Try to extract text from structured content
|
|
1514
|
+
let displayContent = ensureStringContent(finalContent);
|
|
1515
|
+
const parser = streamParsers.get(assistant.id);
|
|
1516
|
+
if (parser) {
|
|
1517
|
+
const extractedText = extractTextFromJson(stringContent);
|
|
1518
|
+
if (extractedText !== null) {
|
|
1519
|
+
displayContent = extractedText;
|
|
1520
|
+
} else {
|
|
1521
|
+
// Try parser if it exists
|
|
1522
|
+
const parsedResult = parser.processChunk(stringContent);
|
|
1523
|
+
if (parsedResult instanceof Promise) {
|
|
1524
|
+
parsedResult.then((result) => {
|
|
1525
|
+
// Extract text from result (could be string or object)
|
|
1526
|
+
const text = typeof result === 'string' ? result : result?.text ?? null;
|
|
1527
|
+
if (text !== null) {
|
|
1528
|
+
const currentAssistant = assistantMessage;
|
|
1529
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
1530
|
+
currentAssistant.content = text;
|
|
1531
|
+
currentAssistant.streaming = false;
|
|
1532
|
+
emitMessage(currentAssistant);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
});
|
|
1536
|
+
}
|
|
1537
|
+
const currentText = parser.getExtractedText();
|
|
1538
|
+
if (currentText !== null) {
|
|
1539
|
+
displayContent = currentText;
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
// Clean up parser and buffer
|
|
1544
|
+
streamParsers.delete(assistant.id);
|
|
1545
|
+
rawContentBuffers.delete(assistant.id);
|
|
1546
|
+
|
|
1547
|
+
// Only emit if something actually changed to avoid flicker
|
|
1548
|
+
const contentChanged = displayContent !== assistant.content;
|
|
1549
|
+
const streamingChanged = assistant.streaming !== false;
|
|
1550
|
+
|
|
1551
|
+
if (contentChanged) {
|
|
1552
|
+
assistant.content = displayContent;
|
|
1553
|
+
}
|
|
1554
|
+
assistant.streaming = false;
|
|
1555
|
+
|
|
1556
|
+
// Only emit if content or streaming state changed
|
|
1557
|
+
if (contentChanged || streamingChanged) {
|
|
1558
|
+
emitMessage(assistant);
|
|
1559
|
+
}
|
|
1560
|
+
} else {
|
|
1561
|
+
// No final content, just mark as complete and clean up
|
|
1562
|
+
if (assistantMessage !== null) {
|
|
1563
|
+
// Clean up any remaining parsers/buffers
|
|
1564
|
+
// TypeScript narrowing issue - assistantMessage is checked for null above
|
|
1565
|
+
const msg: AgentWidgetMessage = assistantMessage;
|
|
1566
|
+
streamParsers.delete(msg.id);
|
|
1567
|
+
rawContentBuffers.delete(msg.id);
|
|
1568
|
+
|
|
1569
|
+
// Only emit if streaming state changed
|
|
1570
|
+
if (msg.streaming !== false) {
|
|
1571
|
+
msg.streaming = false;
|
|
1572
|
+
emitMessage(msg);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
onEvent({ type: "status", status: "idle" });
|
|
1577
|
+
} else if (payloadType === "error" && payload.error) {
|
|
1578
|
+
onEvent({
|
|
1579
|
+
type: "error",
|
|
1580
|
+
error:
|
|
1581
|
+
payload.error instanceof Error
|
|
1582
|
+
? payload.error
|
|
1583
|
+
: new Error(String(payload.error))
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
}
|