vanilla-agent 1.3.0 → 1.4.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 +219 -3
- package/dist/index.cjs +7 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -1
- package/dist/index.d.ts +131 -1
- package/dist/index.global.js +51 -48
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +7 -5
- package/dist/index.js.map +1 -1
- package/dist/widget.css +21 -0
- package/package.json +4 -2
- package/src/client.test.ts +197 -0
- package/src/client.ts +330 -15
- package/src/components/forms.ts +2 -0
- package/src/components/message-bubble.ts +9 -4
- package/src/components/messages.ts +5 -3
- package/src/components/panel.ts +1 -0
- package/src/components/reasoning-bubble.ts +26 -8
- package/src/components/tool-bubble.ts +139 -22
- package/src/index.ts +9 -1
- package/src/plugins/registry.ts +2 -0
- package/src/plugins/types.ts +2 -0
- package/src/runtime/init.ts +4 -1
- package/src/session.ts +4 -0
- package/src/styles/widget.css +21 -0
- package/src/types.ts +107 -0
- package/src/ui.ts +145 -5
- package/src/utils/constants.ts +2 -0
- package/src/utils/dom.ts +2 -0
- package/src/utils/formatting.test.ts +160 -0
- package/src/utils/formatting.ts +252 -1
- package/src/utils/positioning.ts +2 -0
- package/src/utils/theme.ts +2 -0
package/dist/widget.css
CHANGED
|
@@ -56,6 +56,14 @@
|
|
|
56
56
|
justify-content: center;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
.tvw-justify-between {
|
|
60
|
+
justify-content: space-between;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.tvw-justify-end {
|
|
64
|
+
justify-content: flex-end;
|
|
65
|
+
}
|
|
66
|
+
|
|
59
67
|
.tvw-gap-1 {
|
|
60
68
|
gap: 0.25rem;
|
|
61
69
|
}
|
|
@@ -160,6 +168,10 @@
|
|
|
160
168
|
background-color: var(--cw-primary, #111827);
|
|
161
169
|
}
|
|
162
170
|
|
|
171
|
+
.tvw-italic {
|
|
172
|
+
font-style: italic;
|
|
173
|
+
}
|
|
174
|
+
|
|
163
175
|
.tvw-text-base {
|
|
164
176
|
font-size: 1rem;
|
|
165
177
|
line-height: 1.5rem;
|
|
@@ -823,3 +835,12 @@ form:focus-within textarea {
|
|
|
823
835
|
margin: 0.5rem 0;
|
|
824
836
|
border-radius: 0.375rem;
|
|
825
837
|
}
|
|
838
|
+
|
|
839
|
+
/* Ensure links in user messages match the text color */
|
|
840
|
+
#vanilla-agent-root .tvw-text-white a,
|
|
841
|
+
#vanilla-agent-root .tvw-text-white a:visited,
|
|
842
|
+
#vanilla-agent-root .tvw-text-white a:hover,
|
|
843
|
+
#vanilla-agent-root .tvw-text-white a:active {
|
|
844
|
+
color: inherit;
|
|
845
|
+
text-decoration: underline;
|
|
846
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vanilla-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Themeable, plugable streaming agent widget for websites, in plain JS with support for voice input and reasoning / tool output.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -24,7 +24,9 @@
|
|
|
24
24
|
],
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"lucide": "^0.552.0",
|
|
27
|
-
"marked": "^12.0.2"
|
|
27
|
+
"marked": "^12.0.2",
|
|
28
|
+
"partial-json": "^0.1.7",
|
|
29
|
+
"zod": "^3.22.4"
|
|
28
30
|
},
|
|
29
31
|
"devDependencies": {
|
|
30
32
|
"@types/node": "^20.12.7",
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { AgentWidgetClient } from './client';
|
|
3
|
+
import { AgentWidgetEvent, AgentWidgetMessage } from './types';
|
|
4
|
+
import { createJsonStreamParser } from './utils/formatting';
|
|
5
|
+
|
|
6
|
+
describe('AgentWidgetClient - JSON Streaming', () => {
|
|
7
|
+
let client: AgentWidgetClient;
|
|
8
|
+
let events: AgentWidgetEvent[] = [];
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
events = [];
|
|
12
|
+
client = new AgentWidgetClient({
|
|
13
|
+
apiUrl: 'http://localhost:8000',
|
|
14
|
+
streamParser: createJsonStreamParser
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('should stream text incrementally and not show raw JSON at the end', async () => {
|
|
19
|
+
// Simulate the SSE stream from the user's example
|
|
20
|
+
const sseEvents = [
|
|
21
|
+
'data: {"type":"flow_start","flowId":"flow_01k9pfnztzfag9tfz4t65c9c5q","flowName":"Shopping Assistant","totalSteps":1,"startedAt":"2025-11-12T23:47:39.565Z","executionId":"exec_standalone_1762991259266_7wz736k7n","executionContext":{"source":"standalone","record":{"id":"-1","name":"Streaming Chat Widget","created":false},"flow":{"id":"flow_01k9pfnztzfag9tfz4t65c9c5q","name":"Shopping Assistant","created":false}}}',
|
|
22
|
+
'',
|
|
23
|
+
'data: {"type":"step_start","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","stepType":"prompt","index":1,"totalSteps":1,"startedAt":"2025-11-12T23:47:39.565Z"}',
|
|
24
|
+
'',
|
|
25
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"{\n"}',
|
|
26
|
+
'',
|
|
27
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" "}',
|
|
28
|
+
'',
|
|
29
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" \\""}',
|
|
30
|
+
'',
|
|
31
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"action"}',
|
|
32
|
+
'',
|
|
33
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"\\":"}',
|
|
34
|
+
'',
|
|
35
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" \\""}',
|
|
36
|
+
'',
|
|
37
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"message"}',
|
|
38
|
+
'',
|
|
39
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"\\",\\n"}',
|
|
40
|
+
'',
|
|
41
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" "}',
|
|
42
|
+
'',
|
|
43
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" \\""}',
|
|
44
|
+
'',
|
|
45
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"text"}',
|
|
46
|
+
'',
|
|
47
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"\\":"}',
|
|
48
|
+
'',
|
|
49
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" \\""}',
|
|
50
|
+
'',
|
|
51
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"Great"}',
|
|
52
|
+
'',
|
|
53
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"!"}',
|
|
54
|
+
'',
|
|
55
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" If"}',
|
|
56
|
+
'',
|
|
57
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" you"}',
|
|
58
|
+
'',
|
|
59
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" have"}',
|
|
60
|
+
'',
|
|
61
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" any"}',
|
|
62
|
+
'',
|
|
63
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" questions"}',
|
|
64
|
+
'',
|
|
65
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" or"}',
|
|
66
|
+
'',
|
|
67
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" need"}',
|
|
68
|
+
'',
|
|
69
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" help"}',
|
|
70
|
+
'',
|
|
71
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" finding"}',
|
|
72
|
+
'',
|
|
73
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" something"}',
|
|
74
|
+
'',
|
|
75
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":","}',
|
|
76
|
+
'',
|
|
77
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" just"}',
|
|
78
|
+
'',
|
|
79
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" let"}',
|
|
80
|
+
'',
|
|
81
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" me"}',
|
|
82
|
+
'',
|
|
83
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":" know"}',
|
|
84
|
+
'',
|
|
85
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"!\\"\\n"}',
|
|
86
|
+
'',
|
|
87
|
+
'data: {"type":"step_chunk","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":2,"text":"}"}',
|
|
88
|
+
'',
|
|
89
|
+
'data: {"type":"step_complete","id":"step_01k9x5db72fzwvmdenryn0qm48","name":"Prompt 1","executionType":"prompt","index":1,"success":true,"result":{"promptId":"step_01k9x5db72fzwvmdenryn0qm48","promptName":"Prompt 1","processedPrompt":"ok","response":"{\\"\\n \\"action\\": \\"message\\",\\n \\"text\\": \\"Great! If you have any questions or need help finding something, just let me know!\\"\\n}","tokens":{"input":1833,"output":34,"total":1867},"cost":0.000700125,"executionTime":2222,"order":2},"executionTime":2222}',
|
|
90
|
+
'',
|
|
91
|
+
'data: {"type":"flow_complete","flowId":"flow_01k9pfnztzfag9tfz4t65c9c5q","success":true,"duration":2968,"completedAt":"2025-11-12T23:47:42.234Z","totalTokensUsed":0}'
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
// Create a ReadableStream from the SSE events
|
|
95
|
+
const encoder = new TextEncoder();
|
|
96
|
+
const stream = new ReadableStream({
|
|
97
|
+
start(controller) {
|
|
98
|
+
for (const event of sseEvents) {
|
|
99
|
+
controller.enqueue(encoder.encode(event + '\n'));
|
|
100
|
+
}
|
|
101
|
+
controller.close();
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Mock fetch to return our stream
|
|
106
|
+
global.fetch = async () => ({
|
|
107
|
+
ok: true,
|
|
108
|
+
body: stream
|
|
109
|
+
}) as any;
|
|
110
|
+
|
|
111
|
+
// Dispatch and collect events
|
|
112
|
+
await client.dispatch(
|
|
113
|
+
{
|
|
114
|
+
messages: [{ role: 'user', content: 'ok' }]
|
|
115
|
+
},
|
|
116
|
+
(event) => {
|
|
117
|
+
events.push(event);
|
|
118
|
+
if (event.type === 'message') {
|
|
119
|
+
console.log('Message event:', {
|
|
120
|
+
content: event.message.content,
|
|
121
|
+
streaming: event.message.streaming,
|
|
122
|
+
contentLength: event.message.content.length
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Filter for assistant message events
|
|
129
|
+
const messageEvents = events.filter(
|
|
130
|
+
(e) => e.type === 'message' && e.message.role === 'assistant'
|
|
131
|
+
) as Extract<AgentWidgetEvent, { type: 'message' }>[];
|
|
132
|
+
|
|
133
|
+
// Validate behavior
|
|
134
|
+
expect(messageEvents.length).toBeGreaterThan(0);
|
|
135
|
+
|
|
136
|
+
// 1. Check that text starts streaming incrementally (not all at once)
|
|
137
|
+
const streamingMessages = messageEvents.filter((e) => e.message.streaming);
|
|
138
|
+
expect(streamingMessages.length).toBeGreaterThan(1);
|
|
139
|
+
console.log(`Found ${streamingMessages.length} streaming message events`);
|
|
140
|
+
|
|
141
|
+
// 2. Check that text content appears progressively
|
|
142
|
+
let hasPartialText = false;
|
|
143
|
+
const expectedFinalText = "Great! If you have any questions or need help finding something, just let me know!";
|
|
144
|
+
|
|
145
|
+
for (const msgEvent of streamingMessages) {
|
|
146
|
+
const content = msgEvent.message.content;
|
|
147
|
+
|
|
148
|
+
// Should not contain raw JSON during streaming
|
|
149
|
+
if (content.includes('"action"') || content.includes('"text"')) {
|
|
150
|
+
console.error('Found raw JSON in streaming content:', content);
|
|
151
|
+
}
|
|
152
|
+
expect(content).not.toMatch(/"action"|"text":/);
|
|
153
|
+
|
|
154
|
+
// Check for partial text (text that's incomplete)
|
|
155
|
+
if (content.length > 0 && content.length < expectedFinalText.length) {
|
|
156
|
+
hasPartialText = true;
|
|
157
|
+
// Partial text should be a prefix of the final text
|
|
158
|
+
expect(expectedFinalText.startsWith(content)).toBe(true);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
expect(hasPartialText).toBe(true);
|
|
163
|
+
console.log('✓ Text streamed incrementally with partial values');
|
|
164
|
+
|
|
165
|
+
// 3. Check final message (streaming: false)
|
|
166
|
+
const finalMessages = messageEvents.filter((e) => !e.message.streaming);
|
|
167
|
+
expect(finalMessages.length).toBeGreaterThan(0);
|
|
168
|
+
|
|
169
|
+
const finalMessage = finalMessages[finalMessages.length - 1].message;
|
|
170
|
+
console.log('Final message content:', finalMessage.content);
|
|
171
|
+
|
|
172
|
+
// Final content should be ONLY the extracted text, not raw JSON
|
|
173
|
+
expect(finalMessage.content).toBe(expectedFinalText);
|
|
174
|
+
expect(finalMessage.content).not.toContain('"action"');
|
|
175
|
+
expect(finalMessage.content).not.toContain('"text"');
|
|
176
|
+
expect(finalMessage.content).not.toContain('{\n');
|
|
177
|
+
|
|
178
|
+
console.log('✓ Final message contains only extracted text, no raw JSON');
|
|
179
|
+
|
|
180
|
+
// 4. Verify no raw JSON was ever displayed
|
|
181
|
+
const allContents = messageEvents.map((e) => e.message.content);
|
|
182
|
+
const hasRawJson = allContents.some(
|
|
183
|
+
(content) => content.includes('{\n "action": "message"')
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
if (hasRawJson) {
|
|
187
|
+
const rawJsonMessage = allContents.find((content) =>
|
|
188
|
+
content.includes('{\n "action": "message"')
|
|
189
|
+
);
|
|
190
|
+
console.error('Found raw JSON in message content:', rawJsonMessage);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
expect(hasRawJson).toBe(false);
|
|
194
|
+
console.log('✓ No raw JSON was displayed at any point');
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
package/src/client.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { AgentWidgetConfig, AgentWidgetMessage, AgentWidgetEvent } from "./types";
|
|
1
|
+
import { AgentWidgetConfig, AgentWidgetMessage, AgentWidgetEvent, AgentWidgetStreamParser } from "./types";
|
|
2
|
+
import { extractTextFromJson, createPlainTextParser } from "./utils/formatting";
|
|
2
3
|
|
|
3
4
|
type DispatchOptions = {
|
|
4
5
|
messages: AgentWidgetMessage[];
|
|
@@ -13,6 +14,7 @@ export class AgentWidgetClient {
|
|
|
13
14
|
private readonly apiUrl: string;
|
|
14
15
|
private readonly headers: Record<string, string>;
|
|
15
16
|
private readonly debug: boolean;
|
|
17
|
+
private readonly createStreamParser: () => AgentWidgetStreamParser;
|
|
16
18
|
|
|
17
19
|
constructor(private config: AgentWidgetConfig = {}) {
|
|
18
20
|
this.apiUrl = config.apiUrl ?? DEFAULT_ENDPOINT;
|
|
@@ -21,6 +23,8 @@ export class AgentWidgetClient {
|
|
|
21
23
|
...config.headers
|
|
22
24
|
};
|
|
23
25
|
this.debug = Boolean(config.debug);
|
|
26
|
+
// Use custom stream parser from config, or fall back to plain text parser
|
|
27
|
+
this.createStreamParser = config.streamParser ?? createPlainTextParser;
|
|
24
28
|
}
|
|
25
29
|
|
|
26
30
|
public async dispatch(options: DispatchOptions, onEvent: SSEHandler) {
|
|
@@ -322,6 +326,26 @@ export class AgentWidgetClient {
|
|
|
322
326
|
return Date.now();
|
|
323
327
|
};
|
|
324
328
|
|
|
329
|
+
const ensureStringContent = (value: unknown): string => {
|
|
330
|
+
if (typeof value === "string") {
|
|
331
|
+
return value;
|
|
332
|
+
}
|
|
333
|
+
if (value === null || value === undefined) {
|
|
334
|
+
return "";
|
|
335
|
+
}
|
|
336
|
+
// Convert objects/arrays to JSON string
|
|
337
|
+
try {
|
|
338
|
+
return JSON.stringify(value);
|
|
339
|
+
} catch {
|
|
340
|
+
return String(value);
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// Maintain stateful stream parsers per message for incremental parsing
|
|
345
|
+
const streamParsers = new Map<string, AgentWidgetStreamParser>();
|
|
346
|
+
// Track accumulated raw content for structured formats (JSON, XML, etc.)
|
|
347
|
+
const rawContentBuffers = new Map<string, string>();
|
|
348
|
+
|
|
325
349
|
while (true) {
|
|
326
350
|
const { done, value } = await reader.read();
|
|
327
351
|
if (done) break;
|
|
@@ -517,48 +541,339 @@ export class AgentWidgetClient {
|
|
|
517
541
|
toolContext.byCall.delete(callKey);
|
|
518
542
|
}
|
|
519
543
|
} else if (payloadType === "step_chunk") {
|
|
544
|
+
// Only process chunks for prompt steps, not tool/context steps
|
|
545
|
+
const stepType = (payload as any).stepType;
|
|
546
|
+
const executionType = (payload as any).executionType;
|
|
547
|
+
if (stepType === "tool" || executionType === "context") {
|
|
548
|
+
// Skip tool-related chunks - they're handled by tool_start/tool_complete
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
520
551
|
const assistant = ensureAssistantMessage();
|
|
521
552
|
const chunk = payload.text ?? payload.delta ?? payload.content ?? "";
|
|
522
553
|
if (chunk) {
|
|
523
|
-
|
|
524
|
-
|
|
554
|
+
// Accumulate raw content for structured format parsing
|
|
555
|
+
const rawBuffer = rawContentBuffers.get(assistant.id) ?? "";
|
|
556
|
+
const accumulatedRaw = rawBuffer + chunk;
|
|
557
|
+
|
|
558
|
+
// Use stream parser to parse
|
|
559
|
+
if (!streamParsers.has(assistant.id)) {
|
|
560
|
+
streamParsers.set(assistant.id, this.createStreamParser());
|
|
561
|
+
}
|
|
562
|
+
const parser = streamParsers.get(assistant.id)!;
|
|
563
|
+
|
|
564
|
+
// Check if content looks like JSON
|
|
565
|
+
const looksLikeJson = accumulatedRaw.trim().startsWith('{') || accumulatedRaw.trim().startsWith('[');
|
|
566
|
+
|
|
567
|
+
// Store raw buffer before processing (needed for step_complete handler)
|
|
568
|
+
if (looksLikeJson) {
|
|
569
|
+
rawContentBuffers.set(assistant.id, accumulatedRaw);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Check if this is a plain text parser (marked with __isPlainTextParser)
|
|
573
|
+
const isPlainTextParser = (parser as any).__isPlainTextParser === true;
|
|
574
|
+
|
|
575
|
+
// If plain text parser, just append the chunk directly
|
|
576
|
+
if (isPlainTextParser) {
|
|
577
|
+
assistant.content += chunk;
|
|
578
|
+
// Clear any raw buffer/parser since we're in plain text mode
|
|
579
|
+
rawContentBuffers.delete(assistant.id);
|
|
580
|
+
streamParsers.delete(assistant.id);
|
|
581
|
+
emitMessage(assistant);
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Try to parse with the parser (for structured parsers)
|
|
586
|
+
const parsedResult = parser.processChunk(accumulatedRaw);
|
|
587
|
+
|
|
588
|
+
// Handle async parser result
|
|
589
|
+
if (parsedResult instanceof Promise) {
|
|
590
|
+
parsedResult.then((result) => {
|
|
591
|
+
// Extract text from result (could be string or object)
|
|
592
|
+
const text = typeof result === 'string' ? result : result?.text ?? null;
|
|
593
|
+
|
|
594
|
+
if (text !== null && text.trim() !== "") {
|
|
595
|
+
// Parser successfully extracted text
|
|
596
|
+
// Update the message content with extracted text
|
|
597
|
+
const currentAssistant = assistantMessage;
|
|
598
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
599
|
+
currentAssistant.content = text;
|
|
600
|
+
emitMessage(currentAssistant);
|
|
601
|
+
}
|
|
602
|
+
} else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
|
|
603
|
+
// Not a structured format - show as plain text
|
|
604
|
+
const currentAssistant = assistantMessage;
|
|
605
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
606
|
+
currentAssistant.content += chunk;
|
|
607
|
+
rawContentBuffers.delete(currentAssistant.id);
|
|
608
|
+
streamParsers.delete(currentAssistant.id);
|
|
609
|
+
emitMessage(currentAssistant);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// Otherwise wait for more chunks (incomplete structured format)
|
|
613
|
+
}).catch(() => {
|
|
614
|
+
// On error, treat as plain text
|
|
615
|
+
assistant.content += chunk;
|
|
616
|
+
rawContentBuffers.delete(assistant.id);
|
|
617
|
+
streamParsers.delete(assistant.id);
|
|
618
|
+
emitMessage(assistant);
|
|
619
|
+
});
|
|
620
|
+
} else {
|
|
621
|
+
// Synchronous parser result
|
|
622
|
+
// Extract text from result (could be string, null, or object)
|
|
623
|
+
const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
|
|
624
|
+
|
|
625
|
+
if (text !== null && text.trim() !== "") {
|
|
626
|
+
// Parser successfully extracted text
|
|
627
|
+
// Buffer is already set above
|
|
628
|
+
assistant.content = text;
|
|
629
|
+
emitMessage(assistant);
|
|
630
|
+
} else if (!looksLikeJson && !accumulatedRaw.trim().startsWith('<')) {
|
|
631
|
+
// Not a structured format - show as plain text
|
|
632
|
+
assistant.content += chunk;
|
|
633
|
+
// Clear any raw buffer/parser if we were in structured format mode
|
|
634
|
+
rawContentBuffers.delete(assistant.id);
|
|
635
|
+
streamParsers.delete(assistant.id);
|
|
636
|
+
emitMessage(assistant);
|
|
637
|
+
}
|
|
638
|
+
// Otherwise wait for more chunks (incomplete structured format)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Also check if we already have extracted text from previous chunks
|
|
642
|
+
const currentText = parser.getExtractedText();
|
|
643
|
+
if (currentText != null && currentText !== "" && currentText !== assistant.content) {
|
|
644
|
+
assistant.content = currentText;
|
|
645
|
+
emitMessage(assistant);
|
|
646
|
+
}
|
|
525
647
|
}
|
|
526
648
|
if (payload.isComplete) {
|
|
527
649
|
const finalContent = payload.result?.response ?? assistant.content;
|
|
528
650
|
if (finalContent) {
|
|
529
|
-
|
|
651
|
+
// Check if we have raw content buffer that needs final processing
|
|
652
|
+
const rawBuffer = rawContentBuffers.get(assistant.id);
|
|
653
|
+
const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
|
|
654
|
+
|
|
655
|
+
// Try to extract text from final structured content
|
|
656
|
+
const parser = streamParsers.get(assistant.id);
|
|
657
|
+
let extractedText: string | null = null;
|
|
658
|
+
|
|
659
|
+
if (parser) {
|
|
660
|
+
// First check if parser already has extracted text
|
|
661
|
+
extractedText = parser.getExtractedText();
|
|
662
|
+
|
|
663
|
+
if (extractedText === null) {
|
|
664
|
+
// Try extracting with regex
|
|
665
|
+
extractedText = extractTextFromJson(contentToProcess);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (extractedText === null) {
|
|
669
|
+
// Try parser.processChunk as last resort
|
|
670
|
+
const parsedResult = parser.processChunk(contentToProcess);
|
|
671
|
+
if (parsedResult instanceof Promise) {
|
|
672
|
+
parsedResult.then((result) => {
|
|
673
|
+
// Extract text from result (could be string or object)
|
|
674
|
+
const text = typeof result === 'string' ? result : result?.text ?? null;
|
|
675
|
+
if (text !== null) {
|
|
676
|
+
const currentAssistant = assistantMessage;
|
|
677
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
678
|
+
currentAssistant.content = text;
|
|
679
|
+
currentAssistant.streaming = false;
|
|
680
|
+
emitMessage(currentAssistant);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
} else {
|
|
685
|
+
// Extract text from synchronous result
|
|
686
|
+
extractedText = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Set content: use extracted text if available, otherwise use raw content
|
|
692
|
+
if (extractedText !== null && extractedText.trim() !== "") {
|
|
693
|
+
assistant.content = extractedText;
|
|
694
|
+
} else if (!rawContentBuffers.has(assistant.id)) {
|
|
695
|
+
// Only use raw final content if we didn't accumulate chunks
|
|
696
|
+
assistant.content = ensureStringContent(finalContent);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Clean up parser and buffer
|
|
700
|
+
const parserToClose = streamParsers.get(assistant.id);
|
|
701
|
+
if (parserToClose) {
|
|
702
|
+
const closeResult = parserToClose.close?.();
|
|
703
|
+
if (closeResult instanceof Promise) {
|
|
704
|
+
closeResult.catch(() => {});
|
|
705
|
+
}
|
|
706
|
+
streamParsers.delete(assistant.id);
|
|
707
|
+
}
|
|
708
|
+
rawContentBuffers.delete(assistant.id);
|
|
530
709
|
assistant.streaming = false;
|
|
531
710
|
emitMessage(assistant);
|
|
532
711
|
}
|
|
533
712
|
}
|
|
534
713
|
} else if (payloadType === "step_complete") {
|
|
714
|
+
// Only process completions for prompt steps, not tool/context steps
|
|
715
|
+
const stepType = (payload as any).stepType;
|
|
716
|
+
const executionType = (payload as any).executionType;
|
|
717
|
+
if (stepType === "tool" || executionType === "context") {
|
|
718
|
+
// Skip tool-related completions - they're handled by tool_complete
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
535
721
|
const finalContent = payload.result?.response;
|
|
536
722
|
const assistant = ensureAssistantMessage();
|
|
537
|
-
if (finalContent) {
|
|
538
|
-
|
|
723
|
+
if (finalContent !== undefined && finalContent !== null) {
|
|
724
|
+
// Check if we already have extracted text from streaming
|
|
725
|
+
const parser = streamParsers.get(assistant.id);
|
|
726
|
+
let hasExtractedText = false;
|
|
727
|
+
|
|
728
|
+
if (parser) {
|
|
729
|
+
// First check if parser already extracted text during streaming
|
|
730
|
+
const currentExtractedText = parser.getExtractedText();
|
|
731
|
+
if (currentExtractedText !== null && currentExtractedText.trim() !== "") {
|
|
732
|
+
// We already have extracted text from streaming - use it
|
|
733
|
+
assistant.content = currentExtractedText;
|
|
734
|
+
hasExtractedText = true;
|
|
735
|
+
} else {
|
|
736
|
+
// No extracted text yet - try to extract from final content
|
|
737
|
+
const rawBuffer = rawContentBuffers.get(assistant.id);
|
|
738
|
+
const contentToProcess = rawBuffer ?? ensureStringContent(finalContent);
|
|
739
|
+
|
|
740
|
+
// Try fast path first
|
|
741
|
+
const extractedText = extractTextFromJson(contentToProcess);
|
|
742
|
+
if (extractedText !== null) {
|
|
743
|
+
assistant.content = extractedText;
|
|
744
|
+
hasExtractedText = true;
|
|
745
|
+
} else {
|
|
746
|
+
// Try parser
|
|
747
|
+
const parsedResult = parser.processChunk(contentToProcess);
|
|
748
|
+
if (parsedResult instanceof Promise) {
|
|
749
|
+
parsedResult.then((result) => {
|
|
750
|
+
// Extract text from result (could be string or object)
|
|
751
|
+
const text = typeof result === 'string' ? result : result?.text ?? null;
|
|
752
|
+
|
|
753
|
+
if (text !== null && text.trim() !== "") {
|
|
754
|
+
const currentAssistant = assistantMessage;
|
|
755
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
756
|
+
currentAssistant.content = text;
|
|
757
|
+
currentAssistant.streaming = false;
|
|
758
|
+
emitMessage(currentAssistant);
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
// No extracted text - check if we should show raw content
|
|
762
|
+
const finalExtractedText = parser.getExtractedText();
|
|
763
|
+
if (finalExtractedText === null || finalExtractedText.trim() === "") {
|
|
764
|
+
// No extracted text available - show raw content only if no streaming happened
|
|
765
|
+
const currentAssistant = assistantMessage;
|
|
766
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
767
|
+
// Only show raw content if we never had any extracted text
|
|
768
|
+
if (!rawContentBuffers.has(assistant.id)) {
|
|
769
|
+
currentAssistant.content = ensureStringContent(finalContent);
|
|
770
|
+
}
|
|
771
|
+
currentAssistant.streaming = false;
|
|
772
|
+
emitMessage(currentAssistant);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
} else {
|
|
778
|
+
// Extract text from synchronous result
|
|
779
|
+
const text = typeof parsedResult === 'string' ? parsedResult : parsedResult?.text ?? null;
|
|
780
|
+
|
|
781
|
+
if (text !== null && text.trim() !== "") {
|
|
782
|
+
assistant.content = text;
|
|
783
|
+
hasExtractedText = true;
|
|
784
|
+
} else {
|
|
785
|
+
// Check stub one more time
|
|
786
|
+
const finalExtractedText = parser.getExtractedText();
|
|
787
|
+
if (finalExtractedText !== null && finalExtractedText.trim() !== "") {
|
|
788
|
+
assistant.content = finalExtractedText;
|
|
789
|
+
hasExtractedText = true;
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// Only show raw content if we never extracted any text and no buffer was used
|
|
798
|
+
if (!hasExtractedText && !rawContentBuffers.has(assistant.id)) {
|
|
799
|
+
// No extracted text and no streaming happened - show raw content
|
|
800
|
+
assistant.content = ensureStringContent(finalContent);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Clean up parser and buffer
|
|
804
|
+
if (parser) {
|
|
805
|
+
const closeResult = parser.close?.();
|
|
806
|
+
if (closeResult instanceof Promise) {
|
|
807
|
+
closeResult.catch(() => {});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
streamParsers.delete(assistant.id);
|
|
811
|
+
rawContentBuffers.delete(assistant.id);
|
|
539
812
|
assistant.streaming = false;
|
|
540
813
|
emitMessage(assistant);
|
|
541
814
|
} else {
|
|
542
|
-
// No final content, just mark as complete
|
|
815
|
+
// No final content, just mark as complete and clean up
|
|
816
|
+
streamParsers.delete(assistant.id);
|
|
817
|
+
rawContentBuffers.delete(assistant.id);
|
|
543
818
|
assistant.streaming = false;
|
|
544
819
|
emitMessage(assistant);
|
|
545
820
|
}
|
|
546
821
|
} else if (payloadType === "flow_complete") {
|
|
547
822
|
const finalContent = payload.result?.response;
|
|
548
|
-
if (finalContent) {
|
|
823
|
+
if (finalContent !== undefined && finalContent !== null) {
|
|
549
824
|
const assistant = ensureAssistantMessage();
|
|
550
|
-
if
|
|
551
|
-
|
|
825
|
+
// Check if we have raw content buffer that needs final processing
|
|
826
|
+
const rawBuffer = rawContentBuffers.get(assistant.id);
|
|
827
|
+
const stringContent = rawBuffer ?? ensureStringContent(finalContent);
|
|
828
|
+
// Try to extract text from structured content
|
|
829
|
+
let displayContent = ensureStringContent(finalContent);
|
|
830
|
+
const parser = streamParsers.get(assistant.id);
|
|
831
|
+
if (parser) {
|
|
832
|
+
const extractedText = extractTextFromJson(stringContent);
|
|
833
|
+
if (extractedText !== null) {
|
|
834
|
+
displayContent = extractedText;
|
|
835
|
+
} else {
|
|
836
|
+
// Try parser if it exists
|
|
837
|
+
const parsedResult = parser.processChunk(stringContent);
|
|
838
|
+
if (parsedResult instanceof Promise) {
|
|
839
|
+
parsedResult.then((result) => {
|
|
840
|
+
// Extract text from result (could be string or object)
|
|
841
|
+
const text = typeof result === 'string' ? result : result?.text ?? null;
|
|
842
|
+
if (text !== null) {
|
|
843
|
+
const currentAssistant = assistantMessage;
|
|
844
|
+
if (currentAssistant && currentAssistant.id === assistant.id) {
|
|
845
|
+
currentAssistant.content = text;
|
|
846
|
+
currentAssistant.streaming = false;
|
|
847
|
+
emitMessage(currentAssistant);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
const currentText = parser.getExtractedText();
|
|
853
|
+
if (currentText !== null) {
|
|
854
|
+
displayContent = currentText;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// Clean up parser and buffer
|
|
859
|
+
streamParsers.delete(assistant.id);
|
|
860
|
+
rawContentBuffers.delete(assistant.id);
|
|
861
|
+
if (displayContent !== assistant.content) {
|
|
862
|
+
assistant.content = displayContent;
|
|
552
863
|
emitMessage(assistant);
|
|
553
864
|
}
|
|
554
865
|
assistant.streaming = false;
|
|
555
866
|
emitMessage(assistant);
|
|
556
867
|
} else {
|
|
557
|
-
|
|
558
|
-
if (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
868
|
+
// No final content, just mark as complete and clean up
|
|
869
|
+
if (assistantMessage !== null) {
|
|
870
|
+
// Clean up any remaining parsers/buffers
|
|
871
|
+
// TypeScript narrowing issue - assistantMessage is checked for null above
|
|
872
|
+
const msg: AgentWidgetMessage = assistantMessage;
|
|
873
|
+
streamParsers.delete(msg.id);
|
|
874
|
+
rawContentBuffers.delete(msg.id);
|
|
875
|
+
msg.streaming = false;
|
|
876
|
+
emitMessage(msg);
|
|
562
877
|
}
|
|
563
878
|
}
|
|
564
879
|
onEvent({ type: "status", status: "idle" });
|