Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 7 additions & 106 deletions web/oss/src/components/EvalRunDetails/utils/chatMessages.ts
Original file line number Diff line number Diff line change
@@ -1,119 +1,20 @@
import {ReactNode} from "react"

import {renderChatMessages} from "@/oss/components/EvalRunDetails/utils/renderChatMessages"

const CHAT_ARRAY_KEYS = [
"messages",
"message_history",
"history",
"chat",
"conversation",
"logs",
"responses",
"output_messages",
]

export const tryParseJson = (value: unknown): unknown => {
if (typeof value !== "string") return value
try {
return JSON.parse(value)
} catch {
return value
}
}
import {
extractChatMessages as extractSharedChatMessages,
normalizeChatMessages,
} from "@agenta/ui/cell-renderers"

const isChatEntry = (entry: any): boolean => {
if (!entry || typeof entry !== "object") return false
if (
typeof entry.role === "string" ||
typeof entry.sender === "string" ||
typeof entry.author === "string"
) {
return (
entry.content !== undefined ||
entry.text !== undefined ||
entry.message !== undefined ||
Array.isArray(entry.content) ||
Array.isArray(entry.parts) ||
Array.isArray(entry.tool_calls) ||
typeof entry.delta?.content === "string"
)
}
return false
}

export const extractMessageArray = (value: any): any[] | null => {
if (!value) return null
if (Array.isArray(value)) return value
if (typeof value !== "object") return null

for (const key of CHAT_ARRAY_KEYS) {
if (Array.isArray((value as any)[key])) {
return (value as any)[key]
}
}

if (Array.isArray((value as any)?.choices)) {
const messages = (value.choices as any[])
.map((choice) => choice?.message || choice?.delta)
.filter(Boolean)
if (messages.length) return messages
}

if (isChatEntry(value)) {
return [value]
}

return null
}

export const normalizeMessages = (
messages: any[],
): {role: string; content: any; tool_calls?: any[]}[] => {
return messages
.map((entry) => {
if (!entry) return null
if (typeof entry === "string") {
return {role: "assistant", content: entry}
}

const role =
(typeof entry.role === "string" && entry.role) ||
(typeof entry.sender === "string" && entry.sender) ||
(typeof entry.author === "string" && entry.author) ||
"assistant"

const content =
entry.content ??
entry.text ??
entry.message ??
entry.delta?.content ??
entry.response ??
(Array.isArray(entry.parts) ? entry.parts : undefined)

const toolCalls = Array.isArray(entry.tool_calls) ? entry.tool_calls : undefined

// Accept entry if it has content OR tool_calls
if (content === undefined && !toolCalls) {
return null
}

return {role, content, tool_calls: toolCalls}
})
.filter((entry): entry is {role: string; content: any; tool_calls?: any[]} =>
Boolean(entry?.content !== undefined || entry?.tool_calls),
)
}
import {renderChatMessages} from "@/oss/components/EvalRunDetails/utils/renderChatMessages"

