From 2bad86bd6abc56c9976fd3d67ec5db39e870fa87 Mon Sep 17 00:00:00 2001 From: Anuj Soni Date: Mon, 20 Oct 2025 15:11:39 +0530 Subject: [PATCH 1/2] Display all AI SDK LLM message in pretty mode --- .../src/lib/conversationMarkdown.ts | 130 ++++++++++++++++++ apps/opik-frontend/src/lib/prettifyMessage.ts | 119 +++++++++++++++- apps/opik-frontend/src/lib/traces.test.ts | 79 ++++++++++- 3 files changed, 323 insertions(+), 5 deletions(-) diff --git a/apps/opik-frontend/src/lib/conversationMarkdown.ts b/apps/opik-frontend/src/lib/conversationMarkdown.ts index db9275581f3..32abe70584a 100644 --- a/apps/opik-frontend/src/lib/conversationMarkdown.ts +++ b/apps/opik-frontend/src/lib/conversationMarkdown.ts @@ -218,3 +218,133 @@ export const formatTools = (tools: Tool[]): string => { return lines.join("\n"); }; + +/** + * Interface for LLM messages that can be found in various formats + */ +export interface LLMMessage { + role?: string; + type?: string; + content: string | unknown[]; + tool_calls?: ToolCall[]; + tool_call_id?: string; +} + +/** + * Converts an array of LLM messages to markdown with collapsible sections + * @param messages - Array of LLM messages in various formats + * @returns Formatted markdown string with collapsible sections + */ +export const convertLLMMessagesToMarkdown = ( + messages: LLMMessage[], +): string => { + if (!Array.isArray(messages) || messages.length === 0) { + return "No messages to display."; + } + + const lines: string[] = []; + + for (const message of messages) { + if (!isObject(message)) { + continue; + } + + // Determine the role/type for display + const displayRole = message.role || message.type || "message"; + + // Extract content as string + let contentStr = ""; + if (isString(message.content)) { + contentStr = message.content; + } else if (Array.isArray(message.content)) { + // Handle array content (like OpenAI format with text/image objects) + const textItems = message.content.filter( + (item: unknown) => + typeof item === "object" && + item !== null && + "type" in (item as Record) && + ((item as Record).type === "text" || + (item as Record).type === "output_text") && + ((item as Record).type === "text" ? + "text" in (item as Record) : + "text" in (item as Record)) && + typeof ((item as Record).text) === "string" + ); + + if (textItems.length > 0) { + contentStr = textItems + .map((item: unknown) => (item as Record).text as string) + .join(" "); + } else { + // Check for other array content patterns + // Look for any objects with a 'content' field + const contentItems = message.content.filter( + (item: unknown) => + typeof item === "object" && + item !== null && + "content" in (item as Record) && + typeof (item as Record).content === "string" + ); + + if (contentItems.length > 0) { + contentStr = contentItems + .map((item: unknown) => (item as Record).content as string) + .join(" "); + } else { + // If no text items found, try to stringify the array + contentStr = JSON.stringify(message.content); + } + } + } else if (typeof message.content === "object") { + contentStr = JSON.stringify(message.content); + } + + // Determine if this section should be collapsed by default + const isToolCall = displayRole === "tool" || displayRole === "tool_execution_result"; + const shouldCollapse = isToolCall; + + // Add collapsible section + const expandedAttribute = shouldCollapse ? "" : " open"; + lines.push(``); + lines.push(`${capitalizeFirst(displayRole)}`); + lines.push(``); + + // Handle tool calls for assistant messages + if ( + (displayRole === "assistant" || displayRole === "ai") && + message.tool_calls && + message.tool_calls.length > 0 + ) { + for (const toolCall of message.tool_calls) { + lines.push(`
`); + lines.push( + `Tool call: ${toolCall.function.name}`, + ); + lines.push(``); + lines.push( + `    **Function:** ${toolCall.function.name}`, + ); + lines.push( + `    **Arguments:** ${toolCall.function.arguments}`, + ); + lines.push(`
`); + } + + // Add spacing after tool calls + lines.push(`
`); + } + + // Add content if present + if (contentStr.trim()) { + // Strip image tags, keeping only URLs (images are shown in attachments) + contentStr = stripImageTags(contentStr); + lines.push(contentStr.trim()); + } + + // Close the section + lines.push(``); + lines.push(`
`); + } + + return lines.join("\n"); +}; diff --git a/apps/opik-frontend/src/lib/prettifyMessage.ts b/apps/opik-frontend/src/lib/prettifyMessage.ts index b79cff69493..cf722523514 100644 --- a/apps/opik-frontend/src/lib/prettifyMessage.ts +++ b/apps/opik-frontend/src/lib/prettifyMessage.ts @@ -5,6 +5,8 @@ import { extractTextFromArray } from "./arrayExtraction"; import { convertConversationToMarkdown, ConversationData, + convertLLMMessagesToMarkdown, + LLMMessage, } from "./conversationMarkdown"; import { PrettifyMessageResponse, ExtractTextResult } from "./types"; @@ -22,6 +24,19 @@ export const prettifyMessage = ( } as PrettifyMessageResponse; } + // Check if this is an object containing an array of LLM messages + if (isObject(message) && isLLMMessagesObject(message)) { + const messagesArray = extractLLMMessagesArray(message); + if (messagesArray && messagesArray.length > 0) { + const markdown = convertLLMMessagesToMarkdown(messagesArray); + return { + message: markdown, + prettified: true, + renderType: "text", + } as PrettifyMessageResponse; + } + } + // If config is provided, use it for type-specific prettification if (config && config.inputType) { // Handle array input type @@ -163,6 +178,8 @@ const isConversationObject = (obj: object): boolean => { const objRecord = obj as Record; // Check for OpenAI conversation format (with or without model field) + // This should only match full conversation objects with model, tools, or kwargs + // to distinguish from simple message arrays return ( "messages" in objRecord && Array.isArray(objRecord.messages) && @@ -173,6 +190,106 @@ const isConversationObject = (obj: object): boolean => { typeof msg === "object" && msg !== null && "role" in (msg as Record), - ) + ) && + // Must have additional conversation-specific fields to distinguish from simple message arrays + ("model" in objRecord || "tools" in objRecord || "kwargs" in objRecord) ); }; + +/** + * Checks if an object contains an array of LLM messages that should be displayed in pretty mode + * @param obj - The object to check + * @returns True if it contains LLM messages array + */ +const isLLMMessagesObject = (obj: object): boolean => { + if (!isObject(obj)) return false; + + const objRecord = obj as Record; + + // Check for messages array with LLM format + if ("messages" in objRecord && Array.isArray(objRecord.messages)) { + return objRecord.messages.length > 1 && // Only use pretty mode for multiple messages + objRecord.messages.every((msg: unknown) => + typeof msg === "object" && + msg !== null && + (("role" in (msg as Record)) || + ("type" in (msg as Record))) && + "content" in (msg as Record) + ); + } + + // Check for input array (OpenAI Agents format) + if ("input" in objRecord && Array.isArray(objRecord.input)) { + return objRecord.input.length > 1 && // Only use pretty mode for multiple messages + objRecord.input.every((msg: unknown) => + typeof msg === "object" && + msg !== null && + "role" in (msg as Record) && + "content" in (msg as Record) + ); + } + + // Check for output array (OpenAI Agents format) + if ("output" in objRecord && Array.isArray(objRecord.output)) { + return objRecord.output.length > 1 && // Only use pretty mode for multiple messages + objRecord.output.every((msg: unknown) => + typeof msg === "object" && + msg !== null && + "role" in (msg as Record) && + ("content" in (msg as Record) || + "type" in (msg as Record)) + ); + } + + return false; +}; + +/** + * Extracts LLM messages array from various object formats + * @param obj - The object containing messages + * @returns Array of LLM messages or null if not found + */ +const extractLLMMessagesArray = (obj: object): LLMMessage[] | null => { + if (!isObject(obj)) return null; + + const objRecord = obj as Record; + + // Check for messages array + if ("messages" in objRecord && Array.isArray(objRecord.messages)) { + return objRecord.messages.map((msg: unknown) => { + const msgRecord = msg as Record; + return { + role: msgRecord.role as string, + type: msgRecord.type as string, + content: msgRecord.content as string | unknown[], + tool_calls: msgRecord.tool_calls as any, + tool_call_id: msgRecord.tool_call_id as string, + } as LLMMessage; + }); + } + + // Check for input array (OpenAI Agents format) + if ("input" in objRecord && Array.isArray(objRecord.input)) { + return objRecord.input.map((msg: unknown) => { + const msgRecord = msg as Record; + return { + role: msgRecord.role as string, + content: msgRecord.content as string | unknown[], + } as LLMMessage; + }); + } + + // Check for output array (OpenAI Agents format) + if ("output" in objRecord && Array.isArray(objRecord.output)) { + return objRecord.output.map((msg: unknown) => { + const msgRecord = msg as Record; + return { + role: msgRecord.role as string, + type: msgRecord.type as string, + content: msgRecord.content as string | unknown[], + } as LLMMessage; + }); + } + + return null; +}; diff --git a/apps/opik-frontend/src/lib/traces.test.ts b/apps/opik-frontend/src/lib/traces.test.ts index a02fcc69742..274b7ffba02 100644 --- a/apps/opik-frontend/src/lib/traces.test.ts +++ b/apps/opik-frontend/src/lib/traces.test.ts @@ -151,10 +151,16 @@ describe("prettifyMessage", () => { }; const result = prettifyMessage(message); expect(result).toEqual({ - message: "AI response 2", + message: expect.stringContaining("
"), prettified: true, renderType: "text", }); + expect(result.message).toContain("User question"); + expect(result.message).toContain("AI response 1"); + expect(result.message).toContain("Follow-up question"); + expect(result.message).toContain("AI response 2"); + expect(result.message).toContain("Human"); + expect(result.message).toContain("Ai"); }); it("uses default input type when config is not provided", () => { @@ -285,10 +291,17 @@ describe("prettifyMessage", () => { }; const result = prettifyMessage(message); expect(result).toEqual({ - message: "User message 1\n\n ----------------- \n\nUser message 2", + message: expect.stringContaining("
"), prettified: true, renderType: "text", }); + expect(result.message).toContain("System message"); + expect(result.message).toContain("User message 1"); + expect(result.message).toContain("Assistant message"); + expect(result.message).toContain("User message 2"); + expect(result.message).toContain("System"); + expect(result.message).toContain("User"); + expect(result.message).toContain("Assistant"); }); it("handles OpenAI Agents output message with multiple assistant outputs", () => { @@ -309,13 +322,71 @@ describe("prettifyMessage", () => { }; const result = prettifyMessage(message); expect(result).toEqual({ - message: - "Assistant response 1\n\n ----------------- \n\nAssistant response 2", + message: expect.stringContaining("
"), + prettified: true, + renderType: "text", + }); + expect(result.message).toContain("Assistant response 1"); + expect(result.message).toContain("User message"); + expect(result.message).toContain("Assistant response 2"); + expect(result.message).toContain("Assistant"); + expect(result.message).toContain("User"); + }); + + it("maintains backward compatibility for single message arrays", () => { + const message = { messages: [{ content: "Single response" }] }; + const result = prettifyMessage(message); + expect(result).toEqual({ + message: "Single response", prettified: true, renderType: "text", }); }); + it("displays single LangGraph message without collapsible sections", () => { + const message = { + messages: [{ type: "ai", content: "Single AI response" }], + }; + const result = prettifyMessage(message); + expect(result).toEqual({ + message: "Single AI response", + prettified: true, + renderType: "text", + }); + }); + + it("displays tool call messages collapsed by default", () => { + const message = { + messages: [ + { role: "user", content: "Use the calculator" }, + { role: "tool", content: "Calculator result: 42" }, + ], + }; + const result = prettifyMessage(message); + // Check that user message is expanded + expect(result.message).toContain("
"); + expect(result.message).toContain("User"); + // Check that tool message is collapsed (no 'open' attribute) + expect(result.message).toMatch(/
\s*Tool<\/strong><\/summary>/); + expect(result.message).toContain("Calculator result: 42"); + }); + + it("displays tool_execution_result messages collapsed by default", () => { + const message = { + messages: [ + { role: "user", content: "Use the calculator" }, + { role: "tool_execution_result", content: "Calculation completed" }, + ], + }; + const result = prettifyMessage(message); + // Check that user message is expanded + expect(result.message).toContain("
"); + expect(result.message).toContain("User"); + // Check that tool_execution_result message is collapsed (no 'open' attribute) + expect(result.message).toMatch(/
\s*Tool_execution_result<\/strong><\/summary>/); + expect(result.message).toContain("Calculation completed"); + }); + it("handles Demo project blocks structure with text content", () => { const message = { role: "assistant", From 850f9fe839b3f5a97b9a945df6610df1d0978475 Mon Sep 17 00:00:00 2001 From: Anuj Soni Date: Tue, 21 Oct 2025 00:05:04 +0530 Subject: [PATCH 2/2] resolved copilot comments --- apps/opik-frontend/src/lib/prettifyMessage.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/opik-frontend/src/lib/prettifyMessage.ts b/apps/opik-frontend/src/lib/prettifyMessage.ts index cf722523514..21b5385e9b8 100644 --- a/apps/opik-frontend/src/lib/prettifyMessage.ts +++ b/apps/opik-frontend/src/lib/prettifyMessage.ts @@ -7,6 +7,7 @@ import { ConversationData, convertLLMMessagesToMarkdown, LLMMessage, + ToolCall, } from "./conversationMarkdown"; import { PrettifyMessageResponse, ExtractTextResult } from "./types"; @@ -262,7 +263,7 @@ const extractLLMMessagesArray = (obj: object): LLMMessage[] | null => { role: msgRecord.role as string, type: msgRecord.type as string, content: msgRecord.content as string | unknown[], - tool_calls: msgRecord.tool_calls as any, + tool_calls: isValidToolCallsArray(msgRecord.tool_calls) ? msgRecord.tool_calls : undefined, tool_call_id: msgRecord.tool_call_id as string, } as LLMMessage; }); @@ -293,3 +294,26 @@ const extractLLMMessagesArray = (obj: object): LLMMessage[] | null => { return null; }; + +/** + * Type guard to check if a value is a valid ToolCall array + * @param value - The value to check + * @returns True if value is a valid ToolCall array + */ +const isValidToolCallsArray = (value: unknown): value is ToolCall[] => { + if (!Array.isArray(value)) return false; + + return value.every((item: unknown) => { + if (typeof item !== "object" || item === null) return false; + + const toolCall = item as Record; + return ( + typeof toolCall.id === "string" && + toolCall.type === "function" && + typeof toolCall.function === "object" && + toolCall.function !== null && + typeof (toolCall.function as Record).name === "string" && + typeof (toolCall.function as Record).arguments === "string" + ); + }); +};