diff --git a/agentex-ui/components/agentex/json-viewer.tsx b/agentex-ui/components/agentex/json-viewer.tsx index d48927f7..7cc65937 100644 --- a/agentex-ui/components/agentex/json-viewer.tsx +++ b/agentex-ui/components/agentex/json-viewer.tsx @@ -1,14 +1,20 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useEffect, useState, useCallback } from 'react'; import { cva } from 'class-variance-authority'; -import { ChevronDown, ChevronRight } from 'lucide-react'; +import { + ChevronDown, + ChevronRight, + ChevronsDownUp, + ChevronsUpDown, +} from 'lucide-react'; import { CopyButton } from '@/components/agentex/copy-button'; +import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; -type JsonValue = +export type JsonValue = | string | number | boolean @@ -16,6 +22,9 @@ type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; +const URL_REGEX = + /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/g; + const valueStyles = cva('', { variants: { type: { @@ -39,27 +48,80 @@ function serializeValue(data: JsonValue): string { return String(data); } +function LinkifiedString({ value }: { value: string }) { + const parts: (string | React.ReactElement)[] = []; + let lastIndex = 0; + + const matches = value.matchAll(URL_REGEX); + + for (const match of matches) { + if (match.index !== undefined && match.index > lastIndex) { + parts.push(value.substring(lastIndex, match.index)); + } + + const url = match[0]; + parts.push( + e.stopPropagation()} + > + {url} + + ); + + lastIndex = (match.index ?? 0) + url.length; + } + + if (lastIndex < value.length) { + parts.push(value.substring(lastIndex)); + } + + if (parts.length === 0) { + return <>{value}; + } + + return <>{parts}; +} + interface JsonCollapsibleProps extends React.HTMLAttributes { copyContent: string; collapsedContent: React.ReactNode; expandedContent: React.ReactNode; - defaultExpanded?: boolean; + shouldBeExpanded?: boolean; + forceExpandState?: boolean | null; keyName?: string | undefined; + extraButtons?: React.ReactNode; + showCopyButton?: boolean; } function JsonCollapsible({ copyContent, collapsedContent, expandedContent, - defaultExpanded = false, + shouldBeExpanded = false, + forceExpandState = null, keyName, + extraButtons, + showCopyButton = true, ...props }: JsonCollapsibleProps) { - const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const [isExpanded, setIsExpanded] = useState(() => + forceExpandState !== null ? forceExpandState : shouldBeExpanded + ); + + useEffect(() => { + if (forceExpandState !== null) { + setIsExpanded(forceExpandState); + } + }, [forceExpandState]); return (
-
+
-
- +
+ {extraButtons} + {showCopyButton && ( +
+ +
+ )}
{isExpanded &&
{expandedContent}
} @@ -89,16 +156,21 @@ interface JsonNodeProps { data: JsonValue; keyName?: string; level?: number; - defaultExpanded?: boolean; + currentDepth?: number; + maxOpenDepth?: number; + forceExpandState?: boolean | null; + extraButtons?: React.ReactNode; } function JsonNode({ data, keyName, level = 0, - defaultExpanded = false, + currentDepth = 0, + maxOpenDepth = 0, + forceExpandState = null, + extraButtons, }: JsonNodeProps) { - // Try to parse JSON strings let parsedData = data; if (typeof data === 'string') { try { @@ -106,9 +178,7 @@ function JsonNode({ if (typeof parsed === 'object' && parsed !== null) { parsedData = parsed; } - } catch { - // Not valid JSON, keep as string - } + } catch {} } const copyContent = keyName @@ -117,8 +187,9 @@ function JsonNode({ const indentClassName = level > 0 ? 'ml-4' : ''; const [isExpanded, setIsExpanded] = useState(false); + const shouldExpand = maxOpenDepth < 0 || currentDepth < maxOpenDepth; - let content = null; + let content: React.ReactNode = null; let dataType: | 'string' | 'number' @@ -128,7 +199,6 @@ function JsonNode({ | 'array' | null = null; - // Render arrays if (Array.isArray(parsedData) && parsedData.length > 0) { return ( ))} - defaultExpanded={defaultExpanded} + shouldBeExpanded={shouldExpand} + forceExpandState={forceExpandState} + extraButtons={extraButtons} + showCopyButton={!extraButtons} className={indentClassName} /> ); } - // Render objects if ( typeof parsedData === 'object' && parsedData !== null && @@ -174,23 +248,33 @@ function JsonNode({ data={value} keyName={key} level={level + 1} - defaultExpanded={defaultExpanded} + currentDepth={currentDepth + 1} + maxOpenDepth={maxOpenDepth} + forceExpandState={forceExpandState} /> ))} - defaultExpanded={defaultExpanded} + shouldBeExpanded={shouldExpand} + forceExpandState={forceExpandState} + extraButtons={extraButtons} + showCopyButton={!extraButtons} className={indentClassName} /> ); } - // Check if string is long (more than 6 lines worth of characters, ~80 chars per line) const isLongString = typeof parsedData === 'string' && parsedData.length > 480; switch (typeof parsedData) { case 'string': dataType = 'string'; - content = `"${parsedData}"`; + content = ( + <> + " + + " + + ); break; case 'number': dataType = 'number'; @@ -220,7 +304,7 @@ function JsonNode({ return (
@@ -231,7 +315,6 @@ function JsonNode({ )} onClick={e => { if (isLongString) { - // Only toggle if not clicking the copy button const target = e.target as HTMLElement; if (!target.closest('button')) { setIsExpanded(!isExpanded); @@ -259,15 +342,55 @@ function JsonNode({ interface JsonViewerProps { data: JsonValue; - defaultExpanded?: boolean; + defaultOpenDepth?: number; className?: string; } export function JsonViewer({ data, - defaultExpanded = false, + defaultOpenDepth = 0, className, }: JsonViewerProps) { + const [forceExpandState, setForceExpandState] = useState( + null + ); + + const shouldShowExpand = useMemo(() => { + return forceExpandState === null + ? defaultOpenDepth === 0 + : !forceExpandState; + }, [forceExpandState, defaultOpenDepth]); + + const toggleForceExpandState = useCallback(() => { + setForceExpandState(shouldShowExpand); + }, [shouldShowExpand]); + + const extraButtons = useMemo(() => { + return ( + <> + + + + ); + }, [data, shouldShowExpand, toggleForceExpandState]); + return (
- +
); } diff --git a/agentex-ui/components/agentex/task-message-data-content.tsx b/agentex-ui/components/agentex/task-message-data-content.tsx index 15de3d2a..fc684ac9 100644 --- a/agentex-ui/components/agentex/task-message-data-content.tsx +++ b/agentex-ui/components/agentex/task-message-data-content.tsx @@ -1,8 +1,6 @@ -import { useMemo } from 'react'; - import { cva } from 'class-variance-authority'; -import { CodeBlock } from '@/components/ai-elements/code-block'; +import { JsonViewer, type JsonValue } from '@/components/agentex/json-viewer'; import { cn } from '@/lib/utils'; import type { DataContent } from 'agentex/resources'; @@ -25,13 +23,13 @@ function TaskMessageDataContentComponent({ content, key, }: TaskMessageDataContentComponentProps) { - const dataString = useMemo( - () => JSON.stringify(content.data, null, 2), - [content.data] - ); return (
- +
); } diff --git a/agentex-ui/components/agentex/task-message-tool-pair.tsx b/agentex-ui/components/agentex/task-message-tool-pair.tsx index c84ee9ff..dc714ff6 100644 --- a/agentex-ui/components/agentex/task-message-tool-pair.tsx +++ b/agentex-ui/components/agentex/task-message-tool-pair.tsx @@ -1,65 +1,79 @@ import { memo, useMemo, useState } from 'react'; import { motion } from 'framer-motion'; -import { ChevronDownIcon, Wrench } from 'lucide-react'; +import { ChevronDownIcon, Wrench, XCircleIcon } from 'lucide-react'; -import { ToolInput, ToolOutput } from '@/components/ai-elements/tool'; +import { JsonViewer, type JsonValue } from '@/components/agentex/json-viewer'; +import { Collapsible } from '@/components/ui/collapsible'; +import { ShimmeringText } from '@/components/ui/shimmering-text'; import { cn } from '@/lib/utils'; -import { Collapsible } from '../ui/collapsible'; -import { ShimmeringText } from '../ui/shimmering-text'; - import type { TaskMessage, ToolRequestContent, ToolResponseContent, } from 'agentex/resources'; -export type TaskMessageToolPairProps = { +type TaskMessageToolHeaderAndJsonProps = { + title: string; + data: JsonValue; +}; + +function TaskMessageToolHeaderAndJsonComponent({ + title, + data, +}: TaskMessageToolHeaderAndJsonProps) { + return ( +
+

+ {title} +

+
+ +
+
+ ); +} + +type TaskMessageToolPairProps = { toolRequestMessage: TaskMessage & { content: ToolRequestContent }; toolResponseMessage?: | (TaskMessage & { content: ToolResponseContent }) | undefined; }; -function TaskMessageToolPairComponent({ +function TaskMessageToolPairComponentImpl({ toolRequestMessage, toolResponseMessage, }: TaskMessageToolPairProps) { const [isCollapsed, setIsCollapsed] = useState(true); - // const state = useMemo(() => { - // const streamingStatus = toolResponseMessage?.streaming_status; - // if (streamingStatus === 'IN_PROGRESS') { - // return 'input-streaming'; - // } else { - // try { - // const content = toolResponseMessage?.content.content; - // if (typeof content === 'string') { - // const parsed = JSON.parse(content); - // if (parsed.status === 'error') { - // return 'output-error'; - // } - // } - // } catch { - // return 'output-available'; - // } - // return 'output-available'; - // } - // }, [toolResponseMessage]); - - const reponseObject = useMemo(() => { + const responseObject = useMemo(() => { const content = toolResponseMessage?.content.content; if (typeof content === 'string') { try { - return JSON.parse(content); + return JSON.parse(content) as JsonValue; } catch { - return content; + return content as JsonValue; } } - return content; + return content as JsonValue; }, [toolResponseMessage]); + const isError = useMemo( + () => + typeof responseObject === 'object' && + responseObject !== null && + 'status' in responseObject && + typeof responseObject.status === 'string' && + responseObject.status.toLowerCase() === 'error', + [responseObject] + ); + return ( + {isError && }
- - + +
); } -const MemoizedTaskMessageToolPairComponent = memo(TaskMessageToolPairComponent); +const MemoizedTaskMessageToolPairComponent = memo( + TaskMessageToolPairComponentImpl +); -export { MemoizedTaskMessageToolPairComponent, TaskMessageToolPairComponent }; +export { MemoizedTaskMessageToolPairComponent }; diff --git a/agentex-ui/components/agentex/task-messages.tsx b/agentex-ui/components/agentex/task-messages.tsx index f7d9621a..c377dbd4 100644 --- a/agentex-ui/components/agentex/task-messages.tsx +++ b/agentex-ui/components/agentex/task-messages.tsx @@ -9,7 +9,7 @@ import { TaskMessageTextContentComponent } from '@/components/agentex/task-messa import { useAgentexClient } from '@/components/providers'; import { useTaskMessages } from '@/hooks/use-task-messages'; -import { TaskMessageToolPairComponent } from './task-message-tool-pair'; +import { MemoizedTaskMessageToolPairComponent } from './task-message-tool-pair'; import { ShimmeringText } from '../ui/shimmering-text'; import type { @@ -36,10 +36,8 @@ function MemoizedTaskMessagesComponentImpl({ const { agentexClient } = useAgentexClient(); - // Use query hook to get messages from cache if taskId is provided const { data: queryData } = useTaskMessages({ agentexClient, taskId }); - // Prefer query data if available, otherwise use prop messages const messages = useMemo(() => queryData?.messages ?? [], [queryData]); const previousMessageCountRef = useRef(messages.length); @@ -58,7 +56,6 @@ function MemoizedTaskMessagesComponentImpl({ [messages] ); - // Group messages into pairs (user message + subsequent agent messages) const messagePairs = useMemo(() => { const pairs: MessagePair[] = []; let currentUserMessage: TaskMessage | null = null; @@ -68,7 +65,6 @@ function MemoizedTaskMessagesComponentImpl({ const isUserMessage = message.content.author === 'user'; if (isUserMessage) { - // Save previous pair if exists if (currentUserMessage) { pairs.push({ id: currentUserMessage.id || `pair-${pairs.length}`, @@ -76,25 +72,21 @@ function MemoizedTaskMessagesComponentImpl({ agentMessages: currentAgentMessages, }); } - // Start new pair currentUserMessage = message; currentAgentMessages = []; } else { - // Add to current agent messages if (currentUserMessage) { currentAgentMessages.push(message); } else { - // Agent message without a user message - create a pair with just agent messages pairs.push({ id: message.id || `pair-${pairs.length}`, - userMessage: message, // Use the agent message as the "user" message + userMessage: message, agentMessages: [], }); } } } - // Add the last pair if (currentUserMessage) { pairs.push({ id: currentUserMessage.id || `pair-${pairs.length}`, @@ -118,11 +110,9 @@ function MemoizedTaskMessagesComponentImpl({ ); }, [messagePairs, queryData?.rpcStatus]); - // Measure the scrollable container height useEffect(() => { const measureHeight = () => { if (containerRef.current) { - // Walk up the DOM tree to find the scrollable container (has overflow-y-auto) let element = containerRef.current.parentElement; while (element) { const overflowY = window.getComputedStyle(element).overflowY; @@ -135,10 +125,8 @@ function MemoizedTaskMessagesComponentImpl({ } }; - // Measure on mount and when messages change measureHeight(); - // Recalculate on window resize window.addEventListener('resize', measureHeight); return () => window.removeEventListener('resize', measureHeight); }, [messages]); @@ -159,7 +147,6 @@ function MemoizedTaskMessagesComponentImpl({ previousMessageCountRef.current = currentCount; }, [messagePairs.length]); - // Helper function to render a message const renderMessage = (message: TaskMessage) => { switch (message.content.type) { case 'text': @@ -170,7 +157,7 @@ function MemoizedTaskMessagesComponentImpl({ return ; case 'tool_request': return ( -
); diff --git a/agentex-ui/components/ai-elements/tool.tsx b/agentex-ui/components/ai-elements/tool.tsx deleted file mode 100644 index c4a1ffa6..00000000 --- a/agentex-ui/components/ai-elements/tool.tsx +++ /dev/null @@ -1,107 +0,0 @@ -'use client'; - -import type { ComponentProps, ReactNode } from 'react'; -import { isValidElement } from 'react'; - -import { - CheckCircleIcon, - CircleIcon, - ClockIcon, - XCircleIcon, -} from 'lucide-react'; - -import { CodeBlock } from '@/components/ai-elements/code-block'; -import { Badge } from '@/components/ui/badge'; -import { cn } from '@/lib/utils'; - -import type { ToolUIPart } from 'ai'; - -export type ToolHeaderProps = { - title?: string; - type: ToolUIPart['type']; - state: ToolUIPart['state']; - className?: string; -}; - -export const getStatusBadge = (status: ToolUIPart['state']) => { - const labels = { - 'input-streaming': 'Pending', - 'input-available': 'Running', - 'output-available': 'Completed', - 'output-error': 'Error', - } as const; - - const icons = { - 'input-streaming': , - 'input-available': , - 'output-available': , - 'output-error': , - } as const; - - return ( - - {icons[status]} - {labels[status]} - - ); -}; - -export type ToolInputProps = ComponentProps<'div'> & { - input: ToolUIPart['input']; -}; - -export const ToolInput = ({ className, input, ...props }: ToolInputProps) => ( -
-

- Parameters -

-
- -
-
-); - -export type ToolOutputProps = ComponentProps<'div'> & { - output: ToolUIPart['output']; - errorText: ToolUIPart['errorText']; -}; - -export const ToolOutput = ({ - className, - output, - errorText, - ...props -}: ToolOutputProps) => { - if (!(output || errorText)) { - return null; - } - - let Output =
{output as ReactNode}
; - - if (typeof output === 'object' && !isValidElement(output)) { - Output = ( - - ); - } else if (typeof output === 'string') { - Output = ; - } - - return ( -
-

- {errorText ? 'Error' : 'Result'} -

-
- {errorText &&
{errorText}
} - {Output} -
-
- ); -};