Skip to content

Commit 68067f5

Browse files
create sampling response form
1 parent 3032a67 commit 68067f5

6 files changed

Lines changed: 318 additions & 33 deletions

File tree

client/src/components/DynamicJsonForm.tsx

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,17 @@ interface DynamicJsonFormProps {
3636
value: JsonValue;
3737
onChange: (value: JsonValue) => void;
3838
maxDepth?: number;
39+
defaultIsJsonMode?: boolean;
3940
}
4041

4142
const DynamicJsonForm = ({
4243
schema,
4344
value,
4445
onChange,
4546
maxDepth = 3,
47+
defaultIsJsonMode = false,
4648
}: DynamicJsonFormProps) => {
47-
const [isJsonMode, setIsJsonMode] = useState(false);
49+
const [isJsonMode, setIsJsonMode] = useState(defaultIsJsonMode);
4850
const [jsonError, setJsonError] = useState<string>();
4951
// Store the raw JSON string to allow immediate feedback during typing
5052
// while deferring parsing until the user stops typing
@@ -370,11 +372,21 @@ const DynamicJsonForm = ({
370372
<div className="space-y-4">
371373
<div className="flex justify-end space-x-2">
372374
{isJsonMode && (
373-
<Button variant="outline" size="sm" onClick={formatJson}>
375+
<Button
376+
type="button"
377+
variant="outline"
378+
size="sm"
379+
onClick={formatJson}
380+
>
374381
Format JSON
375382
</Button>
376383
)}
377-
<Button variant="outline" size="sm" onClick={handleSwitchToFormMode}>
384+
<Button
385+
type="button"
386+
variant="outline"
387+
size="sm"
388+
onClick={handleSwitchToFormMode}
389+
>
378390
{isJsonMode ? "Switch to Form" : "Switch to JSON"}
379391
</Button>
380392
</div>
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
import { Button } from "@/components/ui/button";
2+
import JsonView from "./JsonView";
3+
import { useMemo, useState } from "react";
4+
import {
5+
CreateMessageResult,
6+
CreateMessageResultSchema,
7+
} from "@modelcontextprotocol/sdk/types.js";
8+
import { PendingRequest } from "./SamplingTab";
9+
import DynamicJsonForm, { JsonSchemaType, JsonValue } from "./DynamicJsonForm";
10+
11+
import { useToast } from "@/hooks/use-toast";
12+
import { text } from "stream/consumers";
13+
14+
export type SamplingRequestProps = {
15+
request: PendingRequest;
16+
onApprove: (id: number, result: CreateMessageResult) => void;
17+
onReject: (id: number) => void;
18+
};
19+
20+
const SamplingRequest = ({
21+
onApprove,
22+
request,
23+
onReject,
24+
}: SamplingRequestProps) => {
25+
const { toast } = useToast();
26+
27+
const [messageResult, setMessageResult] = useState({
28+
model: "GPT-4o",
29+
stopReason: "endTurn",
30+
role: "assistant",
31+
content: {
32+
type: "text",
33+
text: "",
34+
},
35+
});
36+
37+
const s = useMemo(() => {
38+
const schema: JsonSchemaType = {
39+
type: "object",
40+
description: "Message result",
41+
properties: {
42+
model: {
43+
type: "string",
44+
default: "GPT-4o",
45+
description: "model name",
46+
},
47+
stopReason: {
48+
type: "string",
49+
default: "endTurn",
50+
description: "Stop reason",
51+
},
52+
role: {
53+
type: "string",
54+
default: "endTurn",
55+
description: "Role of the model",
56+
},
57+
content: {
58+
type: "object",
59+
properties: {
60+
type: {
61+
type: "string",
62+
default: "text",
63+
description: "Type of content",
64+
},
65+
},
66+
},
67+
},
68+
};
69+
70+
const contentType = (messageResult as any)?.content?.type;
71+
if (contentType === "text" && schema.properties) {
72+
schema.properties.content.properties = {
73+
...schema.properties.content.properties,
74+
text: {
75+
type: "string",
76+
default: "",
77+
description: "text content",
78+
},
79+
};
80+
setMessageResult((prev: any) => ({
81+
...prev,
82+
content: {
83+
type: contentType,
84+
text: "",
85+
},
86+
}));
87+
} else if (contentType === "image" && schema.properties) {
88+
schema.properties.content.properties = {
89+
...schema.properties.content.properties,
90+
data: {
91+
type: "string",
92+
default: "",
93+
description: "Base64 encoded image data",
94+
},
95+
mimeType: {
96+
type: "string",
97+
default: "",
98+
description: "Mime type of the image",
99+
},
100+
};
101+
setMessageResult((prev: any) => ({
102+
...prev,
103+
content: {
104+
type: contentType,
105+
data: "",
106+
mimeType: "",
107+
},
108+
}));
109+
}
110+
111+
return schema;
112+
}, [(messageResult as any)?.content?.type]);
113+
114+
const handleApprove = (id: number) => {
115+
const validationResult = CreateMessageResultSchema.safeParse(messageResult);
116+
if (!validationResult.success) {
117+
toast({
118+
title: "Error",
119+
description: `There was an error validating the message result: ${validationResult.error.message}`,
120+
variant: "destructive",
121+
});
122+
return;
123+
}
124+
125+
onApprove(id, validationResult.data);
126+
};
127+
128+
return (
129+
<div
130+
data-testid="sampling-request"
131+
className="flex gap-4 p-4 border rounded-lg space-y-4"
132+
>
133+
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
134+
<JsonView data={JSON.stringify(request.request)} />
135+
</div>
136+
<form className="flex-1 space-y-4">
137+
<div className="space-y-2">
138+
<DynamicJsonForm
139+
defaultIsJsonMode={true}
140+
schema={s}
141+
value={messageResult}
142+
onChange={(newValue: JsonValue) => {
143+
setMessageResult(newValue);
144+
}}
145+
/>
146+
</div>
147+
<div className="flex space-x-2 mt-1">
148+
<Button type="button" onClick={() => handleApprove(request.id)}>
149+
Approve
150+
</Button>
151+
<Button
152+
type="button"
153+
variant="outline"
154+
onClick={() => onReject(request.id)}
155+
>
156+
Reject
157+
</Button>
158+
</div>
159+
</form>
160+
</div>
161+
);
162+
};
163+
164+
export default SamplingRequest;

client/src/components/SamplingTab.tsx

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import { Alert, AlertDescription } from "@/components/ui/alert";
2-
import { Button } from "@/components/ui/button";
32
import { TabsContent } from "@/components/ui/tabs";
43
import {
54
CreateMessageRequest,
65
CreateMessageResult,
76
} from "@modelcontextprotocol/sdk/types.js";
8-
import JsonView from "./JsonView";
7+
import SamplingRequest from "./SamplingRequest";
98

109
export type PendingRequest = {
1110
id: number;
@@ -19,21 +18,8 @@ export type Props = {
1918
};
2019

2120
const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
22-
const handleApprove = (id: number) => {
23-
// For now, just return a stub response
24-
onApprove(id, {
25-
model: "stub-model",
26-
stopReason: "endTurn",
27-
role: "assistant",
28-
content: {
29-
type: "text",
30-
text: "This is a stub response.",
31-
},
32-
});
33-
};
34-
3521
return (
36-
<TabsContent value="sampling" className="h-96">
22+
<TabsContent value="sampling" className="mh-96">
3723
<Alert>
3824
<AlertDescription>
3925
When the server requests LLM sampling, requests will appear here for
@@ -43,19 +29,12 @@ const SamplingTab = ({ pendingRequests, onApprove, onReject }: Props) => {
4329
<div className="mt-4 space-y-4">
4430
<h3 className="text-lg font-semibold">Recent Requests</h3>
4531
{pendingRequests.map((request) => (
46-
<div key={request.id} className="p-4 border rounded-lg space-y-4">
47-
<JsonView
48-
className="bg-gray-50 dark:bg-gray-800 dark:text-gray-100 rounded"
49-
data={JSON.stringify(request.request)}
50-
/>
51-
52-
<div className="flex space-x-2">
53-
<Button onClick={() => handleApprove(request.id)}>Approve</Button>
54-
<Button variant="outline" onClick={() => onReject(request.id)}>
55-
Reject
56-
</Button>
57-
</div>
58-
</div>
32+
<SamplingRequest
33+
key={request.id}
34+
request={request}
35+
onApprove={onApprove}
36+
onReject={onReject}
37+
/>
5938
))}
6039
{pendingRequests.length === 0 && (
6140
<p className="text-gray-500">No pending requests</p>

client/src/components/Sidebar.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,9 @@ const Sidebar = ({
460460
</SelectTrigger>
461461
<SelectContent>
462462
{Object.values(LoggingLevelSchema.enum).map((level) => (
463-
<SelectItem value={level}>{level}</SelectItem>
463+
<SelectItem key={level} value={level}>
464+
{level}
465+
</SelectItem>
464466
))}
465467
</SelectContent>
466468
</Select>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { render, screen, fireEvent } from "@testing-library/react";
2+
import SamplingRequest from "../SamplingRequest";
3+
import { PendingRequest } from "../SamplingTab";
4+
5+
const mockRequest: PendingRequest = {
6+
id: 1,
7+
request: {
8+
method: "sampling/createMessage",
9+
params: {
10+
messages: [
11+
{
12+
role: "user",
13+
content: {
14+
type: "text",
15+
text: "What files are in the current directory?",
16+
},
17+
},
18+
],
19+
systemPrompt: "You are a helpful file system assistant.",
20+
includeContext: "thisServer",
21+
maxTokens: 100,
22+
},
23+
},
24+
};
25+
26+
describe("Form to handle sampling response", () => {
27+
const mockOnApprove = jest.fn();
28+
const mockOnReject = jest.fn();
29+
30+
afterEach(() => {
31+
jest.clearAllMocks();
32+
});
33+
34+
it("should call onApprove with correct text content when Approve button is clicked", () => {
35+
render(
36+
<SamplingRequest
37+
request={mockRequest}
38+
onApprove={mockOnApprove}
39+
onReject={mockOnReject}
40+
/>,
41+
);
42+
43+
// Click the Approve button
44+
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
45+
46+
// Assert that onApprove is called with the correct arguments
47+
expect(mockOnApprove).toHaveBeenCalledWith(mockRequest.id, {
48+
model: "GPT-4o",
49+
stopReason: "endTurn",
50+
role: "assistant",
51+
content: {
52+
type: "text",
53+
text: "",
54+
},
55+
});
56+
});
57+
58+
it("should call onReject with correct request id when Reject button is clicked", () => {
59+
render(
60+
<SamplingRequest
61+
request={mockRequest}
62+
onApprove={mockOnApprove}
63+
onReject={mockOnReject}
64+
/>,
65+
);
66+
67+
// Click the Approve button
68+
fireEvent.click(screen.getByRole("button", { name: /Reject/i }));
69+
70+
// Assert that onApprove is called with the correct arguments
71+
expect(mockOnReject).toHaveBeenCalledWith(mockRequest.id);
72+
});
73+
});

0 commit comments

Comments
 (0)