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.
- sqlseed_web/AGENTS.md +106 -0
- sqlseed_web/__init__.py +24 -0
- sqlseed_web/__main__.py +8 -0
- sqlseed_web/_application.py +205 -0
- sqlseed_web/ai_settings.py +290 -0
- sqlseed_web/api.py +1102 -0
- sqlseed_web/app.py +26 -0
- sqlseed_web/managed_worker.py +184 -0
- sqlseed_web/operation_errors.py +42 -0
- sqlseed_web/plugin_environment.py +231 -0
- sqlseed_web/plugin_management.py +322 -0
- sqlseed_web/plugin_process.py +131 -0
- sqlseed_web/runtime_lifecycle.py +130 -0
- sqlseed_web/runtime_session.py +97 -0
- sqlseed_web/settings_environment.py +368 -0
- sqlseed_web/sqlite_target.py +86 -0
- sqlseed_web/state.py +348 -0
- sqlseed_web/static/AGENTS.md +180 -0
- sqlseed_web/static/ai.css +57 -0
- sqlseed_web/static/configs.css +50 -0
- sqlseed_web/static/date-picker.css +230 -0
- sqlseed_web/static/disclosure.css +149 -0
- sqlseed_web/static/graph-clarity.css +103 -0
- sqlseed_web/static/index.html +29 -0
- sqlseed_web/static/js/api.js +228 -0
- sqlseed_web/static/js/app.js +97 -0
- sqlseed_web/static/js/dropdown.js +432 -0
- sqlseed_web/static/js/filepicker.js +203 -0
- sqlseed_web/static/js/genform.js +1066 -0
- sqlseed_web/static/js/labels.js +195 -0
- sqlseed_web/static/js/pages/browse.js +209 -0
- sqlseed_web/static/js/pages/configs.js +424 -0
- sqlseed_web/static/js/pages/connect.js +332 -0
- sqlseed_web/static/js/pages/heal.js +395 -0
- sqlseed_web/static/js/pages/meta.js +110 -0
- sqlseed_web/static/js/pages/runs.js +293 -0
- sqlseed_web/static/js/pages/settings.js +942 -0
- sqlseed_web/static/js/pages/wizard.js +751 -0
- sqlseed_web/static/js/pages/workbench.js +3123 -0
- sqlseed_web/static/js/tree.js +126 -0
- sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
- sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
- sqlseed_web/static/js/workbench/ai-stream.js +116 -0
- sqlseed_web/static/js/workbench/ai.js +888 -0
- sqlseed_web/static/js/workbench/connection.js +508 -0
- sqlseed_web/static/js/workbench/date-picker.js +445 -0
- sqlseed_web/static/js/workbench/dependency-view.js +119 -0
- sqlseed_web/static/js/workbench/editor.js +1236 -0
- sqlseed_web/static/js/workbench/focus.js +11 -0
- sqlseed_web/static/js/workbench/graph-layout.js +332 -0
- sqlseed_web/static/js/workbench/graph.js +970 -0
- sqlseed_web/static/js/workbench/guidance.js +29 -0
- sqlseed_web/static/js/workbench/model.js +124 -0
- sqlseed_web/static/js/workbench/plugin-management.js +512 -0
- sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
- sqlseed_web/static/js/workbench/preview.js +572 -0
- sqlseed_web/static/js/workbench/provider-guide.js +33 -0
- sqlseed_web/static/js/workbench/recovery.js +28 -0
- sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
- sqlseed_web/static/js/workbench/session.js +174 -0
- sqlseed_web/static/js/workbench/table-data.js +186 -0
- sqlseed_web/static/js/workbench/ui.js +262 -0
- sqlseed_web/static/navigation.css +92 -0
- sqlseed_web/static/preview.css +29 -0
- sqlseed_web/static/runs.css +53 -0
- sqlseed_web/static/scrollbars.css +42 -0
- sqlseed_web/static/settings.css +108 -0
- sqlseed_web/static/style.css +3382 -0
- sqlseed_web/static/table-data.css +27 -0
- sqlseed_web/static/workbench.css +509 -0
- sqlseed_web/supervised_plugins.py +173 -0
- sqlseed_web/supervisor.py +238 -0
- sqlseed_web/workbench.py +381 -0
- sqlseed_web/workbench_ai.py +887 -0
- sqlseed_web/workbench_ai_relations.py +285 -0
- sqlseed_web/workbench_ai_stream.py +172 -0
- sqlseed_web/workbench_data.py +163 -0
- sqlseed_web/workbench_execution.py +199 -0
- sqlseed_web/workbench_runtime.py +1218 -0
- sqlseed_web/workbench_schema.py +277 -0
- sqlseed_web/workbench_store.py +458 -0
- sqlseed_web/worker_control.py +192 -0
- sqlseed_web-0.2.4.dist-info/METADATA +105 -0
- sqlseed_web-0.2.4.dist-info/RECORD +87 -0
- sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
import { h, api } from '../api.js';
|
|
2
|
+
import { genLabel } from '../labels.js';
|
|
3
|
+
import { button, modal } from './ui.js';
|
|
4
|
+
import { fieldAIEligibility } from './ai-eligibility.js';
|
|
5
|
+
import { requestAISuggestions } from './ai-stream.js';
|
|
6
|
+
const prefix = '/api/workbench/ai';
|
|
7
|
+
const copy = value => structuredClone(value);
|
|
8
|
+
const backendLabels = [{
|
|
9
|
+
id: 'openai_compat',
|
|
10
|
+
label: 'OpenAI 兼容服务'
|
|
11
|
+
}, {
|
|
12
|
+
id: 'google_ai_studio',
|
|
13
|
+
label: 'Google AI Studio'
|
|
14
|
+
}, {
|
|
15
|
+
id: 'ollama',
|
|
16
|
+
label: 'Ollama'
|
|
17
|
+
}, {
|
|
18
|
+
id: 'lm_studio',
|
|
19
|
+
label: 'LM Studio'
|
|
20
|
+
}];
|
|
21
|
+
const stageLabels = {
|
|
22
|
+
context: '准备分析上下文',
|
|
23
|
+
model: '等待 AI 模型',
|
|
24
|
+
validation: '校验建议规则',
|
|
25
|
+
preview: '检查只读样例'
|
|
26
|
+
};
|
|
27
|
+
const ruleText = rule => {
|
|
28
|
+
if (rule?.derive_from) {
|
|
29
|
+
return `同一行字段派生 · ${[rule.derive_from].flat().join('、')}`;
|
|
30
|
+
}
|
|
31
|
+
const name = rule?.generator || rule?.generator_name;
|
|
32
|
+
if (!name) {
|
|
33
|
+
return '使用现有自动匹配规则';
|
|
34
|
+
}
|
|
35
|
+
const params = Object.entries(rule.params || {}).filter(([key]) => !key.startsWith('_'));
|
|
36
|
+
return `${genLabel(name)}${params.length ? ' · ' + JSON.stringify(Object.fromEntries(params)) : ''}`;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// The caller owns the document and writes only reviewed patches. This modal
|
|
40
|
+
// captures model identity/epoch/schema before requests and never writes a DB.
|
|
41
|
+
export function openAIAssistant({
|
|
42
|
+
model,
|
|
43
|
+
defaultModes,
|
|
44
|
+
connId,
|
|
45
|
+
columns = [],
|
|
46
|
+
initialScope = 'current',
|
|
47
|
+
initialState = null,
|
|
48
|
+
isCurrent = () => true,
|
|
49
|
+
onApply,
|
|
50
|
+
onClose,
|
|
51
|
+
onSettings
|
|
52
|
+
}) {
|
|
53
|
+
const epoch = model.epoch,
|
|
54
|
+
schemaHash = model.schema.schema_hash;
|
|
55
|
+
let alive = true,
|
|
56
|
+
busy = false,
|
|
57
|
+
loadingSettings = true,
|
|
58
|
+
settingsKnown = false,
|
|
59
|
+
ready = false,
|
|
60
|
+
available = false,
|
|
61
|
+
availabilityStatus = '',
|
|
62
|
+
version = 0,
|
|
63
|
+
controller = null,
|
|
64
|
+
timer = null,
|
|
65
|
+
elapsedTimer = null,
|
|
66
|
+
analysisState = null,
|
|
67
|
+
diagnostics = null;
|
|
68
|
+
const selected = new Set();
|
|
69
|
+
function initialAnalysisScope() {
|
|
70
|
+
if (columns.length) {
|
|
71
|
+
return 'columns';
|
|
72
|
+
} else if (initialScope === 'selected' && model.document.tables.length) {
|
|
73
|
+
return 'selected';
|
|
74
|
+
} else {
|
|
75
|
+
return 'current';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
let suggestions = [],
|
|
79
|
+
scope = initialState?.scope || initialAnalysisScope();
|
|
80
|
+
const currentTable = initialState?.currentTable || model.view.table;
|
|
81
|
+
const tableSelection = new Set(initialState?.tableSelection || model.document.tables.map(table => table.name));
|
|
82
|
+
const eligibility = (table, column) => {
|
|
83
|
+
const rule = model.rule(table.name, column.name),
|
|
84
|
+
mode = defaultModes?.[table.name]?.[column.name];
|
|
85
|
+
return fieldAIEligibility(table, column, mode === undefined ? rule : {
|
|
86
|
+
...rule,
|
|
87
|
+
generator: mode
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
const columnSelection = new Map(model.schema.tables.map(table => [table.name, new Set(initialState?.columnSelection?.[table.name] || (table.name === currentTable ? table.columns.filter(column => (!columns.length || columns.includes(column.name)) && eligibility(table, column).eligible).map(column => column.name) : []))]));
|
|
91
|
+
const ui = modal('AI 配置助手', {
|
|
92
|
+
wide: true,
|
|
93
|
+
onClose: () => {
|
|
94
|
+
alive = false;
|
|
95
|
+
version++;
|
|
96
|
+
controller?.abort();
|
|
97
|
+
clearTimeout(timer);
|
|
98
|
+
clearTimeout(elapsedTimer);
|
|
99
|
+
onClose?.();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
const current = () => alive && isCurrent() && model.epoch === epoch && model.schema.schema_hash === schemaHash;
|
|
103
|
+
const note = h('p', {
|
|
104
|
+
class: 'hint'
|
|
105
|
+
}, 'AI 根据表结构和业务说明提出字段规则建议,支持当前表、多表和指定字段;由你审阅并应用到生成配置。');
|
|
106
|
+
const disclosure = h('p', {
|
|
107
|
+
class: 'scope-notice'
|
|
108
|
+
}, '仅发送表结构、约束、生成器目录和你填写的业务说明给所选 AI 服务;不发送数据库连接地址、凭据或已有记录。');
|
|
109
|
+
const status = h('p', {
|
|
110
|
+
class: 'wb-ai-status',
|
|
111
|
+
role: 'status',
|
|
112
|
+
'aria-live': 'polite'
|
|
113
|
+
}, '正在读取 AI 设置…');
|
|
114
|
+
const phase = h('strong', {
|
|
115
|
+
role: 'status',
|
|
116
|
+
'aria-live': 'polite'
|
|
117
|
+
}),
|
|
118
|
+
elapsed = h('span', {
|
|
119
|
+
'aria-live': 'off'
|
|
120
|
+
});
|
|
121
|
+
const viewProblems = button('查看问题', () => {
|
|
122
|
+
if (diagnostics) {
|
|
123
|
+
diagnostics.open = true;
|
|
124
|
+
diagnostics.scrollIntoView?.({
|
|
125
|
+
block: 'nearest'
|
|
126
|
+
});
|
|
127
|
+
diagnostics.focus();
|
|
128
|
+
}
|
|
129
|
+
}, {
|
|
130
|
+
small: true,
|
|
131
|
+
hidden: true
|
|
132
|
+
});
|
|
133
|
+
const progress = h('div', {
|
|
134
|
+
class: 'wb-ai-progress',
|
|
135
|
+
hidden: true
|
|
136
|
+
}, phase, elapsed, viewProblems);
|
|
137
|
+
const readiness = h('strong', {}, '正在读取 AI 设置');
|
|
138
|
+
const serviceSummary = h('p', {
|
|
139
|
+
'aria-label': 'AI 服务摘要',
|
|
140
|
+
class: 'hint'
|
|
141
|
+
}, '服务信息尚未读取');
|
|
142
|
+
const settingsLink = button('前往设置', () => goSettings(availabilityStatus === 'import_error' ? 'plugins' : 'ai'), {
|
|
143
|
+
small: true
|
|
144
|
+
});
|
|
145
|
+
const readinessCard = h('section', {
|
|
146
|
+
class: 'wb-source-card wb-ai-service-card',
|
|
147
|
+
'aria-label': 'AI 状态'
|
|
148
|
+
}, readiness, status, serviceSummary, settingsLink);
|
|
149
|
+
const installation = h('section', {
|
|
150
|
+
class: 'wb-source-card',
|
|
151
|
+
'aria-label': '安装 AI 插件',
|
|
152
|
+
hidden: true
|
|
153
|
+
});
|
|
154
|
+
const content = h('div', {
|
|
155
|
+
class: 'wb-ai-review'
|
|
156
|
+
});
|
|
157
|
+
const scopes = h('fieldset', {
|
|
158
|
+
class: 'wb-ai-scope wb-ai-scope-selector',
|
|
159
|
+
'aria-describedby': 'ai-scope-help ai-scope-error'
|
|
160
|
+
}, h('legend', {}, '选择要优化的范围'));
|
|
161
|
+
const scopeOptions = h('div', {
|
|
162
|
+
class: 'wb-ai-scope-options'
|
|
163
|
+
});
|
|
164
|
+
scopes.append(scopeOptions);
|
|
165
|
+
const scopeHelp = h('p', {
|
|
166
|
+
id: 'ai-scope-help',
|
|
167
|
+
class: 'hint'
|
|
168
|
+
}, '这里选择的是可修改规则的范围,独立于本次生成范围。未勾选表的建议仅更新草稿。指定字段时,只需勾选要改规则的字段;同表其他字段及必要上游结构仍作为分析上下文。受保护字段不会被修改。');
|
|
169
|
+
const scopeSummary = h('div', {
|
|
170
|
+
class: 'wb-ai-scope-summary',
|
|
171
|
+
role: 'status',
|
|
172
|
+
'aria-live': 'polite'
|
|
173
|
+
});
|
|
174
|
+
const scopeError = h('p', {
|
|
175
|
+
id: 'ai-scope-error',
|
|
176
|
+
class: 'hint',
|
|
177
|
+
role: 'alert'
|
|
178
|
+
});
|
|
179
|
+
const tablePicker = h('fieldset', {
|
|
180
|
+
class: 'wb-ai-targets'
|
|
181
|
+
}, h('legend', {}, '选择要优化的表'));
|
|
182
|
+
const columnPicker = h('div', {
|
|
183
|
+
class: 'wb-ai-targets wb-ai-columns',
|
|
184
|
+
'aria-label': '选择要优化的字段'
|
|
185
|
+
});
|
|
186
|
+
const fieldEntries = [],
|
|
187
|
+
fieldGroups = [];
|
|
188
|
+
let fieldQuery = '';
|
|
189
|
+
const fieldSearch = h('input', {
|
|
190
|
+
type: 'search',
|
|
191
|
+
'aria-label': '查找表或字段',
|
|
192
|
+
'aria-describedby': 'ai-field-search-help',
|
|
193
|
+
placeholder: '表名、字段名或 orders.promised_at',
|
|
194
|
+
oninput: () => {
|
|
195
|
+
if (!alive || busy) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
filterFields();
|
|
199
|
+
updateFieldControls();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
const fieldCount = h('p', {
|
|
203
|
+
class: 'wb-ai-field-count',
|
|
204
|
+
role: 'status',
|
|
205
|
+
'aria-live': 'polite'
|
|
206
|
+
});
|
|
207
|
+
const fieldResults = h('p', {
|
|
208
|
+
class: 'wb-ai-field-results hint'
|
|
209
|
+
});
|
|
210
|
+
const fieldList = h('div', {
|
|
211
|
+
class: 'wb-ai-field-list'
|
|
212
|
+
});
|
|
213
|
+
const fieldEmpty = h('p', {
|
|
214
|
+
class: 'wb-ai-field-empty hint'
|
|
215
|
+
}, '没有匹配的表或字段;已有选择保留,请修改搜索。');
|
|
216
|
+
const selectFiltered = button('选择筛选结果', () => {
|
|
217
|
+
if (!alive || busy) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
let changed = false;
|
|
221
|
+
for (const entry of fieldEntries.filter(entry => entry.input && !entry.row.hidden)) {
|
|
222
|
+
const set = columnSelection.get(entry.table);
|
|
223
|
+
if (!set.has(entry.column)) {
|
|
224
|
+
set.add(entry.column);
|
|
225
|
+
entry.input.checked = true;
|
|
226
|
+
changed = true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (changed) {
|
|
230
|
+
clearReview();
|
|
231
|
+
}
|
|
232
|
+
}, {
|
|
233
|
+
small: true
|
|
234
|
+
});
|
|
235
|
+
selectFiltered.dataset.aiSelectFiltered = '';
|
|
236
|
+
const clearFields = button('清空选择', () => {
|
|
237
|
+
if (!alive || busy) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
for (const set of columnSelection.values()) {
|
|
241
|
+
set.clear();
|
|
242
|
+
}
|
|
243
|
+
for (const entry of fieldEntries) {
|
|
244
|
+
if (entry.input) {
|
|
245
|
+
entry.input.checked = false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
clearReview();
|
|
249
|
+
}, {
|
|
250
|
+
small: true
|
|
251
|
+
});
|
|
252
|
+
clearFields.setAttribute('title', '清空全部已选字段,包括当前筛选之外的字段');
|
|
253
|
+
columnPicker.append(h('label', {
|
|
254
|
+
class: 'wb-ai-field-search'
|
|
255
|
+
}, '查找表或字段', fieldSearch), h('p', {
|
|
256
|
+
id: 'ai-field-search-help',
|
|
257
|
+
class: 'hint'
|
|
258
|
+
}, '搜索仅影响显示;选择结果会保留其他已选字段,清空选择会移除全部字段授权。'), fieldCount, fieldResults, h('div', {
|
|
259
|
+
class: 'wb-ai-actions'
|
|
260
|
+
}, selectFiltered, clearFields), fieldList);
|
|
261
|
+
function clearReview() {
|
|
262
|
+
suggestions = [];
|
|
263
|
+
selected.clear();
|
|
264
|
+
content.replaceChildren();
|
|
265
|
+
diagnostics = null;
|
|
266
|
+
viewProblems.hidden = true;
|
|
267
|
+
progress.hidden = true;
|
|
268
|
+
analysisState = null;
|
|
269
|
+
clearTimeout(elapsedTimer);
|
|
270
|
+
apply.hidden = true;
|
|
271
|
+
update();
|
|
272
|
+
}
|
|
273
|
+
const scopeChoices = [['current', `当前表 · ${currentTable}`, '优化当前正在查看的表,不改变生成勾选。'], ['selected', `已选表 · ${model.document.tables.length} 张`, model.document.tables.length ? '优化左侧已勾选的生成表。' : '尚未勾选生成表;可选择当前表或指定表。'], ['database', `整库 · ${model.schema.tables.length} 张表`, '覆盖数据库中的所有表;未勾选表只更新草稿。'], ['tables', '指定表(多选)', '在下方选择一张或多张表。'], ['columns', '指定字段(多选)', '在下方选择要调整规则的字段,可说明同表关系。']];
|
|
274
|
+
for (const [value, label, description] of scopeChoices) {
|
|
275
|
+
const helpId = `ai-scope-${value}-help`;
|
|
276
|
+
const input = h('input', {
|
|
277
|
+
type: 'radio',
|
|
278
|
+
name: 'ai-scope',
|
|
279
|
+
value,
|
|
280
|
+
'aria-label': label,
|
|
281
|
+
'aria-describedby': helpId,
|
|
282
|
+
checked: scope === value,
|
|
283
|
+
onchange: () => {
|
|
284
|
+
scope = value;
|
|
285
|
+
clearReview();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
if (value === 'selected' && !model.document.tables.length) {
|
|
289
|
+
input.disabled = true;
|
|
290
|
+
}
|
|
291
|
+
scopeOptions.append(h('label', {
|
|
292
|
+
class: 'wb-ai-scope-choice'
|
|
293
|
+
}, input, h('span', {}, h('strong', {}, label), h('small', {
|
|
294
|
+
id: helpId,
|
|
295
|
+
class: 'wb-ai-scope-description'
|
|
296
|
+
}, description))));
|
|
297
|
+
}
|
|
298
|
+
let fieldIndex = 0;
|
|
299
|
+
for (const table of model.schema.tables) {
|
|
300
|
+
const input = h('input', {
|
|
301
|
+
type: 'checkbox',
|
|
302
|
+
'data-ai-table': table.name,
|
|
303
|
+
checked: tableSelection.has(table.name),
|
|
304
|
+
onchange: () => {
|
|
305
|
+
if (input.checked) {
|
|
306
|
+
tableSelection.add(table.name);
|
|
307
|
+
} else {
|
|
308
|
+
tableSelection.delete(table.name);
|
|
309
|
+
}
|
|
310
|
+
clearReview();
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
tablePicker.append(h('label', {}, input, h('span', {
|
|
314
|
+
class: 'mono'
|
|
315
|
+
}, table.name), h('small', {
|
|
316
|
+
class: 'hint'
|
|
317
|
+
}, model.selected(table.name) ? '本次生成' : '仅更新草稿')));
|
|
318
|
+
const group = h('details', {
|
|
319
|
+
'data-ai-field-table': table.name
|
|
320
|
+
}, h('summary', {
|
|
321
|
+
class: 'mono'
|
|
322
|
+
}, table.name));
|
|
323
|
+
group.open = table.name === currentTable;
|
|
324
|
+
const fields = h('fieldset', {}, h('legend', {}, `${table.name} 的字段`));
|
|
325
|
+
const protectedTitle = h('summary', {}, '受保护字段 · 保留现有规则');
|
|
326
|
+
const protectedFields = h('details', {
|
|
327
|
+
class: 'wb-ai-protected-fields'
|
|
328
|
+
}, protectedTitle);
|
|
329
|
+
const entries = [];
|
|
330
|
+
for (const column of table.columns) {
|
|
331
|
+
const permission = eligibility(table, column),
|
|
332
|
+
helpId = `ai-column-help-${fieldIndex++}`;
|
|
333
|
+
const qualified = `${table.name}.${column.name}`;
|
|
334
|
+
let choice = null,
|
|
335
|
+
row;
|
|
336
|
+
if (permission.eligible) {
|
|
337
|
+
choice = h('input', {
|
|
338
|
+
type: 'checkbox',
|
|
339
|
+
'data-ai-column': qualified,
|
|
340
|
+
'aria-describedby': helpId,
|
|
341
|
+
checked: columnSelection.get(table.name).has(column.name),
|
|
342
|
+
onchange: () => {
|
|
343
|
+
const set = columnSelection.get(table.name);
|
|
344
|
+
if (!alive || busy) {
|
|
345
|
+
choice.checked = set.has(column.name);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (choice.checked) {
|
|
349
|
+
set.add(column.name);
|
|
350
|
+
} else {
|
|
351
|
+
set.delete(column.name);
|
|
352
|
+
}
|
|
353
|
+
clearReview();
|
|
354
|
+
}
|
|
355
|
+
});
|
|
356
|
+
row = h('label', {}, choice, h('span', {
|
|
357
|
+
class: 'mono'
|
|
358
|
+
}, column.name), h('small', {
|
|
359
|
+
id: helpId,
|
|
360
|
+
class: 'hint'
|
|
361
|
+
}, column.type));
|
|
362
|
+
fields.append(row);
|
|
363
|
+
} else {
|
|
364
|
+
row = h('div', {
|
|
365
|
+
class: 'wb-ai-protected-field',
|
|
366
|
+
'data-ai-protected-column': qualified
|
|
367
|
+
}, h('span', {
|
|
368
|
+
class: 'mono'
|
|
369
|
+
}, column.name), h('small', {
|
|
370
|
+
class: 'hint'
|
|
371
|
+
}, permission.reason));
|
|
372
|
+
protectedFields.append(row);
|
|
373
|
+
}
|
|
374
|
+
const entry = {
|
|
375
|
+
table: table.name,
|
|
376
|
+
column: column.name,
|
|
377
|
+
key: qualified.toLowerCase(),
|
|
378
|
+
input: choice,
|
|
379
|
+
row
|
|
380
|
+
};
|
|
381
|
+
entries.push(entry);
|
|
382
|
+
fieldEntries.push(entry);
|
|
383
|
+
}
|
|
384
|
+
group.append(fields, protectedFields);
|
|
385
|
+
fieldList.append(group);
|
|
386
|
+
fieldGroups.push({
|
|
387
|
+
group,
|
|
388
|
+
fields,
|
|
389
|
+
protectedFields,
|
|
390
|
+
protectedTitle,
|
|
391
|
+
entries
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
fieldList.append(fieldEmpty);
|
|
395
|
+
filterFields();
|
|
396
|
+
function filterFields() {
|
|
397
|
+
const query = fieldSearch.value.trim().toLowerCase();
|
|
398
|
+
for (const item of fieldGroups) {
|
|
399
|
+
if (!fieldQuery && query) {
|
|
400
|
+
item.savedOpen = item.group.open;
|
|
401
|
+
item.savedProtectedOpen = item.protectedFields.open;
|
|
402
|
+
}
|
|
403
|
+
for (const entry of item.entries) {
|
|
404
|
+
entry.row.hidden = !entry.key.includes(query);
|
|
405
|
+
}
|
|
406
|
+
const visible = item.entries.filter(entry => !entry.row.hidden);
|
|
407
|
+
const protectedCount = visible.filter(entry => !entry.input).length;
|
|
408
|
+
item.group.hidden = !visible.length;
|
|
409
|
+
item.fields.hidden = !visible.some(entry => entry.input);
|
|
410
|
+
item.protectedFields.hidden = !protectedCount;
|
|
411
|
+
item.protectedTitle.textContent = `受保护字段(${protectedCount})· 保留现有规则`;
|
|
412
|
+
if (query) {
|
|
413
|
+
item.group.open = true;
|
|
414
|
+
item.protectedFields.open = true;
|
|
415
|
+
} else if (fieldQuery) {
|
|
416
|
+
item.group.open = item.savedOpen;
|
|
417
|
+
item.protectedFields.open = item.savedProtectedOpen;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
fieldQuery = query;
|
|
421
|
+
fieldEmpty.hidden = fieldEntries.some(entry => !entry.row.hidden);
|
|
422
|
+
}
|
|
423
|
+
function updateFieldControls() {
|
|
424
|
+
const editable = fieldEntries.filter(entry => entry.input),
|
|
425
|
+
matches = editable.filter(entry => !entry.row.hidden);
|
|
426
|
+
const chosen = editable.filter(entry => columnSelection.get(entry.table).has(entry.column));
|
|
427
|
+
const matchedChosen = chosen.filter(entry => !entry.row.hidden).length;
|
|
428
|
+
const protectedCount = fieldEntries.filter(entry => !entry.input && !entry.row.hidden).length;
|
|
429
|
+
fieldCount.textContent = `已选 ${chosen.length} 个字段允许修改 · 筛选内 ${matchedChosen},其他 ${chosen.length - matchedChosen}`;
|
|
430
|
+
fieldResults.textContent = `${fieldQuery ? '筛选结果' : '全部表'}:${matches.length} 个可选字段${protectedCount ? "," + protectedCount + " 个受保护字段仅作上下文" : ''}。`;
|
|
431
|
+
selectFiltered.textContent = `${fieldQuery ? '选择筛选结果' : '选择全部可选字段'}(${matches.length})`;
|
|
432
|
+
selectFiltered.disabled = busy || matches.length === matchedChosen;
|
|
433
|
+
clearFields.disabled = busy || !chosen.length;
|
|
434
|
+
fieldSearch.disabled = busy;
|
|
435
|
+
}
|
|
436
|
+
const business = h('textarea', {
|
|
437
|
+
'aria-label': '业务说明',
|
|
438
|
+
'aria-describedby': 'ai-business-help',
|
|
439
|
+
rows: 3,
|
|
440
|
+
maxlength: 4000,
|
|
441
|
+
placeholder: '例如:姓名使用中文;订单总额 = 数量 × 单价;完成日期在创建日期后 7 天。',
|
|
442
|
+
oninput: clearReview
|
|
443
|
+
});
|
|
444
|
+
const businessField = h('label', {
|
|
445
|
+
class: 'wb-ai-business'
|
|
446
|
+
}, '业务说明(可选)', business, h('small', {
|
|
447
|
+
id: 'ai-business-help',
|
|
448
|
+
class: 'hint'
|
|
449
|
+
}, '说明数据含义、范围和字段关系。请填写业务规则,不要填写密钥或真实个人记录。'));
|
|
450
|
+
business.value = initialState?.businessContext || '';
|
|
451
|
+
const analyze = button('开始分析', analyzeScope, {
|
|
452
|
+
primary: true
|
|
453
|
+
});
|
|
454
|
+
const apply = button('应用所选建议', applySuggestions, {
|
|
455
|
+
primary: true,
|
|
456
|
+
disabled: true
|
|
457
|
+
});
|
|
458
|
+
const analysis = h('div', {
|
|
459
|
+
class: 'wb-ai-analysis',
|
|
460
|
+
hidden: true
|
|
461
|
+
}, disclosure, scopes, tablePicker, columnPicker, scopeSummary, scopeHelp, scopeError, businessField, content);
|
|
462
|
+
ui.body.append(note, readinessCard, installation, analysis);
|
|
463
|
+
ui.actions.append(progress, button('取消', ui.close), analyze, apply);
|
|
464
|
+
apply.hidden = true;
|
|
465
|
+
function updateElapsed() {
|
|
466
|
+
if (analysisState) {
|
|
467
|
+
elapsed.textContent = `已耗时 ${Math.max(0, Math.floor(((analysisState.finished ?? Date.now()) - analysisState.started) / 1000))} 秒`;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function tickElapsed() {
|
|
471
|
+
updateElapsed();
|
|
472
|
+
if (alive && analysisState?.finished === null) {
|
|
473
|
+
elapsedTimer = setTimeout(tickElapsed, 1000);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function finishProgress(message) {
|
|
477
|
+
if (!analysisState) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
analysisState.finished = Date.now();
|
|
481
|
+
clearTimeout(elapsedTimer);
|
|
482
|
+
phase.textContent = message;
|
|
483
|
+
updateElapsed();
|
|
484
|
+
}
|
|
485
|
+
function failedAnalysis(error) {
|
|
486
|
+
const expired = !current();
|
|
487
|
+
const missing = error.code === 'unknown_connection' || (error.status === 404 || error.code === 'not_found') && /unknown connection/i.test(error.message);
|
|
488
|
+
let message;
|
|
489
|
+
if (expired) {
|
|
490
|
+
message = '配置已变化,请关闭面板后重新分析。';
|
|
491
|
+
} else if (missing) {
|
|
492
|
+
message = '数据库连接已失效,Web 服务重启后需要重新连接原数据库,再打开 AI 助手;当前配置未改变。';
|
|
493
|
+
} else {
|
|
494
|
+
message = error.message;
|
|
495
|
+
}
|
|
496
|
+
status.textContent = message;
|
|
497
|
+
showDiagnostics({
|
|
498
|
+
issues: [message]
|
|
499
|
+
});
|
|
500
|
+
const summary = message.length > 180 ? `${message.slice(0, 177)}…` : message;
|
|
501
|
+
function analysisFailureStatus() {
|
|
502
|
+
if (expired) {
|
|
503
|
+
return `分析已失效 · ${summary}`;
|
|
504
|
+
} else {
|
|
505
|
+
return `${stageLabels[analysisState.stage]}${error.code === 'ai_timeout' ? '超时' : '失败'} · ${summary}`;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
finishProgress(analysisFailureStatus());
|
|
509
|
+
}
|
|
510
|
+
function showDiagnostics(result) {
|
|
511
|
+
const issues = [...(Array.isArray(result.validation?.issues) ? result.validation.issues : []), ...(Array.isArray(result.issues) ? result.issues : [])];
|
|
512
|
+
const reasons = issues.map(issue => {
|
|
513
|
+
if (typeof issue === 'string') {
|
|
514
|
+
return issue;
|
|
515
|
+
} else {
|
|
516
|
+
return [issue.table, issue.column].filter(Boolean).join('.') + (issue.message ? `:${issue.message}` : '');
|
|
517
|
+
}
|
|
518
|
+
});
|
|
519
|
+
if (Array.isArray(result.rejected)) {
|
|
520
|
+
reasons.push(...result.rejected.map(item => typeof item === 'string' ? item : item.message || item.reason || '建议未通过检查'));
|
|
521
|
+
}
|
|
522
|
+
if (!reasons.length) {
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
diagnostics = h('details', {
|
|
526
|
+
class: 'wb-ai-diagnostics',
|
|
527
|
+
tabindex: -1
|
|
528
|
+
}, h('summary', {}, `检查详情 · ${reasons.length} 项`), h('ul', {}, ...reasons.map(reason => h('li', {}, reason))));
|
|
529
|
+
diagnostics.open = true;
|
|
530
|
+
content.append(diagnostics);
|
|
531
|
+
viewProblems.hidden = false;
|
|
532
|
+
}
|
|
533
|
+
function update() {
|
|
534
|
+
const importError = availabilityStatus === 'import_error';
|
|
535
|
+
updateServiceStatus();
|
|
536
|
+
installation.hidden = !settingsKnown || available;
|
|
537
|
+
analysis.hidden = !available;
|
|
538
|
+
analyze.hidden = !available;
|
|
539
|
+
settingsLink.hidden = settingsKnown && !available && !importError;
|
|
540
|
+
settingsLink.disabled = busy || loadingSettings;
|
|
541
|
+
if (importError) {
|
|
542
|
+
settingsLink.textContent = '查看插件状态';
|
|
543
|
+
} else if (ready) {
|
|
544
|
+
settingsLink.textContent = '更改设置';
|
|
545
|
+
} else {
|
|
546
|
+
settingsLink.textContent = '前往设置';
|
|
547
|
+
}
|
|
548
|
+
const targets = allowedTargets(),
|
|
549
|
+
names = analysisTables();
|
|
550
|
+
const count = targets.reduce((total, target) => total + target.columns.length, 0);
|
|
551
|
+
const protectedCount = model.schema.tables.filter(table => names.includes(table.name)).reduce((total, table) => total + table.columns.filter(column => !eligibility(table, column).eligible).length, 0);
|
|
552
|
+
const draftCount = names.filter(name => !model.selected(name)).length;
|
|
553
|
+
scopeSummary.replaceChildren(h('strong', {}, `${scopeChoices.find(choice => choice[0] === scope)[1]} · ${count} 个字段可优化`), h('p', {}, `${names.length} 张表作为分析上下文${protectedCount ? "," + protectedCount + " 个受保护字段保留现有规则" : ''}。${draftCount ? "" + draftCount + " 张表未加入生成范围,建议仅更新草稿。" : ''}`));
|
|
554
|
+
analyze.disabled = busy || !available || !ready || !targets.length;
|
|
555
|
+
apply.disabled = busy || selected.size === 0;
|
|
556
|
+
for (const input of scopes.querySelectorAll('input')) {
|
|
557
|
+
input.disabled = busy || input.value === 'selected' && !model.document.tables.length;
|
|
558
|
+
}
|
|
559
|
+
for (const input of [...tablePicker.querySelectorAll('input'), ...columnPicker.querySelectorAll('input')]) {
|
|
560
|
+
input.disabled = busy;
|
|
561
|
+
}
|
|
562
|
+
updateFieldControls();
|
|
563
|
+
business.disabled = busy;
|
|
564
|
+
tablePicker.hidden = scope !== 'tables';
|
|
565
|
+
columnPicker.hidden = scope !== 'columns';
|
|
566
|
+
scopeError.textContent = targets.length ? '' : '请至少选择一张表或一个可由 AI 调整的字段;受保护字段仅作为结构上下文。';
|
|
567
|
+
function updateServiceStatus() {
|
|
568
|
+
if (loadingSettings) {
|
|
569
|
+
readiness.textContent = '正在读取 AI 设置';
|
|
570
|
+
} else if (!settingsKnown) {
|
|
571
|
+
readiness.textContent = 'AI 状态未获取';
|
|
572
|
+
} else if (!available) {
|
|
573
|
+
if (importError) {
|
|
574
|
+
readiness.textContent = 'AI 插件加载异常';
|
|
575
|
+
} else {
|
|
576
|
+
readiness.textContent = 'AI 扩展未安装';
|
|
577
|
+
}
|
|
578
|
+
} else if (ready) {
|
|
579
|
+
readiness.textContent = 'AI 配置已填写';
|
|
580
|
+
} else {
|
|
581
|
+
readiness.textContent = 'AI 待配置';
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function analysisTables() {
|
|
586
|
+
if (scope === 'current') {
|
|
587
|
+
return [currentTable];
|
|
588
|
+
} else if (scope === 'selected') {
|
|
589
|
+
return model.document.tables.map(table => table.name);
|
|
590
|
+
} else if (scope === 'tables') {
|
|
591
|
+
return [...tableSelection];
|
|
592
|
+
} else if (scope === 'columns') {
|
|
593
|
+
return model.schema.tables.filter(table => columnSelection.get(table.name).size).map(table => table.name);
|
|
594
|
+
} else {
|
|
595
|
+
return model.schema.tables.map(table => table.name);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function allowedTargets() {
|
|
599
|
+
const names = analysisTables();
|
|
600
|
+
return model.schema.tables.filter(table => names.includes(table.name)).map(table => ({
|
|
601
|
+
table: table.name,
|
|
602
|
+
columns: table.columns.filter(column => eligibility(table, column).eligible && (scope !== 'columns' || columnSelection.get(table.name).has(column.name))).map(column => column.name)
|
|
603
|
+
})).filter(target => target.columns.length);
|
|
604
|
+
}
|
|
605
|
+
function goSettings(section) {
|
|
606
|
+
if (!current() || busy) {
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
const context = {
|
|
610
|
+
scope,
|
|
611
|
+
currentTable,
|
|
612
|
+
tableSelection: [...tableSelection],
|
|
613
|
+
columnSelection: Object.fromEntries([...columnSelection].map(([table, values]) => [table, [...values]])),
|
|
614
|
+
businessContext: business.value
|
|
615
|
+
};
|
|
616
|
+
ui.close();
|
|
617
|
+
onSettings?.({
|
|
618
|
+
section,
|
|
619
|
+
context
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
function showSettings(config) {
|
|
623
|
+
settingsKnown = true;
|
|
624
|
+
available = Boolean(config.available);
|
|
625
|
+
ready = available && Boolean(config.ready);
|
|
626
|
+
availabilityStatus = config.availability_status || (available ? 'available' : 'not_installed');
|
|
627
|
+
const effective = config.effective || {};
|
|
628
|
+
// Only the known provider label and model ID are displayed. Never render
|
|
629
|
+
// endpoint URLs or arbitrary returned credential fields in this summary.
|
|
630
|
+
const backend = backendLabels.find(item => item.id === effective.backend)?.label || 'AI 服务';
|
|
631
|
+
serviceSummary.textContent = available ? `${backend} · ${effective.model || '尚未选择模型'}` : '';
|
|
632
|
+
if (!available) {
|
|
633
|
+
if (availabilityStatus === 'import_error') {
|
|
634
|
+
status.textContent = 'AI 扩展已安装但加载异常,规则建议与分析不可用。';
|
|
635
|
+
} else {
|
|
636
|
+
status.textContent = 'AI 扩展未安装,规则建议与分析不可用。';
|
|
637
|
+
}
|
|
638
|
+
} else {
|
|
639
|
+
status.textContent = config.message || (ready ? '配置已填写,可开始分析。' : '请前往设置页完成 AI 服务配置。');
|
|
640
|
+
}
|
|
641
|
+
if (!available) {
|
|
642
|
+
showUnavailableAI();
|
|
643
|
+
}
|
|
644
|
+
update();
|
|
645
|
+
function showUnavailableAI() {
|
|
646
|
+
const broken = availabilityStatus === 'import_error';
|
|
647
|
+
installation.setAttribute('aria-label', broken ? '修复 AI 插件' : '安装 AI 插件');
|
|
648
|
+
installation.replaceChildren(h('p', {}, broken ? '请在“设置 → 插件与版本”查看异常信息,处理后返回 AI 助手。' : '请在“设置 → 插件与版本”安装 AI 扩展,完成后返回 AI 助手。'), h('p', {
|
|
649
|
+
class: 'hint'
|
|
650
|
+
}, '当前生成配置保留,仍可手动调整规则、预览和生成数据。'), ...(!broken ? [button('前往插件设置', () => goSettings('plugins'), {
|
|
651
|
+
small: true
|
|
652
|
+
})] : []));
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
async function analyzeScope() {
|
|
656
|
+
if (!current()) {
|
|
657
|
+
status.textContent = '配置已变化,请关闭面板后重新分析。';
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
if (busy || !available || !ready) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
const targets = allowedTargets(),
|
|
664
|
+
names = analysisTables();
|
|
665
|
+
if (!names.length || !targets.length) {
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const document = copy(model.document);
|
|
669
|
+
const table_drafts = copy(Object.values(model.view.tableDrafts || {}).filter(table => !model.selected(table.name)));
|
|
670
|
+
const request = {
|
|
671
|
+
conn_id: connId,
|
|
672
|
+
schema_hash: schemaHash,
|
|
673
|
+
tables: names,
|
|
674
|
+
allowed_targets: targets,
|
|
675
|
+
business_context: business.value.trim(),
|
|
676
|
+
table_drafts,
|
|
677
|
+
document
|
|
678
|
+
};
|
|
679
|
+
busy = true;
|
|
680
|
+
const ticket = ++version;
|
|
681
|
+
selected.clear();
|
|
682
|
+
suggestions = [];
|
|
683
|
+
content.replaceChildren();
|
|
684
|
+
diagnostics = null;
|
|
685
|
+
viewProblems.hidden = true;
|
|
686
|
+
apply.hidden = true;
|
|
687
|
+
controller = new AbortController();
|
|
688
|
+
analysisState = {
|
|
689
|
+
started: Date.now(),
|
|
690
|
+
finished: null,
|
|
691
|
+
stage: 'context'
|
|
692
|
+
};
|
|
693
|
+
progress.hidden = false;
|
|
694
|
+
phase.textContent = '正在提交分析请求…';
|
|
695
|
+
tickElapsed();
|
|
696
|
+
update();
|
|
697
|
+
status.textContent = '正在准备分析上下文…';
|
|
698
|
+
const requestTimer = setTimeout(() => {
|
|
699
|
+
if (alive && ticket === version) {
|
|
700
|
+
controller.abort();
|
|
701
|
+
version++;
|
|
702
|
+
busy = false;
|
|
703
|
+
const error = new Error('AI 分析超过 180 秒,请检查失败阶段后重试;当前规则未改变。');
|
|
704
|
+
error.code = 'ai_timeout';
|
|
705
|
+
failedAnalysis(error);
|
|
706
|
+
update();
|
|
707
|
+
}
|
|
708
|
+
}, 180000);
|
|
709
|
+
timer = requestTimer;
|
|
710
|
+
try {
|
|
711
|
+
const result = await requestAISuggestions(`${prefix}/suggest`, request, {
|
|
712
|
+
signal: controller.signal,
|
|
713
|
+
onProgress: event => {
|
|
714
|
+
if (!alive || ticket !== version) {
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
if (!current()) {
|
|
718
|
+
controller.abort();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
analysisState.stage = event.stage;
|
|
722
|
+
analysisState.hasProgress = true;
|
|
723
|
+
phase.textContent = `${stageLabels[event.stage]} · ${event.message}`;
|
|
724
|
+
status.textContent = event.message;
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
if (!alive || ticket !== version) {
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
if (!current()) {
|
|
731
|
+
throw new Error('配置已变化,请关闭面板后重新分析。');
|
|
732
|
+
}
|
|
733
|
+
if (result.schema_hash !== schemaHash) {
|
|
734
|
+
throw new Error('数据库结构已变化,请刷新后重新分析。');
|
|
735
|
+
}
|
|
736
|
+
if (!Array.isArray(result.suggestions)) {
|
|
737
|
+
throw new TypeError('AI 建议格式不正确,请重试。');
|
|
738
|
+
}
|
|
739
|
+
acceptSuggestions(result);
|
|
740
|
+
renderSuggestionGroups();
|
|
741
|
+
apply.hidden = !suggestions.length;
|
|
742
|
+
} catch (error) {
|
|
743
|
+
if (alive && ticket === version) {
|
|
744
|
+
failedAnalysis(error);
|
|
745
|
+
}
|
|
746
|
+
} finally {
|
|
747
|
+
clearTimeout(requestTimer);
|
|
748
|
+
if (alive && ticket === version) {
|
|
749
|
+
clearTimeout(elapsedTimer);
|
|
750
|
+
busy = false;
|
|
751
|
+
update();
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
function renderSuggestionGroups() {
|
|
755
|
+
const groups = new Map();
|
|
756
|
+
suggestions.forEach((item, index) => {
|
|
757
|
+
const id = item.group_id || `single-${index}`;
|
|
758
|
+
if (!groups.has(id)) {
|
|
759
|
+
groups.set(id, []);
|
|
760
|
+
}
|
|
761
|
+
groups.get(id).push(index);
|
|
762
|
+
});
|
|
763
|
+
for (const indices of groups.values()) {
|
|
764
|
+
const input = h('input', {
|
|
765
|
+
type: 'checkbox',
|
|
766
|
+
'data-ai-suggestion': String(indices[0]),
|
|
767
|
+
onchange: () => {
|
|
768
|
+
for (const index of indices) {
|
|
769
|
+
if (input.checked) {
|
|
770
|
+
selected.add(index);
|
|
771
|
+
} else {
|
|
772
|
+
selected.delete(index);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
update();
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
const article = h('article', {
|
|
779
|
+
class: 'wb-ai-suggestion'
|
|
780
|
+
}, h('label', {}, input, h('strong', {}, indices.length > 1 ? `关联规则 · ${indices.length} 个字段,一起应用` : `${suggestions[indices[0]].table}.${suggestions[indices[0]].column}`)));
|
|
781
|
+
for (const index of indices) {
|
|
782
|
+
appendSuggestionDetails(article, index, indices);
|
|
783
|
+
}
|
|
784
|
+
content.append(article);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
function appendSuggestionDetails(article, index, indices) {
|
|
788
|
+
const item = suggestions[index],
|
|
789
|
+
currentRule = model.rule(item.table, item.column);
|
|
790
|
+
if (indices.length > 1) {
|
|
791
|
+
article.append(h('h4', {
|
|
792
|
+
class: 'mono'
|
|
793
|
+
}, `${item.table}.${item.column}`));
|
|
794
|
+
}
|
|
795
|
+
if (!model.selected(item.table)) {
|
|
796
|
+
article.append(h('p', {
|
|
797
|
+
class: 'hint'
|
|
798
|
+
}, '该表未加入生成范围;应用后仅更新其草稿。'));
|
|
799
|
+
}
|
|
800
|
+
article.append(h('p', {}, item.reason || '请结合业务含义确认。'), h('div', {
|
|
801
|
+
class: 'wb-ai-diff'
|
|
802
|
+
}, h('div', {}, h('small', {}, '当前规则'), h('p', {}, ruleText(currentRule))), h('div', {}, h('small', {}, '建议规则'), h('p', {}, ruleText(item.after)))));
|
|
803
|
+
if (item.relation) {
|
|
804
|
+
const labels = {
|
|
805
|
+
copy: '复制',
|
|
806
|
+
concat: '按顺序拼接',
|
|
807
|
+
product: '相乘',
|
|
808
|
+
date_offset: '日期偏移'
|
|
809
|
+
};
|
|
810
|
+
article.append(h('p', {
|
|
811
|
+
class: 'wb-ai-relation'
|
|
812
|
+
}, `${item.relation.sources.join(' + ')} → ${item.column} · ${labels[item.relation.template] || '同一行关系'}${Object.keys(item.relation.options || {}).length ? ' · ' + JSON.stringify(item.relation.options) : ''}`));
|
|
813
|
+
if (item.evidence) {
|
|
814
|
+
article.append(h('p', {
|
|
815
|
+
class: 'hint'
|
|
816
|
+
}, item.evidence.message));
|
|
817
|
+
const rows = item.evidence.rows || [];
|
|
818
|
+
if (rows.length) {
|
|
819
|
+
const names = Object.keys(rows[0]);
|
|
820
|
+
article.append(h('div', {
|
|
821
|
+
class: 'wb-ai-evidence'
|
|
822
|
+
}, h('table', {}, h('caption', {}, '同一行关系的只读样例'), h('thead', {}, h('tr', {}, ...names.map(name => h('th', {
|
|
823
|
+
scope: 'col'
|
|
824
|
+
}, name)))), h('tbody', {}, ...rows.map(row => h('tr', {}, ...names.map(name => h('td', {}, row[name] === null ? 'NULL' : String(row[name])))))))));
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
function acceptSuggestions(result) {
|
|
830
|
+
const valid = item => targets.some(target => target.table === item.table && target.columns.includes(item.column)) && item.after?.name === item.column;
|
|
831
|
+
const invalidGroups = new Set(result.suggestions.filter(item => !valid(item)).map(item => item.group_id).filter(Boolean));
|
|
832
|
+
suggestions = result.validation?.ok === false ? [] : result.suggestions.filter(item => valid(item) && !invalidGroups.has(item.group_id));
|
|
833
|
+
status.textContent = suggestions.length ? `收到 ${suggestions.length} 条建议。存在关联的规则会作为一组应用,请对比后勾选。` : result.validation?.message || '没有可应用的建议,现有规则保持不变。';
|
|
834
|
+
const analysisCompletionLabel = () => {
|
|
835
|
+
if (result.validation?.ok === false) {
|
|
836
|
+
return "" + (analysisState.hasProgress ? stageLabels[analysisState.stage] : '候选检查') + "未通过";
|
|
837
|
+
} else {
|
|
838
|
+
return '分析完成';
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
finishProgress(`${analysisCompletionLabel()} · ${status.textContent}`);
|
|
842
|
+
showDiagnostics(result);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
async function applySuggestions() {
|
|
846
|
+
if (!current()) {
|
|
847
|
+
status.textContent = '配置已变化,请关闭面板后重新分析。';
|
|
848
|
+
apply.disabled = true;
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (!selected.size || busy) {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
busy = true;
|
|
855
|
+
update();
|
|
856
|
+
try {
|
|
857
|
+
await onApply?.([...selected].map(index => copy(suggestions[index])));
|
|
858
|
+
if (alive) {
|
|
859
|
+
ui.close();
|
|
860
|
+
}
|
|
861
|
+
} catch (error) {
|
|
862
|
+
if (alive) {
|
|
863
|
+
status.textContent = error.message;
|
|
864
|
+
busy = false;
|
|
865
|
+
update();
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
api(`${prefix}/config`).then(config => {
|
|
870
|
+
if (alive) {
|
|
871
|
+
showSettings(config);
|
|
872
|
+
}
|
|
873
|
+
}).catch(error => {
|
|
874
|
+
if (alive) {
|
|
875
|
+
status.textContent = error.message;
|
|
876
|
+
}
|
|
877
|
+
}).finally(() => {
|
|
878
|
+
if (alive) {
|
|
879
|
+
loadingSettings = false;
|
|
880
|
+
update();
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
update();
|
|
884
|
+
return {
|
|
885
|
+
...ui,
|
|
886
|
+
destroy: ui.close
|
|
887
|
+
};
|
|
888
|
+
}
|