@nsyan/db 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -0
- package/index.ts +929 -0
- package/package.json +57 -0
- package/src/config.ts +220 -0
- package/src/core/export.ts +14 -0
- package/src/core/index.ts +7 -0
- package/src/core/policy.ts +11 -0
- package/src/core/scan/candidates.ts +286 -0
- package/src/core/scan/parsers.ts +149 -0
- package/src/core/scan/placeholders.ts +24 -0
- package/src/core/scan/scoring.ts +25 -0
- package/src/core/scan/spring.ts +73 -0
- package/src/core/scan/walker.ts +47 -0
- package/src/core/sql-text.ts +72 -0
- package/src/core/types.ts +88 -0
- package/src/core/whitelist.ts +4 -0
- package/src/dialects/bigdata-dialect.ts +197 -0
- package/src/dialects/dialect.ts +38 -0
- package/src/dialects/dm.ts +159 -0
- package/src/dialects/elasticsearch.ts +219 -0
- package/src/dialects/hive.ts +124 -0
- package/src/dialects/index.ts +12 -0
- package/src/dialects/kv-dialect.ts +127 -0
- package/src/dialects/mysql.ts +158 -0
- package/src/dialects/oracle.ts +177 -0
- package/src/dialects/postgresql.ts +168 -0
- package/src/dialects/redis.ts +190 -0
- package/src/dialects/relational-dialect.ts +76 -0
- package/src/dialects/search-dialect.ts +128 -0
- package/src/dialects/spark.ts +120 -0
package/index.ts
ADDED
|
@@ -0,0 +1,929 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { registry } from "./src/dialects/index.js";
|
|
5
|
+
import { decide } from "./src/core/policy.js";
|
|
6
|
+
import { scanProject } from "./src/core/scan/candidates.js";
|
|
7
|
+
import type { Candidate, ConnConfig, DbTypeId } from "./src/core/types.js";
|
|
8
|
+
import {
|
|
9
|
+
loadConfigs, saveConfigs, loadPluginConfig, savePluginConfig, getConfigSummary,
|
|
10
|
+
findConfig, toRuntimeConfig, shortTypeLabel, fullTypeLabel,
|
|
11
|
+
parseConnectionString, getDefaultConfig, setDefaultConfig, writeQueryExport,
|
|
12
|
+
} from "./src/config.js";
|
|
13
|
+
import type { PluginConfig } from "./src/config.js";
|
|
14
|
+
|
|
15
|
+
// ── URL 解析:遍历 registry 各方言 parseUrl,首个非 null 胜出 ────
|
|
16
|
+
// 关系型 JDBC + 原生 URI 双形态由各方言 parseUrl 兼收(Spec §7)
|
|
17
|
+
|
|
18
|
+
function parseDbUrl(url: string): { dialectId: DbTypeId; host: string; port: number; username?: string; password?: string; database?: string; dbIndex?: number } | null {
|
|
19
|
+
for (const d of registry.values()) {
|
|
20
|
+
const p = d.parseUrl(url);
|
|
21
|
+
if (p) return { dialectId: d.id, ...p };
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getDbNames(configs: ConnConfig[]): string {
|
|
27
|
+
return configs
|
|
28
|
+
.map((c) => {
|
|
29
|
+
const desc = c.description ? `(${c.description})` : "";
|
|
30
|
+
const typeLabel = shortTypeLabel(c.type);
|
|
31
|
+
return `${c.name}[${typeLabel}]${desc}`;
|
|
32
|
+
})
|
|
33
|
+
.join(", ");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function buildDisplayList(configs: ConnConfig[]): { list: string[]; map: Map<string, ConnConfig> } {
|
|
37
|
+
const map = new Map<string, ConnConfig>();
|
|
38
|
+
const list: string[] = [];
|
|
39
|
+
for (const c of configs) {
|
|
40
|
+
const typeLabel = shortTypeLabel(c.type);
|
|
41
|
+
const desc = c.description ? ` - ${c.description}` : "";
|
|
42
|
+
const display = `${c.name} [${typeLabel}]${desc}`;
|
|
43
|
+
list.push(display);
|
|
44
|
+
map.set(display, c);
|
|
45
|
+
}
|
|
46
|
+
return { list, map };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 构建「可用数据库」提示(注入系统提示,让 AI 知道有哪些连接) ────
|
|
50
|
+
// 效率项(Spec §6):每连接只注一行 `名称[家族] + 一行语义`,细节压进系统提示
|
|
51
|
+
|
|
52
|
+
// 各家族一行语义(KV/搜索/大数据行由后续任务的 dialect.label 接管,此处三库先行)
|
|
53
|
+
function familyHint(c: ConnConfig): string {
|
|
54
|
+
switch (c.type) {
|
|
55
|
+
case "postgresql":
|
|
56
|
+
case "mysql":
|
|
57
|
+
case "oracle":
|
|
58
|
+
return "关系型,sql 参数填 SQL";
|
|
59
|
+
default:
|
|
60
|
+
return `${fullTypeLabel(c.type)},sql 参数填查询或命令`;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 系统提示触发词文案(Spec §8.5:AI 侧入口;建连写盘前必须经用户确认)
|
|
65
|
+
const SCAN_TRIGGER_HINT = [
|
|
66
|
+
"[项目扫描建连]",
|
|
67
|
+
"当用户说“连一下这个项目的数据库”“帮我把这项目的库配上”等时,调用 scan_project_configs 工具扫描项目连接候选(返回掩码结果,不写盘)。",
|
|
68
|
+
"建连写盘前必须经用户在终端确认(/db scan),密码类字段只在终端 TUI 补录,不进模型上下文。",
|
|
69
|
+
].join("\n");
|
|
70
|
+
|
|
71
|
+
function buildDbListHint(configs: ConnConfig[], cfg: PluginConfig): string {
|
|
72
|
+
const policy = cfg.ai_readonly
|
|
73
|
+
? "当前 AI 只读模式:是。query_database 只能执行查询语句,禁止 INSERT、UPDATE、DELETE 等写操作。"
|
|
74
|
+
: "当前 AI 只读模式:否。query_database 允许执行查询和写操作,不要因为工具名称或通用描述而将其限制为 SELECT。";
|
|
75
|
+
const confirmation =
|
|
76
|
+
cfg.confirm_before_exec === "never"
|
|
77
|
+
? "当前执行确认:不确认。符合条件的 SQL 不会弹出确认框。"
|
|
78
|
+
: cfg.confirm_before_exec === "write"
|
|
79
|
+
? "当前执行确认:写操作确认。写操作执行前会弹出确认框。"
|
|
80
|
+
: "当前执行确认:每次都确认。每条 SQL 执行前都会弹出确认框。";
|
|
81
|
+
const safety = "DROP TABLE 始终禁止通过 query_database 执行,与 AI 只读模式设置无关。";
|
|
82
|
+
|
|
83
|
+
if (configs.length === 0) {
|
|
84
|
+
return [
|
|
85
|
+
"[数据库工具执行策略]",
|
|
86
|
+
policy,
|
|
87
|
+
confirmation,
|
|
88
|
+
safety,
|
|
89
|
+
"[可用数据库]",
|
|
90
|
+
"暂无数据库连接。请告知用户先通过 /db add 添加数据库连接,或用 /db scan 扫描项目配置建连,再执行查询。",
|
|
91
|
+
SCAN_TRIGGER_HINT,
|
|
92
|
+
].join("\n");
|
|
93
|
+
}
|
|
94
|
+
const lines = configs.map((c) => {
|
|
95
|
+
const desc = c.description ? ` - ${c.description}` : "";
|
|
96
|
+
return `- ${c.name}[${shortTypeLabel(c.type)}] - ${fullTypeLabel(c.type)},${familyHint(c)}${desc}`;
|
|
97
|
+
});
|
|
98
|
+
return [
|
|
99
|
+
"[数据库工具执行策略]",
|
|
100
|
+
policy,
|
|
101
|
+
confirmation,
|
|
102
|
+
safety,
|
|
103
|
+
"[可用数据库]",
|
|
104
|
+
...lines,
|
|
105
|
+
"query_database / list_tables / describe_table 的 database 参数必须使用上述名称(不含中括号内容,名称区分大小写)。",
|
|
106
|
+
SCAN_TRIGGER_HINT,
|
|
107
|
+
].join("\n");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// scan 候选状态图标(Spec §8.5:✅ 可直接建 / ✏️ 待补字段 / 🔒 加密密码 / ⏭️ 已存在)
|
|
111
|
+
const SCAN_GLYPH: Record<Candidate["status"], string> = {
|
|
112
|
+
ready: "✅", incomplete: "✏️", encrypted: "🔒", exists: "⏭️",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
// 密码掩码(输出层红线):真实密码不进模型上下文;jasypt ENC( 密文保留(非明文,可用信号)
|
|
116
|
+
function maskCandidate(c: Candidate): Candidate {
|
|
117
|
+
const p = { ...c.partial };
|
|
118
|
+
if (typeof p.password === "string" && p.password && !p.password.startsWith("ENC(")) p.password = "***";
|
|
119
|
+
return { ...c, partial: p };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function candidateLine(c: Candidate): string {
|
|
123
|
+
const p = c.partial;
|
|
124
|
+
const miss = c.missing.length ? `,缺: ${c.missing.join("/")}` : "";
|
|
125
|
+
return `${SCAN_GLYPH[c.status]} [${c.status}] ${p.name} (${c.dialectId}) ${p.host ?? ""}:${p.port ?? ""}${p.database ? "/" + p.database : ""}${miss} <- ${c.source.file}(置信度 ${c.source.confidence})`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── 导出扩展 ──────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
// scan 向导核心(由 registerCommand 内的 scanWizard 调用;需要 ctx.ui)
|
|
131
|
+
|
|
132
|
+
export default function (pi: ExtensionAPI) {
|
|
133
|
+
// ── 代码扫描建连向导(/db scan,Spec §8.5)──────
|
|
134
|
+
// 硬性原则:绝不静默建连(每次写盘前确认);绝不静默覆盖(同名三选一);
|
|
135
|
+
// jasypt ENC 只标注不建;密码类字段只在此 TUI 通道补录。
|
|
136
|
+
const scanWizard = async (ctx: any, scanPath: string) => {
|
|
137
|
+
let candidates: Candidate[];
|
|
138
|
+
try {
|
|
139
|
+
candidates = await scanProject(scanPath);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
ctx.ui.notify(`扫描失败: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (candidates.length === 0) {
|
|
145
|
+
ctx.ui.notify(`在 ${scanPath} 未扫出数据库连接候选`, "info");
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 1. 分组展示
|
|
150
|
+
ctx.ui.notify(
|
|
151
|
+
`扫描到 ${candidates.length} 个连接候选:\n${candidates.map(candidateLine).join("\n")}\n\n接下来逐个确认,不会静默建连。`,
|
|
152
|
+
"info",
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// 2. 逐个确认
|
|
156
|
+
for (const cand of candidates) {
|
|
157
|
+
if (cand.status === "encrypted") {
|
|
158
|
+
ctx.ui.notify(
|
|
159
|
+
`🔒 ${cand.partial.name}: 检测到 jasypt 加密密码(${cand.source.file}),不自动建连。请人工解密后用 /db add 手动添加。`,
|
|
160
|
+
"info",
|
|
161
|
+
);
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const ok = await ctx.ui.confirm("扫描建连", `添加 ${cand.partial.name} (${cand.dialectId})?\n来源: ${cand.source.file}`);
|
|
165
|
+
if (!ok) continue;
|
|
166
|
+
|
|
167
|
+
let name = cand.partial.name!;
|
|
168
|
+
if (loadConfigs().some((x) => x.name === name)) {
|
|
169
|
+
const how = await ctx.ui.select(`同名配置已存在: ${name}`, ["覆盖", "改名", "跳过"]);
|
|
170
|
+
if (!how || how === "跳过") continue;
|
|
171
|
+
if (how === "改名") {
|
|
172
|
+
name = (await ctx.ui.input("新名称", name))?.trim() || name;
|
|
173
|
+
// 改名后仍需查重:新名若也撞车,退回三选一(绝不静默覆盖)
|
|
174
|
+
while (loadConfigs().some((x) => x.name === name)) {
|
|
175
|
+
const again = await ctx.ui.select(`新名称 ${name} 也已存在`, ["覆盖", "再改一次", "跳过"]);
|
|
176
|
+
if (!again || again === "跳过") { name = ""; break; }
|
|
177
|
+
if (again === "再改一次") {
|
|
178
|
+
name = (await ctx.ui.input("新名称", name))?.trim() || name;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
break; // 覆盖
|
|
182
|
+
}
|
|
183
|
+
if (name === "") continue;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 缺字段追问(仅 TUI;密码在此补录,不进模型上下文)
|
|
188
|
+
const bag: Partial<ConnConfig> = { ...cand.partial, name };
|
|
189
|
+
for (const f of cand.missing) {
|
|
190
|
+
const v = (await ctx.ui.input(`补充 ${f}(${name})`, ""))?.trim();
|
|
191
|
+
if (v !== undefined && v !== "") {
|
|
192
|
+
(bag as Record<string, unknown>)[f] = f === "port" || f === "dbIndex" ? parseInt(v, 10) : v;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const dialect = registry.get(cand.dialectId);
|
|
197
|
+
if (!dialect) continue;
|
|
198
|
+
const conn: ConnConfig = { id: randomUUID(), createdAt: new Date().toISOString(), type: cand.dialectId, ...bag } as ConnConfig;
|
|
199
|
+
|
|
200
|
+
// 3. 逐个 testConnection → 成功保存,失败给可操作建议
|
|
201
|
+
ctx.ui.notify(`正在测试 ${dialect.label} 连接...`, "info");
|
|
202
|
+
const result = await dialect.testConnection(toRuntimeConfig(conn, dialect.defaultPort));
|
|
203
|
+
if (!result.success) {
|
|
204
|
+
ctx.ui.notify(`连接失败: ${result.error}\n未保存。请检查网络/账号后重跑 /db scan,或用 /db add 手动添加。`, "error");
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
ctx.ui.notify(`连接成功 (${result.version}${result.warning ? ",警告: " + result.warning : ""}, ${result.latency})`, "success");
|
|
208
|
+
const all = loadConfigs();
|
|
209
|
+
const idx = all.findIndex((x) => x.name === name);
|
|
210
|
+
if (idx >= 0) all[idx] = conn; else all.push(conn);
|
|
211
|
+
saveConfigs(all);
|
|
212
|
+
ctx.ui.notify(`配置已保存: ${name}`, "success");
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// ── 添加数据库连接(P0:一键连接串 / 家族分支逐步表单,Spec §7/§11.1)───
|
|
217
|
+
const addDbConfig = async (ctx: any) => {
|
|
218
|
+
const name = (await ctx.ui.input("连接名称", ""))?.trim();
|
|
219
|
+
if (!name) { ctx.ui.notify("连接名称不能为空", "error"); return; }
|
|
220
|
+
|
|
221
|
+
// 首问:一键连接串 / 逐步填写
|
|
222
|
+
const mode = await ctx.ui.select("添加方式", [
|
|
223
|
+
"⚡ 粘贴连接串(一键)",
|
|
224
|
+
"📝 逐步填写",
|
|
225
|
+
]);
|
|
226
|
+
if (!mode) return;
|
|
227
|
+
|
|
228
|
+
let parsed: ReturnType<typeof parseDbUrl>;
|
|
229
|
+
let username = "";
|
|
230
|
+
let password = "";
|
|
231
|
+
let dbIndex: number | undefined;
|
|
232
|
+
|
|
233
|
+
if (mode === "⚡ 粘贴连接串(一键)") {
|
|
234
|
+
const url = (await ctx.ui.input("连接串", "postgresql://user:pass@host:5432/db 或 jdbc:mysql://... 或 redis://:pass@host:6379/0"))?.trim();
|
|
235
|
+
if (!url) { ctx.ui.notify("连接串不能为空", "error"); return; }
|
|
236
|
+
const pcs = parseConnectionString(url);
|
|
237
|
+
if (!pcs) {
|
|
238
|
+
ctx.ui.notify("连接串无法识别。支持: postgresql/mysql/oracle/dm/hive JDBC、redis(s)://、http(s)://host:9200", "error");
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database };
|
|
242
|
+
username = pcs.username ?? "";
|
|
243
|
+
password = pcs.password ?? "";
|
|
244
|
+
dbIndex = pcs.dbIndex;
|
|
245
|
+
} else {
|
|
246
|
+
const url = (await ctx.ui.input("连接 URL", "jdbc:postgresql://host:port/database"))?.trim();
|
|
247
|
+
if (!url) { ctx.ui.notify("连接 URL 不能为空", "error"); return; }
|
|
248
|
+
const pcs = parseConnectionString(url);
|
|
249
|
+
if (!pcs) {
|
|
250
|
+
ctx.ui.notify("URL 格式无法识别。支持格式:\n" +
|
|
251
|
+
" PostgreSQL/MySQL/DM/Hive: jdbc:<dialect>://host:port/db\n" +
|
|
252
|
+
" Oracle: jdbc:oracle:thin:@//host:port/service 或 @host:port:SID\n" +
|
|
253
|
+
" Redis: redis://[:password@]host:port[/db]\n" +
|
|
254
|
+
" ES: http://host:9200", "error");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database };
|
|
258
|
+
dbIndex = pcs.dbIndex;
|
|
259
|
+
|
|
260
|
+
// 家族分支字段:Redis 无账号要求、需库号;ES 账号/密码;其余 URL+账号+密码
|
|
261
|
+
if (parsed.dialectId === "redis") {
|
|
262
|
+
password = (await ctx.ui.input("密码(可空)", ""))?.trim() ?? "";
|
|
263
|
+
const idxInput = (await ctx.ui.input("库号 dbIndex(0-15,缺省 0)", String(dbIndex ?? 0)))?.trim();
|
|
264
|
+
dbIndex = idxInput !== undefined && idxInput !== "" ? parseInt(idxInput, 10) : dbIndex;
|
|
265
|
+
} else {
|
|
266
|
+
username = (await ctx.ui.input("账号", "root"))?.trim() || "root";
|
|
267
|
+
password = (await ctx.ui.input("密码", ""))?.trim() ?? "";
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const dialect = registry.get(parsed.dialectId)!;
|
|
272
|
+
const description = (await ctx.ui.input("用途说明(可选)", ""))?.trim() || undefined;
|
|
273
|
+
|
|
274
|
+
// 测试连接
|
|
275
|
+
ctx.ui.notify(`正在测试 ${dialect.label} 连接...`, "info");
|
|
276
|
+
const result = await dialect.testConnection(toRuntimeConfig({
|
|
277
|
+
id: "", name, type: parsed.dialectId,
|
|
278
|
+
host: parsed.host, port: parsed.port,
|
|
279
|
+
username, password, database: parsed.database,
|
|
280
|
+
dbIndex,
|
|
281
|
+
createdAt: "",
|
|
282
|
+
}, dialect.defaultPort));
|
|
283
|
+
if (!result.success) {
|
|
284
|
+
ctx.ui.notify(`连接失败: ${result.error}`, "error");
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
ctx.ui.notify(`连接成功 (${result.version}, ${result.latency})`, "success");
|
|
288
|
+
|
|
289
|
+
const configs = loadConfigs();
|
|
290
|
+
if (configs.some((c) => c.name === name)) {
|
|
291
|
+
ctx.ui.notify(`已存在同名配置: ${name}`, "error");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const config: ConnConfig = {
|
|
296
|
+
id: randomUUID(),
|
|
297
|
+
name,
|
|
298
|
+
type: parsed.dialectId,
|
|
299
|
+
description,
|
|
300
|
+
host: parsed.host,
|
|
301
|
+
port: parsed.port,
|
|
302
|
+
username,
|
|
303
|
+
password,
|
|
304
|
+
database: parsed.database,
|
|
305
|
+
dbIndex,
|
|
306
|
+
isDefault: configs.length === 0, // 首个连接自动设为默认(与提示文案一致)
|
|
307
|
+
createdAt: new Date().toISOString(),
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
configs.push(config);
|
|
311
|
+
saveConfigs(configs);
|
|
312
|
+
ctx.ui.notify(`配置已保存: ${name}${configs.length === 1 ? "(首个连接已设为默认)" : ""}`, "success");
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// ── 编辑数据库连接 ──────────────────────────────
|
|
316
|
+
const editDbConfig = async (ctx: any, original: ConnConfig) => {
|
|
317
|
+
const currentUrl = registry.get(original.type)!.displayUrl(original);
|
|
318
|
+
const prefill = [
|
|
319
|
+
`名称: ${original.name}`,
|
|
320
|
+
`URL: ${currentUrl}`,
|
|
321
|
+
`账号: ${original.username}`,
|
|
322
|
+
`密码: ${original.password || ""}`,
|
|
323
|
+
`说明: ${original.description || ""}`,
|
|
324
|
+
].join("\n");
|
|
325
|
+
|
|
326
|
+
const result = await ctx.ui.editor("编辑数据库连接(修改后保存,留空则保持原值)", prefill);
|
|
327
|
+
if (!result) return;
|
|
328
|
+
|
|
329
|
+
// 解析编辑结果
|
|
330
|
+
const lines = result.split("\n");
|
|
331
|
+
|
|
332
|
+
function getValue(key: string): string | undefined {
|
|
333
|
+
for (const line of lines) {
|
|
334
|
+
const trimmed = line.trim();
|
|
335
|
+
if (trimmed.startsWith(key + ":")) {
|
|
336
|
+
return trimmed.slice(key.length + 1).trim();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const name = getValue("名称") || original.name;
|
|
343
|
+
const url = getValue("URL") || currentUrl;
|
|
344
|
+
|
|
345
|
+
const parsed = parseDbUrl(url);
|
|
346
|
+
if (!parsed) {
|
|
347
|
+
ctx.ui.notify("JDBC URL 格式无法识别。", "error");
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const username = getValue("账号") || original.username || "";
|
|
352
|
+
const password = getValue("密码") ?? original.password ?? "";
|
|
353
|
+
const descRaw = getValue("说明");
|
|
354
|
+
const description = descRaw === "" ? undefined : (descRaw || original.description);
|
|
355
|
+
|
|
356
|
+
const updated: ConnConfig = {
|
|
357
|
+
...original,
|
|
358
|
+
name,
|
|
359
|
+
type: parsed.dialectId,
|
|
360
|
+
host: parsed.host,
|
|
361
|
+
port: parsed.port,
|
|
362
|
+
username,
|
|
363
|
+
password,
|
|
364
|
+
database: parsed.database,
|
|
365
|
+
description,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const all = loadConfigs();
|
|
369
|
+
if (updated.name !== original.name && all.some((c) => c.id !== original.id && c.name === updated.name)) {
|
|
370
|
+
ctx.ui.notify(`已存在同名配置: ${updated.name}`, "error");
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const idx = all.findIndex((c) => c.id === original.id);
|
|
375
|
+
if (idx < 0) {
|
|
376
|
+
ctx.ui.notify("找不到原配置", "error");
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
all[idx] = updated;
|
|
380
|
+
saveConfigs(all);
|
|
381
|
+
ctx.ui.notify(`已更新: ${updated.name}`, "success");
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
// ── 选择数据库公共操作 ────────────────────────────
|
|
385
|
+
|
|
386
|
+
const selectDbConfig = async (ctx: any, configs: ConnConfig[], title: string): Promise<ConnConfig | undefined> => {
|
|
387
|
+
if (configs.length === 0) {
|
|
388
|
+
ctx.ui.notify("尚无数据库连接", "info");
|
|
389
|
+
return undefined;
|
|
390
|
+
}
|
|
391
|
+
const { list, map } = buildDisplayList(configs);
|
|
392
|
+
const choice = await ctx.ui.select(title, list);
|
|
393
|
+
return choice ? map.get(choice) : undefined;
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
const showDbList = (ctx: any, configs: ConnConfig[]) => {
|
|
397
|
+
if (configs.length === 0) {
|
|
398
|
+
ctx.ui.notify("尚无数据库连接", "info");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
const lines = configs.map((c) => {
|
|
402
|
+
const desc = c.description ? ` - ${c.description}` : "";
|
|
403
|
+
return ` ${c.name} [${shortTypeLabel(c.type)}]${desc}`;
|
|
404
|
+
});
|
|
405
|
+
ctx.ui.notify(`数据库连接 (${configs.length}):\n${lines.join("\n")}`, "info");
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
const deleteDbConfig = async (ctx: any, configs: ConnConfig[]) => {
|
|
409
|
+
const target = await selectDbConfig(ctx, configs, "选择要删除的连接");
|
|
410
|
+
if (!target) return;
|
|
411
|
+
const ok = await ctx.ui.confirm("确认删除", `确定删除数据库连接 ${target.name}?`);
|
|
412
|
+
if (!ok) return;
|
|
413
|
+
const all = loadConfigs();
|
|
414
|
+
saveConfigs(all.filter((c) => c.id !== target.id));
|
|
415
|
+
ctx.ui.notify(`已删除: ${target.name}`, "success");
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// ── 选择数据库后操作菜单(循环,执行后留在当前界面)───
|
|
419
|
+
// 菜单执行路径改走 registry + policy(deny 展示 reason;确认策略与工具一致——有意的行为变更)
|
|
420
|
+
const showDbActions = async (ctx: any, config: ConnConfig) => {
|
|
421
|
+
while (true) {
|
|
422
|
+
const actions = [
|
|
423
|
+
"📝 执行查询",
|
|
424
|
+
"📋 列出表",
|
|
425
|
+
"🔍 查看详情",
|
|
426
|
+
config.isDefault ? "⭐ 取消默认" : "⭐ 设为默认",
|
|
427
|
+
"✏️ 编辑",
|
|
428
|
+
"🗑️ 删除",
|
|
429
|
+
"← 返回",
|
|
430
|
+
];
|
|
431
|
+
const choice = await ctx.ui.select(`选择操作 - ${config.name}${config.isDefault ? "(默认)" : ""}`, actions);
|
|
432
|
+
if (!choice || choice === "← 返回") return;
|
|
433
|
+
|
|
434
|
+
if (choice.startsWith("⭐")) {
|
|
435
|
+
const all = loadConfigs();
|
|
436
|
+
saveConfigs(setDefaultConfig(all, config.isDefault ? "" : config.id));
|
|
437
|
+
ctx.ui.notify(config.isDefault ? `已取消默认: ${config.name}` : `已设为默认: ${config.name}`, "success");
|
|
438
|
+
return;
|
|
439
|
+
} else if (choice === "📝 执行查询") {
|
|
440
|
+
const sql = (await ctx.ui.input("输入 SQL 语句", ""))?.trim();
|
|
441
|
+
if (!sql) return;
|
|
442
|
+
const cfg = loadPluginConfig();
|
|
443
|
+
const dialect = registry.get(config.type)!;
|
|
444
|
+
const verdict = dialect.isAllowed(sql, cfg.ai_readonly);
|
|
445
|
+
const action = decide(verdict, cfg.ai_readonly, cfg.confirm_before_exec);
|
|
446
|
+
if (action === "deny") {
|
|
447
|
+
ctx.ui.notify(`不允许执行: ${verdict.reason}`, "error");
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (action === "confirm") {
|
|
451
|
+
const ok = await ctx.ui.confirm(
|
|
452
|
+
"SQL 执行确认",
|
|
453
|
+
`${verdict.summary ?? ""}\n\nSQL:\n${sql}`
|
|
454
|
+
);
|
|
455
|
+
if (!ok) continue;
|
|
456
|
+
}
|
|
457
|
+
ctx.ui.notify("正在执行查询...", "info");
|
|
458
|
+
const result = await dialect.executeOn(toRuntimeConfig(config, dialect.defaultPort), sql, {
|
|
459
|
+
readonly: cfg.ai_readonly, maxRows: cfg.max_rows, timeoutSec: cfg.query_timeout,
|
|
460
|
+
});
|
|
461
|
+
if (result.success) {
|
|
462
|
+
const lines = [`查询完成 (${result.duration})`, `返回 ${result.rowCount} 行`];
|
|
463
|
+
if (result.columns && result.columns.length > 0) {
|
|
464
|
+
lines.push("列: " + result.columns.join(", "));
|
|
465
|
+
}
|
|
466
|
+
if (result.rows && result.rows.length > 0) {
|
|
467
|
+
const preview = result.rows.slice(0, 10).map((r) => JSON.stringify(r)).join("\n");
|
|
468
|
+
lines.push("数据预览:\n" + preview);
|
|
469
|
+
if (result.rows.length > 10) {
|
|
470
|
+
lines.push(`... 还有 ${result.rows.length - 10} 行`);
|
|
471
|
+
}
|
|
472
|
+
if (result.rows.length > 50) {
|
|
473
|
+
try {
|
|
474
|
+
const exp = writeQueryExport(result.columns ?? [], result.rows, `db-menu-${config.name.replace(/[^\w.-]/g, "_")}`);
|
|
475
|
+
lines.push(`完整结果已导出: ${exp.csvPath} | ${exp.jsonPath}`);
|
|
476
|
+
} catch { /* 导出失败不影响主结果 */ }
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
if (result.truncated) {
|
|
480
|
+
lines.push("(结果已达 maxRows 上限截断)");
|
|
481
|
+
}
|
|
482
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
483
|
+
} else {
|
|
484
|
+
ctx.ui.notify(`查询失败: ${result.error}`, "error");
|
|
485
|
+
}
|
|
486
|
+
} else if (choice === "📋 列出表") {
|
|
487
|
+
const dialect = registry.get(config.type)!;
|
|
488
|
+
const result = await dialect.listTables(toRuntimeConfig(config, dialect.defaultPort));
|
|
489
|
+
if (result.success && result.tables) {
|
|
490
|
+
const lines = result.tables.map((t) => {
|
|
491
|
+
const schema = t.schema ? `${t.schema}.` : "";
|
|
492
|
+
const desc = t.description ? ` - ${t.description}` : "";
|
|
493
|
+
return `${schema}${t.name} (${t.type})${desc}`;
|
|
494
|
+
});
|
|
495
|
+
ctx.ui.notify(`共 ${result.count} 张表:\n` + lines.join("\n"), "info");
|
|
496
|
+
} else {
|
|
497
|
+
ctx.ui.notify(`获取表列表失败: ${result.error}`, "error");
|
|
498
|
+
}
|
|
499
|
+
} else if (choice === "🔍 查看详情") {
|
|
500
|
+
const dialect = registry.get(config.type)!;
|
|
501
|
+
const url = dialect.displayUrl(config);
|
|
502
|
+
const parts = [
|
|
503
|
+
`【名称】 ${config.name}`,
|
|
504
|
+
`【类型】 ${dialect.label}`,
|
|
505
|
+
`【JDBC URL】 ${url}`,
|
|
506
|
+
`【账号】 ${config.username}`,
|
|
507
|
+
];
|
|
508
|
+
if (config.description) parts.push(`【说明】 ${config.description}`);
|
|
509
|
+
parts.push(`【创建时间】 ${config.createdAt}`);
|
|
510
|
+
ctx.ui.notify(parts.join("\n"), "info");
|
|
511
|
+
} else if (choice === "✏️ 编辑") {
|
|
512
|
+
await editDbConfig(ctx, config);
|
|
513
|
+
} else if (choice === "🗑️ 删除") {
|
|
514
|
+
const ok = await ctx.ui.confirm("确认删除", `确定删除数据库连接 ${config.name}?`);
|
|
515
|
+
if (!ok) continue;
|
|
516
|
+
const all = loadConfigs();
|
|
517
|
+
saveConfigs(all.filter((c) => c.id !== config.id));
|
|
518
|
+
ctx.ui.notify(`已删除: ${config.name}`, "success");
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
// ── 插件全局配置菜单 ────────────────────────────
|
|
525
|
+
const showPluginConfigMenu = async (ctx: any) => {
|
|
526
|
+
const cfg = loadPluginConfig();
|
|
527
|
+
ctx.ui.notify(`当前设置:\n${getConfigSummary(cfg)}`, "info");
|
|
528
|
+
await editPluginConfig(ctx, cfg);
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const editPluginConfig = async (ctx: any, cfg: PluginConfig) => {
|
|
532
|
+
const fields = [
|
|
533
|
+
{
|
|
534
|
+
key: "ai_readonly" as const,
|
|
535
|
+
label: "AI 只读模式",
|
|
536
|
+
current: cfg.ai_readonly ? "是" : "否",
|
|
537
|
+
options: ["是", "否"],
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
key: "confirm_before_exec" as const,
|
|
541
|
+
label: "执行确认",
|
|
542
|
+
current:
|
|
543
|
+
cfg.confirm_before_exec === "never" ? "不确认" :
|
|
544
|
+
cfg.confirm_before_exec === "write" ? "写操作确认" : "每次都确认",
|
|
545
|
+
options: ["不确认", "写操作确认", "每次都确认"],
|
|
546
|
+
valueMap: { "不确认": "never" as const, "写操作确认": "write" as const, "每次都确认": "always" as const },
|
|
547
|
+
},
|
|
548
|
+
{
|
|
549
|
+
key: "max_rows" as const,
|
|
550
|
+
label: "最大行数",
|
|
551
|
+
current: String(cfg.max_rows),
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
key: "query_timeout" as const,
|
|
555
|
+
label: "查询超时(s)",
|
|
556
|
+
current: String(cfg.query_timeout),
|
|
557
|
+
},
|
|
558
|
+
];
|
|
559
|
+
|
|
560
|
+
// 选择要修改的字段
|
|
561
|
+
const fieldLabels = fields.map((f) => `${f.label}(当前: ${f.current})`);
|
|
562
|
+
fieldLabels.push("✅ 完成修改");
|
|
563
|
+
|
|
564
|
+
const newCfg = { ...cfg };
|
|
565
|
+
|
|
566
|
+
while (true) {
|
|
567
|
+
const pick = await ctx.ui.select("选择要修改的设置项", fieldLabels);
|
|
568
|
+
if (!pick || pick === "✅ 完成修改") break;
|
|
569
|
+
|
|
570
|
+
const idx = fieldLabels.indexOf(pick);
|
|
571
|
+
if (idx < 0) break;
|
|
572
|
+
const field = fields[idx];
|
|
573
|
+
|
|
574
|
+
if (field.options) {
|
|
575
|
+
// 枚举型 -> 选择
|
|
576
|
+
const val = await ctx.ui.select(`选择 ${field.label}`, field.options);
|
|
577
|
+
if (!val) continue;
|
|
578
|
+
if (field.valueMap) {
|
|
579
|
+
(newCfg as any)[field.key] = field.valueMap[val];
|
|
580
|
+
} else {
|
|
581
|
+
(newCfg as any)[field.key] = val === "是";
|
|
582
|
+
}
|
|
583
|
+
} else {
|
|
584
|
+
// 数字型 -> 输入
|
|
585
|
+
const input = await ctx.ui.input(`${field.label}(当前: ${field.current})`, field.current);
|
|
586
|
+
if (!input) continue;
|
|
587
|
+
const num = parseInt(input, 10);
|
|
588
|
+
if (isNaN(num) || num <= 0) {
|
|
589
|
+
ctx.ui.notify("请输入正整数", "error");
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
(newCfg as any)[field.key] = num;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// 更新 fieldLabels 中的当前值
|
|
596
|
+
const updatedFields = fields.map((f) => {
|
|
597
|
+
const val = (newCfg as any)[f.key];
|
|
598
|
+
const display =
|
|
599
|
+
f.key === "ai_readonly" ? (val ? "是" : "否") :
|
|
600
|
+
f.key === "confirm_before_exec" ?
|
|
601
|
+
(val === "never" ? "不确认" : val === "write" ? "写操作确认" : "每次都确认") :
|
|
602
|
+
String(val);
|
|
603
|
+
return `${f.label}(当前: ${display})`;
|
|
604
|
+
});
|
|
605
|
+
updatedFields.push("✅ 完成修改");
|
|
606
|
+
fieldLabels.length = 0;
|
|
607
|
+
fieldLabels.push(...updatedFields);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
savePluginConfig(newCfg);
|
|
611
|
+
ctx.ui.notify(`设置已保存\n${getConfigSummary(newCfg)}`, "success");
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
// ── 系统提示注入:每轮告知 AI 可用数据库列表 ──────
|
|
615
|
+
// 解决 AI 不知道有哪些数据库连接、database 参数只能靠猜的问题。
|
|
616
|
+
|
|
617
|
+
pi.on("before_agent_start", async (event) => {
|
|
618
|
+
return {
|
|
619
|
+
systemPrompt: event.systemPrompt + "\n\n" + buildDbListHint(loadConfigs(), loadPluginConfig()),
|
|
620
|
+
};
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
// ── 注册 3 个工具(给 LLM 调用) ──────────────────
|
|
624
|
+
|
|
625
|
+
// 解析工具 database 参数:缺省走默认连接(Spec §11.1);无默认则报错指引
|
|
626
|
+
function resolveTargetDb(name: string | undefined): { config?: ConnConfig; error?: string } {
|
|
627
|
+
const configs = loadConfigs();
|
|
628
|
+
let config: ConnConfig | undefined;
|
|
629
|
+
if (name === undefined || name === "") {
|
|
630
|
+
config = getDefaultConfig(configs);
|
|
631
|
+
if (!config) {
|
|
632
|
+
return { error: `未指定 database 且无默认连接。可用数据库: ${configs.map((c) => c.name).join(", ") || "无"}。可在 /db 菜单「设为默认」,或在调用时显式传 database 参数。` };
|
|
633
|
+
}
|
|
634
|
+
return { config };
|
|
635
|
+
}
|
|
636
|
+
config = findConfig(configs, name);
|
|
637
|
+
if (!config) {
|
|
638
|
+
return { error: `数据库 "${name}" 未找到。可用数据库: ${configs.map((c) => c.name).join(", ") || "无"}。database 参数应使用系统提示「可用数据库」列表中的名称。` };
|
|
639
|
+
}
|
|
640
|
+
return { config };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// 工具 1: query_database
|
|
644
|
+
pi.registerTool({
|
|
645
|
+
name: "query_database",
|
|
646
|
+
label: "数据库查询",
|
|
647
|
+
description: "执行 SQL 语句,支持关系型(PostgreSQL/MySQL/Oracle/达梦)/ Redis / Elasticsearch / Hive / Spark 八种数据库,返回执行结果。支持读和写,写操作受确认策略约束;是否允许写以及是否需确认,以系统提示中的当前数据库工具执行策略为准。DROP TABLE 始终禁止。",
|
|
648
|
+
promptSnippet: "执行 SQL 语句。先根据系统提示中的当前数据库工具执行策略判断是否允许写操作;database 参数取系统提示「可用数据库」列表中的名称(缺省走默认连接)。使用 list_tables 查看表结构后再编写 SQL。",
|
|
649
|
+
parameters: Type.Object({
|
|
650
|
+
database: Type.Optional(Type.String({ description: "数据库连接名称(取系统提示「可用数据库」列表中的名称;缺省走默认连接)" })),
|
|
651
|
+
sql: Type.String({ description: "SQL 语句" }),
|
|
652
|
+
}),
|
|
653
|
+
async execute(_toolCallId: string, params: { database?: string; sql: string }, _signal: any, _onUpdate?: any, ctx?: any) {
|
|
654
|
+
const cfg = loadPluginConfig();
|
|
655
|
+
|
|
656
|
+
const target = resolveTargetDb(params.database);
|
|
657
|
+
if (target.error || !target.config) {
|
|
658
|
+
return {
|
|
659
|
+
content: [{ type: "text" as const, text: target.error ?? "未找到数据库配置" }],
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
const config = target.config;
|
|
663
|
+
|
|
664
|
+
// verdict 流程(策略层统一裁决,含 DROP 硬限制与只读检查):
|
|
665
|
+
// verdict = dialect.isAllowed(sql, readonly) → decide → deny/confirm/run
|
|
666
|
+
const dialect = registry.get(config.type);
|
|
667
|
+
if (!dialect) {
|
|
668
|
+
return {
|
|
669
|
+
content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
const verdict = dialect.isAllowed(params.sql, cfg.ai_readonly);
|
|
673
|
+
const action = decide(verdict, cfg.ai_readonly, cfg.confirm_before_exec);
|
|
674
|
+
if (action === "deny") {
|
|
675
|
+
return {
|
|
676
|
+
content: [{ type: "text" as const, text: verdict.reason ?? "该操作不被允许。如需修改,请执行 /db config 更改配置。" }],
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
if (action === "confirm") {
|
|
680
|
+
if (!ctx?.hasUI) {
|
|
681
|
+
return {
|
|
682
|
+
content: [{ type: "text" as const, text: "当前环境无法弹出确认对话框,已取消 SQL 执行。请在有界面的环境中操作。" }],
|
|
683
|
+
};
|
|
684
|
+
}
|
|
685
|
+
const ok = await ctx.ui.confirm(
|
|
686
|
+
"SQL 执行确认",
|
|
687
|
+
`${verdict.summary ?? ""}\n\n数据库: ${config.name}\n\nSQL:\n${params.sql}`,
|
|
688
|
+
);
|
|
689
|
+
if (!ok) {
|
|
690
|
+
return {
|
|
691
|
+
content: [{ type: "text" as const, text: "用户取消了 SQL 执行。" }],
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
// 用户确认,继续执行
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const result = await dialect.executeOn(
|
|
698
|
+
toRuntimeConfig(config, dialect.defaultPort),
|
|
699
|
+
params.sql,
|
|
700
|
+
{ readonly: cfg.ai_readonly, maxRows: cfg.max_rows, timeoutSec: cfg.query_timeout },
|
|
701
|
+
);
|
|
702
|
+
|
|
703
|
+
if (!result.success) {
|
|
704
|
+
return {
|
|
705
|
+
content: [{ type: "text" as const, text: `查询失败: ${result.error}` }],
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
let text = `查询完成 (${result.duration}),返回 ${result.rowCount} 行\n`;
|
|
710
|
+
if (result.columns && result.columns.length > 0) {
|
|
711
|
+
text += `列: ${result.columns.join(", ")}\n\n`;
|
|
712
|
+
}
|
|
713
|
+
if (result.rows && result.rows.length > 0) {
|
|
714
|
+
// 格式化为表格文本
|
|
715
|
+
const header = result.columns?.join(" | ") || "";
|
|
716
|
+
const separator = result.columns?.map(() => "---").join(" | ") || "";
|
|
717
|
+
const rows = result.rows.slice(0, 50).map((r) => r.join(" | "));
|
|
718
|
+
text += [header, separator, ...rows].join("\n");
|
|
719
|
+
if (result.rows.length > 50) {
|
|
720
|
+
// P0 导出:长结果落盘 /tmp(CSV+JSON),只回路径不贴全量
|
|
721
|
+
text += `\n... 还有 ${result.rows.length - 50} 行`;
|
|
722
|
+
try {
|
|
723
|
+
const exp = writeQueryExport(result.columns ?? [], result.rows, `db-query-${config.name.replace(/[^\w.-]/g, "_")}`);
|
|
724
|
+
text += `\n完整结果已导出: ${exp.csvPath} | ${exp.jsonPath}`;
|
|
725
|
+
} catch { /* 导出失败不影响主结果 */ }
|
|
726
|
+
}
|
|
727
|
+
if (result.truncated) {
|
|
728
|
+
text += `\n(结果已达 maxRows 上限截断)`;
|
|
729
|
+
}
|
|
730
|
+
} else {
|
|
731
|
+
text += "无数据返回。";
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return { content: [{ type: "text" as const, text }] };
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
// 工具 2: list_tables
|
|
739
|
+
pi.registerTool({
|
|
740
|
+
name: "list_tables",
|
|
741
|
+
label: "列出数据库表",
|
|
742
|
+
description: "列出指定数据库中的所有表,包含 schema、表名、类型。支持 pattern 过滤。",
|
|
743
|
+
promptSnippet: "列出数据库中的表(表上千时用 pattern 过滤,如 user%),了解表结构后再查询。database 参数取系统提示「可用数据库」列表中的名称(缺省走默认连接)。",
|
|
744
|
+
parameters: Type.Object({
|
|
745
|
+
database: Type.Optional(Type.String({ description: "数据库连接名称(取系统提示「可用数据库」列表中的名称;缺省走默认连接)" })),
|
|
746
|
+
pattern: Type.Optional(Type.String({ description: "表名过滤模式,如 user% / order_*;缺省全量" })),
|
|
747
|
+
}),
|
|
748
|
+
async execute(_toolCallId: string, params: { database?: string; pattern?: string }, _signal: any) {
|
|
749
|
+
const target = resolveTargetDb(params.database);
|
|
750
|
+
if (target.error || !target.config) {
|
|
751
|
+
return {
|
|
752
|
+
content: [{ type: "text" as const, text: target.error ?? "未找到数据库配置" }],
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
const config = target.config;
|
|
756
|
+
|
|
757
|
+
const dialect = registry.get(config.type);
|
|
758
|
+
if (!dialect) {
|
|
759
|
+
return {
|
|
760
|
+
content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
const result = await dialect.listTables(toRuntimeConfig(config, dialect.defaultPort), params.pattern);
|
|
764
|
+
|
|
765
|
+
if (!result.success || !result.tables) {
|
|
766
|
+
return {
|
|
767
|
+
content: [{ type: "text" as const, text: `获取表列表失败: ${result.error}` }],
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const lines = result.tables.map((t) => {
|
|
772
|
+
const schema = t.schema ? `${t.schema}.` : "";
|
|
773
|
+
const desc = t.description ? ` - ${t.description}` : "";
|
|
774
|
+
return `${schema}${t.name} (${t.type})${desc}`;
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
return {
|
|
778
|
+
content: [{ type: "text" as const, text: `数据库 "${config.name}"${params.pattern ? `(pattern: ${params.pattern})` : ""} 共 ${result.count} 张表:\n${lines.join("\n")}` }],
|
|
779
|
+
};
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
// 工具 3: describe_table
|
|
784
|
+
pi.registerTool({
|
|
785
|
+
name: "describe_table",
|
|
786
|
+
label: "查看表结构",
|
|
787
|
+
description: "查看指定表的列定义、类型、默认值、主键等。",
|
|
788
|
+
promptSnippet: "查看表结构,了解列名和类型后编写精确的 SQL。database 参数取系统提示「可用数据库」列表中的名称(缺省走默认连接)。",
|
|
789
|
+
parameters: Type.Object({
|
|
790
|
+
database: Type.Optional(Type.String({ description: "数据库连接名称(取系统提示「可用数据库」列表中的名称;缺省走默认连接)" })),
|
|
791
|
+
table: Type.String({ description: "表名(可带 schema,如 public.users)" }),
|
|
792
|
+
}),
|
|
793
|
+
async execute(_toolCallId: string, params: { database?: string; table: string }, _signal: any) {
|
|
794
|
+
const target = resolveTargetDb(params.database);
|
|
795
|
+
if (target.error || !target.config) {
|
|
796
|
+
return {
|
|
797
|
+
content: [{ type: "text" as const, text: target.error ?? "未找到数据库配置" }],
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
const config = target.config;
|
|
801
|
+
|
|
802
|
+
const dialect = registry.get(config.type);
|
|
803
|
+
if (!dialect) {
|
|
804
|
+
return {
|
|
805
|
+
content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
const result = await dialect.describeTable(toRuntimeConfig(config, dialect.defaultPort), params.table);
|
|
809
|
+
|
|
810
|
+
if (!result.success || !result.columns) {
|
|
811
|
+
return {
|
|
812
|
+
content: [{ type: "text" as const, text: `获取表结构失败: ${result.error}` }],
|
|
813
|
+
};
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
const lines = [`表: ${params.table}`, `共 ${result.count} 列\n`];
|
|
817
|
+
// 表头
|
|
818
|
+
lines.push("列名 | 类型 | 可空 | 默认值 | 主键 | 说明");
|
|
819
|
+
lines.push("--- | --- | --- | --- | --- | ---");
|
|
820
|
+
for (const col of result.columns) {
|
|
821
|
+
lines.push(
|
|
822
|
+
`${col.name} | ${col.type} | ${col.nullable ? "YES" : "NO"} | ${col.default ?? ""} | ${col.primaryKey ? "✓" : ""} | ${col.comment || ""}`
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
return {
|
|
827
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
828
|
+
};
|
|
829
|
+
},
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
// 工具 4: scan_project_configs(Spec §8.5 AI 侧入口;只返回掩码候选,不写盘)
|
|
833
|
+
pi.registerTool({
|
|
834
|
+
name: "scan_project_configs",
|
|
835
|
+
label: "扫描项目数据库配置",
|
|
836
|
+
description: "扫描项目源码(Spring 配置 / docker-compose / .env 等)抽取数据库连接候选。只返回掩码后的候选列表,绝不写盘;建连请让用户在终端执行 /db scan 完成。",
|
|
837
|
+
promptSnippet: "当用户说“连一下这个项目的数据库”“帮我把这项目的库配上”等时调用。返回掩码候选与状态(可直接建/待补/加密/已存在);把结果展示给用户后,引导其在终端用 /db scan 完成建连。path 必须在当前工作目录子树内。",
|
|
838
|
+
parameters: Type.Object({
|
|
839
|
+
path: Type.Optional(Type.String({ description: "扫描根目录,缺省为当前工作目录;强制限定在当前工作目录子树内,越界拒绝" })),
|
|
840
|
+
}),
|
|
841
|
+
async execute(_toolCallId: string, params: { path?: string }, _signal: any) {
|
|
842
|
+
try {
|
|
843
|
+
const candidates = (await scanProject(params.path ?? ".")).map(maskCandidate);
|
|
844
|
+
if (candidates.length === 0) {
|
|
845
|
+
return {
|
|
846
|
+
content: [{ type: "text" as const, text: `在 ${params.path ?? "当前目录"} 未扫出数据库连接候选。可建议用户用 /db add 手动添加。` }],
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
const text = [
|
|
850
|
+
`扫描到 ${candidates.length} 个连接候选(密码已掩码,不写盘):`,
|
|
851
|
+
...candidates.map(candidateLine),
|
|
852
|
+
"",
|
|
853
|
+
"建连写盘需用户确认:请引导用户在终端执行 /db scan 完成逐个确认与密码补录。",
|
|
854
|
+
].join("\n");
|
|
855
|
+
return { content: [{ type: "text" as const, text }] };
|
|
856
|
+
} catch (err) {
|
|
857
|
+
return {
|
|
858
|
+
content: [{ type: "text" as const, text: `扫描失败: ${err instanceof Error ? err.message : String(err)}` }],
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
},
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
// ── 注册 /db 命令(给用户管理连接) ──────────────
|
|
865
|
+
pi.registerCommand("db", {
|
|
866
|
+
description: "AI 接入数据库",
|
|
867
|
+
handler: async (args: string, ctx: any) => {
|
|
868
|
+
const sub = args.trim().toLowerCase();
|
|
869
|
+
const configs = loadConfigs();
|
|
870
|
+
|
|
871
|
+
if (!sub) {
|
|
872
|
+
// 导航菜单:查看 / 编辑 / 新增 / 删除 / 设置
|
|
873
|
+
const navActions = [
|
|
874
|
+
"📋 打开连接",
|
|
875
|
+
"✏️ 编辑连接",
|
|
876
|
+
"➕ 新增连接",
|
|
877
|
+
"🔎 扫描建连",
|
|
878
|
+
"🗑️ 删除连接",
|
|
879
|
+
"⚙️ 设置",
|
|
880
|
+
];
|
|
881
|
+
const navChoice = await ctx.ui.select("数据库管理", navActions);
|
|
882
|
+
if (!navChoice) return;
|
|
883
|
+
|
|
884
|
+
if (navChoice === "⚙️ 设置") {
|
|
885
|
+
await showPluginConfigMenu(ctx);
|
|
886
|
+
} else if (navChoice === "📋 打开连接") {
|
|
887
|
+
const config = await selectDbConfig(ctx, configs, "选择连接");
|
|
888
|
+
if (config) await showDbActions(ctx, config);
|
|
889
|
+
} else if (navChoice === "✏️ 编辑连接") {
|
|
890
|
+
const config = await selectDbConfig(ctx, configs, "选择要编辑的连接");
|
|
891
|
+
if (config) await editDbConfig(ctx, config);
|
|
892
|
+
} else if (navChoice === "➕ 新增连接") {
|
|
893
|
+
await addDbConfig(ctx);
|
|
894
|
+
} else if (navChoice === "🔎 扫描建连") {
|
|
895
|
+
await scanWizard(ctx, ".");
|
|
896
|
+
} else if (navChoice === "🗑️ 删除连接") {
|
|
897
|
+
await deleteDbConfig(ctx, configs);
|
|
898
|
+
}
|
|
899
|
+
} else if (sub === "config" || sub === "c") {
|
|
900
|
+
await showPluginConfigMenu(ctx);
|
|
901
|
+
} else if (sub === "add" || sub === "new" || sub === "a") {
|
|
902
|
+
await addDbConfig(ctx);
|
|
903
|
+
} else if (sub === "edit" || sub === "e") {
|
|
904
|
+
const config = await selectDbConfig(ctx, configs, "选择要编辑的连接");
|
|
905
|
+
if (config) await editDbConfig(ctx, config);
|
|
906
|
+
} else if (sub === "rm" || sub === "remove" || sub === "del" || sub === "delete" || sub === "d") {
|
|
907
|
+
await deleteDbConfig(ctx, configs);
|
|
908
|
+
} else if (sub === "scan" || sub.startsWith("scan ")) {
|
|
909
|
+
// /db scan [path]:path 缺省为当前工作目录,越界由 scanProject 拒绝
|
|
910
|
+
const scanPath = args.trim().slice(4).trim() || ".";
|
|
911
|
+
await scanWizard(ctx, scanPath);
|
|
912
|
+
} else if (sub === "ls" || sub === "list") {
|
|
913
|
+
showDbList(ctx, configs);
|
|
914
|
+
} else {
|
|
915
|
+
ctx.ui.notify(
|
|
916
|
+
"用法: /db [add|edit|rm|ls|scan|config]\n" +
|
|
917
|
+
" add 新增数据库连接\n" +
|
|
918
|
+
" edit 编辑连接\n" +
|
|
919
|
+
" rm 删除连接\n" +
|
|
920
|
+
" ls 列出所有连接\n" +
|
|
921
|
+
" scan 扫描项目配置建连(可带路径,缺省当前目录)\n" +
|
|
922
|
+
" config 查看/修改插件设置\n" +
|
|
923
|
+
" 默认 打开管理菜单(查看/编辑/新增/扫描/删除/设置)",
|
|
924
|
+
"info"
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
});
|
|
929
|
+
}
|