Skip to content

Commit 1173a30

Browse files
Junyi-99Copilot
andauthored
feat: jsonrpc tool call card (#12)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 0e04158 commit 1173a30

5 files changed

Lines changed: 209 additions & 2 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { cn } from "@heroui/react";
2+
import { JsonRpcResult } from "./utils/common";
3+
import MarkdownComponent from "../../markdown";
4+
import { LoadingIndicator } from "../../loading-indicator";
5+
import { useState } from "react";
6+
7+
type JsonRpcProps = {
8+
functionName: string;
9+
jsonRpcResult: JsonRpcResult;
10+
preparing: boolean;
11+
animated: boolean;
12+
};
13+
14+
export const JsonRpc = ({ functionName, jsonRpcResult, preparing, animated }: JsonRpcProps) => {
15+
const [isCollapsed, setIsCollapsed] = useState(false);
16+
17+
if (preparing) {
18+
return (
19+
<div className={cn("tool-card", { animated: animated })}>
20+
<div className="flex items-center justify-between">
21+
<h3 className="tool-card-title tool-card-jsonrpc">{functionName}</h3>
22+
</div>
23+
<LoadingIndicator text="Processing ..." estimatedSeconds={300} />
24+
</div>
25+
);
26+
}
27+
28+
const toggleCollapse = () => {
29+
setIsCollapsed(!isCollapsed);
30+
};
31+
32+
return (
33+
<div className={cn("tool-card noselect narrow", { animated: animated })}>
34+
<div className="flex items-center justify-between cursor-pointer" onClick={toggleCollapse}>
35+
<h3 className="tool-card-title tool-card-jsonrpc">{functionName}</h3>
36+
<button
37+
className="text-gray-400 hover:text-gray-600 transition-colors duration-200 p-1 rounded"
38+
aria-label={isCollapsed ? "Expand" : "Collapse"}
39+
>
40+
<svg
41+
className={cn("w-4 h-4 transition-transform duration-200", {
42+
"rotate-180": !isCollapsed
43+
})}
44+
fill="none"
45+
stroke="currentColor"
46+
viewBox="0 0 24 24"
47+
>
48+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
49+
</svg>
50+
</button>
51+
</div>
52+
53+
<div className={cn("canselect overflow-hidden transition-all duration-300 ease-in-out", {
54+
"max-h-0 opacity-0": isCollapsed,
55+
"max-h-[1000px] opacity-100": !isCollapsed
56+
})}>
57+
{jsonRpcResult.result && (
58+
<div className="text-xs">
59+
<MarkdownComponent animated={animated}>
60+
{jsonRpcResult.result.content?.map((content) => content.text).join("\n") || ""}
61+
</MarkdownComponent>
62+
</div>
63+
)}
64+
65+
{jsonRpcResult.error && (
66+
<div className="text-xs text-red-600">
67+
{jsonRpcResult.error.message}
68+
</div>
69+
)}
70+
</div>
71+
</div>
72+
);
73+
};

webapp/_webapp/src/components/message-entry-container/tools/tools.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { UnknownToolCard } from "./unknown";
44
import { GreetingCard } from "./greeting";
55
import { ErrorToolCard } from "./error";
66
import { AlwaysExceptionCard } from "./always-exception";
7+
import { JsonRpc } from "./jsonrpc";
8+
import { parseJsonRpcResult, UNKNOWN_JSONRPC_RESULT } from "./utils/common";
79

810
type ToolsProps = {
911
messageId: string;
@@ -14,11 +16,26 @@ type ToolsProps = {
1416
animated: boolean;
1517
};
1618

19+
// define a const string list.
20+
const XTRA_MCP_TOOL_NAMES = [
21+
"enhance_academic_writing",
22+
"search_relevant_papers",
23+
"search_user",
24+
"identify_improvements",
25+
"get_user_papers",
26+
"deep_research",
27+
"get_conference_papers",
28+
"search_papers_on_openreview",
29+
"search_papers",
30+
];
31+
1732
export default function Tools({ messageId, functionName, message, error, preparing, animated }: ToolsProps) {
1833
if (error && error !== "") {
1934
return <ErrorToolCard functionName={functionName} errorMessage={error} animated={animated} />;
2035
}
2136

37+
const jsonRpcResult = parseJsonRpcResult(message);
38+
2239
if (functionName === "paper_score") {
2340
return <PaperScoreCard message={message} preparing={preparing} animated={animated} />;
2441
} else if (functionName === "paper_score_comment") {
@@ -27,7 +44,14 @@ export default function Tools({ messageId, functionName, message, error, prepari
2744
return <GreetingCard message={message} preparing={preparing} animated={animated} />;
2845
} else if (functionName === "always_exception") {
2946
return <AlwaysExceptionCard message={message} preparing={preparing} animated={animated} />;
47+
} else if (XTRA_MCP_TOOL_NAMES.includes(functionName)) {
48+
return <JsonRpc functionName={functionName} jsonRpcResult={jsonRpcResult || UNKNOWN_JSONRPC_RESULT} preparing={preparing} animated={animated} />;
3049
}
3150

32-
return <UnknownToolCard functionName={functionName} message={message} animated={animated} />;
51+
// fallback to unknown tool card if the json rpc result is not defined
52+
if (jsonRpcResult) {
53+
return <JsonRpc functionName={functionName} jsonRpcResult={jsonRpcResult} preparing={preparing} animated={animated} />;
54+
} else {
55+
return <UnknownToolCard functionName={functionName} message={message} animated={animated} />;
56+
}
3357
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { cn } from "@heroui/react";
2+
3+
type UnknownJsonRpcProps = {
4+
functionName: string;
5+
message: string;
6+
animated: boolean;
7+
};
8+
9+
export const UnknownJsonRpc = ({ functionName, message, animated }: UnknownJsonRpcProps) => {
10+
return (
11+
<div className={cn("tool-card", { animated: animated })}>
12+
<h3 className="text-xs font-semibold font-sans text-primary-700 uppercase tracking-wider mb-1">
13+
Unknown JsonRPC "{functionName}"
14+
</h3>
15+
<span className="text-xs text-primary-600">{message}</span>
16+
</div>
17+
);
18+
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
export type JsonRpcResult = {
2+
jsonrpc: string;
3+
id: number;
4+
result?: {
5+
content: Array<{
6+
type: string;
7+
text: string;
8+
}>;
9+
};
10+
error?: {
11+
code: number;
12+
message: string;
13+
}
14+
}
15+
16+
export const UNKNOWN_JSONRPC_RESULT: JsonRpcResult = {
17+
jsonrpc: "2.0",
18+
id: -1,
19+
error: {
20+
code: -1,
21+
message: "Unknown JSONRPC result",
22+
},
23+
}
24+
25+
const isValidJsonRpcResult = (obj: any): obj is JsonRpcResult => {
26+
// Check if obj is an object and not null
27+
if (typeof obj !== 'object' || obj === null) {
28+
return false;
29+
}
30+
31+
// Check required properties
32+
if (typeof obj.jsonrpc !== 'string' || typeof obj.id !== 'number') {
33+
return false;
34+
}
35+
36+
// Check that either result or error is present (but not both required)
37+
const hasResult = obj.result !== undefined;
38+
const hasError = obj.error !== undefined;
39+
40+
// Validate result structure if present
41+
if (hasResult) {
42+
if (typeof obj.result !== 'object' || obj.result === null) {
43+
return false;
44+
}
45+
if (obj.result.content !== undefined) {
46+
if (!Array.isArray(obj.result.content)) {
47+
return false;
48+
}
49+
// Validate each content item
50+
for (const item of obj.result.content) {
51+
if (typeof item !== 'object' || item === null ||
52+
typeof item.type !== 'string' || typeof item.text !== 'string') {
53+
return false;
54+
}
55+
}
56+
}
57+
}
58+
59+
// Validate error structure if present
60+
if (hasError) {
61+
if (typeof obj.error !== 'object' || obj.error === null ||
62+
typeof obj.error.code !== 'number' || typeof obj.error.message !== 'string') {
63+
return false;
64+
}
65+
}
66+
67+
return true;
68+
};
69+
70+
export const parseJsonRpcResult = (message: string): JsonRpcResult | undefined => {
71+
try {
72+
const json = JSON.parse(message);
73+
74+
// Validate the structure before casting
75+
if (isValidJsonRpcResult(json)) {
76+
return json;
77+
}
78+
79+
return undefined;
80+
} catch (error) {
81+
return undefined;
82+
}
83+
}

webapp/_webapp/src/index.css

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,16 +91,25 @@ body {
9191
border-color: var(--pd-border-color);
9292
}
9393

94+
.tool-card.narrow {
95+
@apply px-2 py-0 my-1 bg-transparent;
96+
}
97+
9498
.tool-card.animated {
9599
opacity: 0;
96100
animation: toolCardAppear 0.5s ease-out 200ms forwards;
97101
transition-behavior: allow-discrete;
98102
}
99103

100104
.tool-card-title {
101-
@apply text-xs font-semibold font-sans text-primary-700 uppercase tracking-wider mb-1 noselect;
105+
@apply text-xs font-semibold font-sans text-primary-700 uppercase tracking-wider noselect;
102106
}
103107

108+
.tool-card-title.tool-card-jsonrpc {
109+
@apply font-medium text-gray-500;
110+
}
111+
112+
104113
/* 相邻 tool-card 的样式处理 */
105114
.tool-card + .tool-card {
106115
/* 相邻的第二个卡片:移除上边框,调整上圆角,减少上边距,减少上 padding */

0 commit comments

Comments
 (0)