Skip to content
Open
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
130 changes: 130 additions & 0 deletions apps/opik-frontend/src/lib/conversationMarkdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +225 to +231
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe you could rename this interface because it conflicts with the one in opik/apps/opik-frontend/src/types/llm.ts


/**
* 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<string, unknown>) &&
((item as Record<string, unknown>).type === "text" ||
(item as Record<string, unknown>).type === "output_text") &&
((item as Record<string, unknown>).type === "text" ?
"text" in (item as Record<string, unknown>) :
"text" in (item as Record<string, unknown>)) &&
typeof ((item as Record<string, unknown>).text) === "string"
);

if (textItems.length > 0) {
contentStr = textItems
.map((item: unknown) => (item as Record<string, unknown>).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<string, unknown>) &&
typeof (item as Record<string, unknown>).content === "string"
);

if (contentItems.length > 0) {
contentStr = contentItems
.map((item: unknown) => (item as Record<string, unknown>).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(`<details${expandedAttribute}>`);
lines.push(`<summary><strong>${capitalizeFirst(displayRole)}</strong></summary>`);
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(`<details style="margin-left: 20px;">`);
lines.push(
`<summary><strong>Tool call: ${toolCall.function.name}</strong></summary>`,
);
lines.push(``);
lines.push(
`&nbsp;&nbsp;&nbsp;&nbsp;**Function:** ${toolCall.function.name}`,
);
lines.push(
`&nbsp;&nbsp;&nbsp;&nbsp;**Arguments:** ${toolCall.function.arguments}`,
);
lines.push(`</details>`);
}

// Add spacing after tool calls
lines.push(`<div style="height: 1px; margin: 4px 0;"></div>`);
}

// 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(`</details>`);
lines.push(`<div style="height: 1px; margin: 4px 0;"></div>`);
}

return lines.join("\n");
};
143 changes: 142 additions & 1 deletion apps/opik-frontend/src/lib/prettifyMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { extractTextFromArray } from "./arrayExtraction";
import {
convertConversationToMarkdown,
ConversationData,
convertLLMMessagesToMarkdown,
LLMMessage,
ToolCall,
} from "./conversationMarkdown";
import { PrettifyMessageResponse, ExtractTextResult } from "./types";

Expand All @@ -22,6 +25,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
Expand Down Expand Up @@ -163,6 +179,8 @@ const isConversationObject = (obj: object): boolean => {
const objRecord = obj as Record<string, unknown>;

// 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) &&
Expand All @@ -173,6 +191,129 @@ const isConversationObject = (obj: object): boolean => {
typeof msg === "object" &&
msg !== null &&
"role" in (msg as Record<string, unknown>),
)
) &&
// 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<string, unknown>;

// 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<string, unknown>)) ||
("type" in (msg as Record<string, unknown>))) &&
"content" in (msg as Record<string, unknown>)
);
}

// 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<string, unknown>) &&
"content" in (msg as Record<string, unknown>)
);
}

// 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<string, unknown>) &&
("content" in (msg as Record<string, unknown>) ||
"type" in (msg as Record<string, unknown>))
);
}

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<string, unknown>;

// Check for messages array
if ("messages" in objRecord && Array.isArray(objRecord.messages)) {
return objRecord.messages.map((msg: unknown) => {
const msgRecord = msg as Record<string, unknown>;
return {
role: msgRecord.role as string,
type: msgRecord.type as string,
content: msgRecord.content as string | unknown[],
tool_calls: isValidToolCallsArray(msgRecord.tool_calls) ? msgRecord.tool_calls : undefined,
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<string, unknown>;
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<string, unknown>;
return {
role: msgRecord.role as string,
type: msgRecord.type as string,
content: msgRecord.content as string | unknown[],
} as LLMMessage;
});
}

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<string, unknown>;
return (
typeof toolCall.id === "string" &&
toolCall.type === "function" &&
typeof toolCall.function === "object" &&
toolCall.function !== null &&
typeof (toolCall.function as Record<string, unknown>).name === "string" &&
typeof (toolCall.function as Record<string, unknown>).arguments === "string"
);
});
};
Loading