diff --git a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx index f8f2b35dd7..b83e13f615 100644 --- a/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx +++ b/web/oss/src/components/DrillInView/BeautifiedJsonView.tsx @@ -126,36 +126,215 @@ const EDITOR_RESET_CLASSES = [ 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} +} + 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: {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 @@ -220,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 { @@ -338,11 +495,11 @@ const ScalarValue = ({value}: {value: unknown}) => { const CopyButton = ({value}: {value: unknown}) => { const [copied, setCopied] = useState(false) - const timerRef = useRef | null>(null) + const timerRef = useRef(null) useEffect( () => () => { - if (timerRef.current !== null) clearTimeout(timerRef.current) + if (timerRef.current !== null) window.clearTimeout(timerRef.current) }, [], ) @@ -355,19 +512,24 @@ const CopyButton = ({value}: {value: unknown}) => { try { await navigator.clipboard.writeText(text) setCopied(true) - if (timerRef.current !== null) clearTimeout(timerRef.current) + 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 ( + + + ) : null} + + {needsTruncation && expanded ? ( +
+ +
+ ) : null} + + ) +}) + +// ── 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" +} + // ── MessageNodeRow ────────────────────────────────────────────────────── const MessageNodeRow = memo(function MessageNodeRow({ @@ -480,53 +781,91 @@ const MessageNodeRow = memo(function MessageNodeRow({ index, keyPrefix, }: { - msg: {role: string; content: unknown} + msg: {role: string; content: unknown; tool_calls?: unknown[]} index: number keyPrefix: string }) { const role = (msg.role || "").toLowerCase() const roleColor = ROLE_COLOR_CLASSES[role] ?? DEFAULT_ROLE_COLOR_CLASS - const text = getMessageText(msg.content) + 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 body = useMemo( - () => ( - - - - - ), - [editorId, text], - ) + 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 ( +
+ {hasText && } + {structuredParts.map((part, i) => ( + + ))} + {hasToolCalls && + toolCalls.map((tc, i) => ( + + ))} +
+ ) + }, [text, toolCalls, editorId, parsedContent, structuredParts]) return ( )