devconfig-gen 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,2143 @@
1
+ """Embedded local WebUI for DevConfig-Gen.
2
+
3
+ Provides a zero-build, Mac-first, self-contained web wizard with live preview,
4
+ split-view editing, template pre-filling, and config export. Zero npm/node_modules
5
+ required — runs out-of-the-box using the Python standard library http.server.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import urllib.parse
12
+ import webbrowser
13
+ from http import HTTPStatus
14
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
15
+ from pathlib import Path
16
+ from typing import Any, Optional
17
+
18
+ from . import formats
19
+ from .engine import describe_provider, diagnose_request, generate
20
+ from .models import GenerationRequest
21
+ from .registry import ProviderRegistry, default_registry
22
+
23
+ _HTML_PAGE = """<!DOCTYPE html>
24
+ <html lang="zh">
25
+ <head>
26
+ <meta charset="UTF-8">
27
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
28
+ <title>DevConfig-Gen — 配置工作台</title>
29
+ <style>
30
+ :root {
31
+ --font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
32
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
33
+
34
+ --bg: #f5f5f7;
35
+ --card-bg: #ffffff;
36
+ --card-border: #e5e5ea;
37
+ --text: #1d1d1f;
38
+ --text-muted: #86868b;
39
+ --primary: #0071e3;
40
+ --primary-hover: #0077ed;
41
+ --accent: #34c759;
42
+ --danger: #ff3b30;
43
+ --code-bg: #1e1e24;
44
+ --code-text: #e1e1e6;
45
+ --input-bg: #ffffff;
46
+ --input-border: #d2d2d7;
47
+ --input-focus: #0071e3;
48
+ --nav-bg: rgba(255, 255, 255, 0.85);
49
+ }
50
+
51
+ @media (prefers-color-scheme: dark) {
52
+ :root {
53
+ --bg: #000000;
54
+ --card-bg: #1c1c1e;
55
+ --card-border: #2c2c2e;
56
+ --text: #f5f5f7;
57
+ --text-muted: #86868b;
58
+ --primary: #2997ff;
59
+ --primary-hover: #40a9ff;
60
+ --accent: #30d158;
61
+ --danger: #ff453a;
62
+ --code-bg: #121214;
63
+ --code-text: #e1e1e6;
64
+ --input-bg: #2c2c2e;
65
+ --input-border: #3a3a3c;
66
+ --input-focus: #2997ff;
67
+ --nav-bg: rgba(28, 28, 30, 0.85);
68
+ }
69
+ }
70
+
71
+ * { box-sizing: border-box; margin: 0; padding: 0; }
72
+ body {
73
+ font-family: var(--font-sans);
74
+ background-color: var(--bg);
75
+ color: var(--text);
76
+ line-height: 1.5;
77
+ -webkit-font-smoothing: antialiased;
78
+ display: flex;
79
+ flex-direction: column;
80
+ min-height: 100vh;
81
+ }
82
+
83
+ header {
84
+ position: sticky;
85
+ top: 0;
86
+ z-index: 100;
87
+ background: var(--nav-bg);
88
+ backdrop-filter: saturate(180%) blur(20px);
89
+ -webkit-backdrop-filter: saturate(180%) blur(20px);
90
+ border-bottom: 1px solid var(--card-border);
91
+ padding: 0.75rem 1.5rem;
92
+ display: flex;
93
+ justify-content: space-between;
94
+ align-items: center;
95
+ }
96
+
97
+ .brand {
98
+ display: flex;
99
+ align-items: center;
100
+ gap: 0.5rem;
101
+ font-weight: 600;
102
+ font-size: 1.05rem;
103
+ letter-spacing: -0.01em;
104
+ }
105
+ .brand-tag {
106
+ font-size: 0.75rem;
107
+ padding: 0.15rem 0.4rem;
108
+ background: var(--primary);
109
+ color: #fff;
110
+ border-radius: 4px;
111
+ font-weight: 500;
112
+ }
113
+
114
+ .header-controls {
115
+ display: flex;
116
+ align-items: center;
117
+ gap: 0.75rem;
118
+ }
119
+
120
+ .container {
121
+ display: flex;
122
+ flex: 1;
123
+ max-width: 1600px;
124
+ width: 100%;
125
+ margin: 0 auto;
126
+ padding: 1.5rem;
127
+ gap: 1.5rem;
128
+ }
129
+
130
+ .wizard-panel {
131
+ flex: 1.1;
132
+ display: flex;
133
+ flex-direction: column;
134
+ gap: 1.25rem;
135
+ }
136
+
137
+ .preview-panel {
138
+ flex: 0.9;
139
+ display: flex;
140
+ flex-direction: column;
141
+ background: var(--card-bg);
142
+ border: 1px solid var(--card-border);
143
+ border-radius: 12px;
144
+ overflow: hidden;
145
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
146
+ }
147
+
148
+ /* Steps Bar */
149
+ .stepper-nav {
150
+ display: flex;
151
+ gap: 0.5rem;
152
+ background: var(--card-bg);
153
+ border: 1px solid var(--card-border);
154
+ border-radius: 10px;
155
+ padding: 0.5rem;
156
+ }
157
+ .step-btn {
158
+ flex: 1;
159
+ padding: 0.6rem 0.8rem;
160
+ background: transparent;
161
+ border: none;
162
+ border-radius: 8px;
163
+ font-family: inherit;
164
+ font-size: 0.85rem;
165
+ font-weight: 500;
166
+ color: var(--text-muted);
167
+ cursor: pointer;
168
+ display: flex;
169
+ align-items: center;
170
+ justify-content: center;
171
+ gap: 0.4rem;
172
+ transition: all 0.15s ease;
173
+ }
174
+ .step-btn.active {
175
+ background: var(--primary);
176
+ color: #fff;
177
+ box-shadow: 0 2px 8px rgba(0, 113, 227, 0.25);
178
+ }
179
+ .step-btn.completed {
180
+ color: var(--text);
181
+ }
182
+
183
+ /* Card */
184
+ .card {
185
+ background: var(--card-bg);
186
+ border: 1px solid var(--card-border);
187
+ border-radius: 12px;
188
+ padding: 1.5rem;
189
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
190
+ }
191
+
192
+ .card-title {
193
+ font-size: 1.25rem;
194
+ font-weight: 600;
195
+ margin-bottom: 0.25rem;
196
+ letter-spacing: -0.01em;
197
+ }
198
+ .card-desc {
199
+ color: var(--text-muted);
200
+ font-size: 0.9rem;
201
+ margin-bottom: 1.5rem;
202
+ }
203
+
204
+ /* Form Fields */
205
+ .form-group {
206
+ margin-bottom: 1.25rem;
207
+ }
208
+ .form-label {
209
+ display: flex;
210
+ justify-content: space-between;
211
+ margin-bottom: 0.4rem;
212
+ font-size: 0.85rem;
213
+ font-weight: 600;
214
+ }
215
+ .form-hint {
216
+ color: var(--text-muted);
217
+ font-size: 0.8rem;
218
+ font-weight: normal;
219
+ margin-top: 0.25rem;
220
+ }
221
+ .form-error {
222
+ color: var(--danger);
223
+ font-size: 0.8rem;
224
+ margin-top: 0.25rem;
225
+ display: none;
226
+ }
227
+ .has-error .form-error { display: block; }
228
+ .has-error input, .has-error select, .has-error textarea {
229
+ border-color: var(--danger) !important;
230
+ }
231
+
232
+ input[type="text"], input[type="number"], select, textarea {
233
+ width: 100%;
234
+ padding: 0.65rem 0.85rem;
235
+ font-family: inherit;
236
+ font-size: 0.9rem;
237
+ background: var(--input-bg);
238
+ border: 1px solid var(--input-border);
239
+ border-radius: 8px;
240
+ color: var(--text);
241
+ transition: border-color 0.15s ease, box-shadow 0.15s ease;
242
+ outline: none;
243
+ }
244
+ input[type="text"]:focus, input[type="number"]:focus, select:focus, textarea:focus {
245
+ border-color: var(--input-focus);
246
+ box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.15);
247
+ }
248
+
249
+ .checkbox-label {
250
+ display: flex;
251
+ align-items: center;
252
+ gap: 0.5rem;
253
+ cursor: pointer;
254
+ font-size: 0.9rem;
255
+ font-weight: 500;
256
+ }
257
+ .checkbox-label input {
258
+ width: 1.1rem;
259
+ height: 1.1rem;
260
+ accent-color: var(--primary);
261
+ }
262
+
263
+ /* Buttons */
264
+ .btn {
265
+ padding: 0.6rem 1rem;
266
+ border-radius: 8px;
267
+ font-family: inherit;
268
+ font-size: 0.85rem;
269
+ font-weight: 600;
270
+ border: 1px solid transparent;
271
+ cursor: pointer;
272
+ display: inline-flex;
273
+ align-items: center;
274
+ gap: 0.4rem;
275
+ transition: all 0.15s ease;
276
+ }
277
+ .btn-primary {
278
+ background: var(--primary);
279
+ color: #fff;
280
+ }
281
+ .btn-primary:hover { background: var(--primary-hover); }
282
+ .btn-secondary {
283
+ background: var(--card-bg);
284
+ border-color: var(--card-border);
285
+ color: var(--text);
286
+ }
287
+ .btn-secondary:hover {
288
+ background: rgba(128, 128, 128, 0.08);
289
+ }
290
+
291
+ .wizard-actions {
292
+ display: flex;
293
+ justify-content: space-between;
294
+ margin-top: 1.5rem;
295
+ padding-top: 1.25rem;
296
+ border-top: 1px solid var(--card-border);
297
+ }
298
+
299
+ /* Preview Panel */
300
+ .preview-header {
301
+ padding: 0.75rem 1.25rem;
302
+ background: var(--card-bg);
303
+ border-bottom: 1px solid var(--card-border);
304
+ display: flex;
305
+ justify-content: space-between;
306
+ align-items: center;
307
+ }
308
+ .preview-title {
309
+ font-size: 0.9rem;
310
+ font-weight: 600;
311
+ }
312
+ .preview-toolbar {
313
+ display: flex;
314
+ gap: 0.5rem;
315
+ align-items: center;
316
+ }
317
+ .format-toggle {
318
+ display: flex;
319
+ background: var(--input-bg);
320
+ border: 1px solid var(--card-border);
321
+ border-radius: 6px;
322
+ padding: 2px;
323
+ }
324
+ .format-btn {
325
+ padding: 0.25rem 0.6rem;
326
+ border: none;
327
+ background: transparent;
328
+ color: var(--text-muted);
329
+ font-size: 0.75rem;
330
+ font-weight: 600;
331
+ border-radius: 4px;
332
+ cursor: pointer;
333
+ display: inline-flex;
334
+ align-items: center;
335
+ gap: 0.25rem;
336
+ }
337
+ .format-btn.active {
338
+ background: var(--primary);
339
+ color: #fff;
340
+ }
341
+ .format-btn.reserved {
342
+ opacity: 0.7;
343
+ }
344
+ .format-btn.reserved:hover {
345
+ opacity: 0.95;
346
+ }
347
+ .format-badge {
348
+ font-size: 0.6rem;
349
+ padding: 1px 4px;
350
+ border-radius: 3px;
351
+ background: rgba(128, 128, 128, 0.2);
352
+ color: var(--text-muted);
353
+ font-weight: 500;
354
+ text-transform: uppercase;
355
+ }
356
+
357
+ /* Document Upload & Raw Editor */
358
+ .doc-editor-card {
359
+ border: 1px solid var(--card-border);
360
+ border-radius: 8px;
361
+ overflow: hidden;
362
+ margin-top: 0.5rem;
363
+ background: var(--card-bg);
364
+ }
365
+ .doc-dropzone {
366
+ border: 2px dashed var(--card-border);
367
+ border-radius: 8px;
368
+ padding: 1.25rem 1rem;
369
+ text-align: center;
370
+ cursor: pointer;
371
+ background: rgba(128, 128, 128, 0.03);
372
+ transition: all 0.2s ease;
373
+ margin-bottom: 0.75rem;
374
+ }
375
+ .doc-dropzone:hover, .doc-dropzone.drag-over {
376
+ border-color: var(--primary);
377
+ background: rgba(0, 113, 227, 0.06);
378
+ }
379
+ .doc-dropzone-icon {
380
+ font-size: 1.5rem;
381
+ margin-bottom: 0.25rem;
382
+ }
383
+ .doc-dropzone-text {
384
+ font-size: 0.85rem;
385
+ font-weight: 500;
386
+ color: var(--text);
387
+ }
388
+ .doc-dropzone-sub {
389
+ font-size: 0.75rem;
390
+ color: var(--text-muted);
391
+ margin-top: 0.2rem;
392
+ }
393
+ .doc-editor-toolbar {
394
+ display: flex;
395
+ justify-content: space-between;
396
+ align-items: center;
397
+ padding: 0.4rem 0.75rem;
398
+ background: var(--input-bg);
399
+ border-bottom: 1px solid var(--card-border);
400
+ font-size: 0.75rem;
401
+ }
402
+ .doc-editor-toolbar-actions {
403
+ display: flex;
404
+ gap: 0.4rem;
405
+ }
406
+ .doc-editor-textarea {
407
+ width: 100%;
408
+ min-height: 220px;
409
+ padding: 0.75rem;
410
+ font-family: var(--font-mono);
411
+ font-size: 0.82rem;
412
+ line-height: 1.5;
413
+ background: var(--code-bg);
414
+ color: var(--code-text);
415
+ border: none;
416
+ resize: vertical;
417
+ outline: none;
418
+ box-sizing: border-box;
419
+ display: block;
420
+ }
421
+
422
+ /* Recursive Tree Editor */
423
+ .tree-root {
424
+ border: 1px solid var(--card-border);
425
+ border-radius: 8px;
426
+ padding: 0.6rem 0.75rem;
427
+ background: var(--card-bg);
428
+ overflow-x: auto;
429
+ }
430
+ .tree-children {
431
+ border-left: 2px solid var(--card-border);
432
+ margin-left: 0.35rem;
433
+ padding-left: 0.5rem;
434
+ margin-top: 0.3rem;
435
+ }
436
+ .tree-row {
437
+ display: flex;
438
+ align-items: flex-start;
439
+ gap: 0.4rem;
440
+ margin-bottom: 0.35rem;
441
+ }
442
+ .tree-row > .tree-node {
443
+ flex: 1 1 auto;
444
+ min-width: 0;
445
+ }
446
+ .tree-row input.tree-key {
447
+ flex: 0 0 8rem;
448
+ width: 8rem;
449
+ min-width: 6rem;
450
+ padding: 0.35rem 0.5rem;
451
+ font-size: 0.8rem;
452
+ }
453
+ .tree-type {
454
+ flex: 0 0 auto;
455
+ width: auto;
456
+ padding: 0.3rem 0.4rem;
457
+ font-size: 0.75rem;
458
+ }
459
+ .tree-row input.tree-value {
460
+ flex: 1 1 6rem;
461
+ min-width: 5rem;
462
+ width: auto;
463
+ padding: 0.35rem 0.5rem;
464
+ font-size: 0.8rem;
465
+ }
466
+ .tree-btn {
467
+ padding: 0.25rem 0.5rem;
468
+ font-size: 0.72rem;
469
+ border-radius: 6px;
470
+ border: 1px solid var(--card-border);
471
+ background: var(--input-bg);
472
+ color: var(--text);
473
+ cursor: pointer;
474
+ white-space: nowrap;
475
+ }
476
+ .tree-btn.remove {
477
+ color: var(--danger);
478
+ }
479
+ .tree-btn.add {
480
+ color: var(--primary);
481
+ }
482
+ .tree-btn.nest {
483
+ color: var(--text);
484
+ font-weight: 600;
485
+ }
486
+ .tree-bulk {
487
+ display: flex;
488
+ gap: 0.35rem;
489
+ margin-top: 0.3rem;
490
+ align-items: center;
491
+ }
492
+ .tree-bulk input {
493
+ flex: 1 1 auto;
494
+ min-width: 8rem;
495
+ padding: 0.35rem 0.5rem;
496
+ font-size: 0.78rem;
497
+ }
498
+ .tree-bulk .tree-btn {
499
+ flex: 0 0 auto;
500
+ }
501
+ .tree-null {
502
+ font-size: 0.8rem;
503
+ color: var(--text-muted);
504
+ padding: 0.35rem 0;
505
+ }
506
+ .tree-toolbar {
507
+ display: flex;
508
+ justify-content: space-between;
509
+ align-items: center;
510
+ margin-bottom: 0.4rem;
511
+ }
512
+ .tree-toolbar-title {
513
+ font-size: 0.78rem;
514
+ font-weight: 600;
515
+ color: var(--text);
516
+ }
517
+ .editor-mode-toggle {
518
+ display: inline-flex;
519
+ gap: 2px;
520
+ padding: 2px;
521
+ background: var(--input-bg);
522
+ border: 1px solid var(--card-border);
523
+ border-radius: 8px;
524
+ margin-bottom: 0.6rem;
525
+ }
526
+ .mode-btn {
527
+ padding: 0.25rem 0.8rem;
528
+ font-size: 0.75rem;
529
+ font-weight: 600;
530
+ border: none;
531
+ background: transparent;
532
+ color: var(--text-muted);
533
+ border-radius: 6px;
534
+ cursor: pointer;
535
+ }
536
+ .mode-btn.active {
537
+ background: var(--primary);
538
+ color: #fff;
539
+ }
540
+
541
+ .preview-body {
542
+ flex: 1;
543
+ padding: 1.25rem;
544
+ background: var(--code-bg);
545
+ color: var(--code-text);
546
+ font-family: var(--font-mono);
547
+ font-size: 0.85rem;
548
+ line-height: 1.6;
549
+ overflow: auto;
550
+ white-space: pre;
551
+ }
552
+
553
+ .preview-status {
554
+ padding: 0.5rem 1.25rem;
555
+ font-size: 0.8rem;
556
+ border-top: 1px solid var(--card-border);
557
+ display: flex;
558
+ align-items: center;
559
+ gap: 0.4rem;
560
+ }
561
+ .status-ok { color: var(--accent); }
562
+ .status-err { color: var(--danger); }
563
+
564
+ /* Key-Value Mapping Table */
565
+ .mapping-table {
566
+ width: 100%;
567
+ margin-bottom: 0.5rem;
568
+ }
569
+ .mapping-row {
570
+ display: flex;
571
+ gap: 0.5rem;
572
+ margin-bottom: 0.4rem;
573
+ }
574
+ .mapping-row input { flex: 1; }
575
+ .mapping-remove {
576
+ padding: 0.4rem 0.6rem;
577
+ background: transparent;
578
+ border: 1px solid var(--card-border);
579
+ border-radius: 6px;
580
+ color: var(--danger);
581
+ cursor: pointer;
582
+ }
583
+
584
+ /* Modal / Toast */
585
+ .toast {
586
+ position: fixed;
587
+ bottom: 2rem;
588
+ right: 2rem;
589
+ background: #333;
590
+ color: #fff;
591
+ padding: 0.75rem 1.25rem;
592
+ border-radius: 8px;
593
+ font-size: 0.85rem;
594
+ box-shadow: 0 4px 16px rgba(0,0,0,0.2);
595
+ display: none;
596
+ z-index: 1000;
597
+ }
598
+ </style>
599
+ </head>
600
+ <body>
601
+ <header>
602
+ <div class="brand">
603
+ <span>⚙️ DevConfig-Gen</span>
604
+ <span class="brand-tag" data-i18n="studio">工作台</span>
605
+ </div>
606
+ <div class="header-controls">
607
+ <select id="providerSelect" style="width: auto; padding: 0.4rem 0.8rem;">
608
+ <option value="custom">Provider: custom</option>
609
+ </select>
610
+ <button class="btn btn-secondary" id="btnClearAll" data-i18n="clearAll">🧹 全部清空</button>
611
+ <button class="btn btn-secondary" id="btnUploadConfig" data-i18n="importFile">⬆️ 导入文件</button>
612
+ <input type="file" id="fileInput" style="display: none;" accept=".json,.yaml,.yml">
613
+ <button class="btn btn-secondary" id="btnPreset" data-i18n="loadPreset">✨ 加载预设</button>
614
+ <button class="btn btn-primary" id="btnExport" data-i18n="saveToDisk">💾 保存到磁盘</button>
615
+ <button class="btn btn-secondary" id="btnLang" style="padding: 0.4rem 0.7rem; font-size: 0.8rem;">中 / EN</button>
616
+ </div>
617
+ </header>
618
+
619
+ <div class="container">
620
+ <div class="wizard-panel">
621
+ <div class="stepper-nav" id="stepperNav"></div>
622
+
623
+ <div class="card" id="stepCard">
624
+ <h2 class="card-title" id="stepTitle">步骤标题</h2>
625
+ <p class="card-desc" id="stepDesc">步骤描述</p>
626
+
627
+ <div id="stepFields"></div>
628
+
629
+ <div class="wizard-actions">
630
+ <button class="btn btn-secondary" id="btnPrev" disabled data-i18n="prev">← 上一步</button>
631
+ <div style="font-size: 0.8rem; color: var(--text-muted); align-self: center;">
632
+ <span data-i18n="shortcut">快捷键</span>: <kbd>⌘</kbd> + <kbd>Enter</kbd>
633
+ </div>
634
+ <button class="btn btn-primary" id="btnNext" data-i18n="next">下一步 →</button>
635
+ </div>
636
+ </div>
637
+ </div>
638
+
639
+ <div class="preview-panel">
640
+ <div class="preview-header">
641
+ <span class="preview-title" data-i18n="livePreview">实时配置预览</span>
642
+ <div class="preview-toolbar">
643
+ <div class="format-toggle">
644
+ <button class="format-btn active" id="fmtYaml" onclick="setFormat('yaml')">YAML</button>
645
+ <button class="format-btn" id="fmtJson" onclick="setFormat('json')">JSON</button>
646
+ <button class="format-btn reserved" id="fmtXml" onclick="setFormat('xml')" title="XML 格式开关预留(即将支持)/ Reserved XML switch">XML <span class="format-badge">soon</span></button>
647
+ </div>
648
+ <button class="btn btn-secondary" id="btnCopy" style="padding: 0.25rem 0.6rem; font-size: 0.75rem;" data-i18n="copy">📋 复制</button>
649
+ </div>
650
+ </div>
651
+ <div class="preview-body" id="previewCode"># 生成预览中...</div>
652
+ <div class="preview-status" id="previewStatus">
653
+ <span class="status-ok">●</span> <span data-i18n="validConfig">配置有效</span>
654
+ </div>
655
+ </div>
656
+ </div>
657
+
658
+ <div class="toast" id="toast">通知消息</div>
659
+
660
+ <script>
661
+ // ── i18n ──────────────────────────────────────────────
662
+ const i18n = {
663
+ zh: {
664
+ studio: "工作台", importFile: "⬆️ 导入文件", loadPreset: "✨ 加载预设",
665
+ saveToDisk: "💾 保存到磁盘", prev: "← 上一步", next: "下一步 →",
666
+ finish: "完成 ✓", shortcut: "快捷键", livePreview: "实时配置预览",
667
+ copy: "📋 复制", validConfig: "配置有效", providerLabel: "Provider: ",
668
+ issues: (n) => `${n} 个校验问题`,
669
+ allDone: "✓ 全部步骤完成!可以保存或复制。",
670
+ copied: "📋 已复制配置到剪贴板!",
671
+ presetLoaded: "✨ 已加载预设!",
672
+ saved: (f) => `💾 已保存: ${f}`,
673
+ saveFail: (e) => `保存失败: ${e}`,
674
+ exportErr: (e) => `导出错误: ${e}`,
675
+ enterDir: "请输入保存配置的目标目录:",
676
+ parseFail: (e) => `解析失败: ${e}`,
677
+ uploadOk: (n) => `⬆️ 已从 ${n} 反向解析并填充!`,
678
+ uploadErr: (e) => `上传失败: ${e}`,
679
+ noEntries: "暂无条目",
680
+ keyValuePairs: "键值对",
681
+ add: "+ 添加",
682
+ key: "键", value: "值",
683
+ generating: "# 生成预览中...",
684
+ validationError: "# 校验错误:\\n# ",
685
+ xmlSoon: "XML 格式开关已预留,后续版本即将接入!",
686
+ uploadOrDrop: "点击或拖拽 JSON / YAML 文件到此处上传",
687
+ dropHint: "支持 .json, .yaml, .yml 格式文档,自动反向解析填充",
688
+ docEditor: "文档编辑器 (JSON / YAML)",
689
+ sampleDoc: "✨ 载入示例",
690
+ formatDoc: "格式化",
691
+ clearDoc: "清空",
692
+ clearAll: "🧹 全部清空",
693
+ clearAllDone: "🧹 已清空全部内容。",
694
+ treeEmpty: "暂无字段,点击下方按钮添加。",
695
+ treeAddField: "+ 添加字段",
696
+ treeAddItem: "+ 添加项",
697
+ treeRemove: "删除",
698
+ treeNest: "往里",
699
+ treeNestHint: "用 { } 包起来,里面可以继续嵌套",
700
+ treeBulkAdd: "批量添加",
701
+ treeBulkObj: "批量:a, b, c 或 a=1, b=2, …(逗号分隔,一次可加很多)",
702
+ treeBulkArr: "批量:1, 2, 3, …(逗号分隔,一次可加很多)",
703
+ treeRoot: "根节点",
704
+ treeKey: "键名",
705
+ treeType: "类型",
706
+ modeTree: "结构模式",
707
+ modeText: "文本模式",
708
+ textHint: '直接输入 JSON 或 YAML:列表 [1,2,3,4,5]、嵌套 {"1":{"2":{}}}、混合都行。',
709
+ textParseError: "解析失败",
710
+ },
711
+ en: {
712
+ studio: "Studio", importFile: "⬆️ Import File", loadPreset: "✨ Load Preset",
713
+ saveToDisk: "💾 Save to Disk", prev: "← Previous", next: "Next →",
714
+ finish: "Finish ✓", shortcut: "Shortcut", livePreview: "Live Generated Configuration",
715
+ copy: "📋 Copy", validConfig: "Valid configuration", providerLabel: "Provider: ",
716
+ issues: (n) => `${n} validation ${n === 1 ? 'issue' : 'issues'}`,
717
+ allDone: "✓ All steps completed! Ready to save or copy.",
718
+ copied: "📋 Copied configuration to clipboard!",
719
+ presetLoaded: "✨ Loaded preset!",
720
+ saved: (f) => `💾 Saved: ${f}`,
721
+ saveFail: (e) => `Save failed: ${e}`,
722
+ exportErr: (e) => `Export error: ${e}`,
723
+ enterDir: "Enter target directory to save configuration:",
724
+ parseFail: (e) => `Parse failed: ${e}`,
725
+ uploadOk: (n) => `⬆️ Successfully backfilled from ${n}!`,
726
+ uploadErr: (e) => `Upload failed: ${e}`,
727
+ noEntries: "No entries defined",
728
+ keyValuePairs: "Key-Value pairs",
729
+ add: "+ Add",
730
+ key: "Key", value: "Value",
731
+ generating: "# Generating preview...",
732
+ validationError: "# Validation Error:\\n# ",
733
+ xmlSoon: "XML format switch is reserved and will be supported in an upcoming release!",
734
+ uploadOrDrop: "Click or drag & drop a JSON / YAML file here to upload",
735
+ dropHint: "Supports .json, .yaml, .yml documents with auto-parsing",
736
+ docEditor: "Document Editor (JSON / YAML)",
737
+ sampleDoc: "✨ Sample",
738
+ formatDoc: "Format",
739
+ clearDoc: "Clear",
740
+ clearAll: "🧹 Clear All",
741
+ clearAllDone: "🧹 Cleared all content.",
742
+ treeEmpty: "No fields yet. Use the button below to add one.",
743
+ treeAddField: "+ Add field",
744
+ treeAddItem: "+ Add item",
745
+ treeRemove: "Remove",
746
+ treeNest: "Nest",
747
+ treeNestHint: "Wrap in { }; you can keep nesting inside",
748
+ treeBulkAdd: "Add all",
749
+ treeBulkObj: "Bulk: a, b, c or a=1, b=2, … (comma-separated, many at once)",
750
+ treeBulkArr: "Bulk: 1, 2, 3, … (comma-separated, many at once)",
751
+ treeRoot: "Root",
752
+ treeKey: "Key",
753
+ treeType: "Type",
754
+ modeTree: "Tree mode",
755
+ modeText: "Text mode",
756
+ textHint: 'Type JSON or YAML directly: lists [1,2,3,4,5], nesting {"1":{"2":{}}}, or anything mixed.',
757
+ textParseError: "Parse failed",
758
+ }
759
+ };
760
+ let currentLang = localStorage.getItem("dcg_lang") || "zh";
761
+
762
+ function t(key, arg) {
763
+ const val = i18n[currentLang][key];
764
+ return typeof val === "function" ? val(arg) : val;
765
+ }
766
+
767
+ // Localize a schema object (step or field) using its i18n map.
768
+ function loc(item, key) {
769
+ if (item && item.i18n && item.i18n[currentLang] && item.i18n[currentLang][key]) {
770
+ return item.i18n[currentLang][key];
771
+ }
772
+ return item ? item[key] : undefined;
773
+ }
774
+
775
+ function applyLang() {
776
+ document.querySelectorAll("[data-i18n]").forEach(el => {
777
+ const key = el.getAttribute("data-i18n");
778
+ const v = t(key);
779
+ if (v !== undefined) el.textContent = v;
780
+ });
781
+ document.getElementById("previewCode").textContent =
782
+ (currentLang === "zh") ? "# 生成预览中..." : "# Generating preview...";
783
+ document.getElementById("toast").textContent =
784
+ (currentLang === "zh") ? "通知消息" : "Notice message";
785
+ const btn = document.getElementById("btnLang");
786
+ btn.textContent = (currentLang === "zh") ? "EN" : "中";
787
+ document.documentElement.lang = (currentLang === "zh") ? "zh" : "en";
788
+ document.title = (currentLang === "zh")
789
+ ? "DevConfig-Gen — 配置工作台"
790
+ : "DevConfig-Gen — Configuration Studio";
791
+ // Refresh provider option labels
792
+ document.querySelectorAll("#providerSelect option").forEach(opt => {
793
+ opt.textContent = `${t("providerLabel")}${opt.value}`;
794
+ });
795
+ }
796
+
797
+ // ── Core State ────────────────────────────────────────
798
+ let currentProvider = "custom";
799
+ let schemaSteps = [];
800
+ let activeStepIdx = 0;
801
+ let formData = {};
802
+ let currentFormat = "yaml";
803
+ let debounceTimer = null;
804
+
805
+ // Escape untrusted strings before HTML/attribute interpolation
806
+ function esc(value) {
807
+ return String(value === null || value === undefined ? "" : value)
808
+ .replace(/&/g, "&amp;")
809
+ .replace(/</g, "&lt;")
810
+ .replace(/>/g, "&gt;")
811
+ .replace(/"/g, "&quot;")
812
+ .replace(/'/g, "&#39;");
813
+ }
814
+
815
+ // Toast
816
+ function showToast(msg, duration = 2500) {
817
+ const toast = document.getElementById("toast");
818
+ toast.textContent = msg;
819
+ toast.style.display = "block";
820
+ setTimeout(() => { toast.style.display = "none"; }, duration);
821
+ }
822
+
823
+ // Deep setter for dotted path
824
+ function setDotted(obj, path, val) {
825
+ const parts = path.split(".");
826
+ let cur = obj;
827
+ for (let i = 0; i < parts.length - 1; i++) {
828
+ if (!cur[parts[i]] || typeof cur[parts[i]] !== "object") cur[parts[i]] = {};
829
+ cur = cur[parts[i]];
830
+ }
831
+ cur[parts[parts.length - 1]] = val;
832
+ }
833
+
834
+ // Deep getter for dotted path
835
+ function getDotted(obj, path, def = null) {
836
+ if (!obj) return def;
837
+ const parts = path.split(".");
838
+ let cur = obj;
839
+ for (const p of parts) {
840
+ if (!cur || typeof cur !== "object" || !(p in cur)) return def;
841
+ cur = cur[p];
842
+ }
843
+ return cur !== undefined ? cur : def;
844
+ }
845
+
846
+ // ── Recursive tree editor ─────────────────────────────
847
+ const TREE_TYPES = ["string", "number", "boolean", "object", "array", "null"];
848
+
849
+ function valueType(v) {
850
+ if (v === null) return "null";
851
+ if (Array.isArray(v)) return "array";
852
+ if (typeof v === "object") return "object";
853
+ if (typeof v === "boolean") return "boolean";
854
+ if (typeof v === "number") return "number";
855
+ return "string";
856
+ }
857
+
858
+ function defaultForType(type) {
859
+ if (type === "number") return 0;
860
+ if (type === "boolean") return false;
861
+ if (type === "null") return null;
862
+ if (type === "object") return {};
863
+ if (type === "array") return [];
864
+ return "";
865
+ }
866
+
867
+ // Interpret a typed token as number/boolean/null when it clearly is one.
868
+ function coerceToken(text) {
869
+ const s = String(text).trim();
870
+ if (s === "true") return true;
871
+ if (s === "false") return false;
872
+ if (s === "null") return null;
873
+ if (s !== "" && !isNaN(Number(s))) return Number(s);
874
+ return s;
875
+ }
876
+
877
+ // Build an editor for one value. onChange(newValue) is called on every edit.
878
+ function buildValueEditor(value, onChange, depth) {
879
+ const wrap = document.createElement("div");
880
+ wrap.className = "tree-node";
881
+ const type = valueType(value);
882
+
883
+ const head = document.createElement("div");
884
+ head.className = "tree-row";
885
+
886
+ const typeSel = document.createElement("select");
887
+ typeSel.className = "tree-type";
888
+ TREE_TYPES.forEach(tt => {
889
+ const o = document.createElement("option");
890
+ o.value = tt;
891
+ o.textContent = tt;
892
+ if (tt === type) o.selected = true;
893
+ typeSel.appendChild(o);
894
+ });
895
+ typeSel.onchange = () => onChange(defaultForType(typeSel.value), true);
896
+ head.appendChild(typeSel);
897
+
898
+ if (type === "string" || type === "number") {
899
+ const inp = document.createElement("input");
900
+ inp.type = type === "number" ? "number" : "text";
901
+ inp.className = "tree-value";
902
+ inp.value = value === null || value === undefined ? "" : value;
903
+ inp.oninput = () => {
904
+ if (type === "number") {
905
+ onChange(inp.value === "" ? 0 : Number(inp.value), false);
906
+ } else {
907
+ onChange(inp.value, false);
908
+ }
909
+ };
910
+ head.appendChild(inp);
911
+ } else if (type === "boolean") {
912
+ const inp = document.createElement("input");
913
+ inp.type = "checkbox";
914
+ inp.checked = !!value;
915
+ inp.onchange = () => onChange(inp.checked, false);
916
+ head.appendChild(inp);
917
+ } else if (type === "null") {
918
+ const span = document.createElement("span");
919
+ span.className = "tree-null";
920
+ span.textContent = "null";
921
+ head.appendChild(span);
922
+ }
923
+ wrap.appendChild(head);
924
+
925
+ if (type === "object") {
926
+ wrap.appendChild(buildContainerEditor(value, onChange, depth, false));
927
+ } else if (type === "array") {
928
+ wrap.appendChild(buildContainerEditor(value, onChange, depth, true));
929
+ }
930
+ return wrap;
931
+ }
932
+
933
+ function buildContainerEditor(obj, onChange, depth, isArray) {
934
+ const box = document.createElement("div");
935
+ box.className = "tree-children";
936
+
937
+ const keys = isArray ? obj.map((_, i) => String(i)) : Object.keys(obj);
938
+ if (keys.length === 0) {
939
+ const empty = document.createElement("div");
940
+ empty.className = "tree-null";
941
+ empty.textContent = t("treeEmpty");
942
+ box.appendChild(empty);
943
+ }
944
+
945
+ keys.forEach(k => {
946
+ const row = document.createElement("div");
947
+ row.className = "tree-row";
948
+
949
+ if (!isArray) {
950
+ const keyInp = document.createElement("input");
951
+ keyInp.type = "text";
952
+ keyInp.className = "tree-key";
953
+ keyInp.placeholder = t("treeKey");
954
+ keyInp.value = k;
955
+ keyInp.onchange = () => {
956
+ const newKey = keyInp.value.trim();
957
+ if (!newKey || newKey === k) { keyInp.value = k; return; }
958
+ const next = {};
959
+ Object.keys(obj).forEach(kk => {
960
+ if (kk === k) next[newKey] = obj[k];
961
+ else next[kk] = obj[kk];
962
+ });
963
+ onChange(next, true);
964
+ };
965
+ row.appendChild(keyInp);
966
+ } else {
967
+ const idx = document.createElement("span");
968
+ idx.className = "tree-null";
969
+ idx.style.flex = "0 0 auto";
970
+ idx.textContent = `[${k}]`;
971
+ row.appendChild(idx);
972
+ }
973
+
974
+ const childEditor = buildValueEditor(
975
+ isArray ? obj[Number(k)] : obj[k],
976
+ (nv, structural) => {
977
+ if (structural) {
978
+ if (isArray) {
979
+ const arr = obj.slice();
980
+ arr[Number(k)] = nv;
981
+ onChange(arr, true);
982
+ } else {
983
+ const next = Object.assign({}, obj);
984
+ next[k] = nv;
985
+ onChange(next, true);
986
+ }
987
+ } else {
988
+ if (isArray) obj[Number(k)] = nv;
989
+ else obj[k] = nv;
990
+ onChange(obj, false);
991
+ }
992
+ },
993
+ depth + 1
994
+ );
995
+ row.appendChild(childEditor);
996
+
997
+ const nest = document.createElement("button");
998
+ nest.type = "button";
999
+ nest.className = "tree-btn nest";
1000
+ nest.textContent = t("treeNest");
1001
+ nest.title = t("treeNestHint");
1002
+ nest.onclick = () => {
1003
+ const cur = isArray ? obj[Number(k)] : obj[k];
1004
+ const wrapped = (cur === undefined || cur === null || cur === "")
1005
+ ? {}
1006
+ : (typeof cur === "object" ? cur : { value: cur });
1007
+ if (isArray) {
1008
+ const arr = obj.slice();
1009
+ arr[Number(k)] = wrapped;
1010
+ onChange(arr, true);
1011
+ } else {
1012
+ const next = Object.assign({}, obj);
1013
+ next[k] = wrapped;
1014
+ onChange(next, true);
1015
+ }
1016
+ };
1017
+ row.appendChild(nest);
1018
+
1019
+ const rm = document.createElement("button");
1020
+ rm.type = "button";
1021
+ rm.className = "tree-btn remove";
1022
+ rm.textContent = "✕";
1023
+ rm.title = t("treeRemove");
1024
+ rm.onclick = () => {
1025
+ if (isArray) {
1026
+ const arr = obj.slice();
1027
+ arr.splice(Number(k), 1);
1028
+ onChange(arr, true);
1029
+ } else {
1030
+ const next = Object.assign({}, obj);
1031
+ delete next[k];
1032
+ onChange(next, true);
1033
+ }
1034
+ };
1035
+ row.appendChild(rm);
1036
+ box.appendChild(row);
1037
+ });
1038
+
1039
+ const add = document.createElement("button");
1040
+ add.type = "button";
1041
+ add.className = "tree-btn add";
1042
+ add.textContent = isArray ? t("treeAddItem") : t("treeAddField");
1043
+ add.onclick = () => {
1044
+ if (isArray) {
1045
+ onChange(obj.concat([""]), true);
1046
+ } else {
1047
+ let name = "field";
1048
+ let i = 1;
1049
+ while (name in obj) { name = `field_${i++}`; }
1050
+ const next = Object.assign({}, obj);
1051
+ next[name] = "";
1052
+ onChange(next, true);
1053
+ }
1054
+ };
1055
+ box.appendChild(add);
1056
+
1057
+ // Bulk add: many comma-separated elements in one go.
1058
+ const bulk = document.createElement("div");
1059
+ bulk.className = "tree-bulk";
1060
+ const bulkInput = document.createElement("input");
1061
+ bulkInput.type = "text";
1062
+ bulkInput.placeholder = isArray ? t("treeBulkArr") : t("treeBulkObj");
1063
+ const bulkBtn = document.createElement("button");
1064
+ bulkBtn.type = "button";
1065
+ bulkBtn.className = "tree-btn add";
1066
+ bulkBtn.textContent = t("treeBulkAdd");
1067
+ function commitBulk() {
1068
+ const tokens = bulkInput.value
1069
+ .split(/[,,]/)
1070
+ .map(s => s.trim())
1071
+ .filter(s => s !== "");
1072
+ if (tokens.length === 0) return;
1073
+ if (isArray) {
1074
+ onChange(obj.concat(tokens.map(coerceToken)), true);
1075
+ } else {
1076
+ const next = Object.assign({}, obj);
1077
+ tokens.forEach(tok => {
1078
+ const eq = tok.indexOf("=");
1079
+ if (eq >= 0) {
1080
+ const key = tok.slice(0, eq).trim();
1081
+ if (key) next[key] = coerceToken(tok.slice(eq + 1));
1082
+ } else {
1083
+ next[tok] = "";
1084
+ }
1085
+ });
1086
+ onChange(next, true);
1087
+ }
1088
+ bulkInput.value = "";
1089
+ }
1090
+ bulkBtn.onclick = commitBulk;
1091
+ bulkInput.onkeydown = (e) => {
1092
+ if (e.key === "Enter") { e.preventDefault(); commitBulk(); }
1093
+ };
1094
+ bulk.appendChild(bulkInput);
1095
+ bulk.appendChild(bulkBtn);
1096
+ box.appendChild(bulk);
1097
+ return box;
1098
+ }
1099
+
1100
+ // Mount the tree editor into `host`; persist(newRoot) stores each change.
1101
+ function renderTreeEditor(host, root, persist) {
1102
+ let current = (root && typeof root === "object" && !Array.isArray(root)) ? root : {};
1103
+
1104
+ function apply(next, structural) {
1105
+ current = next;
1106
+ persist(next);
1107
+ if (structural) paint();
1108
+ }
1109
+
1110
+ function paint() {
1111
+ host.innerHTML = "";
1112
+ const box = document.createElement("div");
1113
+ box.className = "tree-root";
1114
+
1115
+ const toolbar = document.createElement("div");
1116
+ toolbar.className = "tree-toolbar";
1117
+ const title = document.createElement("span");
1118
+ title.className = "tree-toolbar-title";
1119
+ title.textContent = t("treeRoot") + " (object)";
1120
+ toolbar.appendChild(title);
1121
+ const clearBtn = document.createElement("button");
1122
+ clearBtn.type = "button";
1123
+ clearBtn.className = "tree-btn remove";
1124
+ clearBtn.textContent = t("clearDoc");
1125
+ clearBtn.onclick = () => apply({}, true);
1126
+ toolbar.appendChild(clearBtn);
1127
+ box.appendChild(toolbar);
1128
+
1129
+ box.appendChild(buildContainerEditor(current, apply, 0, false));
1130
+ host.appendChild(box);
1131
+ }
1132
+
1133
+ paint();
1134
+ }
1135
+
1136
+ // Free-form text editor for arbitrary JSON/YAML. persist(parsed) stores it.
1137
+ function mountTextEditor(host, value, persist) {
1138
+ host.innerHTML = `
1139
+ <div class="doc-editor-card">
1140
+ <div class="doc-editor-toolbar">
1141
+ <span style="font-weight:600; color:var(--text);">${t("docEditor")}</span>
1142
+ <div class="doc-editor-toolbar-actions">
1143
+ <button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" data-act="format">${t("formatDoc")}</button>
1144
+ <button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" data-act="clear">${t("clearDoc")}</button>
1145
+ </div>
1146
+ </div>
1147
+ <textarea class="doc-editor-textarea" spellcheck="false"></textarea>
1148
+ </div>
1149
+ <div class="form-hint">${t("textHint")}</div>
1150
+ <div class="form-error"></div>
1151
+ `;
1152
+ const ta = host.querySelector("textarea");
1153
+ const err = host.querySelector(".form-error");
1154
+ try {
1155
+ ta.value = JSON.stringify(value || {}, null, 2);
1156
+ } catch (e) {
1157
+ ta.value = "{}";
1158
+ }
1159
+
1160
+ let timer = null;
1161
+ ta.oninput = () => {
1162
+ clearTimeout(timer);
1163
+ timer = setTimeout(async () => {
1164
+ const text = ta.value.trim();
1165
+ if (!text) {
1166
+ err.textContent = "";
1167
+ persist({});
1168
+ return;
1169
+ }
1170
+ try {
1171
+ const resp = await fetch("/api/parse", {
1172
+ method: "POST",
1173
+ headers: { "Content-Type": "application/json" },
1174
+ body: JSON.stringify({ content: text })
1175
+ });
1176
+ const res = await resp.json();
1177
+ if (res.context) {
1178
+ err.textContent = "";
1179
+ persist(res.context);
1180
+ } else {
1181
+ err.textContent = res.error || t("textParseError");
1182
+ }
1183
+ } catch (e) {
1184
+ err.textContent = String(e);
1185
+ }
1186
+ }, 200);
1187
+ };
1188
+
1189
+ host.querySelector('[data-act="format"]').onclick = () => {
1190
+ try {
1191
+ ta.value = JSON.stringify(JSON.parse(ta.value), null, 2);
1192
+ err.textContent = "";
1193
+ } catch (e) {
1194
+ err.textContent = t("textParseError");
1195
+ }
1196
+ };
1197
+ host.querySelector('[data-act="clear"]').onclick = () => {
1198
+ ta.value = "{}";
1199
+ persist({});
1200
+ };
1201
+ }
1202
+
1203
+ function starterData(provider) {
1204
+ if (provider === "custom") {
1205
+ return {
1206
+ document: {
1207
+ app: {
1208
+ name: "my-app",
1209
+ enabled: true,
1210
+ port: 8080,
1211
+ tags: ["web", "api"],
1212
+ database: { host: "localhost", port: 5432, ssl: false },
1213
+ notes: "comma, 逗号, both are kept"
1214
+ }
1215
+ }
1216
+ };
1217
+ }
1218
+ if (provider === "json") {
1219
+ return { document: {} };
1220
+ }
1221
+ return {};
1222
+ }
1223
+
1224
+ async function initApp() {
1225
+ try {
1226
+ const resp = await fetch("/api/providers");
1227
+ const data = await resp.json();
1228
+ const select = document.getElementById("providerSelect");
1229
+ select.innerHTML = "";
1230
+ data.providers.forEach(p => {
1231
+ const opt = document.createElement("option");
1232
+ opt.value = p;
1233
+ opt.textContent = `${t("providerLabel")}${p}`;
1234
+ select.appendChild(opt);
1235
+ });
1236
+ select.value = currentProvider;
1237
+ } catch (err) {
1238
+ console.error("Failed to load providers:", err);
1239
+ }
1240
+
1241
+ await loadSchema(currentProvider);
1242
+ restoreDraft();
1243
+ if (!formData || Object.keys(formData).length === 0) {
1244
+ formData = starterData(currentProvider);
1245
+ }
1246
+ renderStep();
1247
+ triggerLivePreview();
1248
+ }
1249
+
1250
+ async function loadSchema(provider) {
1251
+ const resp = await fetch(`/api/schema?provider=${provider}`);
1252
+ schemaSteps = await resp.json();
1253
+ activeStepIdx = 0;
1254
+ renderStepper();
1255
+ }
1256
+
1257
+ function renderStepper() {
1258
+ const nav = document.getElementById("stepperNav");
1259
+ nav.innerHTML = "";
1260
+ schemaSteps.forEach((step, idx) => {
1261
+ const btn = document.createElement("button");
1262
+ btn.className = `step-btn ${idx === activeStepIdx ? "active" : ""}`;
1263
+ btn.innerHTML = `<span>${idx + 1}.</span> <span>${esc(loc(step, "title"))}</span>`;
1264
+ btn.onclick = () => { activeStepIdx = idx; renderStep(); };
1265
+ nav.appendChild(btn);
1266
+ });
1267
+ }
1268
+
1269
+ function renderStep() {
1270
+ renderStepper();
1271
+ const step = schemaSteps[activeStepIdx];
1272
+ if (!step) return;
1273
+
1274
+ document.getElementById("stepTitle").textContent = loc(step, "title");
1275
+ document.getElementById("stepDesc").textContent = loc(step, "description") || "";
1276
+ document.getElementById("btnPrev").disabled = activeStepIdx === 0;
1277
+ document.getElementById("btnNext").textContent = activeStepIdx === schemaSteps.length - 1 ? t("finish") : t("next");
1278
+
1279
+ const fieldsContainer = document.getElementById("stepFields");
1280
+ fieldsContainer.innerHTML = "";
1281
+
1282
+ step.fields.forEach(field => {
1283
+ const grp = document.createElement("div");
1284
+ grp.className = "form-group";
1285
+ grp.id = `grp_${field.name.replace(/\\./g, "_")}`;
1286
+
1287
+ const existing = getDotted(formData, field.name, field.default);
1288
+ const fid = field.name.replace(/\\./g, "_");
1289
+
1290
+ if (field.type === "boolean") {
1291
+ grp.innerHTML = `
1292
+ <label class="checkbox-label">
1293
+ <input type="checkbox" id="field_${fid}" ${existing ? "checked" : ""}>
1294
+ <span>${esc(loc(field, "title"))} ${field.required ? '<span style="color:var(--danger)">*</span>' : ''}</span>
1295
+ </label>
1296
+ <div class="form-hint">${esc(loc(field, "description") || "")}</div>
1297
+ `;
1298
+ const input = grp.querySelector("input");
1299
+ input.onchange = () => {
1300
+ setDotted(formData, field.name, input.checked);
1301
+ saveDraft();
1302
+ triggerLivePreview();
1303
+ };
1304
+ } else if (field.choices && field.choices.length > 0) {
1305
+ grp.innerHTML = `
1306
+ <label class="form-label">
1307
+ <span>${esc(loc(field, "title"))} ${field.required ? '<span style="color:var(--danger)">*</span>' : ''}</span>
1308
+ </label>
1309
+ <select id="field_${fid}">
1310
+ ${field.choices.map(c => `<option value="${esc(c)}" ${c === existing ? "selected" : ""}>${esc(c)}</option>`).join("")}
1311
+ </select>
1312
+ <div class="form-hint">${esc(loc(field, "description") || "")}</div>
1313
+ <div class="form-error"></div>
1314
+ `;
1315
+ const sel = grp.querySelector("select");
1316
+ sel.onchange = () => {
1317
+ setDotted(formData, field.name, sel.value);
1318
+ saveDraft();
1319
+ triggerLivePreview();
1320
+ };
1321
+ } else if (field.type === "tree") {
1322
+ grp.innerHTML = `
1323
+ <label class="form-label">
1324
+ <span>${esc(loc(field, "title"))}</span>
1325
+ </label>
1326
+ <div class="editor-mode-toggle">
1327
+ <button type="button" class="mode-btn active" id="modeTree_${fid}">${t("modeTree")}</button>
1328
+ <button type="button" class="mode-btn" id="modeText_${fid}">${t("modeText")}</button>
1329
+ </div>
1330
+ <div id="treeHost_${fid}"></div>
1331
+ <div id="textHost_${fid}" style="display:none;"></div>
1332
+ <div class="form-hint">${esc(loc(field, "description") || "")}</div>
1333
+ <div class="form-error"></div>
1334
+ `;
1335
+ const treeHost = grp.querySelector(`#treeHost_${fid}`);
1336
+ const textHost = grp.querySelector(`#textHost_${fid}`);
1337
+ const btnTree = grp.querySelector(`#modeTree_${fid}`);
1338
+ const btnText = grp.querySelector(`#modeText_${fid}`);
1339
+ let treeData = existing;
1340
+ if (!treeData || typeof treeData !== "object") {
1341
+ treeData = {};
1342
+ }
1343
+ const persistTree = (next) => {
1344
+ treeData = next;
1345
+ setDotted(formData, field.name, next);
1346
+ saveDraft();
1347
+ triggerLivePreview();
1348
+ };
1349
+ function showTreeMode() {
1350
+ btnTree.className = "mode-btn active";
1351
+ btnText.className = "mode-btn";
1352
+ treeHost.style.display = "";
1353
+ textHost.style.display = "none";
1354
+ renderTreeEditor(treeHost, treeData, persistTree);
1355
+ }
1356
+ function showTextMode() {
1357
+ btnTree.className = "mode-btn";
1358
+ btnText.className = "mode-btn active";
1359
+ treeHost.style.display = "none";
1360
+ textHost.style.display = "";
1361
+ mountTextEditor(textHost, treeData, persistTree);
1362
+ }
1363
+ btnTree.onclick = showTreeMode;
1364
+ btnText.onclick = showTextMode;
1365
+ showTreeMode();
1366
+ } else if (field.type === "document" || (currentProvider === "json" && field.name === "document")) {
1367
+ let docData = existing;
1368
+ if ((!docData || (typeof docData === "object" && Object.keys(docData).length === 0)) && currentProvider === "json") {
1369
+ const keys = Object.keys(formData).filter(k => k !== "document");
1370
+ if (keys.length > 0) docData = formData;
1371
+ }
1372
+ if (!docData) {
1373
+ docData = {
1374
+ app: {
1375
+ name: "example-app",
1376
+ port: 8080,
1377
+ environment: "development"
1378
+ }
1379
+ };
1380
+ if (currentProvider === "json") {
1381
+ formData = JSON.parse(JSON.stringify(docData));
1382
+ formData.document = JSON.parse(JSON.stringify(docData));
1383
+ } else {
1384
+ setDotted(formData, field.name, docData);
1385
+ }
1386
+ }
1387
+ let initialText = "";
1388
+ try {
1389
+ initialText = typeof docData === "string" ? docData : JSON.stringify(docData, null, 2);
1390
+ } catch (e) {
1391
+ initialText = "{}";
1392
+ }
1393
+
1394
+ grp.innerHTML = `
1395
+ <label class="form-label">
1396
+ <span>${esc(loc(field, "title"))} ${field.required ? '<span style="color:var(--danger)">*</span>' : ''}</span>
1397
+ </label>
1398
+ <div class="doc-dropzone" id="dropzone_${fid}">
1399
+ <div class="doc-dropzone-icon">📄</div>
1400
+ <div class="doc-dropzone-text">${t("uploadOrDrop")}</div>
1401
+ <div class="doc-dropzone-sub">${t("dropHint")}</div>
1402
+ <input type="file" id="dropInput_${fid}" style="display:none;" accept=".json,.yaml,.yml">
1403
+ </div>
1404
+ <div class="doc-editor-card">
1405
+ <div class="doc-editor-toolbar">
1406
+ <span style="font-weight:600; color:var(--text);">${t("docEditor")}</span>
1407
+ <div class="doc-editor-toolbar-actions">
1408
+ <button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" id="btnDocSample_${fid}">${t("sampleDoc")}</button>
1409
+ <button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" id="btnDocFormat_${fid}">${t("formatDoc")}</button>
1410
+ <button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" id="btnDocClear_${fid}">${t("clearDoc")}</button>
1411
+ </div>
1412
+ </div>
1413
+ <textarea class="doc-editor-textarea" id="docText_${fid}" spellcheck="false"></textarea>
1414
+ </div>
1415
+ <div class="form-hint">${esc(loc(field, "description") || "")}</div>
1416
+ <div class="form-error" id="docErr_${fid}"></div>
1417
+ `;
1418
+
1419
+ const dropzone = grp.querySelector(`#dropzone_${fid}`);
1420
+ const dropInput = grp.querySelector(`#dropInput_${fid}`);
1421
+ const textarea = grp.querySelector(`#docText_${fid}`);
1422
+ const docErr = grp.querySelector(`#docErr_${fid}`);
1423
+ const btnSample = grp.querySelector(`#btnDocSample_${fid}`);
1424
+ const btnFormat = grp.querySelector(`#btnDocFormat_${fid}`);
1425
+ const btnClear = grp.querySelector(`#btnDocClear_${fid}`);
1426
+
1427
+ textarea.value = initialText;
1428
+
1429
+ function updateDocContext(parsed) {
1430
+ if (currentProvider === "json") {
1431
+ formData = parsed;
1432
+ formData.document = parsed;
1433
+ } else {
1434
+ setDotted(formData, field.name, parsed);
1435
+ }
1436
+ saveDraft();
1437
+ triggerLivePreview();
1438
+ }
1439
+
1440
+ let docTimer = null;
1441
+ function parseAndApply(text) {
1442
+ clearTimeout(docTimer);
1443
+ docTimer = setTimeout(async () => {
1444
+ const trimmed = text.trim();
1445
+ if (!trimmed) {
1446
+ docErr.textContent = "";
1447
+ updateDocContext({});
1448
+ return;
1449
+ }
1450
+ try {
1451
+ const resp = await fetch("/api/parse", {
1452
+ method: "POST",
1453
+ headers: { "Content-Type": "application/json" },
1454
+ body: JSON.stringify({ content: trimmed })
1455
+ });
1456
+ const res = await resp.json();
1457
+ if (res.context) {
1458
+ docErr.textContent = "";
1459
+ updateDocContext(res.context);
1460
+ } else {
1461
+ docErr.textContent = res.error || "Format parse error";
1462
+ }
1463
+ } catch (err) {
1464
+ docErr.textContent = String(err);
1465
+ }
1466
+ }, 150);
1467
+ }
1468
+
1469
+ textarea.oninput = () => {
1470
+ parseAndApply(textarea.value);
1471
+ };
1472
+
1473
+ async function handleDocFile(file) {
1474
+ if (!file) return;
1475
+ try {
1476
+ const text = await file.text();
1477
+ textarea.value = text;
1478
+ const resp = await fetch("/api/parse", {
1479
+ method: "POST",
1480
+ headers: { "Content-Type": "application/json" },
1481
+ body: JSON.stringify({ content: text })
1482
+ });
1483
+ const res = await resp.json();
1484
+ if (res.context) {
1485
+ docErr.textContent = "";
1486
+ updateDocContext(res.context);
1487
+ showToast(t("uploadOk")(file.name));
1488
+ } else {
1489
+ docErr.textContent = res.error || "Format parse error";
1490
+ }
1491
+ } catch (e) {
1492
+ docErr.textContent = String(e);
1493
+ }
1494
+ }
1495
+
1496
+ dropzone.onclick = () => dropInput.click();
1497
+ dropInput.onchange = () => {
1498
+ if (dropInput.files[0]) handleDocFile(dropInput.files[0]);
1499
+ };
1500
+ dropzone.ondragover = (e) => {
1501
+ e.preventDefault();
1502
+ dropzone.classList.add("drag-over");
1503
+ };
1504
+ dropzone.ondragleave = () => {
1505
+ dropzone.classList.remove("drag-over");
1506
+ };
1507
+ dropzone.ondrop = (e) => {
1508
+ e.preventDefault();
1509
+ dropzone.classList.remove("drag-over");
1510
+ if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]) {
1511
+ handleDocFile(e.dataTransfer.files[0]);
1512
+ }
1513
+ };
1514
+
1515
+ btnSample.onclick = () => {
1516
+ const sample = {
1517
+ app: {
1518
+ name: "checkout-api",
1519
+ version: "1.0.0",
1520
+ port: 8080,
1521
+ environment: "production",
1522
+ labels: { tier: "backend", team: "core" }
1523
+ }
1524
+ };
1525
+ textarea.value = JSON.stringify(sample, null, 2);
1526
+ parseAndApply(textarea.value);
1527
+ };
1528
+
1529
+ btnFormat.onclick = () => {
1530
+ try {
1531
+ const val = JSON.parse(textarea.value);
1532
+ textarea.value = JSON.stringify(val, null, 2);
1533
+ docErr.textContent = "";
1534
+ } catch (e) {
1535
+ showToast("JSON 格式化需要标准 JSON 语法 / Standard JSON required");
1536
+ }
1537
+ };
1538
+
1539
+ btnClear.onclick = () => {
1540
+ textarea.value = "{\\n}";
1541
+ parseAndApply(textarea.value);
1542
+ };
1543
+ } else if (field.type === "mapping") {
1544
+ const mapData = existing || {};
1545
+ grp.innerHTML = `
1546
+ <label class="form-label">
1547
+ <span>${esc(loc(field, "title"))} (${t("keyValuePairs")})</span>
1548
+ <button type="button" class="btn btn-secondary" style="padding:0.2rem 0.5rem; font-size:0.75rem;" id="btnAddMap_${fid}">${t("add")}</button>
1549
+ </label>
1550
+ <div class="mapping-table" id="mapTable_${fid}"></div>
1551
+ <div class="form-hint">${esc(loc(field, "description") || "")}</div>
1552
+ `;
1553
+ const table = grp.querySelector(`#mapTable_${fid}`);
1554
+ const addBtn = grp.querySelector(`#btnAddMap_${fid}`);
1555
+
1556
+ function renderMapRows() {
1557
+ table.innerHTML = "";
1558
+ const keys = Object.keys(mapData);
1559
+ if (keys.length === 0) {
1560
+ table.innerHTML = `<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.4rem;">${t("noEntries")}</div>`;
1561
+ }
1562
+ keys.forEach(k => {
1563
+ const row = document.createElement("div");
1564
+ row.className = "mapping-row";
1565
+ row.innerHTML = `
1566
+ <input type="text" placeholder="${t("key")}" value="${esc(k)}" class="map-k">
1567
+ <input type="text" placeholder="${t("value")}" value="${esc(mapData[k])}" class="map-v">
1568
+ <button type="button" class="mapping-remove">✕</button>
1569
+ `;
1570
+ const kInput = row.querySelector(".map-k");
1571
+ const vInput = row.querySelector(".map-v");
1572
+ row.querySelector(".mapping-remove").onclick = () => {
1573
+ delete mapData[k];
1574
+ setDotted(formData, field.name, mapData);
1575
+ renderMapRows();
1576
+ triggerLivePreview();
1577
+ };
1578
+ kInput.onchange = () => {
1579
+ const newK = kInput.value.trim();
1580
+ const oldV = mapData[k];
1581
+ delete mapData[k];
1582
+ if (newK) mapData[newK] = oldV;
1583
+ setDotted(formData, field.name, mapData);
1584
+ renderMapRows();
1585
+ triggerLivePreview();
1586
+ };
1587
+ vInput.oninput = () => {
1588
+ mapData[k] = vInput.value.trim();
1589
+ setDotted(formData, field.name, mapData);
1590
+ triggerLivePreview();
1591
+ };
1592
+ table.appendChild(row);
1593
+ });
1594
+ }
1595
+ addBtn.onclick = () => {
1596
+ const nextKey = `key_${Object.keys(mapData).length + 1}`;
1597
+ mapData[nextKey] = "value";
1598
+ setDotted(formData, field.name, mapData);
1599
+ renderMapRows();
1600
+ triggerLivePreview();
1601
+ };
1602
+ renderMapRows();
1603
+ } else {
1604
+ // string or integer
1605
+ const isNum = field.type === "integer";
1606
+ grp.innerHTML = `
1607
+ <label class="form-label">
1608
+ <span>${esc(loc(field, "title"))} ${field.required ? '<span style="color:var(--danger)">*</span>' : ''}</span>
1609
+ ${field.minimum !== undefined && field.maximum !== undefined ? `<span class="form-hint">[${field.minimum} - ${field.maximum}]</span>` : ""}
1610
+ </label>
1611
+ <input type="${isNum ? 'number' : 'text'}" id="field_${fid}"
1612
+ value="${esc(existing !== null && existing !== undefined ? existing : '')}"
1613
+ placeholder="${esc(field.default !== null && field.default !== undefined ? field.default : '')}">
1614
+ <div class="form-hint">${esc(loc(field, "description") || "")}</div>
1615
+ <div class="form-error"></div>
1616
+ `;
1617
+ const input = grp.querySelector("input");
1618
+ input.oninput = () => {
1619
+ let val = input.value;
1620
+ if (isNum && val !== "") val = parseInt(val, 10);
1621
+ setDotted(formData, field.name, val === "" ? null : val);
1622
+ saveDraft();
1623
+ triggerLivePreview();
1624
+ };
1625
+ }
1626
+ fieldsContainer.appendChild(grp);
1627
+ });
1628
+ }
1629
+
1630
+ // Trigger Live Preview & Diagnostics
1631
+ function triggerLivePreview() {
1632
+ clearTimeout(debounceTimer);
1633
+ debounceTimer = setTimeout(runPreviewAndValidate, 150);
1634
+ }
1635
+
1636
+ async function runPreviewAndValidate() {
1637
+ try {
1638
+ // Validate
1639
+ const valResp = await fetch("/api/validate", {
1640
+ method: "POST",
1641
+ headers: { "Content-Type": "application/json" },
1642
+ body: JSON.stringify({ provider: currentProvider, context: formData })
1643
+ });
1644
+ const valData = await valResp.json();
1645
+
1646
+ // Clear all field errors
1647
+ document.querySelectorAll(".form-group").forEach(el => {
1648
+ el.classList.remove("has-error");
1649
+ const errEl = el.querySelector(".form-error");
1650
+ if (errEl) errEl.textContent = "";
1651
+ });
1652
+
1653
+ const statusEl = document.getElementById("previewStatus");
1654
+ if (valData.valid) {
1655
+ statusEl.innerHTML = `<span class="status-ok">●</span> ${t("validConfig")}`;
1656
+ } else {
1657
+ const count = valData.diagnostics.length;
1658
+ statusEl.innerHTML = `<span class="status-err">●</span> ${t("issues")(count)}`;
1659
+ valData.diagnostics.forEach(d => {
1660
+ const grpId = `grp_${d.field.replace(/\\./g, "_")}`;
1661
+ const grp = document.getElementById(grpId);
1662
+ if (grp) {
1663
+ grp.classList.add("has-error");
1664
+ const errEl = grp.querySelector(".form-error");
1665
+ if (errEl) errEl.textContent = d.message;
1666
+ }
1667
+ });
1668
+ }
1669
+
1670
+ // Generate Preview
1671
+ const genResp = await fetch("/api/generate", {
1672
+ method: "POST",
1673
+ headers: { "Content-Type": "application/json" },
1674
+ body: JSON.stringify({
1675
+ provider: currentProvider,
1676
+ context: formData,
1677
+ format: currentFormat
1678
+ })
1679
+ });
1680
+ const genData = await genResp.json();
1681
+ const codeEl = document.getElementById("previewCode");
1682
+ if (genData.artifacts && genData.artifacts.length > 0) {
1683
+ codeEl.textContent = genData.artifacts[0].content;
1684
+ } else if (genData.error) {
1685
+ codeEl.textContent = t("validationError") + genData.error;
1686
+ }
1687
+ } catch (err) {
1688
+ console.error("Preview failed:", err);
1689
+ }
1690
+ }
1691
+
1692
+ function setFormat(fmt) {
1693
+ if (fmt === "xml") {
1694
+ showToast(t("xmlSoon"));
1695
+ return;
1696
+ }
1697
+ currentFormat = fmt;
1698
+ document.getElementById("fmtYaml").className = `format-btn ${fmt === "yaml" ? "active" : ""}`;
1699
+ document.getElementById("fmtJson").className = `format-btn ${fmt === "json" ? "active" : ""}`;
1700
+ triggerLivePreview();
1701
+ }
1702
+
1703
+ // Navigation
1704
+ document.getElementById("btnPrev").onclick = () => {
1705
+ if (activeStepIdx > 0) {
1706
+ activeStepIdx--;
1707
+ renderStep();
1708
+ }
1709
+ };
1710
+ document.getElementById("btnNext").onclick = () => {
1711
+ if (activeStepIdx < schemaSteps.length - 1) {
1712
+ activeStepIdx++;
1713
+ renderStep();
1714
+ } else {
1715
+ showToast(t("allDone"));
1716
+ }
1717
+ };
1718
+
1719
+ // Keyboard Shortcut (⌘ + Enter)
1720
+ window.addEventListener("keydown", (e) => {
1721
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
1722
+ e.preventDefault();
1723
+ document.getElementById("btnNext").click();
1724
+ }
1725
+ if ((e.metaKey || e.ctrlKey) && e.key === "s") {
1726
+ e.preventDefault();
1727
+ document.getElementById("btnExport").click();
1728
+ }
1729
+ });
1730
+
1731
+ // Copy to clipboard
1732
+ document.getElementById("btnCopy").onclick = () => {
1733
+ const code = document.getElementById("previewCode").textContent;
1734
+ navigator.clipboard.writeText(code).then(() => {
1735
+ showToast(t("copied"));
1736
+ });
1737
+ };
1738
+
1739
+ // Clear All
1740
+ document.getElementById("btnClearAll").onclick = () => {
1741
+ formData = {};
1742
+ if (currentProvider === "custom" || currentProvider === "json") {
1743
+ formData = { document: {} };
1744
+ }
1745
+ saveDraft();
1746
+ renderStep();
1747
+ triggerLivePreview();
1748
+ showToast(t("clearAllDone"));
1749
+ };
1750
+
1751
+ // Preset
1752
+ document.getElementById("btnPreset").onclick = () => {
1753
+ if (currentProvider === "json") {
1754
+ formData = {
1755
+ app: {
1756
+ name: "payments-core",
1757
+ version: "1.0.0",
1758
+ port: 8080,
1759
+ environment: "production",
1760
+ replicas: 3,
1761
+ labels: { team: "payments", tier: "backend" }
1762
+ }
1763
+ };
1764
+ formData.document = JSON.parse(JSON.stringify(formData));
1765
+ } else if (currentProvider === "custom") {
1766
+ formData = {
1767
+ document: {
1768
+ app: {
1769
+ name: "my-app",
1770
+ enabled: true,
1771
+ port: 8080,
1772
+ tags: ["web", "api"],
1773
+ database: { host: "localhost", port: 5432, ssl: false },
1774
+ limits: { cpu: "500m", memory: "512Mi" }
1775
+ }
1776
+ }
1777
+ };
1778
+ } else if (currentProvider === "env") {
1779
+ formData = {
1780
+ variables: {
1781
+ database: { host: "localhost", port: 5432, name: "app_db" },
1782
+ debug: true,
1783
+ log_level: "info"
1784
+ }
1785
+ };
1786
+ } else {
1787
+ formData = {
1788
+ app: {
1789
+ name: "payments-core",
1790
+ version: "1.0.0",
1791
+ port: 8080,
1792
+ environment: "production",
1793
+ replicas: 3,
1794
+ labels: { team: "payments", tier: "backend" },
1795
+ health_check: { path: "/healthz", interval_seconds: 15, timeout_seconds: 5 }
1796
+ }
1797
+ };
1798
+ }
1799
+ saveDraft();
1800
+ renderStep();
1801
+ triggerLivePreview();
1802
+ showToast(t("presetLoaded"));
1803
+ };
1804
+
1805
+ // Export to Disk
1806
+ document.getElementById("btnExport").onclick = async () => {
1807
+ const targetDir = prompt(t("enterDir"), ".");
1808
+ if (targetDir === null) return;
1809
+ try {
1810
+ const resp = await fetch("/api/export", {
1811
+ method: "POST",
1812
+ headers: { "Content-Type": "application/json" },
1813
+ body: JSON.stringify({
1814
+ provider: currentProvider,
1815
+ context: formData,
1816
+ format: currentFormat,
1817
+ output_dir: targetDir
1818
+ })
1819
+ });
1820
+ const res = await resp.json();
1821
+ if (res.success) {
1822
+ showToast(t("saved")(res.saved.join(", ")));
1823
+ } else {
1824
+ alert(t("saveFail")(res.error));
1825
+ }
1826
+ } catch (err) {
1827
+ alert(t("exportErr")(err));
1828
+ }
1829
+ };
1830
+
1831
+ // Upload & Reverse Parse
1832
+ const fileInput = document.getElementById("fileInput");
1833
+ document.getElementById("btnUploadConfig").onclick = () => fileInput.click();
1834
+ fileInput.onchange = async () => {
1835
+ const file = fileInput.files[0];
1836
+ if (!file) return;
1837
+ const text = await file.text();
1838
+ try {
1839
+ const resp = await fetch("/api/parse", {
1840
+ method: "POST",
1841
+ headers: { "Content-Type": "application/json" },
1842
+ body: JSON.stringify({ content: text })
1843
+ });
1844
+ const res = await resp.json();
1845
+ if (res.context) {
1846
+ formData = res.context;
1847
+ saveDraft();
1848
+ renderStep();
1849
+ triggerLivePreview();
1850
+ showToast(t("uploadOk")(file.name));
1851
+ } else {
1852
+ alert(t("parseFail")(res.error));
1853
+ }
1854
+ } catch (err) {
1855
+ alert(t("uploadErr")(err));
1856
+ }
1857
+ };
1858
+
1859
+ // LocalStorage Draft
1860
+ function saveDraft() {
1861
+ try {
1862
+ localStorage.setItem(`devconfig_draft_${currentProvider}`, JSON.stringify(formData));
1863
+ } catch (e) {}
1864
+ }
1865
+ function restoreDraft() {
1866
+ try {
1867
+ const saved = localStorage.getItem(`devconfig_draft_${currentProvider}`);
1868
+ if (saved) formData = JSON.parse(saved);
1869
+ } catch (e) {}
1870
+ }
1871
+
1872
+ // Provider Selector Change
1873
+ document.getElementById("providerSelect").onchange = async (e) => {
1874
+ currentProvider = e.target.value;
1875
+ await loadSchema(currentProvider);
1876
+ formData = {};
1877
+ restoreDraft();
1878
+ if (!formData || Object.keys(formData).length === 0) {
1879
+ formData = starterData(currentProvider);
1880
+ }
1881
+ renderStep();
1882
+ triggerLivePreview();
1883
+ };
1884
+
1885
+ // ── Language Toggle ───────────────────────────────────
1886
+ document.getElementById("btnLang").onclick = () => {
1887
+ currentLang = currentLang === "zh" ? "en" : "zh";
1888
+ localStorage.setItem("dcg_lang", currentLang);
1889
+ applyLang();
1890
+ renderStep();
1891
+ triggerLivePreview();
1892
+ };
1893
+
1894
+ // ── Start App ────────────────────────────────────────
1895
+ window.onload = () => { applyLang(); initApp(); };
1896
+ </script>
1897
+ </body>
1898
+ </html>
1899
+ """
1900
+
1901
+
1902
+ class WebUIRequestHandler(BaseHTTPRequestHandler):
1903
+ """Serve the single-page studio and a small JSON API over the engine.
1904
+
1905
+ The server is intended for local use only. ``workspace_root`` bounds where
1906
+ ``/api/export`` may write, and requests with a non-local ``Host`` header are
1907
+ rejected to mitigate DNS-rebinding attacks against the loopback listener.
1908
+ """
1909
+
1910
+ registry: ProviderRegistry = default_registry
1911
+ workspace_root: Optional[Path] = None
1912
+
1913
+ def log_message(self, format: str, *args: Any) -> None:
1914
+ # Keep terminal log clean and minimal
1915
+ pass
1916
+
1917
+ def _allowed_host(self) -> bool:
1918
+ host = (self.headers.get("Host") or "").strip().lower()
1919
+ if host.startswith("["): # IPv6 literal, e.g. [::1]:8848
1920
+ host = host[1:].split("]", 1)[0]
1921
+ else:
1922
+ host = host.split(":", 1)[0]
1923
+ return host in ("", "127.0.0.1", "localhost", "::1")
1924
+
1925
+ def _workspace(self) -> Path:
1926
+ if self.workspace_root is not None:
1927
+ return Path(self.workspace_root).resolve()
1928
+ return Path.cwd().resolve()
1929
+
1930
+ def _safe_output_dir(self, requested: str) -> Path:
1931
+ root = self._workspace()
1932
+ candidate = Path(requested or ".")
1933
+ if not candidate.is_absolute():
1934
+ candidate = root / candidate
1935
+ target = candidate.resolve()
1936
+ if target != root and root not in target.parents:
1937
+ raise ValueError(f"output directory must stay inside the workspace root: {root}")
1938
+ return target
1939
+
1940
+ def _read_json_body(self) -> dict:
1941
+ length = int(self.headers.get("Content-Length", 0) or 0)
1942
+ raw = self.rfile.read(length) if length > 0 else b""
1943
+ if not raw:
1944
+ return {}
1945
+ try:
1946
+ text = raw.decode("utf-8")
1947
+ except UnicodeDecodeError as exc:
1948
+ raise ValueError(f"request body must be UTF-8: {exc}") from exc
1949
+ try:
1950
+ payload = json.loads(text)
1951
+ except json.JSONDecodeError as exc:
1952
+ raise ValueError(f"invalid JSON request body: {exc}") from exc
1953
+ if not isinstance(payload, dict):
1954
+ raise ValueError("request body must be a JSON object")
1955
+ return payload
1956
+
1957
+ def _send_json(self, data: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
1958
+ encoded = json.dumps(data, indent=2).encode("utf-8")
1959
+ self.send_response(status)
1960
+ self.send_header("Content-Type", "application/json; charset=utf-8")
1961
+ self.send_header("Content-Length", str(len(encoded)))
1962
+ self.send_header("Cache-Control", "no-store")
1963
+ self.end_headers()
1964
+ self.wfile.write(encoded)
1965
+
1966
+ def _send_html(self, html: str, status: HTTPStatus = HTTPStatus.OK) -> None:
1967
+ encoded = html.encode("utf-8")
1968
+ self.send_response(status)
1969
+ self.send_header("Content-Type", "text/html; charset=utf-8")
1970
+ self.send_header("Content-Length", str(len(encoded)))
1971
+ self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
1972
+ self.send_header("Pragma", "no-cache")
1973
+ self.send_header("Expires", "0")
1974
+ self.end_headers()
1975
+ self.wfile.write(encoded)
1976
+
1977
+ def do_GET(self) -> None:
1978
+ if not self._allowed_host():
1979
+ self._send_json({"error": "forbidden host"}, status=HTTPStatus.FORBIDDEN)
1980
+ return
1981
+
1982
+ parsed = urllib.parse.urlparse(self.path)
1983
+ path = parsed.path
1984
+ query = urllib.parse.parse_qs(parsed.query)
1985
+
1986
+ try:
1987
+ if path in ("/", "/index.html"):
1988
+ self._send_html(_HTML_PAGE)
1989
+ return
1990
+
1991
+ if path == "/api/providers":
1992
+ self._send_json({"providers": list(self.registry.names())})
1993
+ return
1994
+
1995
+ if path == "/api/schema":
1996
+ provider_name = query.get("provider", ["custom"])[0]
1997
+ steps = describe_provider(provider_name, registry=self.registry)
1998
+ self._send_json([s.as_dict() for s in steps])
1999
+ return
2000
+ except ValueError as exc:
2001
+ self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
2002
+ return
2003
+ except Exception as exc: # pragma: no cover - defensive
2004
+ self._send_json(
2005
+ {"error": f"internal error: {exc}"}, status=HTTPStatus.INTERNAL_SERVER_ERROR
2006
+ )
2007
+ return
2008
+
2009
+ self.send_error(HTTPStatus.NOT_FOUND)
2010
+
2011
+ def do_POST(self) -> None:
2012
+ if not self._allowed_host():
2013
+ self._send_json({"error": "forbidden host"}, status=HTTPStatus.FORBIDDEN)
2014
+ return
2015
+
2016
+ path = urllib.parse.urlparse(self.path).path
2017
+ try:
2018
+ payload = self._read_json_body()
2019
+ except ValueError as exc:
2020
+ self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
2021
+ return
2022
+
2023
+ try:
2024
+ if path == "/api/validate":
2025
+ provider_name = payload.get("provider", "custom")
2026
+ context = payload.get("context", {})
2027
+ diagnostics = diagnose_request(
2028
+ provider_name, context=context, registry=self.registry
2029
+ )
2030
+ self._send_json(
2031
+ {
2032
+ "valid": not any(d.severity == "error" for d in diagnostics),
2033
+ "diagnostics": [d.as_dict() for d in diagnostics],
2034
+ }
2035
+ )
2036
+ return
2037
+
2038
+ if path == "/api/generate":
2039
+ provider_name = payload.get("provider", "custom")
2040
+ context = payload.get("context", {})
2041
+ fmt = payload.get("format", "yaml")
2042
+ result = generate(
2043
+ provider_name,
2044
+ GenerationRequest(context=context, options={"format": fmt}),
2045
+ registry=self.registry,
2046
+ )
2047
+ artifacts_data = []
2048
+ for artifact in result.artifacts:
2049
+ content_str = (
2050
+ artifact.content
2051
+ if isinstance(artifact.content, str)
2052
+ else formats.dumps(
2053
+ artifact.content,
2054
+ formats.format_from_media_type(artifact.media_type),
2055
+ )
2056
+ )
2057
+ artifacts_data.append(
2058
+ {
2059
+ "name": artifact.name,
2060
+ "content": content_str,
2061
+ "media_type": artifact.media_type,
2062
+ }
2063
+ )
2064
+ self._send_json({"artifacts": artifacts_data})
2065
+ return
2066
+
2067
+ if path == "/api/export":
2068
+ provider_name = payload.get("provider", "custom")
2069
+ context = payload.get("context", {})
2070
+ fmt = payload.get("format", "yaml")
2071
+ output_dir = self._safe_output_dir(str(payload.get("output_dir", ".")))
2072
+ result = generate(
2073
+ provider_name,
2074
+ GenerationRequest(context=context, options={"format": fmt}),
2075
+ registry=self.registry,
2076
+ output_dir=str(output_dir),
2077
+ )
2078
+ saved = [str(output_dir / artifact.name) for artifact in result.artifacts]
2079
+ self._send_json({"success": True, "saved": saved})
2080
+ return
2081
+
2082
+ if path == "/api/parse":
2083
+ content = payload.get("content", "")
2084
+ if not isinstance(content, str):
2085
+ raise ValueError("content must be a string")
2086
+ parsed_ctx = formats.loads(content)
2087
+ self._send_json({"context": parsed_ctx})
2088
+ return
2089
+ except ValueError as exc:
2090
+ self._send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
2091
+ return
2092
+ except Exception as exc: # pragma: no cover - defensive
2093
+ self._send_json(
2094
+ {"error": f"internal error: {exc}"}, status=HTTPStatus.INTERNAL_SERVER_ERROR
2095
+ )
2096
+ return
2097
+
2098
+ self.send_error(HTTPStatus.NOT_FOUND)
2099
+
2100
+ def run_web_ui(
2101
+ host: str = "127.0.0.1",
2102
+ port: int = 8848,
2103
+ open_browser: bool = True,
2104
+ registry: Optional[ProviderRegistry] = None,
2105
+ workspace_root: Optional[str] = None,
2106
+ ) -> None:
2107
+ """Start the local WebUI HTTP server and open a browser.
2108
+
2109
+ ``workspace_root`` bounds the directories ``/api/export`` may write to; it
2110
+ defaults to the current working directory.
2111
+ """
2112
+
2113
+ reg = registry or default_registry
2114
+ root = Path(workspace_root).resolve() if workspace_root else Path.cwd().resolve()
2115
+
2116
+ class CustomHandler(WebUIRequestHandler):
2117
+ pass
2118
+
2119
+ CustomHandler.registry = reg
2120
+ CustomHandler.workspace_root = root
2121
+
2122
+ server = ThreadingHTTPServer((host, port), CustomHandler)
2123
+ url = f"http://{host}:{port}"
2124
+
2125
+ print("=================================================================")
2126
+ print(" DevConfig-Gen Studio (本地配置工作台) ")
2127
+ print(f" 本地运行地址: {url}")
2128
+ print(f" 导出工作空间: {root}")
2129
+ print(" 按 Ctrl+C 停止服务")
2130
+ print("=================================================================")
2131
+
2132
+ if open_browser:
2133
+ try:
2134
+ webbrowser.open(url)
2135
+ except Exception:
2136
+ pass
2137
+
2138
+ try:
2139
+ server.serve_forever()
2140
+ except KeyboardInterrupt:
2141
+ print("\nStudio server stopped. / 服务已停止。")
2142
+ finally:
2143
+ server.server_close()