Skip to content

Commit 0fd92fe

Browse files
committed
fix: handle object rendering in chat UI (#2725)
1 parent 1b22d2c commit 0fd92fe

File tree

4 files changed

+563
-20
lines changed

4 files changed

+563
-20
lines changed

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import { type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
44
import { createLogger } from '@sim/logger'
5+
import { formatOutputForWorkflow } from '@/lib/core/utils/format-output'
56
import {
67
AlertCircle,
78
ArrowDownToLine,
@@ -156,20 +157,8 @@ const extractOutputFromLogs = (logs: BlockLog[] | undefined, outputId: string):
156157
return output
157158
}
158159

159-
/**
160-
* Formats output content for display in chat
161-
* @param output - Output value to format (string, object, or other)
162-
* @returns Formatted string, markdown code block for objects, or empty string
163-
*/
164-
const formatOutputContent = (output: unknown): string => {
165-
if (typeof output === 'string') {
166-
return output
167-
}
168-
if (output && typeof output === 'object') {
169-
return `\`\`\`json\n${JSON.stringify(output, null, 2)}\n\`\`\``
170-
}
171-
return ''
172-
}
160+
// Use shared utility for formatting output - removed duplicate code
161+
const formatOutputContent = formatOutputForWorkflow
173162

174163
/**
175164
* Represents a field in the start block's input format configuration

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/chat-message/chat-message.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useMemo } from 'react'
22
import { StreamingIndicator } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/copilot/components/copilot-message/components/smooth-streaming'
3+
import { formatOutputForChat } from '@/lib/core/utils/format-output'
34

45
interface ChatAttachment {
56
id: string
@@ -93,12 +94,10 @@ const WordWrap = ({ text }: { text: string }) => {
9394
* Renders a chat message with optional file attachments
9495
*/
9596
export function ChatMessage({ message }: ChatMessageProps) {
96-
const formattedContent = useMemo(() => {
97-
if (typeof message.content === 'object' && message.content !== null) {
98-
return JSON.stringify(message.content, null, 2)
99-
}
100-
return String(message.content || '')
101-
}, [message.content])
97+
const formattedContent = useMemo(
98+
() => formatOutputForChat(message.content),
99+
[message.content]
100+
)
102101

103102
const handleAttachmentClick = (attachment: ChatAttachment) => {
104103
const validDataUrl = attachment.dataUrl?.trim()
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
import {
3+
formatOutputForDisplay,
4+
formatOutputForChat,
5+
formatOutputForWorkflow,
6+
formatOutputRaw,
7+
formatOutputSafe,
8+
isOutputSafe
9+
} from './format-output'
10+
11+
describe('format-output utilities', () => {
12+
describe('formatOutputForDisplay', () => {
13+
// Basic types
14+
it('handles null and undefined', () => {
15+
expect(formatOutputForDisplay(null)).toBe('')
16+
expect(formatOutputForDisplay(undefined)).toBe('')
17+
})
18+
19+
it('handles primitive types', () => {
20+
expect(formatOutputForDisplay('hello')).toBe('hello')
21+
expect(formatOutputForDisplay(123)).toBe('123')
22+
expect(formatOutputForDisplay(true)).toBe('true')
23+
expect(formatOutputForDisplay(false)).toBe('false')
24+
expect(formatOutputForDisplay(0)).toBe('0')
25+
expect(formatOutputForDisplay(BigInt(999))).toBe('999')
26+
})
27+
28+
// Object with text property
29+
it('extracts text from objects with text property', () => {
30+
expect(formatOutputForDisplay({ text: 'Hello World', type: 'response' })).toBe('Hello World')
31+
expect(formatOutputForDisplay({ text: ' spaced ', other: 'data' })).toBe('spaced')
32+
})
33+
34+
// Nested objects
35+
it('handles deeply nested text properties', () => {
36+
const nested = {
37+
data: {
38+
response: {
39+
message: {
40+
content: 'Deep text'
41+
}
42+
}
43+
}
44+
}
45+
expect(formatOutputForDisplay(nested)).toBe('Deep text')
46+
})
47+
48+
// Arrays
49+
it('handles arrays of text objects', () => {
50+
const arr = [
51+
{ text: 'Line 1' },
52+
{ text: 'Line 2' },
53+
{ content: 'Line 3' }
54+
]
55+
expect(formatOutputForDisplay(arr)).toBe('Line 1 Line 2 Line 3')
56+
})
57+
58+
it('handles mixed arrays', () => {
59+
const mixed = [
60+
'String',
61+
{ text: 'Object text' },
62+
123,
63+
null,
64+
{ message: 'Message text' }
65+
]
66+
expect(formatOutputForDisplay(mixed)).toBe('String Object text 123 Message text')
67+
})
68+
69+
// Special objects
70+
it('handles Date objects', () => {
71+
const date = new Date('2024-01-01T00:00:00Z')
72+
expect(formatOutputForDisplay(date)).toBe('2024-01-01T00:00:00.000Z')
73+
})
74+
75+
it('handles Error objects', () => {
76+
const error = new Error('Test error')
77+
expect(formatOutputForDisplay(error)).toBe('Test error')
78+
})
79+
80+
it('handles RegExp objects', () => {
81+
const regex = /test.*pattern/gi
82+
expect(formatOutputForDisplay(regex)).toBe('/test.*pattern/gi')
83+
})
84+
85+
// Circular references
86+
it('handles circular references', () => {
87+
const obj: any = { a: 1 }
88+
obj.self = obj
89+
const result = formatOutputForDisplay(obj, { mode: 'raw' })
90+
expect(result).toContain('[Circular]')
91+
expect(result).not.toThrow()
92+
})
93+
94+
// Large arrays
95+
it('handles large arrays gracefully', () => {
96+
const bigArray = new Array(2000).fill('item')
97+
const result = formatOutputForDisplay(bigArray)
98+
expect(result).toContain('[Large Array: 2000 items]')
99+
})
100+
101+
// Binary data
102+
it('handles Buffer data', () => {
103+
const buffer = Buffer.from('Hello Buffer')
104+
expect(formatOutputForDisplay(buffer)).toBe('Hello Buffer')
105+
106+
const binaryBuffer = Buffer.from([0xFF, 0xFE, 0x00, 0x01])
107+
expect(formatOutputForDisplay(binaryBuffer)).toBe('[Binary Data]')
108+
})
109+
110+
// Truncation
111+
it('truncates long strings when specified', () => {
112+
const longText = 'x'.repeat(10000)
113+
const result = formatOutputForDisplay(longText, { maxLength: 100, truncate: true })
114+
expect(result.length).toBeLessThan(150)
115+
expect(result).toContain('... [truncated]')
116+
})
117+
118+
// Whitespace handling
119+
it('preserves whitespace when requested', () => {
120+
const spaced = 'Line 1\n\nLine 2\t\tTabbed'
121+
expect(formatOutputForDisplay(spaced, { preserveWhitespace: true }))
122+
.toBe('Line 1\n\nLine 2\t\tTabbed')
123+
expect(formatOutputForDisplay(spaced, { preserveWhitespace: false }))
124+
.toBe('Line 1 Line 2 Tabbed')
125+
})
126+
127+
// Mode-specific formatting
128+
it('formats correctly for different modes', () => {
129+
const obj = { data: 'test' }
130+
131+
const chatFormat = formatOutputForDisplay(obj, { mode: 'chat' })
132+
expect(chatFormat).toContain('test')
133+
134+
const workflowFormat = formatOutputForDisplay(obj, { mode: 'workflow' })
135+
expect(workflowFormat).toMatch(/```json/)
136+
137+
const rawFormat = formatOutputForDisplay(obj, { mode: 'raw' })
138+
expect(rawFormat).toBe('{"data":"test"}')
139+
})
140+
141+
// Edge cases
142+
it('handles objects with toString method', () => {
143+
const customObj = {
144+
toString() {
145+
return 'Custom String'
146+
}
147+
}
148+
expect(formatOutputForDisplay(customObj)).toBe('Custom String')
149+
})
150+
151+
it('handles undefined and function properties', () => {
152+
const obj = {
153+
func: () => console.log('test'),
154+
undef: undefined,
155+
sym: Symbol('test')
156+
}
157+
const result = formatOutputForDisplay(obj, { mode: 'raw' })
158+
expect(result).toContain('[Function]')
159+
expect(result).toContain('[undefined]')
160+
expect(result).toContain('[Symbol]')
161+
})
162+
})
163+
164+
describe('specialized formatters', () => {
165+
it('formatOutputForChat limits length', () => {
166+
const longText = 'x'.repeat(10000)
167+
const result = formatOutputForChat(longText)
168+
expect(result.length).toBeLessThanOrEqual(5100) // 5000 + truncation message
169+
})
170+
171+
it('formatOutputForWorkflow wraps in code block', () => {
172+
const obj = { test: 'data' }
173+
const result = formatOutputForWorkflow(obj)
174+
expect(result).toMatch(/^```json/)
175+
expect(result).toMatch(/```$/)
176+
})
177+
178+
it('formatOutputRaw preserves everything', () => {
179+
const text = ' \n\t spaced \n\t '
180+
const result = formatOutputRaw(text)
181+
expect(result).toBe(text)
182+
})
183+
})
184+
185+
describe('security features', () => {
186+
it('detects unsafe content', () => {
187+
expect(isOutputSafe('<script>alert("xss")</script>')).toBe(false)
188+
expect(isOutputSafe('javascript:void(0)')).toBe(false)
189+
expect(isOutputSafe('<div onclick="alert(1)">')).toBe(false)
190+
expect(isOutputSafe('<iframe src="evil">')).toBe(false)
191+
expect(isOutputSafe('Normal text')).toBe(true)
192+
})
193+
194+
it('escapes HTML in unsafe content', () => {
195+
const unsafe = '<script>alert("xss")</script>'
196+
const result = formatOutputSafe(unsafe)
197+
expect(result).not.toContain('<script>')
198+
expect(result).toContain('&lt;script')
199+
expect(result).toContain('&gt;')
200+
})
201+
202+
it('leaves safe content unescaped', () => {
203+
const safe = 'Normal text with no HTML'
204+
const result = formatOutputSafe(safe)
205+
expect(result).toBe(safe)
206+
})
207+
})
208+
209+
describe('error handling', () => {
210+
it('handles errors gracefully', () => {
211+
// Create object that throws on property access
212+
const evil = new Proxy({}, {
213+
get() {
214+
throw new Error('Evil object!')
215+
}
216+
})
217+
218+
const result = formatOutputForDisplay(evil)
219+
expect(result).toContain('[')
220+
expect(() => formatOutputForDisplay(evil)).not.toThrow()
221+
})
222+
223+
it('handles very deep recursion', () => {
224+
let deep: any = { text: 'Found it!' }
225+
for (let i = 0; i < 20; i++) {
226+
deep = { nested: deep }
227+
}
228+
229+
const result = formatOutputForDisplay(deep)
230+
// Should stop at MAX_DEPTH but not crash
231+
expect(result).toBeTruthy()
232+
expect(() => formatOutputForDisplay(deep)).not.toThrow()
233+
})
234+
})
235+
236+
describe('real-world LLM outputs', () => {
237+
it('handles OpenAI format', () => {
238+
const openAIResponse = {
239+
choices: [{
240+
message: {
241+
content: 'AI response here'
242+
}
243+
}]
244+
}
245+
expect(formatOutputForDisplay(openAIResponse)).toBe('AI response here')
246+
})
247+
248+
it('handles Anthropic format', () => {
249+
const anthropicResponse = {
250+
content: [{
251+
text: 'Claude response'
252+
}]
253+
}
254+
expect(formatOutputForDisplay(anthropicResponse)).toBe('Claude response')
255+
})
256+
257+
it('handles streaming chunks', () => {
258+
const chunk = {
259+
delta: {
260+
content: 'Streaming text'
261+
}
262+
}
263+
expect(formatOutputForDisplay(chunk)).toBe('Streaming text')
264+
})
265+
266+
it('handles tool outputs', () => {
267+
const toolOutput = {
268+
result: {
269+
data: {
270+
output: 'Tool execution result'
271+
}
272+
}
273+
}
274+
expect(formatOutputForDisplay(toolOutput)).toBe('Tool execution result')
275+
})
276+
})
277+
})

0 commit comments

Comments
 (0)