Skip to content

Commit fbc5fcb

Browse files
authored
Multimodal output support (#1826)
1 parent a69a908 commit fbc5fcb

File tree

3 files changed

+109
-18
lines changed

3 files changed

+109
-18
lines changed

apps/web/src/components/ChatWrapper/Message/index.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@ import {
99
ToolContent,
1010
} from '@latitude-data/constants/legacyCompiler'
1111

12-
import { AgentToolsMap, isSafeUrl } from '@latitude-data/constants'
12+
import {
13+
AgentToolsMap,
14+
isEncodedImage,
15+
isInlineImage,
16+
isSafeUrl,
17+
} from '@latitude-data/constants'
1318
import { Badge, BadgeProps } from '@latitude-data/web-ui/atoms/Badge'
1419
import { Button } from '@latitude-data/web-ui/atoms/Button'
1520
import { CodeBlock } from '@latitude-data/web-ui/atoms/CodeBlock'
@@ -648,13 +653,30 @@ const ContentImage = memo(
648653
collapseParameters?: boolean
649654
sourceMap?: PromptlSourceRef[]
650655
}) => {
656+
const TextComponent = size === 'small' ? Text.H6 : Text.H5
651657
const segment = useMemo(
652658
() => computeSegments('image', image.toString(), sourceMap, parameters),
653659
[image, sourceMap, parameters],
654660
)[0]
655661

656662
if (!isSafeUrl(image)) {
657-
const TextComponent = size === 'small' ? Text.H6 : Text.H5
663+
if (isInlineImage(image)) {
664+
return (
665+
<Image
666+
src={image.toString()}
667+
className='max-h-72 rounded-xl w-fit object-contain'
668+
/>
669+
)
670+
}
671+
672+
if (isEncodedImage(image)) {
673+
return (
674+
<Image
675+
src={`data:image/png;base64,${image.toString()}`}
676+
className='max-h-72 rounded-xl w-fit object-contain'
677+
/>
678+
)
679+
}
658680

659681
return (
660682
<div className='flex flex-row p-4 gap-2 bg-muted rounded-xl w-fit items-center'>
@@ -670,8 +692,6 @@ const ContentImage = memo(
670692
)
671693
}
672694

673-
const TextComponent = size === 'small' ? Text.H6 : Text.H5
674-
675695
if (!segment || typeof segment === 'string') {
676696
return (
677697
<Image
@@ -719,6 +739,7 @@ const ContentFile = memo(
719739
collapseParameters?: boolean
720740
sourceMap?: PromptlSourceRef[]
721741
}) => {
742+
const TextComponent = size === 'small' ? Text.H6 : Text.H5
722743
const segment = useMemo(
723744
() => computeSegments('file', file.toString(), sourceMap, parameters),
724745
[file, sourceMap, parameters],
@@ -735,8 +756,6 @@ const ContentFile = memo(
735756
)
736757
}
737758

738-
const TextComponent = size === 'small' ? Text.H6 : Text.H5
739-
740759
if (!segment || typeof segment === 'string') {
741760
return <FileComponent src={file.toString()} />
742761
}

apps/web/src/hooks/playgroundChat/useProviderEventHandler.ts

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { tokenizeText } from '$/lib/tokenize'
22
import { ChainEvent } from '@latitude-data/constants'
33
import {
4+
AssistantMessage,
5+
FileContent,
6+
ImageContent,
47
Message,
58
MessageContent,
69
MessageRole,
710
ToolCall,
811
ToolRequestContent,
912
} from '@latitude-data/constants/legacyCompiler'
13+
import { StreamEventTypes } from '@latitude-data/core/constants'
1014
import { ParsedEvent } from 'eventsource-parser/stream'
1115
import React, { useCallback } from 'react'
12-
import { StreamEventTypes } from '@latitude-data/core/constants'
1316

1417
type SetMessagesFunction = React.Dispatch<React.SetStateAction<Message[]>>
1518
type SetUnrespondedToolCallsFunction = React.Dispatch<
@@ -183,17 +186,13 @@ export function useProviderEventHandler({
183186

184187
// Helper function to handle tool-result events
185188
const handleToolResult = useCallback(
186-
(
187-
data: {
188-
type: 'tool-result'
189-
} & {
190-
type: 'tool-result'
191-
toolCallId: string
192-
toolName: string
193-
args: any
194-
result: any
195-
},
196-
) => {
189+
(data: {
190+
type: 'tool-result'
191+
toolCallId: string
192+
toolName: string
193+
args: any
194+
result: any
195+
}) => {
197196
setMessages((messages) => {
198197
const lastMessage = messages.at(-1)!
199198
if (lastMessage.role === MessageRole.assistant) {
@@ -293,6 +292,54 @@ export function useProviderEventHandler({
293292
[setMessages, incrementUsageDelta],
294293
)
295294

295+
const handleFile = useCallback(
296+
({
297+
file: { mediaType, base64Data, base64, uint8Array },
298+
}: {
299+
type: 'file'
300+
file: {
301+
mediaType: string
302+
base64Data?: string
303+
base64?: string
304+
uint8Array?: Uint8Array
305+
}
306+
}) => {
307+
const mediaData = base64Data ?? base64 ?? uint8Array?.toString() ?? ''
308+
309+
const content = mediaType?.startsWith('image/')
310+
? ({
311+
type: 'image',
312+
image: mediaData,
313+
} as ImageContent)
314+
: ({
315+
type: 'file',
316+
file: mediaData,
317+
mimeType: mediaType,
318+
} as FileContent)
319+
320+
setMessages((messages) => {
321+
const lastMessage = messages.at(-1)!
322+
if (lastMessage.role === MessageRole.assistant) {
323+
return [
324+
...messages.slice(0, -1),
325+
{
326+
...lastMessage,
327+
content: [...(lastMessage.content ?? []), content],
328+
} as AssistantMessage,
329+
]
330+
} else {
331+
// Should not be possible
332+
throw new Error('Expected assistant message')
333+
}
334+
})
335+
336+
incrementUsageDelta({
337+
completionTokens: tokenizeText(mediaData),
338+
})
339+
},
340+
[setMessages, incrementUsageDelta],
341+
)
342+
296343
// Main handler that delegates to the appropriate helper based on event type
297344
const handleProviderEvent = useCallback(
298345
(parsedEvent: ParsedEvent, data: ChainEvent['data']) => {
@@ -314,6 +361,9 @@ export function useProviderEventHandler({
314361
case 'reasoning':
315362
handleReasoning(data)
316363
break
364+
case 'file':
365+
handleFile(data)
366+
break
317367
}
318368
},
319369
[
@@ -322,6 +372,7 @@ export function useProviderEventHandler({
322372
handleToolCall,
323373
handleToolResult,
324374
handleReasoning,
375+
handleFile,
325376
],
326377
)
327378

packages/constants/src/helpers.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,27 @@ export function simplifyDocument(
6666
}
6767
}
6868

69+
const INLINE_IMAGE_REGEX = /^data:image\/[^;]+;base64,/i
70+
export function isInlineImage(src: unknown): boolean {
71+
if (!src) return false
72+
73+
return INLINE_IMAGE_REGEX.test(src.toString().trim())
74+
}
75+
76+
export function isEncodedImage(src: unknown): boolean {
77+
if (!src) return false
78+
79+
const clean = src.toString().trim().replace(/\s+/g, '')
80+
const pad = clean.length % 4
81+
const decoded = pad ? clean + '==='.slice(pad) : clean
82+
83+
try {
84+
return !!atob(decoded)
85+
} catch {
86+
return false
87+
}
88+
}
89+
6990
export function isSafeUrl(url: unknown): url is string | URL {
7091
const isUrl =
7192
url instanceof URL ||

0 commit comments

Comments
 (0)