@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
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { createJsonStreamParser } from "./formatting";
|
|
3
|
+
|
|
4
|
+
describe("JSON Stream Parser", () => {
|
|
5
|
+
it("should extract text field incrementally as JSON streams in", () => {
|
|
6
|
+
// Simulate the actual stream chunks from the user's example
|
|
7
|
+
const chunks = [
|
|
8
|
+
'{\n',
|
|
9
|
+
' ',
|
|
10
|
+
' "',
|
|
11
|
+
'action',
|
|
12
|
+
'":',
|
|
13
|
+
' "',
|
|
14
|
+
'message',
|
|
15
|
+
'",\n',
|
|
16
|
+
' ',
|
|
17
|
+
' "',
|
|
18
|
+
'text',
|
|
19
|
+
'":',
|
|
20
|
+
' "',
|
|
21
|
+
'You\'re',
|
|
22
|
+
' welcome',
|
|
23
|
+
'!',
|
|
24
|
+
' Enjoy',
|
|
25
|
+
' your',
|
|
26
|
+
' browsing',
|
|
27
|
+
',',
|
|
28
|
+
' and',
|
|
29
|
+
' I\'m',
|
|
30
|
+
' here',
|
|
31
|
+
' if',
|
|
32
|
+
' you',
|
|
33
|
+
' need',
|
|
34
|
+
' anything',
|
|
35
|
+
'!"\n',
|
|
36
|
+
'}'
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const parser = createJsonStreamParser();
|
|
40
|
+
let accumulatedContent = "";
|
|
41
|
+
const extractedTexts: string[] = [];
|
|
42
|
+
|
|
43
|
+
// Process each chunk incrementally
|
|
44
|
+
for (const chunk of chunks) {
|
|
45
|
+
accumulatedContent += chunk;
|
|
46
|
+
const result = parser.processChunk(accumulatedContent);
|
|
47
|
+
|
|
48
|
+
// Extract text from result (can be string or object with text property)
|
|
49
|
+
const text = typeof result === 'string' ? result : result?.text ?? null;
|
|
50
|
+
if (text !== null) {
|
|
51
|
+
extractedTexts.push(text);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Also check getExtractedText
|
|
55
|
+
const currentText = parser.getExtractedText();
|
|
56
|
+
if (currentText !== null && !extractedTexts.includes(currentText)) {
|
|
57
|
+
extractedTexts.push(currentText);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Verify that we extracted text progressively
|
|
62
|
+
expect(extractedTexts.length).toBeGreaterThan(5); // Should have many incremental updates
|
|
63
|
+
|
|
64
|
+
// The final extracted text should be the complete text value
|
|
65
|
+
const finalText = parser.getExtractedText();
|
|
66
|
+
expect(finalText).toBe("You're welcome! Enjoy your browsing, and I'm here if you need anything!");
|
|
67
|
+
|
|
68
|
+
// Verify intermediate extractions show progressive text
|
|
69
|
+
// The text should start appearing once the "text" field value starts streaming
|
|
70
|
+
const hasPartialText = extractedTexts.some(text =>
|
|
71
|
+
text.includes("You're") || text.includes("welcome")
|
|
72
|
+
);
|
|
73
|
+
expect(hasPartialText).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should handle incomplete JSON gracefully", () => {
|
|
77
|
+
const chunks = [
|
|
78
|
+
'{\n',
|
|
79
|
+
' "action": "message",\n',
|
|
80
|
+
' "text": "',
|
|
81
|
+
'Hello',
|
|
82
|
+
' ',
|
|
83
|
+
'world'
|
|
84
|
+
// Note: No closing quote or brace
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const parser = createJsonStreamParser();
|
|
88
|
+
let accumulated = "";
|
|
89
|
+
|
|
90
|
+
for (const chunk of chunks) {
|
|
91
|
+
accumulated += chunk;
|
|
92
|
+
parser.processChunk(accumulated);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Should still extract partial text
|
|
96
|
+
const result = parser.getExtractedText();
|
|
97
|
+
expect(result).toBe("Hello world");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("should handle complete JSON in one chunk", () => {
|
|
101
|
+
const completeJson = '{"action": "message", "text": "Hello world!"}';
|
|
102
|
+
|
|
103
|
+
const parser = createJsonStreamParser();
|
|
104
|
+
const result = parser.processChunk(completeJson);
|
|
105
|
+
|
|
106
|
+
// Extract text from result (can be string or object with text property)
|
|
107
|
+
const text = typeof result === 'string' ? result : result?.text ?? null;
|
|
108
|
+
expect(text).toBe("Hello world!");
|
|
109
|
+
expect(parser.getExtractedText()).toBe("Hello world!");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("should handle the exact stream format from user example", () => {
|
|
113
|
+
// Extract just the text chunks from the SSE stream
|
|
114
|
+
const textChunks = [
|
|
115
|
+
'{\n',
|
|
116
|
+
' ',
|
|
117
|
+
' "',
|
|
118
|
+
'action',
|
|
119
|
+
'":',
|
|
120
|
+
' "',
|
|
121
|
+
'message',
|
|
122
|
+
'",\n',
|
|
123
|
+
' ',
|
|
124
|
+
' "',
|
|
125
|
+
'text',
|
|
126
|
+
'":',
|
|
127
|
+
' "',
|
|
128
|
+
'You\'re',
|
|
129
|
+
' welcome',
|
|
130
|
+
'!',
|
|
131
|
+
' Enjoy',
|
|
132
|
+
' your',
|
|
133
|
+
' browsing',
|
|
134
|
+
',',
|
|
135
|
+
' and',
|
|
136
|
+
' I\'m',
|
|
137
|
+
' here',
|
|
138
|
+
' if',
|
|
139
|
+
' you',
|
|
140
|
+
' need',
|
|
141
|
+
' anything',
|
|
142
|
+
'!"\n',
|
|
143
|
+
'}'
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
const parser = createJsonStreamParser();
|
|
147
|
+
let accumulated = "";
|
|
148
|
+
const allExtractedTexts: (string | null)[] = [];
|
|
149
|
+
|
|
150
|
+
for (const chunk of textChunks) {
|
|
151
|
+
accumulated += chunk;
|
|
152
|
+
const result = parser.processChunk(accumulated);
|
|
153
|
+
// Extract text from result (can be string or object with text property)
|
|
154
|
+
const text = typeof result === 'string' ? result : result?.text ?? null;
|
|
155
|
+
allExtractedTexts.push(text);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Should have many non-null results (incremental updates)
|
|
159
|
+
const nonNullResults = allExtractedTexts.filter(r => r !== null);
|
|
160
|
+
expect(nonNullResults.length).toBeGreaterThan(10);
|
|
161
|
+
|
|
162
|
+
// Final result should be the complete text
|
|
163
|
+
const finalResult = parser.getExtractedText();
|
|
164
|
+
expect(finalResult).toBe("You're welcome! Enjoy your browsing, and I'm here if you need anything!");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { AgentWidgetReasoning, AgentWidgetToolCall, AgentWidgetStreamParser, AgentWidgetStreamParserResult } from "../types";
|
|
2
|
+
import { parse as parsePartialJson, STR, OBJ } from "partial-json";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Unescapes JSON string escape sequences that LLMs often double-escape.
|
|
6
|
+
* Converts literal \n, \r, \t sequences to actual control characters.
|
|
7
|
+
*/
|
|
8
|
+
const unescapeJsonString = (str: string): string => {
|
|
9
|
+
return str
|
|
10
|
+
.replace(/\\n/g, '\n')
|
|
11
|
+
.replace(/\\r/g, '\r')
|
|
12
|
+
.replace(/\\t/g, '\t')
|
|
13
|
+
.replace(/\\"/g, '"')
|
|
14
|
+
.replace(/\\\\/g, '\\');
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const formatUnknownValue = (value: unknown): string => {
|
|
18
|
+
if (value === null) return "null";
|
|
19
|
+
if (value === undefined) return "";
|
|
20
|
+
if (typeof value === "string") return value;
|
|
21
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
22
|
+
return String(value);
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
return JSON.stringify(value, null, 2);
|
|
26
|
+
} catch (error) {
|
|
27
|
+
return String(value);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const formatReasoningDuration = (reasoning: AgentWidgetReasoning) => {
|
|
32
|
+
const end = reasoning.completedAt ?? Date.now();
|
|
33
|
+
const start = reasoning.startedAt ?? end;
|
|
34
|
+
const durationMs =
|
|
35
|
+
reasoning.durationMs !== undefined
|
|
36
|
+
? reasoning.durationMs
|
|
37
|
+
: Math.max(0, end - start);
|
|
38
|
+
const seconds = durationMs / 1000;
|
|
39
|
+
if (seconds < 0.1) {
|
|
40
|
+
return "Thought for <0.1 seconds";
|
|
41
|
+
}
|
|
42
|
+
const formatted =
|
|
43
|
+
seconds >= 10
|
|
44
|
+
? Math.round(seconds).toString()
|
|
45
|
+
: seconds.toFixed(1).replace(/\.0$/, "");
|
|
46
|
+
return `Thought for ${formatted} seconds`;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const describeReasonStatus = (reasoning: AgentWidgetReasoning) => {
|
|
50
|
+
if (reasoning.status === "complete") return formatReasoningDuration(reasoning);
|
|
51
|
+
if (reasoning.status === "pending") return "Waiting";
|
|
52
|
+
return "";
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const formatToolDuration = (tool: AgentWidgetToolCall) => {
|
|
56
|
+
const durationMs =
|
|
57
|
+
typeof tool.duration === "number"
|
|
58
|
+
? tool.duration
|
|
59
|
+
: typeof tool.durationMs === "number"
|
|
60
|
+
? tool.durationMs
|
|
61
|
+
: Math.max(
|
|
62
|
+
0,
|
|
63
|
+
(tool.completedAt ?? Date.now()) -
|
|
64
|
+
(tool.startedAt ?? tool.completedAt ?? Date.now())
|
|
65
|
+
);
|
|
66
|
+
const seconds = durationMs / 1000;
|
|
67
|
+
if (seconds < 0.1) {
|
|
68
|
+
return "Used tool for <0.1 seconds";
|
|
69
|
+
}
|
|
70
|
+
const formatted =
|
|
71
|
+
seconds >= 10
|
|
72
|
+
? Math.round(seconds).toString()
|
|
73
|
+
: seconds.toFixed(1).replace(/\.0$/, "");
|
|
74
|
+
return `Used tool for ${formatted} seconds`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const describeToolStatus = (status: AgentWidgetToolCall["status"]) => {
|
|
78
|
+
if (status === "complete") return "";
|
|
79
|
+
if (status === "pending") return "Starting";
|
|
80
|
+
return "Running";
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const describeToolTitle = (tool: AgentWidgetToolCall) => {
|
|
84
|
+
if (tool.status === "complete") {
|
|
85
|
+
return formatToolDuration(tool);
|
|
86
|
+
}
|
|
87
|
+
return "Using tool...";
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Creates a regex-based parser for extracting text from JSON streams.
|
|
92
|
+
* This is a simpler alternative to schema-stream that uses regex to extract
|
|
93
|
+
* the 'text' field incrementally as JSON streams in.
|
|
94
|
+
*
|
|
95
|
+
* This can be used as an alternative parser option.
|
|
96
|
+
*/
|
|
97
|
+
const createRegexJsonParserInternal = (): {
|
|
98
|
+
processChunk(accumulatedContent: string): Promise<AgentWidgetStreamParserResult | string | null>;
|
|
99
|
+
getExtractedText(): string | null;
|
|
100
|
+
close?(): Promise<void>;
|
|
101
|
+
} => {
|
|
102
|
+
let extractedText: string | null = null;
|
|
103
|
+
let processedLength = 0;
|
|
104
|
+
|
|
105
|
+
// Regex-based extraction for incremental JSON parsing
|
|
106
|
+
const extractTextFromIncompleteJson = (jsonString: string): string | null => {
|
|
107
|
+
// Look for "text": "value" pattern, handling incomplete strings
|
|
108
|
+
// Match: "text": " followed by any characters (including incomplete)
|
|
109
|
+
const textFieldRegex = /"text"\s*:\s*"((?:[^"\\]|\\.|")*?)"/;
|
|
110
|
+
const match = jsonString.match(textFieldRegex);
|
|
111
|
+
|
|
112
|
+
if (match && match[1]) {
|
|
113
|
+
// Unescape the string value
|
|
114
|
+
try {
|
|
115
|
+
// Replace escaped characters
|
|
116
|
+
let unescaped = match[1]
|
|
117
|
+
.replace(/\\n/g, '\n')
|
|
118
|
+
.replace(/\\r/g, '\r')
|
|
119
|
+
.replace(/\\t/g, '\t')
|
|
120
|
+
.replace(/\\"/g, '"')
|
|
121
|
+
.replace(/\\\\/g, '\\');
|
|
122
|
+
return unescaped;
|
|
123
|
+
} catch {
|
|
124
|
+
return match[1];
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Also try to match incomplete text field (text field that hasn't closed yet)
|
|
129
|
+
// Look for "text": " followed by content that may not be closed
|
|
130
|
+
const incompleteTextFieldRegex = /"text"\s*:\s*"((?:[^"\\]|\\.)*)/;
|
|
131
|
+
const incompleteMatch = jsonString.match(incompleteTextFieldRegex);
|
|
132
|
+
|
|
133
|
+
if (incompleteMatch && incompleteMatch[1]) {
|
|
134
|
+
// Unescape the partial string value
|
|
135
|
+
try {
|
|
136
|
+
let unescaped = incompleteMatch[1]
|
|
137
|
+
.replace(/\\n/g, '\n')
|
|
138
|
+
.replace(/\\r/g, '\r')
|
|
139
|
+
.replace(/\\t/g, '\t')
|
|
140
|
+
.replace(/\\"/g, '"')
|
|
141
|
+
.replace(/\\\\/g, '\\');
|
|
142
|
+
return unescaped;
|
|
143
|
+
} catch {
|
|
144
|
+
return incompleteMatch[1];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return null;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
getExtractedText: () => extractedText,
|
|
153
|
+
processChunk: async (accumulatedContent: string): Promise<AgentWidgetStreamParserResult | string | null> => {
|
|
154
|
+
// Skip if no new content
|
|
155
|
+
if (accumulatedContent.length <= processedLength) {
|
|
156
|
+
return extractedText !== null
|
|
157
|
+
? { text: extractedText, raw: accumulatedContent }
|
|
158
|
+
: null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Validate that the accumulated content looks like valid JSON
|
|
162
|
+
const trimmed = accumulatedContent.trim();
|
|
163
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Try to extract text field using regex
|
|
168
|
+
const extracted = extractTextFromIncompleteJson(accumulatedContent);
|
|
169
|
+
if (extracted !== null) {
|
|
170
|
+
extractedText = extracted;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Update processed length
|
|
174
|
+
processedLength = accumulatedContent.length;
|
|
175
|
+
|
|
176
|
+
// Return both the extracted text and raw JSON
|
|
177
|
+
if (extractedText !== null) {
|
|
178
|
+
return {
|
|
179
|
+
text: extractedText,
|
|
180
|
+
raw: accumulatedContent
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return null;
|
|
185
|
+
},
|
|
186
|
+
close: async () => {
|
|
187
|
+
// No cleanup needed for regex-based parser
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Extracts the text field from JSON (works with partial JSON during streaming).
|
|
194
|
+
* For complete JSON, uses fast path. For incomplete JSON, returns null (use stateful parser in client.ts).
|
|
195
|
+
*
|
|
196
|
+
* @param jsonString - The JSON string (can be partial/incomplete during streaming)
|
|
197
|
+
* @returns The extracted text value, or null if not found or invalid
|
|
198
|
+
*/
|
|
199
|
+
export const extractTextFromJson = (jsonString: string): string | null => {
|
|
200
|
+
try {
|
|
201
|
+
// Try to parse complete JSON first (fast path)
|
|
202
|
+
const parsed = JSON.parse(jsonString);
|
|
203
|
+
if (parsed && typeof parsed === "object" && typeof parsed.text === "string") {
|
|
204
|
+
return parsed.text;
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
// For incomplete JSON, return null - use stateful parser in client.ts
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Plain text parser - passes through text as-is without any parsing.
|
|
215
|
+
* This is the default parser.
|
|
216
|
+
*/
|
|
217
|
+
export const createPlainTextParser = (): AgentWidgetStreamParser => {
|
|
218
|
+
const parser: AgentWidgetStreamParser = {
|
|
219
|
+
processChunk: (accumulatedContent: string): string | null => {
|
|
220
|
+
// Always return null to indicate this isn't a structured format
|
|
221
|
+
// Content will be displayed as plain text
|
|
222
|
+
return null;
|
|
223
|
+
},
|
|
224
|
+
getExtractedText: (): string | null => {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
// Mark this as a plain text parser
|
|
229
|
+
(parser as any).__isPlainTextParser = true;
|
|
230
|
+
return parser;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* JSON parser using regex-based extraction.
|
|
235
|
+
* Extracts the 'text' field from JSON responses using regex patterns.
|
|
236
|
+
* This is a simpler regex-based alternative to createJsonStreamParser.
|
|
237
|
+
* Less robust for complex/malformed JSON but has no external dependencies.
|
|
238
|
+
*/
|
|
239
|
+
export const createRegexJsonParser = (): AgentWidgetStreamParser => {
|
|
240
|
+
const regexParser = createRegexJsonParserInternal();
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
processChunk: async (accumulatedContent: string): Promise<AgentWidgetStreamParserResult | string | null> => {
|
|
244
|
+
// Only process if it looks like JSON
|
|
245
|
+
const trimmed = accumulatedContent.trim();
|
|
246
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
return regexParser.processChunk(accumulatedContent);
|
|
250
|
+
},
|
|
251
|
+
getExtractedText: regexParser.getExtractedText.bind(regexParser),
|
|
252
|
+
close: regexParser.close?.bind(regexParser)
|
|
253
|
+
};
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* JSON stream parser using partial-json library.
|
|
258
|
+
* Extracts the 'text' field from JSON responses using the partial-json library,
|
|
259
|
+
* which is specifically designed for parsing incomplete JSON from LLMs.
|
|
260
|
+
* This is the recommended parser as it's more robust than regex.
|
|
261
|
+
*
|
|
262
|
+
* Library: https://github.com/promplate/partial-json-parser-js
|
|
263
|
+
*/
|
|
264
|
+
export const createJsonStreamParser = (): AgentWidgetStreamParser => {
|
|
265
|
+
let extractedText: string | null = null;
|
|
266
|
+
let processedLength = 0;
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
getExtractedText: () => extractedText,
|
|
270
|
+
processChunk: (accumulatedContent: string): AgentWidgetStreamParserResult | string | null => {
|
|
271
|
+
// Validate that the accumulated content looks like JSON
|
|
272
|
+
const trimmed = accumulatedContent.trim();
|
|
273
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Skip if no new content
|
|
278
|
+
if (accumulatedContent.length <= processedLength) {
|
|
279
|
+
return extractedText !== null || extractedText === ""
|
|
280
|
+
? { text: extractedText || "", raw: accumulatedContent }
|
|
281
|
+
: null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
// Parse partial JSON - allow partial strings and objects
|
|
286
|
+
// STR | OBJ allows incomplete strings and objects during streaming
|
|
287
|
+
const parsed = parsePartialJson(accumulatedContent, STR | OBJ);
|
|
288
|
+
|
|
289
|
+
if (parsed && typeof parsed === "object") {
|
|
290
|
+
// Check for component directives - extract text if present for combined text+component
|
|
291
|
+
if (parsed.component && typeof parsed.component === "string") {
|
|
292
|
+
// For component directives, extract text if present, otherwise empty
|
|
293
|
+
extractedText = typeof parsed.text === "string" ? unescapeJsonString(parsed.text) : "";
|
|
294
|
+
}
|
|
295
|
+
// Check for form directives - these also don't have text fields
|
|
296
|
+
else if (parsed.type === "init" && parsed.form) {
|
|
297
|
+
// For form directives, return empty - they're handled by form postprocessor
|
|
298
|
+
extractedText = "";
|
|
299
|
+
}
|
|
300
|
+
// Extract text field if available
|
|
301
|
+
else if (typeof parsed.text === "string") {
|
|
302
|
+
extractedText = unescapeJsonString(parsed.text);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
} catch (error) {
|
|
306
|
+
// If parsing fails completely, keep the last extracted text
|
|
307
|
+
// This can happen with very malformed JSON
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Update processed length
|
|
311
|
+
processedLength = accumulatedContent.length;
|
|
312
|
+
|
|
313
|
+
// Always return raw JSON for component/form directive detection
|
|
314
|
+
// Return empty string for text if it's a component/form directive
|
|
315
|
+
if (extractedText !== null) {
|
|
316
|
+
return {
|
|
317
|
+
text: extractedText,
|
|
318
|
+
raw: accumulatedContent
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return null;
|
|
323
|
+
},
|
|
324
|
+
close: () => {
|
|
325
|
+
// No cleanup needed
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Flexible JSON stream parser that can extract text from various field names.
|
|
332
|
+
* This parser looks for display text in multiple possible fields, making it
|
|
333
|
+
* compatible with different JSON response formats.
|
|
334
|
+
*
|
|
335
|
+
* @param textExtractor Optional function to extract display text from parsed JSON.
|
|
336
|
+
* If not provided, looks for common text fields.
|
|
337
|
+
*/
|
|
338
|
+
export const createFlexibleJsonStreamParser = (
|
|
339
|
+
textExtractor?: (parsed: any) => string | null
|
|
340
|
+
): AgentWidgetStreamParser => {
|
|
341
|
+
let extractedText: string | null = null;
|
|
342
|
+
let processedLength = 0;
|
|
343
|
+
|
|
344
|
+
// Default text extractor that handles common patterns
|
|
345
|
+
const defaultExtractor = (parsed: any): string | null => {
|
|
346
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
347
|
+
|
|
348
|
+
// Helper to safely extract and unescape text
|
|
349
|
+
const getText = (value: any): string | null => {
|
|
350
|
+
return typeof value === "string" ? unescapeJsonString(value) : null;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
// Check for component directives - extract text if present for combined text+component
|
|
354
|
+
if (parsed.component && typeof parsed.component === "string") {
|
|
355
|
+
// For component directives, extract text if present, otherwise empty
|
|
356
|
+
return typeof parsed.text === "string" ? unescapeJsonString(parsed.text) : "";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Check for form directives - these also don't have text fields
|
|
360
|
+
if (parsed.type === "init" && parsed.form) {
|
|
361
|
+
// For form directives, return empty - they're handled by form postprocessor
|
|
362
|
+
return "";
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Check for action-based text fields
|
|
366
|
+
if (parsed.action) {
|
|
367
|
+
switch (parsed.action) {
|
|
368
|
+
case 'nav_then_click':
|
|
369
|
+
return getText(parsed.on_load_text) || getText(parsed.text) || null;
|
|
370
|
+
case 'message':
|
|
371
|
+
case 'message_and_click':
|
|
372
|
+
case 'checkout':
|
|
373
|
+
return getText(parsed.text) || null;
|
|
374
|
+
default:
|
|
375
|
+
return getText(parsed.text) || getText(parsed.display_text) || getText(parsed.message) || null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Fallback to common text field names
|
|
380
|
+
return getText(parsed.text) || getText(parsed.display_text) || getText(parsed.message) || getText(parsed.content) || null;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const extractText = textExtractor || defaultExtractor;
|
|
384
|
+
|
|
385
|
+
return {
|
|
386
|
+
getExtractedText: () => extractedText,
|
|
387
|
+
processChunk: (accumulatedContent: string): AgentWidgetStreamParserResult | string | null => {
|
|
388
|
+
// Validate that the accumulated content looks like JSON
|
|
389
|
+
const trimmed = accumulatedContent.trim();
|
|
390
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
|
391
|
+
return null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Skip if no new content
|
|
395
|
+
if (accumulatedContent.length <= processedLength) {
|
|
396
|
+
return extractedText !== null
|
|
397
|
+
? { text: extractedText, raw: accumulatedContent }
|
|
398
|
+
: null;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
// Parse partial JSON - allow partial strings and objects
|
|
403
|
+
// STR | OBJ allows incomplete strings and objects during streaming
|
|
404
|
+
const parsed = parsePartialJson(accumulatedContent, STR | OBJ);
|
|
405
|
+
|
|
406
|
+
// Extract text using the provided or default extractor
|
|
407
|
+
const newText = extractText(parsed);
|
|
408
|
+
if (newText !== null) {
|
|
409
|
+
extractedText = newText;
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
// If parsing fails completely, keep the last extracted text
|
|
413
|
+
// This can happen with very malformed JSON
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Update processed length
|
|
417
|
+
processedLength = accumulatedContent.length;
|
|
418
|
+
|
|
419
|
+
// Always return the raw JSON for action parsing and component detection
|
|
420
|
+
// Text may be null or empty for component/form directives, that's ok
|
|
421
|
+
return {
|
|
422
|
+
text: extractedText || "",
|
|
423
|
+
raw: accumulatedContent
|
|
424
|
+
};
|
|
425
|
+
},
|
|
426
|
+
close: () => {
|
|
427
|
+
// No cleanup needed
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* XML stream parser.
|
|
434
|
+
* Extracts text from <text>...</text> tags in XML responses.
|
|
435
|
+
*/
|
|
436
|
+
export const createXmlParser = (): AgentWidgetStreamParser => {
|
|
437
|
+
let extractedText: string | null = null;
|
|
438
|
+
|
|
439
|
+
return {
|
|
440
|
+
processChunk: (accumulatedContent: string): AgentWidgetStreamParserResult | string | null => {
|
|
441
|
+
// Return null if not XML format
|
|
442
|
+
const trimmed = accumulatedContent.trim();
|
|
443
|
+
if (!trimmed.startsWith('<')) {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Extract text from <text>...</text> tags
|
|
448
|
+
// Handle both <text>content</text> and <text attr="value">content</text>
|
|
449
|
+
const match = accumulatedContent.match(/<text[^>]*>([\s\S]*?)<\/text>/);
|
|
450
|
+
if (match && match[1]) {
|
|
451
|
+
extractedText = match[1];
|
|
452
|
+
// For XML, we typically don't need the raw content for middleware
|
|
453
|
+
// but we can include it for consistency
|
|
454
|
+
return { text: extractedText, raw: accumulatedContent };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return null;
|
|
458
|
+
},
|
|
459
|
+
getExtractedText: (): string | null => {
|
|
460
|
+
return extractedText;
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
|