sqlseed-web 0.2.4__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.
Files changed (87) hide show
  1. sqlseed_web/AGENTS.md +106 -0
  2. sqlseed_web/__init__.py +24 -0
  3. sqlseed_web/__main__.py +8 -0
  4. sqlseed_web/_application.py +205 -0
  5. sqlseed_web/ai_settings.py +290 -0
  6. sqlseed_web/api.py +1102 -0
  7. sqlseed_web/app.py +26 -0
  8. sqlseed_web/managed_worker.py +184 -0
  9. sqlseed_web/operation_errors.py +42 -0
  10. sqlseed_web/plugin_environment.py +231 -0
  11. sqlseed_web/plugin_management.py +322 -0
  12. sqlseed_web/plugin_process.py +131 -0
  13. sqlseed_web/runtime_lifecycle.py +130 -0
  14. sqlseed_web/runtime_session.py +97 -0
  15. sqlseed_web/settings_environment.py +368 -0
  16. sqlseed_web/sqlite_target.py +86 -0
  17. sqlseed_web/state.py +348 -0
  18. sqlseed_web/static/AGENTS.md +180 -0
  19. sqlseed_web/static/ai.css +57 -0
  20. sqlseed_web/static/configs.css +50 -0
  21. sqlseed_web/static/date-picker.css +230 -0
  22. sqlseed_web/static/disclosure.css +149 -0
  23. sqlseed_web/static/graph-clarity.css +103 -0
  24. sqlseed_web/static/index.html +29 -0
  25. sqlseed_web/static/js/api.js +228 -0
  26. sqlseed_web/static/js/app.js +97 -0
  27. sqlseed_web/static/js/dropdown.js +432 -0
  28. sqlseed_web/static/js/filepicker.js +203 -0
  29. sqlseed_web/static/js/genform.js +1066 -0
  30. sqlseed_web/static/js/labels.js +195 -0
  31. sqlseed_web/static/js/pages/browse.js +209 -0
  32. sqlseed_web/static/js/pages/configs.js +424 -0
  33. sqlseed_web/static/js/pages/connect.js +332 -0
  34. sqlseed_web/static/js/pages/heal.js +395 -0
  35. sqlseed_web/static/js/pages/meta.js +110 -0
  36. sqlseed_web/static/js/pages/runs.js +293 -0
  37. sqlseed_web/static/js/pages/settings.js +942 -0
  38. sqlseed_web/static/js/pages/wizard.js +751 -0
  39. sqlseed_web/static/js/pages/workbench.js +3123 -0
  40. sqlseed_web/static/js/tree.js +126 -0
  41. sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
  42. sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
  43. sqlseed_web/static/js/workbench/ai-stream.js +116 -0
  44. sqlseed_web/static/js/workbench/ai.js +888 -0
  45. sqlseed_web/static/js/workbench/connection.js +508 -0
  46. sqlseed_web/static/js/workbench/date-picker.js +445 -0
  47. sqlseed_web/static/js/workbench/dependency-view.js +119 -0
  48. sqlseed_web/static/js/workbench/editor.js +1236 -0
  49. sqlseed_web/static/js/workbench/focus.js +11 -0
  50. sqlseed_web/static/js/workbench/graph-layout.js +332 -0
  51. sqlseed_web/static/js/workbench/graph.js +970 -0
  52. sqlseed_web/static/js/workbench/guidance.js +29 -0
  53. sqlseed_web/static/js/workbench/model.js +124 -0
  54. sqlseed_web/static/js/workbench/plugin-management.js +512 -0
  55. sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
  56. sqlseed_web/static/js/workbench/preview.js +572 -0
  57. sqlseed_web/static/js/workbench/provider-guide.js +33 -0
  58. sqlseed_web/static/js/workbench/recovery.js +28 -0
  59. sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
  60. sqlseed_web/static/js/workbench/session.js +174 -0
  61. sqlseed_web/static/js/workbench/table-data.js +186 -0
  62. sqlseed_web/static/js/workbench/ui.js +262 -0
  63. sqlseed_web/static/navigation.css +92 -0
  64. sqlseed_web/static/preview.css +29 -0
  65. sqlseed_web/static/runs.css +53 -0
  66. sqlseed_web/static/scrollbars.css +42 -0
  67. sqlseed_web/static/settings.css +108 -0
  68. sqlseed_web/static/style.css +3382 -0
  69. sqlseed_web/static/table-data.css +27 -0
  70. sqlseed_web/static/workbench.css +509 -0
  71. sqlseed_web/supervised_plugins.py +173 -0
  72. sqlseed_web/supervisor.py +238 -0
  73. sqlseed_web/workbench.py +381 -0
  74. sqlseed_web/workbench_ai.py +887 -0
  75. sqlseed_web/workbench_ai_relations.py +285 -0
  76. sqlseed_web/workbench_ai_stream.py +172 -0
  77. sqlseed_web/workbench_data.py +163 -0
  78. sqlseed_web/workbench_execution.py +199 -0
  79. sqlseed_web/workbench_runtime.py +1218 -0
  80. sqlseed_web/workbench_schema.py +277 -0
  81. sqlseed_web/workbench_store.py +458 -0
  82. sqlseed_web/worker_control.py +192 -0
  83. sqlseed_web-0.2.4.dist-info/METADATA +105 -0
  84. sqlseed_web-0.2.4.dist-info/RECORD +87 -0
  85. sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
  86. sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
  87. sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
