返回 DeepSeek-Reasonix
StructuredForm.tsx
根目录 / desktop / frontend / src / components / StructuredForm.tsx
1 import { useEffect, useId, useRef } from "react";
2 import { useI18n } from "../lib/i18n";
3 import {
4 structuredFieldIssue,
5 type StructuredField,
6 type StructuredFieldIssue,
7 type StructuredFieldValue,
8 } from "../lib/structuredFormSchema";
9
10 export {
11 coerceStructuredValues,
12 initialStructuredValues,
13 missingStructuredRequired,
14 normalizeStructuredSchema,
15 parseStructuredSchema,
16 } from "../lib/structuredFormSchema";
17 export type { StructuredField, StructuredFieldIssue, StructuredFieldValue } from "../lib/structuredFormSchema";
18
19 function inputType(field: StructuredField): "text" | "email" | "url" | "date" | "number" {
20 if (field.kind === "number" || field.kind === "integer") return "number";
21 if (field.format === "email") return "email";
22 if (field.format === "uri") return "url";
23 if (field.format === "date") return "date";
24 // RFC 3339 date-times can include a timezone, which datetime-local drops.
25 return "text";
26 }
27
28 // StructuredForm renders the complete MCP flat-schema subset. It owns no
29 // protocol state: the caller decides when validation becomes visible and when
30 // values are submitted.
31 export function StructuredForm({
32 fields,
33 values,
34 onChange,
35 disabled,
36 showErrors = false,
37 focusInvalidNonce = 0,
38 }: {
39 fields: StructuredField[];
40 values: Record<string, StructuredFieldValue>;
41 onChange: (next: Record<string, StructuredFieldValue>) => void;
42 disabled?: boolean;
43 showErrors?: boolean;
44 focusInvalidNonce?: number;
45 }) {
46 const { t } = useI18n();
47 const formID = useId().replace(/:/g, "");
48 const controls = useRef(new Map<string, HTMLElement>());
49
50 useEffect(() => {
51 const first = fields.find((field) => field.kind !== "unsupported");
52 if (first) controls.current.get(first.key)?.focus();
53 }, [fields]);
54
55 useEffect(() => {
56 if (!focusInvalidNonce) return;
57 const firstInvalid = fields.find((field) => structuredFieldIssue(field, values[field.key]));
58 if (firstInvalid) controls.current.get(firstInvalid.key)?.focus();
59 }, [fields, focusInvalidNonce, values]);
60
61 const set = (key: string, value: StructuredFieldValue | undefined) => {
62 const next = { ...values };
63 if (value === undefined) delete next[key];
64 else next[key] = value;
65 onChange(next);
66 };
67
68 const messageFor = (field: StructuredField, issue: StructuredFieldIssue): string => {
69 if (issue === "required") return t("mcp.interaction.fieldRequired");
70 if (issue === "tooShort") return t("mcp.interaction.fieldTooShort", { min: field.minLength ?? 0 });
71 if (issue === "tooLong") return t("mcp.interaction.fieldTooLong", { max: field.maxLength ?? 0 });
72 if (issue === "belowMinimum") return t("mcp.interaction.fieldBelowMinimum", { min: field.minimum ?? 0 });
73 if (issue === "aboveMaximum") return t("mcp.interaction.fieldAboveMaximum", { max: field.maximum ?? 0 });
74 if (issue === "tooFewItems") return t("mcp.interaction.fieldTooFewItems", { min: field.minItems ?? 0 });
75 if (issue === "tooManyItems") return t("mcp.interaction.fieldTooManyItems", { max: field.maxItems ?? 0 });
76 if (issue === "unsupported") return t("mcp.interaction.fieldUnsupported");
77 return t("mcp.interaction.fieldInvalid");
78 };
79
80 return (
81 <div className="structured-form">
82 {fields.map((field, index) => {
83 const raw = values[field.key];
84 const fieldID = `${formID}-field-${index}`;
85 const hintID = field.description ? `${fieldID}-hint` : undefined;
86 const issue = structuredFieldIssue(field, raw);
87 const errorID = showErrors && issue ? `${fieldID}-error` : undefined;
88 const describedBy = [hintID, errorID].filter(Boolean).join(" ") || undefined;
89 const setControl = (node: HTMLElement | null) => {
90 if (node) controls.current.set(field.key, node);
91 else controls.current.delete(field.key);
92 };
93 const label = (
94 <>
95 {field.label}
96 {field.required ? <span className="structured-form-required" aria-hidden="true"> *</span> : null}
97 </>
98 );
99 const help = (
100 <>
101 {field.description ? <span id={hintID} className="structured-form-hint">{field.description}</span> : null}
102 {showErrors && issue ? <span id={errorID} className="structured-form-error" role="alert">{messageFor(field, issue)}</span> : null}
103 </>
104 );
105
106 if (field.kind === "unsupported") {
107 return (
108 <div key={field.key} className="structured-form-field structured-form-field--wide">
109 <span className="structured-form-label">{label}</span>
110 <span className="structured-form-unsupported" role="alert">{t("mcp.interaction.fieldUnsupported")}</span>
111 </div>
112 );
113 }
114
115 if (field.kind === "boolean") {
116 return (
117 <div key={field.key} className="structured-form-field structured-form-field--wide">
118 <label className="structured-form-checkbox-row" htmlFor={fieldID}>
119 <input
120 ref={setControl}
121 id={fieldID}
122 type="checkbox"
123 className="structured-form-checkbox"
124 checked={raw === true}
125 disabled={disabled}
126 aria-required={field.required || undefined}
127 aria-invalid={showErrors && issue ? true : undefined}
128 aria-describedby={describedBy}
129 onChange={(event) => set(field.key, event.target.checked)}
130 />
131 <span className="structured-form-label">{label}</span>
132 </label>
133 {help}
134 </div>
135 );
136 }
137
138 if (field.kind === "multi-enum" && field.options) {
139 const selected = Array.isArray(raw) ? raw : [];
140 return (
141 <fieldset
142 key={field.key}
143 className="structured-form-field structured-form-field--wide structured-form-options"
144 aria-required={field.required || undefined}
145 aria-invalid={showErrors && issue ? true : undefined}
146 aria-describedby={describedBy}
147 >
148 <legend className="structured-form-label">{label}</legend>
149 {field.options.map((option, optionIndex) => (
150 <label key={option.value} className="structured-form-option">
151 <input
152 ref={optionIndex === 0 ? setControl : undefined}
153 type="checkbox"
154 checked={selected.includes(option.value)}
155 disabled={disabled}
156 onChange={(event) => {
157 const next = event.target.checked
158 ? [...selected, option.value]
159 : selected.filter((value) => value !== option.value);
160 set(field.key, next);
161 }}
162 />
163 <span>{option.label}</span>
164 </label>
165 ))}
166 {help}
167 </fieldset>
168 );
169 }
170
171 const stringRaw = raw === undefined || Array.isArray(raw) ? "" : String(raw);
172 return (
173 <div key={field.key} className="structured-form-field">
174 <label className="structured-form-label" htmlFor={fieldID}>{label}</label>
175 {field.kind === "enum" && field.options ? (
176 <select
177 ref={setControl}
178 id={fieldID}
179 className="structured-form-control"
180 value={stringRaw}
181 disabled={disabled}
182 required={field.required}
183 aria-required={field.required || undefined}
184 aria-invalid={showErrors && issue ? true : undefined}
185 aria-describedby={describedBy}
186 onChange={(event) => set(field.key, event.target.value || undefined)}
187 >
188 <option value="">{t("mcp.interaction.chooseOption")}</option>
189 {field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
190 </select>
191 ) : (
192 <input
193 ref={setControl}
194 id={fieldID}
195 type={inputType(field)}
196 className="structured-form-control"
197 value={stringRaw}
198 disabled={disabled}
199 required={field.required}
200 aria-required={field.required || undefined}
201 aria-invalid={showErrors && issue ? true : undefined}
202 aria-describedby={describedBy}
203 min={field.minimum}
204 max={field.maximum}
205 step={field.kind === "integer" ? 1 : field.kind === "number" ? "any" : undefined}
206 minLength={field.minLength}
207 maxLength={field.maxLength}
208 inputMode={field.kind === "integer" ? "numeric" : field.kind === "number" ? "decimal" : undefined}
209 placeholder={field.format === "date-time" ? "2026-08-29T12:00:00Z" : undefined}
210 onChange={(event) => set(field.key, event.target.value || undefined)}
211 />
212 )}
213 {help}
214 </div>
215 );
216 })}
217 </div>
218 );
219 }
220
220 lines Plain Text