Skip to content

Commit a058229

Browse files
committed
feat: add ElicitationModal
1 parent e6166a7 commit a058229

File tree

3 files changed

+717
-1
lines changed

3 files changed

+717
-1
lines changed

client/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
"dependencies": {
2626
"@modelcontextprotocol/sdk": "^1.12.1",
2727
"@radix-ui/react-checkbox": "^1.1.4",
28-
"ajv": "^6.12.6",
2928
"@radix-ui/react-dialog": "^1.1.3",
3029
"@radix-ui/react-icons": "^1.3.0",
3130
"@radix-ui/react-label": "^2.1.0",
@@ -35,6 +34,7 @@
3534
"@radix-ui/react-tabs": "^1.1.1",
3635
"@radix-ui/react-toast": "^1.2.6",
3736
"@radix-ui/react-tooltip": "^1.1.8",
37+
"ajv": "^6.12.6",
3838
"class-variance-authority": "^0.7.0",
3939
"clsx": "^2.1.1",
4040
"cmdk": "^1.0.4",
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import { useState, useEffect } from "react";
2+
import { Button } from "@/components/ui/button";
3+
import {
4+
Dialog,
5+
DialogContent,
6+
DialogDescription,
7+
DialogFooter,
8+
DialogHeader,
9+
DialogTitle,
10+
} from "@/components/ui/dialog";
11+
import DynamicJsonForm from "./DynamicJsonForm";
12+
import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
13+
import { generateDefaultValue } from "@/utils/schemaUtils";
14+
import Ajv from "ajv";
15+
16+
export interface ElicitationRequest {
17+
id: number;
18+
message: string;
19+
requestedSchema: JsonSchemaType;
20+
resolve: (response: ElicitationResponse) => void;
21+
}
22+
23+
export interface ElicitationResponse {
24+
action: "accept" | "reject" | "cancel";
25+
content?: Record<string, unknown>;
26+
}
27+
28+
interface ElicitationModalProps {
29+
request: ElicitationRequest | null;
30+
onClose: () => void;
31+
}
32+
33+
const ElicitationModal = ({ request, onClose }: ElicitationModalProps) => {
34+
const [formData, setFormData] = useState<JsonValue>({});
35+
const [validationError, setValidationError] = useState<string | null>(null);
36+
37+
useEffect(() => {
38+
if (request) {
39+
const defaultValue = generateDefaultValue(request.requestedSchema);
40+
setFormData(defaultValue);
41+
setValidationError(null);
42+
}
43+
}, [request]);
44+
45+
if (!request) return null;
46+
47+
const validateEmailFormat = (email: string): boolean => {
48+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
49+
return emailRegex.test(email);
50+
};
51+
52+
const validateFormData = (
53+
data: JsonValue,
54+
schema: JsonSchemaType,
55+
): boolean => {
56+
if (
57+
schema.type === "object" &&
58+
schema.properties &&
59+
typeof data === "object" &&
60+
data !== null
61+
) {
62+
const dataObj = data as Record<string, unknown>;
63+
64+
if (Array.isArray(schema.required)) {
65+
for (const field of schema.required) {
66+
const value = dataObj[field];
67+
if (value === undefined || value === null || value === "") {
68+
setValidationError(`Required field missing: ${field}`);
69+
return false;
70+
}
71+
}
72+
}
73+
74+
for (const [fieldName, fieldValue] of Object.entries(dataObj)) {
75+
const fieldSchema = schema.properties[fieldName];
76+
if (
77+
fieldSchema &&
78+
fieldSchema.format === "email" &&
79+
typeof fieldValue === "string"
80+
) {
81+
if (!validateEmailFormat(fieldValue)) {
82+
setValidationError(`Invalid email format: ${fieldName}`);
83+
return false;
84+
}
85+
}
86+
}
87+
}
88+
89+
return true;
90+
};
91+
92+
const handleAccept = () => {
93+
try {
94+
if (!validateFormData(formData, request.requestedSchema)) {
95+
return;
96+
}
97+
98+
const ajv = new Ajv();
99+
const validate = ajv.compile(request.requestedSchema);
100+
const isValid = validate(formData);
101+
102+
if (!isValid) {
103+
const errorMessage = ajv.errorsText(validate.errors);
104+
setValidationError(errorMessage);
105+
return;
106+
}
107+
108+
request.resolve({
109+
action: "accept",
110+
content: formData as Record<string, unknown>,
111+
});
112+
onClose();
113+
} catch (error) {
114+
setValidationError(
115+
error instanceof Error ? error.message : "Validation failed",
116+
);
117+
}
118+
};
119+
120+
const handleReject = () => {
121+
request.resolve({ action: "reject" });
122+
onClose();
123+
};
124+
125+
const handleCancel = () => {
126+
request.resolve({ action: "cancel" });
127+
onClose();
128+
};
129+
130+
const schemaTitle = request.requestedSchema.title || "Information Request";
131+
const schemaDescription = request.requestedSchema.description;
132+
133+
return (
134+
<Dialog open={true} onOpenChange={handleCancel}>
135+
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
136+
<DialogHeader>
137+
<DialogTitle>{schemaTitle}</DialogTitle>
138+
<DialogDescription className="space-y-2">
139+
<span className="block">{request.message}</span>
140+
{schemaDescription && (
141+
<span className="block text-sm text-muted-foreground">
142+
{schemaDescription}
143+
</span>
144+
)}
145+
</DialogDescription>
146+
</DialogHeader>
147+
148+
<div className="py-4">
149+
<DynamicJsonForm
150+
schema={request.requestedSchema}
151+
value={formData}
152+
onChange={(newValue: JsonValue) => {
153+
setFormData(newValue);
154+
setValidationError(null);
155+
}}
156+
/>
157+
158+
{validationError && (
159+
<div className="mt-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
160+
<div className="text-sm text-red-600 dark:text-red-400">
161+
<strong>Validation Error:</strong> {validationError}
162+
</div>
163+
</div>
164+
)}
165+
</div>
166+
167+
<DialogFooter className="gap-2">
168+
<Button variant="outline" onClick={handleCancel}>
169+
Cancel
170+
</Button>
171+
<Button variant="outline" onClick={handleReject}>
172+
Decline
173+
</Button>
174+
<Button onClick={handleAccept}>Submit</Button>
175+
</DialogFooter>
176+
</DialogContent>
177+
</Dialog>
178+
);
179+
};
180+
181+
export default ElicitationModal;

0 commit comments

Comments
 (0)