diff --git a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx index 4ada672cfb..39851eff74 100644 --- a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx +++ b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx @@ -1,4 +1,4 @@ -import {memo, useEffect, useLayoutEffect, useMemo} from "react" +import {memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react" import { Editor as EditorWrapper, @@ -7,7 +7,7 @@ import { SET_MARKDOWN_VIEW, } from "@agenta/ui" import { - extractChatMessages, + isChatMessagesArray, normalizeChatMessages, ROLE_COLOR_CLASSES, DEFAULT_ROLE_COLOR_CLASS, @@ -16,37 +16,92 @@ import { /** * "Beautified JSON" view. * - * ## What this mode does - * * Reshapes data for readability and renders it OUTSIDE the JSON editor as a * component tree: * - * - chat-like arrays and single messages → chat bubbles (role label + content + * - chat-like arrays and single messages -> chat bubbles (role label + content * editor with markdown support) - * - plain objects → per-key labeled variable fields (recursive; short leaves - * inline as `key: value`) + * - plain objects -> per-key labeled fields with collapse/expand, count badges, + * indent guides, and hover-to-copy + * - short leaf values (null, bool, number, short string) inline as + * `key: value` with type-colored values * - known envelope patterns (AI SDK `{type: "text"|"tool-call"|"tool-result"}`) * are unwrapped into their payload - * - noisy provider-metadata keys (`providerOptions`, `rawHeaders`, `rawCall`, - * `rawResponse`, `logprobs`, etc.) are stripped from objects - * - * ## What this mode is NOT + * - noisy provider-metadata keys (`providerOptions`, `rawHeaders`, etc.) are + * stripped from objects * - * "Beautified JSON" is not JSON — it hides fields, restructures values, and + * "Beautified JSON" is not JSON -- it hides fields, restructures values, and * renders via custom React components. When the exact shape of the wire data * matters, use "JSON" (faithful) or "Decoded JSON" (faithful shape with * string decoding, see `decodedJsonHelpers.ts`). - * - * This mode is the default when `viewModePreset="message"`, because that - * preset is used specifically for chat-style data where the reshape is - * desirable. Everywhere else, "Decoded JSON" is the default. - * - * ## Authoritative reference - * - * `VIEW_MODES.md` in this folder documents every view mode and the rules - * for choosing a default. Keep it in sync when you change behavior here. */ +// Keep in sync with MESSAGE_KEY_HINTS in messagePanels.ts +const CHAT_ARRAY_KEYS = new Set([ + "prompt", + "input_messages", + "completion", + "output_messages", + "responses", + "messages", + "message_history", + "history", + "chat", + "conversation", + "logs", +]) + +// Matches isChatEntry in @agenta/ui/cell-renderers — accept the same role +// aliases (sender, author) and content aliases (text, message, parts, etc.) +const isSingleMessage = (value: unknown): boolean => { + if (!value || typeof value !== "object" || Array.isArray(value)) return false + const obj = value as Record + const hasRole = + typeof obj.role === "string" || + typeof obj.sender === "string" || + typeof obj.author === "string" + if (!hasRole) return false + return ( + obj.content !== undefined || + obj.text !== undefined || + obj.message !== undefined || + Array.isArray(obj.content) || + Array.isArray(obj.parts) || + Array.isArray(obj.tool_calls) || + typeof (obj.delta as Record)?.content === "string" + ) +} + +const shallowExtractChatMessages = ( + value: unknown, +): {messages: unknown[]; viaKey?: string} | null => { + if (Array.isArray(value) && isChatMessagesArray(value)) { + return {messages: value} + } + if (isSingleMessage(value)) { + return {messages: [value]} + } + if (value && typeof value === "object" && !Array.isArray(value)) { + const obj = value as Record + for (const key of CHAT_ARRAY_KEYS) { + const arr = obj[key] + if (Array.isArray(arr) && isChatMessagesArray(arr)) { + return {messages: arr, viaKey: key} + } + } + // OpenAI choices format: {choices: [{message: {role, content}}, ...]} + if (Array.isArray(obj.choices)) { + const extracted = (obj.choices as Record[]) + .map((c) => c?.message ?? c?.delta) + .filter(Boolean) + if (extracted.length > 0 && isChatMessagesArray(extracted)) { + return {messages: extracted, viaKey: "choices"} + } + } + } + return null +} + const METADATA_NOISE_KEYS = new Set([ "providerOptions", "experimental_providerMetadata", @@ -59,45 +114,227 @@ const METADATA_NOISE_KEYS = new Set([ "logprobs", ]) -const EDITOR_RESET_CLASSES = - "!min-h-0 [&_.editor-inner]:!border-0 [&_.editor-inner]:!rounded-none [&_.editor-inner]:!min-h-0 [&_.editor-container]:!bg-transparent [&_.editor-container]:!min-h-0 [&_.editor-input]:!min-h-0 [&_.editor-input]:!px-0 [&_.editor-input]:!py-0 [&_.editor-paragraph]:!mb-1 [&_.editor-paragraph:last-child]:!mb-0 [&_.agenta-editor-wrapper]:!min-h-0" +const EDITOR_RESET_CLASSES = [ + "!min-h-0", + "[&_.editor-inner]:!border-0 [&_.editor-inner]:!rounded-none [&_.editor-inner]:!min-h-0", + "[&_.editor-container]:!bg-transparent [&_.editor-container]:!min-h-0", + "[&_.editor-input]:!min-h-0 [&_.editor-input]:!px-0 [&_.editor-input]:!py-0", + "[&_.editor-paragraph]:!mb-1 [&_.editor-paragraph:last-child]:!mb-0", + "[&_.agenta-editor-wrapper]:!min-h-0", +].join(" ") const DEFAULT_MAX_RENDER_DEPTH = 5 +const DEFAULT_EXPAND_DEPTH = 2 + +const tryParseJsonString = (value: string): unknown | null => { + const trimmed = value.trim() + if (!trimmed || (trimmed[0] !== "{" && trimmed[0] !== "[")) return null + try { + const parsed = JSON.parse(trimmed) + return typeof parsed === "object" && parsed !== null ? parsed : null + } catch { + return null + } +} + +const getTextPartContent = (rec: Record): string | null => { + if (rec.type !== "text") return null + if (typeof rec.text === "string") return rec.text + if (typeof rec.content === "string") return rec.content + return null +} + +const getNormalizedToolArgs = (raw: unknown): unknown => { + if (typeof raw === "string") { + return tryParseJsonString(raw) ?? raw + } + return raw +} + +const getNormalizedToolResult = (raw: unknown): unknown => { + if (typeof raw === "string") { + return tryParseJsonString(raw) ?? raw + } + return raw +} + +const formatToolCall = (name: string, rawArgs: unknown): string => { + const args = getNormalizedToolArgs(rawArgs) + if (!args || (typeof args === "object" && Object.keys(args as object).length === 0)) { + return `${name}()` + } + try { + return `${name}(${JSON.stringify(args, null, 2)})` + } catch { + return `${name}(...)` + } +} + +const getToolCallPartText = (rec: Record): string | null => { + if (rec.type !== "tool-call" && rec.type !== "tool_call") return null + const name = rec.toolName ?? rec.name + if (typeof name !== "string") return null + return formatToolCall(name, rec.input ?? rec.args ?? rec.arguments) +} + +const getToolResultPartText = (rec: Record): string | null => { + if (rec.type !== "tool-result" && rec.type !== "tool_result") return null + const output = rec.output as Record | undefined + const value = getNormalizedToolResult(output?.value ?? output ?? rec.result ?? rec.content) + return typeof value === "string" ? value : JSON.stringify(value, null, 2) +} + +interface StructuredMessagePart { + kind: "tool-call" | "tool-result" + name: string + value: unknown +} + +const isToolCallPart = (rec: Record): boolean => + rec.type === "tool-call" || rec.type === "tool_call" + +const isToolResultPart = (rec: Record): boolean => + rec.type === "tool-result" || + rec.type === "tool_result" || + rec.type === "tool-call-response" || + rec.type === "tool_call_response" + +const getStructuredMessagePart = (part: unknown): StructuredMessagePart | null => { + if (!part || typeof part !== "object" || Array.isArray(part)) return null + + const rec = part as Record + if (isToolCallPart(rec)) { + const name = rec.toolName ?? rec.name + const rawArgs = rec.input ?? rec.args ?? rec.arguments + return { + kind: "tool-call", + name: typeof name === "string" && name ? name : "tool call", + value: getNormalizedToolArgs(rawArgs), + } + } + + if (isToolResultPart(rec)) { + const output = rec.output as Record | undefined + const name = rec.toolName ?? rec.name + const result = getNormalizedToolResult(output?.value ?? output ?? rec.result ?? rec.content) + return { + kind: "tool-result", + name: `${typeof name === "string" && name ? name : "tool"} result`, + value: result, + } + } + + return null +} + +const getMessagePartText = (part: unknown): string | null => { + if (!part || typeof part !== "object" || Array.isArray(part)) { + return part === null || part === undefined ? "" : String(part) + } + + const rec = part as Record + return ( + getTextPartContent(rec) ?? + getToolCallPartText(rec) ?? + getToolResultPartText(rec) ?? + (typeof rec.text === "string" ? rec.text : null) ?? + (typeof rec.content === "string" ? rec.content : null) + ) +} + +const getStructuredMessageText = (content: unknown): string | null => { + if (!content || typeof content !== "object") return null + + if (Array.isArray(content)) { + const parts = content + .map(getMessagePartText) + .filter((part): part is string => part !== null) + return parts.length > 0 ? parts.join("\n") : null + } + + return getMessagePartText(content) +} + +const getMessageContentParts = (content: unknown): unknown[] | null => { + if (Array.isArray(content)) return content + + if (typeof content === "string") { + const parsed = tryParseJsonString(content) + if (Array.isArray(parsed)) return parsed + if ( + parsed && + typeof parsed === "object" && + Array.isArray((parsed as Record).parts) + ) { + return (parsed as Record).parts as unknown[] + } + } + + if (content && typeof content === "object" && !Array.isArray(content)) { + const rec = content as Record + if (Array.isArray(rec.parts)) return rec.parts + } + + return null +} + +const getMessageContentDisplay = ( + content: unknown, +): {text: string; structuredParts: StructuredMessagePart[]} => { + const parts = getMessageContentParts(content) + if (!parts) { + return {text: getMessageText(content), structuredParts: []} + } + + const textParts: string[] = [] + const structuredParts: StructuredMessagePart[] = [] + + for (const part of parts) { + const structuredPart = getStructuredMessagePart(part) + if (structuredPart) { + structuredParts.push(structuredPart) + continue + } + + const textPart = getMessagePartText(part) + if (textPart) textParts.push(textPart) + } + + return {text: textParts.join("\n"), structuredParts} +} -/** - * Simplify a value by unwrapping known envelope patterns. - * Returns the simplified value, or the original if no simplification applies. - */ const simplifyValue = (value: unknown): unknown => { if (!value || typeof value !== "object") return value const rec = value as Record - // AI SDK text part: {type: "text", text: "hello"} → "hello" - if (rec.type === "text" && typeof rec.text === "string") { - return rec.text + // Text part: AI SDK uses {text}; PydanticAI/OpenAI parts may use {content}. + const textPartContent = getTextPartContent(rec) + if (textPartContent !== null) { + return textPartContent } - // AI SDK tool-call: {type: "tool-call", toolName: "fn", input: {...}} → "fn({...})" - if (rec.type === "tool-call" && typeof rec.toolName === "string") { - const args = rec.input ?? rec.args - if (!args || (typeof args === "object" && Object.keys(args as object).length === 0)) { - return `${rec.toolName}()` - } - try { - return `${rec.toolName}(${JSON.stringify(args, null, 2)})` - } catch { - return `${rec.toolName}(...)` + // Tool-call part: AI SDK uses tool-call/toolName/input; PydanticAI uses tool_call/name/arguments. + if (isToolCallPart(rec)) { + return { + ...(rec.id ? {id: rec.id} : {}), + name: rec.toolName ?? rec.name ?? "tool", + arguments: getNormalizedToolArgs(rec.input ?? rec.args ?? rec.arguments), } } - // AI SDK tool-result envelope: {type: "tool-result", output: {type: "json", value: X}} → X - if (rec.type === "tool-result" && rec.output !== undefined) { + // Tool result/response envelope: unwrap to its structured result payload. + if (isToolResultPart(rec)) { const output = rec.output as Record | undefined - if (output && typeof output === "object" && output.value !== undefined) { - return output.value + const result = getNormalizedToolResult(output?.value ?? output ?? rec.result ?? rec.content) + if (rec.id || rec.name || rec.toolName) { + return { + ...(rec.id ? {id: rec.id} : {}), + name: rec.toolName ?? rec.name ?? "tool", + result, + } } - return rec.output + return result } // Single-element array of a simplifiable item @@ -106,7 +343,7 @@ const simplifyValue = (value: unknown): unknown => { if (simplified !== value[0]) return simplified } - // Multi-element array: simplify each element + // Multi-element array: simplify each element, join strings if (Array.isArray(value) && value.length > 1) { const simplified = value.map(simplifyValue) const changed = simplified.some((s, i) => s !== value[i]) @@ -122,17 +359,13 @@ const simplifyValue = (value: unknown): unknown => { if (!Array.isArray(value)) { const keys = Object.keys(rec) const noiseKeys = keys.filter((k) => METADATA_NOISE_KEYS.has(k)) - if (noiseKeys.length > 0 && noiseKeys.length < keys.length) { + if (noiseKeys.length > 0) { const cleaned: Record = {} for (const k of keys) { if (!METADATA_NOISE_KEYS.has(k)) { cleaned[k] = rec[k] } } - const cleanedKeys = Object.keys(cleaned) - if (cleanedKeys.length === 1) { - return cleaned[cleanedKeys[0]] - } return cleaned } } @@ -140,12 +373,6 @@ const simplifyValue = (value: unknown): unknown => { return value } -const formatLabel = (key: string): string => - key - .replace(/_/g, " ") - .replace(/([a-z])([A-Z])/g, "$1 $2") - .replace(/\b\w/g, (c) => c.toUpperCase()) - const isShortLeaf = (value: unknown): boolean => { if (value === null || value === undefined) return true if (typeof value === "boolean" || typeof value === "number") return true @@ -153,21 +380,6 @@ const isShortLeaf = (value: unknown): boolean => { return false } -const InlineKeyValue = memo(function InlineKeyValue({ - label, - value, -}: { - label: string - value: string -}) { - return ( -
- {label} - {value || "—"} -
- ) -}) - const MarkdownModeSync = ({isMarkdownView}: {isMarkdownView: boolean}) => { const [editor] = useLexicalComposerContext() @@ -187,43 +399,21 @@ const MarkdownModeSync = ({isMarkdownView}: {isMarkdownView: boolean}) => { const getMessageText = (content: unknown): string => { if (content === null || content === undefined) return "" - if (typeof content === "string") return content + if (typeof content === "string") { + const parsed = tryParseJsonString(content) + const parsedText = parsed ? getStructuredMessageText(parsed) : null + return parsedText ?? content + } if (content && typeof content === "object" && !Array.isArray(content)) { const rec = content as Record - if (rec.type === "text" && typeof rec.text === "string") return rec.text - if (rec.type === "tool-call" && typeof rec.toolName === "string") { - const args = rec.args ?? rec.input - return args ? `${rec.toolName}(${JSON.stringify(args, null, 2)})` : String(rec.toolName) - } - if (rec.type === "tool-result") { - const output = rec.output as Record | undefined - const value = output?.value ?? output ?? rec.result - return typeof value === "string" ? value : JSON.stringify(value, null, 2) - } + const partText = getMessagePartText(rec) + if (partText !== null) return partText } if (Array.isArray(content)) { - const parts: string[] = [] - for (const c of content) { - const rec = c as Record | null - if (!rec || typeof rec !== "object") { - parts.push(String(c)) - continue - } - if (rec.type === "text" && typeof rec.text === "string") { - parts.push(rec.text) - } else if (rec.type === "tool-call" && typeof rec.toolName === "string") { - parts.push(`[tool: ${rec.toolName}]`) - } else if (rec.type === "tool-result") { - const output = rec.output as Record | undefined - const value = output?.value ?? output ?? rec.result - parts.push(typeof value === "string" ? value : JSON.stringify(value, null, 2)) - } else if (typeof rec.text === "string") { - parts.push(rec.text) - } - } - if (parts.length > 0) return parts.join("\n") + const partsText = getStructuredMessageText(content) + if (partsText !== null) return partsText } try { @@ -233,294 +423,678 @@ const getMessageText = (content: unknown): string => { } } -const RenderedChatMessages = memo(function RenderedChatMessages({ - messages, - keyPrefix, +const valueToString = (value: unknown): string => { + if (value === null || value === undefined) return "" + if (typeof value === "string") return value + if (typeof value === "number" || typeof value === "boolean") return String(value) + try { + return JSON.stringify(value, null, 2) + } catch { + return String(value) + } +} + +// ── Icons (static, no props — defined as elements to skip component overhead) ─ + +const CHEVRON_ICON = ( + +) + +const COPY_ICON = ( + +) + +// ── Small components ──────────────────────────────────────────────────── + +const ScalarValue = ({value}: {value: unknown}) => { + if (value === null || value === undefined) { + return ( + + null + + ) + } + if (typeof value === "boolean") { + return ( + + {String(value)} + + ) + } + if (typeof value === "number") { + return ( + + {String(value)} + + ) + } + if (typeof value === "string") { + return {value} + } + return null +} + +const CopyButton = ({value}: {value: unknown}) => { + const [copied, setCopied] = useState(false) + const timerRef = useRef(null) + + useEffect( + () => () => { + if (timerRef.current !== null) window.clearTimeout(timerRef.current) + }, + [], + ) + + const handleCopy = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation() + const text = typeof value === "string" ? value : JSON.stringify(value, null, 2) || "" + if (!navigator.clipboard) return + try { + await navigator.clipboard.writeText(text) + setCopied(true) + if (timerRef.current !== null) window.clearTimeout(timerRef.current) + timerRef.current = window.setTimeout(() => setCopied(false), 1200) + } catch {} + }, + [value], + ) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + e.stopPropagation() + }, []) + + return ( + + ) +} + +// ── NodeRow: unified row structure ────────────────────────────────────── +// +// Every row has the same layout: +// [chevron 14px] [key (mono)] [meta or inline value] [copy-on-hover] +// so keys align in a single column at every depth. Containers get an +// interactive chevron; leaves get an invisible 14px spacer. + +const NodeRow = memo(function NodeRow({ + keyLabel, + meta, + inlineValue, + body, + collapsible, + defaultOpen = true, + value, + isSection, + isMessage, }: { - messages: unknown[] - keyPrefix: string + keyLabel: React.ReactNode + meta?: string + inlineValue?: React.ReactNode + body?: React.ReactNode + collapsible?: boolean + defaultOpen?: boolean + value?: unknown + isSection?: boolean + isMessage?: boolean }) { - const normalized = useMemo(() => normalizeChatMessages(messages), [messages]) + const [open, setOpen] = useState(defaultOpen) + const toggle = useCallback(() => setOpen((o) => !o), []) + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.target !== e.currentTarget) return + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + setOpen((o) => !o) + } + }, []) return ( -
- {normalized.map((msg, i) => { - const roleColor = - ROLE_COLOR_CLASSES[msg.role.toLowerCase()] ?? DEFAULT_ROLE_COLOR_CLASS - const text = getMessageText(msg.content) - const editorId = `${keyPrefix}-msg-${i}` - - return ( -
- - {msg.role} +
+
+ + {collapsible && ( + + {CHEVRON_ICON} - + + {isMessage ? ( + {keyLabel} + ) : ( + + {keyLabel} + + )} + + {meta ? ( + + {meta} + + ) : null} + + {inlineValue ? ( + + {inlineValue} + + ) : null} + + {value !== undefined ? : null} +
+ + {body && open ? ( + // border-0 then border-l then border-solid: Ant Design's CSS layer + // overrides Tailwind preflight, so border-style must be set explicitly. +
+ {body} +
+ ) : null} +
+ ) +}) + +// ── TruncatedMessageBody ─────────────────────────────────────────────── +// +// Wraps the editor for a chat message. When the content exceeds +// TRUNCATE_HEIGHT_PX the body is clamped with a bottom fade overlay and +// a centered "Show more" pill. Clicking the pill expands to full height +// and shows a "Show less" pill at the bottom. + +const TRUNCATE_HEIGHT_PX = 160 + +const PILL_BUTTON_CLASSES = + "text-[11px] font-medium text-[var(--ant-color-text-secondary)] bg-[var(--ant-color-fill-quaternary)] hover:bg-[var(--ant-color-fill-tertiary)] border border-solid border-[var(--ant-color-border-secondary)] rounded-full px-3 py-0.5 cursor-pointer focus-visible:ring-1 focus-visible:ring-[var(--ant-color-primary)] focus-visible:outline-none motion-safe:transition-colors" + +const TruncatedMessageBody = memo(function TruncatedMessageBody({ + editorId, + text, +}: { + editorId: string + text: string +}) { + const measureRef = useRef(null) + const [needsTruncation, setNeedsTruncation] = useState(false) + const [expanded, setExpanded] = useState(false) + + useEffect(() => { + setExpanded(false) + const el = measureRef.current + if (!el) return + const check = () => setNeedsTruncation(el.scrollHeight > TRUNCATE_HEIGHT_PX + 8) + check() + const observer = new ResizeObserver(check) + observer.observe(el) + return () => observer.disconnect() + }, [text]) + + const toggle = useCallback(() => setExpanded((v) => !v), []) + + const isTruncated = needsTruncation && !expanded + + return ( +
+
+
+ + + + +
+
+ + {isTruncated ? ( +
+ + ) : null} + + {needsTruncation && expanded ? ( +
+ +
+ ) : null}
) }) -const valueToString = (value: unknown): string => { - if (value === null || value === undefined) return "" - if (typeof value === "string") return value - if (typeof value === "number" || typeof value === "boolean") return String(value) - try { - return JSON.stringify(value, null, 2) - } catch { - return String(value) +// ── Tool call helpers ─────────────────────────────────────────────────── + +const getToolCallName = (tc: unknown): string => { + const obj = tc as Record | null + const fn = obj?.function as Record | undefined + return String(fn?.name || obj?.name || "tool") +} + +const getToolCallArgs = (tc: unknown): unknown => { + const obj = tc as Record | null + const fn = obj?.function as Record | undefined + const raw = fn?.arguments ?? obj?.arguments ?? obj?.input ?? obj?.args + return getNormalizedToolArgs(raw) +} + +const getMessageMeta = ( + text: string, + toolCalls?: unknown[], + structuredParts?: StructuredMessagePart[], +): string => { + const parts: string[] = [] + if (text) parts.push(`${text.length} chars`) + const toolCallCount = + (toolCalls?.length ?? 0) + + (structuredParts?.filter((part) => part.kind === "tool-call").length ?? 0) + const toolResultCount = + structuredParts?.filter((part) => part.kind === "tool-result").length ?? 0 + if (toolCallCount) { + parts.push(`${toolCallCount} tool ${toolCallCount === 1 ? "call" : "calls"}`) } + if (toolResultCount) { + parts.push(`${toolResultCount} tool ${toolResultCount === 1 ? "result" : "results"}`) + } + return parts.join(", ") || "empty" } -const ReadOnlyVariableField = memo(function ReadOnlyVariableField({ - label, - value, - editorId, +// ── MessageNodeRow ────────────────────────────────────────────────────── + +const MessageNodeRow = memo(function MessageNodeRow({ + msg, + index, + keyPrefix, }: { - label: string - value: string - editorId: string + msg: {role: string; content: unknown; tool_calls?: unknown[]} + index: number + keyPrefix: string }) { - return ( - - + const role = (msg.role || "").toLowerCase() + const roleColor = ROLE_COLOR_CLASSES[role] ?? DEFAULT_ROLE_COLOR_CLASS + const {text, structuredParts} = useMemo( + () => getMessageContentDisplay(msg.content), + [msg.content], + ) + const editorId = `${keyPrefix}-msg-${index}` + const toolCalls = msg.tool_calls + + const label = useMemo( + () => {msg.role || "—"}, + [msg.role, roleColor], + ) + + const parsedContent = useMemo(() => { + if (role !== "tool" || !text) return null + try { + const parsed = JSON.parse(text) + return typeof parsed === "object" && parsed !== null ? parsed : null + } catch { + return null + } + }, [role, text]) + + const body = useMemo(() => { + const hasToolCalls = toolCalls && toolCalls.length > 0 + const hasStructuredParts = structuredParts.length > 0 + + if (parsedContent && !hasStructuredParts) { + return ( +
+ +
+ ) + } + + const hasText = text.length > 0 + if (!hasText && !hasToolCalls && !hasStructuredParts) return null + + return (
- - {label} - - + {hasText && } + {structuredParts.map((part, i) => ( + + ))} + {hasToolCalls && + toolCalls.map((tc, i) => ( + + ))}
-
+ ) + }, [text, toolCalls, editorId, parsedContent, structuredParts]) + + return ( + ) }) -const RenderedValueBlock = memo(function RenderedValueBlock({ +// ── RecursiveNode ─────────────────────────────────────────────────────── + +const RecursiveNode = memo(function RecursiveNode({ + name, value: rawValue, keyPrefix, depth = 0, maxDepth = DEFAULT_MAX_RENDER_DEPTH, + expandDepth = DEFAULT_EXPAND_DEPTH, + parentIsArray = false, + isSection = false, }: { + name: string | number value: unknown keyPrefix: string depth?: number maxDepth?: number + expandDepth?: number + parentIsArray?: boolean + isSection?: boolean }) { const value = useMemo(() => simplifyValue(rawValue), [rawValue]) + const keyLabel = parentIsArray ? `[${name}]` : String(name) + const nodePrefix = `${keyPrefix}-${name}` - const chatMessages = useMemo(() => extractChatMessages(value), [value]) + const chatResult = useMemo(() => shallowExtractChatMessages(value), [value]) - if (chatMessages && chatMessages.length > 0) { - return - } + const siblingEntries = useMemo(() => { + if (!chatResult?.viaKey || !value || typeof value !== "object" || Array.isArray(value)) + return null + const entries = Object.entries(value as Record).filter( + ([k]) => + k !== chatResult.viaKey && (chatResult.viaKey !== "choices" || k !== "choices"), + ) + return entries.length > 0 ? entries : null + }, [chatResult, value]) - if (value === null || value === undefined) { - return + if (chatResult && !chatResult.viaKey) { + const normalized = normalizeChatMessages(chatResult.messages) + return ( + + {normalized.map((msg, i) => ( + + ))} +
+ } + /> + ) } - if (typeof value === "string") { + if (chatResult && chatResult.viaKey) { + const normalized = normalizeChatMessages(chatResult.messages) + const chatKey = chatResult.viaKey + const entries: [string, unknown][] = value + ? Object.entries(value as Record) + : [] + const count = entries.length + const meta = `{${count} ${count === 1 ? "key" : "keys"}}` return ( - - - - + + + {normalized.map((msg, i) => ( + + ))} +
+ } + /> + {siblingEntries?.map(([k, v]) => ( + + ))} + + } + /> ) } - if (Array.isArray(value) && value.length === 0) { - return + if (isShortLeaf(value)) { + return ( + } + collapsible={false} + value={value} + isSection={isSection} + /> + ) } - if ( - depth < maxDepth && - Array.isArray(value) && - value.length > 0 && - value.some((item) => item && typeof item === "object") - ) { + if (typeof value === "string") { return ( -
- {value.map((item, i) => { - const simplified = simplifyValue(item) - if ( - typeof simplified === "string" || - typeof simplified === "number" || - typeof simplified === "boolean" - ) { - return ( - - ) - } - if (simplified && typeof simplified === "object") { - return ( -
-
- -
-
- ) - } - return ( - + + - ) - })} -
+ + } + /> ) } - if (value && typeof value === "object" && !Array.isArray(value)) { - const entries = Object.entries(value as Record) - return ( -
- {entries.map(([k, v]) => { - const simplified = simplifyValue(v) - const nestedChat = extractChatMessages(simplified) - if (nestedChat && nestedChat.length > 0) { - return ( -
- - {formatLabel(k)} - - -
- ) - } - - if (isShortLeaf(simplified)) { - return ( - - ) - } + if (value && typeof value === "object") { + const isArray = Array.isArray(value) + const entries: [string, unknown][] = isArray + ? value.map((v, i) => [String(i), v]) + : Object.entries(value as Record) + const count = entries.length + const meta = isArray + ? `[${count} ${count === 1 ? "item" : "items"}]` + : `{${count} ${count === 1 ? "key" : "keys"}}` - if (depth < maxDepth && simplified && typeof simplified === "object") { - return ( -
- - {formatLabel(k)} - -
- -
-
- ) - } + if (depth >= maxDepth) { + return ( + + ) + } - return ( - - ) - })} -
+ return ( + 0} + defaultOpen={depth < expandDepth} + value={value} + isSection={isSection} + body={ + count > 0 + ? entries.map(([k, v]) => ( + + )) + : undefined + } + /> ) } return ( - - - - + + {valueToString(value)} + + } + collapsible={false} + value={value} + isSection={isSection} + /> ) }) -/** - * Beautified JSON view: renders a value as per-key fields or chat messages - * (opt-in display mode; do not use as the default when faithful JSON is - * required — use the JSON code viewer for that). - */ +// ── Top-level entry point ─────────────────────────────────────────────── + export const BeautifiedJsonView = memo(function BeautifiedJsonView({ data: rawData, keyPrefix, @@ -529,61 +1103,135 @@ export const BeautifiedJsonView = memo(function BeautifiedJsonView({ keyPrefix: string }) { const data = useMemo(() => simplifyValue(rawData), [rawData]) + const topChatResult = useMemo(() => shallowExtractChatMessages(data), [data]) - const isDirectChat = useMemo(() => { - if (typeof data === "string") return false - if (Array.isArray(data)) return !!extractChatMessages(data) - if (data && typeof data === "object" && "role" in (data as Record)) { - return !!extractChatMessages(data) + const agDataExpandKeys = useMemo(() => { + if (!data || typeof data !== "object" || Array.isArray(data)) return null + const rec = data as Record + const keys = new Set() + const hasNestedData = (obj: unknown): boolean => { + if (!obj || typeof obj !== "object" || Array.isArray(obj)) return false + const o = obj as Record + return o.data !== undefined && typeof o.data === "object" && !Array.isArray(o.data) } - return false + if (rec.ag && hasNestedData(rec.ag)) { + keys.add("ag") + } + if ( + rec.attributes && + typeof rec.attributes === "object" && + !Array.isArray(rec.attributes) + ) { + const attrs = rec.attributes as Record + if (attrs.ag && hasNestedData(attrs.ag)) { + keys.add("attributes") + } + } + return keys.size > 0 ? keys : null }, [data]) - const directChatMessages = useMemo( - () => (isDirectChat ? extractChatMessages(data) : null), - [isDirectChat, data], - ) - const entries = useMemo(() => { - if (typeof data === "string") return null - if (isDirectChat) return null - if (!data || typeof data !== "object" || Array.isArray(data)) return null - return Object.entries(data as Record) - }, [data, isDirectChat]) + const topSiblingEntries = useMemo(() => { + if (!topChatResult?.viaKey || !data || typeof data !== "object" || Array.isArray(data)) + return null + const entries = Object.entries(data as Record).filter( + ([k]) => + k !== topChatResult.viaKey && + (topChatResult.viaKey !== "choices" || k !== "choices"), + ) + return entries.length > 0 ? entries : null + }, [topChatResult, data]) - if (typeof data === "string") { + if (topChatResult && !topChatResult.viaKey) { + const normalized = normalizeChatMessages(topChatResult.messages) return ( -
- +
+
+ {normalized.map((msg, i) => ( + + ))} +
) } - if (isDirectChat && directChatMessages && directChatMessages.length > 0) { + if (topChatResult && topChatResult.viaKey) { + const normalized = normalizeChatMessages(topChatResult.messages) + const chatKey = topChatResult.viaKey return ( -
- +
+ + {normalized.map((msg, i) => ( + + ))} +
+ } + /> + {topSiblingEntries?.map(([key, value]) => ( + + ))}
) } - if (entries) { + if (typeof data === "string") { return ( -
+
+ +
+ ) + } + + if (data && typeof data === "object" && !Array.isArray(data)) { + const entries = Object.entries(data as Record) + return ( +
{entries.map(([key, value]) => ( -
- {key} - -
+ ))}
) } return ( -
- +
+
) }) - -BeautifiedJsonView.displayName = "BeautifiedJsonView" diff --git a/web/oss/src/components/DrillInView/TraceSpanDrillInView.tsx b/web/oss/src/components/DrillInView/TraceSpanDrillInView.tsx index 66f37edc6f..8b0a80871a 100644 --- a/web/oss/src/components/DrillInView/TraceSpanDrillInView.tsx +++ b/web/oss/src/components/DrillInView/TraceSpanDrillInView.tsx @@ -389,6 +389,11 @@ export const TraceSpanDrillInView = memo( getDefaultJsonViewMode(availableViewModes), ) + const viewModeKey = availableViewModes.join(",") + useEffect(() => { + setViewMode(getDefaultJsonViewMode(availableViewModes)) + }, [viewModeKey]) + const isCodeMode = viewMode === "json" || viewMode === "yaml" || viewMode === "decoded-json" const isBeautifiedJson = viewMode === "beautified-json"