From f0b28d476040f4fa14fdb7bc07cdecd8faaa2d07 Mon Sep 17 00:00:00 2001 From: cgoing Date: Tue, 25 Mar 2025 01:48:29 +0900 Subject: [PATCH] feat: json view component --- client/src/components/History.tsx | 13 +- client/src/components/JsonView.tsx | 210 +++++++++++++++++++++++++ client/src/components/ResourcesTab.tsx | 3 +- client/src/components/SamplingTab.tsx | 3 +- client/src/components/ToolsTab.tsx | 11 +- 5 files changed, 226 insertions(+), 14 deletions(-) create mode 100644 client/src/components/JsonView.tsx diff --git a/client/src/components/History.tsx b/client/src/components/History.tsx index 532d3b1..7ce7dcf 100644 --- a/client/src/components/History.tsx +++ b/client/src/components/History.tsx @@ -1,6 +1,7 @@ import { ServerNotification } from "@modelcontextprotocol/sdk/types.js"; import { Copy } from "lucide-react"; import { useState } from "react"; +import JsonView from "./JsonView"; const HistoryAndNotifications = ({ requestHistory, @@ -75,7 +76,7 @@ const HistoryAndNotifications = ({
-                          {JSON.stringify(JSON.parse(request.request), null, 2)}
+                          
                         
{request.response && ( @@ -92,11 +93,7 @@ const HistoryAndNotifications = ({
-                            {JSON.stringify(
-                              JSON.parse(request.response),
-                              null,
-                              2,
-                            )}
+                            
                           
)} @@ -147,7 +144,9 @@ const HistoryAndNotifications = ({
-                        {JSON.stringify(notification, null, 2)}
+                        
                       
)} diff --git a/client/src/components/JsonView.tsx b/client/src/components/JsonView.tsx new file mode 100644 index 0000000..21d65d8 --- /dev/null +++ b/client/src/components/JsonView.tsx @@ -0,0 +1,210 @@ +import { useState, memo } from "react"; +import { JsonValue } from "./DynamicJsonForm"; + +interface JsonViewProps { + data: JsonValue; + name?: string; + initialExpandDepth?: number; +} + +function tryParseJson(str: string): { success: boolean; data: JsonValue } { + const trimmed = str.trim(); + if ( + !(trimmed.startsWith("{") && trimmed.endsWith("}")) && + !(trimmed.startsWith("[") && trimmed.endsWith("]")) + ) { + return { success: false, data: str }; + } + try { + return { success: true, data: JSON.parse(str) }; + } catch { + return { success: false, data: str }; + } +} + +const JsonView = memo( + ({ data, name, initialExpandDepth = 2 }: JsonViewProps) => { + const normalizedData = + typeof data === "string" + ? tryParseJson(data).success + ? tryParseJson(data).data + : data + : data; + + return ( +
+ +
+ ); + }, +); + +JsonView.displayName = "JsonView"; + +interface JsonNodeProps { + data: JsonValue; + name?: string; + depth: number; + initialExpandDepth: number; +} + +const JsonNode = memo( + ({ data, name, depth = 0, initialExpandDepth }: JsonNodeProps) => { + const [isExpanded, setIsExpanded] = useState(depth < initialExpandDepth); + + const getDataType = (value: JsonValue): string => { + if (Array.isArray(value)) return "array"; + if (value === null) return "null"; + return typeof value; + }; + + const dataType = getDataType(data); + + const typeStyleMap: Record = { + number: "text-blue-600", + boolean: "text-amber-600", + null: "text-purple-600", + undefined: "text-gray-600", + string: "text-green-600", + default: "text-gray-700", + }; + + const renderCollapsible = (isArray: boolean) => { + const items = isArray + ? (data as JsonValue[]) + : Object.entries(data as Record); + const itemCount = items.length; + const isEmpty = itemCount === 0; + + const symbolMap = { + open: isArray ? "[" : "{", + close: isArray ? "]" : "}", + collapsed: isArray ? "[ ... ]" : "{ ... }", + empty: isArray ? "[]" : "{}", + }; + + if (isEmpty) { + return ( +
+ {name && {name}:} + {symbolMap.empty} +
+ ); + } + + return ( +
+
setIsExpanded(!isExpanded)} + > + {name && ( + + {name}: + + )} + {isExpanded ? ( + + {symbolMap.open} + + ) : ( + <> + + {symbolMap.collapsed} + + + {itemCount} {itemCount === 1 ? "item" : "items"} + + + )} +
+ {isExpanded && ( + <> +
+ {isArray + ? (items as JsonValue[]).map((item, index) => ( +
+ +
+ )) + : (items as [string, JsonValue][]).map(([key, value]) => ( +
+ +
+ ))} +
+
{symbolMap.close}
+ + )} +
+ ); + }; + + const renderString = (value: string) => { + const maxLength = 100; + const isTooLong = value.length > maxLength; + + if (!isTooLong) { + return ( +
+ {name && {name}:} + "{value}" +
+ ); + } + + return ( +
+ {name && ( + + {name}: + + )} + setIsExpanded(!isExpanded)} + title={isExpanded ? "클릭하여 축소" : "클릭하여 전체 보기"} + > + {isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`} + +
+ ); + }; + + switch (dataType) { + case "object": + case "array": + return renderCollapsible(dataType === "array"); + case "string": + return renderString(data as string); + default: + return ( +
+ {name && {name}:} + + {data === null ? "null" : String(data)} + +
+ ); + } + }, +); + +JsonNode.displayName = "JsonNode"; + +export default JsonView; diff --git a/client/src/components/ResourcesTab.tsx b/client/src/components/ResourcesTab.tsx index f000840..bc3e39a 100644 --- a/client/src/components/ResourcesTab.tsx +++ b/client/src/components/ResourcesTab.tsx @@ -15,6 +15,7 @@ import { AlertCircle, ChevronRight, FileText, RefreshCw } from "lucide-react"; import ListPane from "./ListPane"; import { useEffect, useState } from "react"; import { useCompletionState } from "@/lib/hooks/useCompletionState"; +import JsonView from "./JsonView"; const ResourcesTab = ({ resources, @@ -215,7 +216,7 @@ const ResourcesTab = ({ ) : selectedResource ? (
-              {resourceContent}
+              
             
) : selectedTemplate ? (
diff --git a/client/src/components/SamplingTab.tsx b/client/src/components/SamplingTab.tsx index 5c45400..d14d4c8 100644 --- a/client/src/components/SamplingTab.tsx +++ b/client/src/components/SamplingTab.tsx @@ -5,6 +5,7 @@ import { CreateMessageRequest, CreateMessageResult, } from "@modelcontextprotocol/sdk/types.js"; +import JsonView from "./JsonView"; export type PendingRequest = { id: number; @@ -44,7 +45,7 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => { {pendingRequests.map((request) => (
-              {JSON.stringify(request.request, null, 2)}
+              
             
diff --git a/client/src/components/ToolsTab.tsx b/client/src/components/ToolsTab.tsx index 82ebdb0..345f7bd 100644 --- a/client/src/components/ToolsTab.tsx +++ b/client/src/components/ToolsTab.tsx @@ -17,6 +17,7 @@ import { AlertCircle, Send } from "lucide-react"; import { useEffect, useState } from "react"; import ListPane from "./ListPane"; import { escapeUnicode } from "@/utils/escapeUnicode"; +import JsonView from "./JsonView"; const ToolsTab = ({ tools, @@ -54,7 +55,7 @@ const ToolsTab = ({ <>

Invalid Tool Result:

-              {escapeUnicode(toolResult)}
+              
             

Errors:

{parsedResult.error.errors.map((error, idx) => ( @@ -62,7 +63,7 @@ const ToolsTab = ({ key={idx} className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64" > - {escapeUnicode(error)} + ))} @@ -80,7 +81,7 @@ const ToolsTab = ({
{item.type === "text" && (
-                  {item.text}
+                  
                 
)} {item.type === "image" && ( @@ -101,7 +102,7 @@ const ToolsTab = ({ ) : (
-                    {escapeUnicode(item.resource)}
+                    
                   
))}
@@ -113,7 +114,7 @@ const ToolsTab = ({ <>

Tool Result (Legacy):

-            {escapeUnicode(toolResult.toolResult)}
+            
           
);