@@ -0,0 +1,751 @@
1
+ // 数据生成向导(参考工具 式三步工作台):
2
+ // Step1 目标:连接选择 + 信息面板 + 流程图
3
+ // Step2 对象:左树(勾选表/列)+ 右列属性面板(生成器/参数/预览/NULL/唯一)
4
+ // Step3 生成:表生成顺序 + 逐表预览 + 按序填充
5
+ // 底部持久栏:保存配置文件 / 加载配置文件 / 表生成顺序 / 上一步 / 下一步
6
+
7
+ import { h, get, post, clear, msg, table, fmt, store, restoreConnection } from '../api.js';
8
+ import { createTree } from '../tree.js';
9
+ import { createGenForm } from '../genform.js';
10
+ let step = 1;
11
+ let meta = null;
12
+ let tablesMeta = []; // [{name, columns, specs, fks, foreignKeys, rowCount}]
13
+ let connInfo = null;
14
+ let tree = null;
15
+ let genform = null;
16
+ let cfg = new Map(); // table -> Map<col, ColumnConfig>(后端形状:null_ratio 0–1)
17
+ let treeSelection = null; // 树勾选跨步骤持久化(AI 选择不被步骤切换重置)
18
+ let aiCfg = null; // /api/ai/config 响应(会话覆盖已合并)——AI 就绪门控用
19
+ let stateConnId = null;
20
+ let stateRevision = 0;
21
+ let metaRequest = 0;
22
+ let configRequest = 0;
23
+ let importedConfig = null;
24
+ let tableConfigs = new Map();
25
+ let countOverride = null;
26
+ const runningConnections = new Set();
27
+
28
+ // 配置均来自 JSON API;深拷贝避免用户编辑嵌套参数改变已提交任务。
29
+ function copyConfig(value) {
30
+ return structuredClone(value);
31
+ }
32
+ function syncConnectionState() {
33
+ if (stateConnId === store.connId) return;
34
+ stateConnId = store.connId;
35
+ stateRevision++;
36
+ step = 1;
37
+ meta = null;
38
+ tablesMeta = [];
39
+ connInfo = null;
40
+ tree = null;
41
+ genform = null;
42
+ cfg = new Map();
43
+ treeSelection = null;
44
+ aiCfg = null;
45
+ importedConfig = null;
46
+ tableConfigs = new Map();
47
+ countOverride = null;
48
+ }
49
+ function isCurrentConnection(connId, revision) {
50
+ return store.connId === connId && stateRevision === revision;
51
+ }
52
+ function selection() {
53
+ return tree?.getSelection() || treeSelection || new Map(tablesMeta.map(tm => [tm.name, new Set(tm.columns.map(c => c.name))]));
54
+ }
55
+ export function render() {
56
+ syncConnectionState();
57
+ const root = h('div', {
58
+ class: 'wizard'
59
+ });
60
+ root.append(renderHeader());
61
+ const body = h('div', {
62
+ class: 'wizard-body',
63
+ id: 'wizard-body'
64
+ });
65
+ root.append(body);
66
+ root.append(renderFooter());
67
+ renderStep(body, root);
68
+ return root;
69
+ }
70
+ export async function mount() {
71
+ if (!store.connId) {
72
+ // 页面刷新会清空模块级 store,但服务端的连接对象仍然活着——
73
+ // 先尝试按 localStorage 记录(或主连接)恢复,失败才提示去连接页。
74
+ const ok = await restoreConnection();
75
+ if (!ok) {
76
+ const body = document.getElementById('wizard-body');
77
+ clear(body);
78
+ body.append(msg('先在「数据库连接」页打开一个数据库,再进入数据生成向导。', 'warn'));
79
+ return;
80
+ }
81
+ }
82
+ // 「配置助手」页的「送到数据生成向导」:导入其产出的 YAML 并直达 Step 2。
83
+ syncConnectionState();
84
+ const connId = store.connId;
85
+ const revision = stateRevision;
86
+ const pending = store.aiYaml;
87
+ delete store.aiYaml;
88
+ try {
89
+ if (!(await loadMeta())) return;
90
+ if (pending) step = 2;
91
+ renderStep();
92
+ if (pending) {
93
+ const {
94
+ tables,
95
+ cols
96
+ } = await applyAiYaml(pending);
97
+ document.getElementById('wizard-body')?.append(msg(`已导入 AI 生成的配置(${tables} 张表 / ${cols} 列),可逐列微调。`, 'ok'));
98
+ }
99
+ } catch (e) {
100
+ if (isCurrentConnection(connId, revision)) {
101
+ document.getElementById('wizard-body')?.append(msg(`加载向导失败:${e.message}`));
102
+ }
103
+ }
104
+ }
105
+ function renderHeader() {
106
+ return h('div', {
107
+ class: 'wizard-header'
108
+ }, h('span', {
109
+ class: 'wizard-db-icon'
110
+ }, '🗄'), h('div', {}, h('div', {
111
+ class: 'wizard-db-name',
112
+ id: 'wizard-db-target'
113
+ }, store.target || '未连接'), h('div', {
114
+ class: 'muted',
115
+ id: 'wizard-step-label'
116
+ }, `步骤 ${step} / 3 — ${['目标', '对象', '生成'][step - 1]}`)));
117
+ }
118
+ async function loadMeta() {
119
+ syncConnectionState();
120
+ const connId = store.connId;
121
+ if (!connId) return false;
122
+ const revision = stateRevision;
123
+ const request = ++metaRequest;
124
+ const names = store.tables.map(t => t.name);
125
+ const [nextMeta, nextAiCfg, snapshot] = await Promise.all([get('/api/meta/generators'), get('/api/ai/config').catch(() => null), loadTablesMeta(connId, names)]);
126
+ if (!isCurrentConnection(connId, revision) || request !== metaRequest) return false;
127
+ meta = nextMeta;
128
+ aiCfg = nextAiCfg;
129
+ connInfo = snapshot.connInfo;
130
+ tablesMeta = snapshot.tables;
131
+ return true;
132
+ }
133
+ async function loadTablesMeta(connId, names) {
134
+ const conns = await get('/api/connections');
135
+ const tables = await Promise.all(names.map(async name => {
136
+ const [schema, mapping] = await Promise.all([get(`/api/connections/${connId}/tables/${encodeURIComponent(name)}/schema`), get(`/api/connections/${connId}/tables/${encodeURIComponent(name)}/mapping`)]);
137
+ return {
138
+ name,
139
+ columns: schema.columns,
140
+ specs: mapping.mapping,
141
+ fks: new Set((schema.foreign_keys || []).map(fk => fk.column)),
142
+ foreignKeys: schema.foreign_keys || [],
143
+ uniqueColumns: new Set(schema.unique_columns || []),
144
+ rowCount: schema.row_count
145
+ };
146
+ }));
147
+ return {
148
+ connInfo: conns.connections.find(c => c.conn_id === connId) || null,
149
+ tables
150
+ };
151
+ }
152
+ function renderStep(bodyEl = null, rootEl = null) {
153
+ const body = bodyEl || document.getElementById('wizard-body');
154
+ if (!body) return;
155
+ clear(body);
156
+ if (step === 1) body.append(renderStep1());else if (step === 2) body.append(renderStep2());else body.append(renderStep3());
157
+ const targetLabel = (rootEl || document).querySelector('#wizard-db-target');
158
+ if (targetLabel) targetLabel.textContent = store.target || '未连接';
159
+ const stepLabel = (rootEl || document).querySelector('#wizard-step-label');
160
+ if (stepLabel) stepLabel.textContent = `步骤 ${step} / 3 — ${['目标', '对象', '生成'][step - 1]}`;
161
+ updateFooter(rootEl);
162
+ showImportNotes(body);
163
+ }
164
+
165
+ // ---- Step 1:目标 -----------------------------------------------------------
166
+
167
+ function renderStep1() {
168
+ const info = connInfo;
169
+ const ready = aiReadiness();
170
+ return h('div', {
171
+ class: 'step1'
172
+ }, h('div', {
173
+ class: 'step1-left'
174
+ }, h('h3', {
175
+ class: 'section-title'
176
+ }, '目标'), h('div', {
177
+ class: 'genform-row'
178
+ }, h('label', {
179
+ class: 'genform-label'
180
+ }, '连接:'), h('span', {
181
+ class: 'pill ok'
182
+ }, info ? `${info.target}` : '未连接')), h('div', {
183
+ class: 'genform-row'
184
+ }, h('label', {
185
+ class: 'genform-label'
186
+ }, '数据语言与地区:'), h('span', {
187
+ class: 'pill'
188
+ }, info?.locale || '—')), h('div', {
189
+ class: 'genform-row'
190
+ }, h('label', {
191
+ class: 'genform-label'
192
+ }, '数据生成引擎:'), h('span', {
193
+ class: 'pill'
194
+ }, info?.provider || '—')), h('div', {
195
+ class: 'genform-row'
196
+ }, h('label', {
197
+ class: 'genform-label'
198
+ }, 'AI 状态:'), ready.ok ? h('span', {
199
+ class: 'pill ok'
200
+ }, `已就绪(${ready.backend} · ${ready.model})`) : h('span', {
201
+ class: 'pill warn'
202
+ }, `未就绪 — ${ready.reason}`)), h('h3', {
203
+ class: 'section-title',
204
+ style: 'margin-top:24px'
205
+ }, '信息'), h('div', {
206
+ class: 'muted',
207
+ style: 'white-space:pre-line'
208
+ }, `数据库类型: SQLite\n文件: ${store.target || '—'}\n表: ${store.tables.length} 张\n总行数: ${store.tables.reduce((n, t) => n + t.row_count, 0)}\n\nAI 状态决定 Step 2 是否可用「AI 一键生成配置」;未就绪时仍可手动配置生成器。`)), h('div', {
209
+ class: 'step1-right'
210
+ }, flowDiagram()));
211
+ }
212
+ function flowDiagram() {
213
+ return h('div', {
214
+ class: 'flow'
215
+ }, h('div', {
216
+ class: 'flow-node'
217
+ }, '▦ 选择要生成数据的表和字段'), h('div', {
218
+ class: 'flow-arrow'
219
+ }, '↓'), h('div', {
220
+ class: 'flow-node'
221
+ }, '⚙ 设置属性并创建测试数据'), h('div', {
222
+ class: 'flow-arrow'
223
+ }, '↓'), h('div', {
224
+ class: 'flow-node'
225
+ }, '🗄 写入数据库'));
226
+ }
227
+
228
+ // ---- Step 2:对象 -----------------------------------------------------------
229
+
230
+ function renderStep2() {
231
+ const connId = store.connId;
232
+ const revision = stateRevision;
233
+ const wrap = h('div', {
234
+ class: 'step2'
235
+ });
236
+ const left = h('div', {
237
+ class: 'step2-left'
238
+ });
239
+ const right = h('div', {
240
+ class: 'step2-right'
241
+ });
242
+ wrap.append(left, right);
243
+ tree = createTree({
244
+ tables: tablesMeta,
245
+ // 树标注优先反映 AI/加载的列级配置(实时反馈),否则用零配置推断
246
+ specResolver: (t, c) => {
247
+ const cc = cfg.get(t)?.get(c);
248
+ if (cc) return {
249
+ generator_name: cc.generator
250
+ };
251
+ const tm = tablesMeta.find(x => x.name === t);
252
+ return tm?.specs?.[c];
253
+ },
254
+ initialSelection: treeSelection,
255
+ onSelectColumn: showColumnInPanel,
256
+ onChange: checked => {
257
+ if (!isCurrentConnection(connId, revision)) return;
258
+ // 勾选状态持久化 + 取消勾选的列从 cfg 移除
259
+ treeSelection = new Map([...checked].map(([k, v]) => [k, new Set(v)]));
260
+ for (const tm of tablesMeta) {
261
+ const colSet = checked.get(tm.name) || new Set();
262
+ const cfgMap = cfg.get(tm.name);
263
+ if (!cfgMap) continue;
264
+ for (const col of cfgMap.keys()) {
265
+ if (!colSet.has(col)) cfgMap.delete(col);
266
+ }
267
+ }
268
+ }
269
+ });
270
+ genform = createGenForm({
271
+ connId: store.connId,
272
+ meta,
273
+ // 数据库唯一约束列(主键/唯一索引)——属性面板据此锁定「设置唯一」。
274
+ uniqueColumnsOf: t => tablesMeta.find(x => x.name === t)?.uniqueColumns,
275
+ // 外键来自 schema,不能被导入配置中的 generator 或派生表达式覆盖。
276
+ foreignKeysOf: t => tablesMeta.find(x => x.name === t)?.foreignKeys || [],
277
+ onChange: (t, c, colCfg) => {
278
+ if (!isCurrentConnection(connId, revision)) return;
279
+ if (!cfg.has(t)) cfg.set(t, new Map());
280
+ cfg.get(t).set(c, colCfg);
281
+ }
282
+ });
283
+ left.append(h('h3', {
284
+ class: 'section-title'
285
+ }, '数据库对象'), renderAiGenBar(), tree.el);
286
+ right.append(genform.el);
287
+ return wrap;
288
+ }
289
+
290
+ // 「AI 一键生成配置」:调用 L5 全流程自愈产出 YAML,再映射回本向导的
291
+ // 树勾选 + 列级配置,用户只需微调。AI 未安装/未配置时给出引导而非静默。
292
+ // 就绪 = 已安装 sqlseed-ai 且(本地后端 或 在线后端已配 Key)。
293
+ function aiReadiness() {
294
+ if (!aiCfg?.available) {
295
+ return {
296
+ ok: false,
297
+ reason: aiCfg?.reason || 'sqlseed-ai 未安装(pip install -e ./plugins/sqlseed-web[ai])'
298
+ };
299
+ }
300
+ const eff = aiCfg.effective;
301
+ const isLocal = eff.backend === 'ollama' || eff.backend === 'lm_studio';
302
+ if (!isLocal && !eff.api_key_present) {
303
+ return {
304
+ ok: false,
305
+ reason: `当前后端 ${eff.backend} 需要 API 密钥,请到「配置助手」页填写,或切换到本地模型(Ollama / LM Studio 无需密钥)`
306
+ };
307
+ }
308
+ return {
309
+ ok: true,
310
+ backend: eff.backend,
311
+ model: eff.model
312
+ };
313
+ }
314
+ function renderAiGenBar() {
315
+ const bar = h('div', {
316
+ class: 'row',
317
+ style: 'margin-bottom:8px'
318
+ });
319
+ const ready = aiReadiness();
320
+ if (!ready.ok) {
321
+ bar.append(h('span', {
322
+ class: 'muted',
323
+ style: 'font-size:12px'
324
+ }, `AI 一键生成配置未就绪:${ready.reason}`));
325
+ return bar;
326
+ }
327
+ bar.append(h('button', {
328
+ class: 'primary small',
329
+ id: 'btn-ai-gen',
330
+ onclick: aiGenerateConfig
331
+ }, 'AI 一键生成配置'), h('span', {
332
+ class: 'muted',
333
+ id: 'ai-gen-status',
334
+ style: 'font-size:12px'
335
+ }, `由 ${ready.backend} 分析 schema 生成一版配置并回填(替换当前列级配置,不写库),你可在右侧逐列微调`));
336
+ return bar;
337
+ }
338
+ async function aiGenerateConfig() {
339
+ const connId = store.connId;
340
+ const revision = stateRevision;
341
+ const btn = document.getElementById('btn-ai-gen');
342
+ const status = document.getElementById('ai-gen-status');
343
+ if (btn) btn.disabled = true;
344
+ if (status) status.textContent = '正在探测 AI 后端…';
345
+ try {
346
+ // 先探测后端可达性:失败直接给友好提示(如 Ollama 未启动),
347
+ // 不发起注定失败的 LLM 任务。
348
+ const probe = await post('/api/ai/test-connection', {});
349
+ if (!isCurrentConnection(connId, revision)) return;
350
+ if (!probe.available || !probe.ok) {
351
+ throw new Error(probe.message || probe.reason || 'AI 后端不可用');
352
+ }
353
+ if (status) status.textContent = `后端 ${probe.backend} 可达,正在按约束生成配置(确定性校验/修复优先,仅在需要语义决策时调用 LLM)…`;
354
+ const res = await post(`/api/connections/${connId}/heal/auto`, {
355
+ budget_seconds: 300
356
+ });
357
+ const job = await pollJob(res.job_id, 900);
358
+ if (!isCurrentConnection(connId, revision)) return;
359
+ if (job.status !== 'done' || !job.result?.yaml) {
360
+ throw new Error(job.error || '生成失败');
361
+ }
362
+ const {
363
+ tables,
364
+ cols
365
+ } = await applyAiYaml(job.result.yaml);
366
+ const n = job.result.llm_calls;
367
+ const llmNote = n === 0 ? '本次未调用 LLM —— schema 无语义违规,全部由确定性校验/修复完成(这正是 AI 插件的契约驱动设计:LLM 只在必要时介入)' : `LLM 调用 ${n} 次`;
368
+ if (status) status.textContent = `已回填 AI 配置(${tables} 张表 / ${cols} 列,${llmNote})——点击列即可微调。`;
369
+ } catch (e) {
370
+ if (status) status.textContent = `AI 生成未执行:${e.message}`;
371
+ } finally {
372
+ if (btn) btn.disabled = false;
373
+ }
374
+ }
375
+
376
+ // 把 AI 产出的 YAML 解析为结构化 config,映射回树勾选与列级配置。
377
+ // 只回填当前连接里真实存在的表/列,忽略 schema 之外的噪声。
378
+ // 返回回填的表/列数量;树标注与右侧面板同步刷新。
379
+ async function applyAiYaml(yamlText, connId = store.connId, revision = stateRevision) {
380
+ const request = ++configRequest;
381
+ const parsed = await post('/api/config/parse', {
382
+ yaml: yamlText
383
+ });
384
+ if (!isCurrentConnection(connId, revision) || request !== configRequest) {
385
+ throw new Error('连接或配置已更新,已忽略旧配置结果');
386
+ }
387
+ if (!parsed.valid) throw new Error(parsed.error || 'AI 产出的配置无法解析');
388
+ importedConfig = copyConfig(parsed.config || {});
389
+ tableConfigs = new Map();
390
+ countOverride = null;
391
+ const tables = importedConfig.tables || [];
392
+ const sel = new Map();
393
+ cfg = new Map();
394
+ readImportedTables();
395
+ treeSelection = new Map([...sel].map(([k, v]) => [k, new Set(v)]));
396
+ if (tree) {
397
+ tree.setSelection(sel); // 触发重渲染:标注即时反映 AI 选择
398
+ }
399
+ // 右侧属性面板同步:当前选中列若在 AI 配置内则展示 AI 值
400
+ const selCol = tree?.getSelectedColumn();
401
+ if (selCol?.table && selCol?.col) showColumnInPanel(selCol.table, selCol.col);
402
+ showImportNotes();
403
+ const colCount = [...sel.values()].reduce((n, s) => n + s.size, 0);
404
+ return {
405
+ tables: sel.size,
406
+ cols: colCount
407
+ };
408
+ function readImportedTables() {
409
+ for (const tc of tables) {
410
+ const tm = tablesMeta.find(x => x.name === tc.name);
411
+ if (!tm) continue;
412
+ const tableCfg = copyConfig(tc);
413
+ delete tableCfg.columns;
414
+ tableConfigs.set(tc.name, tableCfg);
415
+ const colSet = new Set();
416
+ const cfgMap = new Map();
417
+ for (const cc of tc.columns || []) {
418
+ if (!tm.columns.some(x => x.name === cc.name)) continue;
419
+ colSet.add(cc.name);
420
+ const colCfg = copyConfig(cc);
421
+ delete colCfg.name;
422
+ cfgMap.set(cc.name, colCfg);
423
+ }
424
+ // 空 columns 表示整表使用推断规则,仍保留该表的选择与执行参数。
425
+ if (!(tc.columns || []).length) tm.columns.forEach(c => colSet.add(c.name));
426
+ if (colSet.size) {
427
+ sel.set(tc.name, colSet);
428
+ cfg.set(tc.name, cfgMap);
429
+ }
430
+ }
431
+ }
432
+ }
433
+
434
+ // 在右侧属性面板展示某列:AI/加载配置优先于零配置推断。
435
+ // cfg 里 null_ratio 是 0–1(后端形状),genform.fromInferred 内部会
436
+ // *100 转成展示百分比,这里原样透传。
437
+ function showColumnInPanel(t, c) {
438
+ if (!genform) return;
439
+ const tm = tablesMeta.find(x => x.name === t);
440
+ if (!tm) return;
441
+ const colInfo = tm.columns.find(x => x.name === c);
442
+ if (!colInfo) return;
443
+ const cc = cfg.get(t)?.get(c);
444
+ const spec = cc ? {
445
+ ...copyConfig(cc),
446
+ generator_name: cc.generator
447
+ } : tm.specs[c];
448
+ // tm.specs[c] 是零配置推断结果,作为属性面板「重置属性」的回落基线。
449
+ genform.setColumn(t, c, colInfo, spec, tm.specs[c]);
450
+ }
451
+
452
+ // ---- Step 3:生成 -----------------------------------------------------------
453
+
454
+ function renderStep3() {
455
+ const wrap = h('div', {
456
+ class: 'step3'
457
+ });
458
+ const selected = selection();
459
+ const selectedTables = tablesMeta.filter(t => (selected.get(t.name)?.size || 0) > 0);
460
+ const order = topoOrderOf(selectedTables.map(t => t.name));
461
+ wrap.append(h('h3', {
462
+ class: 'section-title'
463
+ }, '表生成顺序(外键拓扑)'));
464
+ const topoOut = h('div', {
465
+ class: 'muted',
466
+ id: 'topo-out'
467
+ }, '计算中…');
468
+ wrap.append(topoOut);
469
+ order.then(names => {
470
+ topoOut.replaceChildren(h('div', {
471
+ class: 'row'
472
+ }, ...names.map((n, i) => h('span', {
473
+ class: 'pill gen'
474
+ }, `${i + 1}. ${n}`))));
475
+ }).catch(e => topoOut.replaceChildren(msg(`生成顺序读取失败:${e.message}`)));
476
+ wrap.append(h('h3', {
477
+ class: 'section-title'
478
+ }, '预览(每表 5 行,不写库)'));
479
+ const previewOut = h('div', {
480
+ id: 'preview-out'
481
+ });
482
+ wrap.append(previewOut);
483
+ doPreviews(selectedTables, previewOut);
484
+ wrap.append(h('div', {
485
+ class: 'row',
486
+ style: 'margin-top:16px'
487
+ }, h('label', {
488
+ class: 'genform-label'
489
+ }, tableConfigs.size ? '每表行数(修改后覆盖配置):' : '每表行数:'), h('input', {
490
+ type: 'number',
491
+ id: 'gen-count',
492
+ value: countOverride ?? (tableConfigs.size ? '' : 50),
493
+ min: 1,
494
+ placeholder: tableConfigs.size ? '保留各表配置行数' : '',
495
+ oninput: e => {
496
+ countOverride = e.target.value === '' ? null : +e.target.value;
497
+ }
498
+ }), h('button', {
499
+ class: 'primary',
500
+ id: 'btn-generate',
501
+ disabled: runningConnections.has(store.connId),
502
+ onclick: () => doGenerate(selectedTables)
503
+ }, '开始生成')));
504
+ wrap.append(h('div', {
505
+ id: 'gen-out'
506
+ }));
507
+ return wrap;
508
+ }
509
+ async function topoOrderOf(names, connId = store.connId) {
510
+ if (!names.length) return [];
511
+ const res = await get(`/api/connections/${connId}/topo-order?tables=${encodeURIComponent(names.join(','))}`);
512
+ return res.tables;
513
+ }
514
+ function buildColumnsFor(tm) {
515
+ const cfgMap = cfg.get(tm.name);
516
+ const cols = {};
517
+ if (cfgMap) {
518
+ for (const [col, colCfg] of cfgMap) cols[col] = copyConfig(colCfg);
519
+ }
520
+ return Object.keys(cols).length ? cols : null;
521
+ }
522
+ function tableRequest(tm, count) {
523
+ const options = copyConfig(tableConfigs.get(tm.name) || {});
524
+ delete options.name;
525
+ return {
526
+ ...options,
527
+ table: tm.name,
528
+ count,
529
+ columns: buildColumnsFor(tm)
530
+ };
531
+ }
532
+ async function doPreviews(selectedTables, out) {
533
+ const connId = store.connId;
534
+ const requests = selectedTables.map(tm => tableRequest(tm, 5));
535
+ clear(out);
536
+ for (const request of requests) {
537
+ try {
538
+ const res = await post(`/api/connections/${connId}/preview`, request);
539
+ const cols = Object.keys(res.rows[0] || {});
540
+ out.append(h('div', {
541
+ class: 'panel',
542
+ style: 'margin-bottom:12px'
543
+ }, h('h3', {
544
+ class: 'section-title'
545
+ }, request.table), h('div', {
546
+ class: 'table-scroll',
547
+ style: 'max-height:220px'
548
+ }, table(cols, res.rows.map(r => cols.map(c => fmt(r[c]))), {
549
+ monoCols: cols.map((_, i) => i)
550
+ }))));
551
+ } catch (e) {
552
+ out.append(msg(`${request.table} 预览失败:${e.message}`));
553
+ }
554
+ }
555
+ }
556
+ async function doGenerate(selectedTables) {
557
+ const connId = store.connId;
558
+ if (runningConnections.has(connId)) return;
559
+ const out = document.getElementById('gen-out');
560
+ const btn = document.getElementById('btn-generate');
561
+ clear(out);
562
+ // 所有可编辑输入必须在第一次 await 前冻结,包括对象内嵌套的 params。
563
+ const defaultCount = +document.getElementById('gen-count').value || 50;
564
+ const requests = new Map(selectedTables.map(tm => {
565
+ const count = countOverride ?? tableConfigs.get(tm.name)?.count ?? defaultCount;
566
+ return [tm.name, tableRequest(tm, count)];
567
+ }));
568
+ if (!requests.size) {
569
+ out.append(msg('请先选择要生成数据的表。', 'warn'));
570
+ return;
571
+ }
572
+ if ([...requests.values()].some(r => !Number.isInteger(r.count) || r.count < 1)) {
573
+ out.append(msg('生成行数必须为正整数。'));
574
+ return;
575
+ }
576
+ runningConnections.add(connId);
577
+ if (btn) btn.disabled = true;
578
+ try {
579
+ const names = [...requests.keys()];
580
+ const order = await topoOrderOf(names, connId);
581
+ if (order.length !== names.length || new Set(order).size !== names.length || order.some(n => !requests.has(n))) {
582
+ throw new Error('返回的表生成顺序与所选表不一致,请重新加载向导');
583
+ }
584
+ for (const tname of order) {
585
+ const progress = h('div', {
586
+ class: 'muted'
587
+ }, `生成 ${tname} …`);
588
+ out.append(progress);
589
+ const res = await post(`/api/connections/${connId}/fill`, requests.get(tname));
590
+ const job = await pollJob(res.job_id);
591
+ const errors = job.result?.errors;
592
+ if (job.status !== 'done' || errors?.length) {
593
+ const details = job.error || (Array.isArray(errors) ? errors.join('; ') : errors) || '失败';
594
+ progress.replaceChildren(msg(`${tname}: ${details}(已写入 ${job.rows_inserted || 0} 行)`));
595
+ out.append(msg('生成已停止,后续表未执行。', 'warn'));
596
+ return;
597
+ }
598
+ progress.replaceChildren(h('span', {
599
+ class: 'pill ok'
600
+ }, `${tname}: ${job.rows_inserted} 行`));
601
+ }
602
+ out.append(msg('全部完成。可在「数据浏览」页查看结果。', 'ok'));
603
+ } catch (e) {
604
+ out.append(msg(`生成已停止:${e.message}`));
605
+ } finally {
606
+ releaseGenerationControls();
607
+ }
608
+ function releaseGenerationControls() {
609
+ runningConnections.delete(connId);
610
+ if (btn) btn.disabled = false;
611
+ if (store.connId === connId) {
612
+ const currentButton = document.getElementById('btn-generate');
613
+ if (currentButton) currentButton.disabled = false;
614
+ }
615
+ }
616
+ }
617
+ async function pollJob(jobId, maxTries = 120) {
618
+ // 默认 120*400ms=48s 足够 fill;AI 全流程自愈可能要几分钟,调用方传更大 maxTries。
619
+ for (let i = 0; i < maxTries; i++) {
620
+ const j = await get(`/api/jobs/${jobId}`);
621
+ if (j.status !== 'running') return j;
622
+ await new Promise(r => setTimeout(r, 400));
623
+ }
624
+ return {
625
+ status: 'error',
626
+ error: '超时'
627
+ };
628
+ }
629
+
630
+ // ---- 底部持久栏 --------------------------------------------------------------
631
+
632
+ function renderFooter() {
633
+ return h('div', {
634
+ class: 'wizard-footer'
635
+ }, h('button', {
636
+ onclick: saveConfig
637
+ }, '保存配置文件'), h('button', {
638
+ onclick: loadConfig
639
+ }, '加载配置文件'), h('span', {
640
+ style: 'flex:1'
641
+ }), h('button', {
642
+ id: 'btn-prev',
643
+ onclick: () => {
644
+ if (step > 1) {
645
+ step--;
646
+ renderStep();
647
+ }
648
+ }
649
+ }, '上一步'), h('button', {
650
+ id: 'btn-next',
651
+ class: 'primary',
652
+ onclick: () => {
653
+ if (step < 3) {
654
+ step++;
655
+ renderStep();
656
+ }
657
+ }
658
+ }, '下一步'));
659
+ }
660
+ function updateFooter(rootEl = null) {
661
+ const scope = rootEl || document;
662
+ const prev = scope.querySelector('#btn-prev');
663
+ const next = scope.querySelector('#btn-next');
664
+ if (prev) prev.disabled = step === 1;
665
+ if (next) next.disabled = step === 3;
666
+ }
667
+ async function saveConfig() {
668
+ let yaml;
669
+ try {
670
+ yaml = await buildYaml();
671
+ } catch (e) {
672
+ document.getElementById('wizard-body')?.append(msg(`保存配置失败:${e.message}`));
673
+ return;
674
+ }
675
+ const blob = new Blob([yaml], {
676
+ type: 'text/yaml'
677
+ });
678
+ const a = document.createElement('a');
679
+ a.href = URL.createObjectURL(blob);
680
+ a.download = 'sqlseed_config.yaml';
681
+ a.click();
682
+ URL.revokeObjectURL(a.href);
683
+ }
684
+ function buildConfig() {
685
+ const result = importedConfig ? copyConfig(importedConfig) : {
686
+ ...(String(store.target).includes('://') ? {
687
+ url: store.target
688
+ } : {
689
+ db_path: store.target
690
+ }),
691
+ ...(connInfo?.provider ? {
692
+ provider: connInfo.provider
693
+ } : {}),
694
+ ...(connInfo?.locale ? {
695
+ locale: connInfo.locale
696
+ } : {})
697
+ };
698
+ const selected = selection();
699
+ result.tables = tablesMeta.filter(tm => selected.get(tm.name)?.size).map(tm => {
700
+ const options = copyConfig(tableConfigs.get(tm.name) || {
701
+ name: tm.name,
702
+ count: 50
703
+ });
704
+ if (countOverride !== null) options.count = countOverride;
705
+ options.columns = tm.columns.filter(c => selected.get(tm.name).has(c.name) && cfg.get(tm.name)?.has(c.name)).map(c => ({
706
+ name: c.name,
707
+ ...copyConfig(cfg.get(tm.name).get(c.name))
708
+ }));
709
+ return options;
710
+ });
711
+ return result;
712
+ }
713
+ async function buildYaml() {
714
+ const res = await post('/api/config/serialize', {
715
+ yaml: JSON.stringify(buildConfig())
716
+ });
717
+ return res.yaml;
718
+ }
719
+ function showImportNotes(body = document.getElementById('wizard-body')) {
720
+ if (!importedConfig) return;
721
+ const notes = [];
722
+ if (importedConfig.associations?.length || importedConfig.custom_column_mappings) {
723
+ notes.push('关联与自定义映射已保留在配置中;这些根设置需使用导出的配置执行。');
724
+ }
725
+ const target = importedConfig.url || importedConfig.db_path;
726
+ if (target && target !== store.target || connInfo?.provider && importedConfig.provider && connInfo.provider !== importedConfig.provider || connInfo?.locale && importedConfig.locale && connInfo.locale !== importedConfig.locale) {
727
+ notes.push('配置中的数据库、数据生成引擎或数据语言与地区与当前连接不同;向导生成使用当前连接,导出保留原设置。');
728
+ }
729
+ if (notes.length) body?.append(msg(notes.join(' '), 'warn'));
730
+ }
731
+ async function loadConfig() {
732
+ const input = document.createElement('input');
733
+ input.type = 'file';
734
+ input.accept = '.yaml,.yml,.json';
735
+ const connId = store.connId;
736
+ const revision = stateRevision;
737
+ input.onchange = async () => {
738
+ const file = input.files[0];
739
+ if (!file) return;
740
+ const text = await file.text();
741
+ try {
742
+ await applyAiYaml(text, connId, revision);
743
+ const body = document.getElementById('wizard-body');
744
+ if (body) body.append(msg('配置已加载并映射到对象树与列属性,可逐列微调。', 'ok'));
745
+ } catch (e) {
746
+ const body = document.getElementById('wizard-body');
747
+ if (body) body.append(msg(`配置无效:${e.message}`));
748
+ }
749
+ };
750
+ input.click();
751
+ }