Skip to content

Commit 4e42782

Browse files
create sampling response form
1 parent 3032a67 commit 4e42782

6 files changed

Lines changed: 290 additions & 31 deletions

File tree

client/src/components/DynamicJsonForm.tsx

Lines changed: 3 additions & 1 deletion
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
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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+
13+
export type SamplingRequestProps = {
14+
request: PendingRequest;
15+
onApprove: (id: number, result: CreateMessageResult) => void;
16+
onReject: (id: number) => void;
17+
};
18+
19+
const SamplingRequest = ({
20+
onApprove,
21+
request,
22+
onReject,
23+
}: SamplingRequestProps) => {
24+
const { toast } = useToast();
25+
26+
const [messageResult, setMessageResult] = useState<JsonValue>({
27+
model: "GPT-4o",
28+
stopReason: "endTurn",
29+
role: "assistant",
30+
content: {
31+
type: "text",
32+
text: "",
33+
},
34+
});
35+
36+
const s = useMemo(() => {
37+
const schema: JsonSchemaType = {
38+
type: "object",
39+
description: "Message result",
40+
properties: {
41+
model: {
42+
type: "string",
43+
default: "GPT-4o",
44+
description: "model name",
45+
},
46+
stopReason: {
47+
type: "string",
48+
default: "endTurn",
49+
description: "Stop reason",
50+
},
51+
role: {
52+
type: "string",
53+
default: "endTurn",
54+
description: "Role of the model",
55+
},
56+
content: {
57+
type: "object",
58+
properties: {
59+
type: {
60+
type: "string",
61+
default: "text",
62+
description: "Type of content",
63+
},
64+
},
65+
},
66+
},
67+
};
68+
69+
const contentType = (messageResult as any)?.content?.type;
70+
if (contentType === "text" && schema.properties) {
71+
schema.properties.content.properties = {
72+
...schema.properties.content.properties,
73+
text: {
74+
type: "string",
75+
default: "",
76+
description: "text content",
77+
},
78+
};
79+
} else if (contentType === "image" && schema.properties) {
80+
schema.properties.content.properties = {
81+
...schema.properties.content.properties,
82+
data: {
83+
type: "string",
84+
default: "",
85+
description: "Base64 encoded image data",
86+
},
87+
mimeType: {
88+
type: "string",
89+
default: "",
90+
description: "Mime type of the image",
91+
},
92+
};
93+
}
94+
95+
return schema;
96+
}, [(messageResult as any)?.content?.type]);
97+
98+
const handleApprove = (id: number) => {
99+
const validationResult = CreateMessageResultSchema.safeParse(messageResult);
100+
if (!validationResult.success) {
101+
toast({
102+
title: "Error",
103+
description: `There was an error validating the message result: ${validationResult.error.message}`,
104+
variant: "destructive",
105+
});
106+
return;
107+
}
108+
109+
onApprove(id, validationResult.data);
110+
};
111+
112+
return (
113+
<div
114+
data-testid="sampling-request"
115+
className="flex gap-4 p-4 border rounded-lg space-y-4"
116+
>
117+
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
118+
<JsonView data={JSON.stringify(request.request)} />
119+
</div>
120+
<form className="flex-1 space-y-4">
121+
<div className="space-y-2">
122+
<DynamicJsonForm
123+
defaultIsJsonMode={true}
124+
schema={s}
125+
value={messageResult}
126+
onChange={(newValue: JsonValue) => {
127+
setMessageResult(newValue);
128+
}}
129+
/>
130+
</div>
131+
<div className="flex space-x-2 mt-1">
132+
<Button type="button" onClick={() => handleApprove(request.id)}>
133+
Approve
134+
</Button>
135+
<Button
136+
type="button"
137+
variant="outline"
138+
onClick={() => onReject(request.id)}
139+
>
140+
Reject
141+
</Button>
142+
</div>
143+
</form>
144+
</div>
145+
);
146+
};
147+
148+
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+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { Tabs } from "@/components/ui/tabs";
3+
import SamplingTab, { PendingRequest } from "../SamplingTab";
4+
5+
describe("Sampling tab", () => {
6+
const mockOnApprove = jest.fn();
7+
const mockOnReject = jest.fn();
8+
9+
const renderSamplingTab = (pendingRequests: PendingRequest[]) =>
10+
render(
11+
<Tabs defaultValue="sampling">
12+
<SamplingTab
13+
pendingRequests={pendingRequests}
14+
onApprove={mockOnApprove}
15+
onReject={mockOnReject}
16+
/>
17+
</Tabs>,
18+
);
19+
20+
it("should render 'No pending requests' when there are no pending requests", () => {
21+
renderSamplingTab([]);
22+
expect(
23+
screen.getByText(
24+
"When the server requests LLM sampling, requests will appear here for approval.",
25+
),
26+
).toBeTruthy();
27+
expect(screen.findByText("No pending requests")).toBeTruthy();
28+
});
29+
30+
it("should render the correct number of requests", () => {
31+
renderSamplingTab(
32+
Array.from({ length: 5 }, (_, i) => ({
33+
id: i,
34+
request: {
35+
method: "sampling/createMessage",
36+
params: {
37+
messages: [
38+
{
39+
role: "user",
40+
content: {
41+
type: "text",
42+
text: "What files are in the current directory?",
43+
},
44+
},
45+
],
46+
systemPrompt: "You are a helpful file system assistant.",
47+
includeContext: "thisServer",
48+
maxTokens: 100,
49+
},
50+
},
51+
})),
52+
);
53+
expect(screen.getAllByTestId("sampling-request").length).toBe(5);
54+
});
55+
});

0 commit comments

Comments
 (0)