export const renderScenarioChatMessages = (
value: unknown,
keyPrefix: string,
): ReactNode[] | null => {
const parsed = tryParseJson(value)
const messageArray = extractMessageArray(parsed)
const messageArray = extractSharedChatMessages(value)
if (!messageArray) return null

const normalized = normalizeMessages(messageArray)
const normalized = normalizeChatMessages(messageArray)
if (!normalized.length) return null

try {
Expand Down
13 changes: 13 additions & 0 deletions web/oss/tests/manual/cell-renderers/test-extract-chat-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ const run = () => {
const choices = {choices: [{message: {role: "assistant", content: "from choices"}}]}
const single = {role: "assistant", content: "single message"}
const plainJson = {foo: "bar", count: 3}
const stringified = JSON.stringify([
{
role: "user",
content: [
{type: "text", text: "what is this picture"},
{
type: "image_url",
image_url: {url: "data:image/jpeg;base64,AAAA", detail: "auto"},
},
],
},
])

assert.deepEqual(extractChatMessages(validPrompt), [{role: "user", content: "hi"}])
assert.equal(extractChatMessages(nonChatPrompt), null)
Expand All @@ -34,6 +46,7 @@ const run = () => {
assert.deepEqual(extractChatMessages(choices), [{role: "assistant", content: "from choices"}])
assert.deepEqual(extractChatMessages(single), [{role: "assistant", content: "single message"}])
assert.equal(extractChatMessages(plainJson), null)
assert.deepEqual(extractChatMessages(stringified), JSON.parse(stringified))

console.log("extractChatMessages tests passed")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import {memo, useMemo} from "react"

import type {MessageContent} from "@agenta/shared/types"
import {getAttachments} from "@agenta/shared/utils"

import ImagePreview from "../components/presentational/attachments/ImagePreview"

import {DEFAULT_ROLE_COLOR_CLASS, ROLE_COLOR_CLASSES} from "./constants"
import {
extractChatMessages,
Expand Down Expand Up @@ -48,12 +53,22 @@ const getContentString = (content: unknown): string => {
if (content === null || content === undefined) return ""
if (typeof content === "string") return content
if (Array.isArray(content)) {
// Handle OpenAI content array format
const textPart = content.find((c: unknown) => {
const part = c as Record<string, unknown> | null
return part?.type === "text"
}) as Record<string, unknown> | undefined
if (textPart?.text) return String(textPart.text)
const textParts = content
.map((entry: unknown) => {
const part = entry as Record<string, unknown> | null
return part?.type === "text" && typeof part.text === "string" ? part.text : null
})
.filter((part): part is string => Boolean(part?.trim()))

if (textParts.length > 0) {
return textParts.join("\n\n")
}

const hasAttachmentParts = content.some((entry: unknown) => {
const part = entry as Record<string, unknown> | null
return part?.type === "image_url" || part?.type === "file"
})
if (hasAttachmentParts) return ""
}
// Use compact JSON (no pretty printing) to minimize rendered lines
try {
Expand All @@ -63,6 +78,15 @@ const getContentString = (content: unknown): string => {
}
}

const getImageUrls = (content: unknown): string[] => {
if (!Array.isArray(content)) return []

return getAttachments(content as MessageContent)
.filter((attachment) => attachment.type === "image_url")
.map((attachment) => attachment.image_url?.url?.trim())
.filter((url): url is string => Boolean(url))
}

interface SingleMessageProps {
message: {role: string; content: unknown; tool_calls?: unknown[]}
keyPrefix: string
Expand All @@ -82,6 +106,7 @@ const CHARS_PER_LINE = 80
const SingleMessage = memo(
({message, keyPrefix, index, truncate, maxLines, showDivider}: SingleMessageProps) => {
const contentString = useMemo(() => getContentString(message.content), [message.content])
const imageUrls = useMemo(() => getImageUrls(message.content), [message.content])
// Calculate max chars based on maxLines to prevent overflow
const maxChars = maxLines * CHARS_PER_LINE
const displayContent = useMemo(
Expand All @@ -99,6 +124,18 @@ const SingleMessage = memo(
{displayContent && (
<span className="whitespace-pre-wrap break-words block">{displayContent}</span>
)}
{imageUrls.length > 0 && (
<div className="flex flex-wrap gap-2 pt-1">
{imageUrls.map((imageUrl, imageIndex) => (
<ImagePreview
key={`${keyPrefix}-${index}-image-${imageIndex}`}
src={imageUrl}
alt={`Message attachment ${imageIndex + 1}`}
size={truncate ? 36 : 56}
/>
))}
</div>
)}
{message.tool_calls && message.tool_calls.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium">Tool Calls:</span>
Expand Down
14 changes: 14 additions & 0 deletions web/packages/agenta-ui/src/CellRenderers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* Shared utility functions for cell content rendering
*/

import {safeJson5Parse} from "@agenta/shared/utils"

import {DEFAULT_MAX_LINES, MAX_CELL_CHARS} from "./constants"

/**
Expand Down Expand Up @@ -176,6 +178,18 @@ export const extractChatMessages = (
if (depth > 3) return null
if (!value) return null

if (typeof value === "string") {
const trimmed = value.trim()
if (!trimmed) return null

const parsed = safeJson5Parse(trimmed)
if (parsed !== null && parsed !== value) {
return extractChatMessages(parsed, options, depth + 1, seen)
}

return null
}

// Direct array - check if it looks like chat messages
if (Array.isArray(value)) {
// Return array if it has chat-like entries
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
* ```
*/

import {useMemo, useState} from "react"
import {useCallback, useMemo, useState} from "react"
import type {KeyboardEvent, MouseEvent} from "react"

import {MagnifyingGlassPlus} from "@phosphor-icons/react"
import {Modal} from "antd"
Expand Down Expand Up @@ -62,15 +63,34 @@ const ImagePreview = ({
return resolveSafeImagePreviewSrc(src)
}, [src])

const stopPropagation = useCallback((event?: {stopPropagation?: () => void}) => {
event?.stopPropagation?.()
}, [])

const handleOpen = useCallback(
(event: MouseEvent<HTMLDivElement>) => {
stopPropagation(event)
setOpen(true)
},
[stopPropagation],
)

const handleCancel = useCallback(
(event: MouseEvent<HTMLElement> | KeyboardEvent<HTMLElement>) => {
stopPropagation(event)
setOpen(false)
},
[stopPropagation],
)

return (
<>
<div
className={`relative group rounded overflow-hidden cursor-pointer flex-shrink-0 ${className}`}
style={{width: size, height: size}}
onClick={(e) => {
e.stopPropagation()
setOpen(true)
}}
onClick={handleOpen}
onMouseDown={stopPropagation}
onMouseUp={stopPropagation}
>
<ImageWithFallback
src={imageURL}
Expand All @@ -85,7 +105,7 @@ const ImagePreview = ({
<Modal
open={open}
footer={null}
onCancel={() => setOpen(false)}
onCancel={handleCancel}
centered
width={800}
height={600}
Expand Down
Loading