feat: json view component

This commit is contained in:
cgoing
2025-03-25 01:48:29 +09:00
parent 2890e036ed
commit f0b28d4760
5 changed files with 226 additions and 14 deletions

View File

@@ -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 = ({
</button>
</div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
{JSON.stringify(JSON.parse(request.request), null, 2)}
<JsonView data={request.request} />
</pre>
</div>
{request.response && (
@@ -92,11 +93,7 @@ const HistoryAndNotifications = ({
</button>
</div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
{JSON.stringify(
JSON.parse(request.response),
null,
2,
)}
<JsonView data={request.response} />
</pre>
</div>
)}
@@ -147,7 +144,9 @@ const HistoryAndNotifications = ({
</button>
</div>
<pre className="whitespace-pre-wrap break-words bg-background p-2 rounded">
{JSON.stringify(notification, null, 2)}
<JsonView
data={JSON.stringify(notification, null, 2)}
/>
</pre>
</div>
)}

View File

@@ -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 (
<div className="font-mono text-sm transition-all duration-300">
<JsonNode
data={normalizedData}
name={name}
depth={0}
initialExpandDepth={initialExpandDepth}
/>
</div>
);
},
);
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<string, string> = {
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<string, JsonValue>);
const itemCount = items.length;
const isEmpty = itemCount === 0;
const symbolMap = {
open: isArray ? "[" : "{",
close: isArray ? "]" : "}",
collapsed: isArray ? "[ ... ]" : "{ ... }",
empty: isArray ? "[]" : "{}",
};
if (isEmpty) {
return (
<div className="flex items-center">
{name && <span className="mr-1 text-gray-400">{name}:</span>}
<span className="text-gray-500">{symbolMap.empty}</span>
</div>
);
}
return (
<div className="flex flex-col">
<div
className="flex items-center mr-1 rounded cursor-pointer group hover:bg-gray-800/20"
onClick={() => setIsExpanded(!isExpanded)}
>
{name && (
<span className="mr-1 text-gray-400 group-hover:text-gray-100">
{name}:
</span>
)}
{isExpanded ? (
<span className="text-gray-400 group-hover:text-gray-100">
{symbolMap.open}
</span>
) : (
<>
<span className="text-gray-600 group-hover:text-gray-100">
{symbolMap.collapsed}
</span>
<span className="ml-1 text-gray-700 group-hover:text-gray-100">
{itemCount} {itemCount === 1 ? "item" : "items"}
</span>
</>
)}
</div>
{isExpanded && (
<>
<div className="pl-2 ml-4 border-l border-gray-800">
{isArray
? (items as JsonValue[]).map((item, index) => (
<div key={index} className="my-1">
<JsonNode
data={item}
name={`${index}`}
depth={depth + 1}
initialExpandDepth={initialExpandDepth}
/>
</div>
))
: (items as [string, JsonValue][]).map(([key, value]) => (
<div key={key} className="my-1">
<JsonNode
data={value}
name={key}
depth={depth + 1}
initialExpandDepth={initialExpandDepth}
/>
</div>
))}
</div>
<div className="text-gray-400">{symbolMap.close}</div>
</>
)}
</div>
);
};
const renderString = (value: string) => {
const maxLength = 100;
const isTooLong = value.length > maxLength;
if (!isTooLong) {
return (
<div className="flex mr-1 rounded hover:bg-gray-800/20">
{name && <span className="mr-1 text-gray-400">{name}:</span>}
<span className={typeStyleMap.string}>"{value}"</span>
</div>
);
}
return (
<div className="flex mr-1 rounded group hover:bg-gray-800/20">
{name && (
<span className="mr-1 text-gray-400 group-hover:text-gray-100">
{name}:
</span>
)}
<span
className={`${typeStyleMap.string} cursor-pointer group-hover:text-green-500`}
onClick={() => setIsExpanded(!isExpanded)}
title={isExpanded ? "클릭하여 축소" : "클릭하여 전체 보기"}
>
{isExpanded ? `"${value}"` : `"${value.slice(0, maxLength)}..."`}
</span>
</div>
);
};
switch (dataType) {
case "object":
case "array":
return renderCollapsible(dataType === "array");
case "string":
return renderString(data as string);
default:
return (
<div className="flex items-center mr-1 rounded hover:bg-gray-800/20">
{name && <span className="mr-1 text-gray-400">{name}:</span>}
<span className={typeStyleMap[dataType] || typeStyleMap.default}>
{data === null ? "null" : String(data)}
</span>
</div>
);
}
},
);
JsonNode.displayName = "JsonNode";
export default JsonView;

View File

@@ -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 = ({
</Alert>
) : selectedResource ? (
<pre className="bg-gray-50 dark:bg-gray-800 p-4 rounded text-sm overflow-auto max-h-96 whitespace-pre-wrap break-words text-gray-900 dark:text-gray-100">
{resourceContent}
<JsonView data={resourceContent} />
</pre>
) : selectedTemplate ? (
<div className="space-y-4">

View File

@@ -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) => (
<div key={request.id} className="p-4 border rounded-lg space-y-4">
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
{JSON.stringify(request.request, null, 2)}
<JsonView data={JSON.stringify(request.request)} />
</pre>
<div className="flex space-x-2">
<Button onClick={() => handleApprove(request.id)}>Approve</Button>

View File

@@ -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 = ({
<>
<h4 className="font-semibold mb-2">Invalid Tool Result:</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{escapeUnicode(toolResult)}
<JsonView data={escapeUnicode(toolResult)} />
</pre>
<h4 className="font-semibold mb-2">Errors:</h4>
{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)}
<JsonView data={escapeUnicode(error)} />
</pre>
))}
</>
@@ -80,7 +81,7 @@ const ToolsTab = ({
<div key={index} className="mb-2">
{item.type === "text" && (
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{item.text}
<JsonView data={item.text} />
</pre>
)}
{item.type === "image" && (
@@ -101,7 +102,7 @@ const ToolsTab = ({
</audio>
) : (
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 whitespace-pre-wrap break-words p-4 rounded text-sm overflow-auto max-h-64">
{escapeUnicode(item.resource)}
<JsonView data={escapeUnicode(item.resource)} />
</pre>
))}
</div>
@@ -113,7 +114,7 @@ const ToolsTab = ({
<>
<h4 className="font-semibold mb-2">Tool Result (Legacy):</h4>
<pre className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-4 rounded text-sm overflow-auto max-h-64">
{escapeUnicode(toolResult.toolResult)}
<JsonView data={escapeUnicode(toolResult.toolResult)} />
</pre>
</>
);