返回 DeepSeek-Reasonix
structuredFormSchema.ts
根目录 / desktop / frontend / src / lib / structuredFormSchema.ts
1 export type StructuredFieldValue = string | number | boolean | string[];
2
3 export type StructuredFieldIssue =
4 | "required"
5 | "invalid"
6 | "tooShort"
7 | "tooLong"
8 | "belowMinimum"
9 | "aboveMaximum"
10 | "tooFewItems"
11 | "tooManyItems"
12 | "unsupported";
13
14 export type StructuredField = {
15 key: string;
16 label: string;
17 kind: "string" | "number" | "integer" | "boolean" | "enum" | "multi-enum" | "unsupported";
18 required: boolean;
19 defaultValue?: StructuredFieldValue;
20 options?: { label: string; value: string }[];
21 format?: "email" | "uri" | "date" | "date-time";
22 minLength?: number;
23 maxLength?: number;
24 minimum?: number;
25 maximum?: number;
26 minItems?: number;
27 maxItems?: number;
28 pattern?: string;
29 description?: string;
30 };
31
32 export type StructuredSchemaResult = {
33 fields: StructuredField[];
34 unsupported: boolean;
35 };
36
37 function titledOptions(raw: unknown, keyword: "oneOf" | "anyOf"): StructuredField["options"] {
38 const choices = (raw as Record<string, unknown>)[keyword];
39 if (!Array.isArray(choices)) return undefined;
40 const options = choices.flatMap((choice) => {
41 if (!choice || typeof choice !== "object") return [];
42 const entry = choice as Record<string, unknown>;
43 if (typeof entry.const !== "string") return [];
44 return [{ value: entry.const, label: typeof entry.title === "string" && entry.title ? entry.title : entry.const }];
45 });
46 return options.length === choices.length && options.length > 0 ? options : undefined;
47 }
48
49 function legacyOptions(raw: Record<string, unknown>): StructuredField["options"] {
50 if (!Array.isArray(raw.enum) || raw.enum.length === 0 || !raw.enum.every((value) => typeof value === "string")) {
51 return undefined;
52 }
53 const names = Array.isArray(raw.enumNames) ? raw.enumNames : [];
54 return raw.enum.map((value, index) => ({
55 value: value as string,
56 label: typeof names[index] === "string" && names[index] ? String(names[index]) : (value as string),
57 }));
58 }
59
60 function optionalNumber(value: unknown): number | undefined {
61 return typeof value === "number" && Number.isFinite(value) ? value : undefined;
62 }
63
64 // MCP form elicitation deliberately supports a flat, bounded subset of JSON
65 // Schema. This accepts both the 2025-06-18 enumNames form and the 2025-11-25
66 // oneOf/anyOf select forms while failing closed for nested or unknown fields.
67 export function parseStructuredSchema(schema: unknown): StructuredSchemaResult {
68 if (schema === undefined || schema === null) return { fields: [], unsupported: false };
69 if (typeof schema !== "object" || Array.isArray(schema)) return { fields: [], unsupported: true };
70 const root = schema as Record<string, unknown>;
71 if (!root.properties || typeof root.properties !== "object" || Array.isArray(root.properties)) {
72 return { fields: [], unsupported: true };
73 }
74 const required = new Set(
75 Array.isArray(root.required) ? root.required.filter((value): value is string => typeof value === "string") : [],
76 );
77 let unsupported = false;
78 const fields: StructuredField[] = [];
79
80 for (const [key, raw] of Object.entries(root.properties as Record<string, unknown>)) {
81 if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
82 unsupported = true;
83 continue;
84 }
85 const prop = raw as Record<string, unknown>;
86 const label = typeof prop.title === "string" && prop.title ? prop.title : key;
87 const base = {
88 key,
89 label,
90 required: required.has(key),
91 description: typeof prop.description === "string" ? prop.description : undefined,
92 };
93 const singleOptions = titledOptions(prop, "oneOf") ?? legacyOptions(prop);
94 const itemSchema = prop.items && typeof prop.items === "object" && !Array.isArray(prop.items)
95 ? (prop.items as Record<string, unknown>)
96 : undefined;
97 const multiOptions = itemSchema ? titledOptions(itemSchema, "anyOf") ?? legacyOptions(itemSchema) : undefined;
98
99 let kind: StructuredField["kind"];
100 if (prop.type === "array" && multiOptions) kind = "multi-enum";
101 else if (singleOptions) kind = "enum";
102 else if (prop.type === "string" || prop.type === "number" || prop.type === "integer" || prop.type === "boolean") {
103 kind = prop.type;
104 } else {
105 kind = "unsupported";
106 unsupported = true;
107 }
108
109 const allowedFormat = prop.format === "email" || prop.format === "uri" || prop.format === "date" || prop.format === "date-time"
110 ? prop.format
111 : undefined;
112 let defaultValue: StructuredFieldValue | undefined;
113 if (kind === "boolean" && typeof prop.default === "boolean") defaultValue = prop.default;
114 else if ((kind === "number" || kind === "integer") && typeof prop.default === "number") defaultValue = prop.default;
115 else if ((kind === "string" || kind === "enum") && typeof prop.default === "string") defaultValue = prop.default;
116 else if (kind === "multi-enum" && Array.isArray(prop.default) && prop.default.every((value) => typeof value === "string")) {
117 defaultValue = [...prop.default] as string[];
118 }
119
120 fields.push({
121 ...base,
122 kind,
123 defaultValue,
124 options: kind === "multi-enum" ? multiOptions : singleOptions,
125 format: kind === "string" ? allowedFormat : undefined,
126 minLength: optionalNumber(prop.minLength),
127 maxLength: optionalNumber(prop.maxLength),
128 minimum: optionalNumber(prop.minimum),
129 maximum: optionalNumber(prop.maximum),
130 minItems: optionalNumber(prop.minItems),
131 maxItems: optionalNumber(prop.maxItems),
132 pattern: typeof prop.pattern === "string" ? prop.pattern : undefined,
133 });
134 }
135 return { fields, unsupported };
136 }
137
138 export function normalizeStructuredSchema(schema: unknown): StructuredField[] {
139 return parseStructuredSchema(schema).fields;
140 }
141
142 export function initialStructuredValues(fields: StructuredField[]): Record<string, StructuredFieldValue> {
143 const values: Record<string, StructuredFieldValue> = {};
144 for (const field of fields) {
145 if (field.defaultValue !== undefined) {
146 values[field.key] = Array.isArray(field.defaultValue) ? [...field.defaultValue] : field.defaultValue;
147 } else if (field.kind === "boolean") {
148 // An unchecked checkbox is an explicit false, including for a required
149 // JSON property; requiring it to be checked would change its meaning.
150 values[field.key] = false;
151 }
152 }
153 return values;
154 }
155
156 function matchesFormat(format: StructuredField["format"], value: string): boolean {
157 if (!format) return true;
158 if (format === "email") return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
159 if (format === "date") return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(Date.parse(`${value}T00:00:00Z`));
160 if (format === "date-time") return !Number.isNaN(Date.parse(value));
161 try {
162 new URL(value);
163 return true;
164 } catch {
165 return false;
166 }
167 }
168
169 export function structuredFieldIssue(field: StructuredField, value: StructuredFieldValue | undefined): StructuredFieldIssue | undefined {
170 if (field.kind === "unsupported") return "unsupported";
171 if (value === undefined || value === "") return field.required ? "required" : undefined;
172 if (field.kind === "multi-enum") {
173 if (!Array.isArray(value)) return "invalid";
174 if (field.minItems !== undefined && value.length < field.minItems) return "tooFewItems";
175 if (field.maxItems !== undefined && value.length > field.maxItems) return "tooManyItems";
176 if (field.options && value.some((entry) => !field.options?.some((option) => option.value === entry))) return "invalid";
177 return undefined;
178 }
179 if (field.kind === "boolean") return typeof value === "boolean" ? undefined : "invalid";
180 if (field.kind === "number" || field.kind === "integer") {
181 const number = typeof value === "number" ? value : Number(value);
182 if (!Number.isFinite(number) || (field.kind === "integer" && !Number.isInteger(number))) return "invalid";
183 if (field.minimum !== undefined && number < field.minimum) return "belowMinimum";
184 if (field.maximum !== undefined && number > field.maximum) return "aboveMaximum";
185 return undefined;
186 }
187 const text = String(value);
188 if (field.minLength !== undefined && text.length < field.minLength) return "tooShort";
189 if (field.maxLength !== undefined && text.length > field.maxLength) return "tooLong";
190 if (field.kind === "enum" && field.options && !field.options.some((option) => option.value === text)) return "invalid";
191 if (!matchesFormat(field.format, text)) return "invalid";
192 if (field.pattern) {
193 try {
194 if (!new RegExp(field.pattern).test(text)) return "invalid";
195 } catch {
196 return "unsupported";
197 }
198 }
199 return undefined;
200 }
201
202 export function missingStructuredRequired(fields: StructuredField[], values: Record<string, StructuredFieldValue>): string[] {
203 return fields.filter((field) => structuredFieldIssue(field, values[field.key]) === "required").map((field) => field.label);
204 }
205
206 export function coerceStructuredValues(
207 fields: StructuredField[],
208 values: Record<string, StructuredFieldValue>,
209 ): { content: Record<string, unknown>; invalid: string[] } {
210 const content: Record<string, unknown> = {};
211 const invalid: string[] = [];
212 for (const field of fields) {
213 const value = values[field.key];
214 const issue = structuredFieldIssue(field, value);
215 if (issue && issue !== "required") {
216 invalid.push(field.label);
217 continue;
218 }
219 if (value === undefined || value === "") continue;
220 if (field.kind === "number" || field.kind === "integer") content[field.key] = Number(value);
221 else if (Array.isArray(value)) content[field.key] = [...value];
222 else content[field.key] = value;
223 }
224 return { content, invalid };
225 }
226
226 lines TYPESCRIPT