Skip to content

Commit 19c6cc0

Browse files
committed
Repair incomplete widget JSON
1 parent 714aa38 commit 19c6cc0

4 files changed

Lines changed: 137 additions & 2 deletions

File tree

packages/gateway/src/utils/chat-widgets.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,19 @@ describe('normalizeChatWidgets', () => {
6262
expect(result).not.toContain('<key_value');
6363
});
6464

65+
it('repairs balanced widget JSON when only closing containers are missing', () => {
66+
const result = normalizeChatWidgets(
67+
`<metric_grid data='{"items":[{"label":"Total","value":"12"},{"label":"Open","value":"3"}' />`
68+
);
69+
70+
expect(result).toContain('<widget name="metric_grid"');
71+
expect(result).toContain('Total');
72+
expect(result).toContain('Open');
73+
expect(result).toContain('&quot;value&quot;:&quot;3&quot;');
74+
expect(result).not.toContain('Widget could not be rendered');
75+
expect(result).not.toContain('Invalid widget data');
76+
});
77+
6578
it('canonicalizes single-quoted widget JSON with apostrophes inside strings', () => {
6679
const result = normalizeChatWidgets(
6780
`<widget name="key_value" data='{"title":"Survival Formula","items":[{"key":"Genclerbirligi","value":"Kasimpasa'yi beat + Trabzonspor'da points stolen -> Play-Out'u skips"},{"key":"Karagumruk","value":"Needs Kayserispor'un loss"}]}' />`

packages/gateway/src/utils/chat-widgets.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ function parseTagAttributes(source: string): Record<string, string> {
101101
}
102102

103103
function parseWidgetData(value: string): unknown {
104-
const candidates = [value, value.replace(/\\"/g, '"').replace(/\\'/g, "'")];
104+
const candidates = expandWidgetDataCandidates(value);
105105

106106
for (const candidate of candidates) {
107107
try {
@@ -118,6 +118,60 @@ function parseWidgetData(value: string): unknown {
118118
throw new Error('Invalid widget data');
119119
}
120120

121+
function expandWidgetDataCandidates(value: string): string[] {
122+
const normalized = value.replace(/\\"/g, '"').replace(/\\'/g, "'");
123+
return Array.from(
124+
new Set([
125+
value,
126+
normalized,
127+
repairJsonLikeWidgetData(value),
128+
repairJsonLikeWidgetData(normalized),
129+
])
130+
).filter(Boolean);
131+
}
132+
133+
function repairJsonLikeWidgetData(value: string): string {
134+
let repaired = value.trim();
135+
if (!repaired) return repaired;
136+
137+
const stack: string[] = [];
138+
let inString = false;
139+
let escaped = false;
140+
141+
for (const char of repaired) {
142+
if (inString) {
143+
if (escaped) {
144+
escaped = false;
145+
} else if (char === '\\') {
146+
escaped = true;
147+
} else if (char === '"') {
148+
inString = false;
149+
}
150+
continue;
151+
}
152+
153+
if (char === '"') {
154+
inString = true;
155+
} else if (char === '{') {
156+
stack.push('}');
157+
} else if (char === '[') {
158+
stack.push(']');
159+
} else if ((char === '}' || char === ']') && stack[stack.length - 1] === char) {
160+
stack.pop();
161+
}
162+
}
163+
164+
if (escaped) repaired = repaired.slice(0, -1);
165+
if (inString) repaired += '"';
166+
167+
while (stack.length > 0) {
168+
repaired = repaired.replace(/,\s*$/, '');
169+
repaired += stack.pop();
170+
}
171+
172+
return repaired.replace(/,\s*([}\]])/g, '$1');
173+
}
174+
121175
function decodeJsonString(value: string): string {
122176
try {
123177
return JSON.parse(`"${value.replace(/"/g, '\\"')}"`) as string;

packages/ui/src/components/MarkdownContent.test.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,20 @@ After`}
226226
expect(html).toContain('Weekly goals');
227227
});
228228

229+
it('repairs widget JSON when only closing containers are missing', () => {
230+
const html = renderToStaticMarkup(
231+
<MarkdownContent
232+
content={`<metric_grid data='{"items":[{"label":"Total","value":"12"},{"label":"Open","value":"3"}' />`}
233+
/>
234+
);
235+
236+
expect(html).toContain('Total');
237+
expect(html).toContain('Open');
238+
expect(html).toContain('3');
239+
expect(html).not.toContain('Widget could not be rendered');
240+
expect(html).not.toContain('Invalid widget data');
241+
});
242+
229243
it('renders key-value, cards, and steps widgets', () => {
230244
const html = renderToStaticMarkup(
231245
<MarkdownContent

packages/ui/src/components/MarkdownContent.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ export const MarkdownContent = memo(function MarkdownContent({
352352
};
353353

354354
const parseWidgetData = (value: string): unknown => {
355-
const candidates = [value, value.replace(/\\"/g, '"').replace(/\\'/g, "'")];
355+
const candidates = expandWidgetDataCandidates(value);
356356

357357
for (const candidate of candidates) {
358358
try {
@@ -369,6 +369,60 @@ export const MarkdownContent = memo(function MarkdownContent({
369369
throw new Error('Invalid widget data');
370370
};
371371

372+
const expandWidgetDataCandidates = (value: string): string[] => {
373+
const normalized = value.replace(/\\"/g, '"').replace(/\\'/g, "'");
374+
return Array.from(
375+
new Set([
376+
value,
377+
normalized,
378+
repairJsonLikeWidgetData(value),
379+
repairJsonLikeWidgetData(normalized),
380+
])
381+
).filter(Boolean);
382+
};
383+
384+
const repairJsonLikeWidgetData = (value: string): string => {
385+
let repaired = value.trim();
386+
if (!repaired) return repaired;
387+
388+
const stack: string[] = [];
389+
let inString = false;
390+
let escaped = false;
391+
392+
for (const char of repaired) {
393+
if (inString) {
394+
if (escaped) {
395+
escaped = false;
396+
} else if (char === '\\') {
397+
escaped = true;
398+
} else if (char === '"') {
399+
inString = false;
400+
}
401+
continue;
402+
}
403+
404+
if (char === '"') {
405+
inString = true;
406+
} else if (char === '{') {
407+
stack.push('}');
408+
} else if (char === '[') {
409+
stack.push(']');
410+
} else if ((char === '}' || char === ']') && stack[stack.length - 1] === char) {
411+
stack.pop();
412+
}
413+
}
414+
415+
if (escaped) repaired = repaired.slice(0, -1);
416+
if (inString) repaired += '"';
417+
418+
while (stack.length > 0) {
419+
repaired = repaired.replace(/,\s*$/, '');
420+
repaired += stack.pop();
421+
}
422+
423+
return repaired.replace(/,\s*([}\]])/g, '$1');
424+
};
425+
372426
const decodeJsonString = (value: string): string => {
373427
try {
374428
return JSON.parse(`"${value.replace(/"/g, '\\"')}"`);

0 commit comments

Comments
 (0)