worsoft-frontend-codegen-local-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mcp_server.js ADDED
@@ -0,0 +1,1013 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ const SERVER_NAME = 'worsoft-codegen-local';
8
+ const SERVER_VERSION = '0.1.0';
9
+ const PROTOCOL_VERSION = '2024-11-05';
10
+ const TOOL_NAME = 'worsoft_codegen_local_generate_frontend';
11
+ const TEMPLATE_LIBRARY_ROOT = path.resolve(__dirname, '..', 'template');
12
+ const STYLE_CATALOG_PATH = path.join(__dirname, 'assets', 'style-catalog.json');
13
+ const DEFAULT_DESIGN_FILE = path.resolve(__dirname, '..', 'sql', 'SQL 设计说明.md');
14
+ const STYLE_CATALOG = loadStyleCatalog();
15
+
16
+ const TOOL_SCHEMA = {
17
+ type: 'object',
18
+ properties: {
19
+ designFile: { type: 'string', description: 'Absolute or relative Markdown design file path. Defaults to ../sql/SQL 设计说明.md when omitted.' },
20
+ tableName: { type: 'string', description: 'Target main table name from the design file.' },
21
+ style: { type: 'string', enum: Object.keys(STYLE_CATALOG), description: 'Style id from assets/style-catalog.json.' },
22
+ childTableName: { type: 'string', description: 'Optional child table name. In master_child_jump mode, this is the correction entry used to choose one child relation from the design file.' },
23
+ frontendPath: { type: 'string', description: 'Absolute frontend output root path.' },
24
+ moduleName: { type: 'string', description: 'Relative frontend module path, for example admin/test.' },
25
+ writeToDisk: { type: 'boolean', default: true, description: 'Whether to write generated files.' },
26
+ overwrite: { type: 'boolean', default: true, description: 'Whether to overwrite existing files. If false, existing files are skipped.' },
27
+ },
28
+ required: ['tableName', 'style', 'frontendPath'],
29
+ additionalProperties: false,
30
+ };
31
+
32
+ function loadStyleCatalog() {
33
+ return JSON.parse(fs.readFileSync(STYLE_CATALOG_PATH, 'utf8'));
34
+ }
35
+
36
+ function writeMessage(payload) {
37
+ process.stdout.write(JSON.stringify(payload) + '\n');
38
+ }
39
+
40
+ function successResponse(id, result) {
41
+ return { jsonrpc: '2.0', id, result };
42
+ }
43
+
44
+ function errorResponse(id, code, message) {
45
+ return { jsonrpc: '2.0', id, error: { code, message } };
46
+ }
47
+
48
+ function toolTextResult(text) {
49
+ return { content: [{ type: 'text', text }], isError: false };
50
+ }
51
+
52
+ function toCamelCase(value) {
53
+ return value.replace(/_([a-zA-Z0-9])/g, (_, letter) => letter.toUpperCase());
54
+ }
55
+
56
+ function toPascalCase(value) {
57
+ const camel = toCamelCase(value);
58
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
59
+ }
60
+
61
+ function normalizeModuleName(moduleName) {
62
+ if (!moduleName) {
63
+ return 'admin/test';
64
+ }
65
+ return moduleName.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
66
+ }
67
+
68
+ function isAuditField(fieldName) {
69
+ return [
70
+ 'id',
71
+ 'tenant_id',
72
+ 'version',
73
+ 'create_time',
74
+ 'update_time',
75
+ 'create_user_id',
76
+ 'update_user_id',
77
+ 'create_user',
78
+ 'update_user',
79
+ 'create_pos_id',
80
+ 'create_pos',
81
+ 'create_dpt_id',
82
+ 'create_dpt',
83
+ 'create_ogn_id',
84
+ 'create_ogn',
85
+ 'create_psm_full_id',
86
+ 'create_psm_full_name',
87
+ 'update_psm_full_id',
88
+ 'update_psm_full_name',
89
+ 'del_flag',
90
+ ].includes(fieldName);
91
+ }
92
+
93
+ function findDictType(comment) {
94
+ return extractDictType(comment);
95
+ const match = comment.match(/(?:字典|dict)[::\s]*([a-zA-Z0-9_]+)/i);
96
+ }
97
+
98
+ function mapFieldType(field) {
99
+ if (field.dictType) return 'select';
100
+ if (field.sqlType === 'DATETIME' || field.sqlType === 'TIMESTAMP') return 'datetime';
101
+ if (field.sqlType === 'DATE') return 'date';
102
+ if (['INT', 'BIGINT', 'DECIMAL', 'NUMERIC'].includes(field.sqlType)) return 'number';
103
+ if (field.sqlType === 'TEXT') return 'textarea';
104
+ if (field.sqlType === 'VARCHAR' && field.length && Number(field.length) > 64) return 'textarea';
105
+ return 'text';
106
+ }
107
+
108
+ function escapeForRegex(value) {
109
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
110
+ }
111
+
112
+ function stripBom(text) {
113
+ return text.replace(/^\uFEFF/, '');
114
+ }
115
+
116
+ function readUtf8File(filePath) {
117
+ return stripBom(fs.readFileSync(filePath, 'utf8'));
118
+ }
119
+
120
+ function normalizeDictType(value) {
121
+ const normalized = String(value || '').trim();
122
+ if (!normalized || normalized === '-' || normalized === '/') {
123
+ return null;
124
+ }
125
+ return normalized.replace(/^['"`]+|['"`]+$/g, '');
126
+ }
127
+
128
+ function extractDictType(text) {
129
+ const normalized = String(text || '').trim();
130
+ if (!normalized) {
131
+ return null;
132
+ }
133
+
134
+ const patterns = [
135
+ /(?:字典|dict)(?:类型|type)?\s*[::=]\s*([a-zA-Z0-9_-]+)/i,
136
+ /(?:字典|dict)(?:类型|type)?\s*[((]\s*([a-zA-Z0-9_-]+)\s*[))]/i,
137
+ /[((]\s*(?:字典|dict)(?:类型|type)?\s*[::=]?\s*([a-zA-Z0-9_-]+)\s*[))]/i,
138
+ ];
139
+
140
+ for (const pattern of patterns) {
141
+ const match = normalized.match(pattern);
142
+ if (match && match[1]) {
143
+ return normalizeDictType(match[1]);
144
+ }
145
+ }
146
+
147
+ return null;
148
+ }
149
+
150
+ function resolveDictType(comment, explicitDictType) {
151
+ return normalizeDictType(explicitDictType) || extractDictType(comment);
152
+ }
153
+
154
+ function detectDictType(comment) {
155
+ return extractDictType(comment);
156
+ return findDictType(comment) || ((comment.match(/(?:字典|dict)\s*[::\((]?\s*([a-zA-Z0-9_]+)/i) || [])[1] ?? null);
157
+ }
158
+
159
+ function splitLength(value) {
160
+ const normalized = String(value || '').trim();
161
+ if (!normalized || normalized === '-' || normalized === '/') {
162
+ return { length: '', scale: '' };
163
+ }
164
+ const parts = normalized.split(',').map((item) => item.trim()).filter(Boolean);
165
+ return { length: parts[0] || '', scale: parts[1] || '' };
166
+ }
167
+
168
+ function normalizeDefaultValue(value) {
169
+ const normalized = String(value || '').trim();
170
+ if (!normalized || normalized === '-' || normalized === '/') {
171
+ return '';
172
+ }
173
+ return normalized;
174
+ }
175
+
176
+ function parseRequiredFlag(value) {
177
+ const normalized = String(value || '').trim().toLowerCase();
178
+ return ['是', 'y', 'yes', '1', 'true', '必填'].includes(normalized);
179
+ }
180
+
181
+ function parseMarkdownRow(line) {
182
+ const trimmed = line.trim();
183
+ if (!trimmed.startsWith('|')) return [];
184
+ return trimmed
185
+ .slice(1, trimmed.endsWith('|') ? -1 : undefined)
186
+ .split('|')
187
+ .map((cell) => cell.trim());
188
+ }
189
+
190
+ function isMarkdownSeparatorRow(cells) {
191
+ return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.replace(/\s+/g, '')));
192
+ }
193
+
194
+ function findHeaderIndex(headers, patterns) {
195
+ return headers.findIndex((header) => patterns.some((pattern) => pattern.test(header)));
196
+ }
197
+
198
+ function findPrimaryKeyFromText(text, fields) {
199
+ const quotedMatch = text.match(/PRIMARY\s+KEY\s*\(\s*`?([a-zA-Z0-9_]+)`?\s*\)/i);
200
+ if (quotedMatch) {
201
+ return quotedMatch[1];
202
+ }
203
+
204
+ const commentPk = fields.find((field) => /主键/i.test(field.comment));
205
+ if (commentPk) {
206
+ return commentPk.fieldName;
207
+ }
208
+
209
+ const idField = fields.find((field) => field.fieldName === 'id');
210
+ if (idField) {
211
+ return idField.fieldName;
212
+ }
213
+
214
+ return fields[0] ? fields[0].fieldName : null;
215
+ }
216
+
217
+ function parseMarkdownTableSection(tableName, tableComment, sectionLines) {
218
+ const firstTableLineIndex = sectionLines.findIndex((line) => line.trim().startsWith('|'));
219
+ if (firstTableLineIndex < 0) {
220
+ throw new Error('Could not find markdown field table for ' + tableName);
221
+ }
222
+
223
+ const tableLines = [];
224
+ for (let index = firstTableLineIndex; index < sectionLines.length; index += 1) {
225
+ const line = sectionLines[index].trim();
226
+ if (!line.startsWith('|')) break;
227
+ tableLines.push(line);
228
+ }
229
+
230
+ const rows = tableLines.map(parseMarkdownRow).filter((cells) => cells.length > 0);
231
+ if (rows.length < 3) {
232
+ throw new Error('Markdown field table is incomplete for ' + tableName);
233
+ }
234
+
235
+ const headers = rows[0];
236
+ const dataRows = rows.slice(1).filter((cells) => !isMarkdownSeparatorRow(cells));
237
+ const fieldNameIndex = findHeaderIndex(headers, [/字段名/, /列名/i, /field/i]);
238
+ const sqlTypeIndex = findHeaderIndex(headers, [/类型/, /数据类型/, /type/i]);
239
+ const lengthIndex = findHeaderIndex(headers, [/长度/, /精度/, /length/i]);
240
+ const requiredIndex = findHeaderIndex(headers, [/必填/, /必输/, /required/i]);
241
+ const defaultIndex = findHeaderIndex(headers, [/默认/, /default/i]);
242
+ const commentIndex = findHeaderIndex(headers, [/说明/, /备注/, /comment/i]);
243
+
244
+ const dictTypeIndex = findHeaderIndex(headers, [/字典/, /dict/i, /dict_?type/i]);
245
+
246
+ if (fieldNameIndex < 0 || sqlTypeIndex < 0) {
247
+ throw new Error('Markdown field table headers are missing required columns for ' + tableName);
248
+ }
249
+
250
+ const fields = [];
251
+ for (const row of dataRows) {
252
+ const fieldName = String(row[fieldNameIndex] || '').trim();
253
+ if (!fieldName || fieldName === '-') continue;
254
+
255
+ const sqlType = String(row[sqlTypeIndex] || '').trim().toUpperCase();
256
+ if (!sqlType) continue;
257
+
258
+ const lengthRaw = lengthIndex >= 0 ? row[lengthIndex] : '';
259
+ const comment = String(commentIndex >= 0 ? row[commentIndex] : '').trim() || fieldName;
260
+ const explicitDictType = dictTypeIndex >= 0 ? row[dictTypeIndex] : '';
261
+ const { length, scale } = splitLength(lengthRaw);
262
+
263
+ fields.push({
264
+ fieldName,
265
+ attrName: toCamelCase(fieldName),
266
+ sqlType,
267
+ length,
268
+ scale,
269
+ comment,
270
+ dictType: resolveDictType(comment, explicitDictType),
271
+ notNull: requiredIndex >= 0 ? parseRequiredFlag(row[requiredIndex]) : false,
272
+ defaultValue: defaultIndex >= 0 ? normalizeDefaultValue(row[defaultIndex]) : '',
273
+ });
274
+ }
275
+
276
+ if (!fields.length) {
277
+ throw new Error('No fields were parsed from markdown table ' + tableName);
278
+ }
279
+
280
+ const sectionText = sectionLines.join('\n');
281
+ const pkName = findPrimaryKeyFromText(sectionText, fields);
282
+ const pkField = fields.find((field) => field.fieldName === pkName) || fields[0];
283
+ return { pkField, fields, tableComment };
284
+ }
285
+
286
+ function parseMarkdownDesignTables(markdownText) {
287
+ const lines = stripBom(markdownText).replace(/\r\n?/g, '\n').split('\n');
288
+ const tables = new Map();
289
+
290
+ for (let index = 0; index < lines.length; index += 1) {
291
+ const heading = lines[index].trim();
292
+ const headingMatch = heading.match(/^###\s+\S+\s+([a-zA-Z0-9_]+)\s*[\((]([^)\n)]+)[\))]/);
293
+ if (!headingMatch) continue;
294
+
295
+ const tableName = headingMatch[1];
296
+ const tableComment = headingMatch[2].trim();
297
+ const sectionLines = [];
298
+ let nextIndex = index + 1;
299
+ while (nextIndex < lines.length && !/^(##|###)\s+/.test(lines[nextIndex])) {
300
+ sectionLines.push(lines[nextIndex]);
301
+ nextIndex += 1;
302
+ }
303
+
304
+ tables.set(tableName, parseMarkdownTableSection(tableName, tableComment, sectionLines));
305
+ index = nextIndex - 1;
306
+ }
307
+
308
+ return tables;
309
+ }
310
+
311
+ function extractIdentifiersFromLine(line) {
312
+ return [...String(line || '').matchAll(/`?([a-zA-Z][a-zA-Z0-9_]*)`?/g)].map((match) => match[1]);
313
+ }
314
+
315
+ function stripMarkdownSyntax(text) {
316
+ return String(text || '')
317
+ .replace(/\*\*/g, '')
318
+ .replace(/`/g, '')
319
+ .replace(/\r\n?/g, '\n');
320
+ }
321
+
322
+ function parseMarkdownRelations(markdownText) {
323
+ const text = stripMarkdownSyntax(markdownText);
324
+ const blocks = [];
325
+ const headingRegex = /^(###|####)\s+(.+)$/gm;
326
+ let current = null;
327
+ let match = headingRegex.exec(text);
328
+
329
+ while (match) {
330
+ if (current) {
331
+ current.body = text.slice(current.start, match.index);
332
+ blocks.push(current);
333
+ }
334
+
335
+ current = {
336
+ title: match[2].trim(),
337
+ start: headingRegex.lastIndex,
338
+ body: '',
339
+ };
340
+ match = headingRegex.exec(text);
341
+ }
342
+
343
+ if (current) {
344
+ current.body = text.slice(current.start);
345
+ blocks.push(current);
346
+ }
347
+
348
+ const relations = [];
349
+ for (const block of blocks) {
350
+ const lines = block.body.split('\n').map((line) => line.trim()).filter(Boolean);
351
+ const mainLine = lines.find((line) => /(主表|父表)\s*[::]/.test(line));
352
+ const childLine = lines.find((line) => /(从表|子表)\s*[::]/.test(line));
353
+ const fieldLine = lines.find((line) => /关联字段\s*[::]/.test(line));
354
+ if (!mainLine || !childLine || !fieldLine) continue;
355
+
356
+ const mainIdentifiers = extractIdentifiersFromLine(mainLine);
357
+ const childIdentifiers = extractIdentifiersFromLine(childLine);
358
+ const fieldIdentifiers = extractIdentifiersFromLine(fieldLine);
359
+ if (!mainIdentifiers.length || !childIdentifiers.length || fieldIdentifiers.length < 2) continue;
360
+
361
+ const relationTypeLine = lines.find((line) => /关系类型\s*[::]/.test(line));
362
+ relations.push({
363
+ title: block.title,
364
+ mainTableName: mainIdentifiers[0],
365
+ childTableName: childIdentifiers[0],
366
+ childField: fieldIdentifiers[0],
367
+ mainField: fieldIdentifiers[1],
368
+ relationType: relationTypeLine ? relationTypeLine.split(/[::]/).slice(1).join(':').trim() : '',
369
+ });
370
+ }
371
+
372
+ return relations;
373
+ }
374
+
375
+ function parseMarkdownDesignFile(markdownText) {
376
+ return {
377
+ tables: parseMarkdownDesignTables(markdownText),
378
+ relations: parseMarkdownRelations(markdownText),
379
+ };
380
+ }
381
+
382
+ function parseTableFromMarkdownDesign(designDoc, tableName) {
383
+ const parsed = designDoc.tables.get(tableName);
384
+ if (!parsed) {
385
+ throw new Error('Could not find table definition for ' + tableName + ' in Markdown design file');
386
+ }
387
+ return parsed;
388
+ }
389
+
390
+ function buildQuestionMarkSegmentRegex(segment) {
391
+ return new RegExp('^' + escapeForRegex(segment).replace(/\\\?/g, '.') + '$', 'i');
392
+ }
393
+
394
+ function tryResolveGarbledPath(resolvedPath) {
395
+ if (!resolvedPath.includes('?')) {
396
+ return null;
397
+ }
398
+
399
+ const parsed = path.parse(resolvedPath);
400
+ const segments = resolvedPath.slice(parsed.root.length).split(path.sep).filter(Boolean);
401
+ let currentPath = parsed.root;
402
+
403
+ for (const segment of segments) {
404
+ const exactPath = path.join(currentPath, segment);
405
+ if (fs.existsSync(exactPath)) {
406
+ currentPath = exactPath;
407
+ continue;
408
+ }
409
+
410
+ if (!segment.includes('?') || !fs.existsSync(currentPath) || !fs.statSync(currentPath).isDirectory()) {
411
+ return null;
412
+ }
413
+
414
+ const matcher = buildQuestionMarkSegmentRegex(segment);
415
+ const matches = fs.readdirSync(currentPath).filter((name) => matcher.test(name));
416
+ if (matches.length !== 1) {
417
+ throw new Error(
418
+ 'Source file path could not be resolved uniquely from garbled input: ' +
419
+ resolvedPath +
420
+ '. Matched entries: ' +
421
+ (matches.length ? matches.join(', ') : 'none')
422
+ );
423
+ }
424
+
425
+ currentPath = path.join(currentPath, matches[0]);
426
+ }
427
+
428
+ return fs.existsSync(currentPath) ? currentPath : null;
429
+ }
430
+
431
+ function resolveSourcePath(inputPath) {
432
+ const resolvedPath = path.resolve(inputPath);
433
+ if (fs.existsSync(resolvedPath)) {
434
+ return resolvedPath;
435
+ }
436
+
437
+ const recoveredPath = tryResolveGarbledPath(resolvedPath);
438
+ if (recoveredPath) {
439
+ return recoveredPath;
440
+ }
441
+
442
+ throw new Error('Source file does not exist: ' + resolvedPath);
443
+ }
444
+
445
+ function formatRelationCandidate(relation) {
446
+ return {
447
+ childTableName: relation.childTableName,
448
+ mainField: relation.mainField,
449
+ childField: relation.childField,
450
+ relationType: relation.relationType || '',
451
+ };
452
+ }
453
+
454
+ function getRelationCandidates(designDoc, tableName) {
455
+ return designDoc.relations
456
+ .filter((relation) => relation.mainTableName === tableName)
457
+ .map(formatRelationCandidate);
458
+ }
459
+
460
+ function buildRetryArguments(safeArgs, sourceFile, childTableName) {
461
+ const retryArguments = {
462
+ tableName: safeArgs.tableName,
463
+ style: safeArgs.style,
464
+ frontendPath: safeArgs.frontendPath,
465
+ moduleName: safeArgs.moduleName,
466
+ writeToDisk: safeArgs.writeToDisk,
467
+ overwrite: safeArgs.overwrite,
468
+ };
469
+
470
+ if (sourceFile) {
471
+ retryArguments.designFile = sourceFile;
472
+ }
473
+
474
+ if (childTableName) {
475
+ retryArguments.childTableName = childTableName;
476
+ }
477
+
478
+ return retryArguments;
479
+ }
480
+
481
+ function buildRelationCorrectionEntry(safeArgs, sourceFile, candidates, selectedChildTableName) {
482
+ return {
483
+ field: 'childTableName',
484
+ title: '主子表关联修正入口',
485
+ tableName: safeArgs.tableName,
486
+ style: safeArgs.style,
487
+ designFile: sourceFile,
488
+ description: candidates.length
489
+ ? 'If the current child relation is not the one you want, pass childTableName to choose a candidate from the design file.'
490
+ : 'No child relation was found. Check the 主从表关联说明 section in the design file.',
491
+ currentValue: selectedChildTableName || '',
492
+ options: candidates.map((item) => item.childTableName),
493
+ candidates,
494
+ example: candidates.length ? buildRetryArguments(safeArgs, sourceFile, candidates[0].childTableName) : buildRetryArguments(safeArgs, sourceFile, selectedChildTableName),
495
+ retryArguments: candidates.map((item) => buildRetryArguments(safeArgs, sourceFile, item.childTableName)),
496
+ };
497
+ }
498
+
499
+ function createRelationResolutionError(message, safeArgs, sourceFile, candidates, selectedChildTableName, status) {
500
+ const error = new Error(message);
501
+ error.details = {
502
+ type: 'relation_resolution',
503
+ status,
504
+ tableName: safeArgs.tableName,
505
+ designFile: sourceFile,
506
+ message,
507
+ candidates,
508
+ correctionEntry: buildRelationCorrectionEntry(safeArgs, sourceFile, candidates, selectedChildTableName),
509
+ };
510
+ return error;
511
+ }
512
+
513
+ function loadSourceDocument(safeArgs) {
514
+ const inputPath = safeArgs.designFile || DEFAULT_DESIGN_FILE;
515
+ const sourcePath = resolveSourcePath(inputPath);
516
+ const text = readUtf8File(sourcePath);
517
+ return {
518
+ path: sourcePath,
519
+ text,
520
+ designDoc: parseMarkdownDesignFile(text),
521
+ };
522
+ }
523
+
524
+ function resolveMarkdownChildRelation(sourceDocument, safeArgs) {
525
+ let candidates = sourceDocument.designDoc.relations.filter((relation) => relation.mainTableName === safeArgs.tableName);
526
+ if (safeArgs.childTableName) {
527
+ candidates = candidates.filter((relation) => relation.childTableName === safeArgs.childTableName);
528
+ }
529
+
530
+ if (!candidates.length) {
531
+ const relationCandidates = getRelationCandidates(sourceDocument.designDoc, safeArgs.tableName);
532
+ const message = relationCandidates.length
533
+ ? 'Could not resolve child relation for main table ' + safeArgs.tableName + ' from Markdown design file.'
534
+ : 'No child relation was found for main table ' + safeArgs.tableName + ' in Markdown design file.';
535
+ throw createRelationResolutionError(message, safeArgs, sourceDocument.path, relationCandidates, safeArgs.childTableName, 'unresolved');
536
+ }
537
+
538
+ if (candidates.length > 1) {
539
+ const relationCandidates = candidates.map(formatRelationCandidate);
540
+ const message = 'Multiple child relations matched main table ' + safeArgs.tableName + '. Provide childTableName to disambiguate.';
541
+ throw createRelationResolutionError(message, safeArgs, sourceDocument.path, relationCandidates, safeArgs.childTableName, 'ambiguous');
542
+ }
543
+
544
+ return candidates[0];
545
+ }
546
+
547
+ function getStylePreset(styleId) {
548
+ const preset = STYLE_CATALOG[styleId];
549
+ if (!preset) throw new Error('Unsupported style: ' + styleId);
550
+ return preset;
551
+ }
552
+
553
+ function resolveSharedTemplates(stylePreset) {
554
+ const selected = {};
555
+ for (const [kind, templateName] of Object.entries(stylePreset.templateFiles || {})) {
556
+ const templatePath = path.join(TEMPLATE_LIBRARY_ROOT, templateName);
557
+ selected[kind] = { name: templateName, path: templatePath, exists: fs.existsSync(templatePath) };
558
+ }
559
+ return selected;
560
+ }
561
+
562
+ function hasRuntimeSupport(stylePreset) {
563
+ return Boolean(stylePreset.runtime && stylePreset.runtime.supported && stylePreset.runtime.templateDir);
564
+ }
565
+
566
+ function normalizeFields(parsed) {
567
+ return parsed.fields.map((field) => ({ ...field, formType: mapFieldType(field), isAudit: isAuditField(field.fieldName) }));
568
+ }
569
+
570
+ function ensureFieldExists(fields, fieldName, tableName, role) {
571
+ const field = fields.find((item) => item.fieldName === fieldName);
572
+ if (!field) throw new Error(role + ' field "' + fieldName + '" was not found on table ' + tableName);
573
+ return field;
574
+ }
575
+
576
+ function buildChildModel(sourceDocument, safeArgs, mainParsed) {
577
+ if (safeArgs.style !== 'master_child_jump') return null;
578
+
579
+ const relation = resolveMarkdownChildRelation(sourceDocument, safeArgs);
580
+ const childParsed = parseTableFromMarkdownDesign(sourceDocument.designDoc, relation.childTableName);
581
+ const childFields = normalizeFields(childParsed);
582
+ const mainRelationField = ensureFieldExists(mainParsed.fields, relation.mainField, safeArgs.tableName, 'Main relation');
583
+ const childRelationField = ensureFieldExists(childParsed.fields, relation.childField, relation.childTableName, 'Child relation');
584
+ const childVisibleFields = childFields.filter(
585
+ (field) => field.fieldName !== childParsed.pkField.fieldName && !field.isAudit && field.fieldName !== relation.childField
586
+ );
587
+
588
+ return {
589
+ tableName: relation.childTableName,
590
+ tableComment: childParsed.tableComment,
591
+ className: toPascalCase(relation.childTableName),
592
+ functionName: toCamelCase(relation.childTableName),
593
+ listName: toCamelCase(relation.childTableName) + 'List',
594
+ pk: childParsed.pkField,
595
+ fields: childFields,
596
+ visibleFields: childVisibleFields,
597
+ mainField: mainRelationField,
598
+ childField: childRelationField,
599
+ relationType: relation.relationType,
600
+ };
601
+ }
602
+
603
+ function buildModel(safeArgs) {
604
+ const sourceDocument = loadSourceDocument(safeArgs);
605
+ const mainParsed = parseTableFromMarkdownDesign(sourceDocument.designDoc, safeArgs.tableName);
606
+ const fields = normalizeFields(mainParsed);
607
+ const visibleFields = fields.filter((field) => field.fieldName !== mainParsed.pkField.fieldName && !field.isAudit);
608
+ const gridFields = visibleFields.slice(0, 8);
609
+ const dictTypes = [...new Set(visibleFields.map((field) => field.dictType).filter(Boolean))];
610
+ const child = buildChildModel(sourceDocument, safeArgs, mainParsed);
611
+
612
+ return {
613
+ sourceFile: sourceDocument.path,
614
+ designFile: sourceDocument.path,
615
+ tableName: safeArgs.tableName,
616
+ tableComment: mainParsed.tableComment,
617
+ className: toPascalCase(safeArgs.tableName),
618
+ functionName: toCamelCase(safeArgs.tableName),
619
+ moduleName: normalizeModuleName(safeArgs.moduleName),
620
+ pk: mainParsed.pkField,
621
+ fields,
622
+ visibleFields,
623
+ gridFields,
624
+ dictTypes,
625
+ frontendPath: path.resolve(safeArgs.frontendPath),
626
+ style: safeArgs.style,
627
+ child,
628
+ };
629
+ }
630
+
631
+ function renderTemplate(templateText, replacements) {
632
+ let output = templateText;
633
+ for (const [key, value] of Object.entries(replacements)) {
634
+ output = output.split('{{' + key + '}}').join(String(value));
635
+ }
636
+ return output;
637
+ }
638
+
639
+ function renderFormField(field) {
640
+ const label = field.comment.replace(/'/g, "\\'");
641
+ const prop = field.attrName;
642
+
643
+ if (field.formType === 'select') {
644
+ return [
645
+ ' <el-col :span="12" class="mb20">',
646
+ ` <el-form-item label="${label}" prop="${prop}">`,
647
+ ` <el-select v-model="form.${prop}" placeholder="Please select ${label}" style="width: 100%">`,
648
+ ` <el-option v-for="item in ${field.dictType}" :key="item.value" :label="item.label" :value="Number(item.value)" />`,
649
+ ' </el-select>',
650
+ ' </el-form-item>',
651
+ ' </el-col>',
652
+ ].join('\n');
653
+ }
654
+
655
+ if (field.formType === 'number') {
656
+ const max = field.comment.includes('%') || field.comment.includes('比例') ? ' :max="100"' : '';
657
+ const precision = field.sqlType === 'DECIMAL' && field.scale ? ` :precision="${field.scale}" :step="0.01"` : '';
658
+ return [
659
+ ' <el-col :span="12" class="mb20">',
660
+ ` <el-form-item label="${label}" prop="${prop}">`,
661
+ ` <el-input-number v-model="form.${prop}" :min="0"${max}${precision} placeholder="Please input ${label}" style="width: 100%" />`,
662
+ ' </el-form-item>',
663
+ ' </el-col>',
664
+ ].join('\n');
665
+ }
666
+
667
+ if (field.formType === 'datetime' || field.formType === 'date') {
668
+ const pickerType = field.formType === 'datetime' ? 'datetime' : 'date';
669
+ const formatName = field.formType === 'datetime' ? 'dateTimeStr' : 'dateStr';
670
+ return [
671
+ ' <el-col :span="12" class="mb20">',
672
+ ` <el-form-item label="${label}" prop="${prop}">`,
673
+ ` <el-date-picker type="${pickerType}" placeholder="Please select ${label}" v-model="form.${prop}" :value-format="${formatName}" style="width: 100%"></el-date-picker>`,
674
+ ' </el-form-item>',
675
+ ' </el-col>',
676
+ ].join('\n');
677
+ }
678
+
679
+ if (field.formType === 'textarea') {
680
+ return [
681
+ ' <el-col :span="24" class="mb20">',
682
+ ` <el-form-item label="${label}" prop="${prop}">`,
683
+ ` <el-input type="textarea" v-model="form.${prop}" placeholder="Please input ${label}" />`,
684
+ ' </el-form-item>',
685
+ ' </el-col>',
686
+ ].join('\n');
687
+ }
688
+
689
+ return [
690
+ ' <el-col :span="12" class="mb20">',
691
+ ` <el-form-item label="${label}" prop="${prop}">`,
692
+ ` <el-input v-model="form.${prop}" placeholder="Please input ${label}" />`,
693
+ ' </el-form-item>',
694
+ ' </el-col>',
695
+ ].join('\n');
696
+ }
697
+
698
+ function renderTableColumn(field) {
699
+ const label = field.comment.replace(/'/g, "\\'");
700
+ if (field.dictType) {
701
+ return [
702
+ ` <el-table-column prop="${field.attrName}" label="${label}" show-overflow-tooltip>`,
703
+ ' <template #default="scope">',
704
+ ` <dict-tag :options="${field.dictType}" :value="scope.row.${field.attrName}" />`,
705
+ ' </template>',
706
+ ' </el-table-column>',
707
+ ].join('\n');
708
+ }
709
+ return ` <el-table-column prop="${field.attrName}" label="${label}" show-overflow-tooltip />`;
710
+ }
711
+
712
+ function renderChildTableColumn(field, childListName) {
713
+ const label = field.comment.replace(/'/g, "\\'");
714
+ return [
715
+ ` <el-table-column label="${label}" prop="${field.attrName}">`,
716
+ ' <template #default="{ row, $index }">',
717
+ ` <el-form-item :prop="\`${childListName}.\${$index}.${field.attrName}\`" :rules="[{ required: true, trigger: 'blur' }]">`,
718
+ ` <el-input v-model="row.${field.attrName}" />`,
719
+ ' </el-form-item>',
720
+ ' </template>',
721
+ ' </el-table-column>',
722
+ ].join('\n');
723
+ }
724
+
725
+ function renderOptionField(field) {
726
+ const label = field.comment.replace(/'/g, "\\'");
727
+ return ` ${field.attrName}: { show:true, alwaysHide:false, smart:false, label: '${label}', width: '120' },`;
728
+ }
729
+
730
+ function renderFilterType(field) {
731
+ if (!field.dictType) return null;
732
+ return ` ${field.attrName}: 30,`;
733
+ }
734
+
735
+ function renderDefaultLine(field) {
736
+ if (field.formType === 'number') return ` ${field.attrName}: 0,`;
737
+ return ` ${field.attrName}: '',`;
738
+ }
739
+
740
+ function renderFormDefaults(model) {
741
+ const lines = [` ${model.pk.attrName}: '',`];
742
+ for (const field of model.visibleFields) lines.push(renderDefaultLine(field));
743
+ return lines.join('\n');
744
+ }
745
+
746
+ function renderChildTempDefaults(childModel) {
747
+ if (!childModel) return '';
748
+ return childModel.fields.map(renderDefaultLine).join('\n');
749
+ }
750
+
751
+ function renderDictImportBlock(dictTypes) {
752
+ if (!dictTypes.length) return '';
753
+ return [
754
+ "import { useDict } from '/@/hooks/dict';",
755
+ `const { ${dictTypes.join(', ')} } = useDict(${dictTypes.map((item) => `'${item}'`).join(', ')});`,
756
+ ].join('\n');
757
+ }
758
+
759
+ function renderValidationRule(field) {
760
+ if (!field.notNull) return null;
761
+ const label = field.comment.replace(/'/g, "\\'");
762
+ return ` ${field.attrName}: [{ required: true, message: '${label}不能为空', trigger: 'blur' }],`;
763
+ }
764
+
765
+ function renderFormRules(visibleFields) {
766
+ const lines = visibleFields.map(renderValidationRule).filter(Boolean);
767
+ return lines.join('\n');
768
+ }
769
+
770
+ function buildReplacements(model) {
771
+ const menuBaseId = Date.now();
772
+ const apiModulePath = `${model.moduleName}/${model.functionName}`;
773
+ const routePath = `${model.moduleName}/${model.functionName}`;
774
+ const permissionPrefix = `${model.moduleName}/${model.functionName}`.replace(/\//g, '_');
775
+ const formDictTypes = [...new Set(model.visibleFields.map((field) => field.dictType).filter(Boolean))];
776
+ const tableDictTypes = [...new Set(model.gridFields.map((field) => field.dictType).filter(Boolean))];
777
+
778
+ return {
779
+ TABLE_NAME: model.tableName,
780
+ TABLE_COMMENT: model.tableComment,
781
+ CLASS_NAME: model.className,
782
+ FUNCTION_NAME: model.functionName,
783
+ PK_ATTR: model.pk.attrName,
784
+ API_MODULE_PATH: apiModulePath,
785
+ API_PATH: apiModulePath,
786
+ VIEW_MODULE_PATH: routePath,
787
+ MENU_ROUTE_PATH: routePath,
788
+ PERMISSION_PREFIX: permissionPrefix,
789
+ MENU_BASE_ID: menuBaseId,
790
+ MENU_BASE_ID_PLUS_1: menuBaseId + 1,
791
+ MENU_BASE_ID_PLUS_2: menuBaseId + 2,
792
+ MENU_BASE_ID_PLUS_3: menuBaseId + 3,
793
+ MENU_BASE_ID_PLUS_4: menuBaseId + 4,
794
+ MENU_BASE_ID_PLUS_5: menuBaseId + 5,
795
+ GENERATED_AT: new Date().toISOString(),
796
+ FORM_FIELDS: model.visibleFields.map(renderFormField).join('\n'),
797
+ TABLE_COLUMNS: model.gridFields.map(renderTableColumn).join('\n'),
798
+ FORM_DEFAULTS: renderFormDefaults(model),
799
+ DICT_IMPORT_BLOCK: renderDictImportBlock(model.dictTypes),
800
+ FORM_DICT_IMPORT_BLOCK: renderDictImportBlock(formDictTypes),
801
+ TABLE_DICT_IMPORT_BLOCK: renderDictImportBlock(tableDictTypes),
802
+ OPTIONS_FIELDS: model.visibleFields.map(renderOptionField).join('\n'),
803
+ FILTER_TYPES: model.visibleFields.map(renderFilterType).filter(Boolean).join('\n'),
804
+ FORM_RULES: renderFormRules(model.visibleFields),
805
+ CHILD_CLASS_NAME: model.child ? model.child.className : '',
806
+ CHILD_LIST_NAME: model.child ? model.child.listName : '',
807
+ CHILD_PK_ATTR: model.child ? model.child.pk.attrName : '',
808
+ CHILD_RELATION_ATTR: model.child ? model.child.childField.attrName : '',
809
+ CHILD_TABLE_COLUMNS: model.child ? model.child.visibleFields.map((field) => renderChildTableColumn(field, model.child.listName)).join('\n') : '',
810
+ CHILD_TEMP_DEFAULTS: model.child ? renderChildTempDefaults(model.child) : '',
811
+ };
812
+ }
813
+
814
+ function renderFiles(model, stylePreset) {
815
+ if (!hasRuntimeSupport(stylePreset)) throw new Error('Runtime templates are not implemented for style: ' + model.style);
816
+
817
+ const runtime = stylePreset.runtime;
818
+ const templateDir = path.resolve(__dirname, runtime.templateDir);
819
+ const replacements = buildReplacements(model);
820
+ const formTemplate = fs.readFileSync(path.join(templateDir, runtime.files.form), 'utf8');
821
+ const listTemplate = fs.readFileSync(path.join(templateDir, runtime.files.list), 'utf8');
822
+ const optionsTemplate = fs.readFileSync(path.join(templateDir, runtime.files.options), 'utf8');
823
+ const apiTemplate = fs.readFileSync(path.join(templateDir, runtime.files.api), 'utf8');
824
+ const menuSqlTemplate = runtime.files.menuSql ? fs.readFileSync(path.join(templateDir, runtime.files.menuSql), 'utf8') : null;
825
+
826
+ const viewRoot = path.join(model.frontendPath, 'src', 'views', ...model.moduleName.split('/'), model.functionName);
827
+ const apiRoot = path.join(model.frontendPath, 'src', 'api', ...model.moduleName.split('/'));
828
+ const menuRoot = path.join(model.frontendPath, 'menu');
829
+
830
+ const files = [
831
+ { type: 'form', path: path.join(viewRoot, 'form.vue'), content: renderTemplate(formTemplate, replacements) },
832
+ { type: 'list', path: path.join(viewRoot, 'index.vue'), content: renderTemplate(listTemplate, replacements) },
833
+ { type: 'options', path: path.join(viewRoot, 'options.ts'), content: renderTemplate(optionsTemplate, replacements) },
834
+ { type: 'api', path: path.join(apiRoot, `${model.functionName}.ts`), content: renderTemplate(apiTemplate, replacements) },
835
+ ];
836
+
837
+ if (menuSqlTemplate) {
838
+ files.push({ type: 'menuSql', path: path.join(menuRoot, `${model.functionName}_menu.sql`), content: renderTemplate(menuSqlTemplate, replacements) });
839
+ }
840
+ return files;
841
+ }
842
+
843
+ function ensureArguments(input) {
844
+ if (!input || typeof input !== 'object') throw new Error('Arguments must be an object');
845
+ for (const key of TOOL_SCHEMA.required) {
846
+ if (!input[key]) throw new Error(key + ' is required');
847
+ }
848
+
849
+ const style = String(input.style);
850
+ getStylePreset(style);
851
+
852
+ return {
853
+ designFile: input.designFile ? String(input.designFile) : null,
854
+ tableName: String(input.tableName),
855
+ style,
856
+ childTableName: input.childTableName ? String(input.childTableName) : null,
857
+ frontendPath: String(input.frontendPath),
858
+ moduleName: input.moduleName ? String(input.moduleName) : 'admin/test',
859
+ writeToDisk: input.writeToDisk === undefined ? true : Boolean(input.writeToDisk),
860
+ overwrite: input.overwrite === undefined ? true : Boolean(input.overwrite),
861
+ };
862
+ }
863
+
864
+ function maybeWriteFiles(files, writeToDisk, overwrite) {
865
+ if (!writeToDisk) return;
866
+ for (const file of files) {
867
+ if (!overwrite && fs.existsSync(file.path)) {
868
+ file.status = 'skipped';
869
+ continue;
870
+ }
871
+ fs.mkdirSync(path.dirname(file.path), { recursive: true });
872
+ fs.writeFileSync(file.path, file.content, 'utf8');
873
+ file.status = 'success';
874
+ }
875
+ }
876
+
877
+ function buildManifest(model, safeArgs, stylePreset, sharedTemplates, files, note) {
878
+ const relationCandidates = getRelationCandidates(loadSourceDocument(safeArgs).designDoc, safeArgs.tableName);
879
+ return {
880
+ mode: 'local-template',
881
+ style: safeArgs.style,
882
+ styleLabel: stylePreset.label,
883
+ runtimeSupported: hasRuntimeSupport(stylePreset),
884
+ sourceFile: model.sourceFile,
885
+ designFile: model.designFile,
886
+ tableName: model.tableName,
887
+ tableComment: model.tableComment,
888
+ moduleName: model.moduleName,
889
+ writeToDisk: safeArgs.writeToDisk,
890
+ selectedTemplates: sharedTemplates,
891
+ files: files.map((file) => ({ type: file.type, path: file.path, bytes: Buffer.byteLength(file.content, 'utf8'), status: file.status || (safeArgs.writeToDisk ? 'success' : 'rendered') })),
892
+ relation: model.child
893
+ ? {
894
+ childTableName: model.child.tableName,
895
+ childTableComment: model.child.tableComment,
896
+ mainField: model.child.mainField.fieldName,
897
+ childField: model.child.childField.fieldName,
898
+ childListName: model.child.listName,
899
+ relationType: model.child.relationType || '',
900
+ }
901
+ : null,
902
+ relationResolution: model.child
903
+ ? {
904
+ status: 'resolved',
905
+ tableName: safeArgs.tableName,
906
+ designFile: model.designFile,
907
+ message: 'The relation was resolved from the design file. If you need another child table, use correctionEntry.retryArguments to resubmit.',
908
+ selected: formatRelationCandidate({
909
+ childTableName: model.child.tableName,
910
+ mainField: model.child.mainField.fieldName,
911
+ childField: model.child.childField.fieldName,
912
+ relationType: model.child.relationType || '',
913
+ }),
914
+ correctionEntry: buildRelationCorrectionEntry(safeArgs, model.designFile, relationCandidates, model.child.tableName),
915
+ }
916
+ : null,
917
+ summary: {
918
+ totalFields: model.fields.length,
919
+ visibleFields: model.visibleFields.length,
920
+ dictFields: model.visibleFields.filter((field) => field.dictType).map((field) => field.attrName),
921
+ skippedAuditFields: model.fields.filter((field) => field.isAudit).map((field) => field.fieldName),
922
+ childVisibleFields: model.child ? model.child.visibleFields.length : 0,
923
+ },
924
+ note,
925
+ };
926
+ }
927
+
928
+ async function handleToolCall(argumentsObject) {
929
+ const safeArgs = ensureArguments(argumentsObject);
930
+ const stylePreset = getStylePreset(safeArgs.style);
931
+ const model = buildModel(safeArgs);
932
+ const sharedTemplates = resolveSharedTemplates(stylePreset);
933
+
934
+ if (!hasRuntimeSupport(stylePreset)) {
935
+ const manifest = buildManifest(model, safeArgs, stylePreset, sharedTemplates, [], 'Style mapping is declared, but runtime template rendering is not implemented yet for this style.');
936
+ return toolTextResult(JSON.stringify(manifest, null, 2));
937
+ }
938
+
939
+ const files = renderFiles(model, stylePreset);
940
+ maybeWriteFiles(files, safeArgs.writeToDisk, safeArgs.overwrite);
941
+ const manifest = buildManifest(model, safeArgs, stylePreset, sharedTemplates, files, 'Runtime template rendering completed.');
942
+ return toolTextResult(JSON.stringify(manifest, null, 2));
943
+ }
944
+
945
+ async function onMessage(message) {
946
+ const { id, method, params } = message;
947
+ const isNotification = id === undefined || id === null;
948
+
949
+ if (method === 'initialize') {
950
+ writeMessage(successResponse(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } }));
951
+ return;
952
+ }
953
+
954
+ if (method === 'notifications/initialized') return;
955
+
956
+ // Silently ignore all other notifications (no id means no response expected).
957
+ if (isNotification) return;
958
+
959
+ if (method === 'ping') {
960
+ writeMessage(successResponse(id, {}));
961
+ return;
962
+ }
963
+
964
+ if (method === 'tools/list') {
965
+ writeMessage(successResponse(id, { tools: [{ name: TOOL_NAME, description: 'Generate Worsoft frontend files and menu SQL from a local Markdown design file with style-based local templates. In master_child_jump mode, relations are inferred from the Markdown design file.', inputSchema: TOOL_SCHEMA }] }));
966
+ return;
967
+ }
968
+
969
+ if (method === 'tools/call') {
970
+ if (!params || params.name !== TOOL_NAME) {
971
+ writeMessage(errorResponse(id, -32602, 'Unknown tool'));
972
+ return;
973
+ }
974
+ try {
975
+ const result = await handleToolCall(params.arguments || {});
976
+ writeMessage(successResponse(id, result));
977
+ } catch (error) {
978
+ const errorText = error && error.details ? JSON.stringify(error.details, null, 2) : String(error.message || error);
979
+ writeMessage(successResponse(id, { content: [{ type: 'text', text: errorText }], isError: true }));
980
+ }
981
+ return;
982
+ }
983
+
984
+ writeMessage(errorResponse(id, -32601, 'Method not found'));
985
+ }
986
+
987
+ function start() {
988
+ process.stdin.setEncoding('utf8');
989
+ let buffer = '';
990
+ process.stdin.on('data', (chunk) => {
991
+ buffer += chunk;
992
+ let newlineIndex = buffer.indexOf('\n');
993
+ while (newlineIndex >= 0) {
994
+ const raw = buffer.slice(0, newlineIndex).trim();
995
+ buffer = buffer.slice(newlineIndex + 1);
996
+ if (raw) {
997
+ try {
998
+ const message = JSON.parse(raw);
999
+ Promise.resolve(onMessage(message)).catch((error) => {
1000
+ if (message && Object.prototype.hasOwnProperty.call(message, 'id')) {
1001
+ writeMessage(errorResponse(message.id, -32603, error.message));
1002
+ }
1003
+ });
1004
+ } catch (error) {
1005
+ writeMessage(errorResponse(null, -32700, 'Parse error: ' + error.message));
1006
+ }
1007
+ }
1008
+ newlineIndex = buffer.indexOf('\n');
1009
+ }
1010
+ });
1011
+ }
1012
+
1013
+ start();