Skip to content

Commit 71bb89d

Browse files
create sampling response form
1 parent 3032a67 commit 71bb89d

File tree

6 files changed

+316
-33
lines changed

6 files changed

+316
-33
lines changed

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: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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+
import { useToast } from "@/hooks/use-toast";
11+
12+
export type SamplingRequestProps = {
13+
request: PendingRequest;
14+
onApprove: (id: number, result: CreateMessageResult) => void;
15+
onReject: (id: number) => void;
16+
};
17+
18+
const SamplingRequest = ({
19+
onApprove,
20+
request,
21+
onReject,
22+
}: SamplingRequestProps) => {
23+
const { toast } = useToast();
24+
25+
const [messageResult, setMessageResult] = useState<JsonValue>({
26+
model: "GPT-4o",
27+
stopReason: "endTurn",
28+
role: "assistant",
29+
content: {
30+
type: "text",
31+
text: "",
32+
},
33+
});
34+
35+
const s = useMemo(() => {
36+
const schema: JsonSchemaType = {
37+
type: "object",
38+
description: "Message result",
39+
properties: {
40+
model: {
41+
type: "string",
42+
default: "GPT-4o",
43+
description: "model name",
44+
},
45+
stopReason: {
46+
type: "string",
47+
default: "endTurn",
48+
description: "Stop reason",
49+
},
50+
role: {
51+
type: "string",
52+
default: "endTurn",
53+
description: "Role of the model",
54+
},
55+
content: {
56+
type: "object",
57+
properties: {
58+
type: {
59+
type: "string",
60+
default: "text",
61+
description: "Type of content",
62+
},
63+
},
64+
},
65+
},
66+
};
67+
68+
const contentType = (messageResult as any)?.content?.type;
69+
if (contentType === "text" && schema.properties) {
70+
schema.properties.content.properties = {
71+
...schema.properties.content.properties,
72+
text: {
73+
type: "string",
74+
default: "",
75+
description: "text content",
76+
},
77+
};
78+
setMessageResult((prev) => ({
79+
...(prev as { [key: string]: JsonValue }),
80+
content: {
81+
type: contentType,
82+
text: "",
83+
},
84+
}));
85+
} else if (contentType === "image" && schema.properties) {
86+
schema.properties.content.properties = {
87+
...schema.properties.content.properties,
88+
data: {
89+
type: "string",
90+
default: "",
91+
description: "Base64 encoded image data",
92+
},
93+
mimeType: {
94+
type: "string",
95+
default: "",
96+
description: "Mime type of the image",
97+
},
98+
};
99+
setMessageResult((prev) => ({
100+
...(prev as { [key: string]: JsonValue }),
101+
content: {
102+
type: contentType,
103+
data: "",
104+
mimeType: "",
105+
},
106+
}));
107+
}
108+
109+
return schema;
110+
}, [(messageResult as any)?.content?.type]);
111+
112+
const handleApprove = (id: number) => {
113+
const validationResult = CreateMessageResultSchema.safeParse(messageResult);
114+
if (!validationResult.success) {
115+
toast({
116+
title: "Error",
117+
description: `There was an error validating the message result: ${validationResult.error.message}`,
118+
variant: "destructive",
119+
});
120+
return;
121+
}
122+
123+
onApprove(id, validationResult.data);
124+
};
125+
126+
return (
127+
<div
128+
data-testid="sampling-request"
129+
className="flex gap-4 p-4 border rounded-lg space-y-4"
130+
>
131+
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
132+
<JsonView data={JSON.stringify(request.request)} />
133+
</div>
134+
<form className="flex-1 space-y-4">
135+
<div className="space-y-2">
136+
<DynamicJsonForm
137+
defaultIsJsonMode={true}
138+
schema={s}
139+
value={messageResult}
140+
onChange={(newValue: JsonValue) => {
141+
setMessageResult(newValue);
142+
}}
143+
/>
144+
</div>
145+
<div className="flex space-x-2 mt-1">
146+
<Button type="button" onClick={() => handleApprove(request.id)}>
147+
Approve
148+
</Button>
149+
<Button
150+
type="button"
151+
variant="outline"
152+
onClick={() => onReject(request.id)}
153+
>
154+
Reject
155+
</Button>
156+
</div>
157+
</form>
158+
</div>
159+
);
160+
};
161+
162+
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)