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,29 @@
|
|
|
1
|
+
// Guidance describes evidence already available to the workbench. It never
|
|
2
|
+
// changes rules, starts AI, or grants permission to write data.
|
|
3
|
+
export function nextStep(model, previews = new Map()) {
|
|
4
|
+
const tables=model.document.tables;
|
|
5
|
+
const scope=`已选 ${tables.length} 张表 · 计划生成 ${tables.reduce((sum,table)=>sum+table.count,0)} 行`;
|
|
6
|
+
if(!tables.length)return {scope:'尚未选择生成表',title:'先选择要生成的表',body:'在左侧勾选本次需要生成数据的表,再设置每张表的生成数量。浏览表名不会加入生成范围。',action:'select',label:'选择生成表'};
|
|
7
|
+
if(model.errors.size)return {scope,title:'先修正无效输入',body:[...model.errors.values()].join(';'),action:'edit',label:'检查输入'};
|
|
8
|
+
if(model.check?.issues?.some(issue=>issue.severity==='error'))return {scope,title:'先处理检查发现的问题',body:'查看具体字段、引用来源和处理建议,修正后重新检查。',action:'check',label:'查看检查问题'};
|
|
9
|
+
const current=new Set(tables.filter(table=>{
|
|
10
|
+
const preview=previews.get(table.name);
|
|
11
|
+
return preview?.epoch===model.epoch && preview.result.ok && preview.result.preview_complete!==false
|
|
12
|
+
&& Array.isArray(preview.result.samples?.[table.name]) && preview.result.samples[table.name].length>0;
|
|
13
|
+
}).map(table=>table.name));
|
|
14
|
+
if(current.size===tables.length)return {scope,title:`已预览所选 ${tables.length} 张表`,body:'请确认样例符合业务要求。下一步查看写入计划,系统会重新检查依赖;确认后才写入数据库。',action:'generate',label:'查看生成计划'};
|
|
15
|
+
const applied=model.aiApplied?.epoch===model.epoch?model.aiApplied.targets.filter(item=>tables.some(table=>table.name===item.table)):[];
|
|
16
|
+
if(applied.some(item=>!current.has(item.table)))return {scope,title:`已应用 ${applied.length} 条 AI 建议,请预览`,body:'建议已进入当前生成配置。请查看实际样例,核对业务要求是否都已覆盖;AI 建议不代表数据库写入一定成功。',action:'preview',label:'预览 AI 调整结果'};
|
|
17
|
+
if(current.size)return {scope,title:`已预览 ${current.size}/${tables.length} 张所选表`,body:'可以继续查看其余表的样例,再核对写入计划。预览不会写入数据库。',action:'preview',label:'预览已选范围'};
|
|
18
|
+
if(tables.some(table=>previews.get(table.name)?.epoch<model.epoch))return {scope,title:'配置已变化,请重新预览',body:'已有样例对应之前的规则或生成范围。重新查看样例,确认本次配置的实际效果。',action:'preview',label:'重新预览'};
|
|
19
|
+
if(tables.some(table=>previews.get(table.name)?.epoch===model.epoch))return {scope,title:'部分样例暂不可用',body:'查看预览中的具体原因;依赖尚未生成的父键时,可先核对依赖计划。',action:'check',label:'检查依赖'};
|
|
20
|
+
return {scope,title:'先确认规则是否符合业务',body:'检查数值范围、日期和字段含义。可以手动调整,也可让 AI 根据业务说明建议规则;已有配置合适时可直接预览。',action:'preview',label:'直接预览'};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function guideAIState(config) {
|
|
24
|
+
if(config?.availability_status==='import_error')return {label:'检查 AI 插件',status:'插件加载异常 · 规则建议不可用'};
|
|
25
|
+
if(config?.available===false)return {label:'安装 AI 扩展',status:'AI 扩展未安装 · 规则建议不可用'};
|
|
26
|
+
if(config?.available && !config.ready)return {label:'配置 AI 助手',status:'可选 · 需配置服务'};
|
|
27
|
+
if(config?.ready)return {label:'用 AI 建议规则',status:'可选 · 服务配置已填写'};
|
|
28
|
+
return {label:'查看 AI 助手',status:'可选 · 状态待确认'};
|
|
29
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// 唯一可执行文档;视图和未勾选表的编辑草稿单独持久化。
|
|
2
|
+
const copy = value => structuredClone(value);
|
|
3
|
+
|
|
4
|
+
export class WorkbenchDocument {
|
|
5
|
+
constructor(schema, document) {
|
|
6
|
+
this.schema = schema;
|
|
7
|
+
this.document = copy(document || {provider:schema.provider, locale:schema.locale, tables:[]});
|
|
8
|
+
this.view = {table:schema.tables[0]?.name || '', page:'fields', graphMode:'all', tableDrafts:{}};
|
|
9
|
+
this.errors = new Map();
|
|
10
|
+
this.epoch = 0;
|
|
11
|
+
this.savedEpoch = -1;
|
|
12
|
+
this.lifecycleVersion = 0;
|
|
13
|
+
this.saved = null;
|
|
14
|
+
this.check = null;
|
|
15
|
+
this.samples = {};
|
|
16
|
+
this.previewIssues = [];
|
|
17
|
+
}
|
|
18
|
+
get dirty() { return this.epoch !== this.savedEpoch; }
|
|
19
|
+
touch() { this.epoch++; this.check = null; this.samples = {}; this.previewIssues = []; }
|
|
20
|
+
restoreView(view) {
|
|
21
|
+
this.view = {...this.view, ...copy(view || {})};
|
|
22
|
+
if (!this.schema.tables.some(t=>t.name===this.view.table)) this.view.table=this.schema.tables[0]?.name || '';
|
|
23
|
+
}
|
|
24
|
+
selectTable(name, page='fields') {
|
|
25
|
+
this.view.table = name;
|
|
26
|
+
this.view.page = page;
|
|
27
|
+
if (page==='graph') this.view.graphMode='paths';
|
|
28
|
+
}
|
|
29
|
+
table(name) {
|
|
30
|
+
return this.document.tables.find(t=>t.name===name)
|
|
31
|
+
|| this.view.tableDrafts[name] || {name,count:100,columns:[]};
|
|
32
|
+
}
|
|
33
|
+
selected(name) { return this.document.tables.some(t=>t.name===name); }
|
|
34
|
+
putTable(table) {
|
|
35
|
+
const index=this.document.tables.findIndex(t=>t.name===table.name);
|
|
36
|
+
if(index>=0) this.document.tables[index]=copy(table);
|
|
37
|
+
else this.view.tableDrafts[table.name]=copy(table);
|
|
38
|
+
this.touch();
|
|
39
|
+
}
|
|
40
|
+
toggleTable(name,selected) {
|
|
41
|
+
if(this.selected(name)===selected) return;
|
|
42
|
+
const table=copy(this.table(name));
|
|
43
|
+
this.document.tables=this.document.tables.filter(t=>t.name!==name);
|
|
44
|
+
if(selected) { this.document.tables.push(table); delete this.view.tableDrafts[name]; }
|
|
45
|
+
else this.view.tableDrafts[name]=table;
|
|
46
|
+
this.touch();
|
|
47
|
+
}
|
|
48
|
+
setCount(name,text) {
|
|
49
|
+
const key=`count:${name}`;
|
|
50
|
+
if(!/^\d+$/.test(String(text)) || !Number.isSafeInteger(Number(text)) || Number(text)<1) {
|
|
51
|
+
this.setError(key,'生成数量必须是大于 0 的整数'); return false;
|
|
52
|
+
}
|
|
53
|
+
this.errors.delete(key);
|
|
54
|
+
this.putTable({...this.table(name),count:Number(text)});
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
setError(key,error) {
|
|
58
|
+
if(error) this.errors.set(key,error); else this.errors.delete(key);
|
|
59
|
+
this.touch();
|
|
60
|
+
}
|
|
61
|
+
setColumn(tableName,column,config) {
|
|
62
|
+
const table=copy(this.table(tableName));
|
|
63
|
+
table.columns=(table.columns || []).filter(c=>c.name!==column);
|
|
64
|
+
if(config) table.columns.push({...copy(config),name:column});
|
|
65
|
+
this.errors.delete(`column:${tableName}.${column}`);
|
|
66
|
+
this.putTable(table);
|
|
67
|
+
}
|
|
68
|
+
applyPatches(patches) {
|
|
69
|
+
const tables=copy(this.document.tables), drafts=copy(this.view.tableDrafts), seen=new Set();
|
|
70
|
+
for(const patch of patches) {
|
|
71
|
+
const key=`${patch.table}.${patch.column}`;
|
|
72
|
+
if(seen.has(key) || !this.schema.tables.find(t=>t.name===patch.table)?.columns?.some(c=>c.name===patch.column))
|
|
73
|
+
throw new Error('建议字段已失效或重复,请重新分析');
|
|
74
|
+
seen.add(key);
|
|
75
|
+
const index=tables.findIndex(t=>t.name===patch.table);
|
|
76
|
+
const table=copy(index>=0?tables[index]:drafts[patch.table] || this.table(patch.table));
|
|
77
|
+
table.columns=(table.columns || []).filter(c=>c.name!==patch.column);
|
|
78
|
+
if(patch.after)table.columns.push({...copy(patch.after),name:patch.column});
|
|
79
|
+
if(index>=0)tables[index]=table;else drafts[patch.table]=table;
|
|
80
|
+
}
|
|
81
|
+
this.document.tables=tables;this.view.tableDrafts=drafts;
|
|
82
|
+
for(const key of seen)this.errors.delete(`column:${key}`);
|
|
83
|
+
if(patches.length)this.touch();
|
|
84
|
+
}
|
|
85
|
+
rule(tableName,column) {
|
|
86
|
+
const override=this.table(tableName).columns?.find(c=>c.name===column);
|
|
87
|
+
if(override) return copy(override);
|
|
88
|
+
const inferred=copy(this.check?.effective_rules?.[tableName]?.[column]
|
|
89
|
+
|| this.schema.tables.find(t=>t.name===tableName)?.mapping?.[column] || {});
|
|
90
|
+
// Reflected/resolved specs may contain sampled parent values. They are
|
|
91
|
+
// runtime evidence, not editable generator parameters or saved rules.
|
|
92
|
+
if(inferred.params) inferred.params=Object.fromEntries(Object.entries(inferred.params).filter(([key])=>!key.startsWith('_')));
|
|
93
|
+
return inferred;
|
|
94
|
+
}
|
|
95
|
+
replaceDocument(document) {
|
|
96
|
+
if('url' in document || 'db_path' in document) throw new Error('连接由当前工作台绑定,文档不能包含连接地址');
|
|
97
|
+
this.document=copy(document);
|
|
98
|
+
this.view.tableDrafts={};
|
|
99
|
+
this.errors.clear();
|
|
100
|
+
this.touch();
|
|
101
|
+
}
|
|
102
|
+
payload(name) {
|
|
103
|
+
if(this.errors.size) throw new Error([...this.errors.values()].join(';'));
|
|
104
|
+
return {name,document:copy(this.document),schema_hash:this.schema.schema_hash,view_state:copy(this.view)};
|
|
105
|
+
}
|
|
106
|
+
markSaved(saved,epoch) { this.saved=copy(saved); this.savedEpoch=epoch; }
|
|
107
|
+
// Keep legacy direct callers' full-result behavior; session callers explicitly
|
|
108
|
+
// distinguish validation from preview, including empty or failed previews.
|
|
109
|
+
acceptCheck(result,epoch,preview=true) {
|
|
110
|
+
if(epoch!==this.epoch || this.errors.size) return false;
|
|
111
|
+
this.check={...copy(result),epoch};
|
|
112
|
+
if(preview) this.acceptPreview(result,epoch);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
acceptPreview(result,epoch) {
|
|
116
|
+
if(epoch!==this.epoch || this.errors.size) return false;
|
|
117
|
+
this.samples=copy(result.samples || {});
|
|
118
|
+
this.previewIssues=copy(result.issues || []);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
canRun() {
|
|
122
|
+
return !!(this.saved && !this.dirty && !this.errors.size && this.check?.ok && this.check.epoch===this.epoch);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { h, api } from '../api.js';
|
|
2
|
+
import { button, modal } from './ui.js';
|
|
3
|
+
const prefix = '/api/settings/plugins';
|
|
4
|
+
const managed = new Set(['ai', 'cli', 'mcp', 'mimesis']);
|
|
5
|
+
export function componentImpact(id) {
|
|
6
|
+
return {
|
|
7
|
+
ai: 'AI 规则建议与分析不可用;手动配置、预览和生成数据仍可使用。',
|
|
8
|
+
cli: '终端 sqlseed 命令不可用;网页中的手动配置、预览和生成数据仍可使用。',
|
|
9
|
+
mcp: 'MCP 客户端调用 sqlseed 的功能不可用;网页工作台仍可使用。',
|
|
10
|
+
mimesis: '使用 Mimesis 的配置暂时无法预览或生成数据;请重新安装,或主动改用其他可用引擎。'
|
|
11
|
+
}[id] || '';
|
|
12
|
+
}
|
|
13
|
+
export function createPluginManagement({
|
|
14
|
+
onChange = () => {},
|
|
15
|
+
onMode = () => {},
|
|
16
|
+
onRestored = async () => {},
|
|
17
|
+
initialEnabled = false
|
|
18
|
+
}) {
|
|
19
|
+
let active = true,
|
|
20
|
+
version = 0,
|
|
21
|
+
info = {
|
|
22
|
+
enabled: initialEnabled
|
|
23
|
+
},
|
|
24
|
+
pending = false,
|
|
25
|
+
error = '',
|
|
26
|
+
uncertain = false;
|
|
27
|
+
let task = null,
|
|
28
|
+
taskError = '',
|
|
29
|
+
polling = false,
|
|
30
|
+
timer = null,
|
|
31
|
+
review = null,
|
|
32
|
+
reconnects = 0,
|
|
33
|
+
generation = null,
|
|
34
|
+
syncing = false;
|
|
35
|
+
const controllers = new Set();
|
|
36
|
+
const el = h('section', {
|
|
37
|
+
class: 'settings-management',
|
|
38
|
+
'aria-label': '插件管理'
|
|
39
|
+
}, h('p', {}, '正在读取插件管理状态…'));
|
|
40
|
+
const current = expected => active && expected === version;
|
|
41
|
+
const automatic = () => info?.automatic_lifecycle === true;
|
|
42
|
+
const maintenance = () => Boolean(info?.enabled) && !automatic();
|
|
43
|
+
const busy = () => pending || Boolean(review) || task?.status === 'running';
|
|
44
|
+
const locked = () => busy() || uncertain || syncing || !info?.token || Boolean(info?.restart_required);
|
|
45
|
+
async function request(path, body) {
|
|
46
|
+
const controller = new AbortController();
|
|
47
|
+
controllers.add(controller);
|
|
48
|
+
try {
|
|
49
|
+
return await api(`${prefix}/${path}`, {
|
|
50
|
+
signal: controller.signal,
|
|
51
|
+
...(body ? {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
'X-Sqlseed-Management-Token': info.token
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify(body)
|
|
58
|
+
} : {})
|
|
59
|
+
});
|
|
60
|
+
} finally {
|
|
61
|
+
controllers.delete(controller);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function draw() {
|
|
65
|
+
const outputOpen = Boolean(el.querySelector('[data-plugin-output]')?.open) || task?.status === 'failed';
|
|
66
|
+
const environmentOpen = Boolean(el.querySelector('[data-plugin-environment]')?.open);
|
|
67
|
+
function managementAvailabilityHint() {
|
|
68
|
+
if (automatic()) {
|
|
69
|
+
return info.reason || '按需安装或卸载可选组件,完成后自动生效。';
|
|
70
|
+
} else if (maintenance()) {
|
|
71
|
+
return info.reason || '可在此环境中安装或卸载可选组件。';
|
|
72
|
+
} else {
|
|
73
|
+
return '此部署暂不支持网页内管理组件,请联系应用管理员。';
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
el.replaceChildren(h('h3', {}, maintenance() ? '插件维护模式' : '组件管理'), h('p', {}, error || managementAvailabilityHint()));
|
|
77
|
+
if (info?.python_executable) {
|
|
78
|
+
const environment = h('details', {
|
|
79
|
+
class: 'settings-management-environment',
|
|
80
|
+
'data-plugin-environment': ''
|
|
81
|
+
}, h('summary', {}, '环境信息'), h('p', {
|
|
82
|
+
class: 'mono'
|
|
83
|
+
}, info.python_executable));
|
|
84
|
+
environment.open = environmentOpen;
|
|
85
|
+
el.append(environment);
|
|
86
|
+
}
|
|
87
|
+
if (info?.restart_required && !automatic()) {
|
|
88
|
+
el.append(h('p', {
|
|
89
|
+
class: 'settings-management-restart',
|
|
90
|
+
role: 'status'
|
|
91
|
+
}, task?.status === 'running' ? '任务结束后,停止 Web 并退出维护模式;重新启动普通模式后验证组件。' : '环境已发生变更。请停止 Web,退出维护模式后重新启动普通模式,再验证组件可用性。'));
|
|
92
|
+
}
|
|
93
|
+
if (pending) {
|
|
94
|
+
el.append(h('p', {
|
|
95
|
+
class: 'settings-notice',
|
|
96
|
+
role: 'status'
|
|
97
|
+
}, '正在处理,请稍候…'));
|
|
98
|
+
}
|
|
99
|
+
if (task) {
|
|
100
|
+
renderTaskProgress();
|
|
101
|
+
}
|
|
102
|
+
if (automatic() && info?.phase === 'recovery_failed') {
|
|
103
|
+
el.append(button('重试恢复服务', recover, {
|
|
104
|
+
small: true,
|
|
105
|
+
disabled: busy()
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
108
|
+
if (info?.session_restore?.session_lost) {
|
|
109
|
+
el.append(h('p', {
|
|
110
|
+
class: 'settings-management-restart',
|
|
111
|
+
role: 'status'
|
|
112
|
+
}, info.session_restore.message || '服务意外退出,原连接和会话密钥需要重新配置。'));
|
|
113
|
+
}
|
|
114
|
+
if (info?.session_restore?.failed_connections?.length) {
|
|
115
|
+
el.append(h('section', {
|
|
116
|
+
class: 'settings-management-restart',
|
|
117
|
+
role: 'status'
|
|
118
|
+
}, h('strong', {}, '部分数据库连接未恢复'), ...info.session_restore.failed_connections.map(item => h('p', {}, item.message || '请检查数据库连接后重新连接。'))));
|
|
119
|
+
}
|
|
120
|
+
onChange();
|
|
121
|
+
function renderTaskProgress() {
|
|
122
|
+
let state;
|
|
123
|
+
if (taskError) {
|
|
124
|
+
if (automatic() && reconnects < 30) {
|
|
125
|
+
state = '重新连接中';
|
|
126
|
+
} else {
|
|
127
|
+
state = '状态暂时未知';
|
|
128
|
+
}
|
|
129
|
+
} else {
|
|
130
|
+
state = {
|
|
131
|
+
running: '执行中',
|
|
132
|
+
succeeded: '已完成',
|
|
133
|
+
failed: '执行失败'
|
|
134
|
+
}[task.status] || '状态暂时未知';
|
|
135
|
+
}
|
|
136
|
+
const progress = h('section', {
|
|
137
|
+
class: 'settings-plugin-task',
|
|
138
|
+
'aria-label': '插件任务',
|
|
139
|
+
'aria-busy': String(task.status === 'running')
|
|
140
|
+
}, h('h4', {}, `${task.action === 'uninstall' ? '卸载' : '安装'} ${{
|
|
141
|
+
ai: 'AI',
|
|
142
|
+
cli: 'CLI',
|
|
143
|
+
mcp: 'MCP',
|
|
144
|
+
mimesis: 'Mimesis'
|
|
145
|
+
}[task.component_id] || task.component_id} · ${state}`), h('p', {
|
|
146
|
+
role: 'status',
|
|
147
|
+
'aria-live': 'polite'
|
|
148
|
+
}, taskError || task.message || state));
|
|
149
|
+
if (automatic() && task.status === 'running') {
|
|
150
|
+
progress.append(h('ol', {
|
|
151
|
+
class: 'settings-plugin-stages',
|
|
152
|
+
'aria-label': '处理阶段'
|
|
153
|
+
}, ...[['preparing', '准备'], ['installing', '处理组件'], ['restoring', '恢复服务']].map(([stage, label]) => h('li', {
|
|
154
|
+
'aria-current': task.stage === stage ? 'step' : 'false'
|
|
155
|
+
}, label))));
|
|
156
|
+
}
|
|
157
|
+
if (task.output?.length) {
|
|
158
|
+
const output = h('details', {
|
|
159
|
+
'data-plugin-output': ''
|
|
160
|
+
}, h('summary', {}, '查看执行输出'), h('pre', {
|
|
161
|
+
class: 'settings-plugin-output'
|
|
162
|
+
}, task.output.join('\n')));
|
|
163
|
+
output.open = outputOpen;
|
|
164
|
+
progress.append(output);
|
|
165
|
+
}
|
|
166
|
+
if (Number.isInteger(task.returncode) && !automatic()) {
|
|
167
|
+
progress.append(h('p', {
|
|
168
|
+
class: 'muted'
|
|
169
|
+
}, `退出码:${task.returncode}`));
|
|
170
|
+
}
|
|
171
|
+
if (taskError && (!automatic() || reconnects >= 30)) {
|
|
172
|
+
progress.append(button('重新读取任务状态', poll, {
|
|
173
|
+
small: true,
|
|
174
|
+
disabled: polling
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
el.append(progress);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function refresh() {
|
|
181
|
+
if (!active || pending || review) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const expected = ++version;
|
|
185
|
+
pending = true;
|
|
186
|
+
error = '';
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
polling = false;
|
|
189
|
+
for (const controller of controllers) {
|
|
190
|
+
controller.abort();
|
|
191
|
+
}
|
|
192
|
+
draw();
|
|
193
|
+
try {
|
|
194
|
+
const response = await request('management');
|
|
195
|
+
if (!current(expected)) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
await receiveManagement(response);
|
|
199
|
+
} catch (error_) {
|
|
200
|
+
if (!current(expected)) return;
|
|
201
|
+
error = `无法读取插件管理状态:${error_.message}。可稍后刷新状态。`;
|
|
202
|
+
if (automatic() && (uncertain || syncing) && ++reconnects < 30) {
|
|
203
|
+
error = '正在重新连接服务并核对操作结果,不会重复提交。';
|
|
204
|
+
timer = setTimeout(refresh, 1000);
|
|
205
|
+
}
|
|
206
|
+
} finally {
|
|
207
|
+
if (current(expected)) {
|
|
208
|
+
pending = false;
|
|
209
|
+
draw();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
async function receiveManagement(response) {
|
|
214
|
+
const nextGeneration = response.automatic_lifecycle ? `${response.instance_id}:${response.service_generation}` : null;
|
|
215
|
+
const restored = generation !== null && nextGeneration !== generation && response.phase === 'ready';
|
|
216
|
+
if (nextGeneration !== null) {
|
|
217
|
+
generation = nextGeneration;
|
|
218
|
+
}
|
|
219
|
+
info = response;
|
|
220
|
+
uncertain = false;
|
|
221
|
+
syncing = false;
|
|
222
|
+
error = '';
|
|
223
|
+
taskError = '';
|
|
224
|
+
task = null;
|
|
225
|
+
reconnects = 0;
|
|
226
|
+
onMode(maintenance());
|
|
227
|
+
if (response.active_task) {
|
|
228
|
+
receiveTask(typeof response.active_task === 'string' ? {
|
|
229
|
+
task_id: response.active_task,
|
|
230
|
+
status: 'running'
|
|
231
|
+
} : response.active_task);
|
|
232
|
+
} else if (automatic() && ['preparing', 'installing', 'restoring'].includes(response.phase)) {
|
|
233
|
+
syncing = true;
|
|
234
|
+
timer = setTimeout(refresh, 1000);
|
|
235
|
+
}
|
|
236
|
+
if (restored) {
|
|
237
|
+
await onRestored(response);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
function allowed(id, action) {
|
|
241
|
+
return active && managed.has(id) && info?.enabled && info.available && !error && Boolean(info.components?.find(value => value.id === id)?.[action === 'install' ? 'can_install' : 'can_uninstall']);
|
|
242
|
+
}
|
|
243
|
+
async function prepare(id, action) {
|
|
244
|
+
if (locked() || !allowed(id, action)) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const expected = version;
|
|
248
|
+
pending = true;
|
|
249
|
+
error = '';
|
|
250
|
+
draw();
|
|
251
|
+
try {
|
|
252
|
+
const plan = await request('plan', {
|
|
253
|
+
component_id: id,
|
|
254
|
+
action
|
|
255
|
+
});
|
|
256
|
+
if (!current(expected)) {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (!plan.plan_id || plan.component_id !== id || plan.action !== action || !Number.isFinite(plan.expires_in) || plan.expires_in <= 0) {
|
|
260
|
+
throw new Error('服务返回的计划与所选操作不一致,请刷新状态。');
|
|
261
|
+
}
|
|
262
|
+
showReview(plan);
|
|
263
|
+
} catch (error_) {
|
|
264
|
+
if (current(expected)) error = `无法生成操作计划:${error_.message}`;
|
|
265
|
+
} finally {
|
|
266
|
+
if (current(expected)) {
|
|
267
|
+
pending = false;
|
|
268
|
+
draw();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function showReview(plan) {
|
|
273
|
+
const operation = plan.action === 'install' ? '安装' : '卸载';
|
|
274
|
+
const confirmation = {
|
|
275
|
+
plan,
|
|
276
|
+
expired: false,
|
|
277
|
+
expiresAt: Date.now() + plan.expires_in * 1000,
|
|
278
|
+
dialog: null,
|
|
279
|
+
timer: null
|
|
280
|
+
};
|
|
281
|
+
const dialog = modal(`确认${operation}插件`, {
|
|
282
|
+
onClose: () => {
|
|
283
|
+
clearTimeout(confirmation.timer);
|
|
284
|
+
if (review === confirmation) {
|
|
285
|
+
review = null;
|
|
286
|
+
}
|
|
287
|
+
if (active) {
|
|
288
|
+
draw();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
confirmation.dialog = dialog;
|
|
293
|
+
review = confirmation;
|
|
294
|
+
const expiry = h('p', {
|
|
295
|
+
class: 'muted',
|
|
296
|
+
role: 'status'
|
|
297
|
+
}, `此计划 ${Math.ceil(plan.expires_in / 60)} 分钟内有效。`);
|
|
298
|
+
const details = h('dl', {
|
|
299
|
+
class: 'settings-plugin-plan'
|
|
300
|
+
}, h('dt', {}, '组件'), h('dd', {
|
|
301
|
+
class: 'mono'
|
|
302
|
+
}, plan.distribution), h('dt', {}, '版本'), h('dd', {
|
|
303
|
+
class: 'mono'
|
|
304
|
+
}, plan.version || '兼容版本'), h('dt', {}, '目标环境'), h('dd', {
|
|
305
|
+
class: 'mono'
|
|
306
|
+
}, info.python_executable || '当前应用环境'));
|
|
307
|
+
dialog.body.append(h('p', {}, automatic() ? `${operation} ${{
|
|
308
|
+
ai: 'AI',
|
|
309
|
+
cli: 'CLI',
|
|
310
|
+
mcp: 'MCP',
|
|
311
|
+
mimesis: 'Mimesis'
|
|
312
|
+
}[plan.component_id] || plan.distribution}` : plan.summary), ...(plan.action === 'uninstall' && componentImpact(plan.component_id) ? [h('p', {
|
|
313
|
+
class: 'settings-component-impact'
|
|
314
|
+
}, `卸载后,${componentImpact(plan.component_id)}`)] : []), automatic() ? h('details', {
|
|
315
|
+
class: 'settings-management-environment'
|
|
316
|
+
}, h('summary', {}, '技术信息'), details) : details, h('ul', {
|
|
317
|
+
class: 'settings-plugin-warnings'
|
|
318
|
+
}, ...(plan.warnings || []).map(warning => h('li', {}, warning))), expiry);
|
|
319
|
+
const confirm = button(`确认${operation}`, () => execute(confirmation), {
|
|
320
|
+
primary: true,
|
|
321
|
+
'data-plugin-confirm': ''
|
|
322
|
+
});
|
|
323
|
+
dialog.actions.append(button('取消', dialog.close), confirm);
|
|
324
|
+
confirmation.timer = setTimeout(() => {
|
|
325
|
+
if (!active || review !== confirmation) {
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
confirmation.expired = true;
|
|
329
|
+
confirm.disabled = true;
|
|
330
|
+
expiry.textContent = '计划已过期。请取消后重新生成并确认计划。';
|
|
331
|
+
}, plan.expires_in * 1000);
|
|
332
|
+
}
|
|
333
|
+
async function execute(confirmation) {
|
|
334
|
+
if (!active || pending || review !== confirmation || confirmation.expired || Date.now() >= confirmation.expiresAt) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const expected = version;
|
|
338
|
+
pending = true;
|
|
339
|
+
error = '';
|
|
340
|
+
uncertain = true;
|
|
341
|
+
confirmation.dialog.close();
|
|
342
|
+
draw();
|
|
343
|
+
try {
|
|
344
|
+
const response = await request('execute', {
|
|
345
|
+
plan_id: confirmation.plan.plan_id
|
|
346
|
+
});
|
|
347
|
+
if (!current(expected)) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
receiveTask(response);
|
|
351
|
+
uncertain = false;
|
|
352
|
+
} catch (error_) {
|
|
353
|
+
if (current(expected)) {
|
|
354
|
+
if ([400, 403, 409, 422].includes(error_.status)) {
|
|
355
|
+
uncertain = false;
|
|
356
|
+
error = error_.message;
|
|
357
|
+
} else {
|
|
358
|
+
error = `提交结果未知:${error_.message}。正在核对后台状态,不会重复提交。`;
|
|
359
|
+
if (automatic()) timer = setTimeout(refresh, 1000);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} finally {
|
|
363
|
+
if (current(expected)) {
|
|
364
|
+
pending = false;
|
|
365
|
+
draw();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function receiveTask(response) {
|
|
370
|
+
if (!response?.task_id || !['running', 'succeeded', 'failed'].includes(response.status)) {
|
|
371
|
+
throw new Error('服务未返回有效任务状态');
|
|
372
|
+
}
|
|
373
|
+
task = response;
|
|
374
|
+
taskError = '';
|
|
375
|
+
reconnects = 0;
|
|
376
|
+
info.restart_required ||= Boolean(task.restart_required);
|
|
377
|
+
clearTimeout(timer);
|
|
378
|
+
if (task.status === 'running') {
|
|
379
|
+
timer = setTimeout(poll, 1000);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
async function poll() {
|
|
383
|
+
if (!active || !task?.task_id || polling) {
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
const expected = version,
|
|
387
|
+
id = task.task_id;
|
|
388
|
+
polling = true;
|
|
389
|
+
clearTimeout(timer);
|
|
390
|
+
try {
|
|
391
|
+
const response = await request(`tasks/${encodeURIComponent(id)}`);
|
|
392
|
+
if (!current(expected) || task?.task_id !== id) {
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
if (response.task_id !== id) {
|
|
396
|
+
throw new Error('服务返回的任务标识不一致');
|
|
397
|
+
}
|
|
398
|
+
receiveTask(response);
|
|
399
|
+
if (automatic() && response.status !== 'running') {
|
|
400
|
+
syncing = true;
|
|
401
|
+
await refresh();
|
|
402
|
+
}
|
|
403
|
+
} catch (error_) {
|
|
404
|
+
showPollingFailure(error_);
|
|
405
|
+
} finally {
|
|
406
|
+
if (current(expected)) {
|
|
407
|
+
polling = false;
|
|
408
|
+
draw();
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function showPollingFailure(error_) {
|
|
412
|
+
if (current(expected) && task?.task_id === id) {
|
|
413
|
+
reconnects++;
|
|
414
|
+
taskError = automatic() && reconnects < 30 ? '正在重新连接服务,后台操作不会重复提交。' : `状态暂时未知:${error_.message}。任务可能仍在执行,请重新读取状态。`;
|
|
415
|
+
if (automatic() && reconnects < 30) timer = setTimeout(poll, 1000);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async function recover() {
|
|
420
|
+
if (!active || busy() || !automatic() || info.phase !== 'recovery_failed') {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const expected = version;
|
|
424
|
+
pending = true;
|
|
425
|
+
error = '';
|
|
426
|
+
draw();
|
|
427
|
+
try {
|
|
428
|
+
const response = await request('recover', {});
|
|
429
|
+
if (current(expected)) {
|
|
430
|
+
await receiveManagement(response);
|
|
431
|
+
}
|
|
432
|
+
} catch (error_) {
|
|
433
|
+
if (current(expected)) {
|
|
434
|
+
error = `恢复结果暂时未知:${error_.message}。正在核对后台状态,不会重复提交。`;
|
|
435
|
+
uncertain = true;
|
|
436
|
+
timer = setTimeout(refresh, 1000);
|
|
437
|
+
}
|
|
438
|
+
} finally {
|
|
439
|
+
if (current(expected)) {
|
|
440
|
+
pending = false;
|
|
441
|
+
draw();
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function controls(item) {
|
|
446
|
+
if (!managed.has(item.id) || !info?.enabled || !info.available) {
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
const component = info.components?.find(value => value.id === item.id);
|
|
450
|
+
if (!component) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
let action;
|
|
454
|
+
if (component.can_install) {
|
|
455
|
+
action = 'install';
|
|
456
|
+
} else if (component.can_uninstall) {
|
|
457
|
+
action = 'uninstall';
|
|
458
|
+
} else {
|
|
459
|
+
action = null;
|
|
460
|
+
}
|
|
461
|
+
let actionButton;
|
|
462
|
+
if (action) {
|
|
463
|
+
actionButton = button(action === 'install' ? '安装' : '卸载', () => {
|
|
464
|
+
if (actionButton.isConnected) {
|
|
465
|
+
return prepare(item.id, action);
|
|
466
|
+
}
|
|
467
|
+
}, {
|
|
468
|
+
small: true,
|
|
469
|
+
disabled: locked() || Boolean(error),
|
|
470
|
+
'data-plugin-action': action
|
|
471
|
+
});
|
|
472
|
+
} else {
|
|
473
|
+
actionButton = null;
|
|
474
|
+
}
|
|
475
|
+
return h('div', {
|
|
476
|
+
class: 'settings-package-management'
|
|
477
|
+
}, actionButton, !action && component.reason ? h('span', {
|
|
478
|
+
class: 'muted'
|
|
479
|
+
}, component.reason) : null, component.required_by?.length ? h('span', {
|
|
480
|
+
class: 'muted'
|
|
481
|
+
}, `依赖此组件:${component.required_by.join('、')}`) : null);
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
el,
|
|
485
|
+
refresh,
|
|
486
|
+
controls,
|
|
487
|
+
get enabled() {
|
|
488
|
+
return Boolean(info?.enabled);
|
|
489
|
+
},
|
|
490
|
+
get automatic() {
|
|
491
|
+
return automatic();
|
|
492
|
+
},
|
|
493
|
+
get maintenance() {
|
|
494
|
+
return maintenance();
|
|
495
|
+
},
|
|
496
|
+
get busy() {
|
|
497
|
+
return busy();
|
|
498
|
+
},
|
|
499
|
+
get refreshBlocked() {
|
|
500
|
+
return pending || Boolean(review);
|
|
501
|
+
},
|
|
502
|
+
destroy() {
|
|
503
|
+
active = false;
|
|
504
|
+
version++;
|
|
505
|
+
clearTimeout(timer);
|
|
506
|
+
review?.dialog.close();
|
|
507
|
+
for (const controller of controllers) {
|
|
508
|
+
controller.abort();
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
}
|