@nsyan/db 1.0.0 → 1.1.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 +55 -9
- package/index.ts +208 -31
- package/package.json +5 -2
- package/src/config.ts +72 -8
- package/src/core/audit.ts +67 -0
- package/src/core/policy.ts +13 -0
- package/src/core/scan/candidates.ts +11 -5
- package/src/core/scan/parsers.ts +1 -1
- package/src/core/scan/spring.ts +5 -2
- package/src/core/types.ts +13 -2
- package/src/dialects/document-dialect.ts +273 -0
- package/src/dialects/index.ts +1 -0
- package/src/dialects/mongodb.ts +334 -0
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
# db
|
|
4
4
|
|
|
5
|
-
**AI 接入数据库插件 —— 方言化架构,原生 Node.js
|
|
5
|
+
**AI 接入数据库插件 —— 方言化架构,原生 Node.js 实现**
|
|
6
6
|
|
|
7
|
-
支持关系型 / KV / 搜索 /
|
|
7
|
+
支持关系型 / KV / 搜索 / 文档 / 大数据五大家族,共 9 种数据库的查询、表结构浏览与项目配置扫描建连。
|
|
8
8
|
|
|
9
9
|

|
|
10
10
|
|
|
@@ -13,24 +13,34 @@
|
|
|
13
13
|
## ✨ 特性
|
|
14
14
|
|
|
15
15
|
- **方言化架构**:`core/` + `dialects/` 零 pi 运行时依赖,为 MCP server 复用铺路
|
|
16
|
-
- **
|
|
17
|
-
-
|
|
16
|
+
- **9 种数据库**:PostgreSQL · MySQL · Oracle · 达梦 DM8 · Redis · Elasticsearch · MongoDB · Hive · Spark(Thrift Server)
|
|
17
|
+
- **五大家族**:关系型 / KV / 搜索 / 文档 / 大数据,各有一套共享基类
|
|
18
18
|
- **安全强化**:
|
|
19
19
|
- 多语句**逐条**检查,`SELECT 1; DROP TABLE x` 无法绕过
|
|
20
20
|
- 注释剥离后再匹配,`/* c */ DELETE` 无法绕过
|
|
21
21
|
- CTE-DML / `FOR UPDATE` 识别为写操作
|
|
22
22
|
- Redis 命令白名单(只读模式放行读命令,`FLUSHALL/CONFIG/SHUTDOWN` 恒拒)
|
|
23
23
|
- ES 端点白名单(只读模式放行读端点,`DELETE index` 恒拒)
|
|
24
|
+
- MongoDB JSON 命令信封白名单(读命令白名单,`drop*`/`create*`/管理命令/服务端 JS `$where/$function/$accumulator` 恒拒,aggregate 含 `$out/$merge` 按写分类)
|
|
24
25
|
- **代码扫描建连** `/db scan`:从 Spring 配置 / docker-compose / .env 自动抽取连接候选
|
|
25
26
|
- **P0 体验**:一键连接串(自动识别家族)、默认连接(`database` 参数可选)、`list_tables` pattern 过滤、超 50 行结果自动导出 CSV/JSON
|
|
26
27
|
|
|
27
28
|
## 📦 安装
|
|
28
29
|
|
|
30
|
+
**npm 安装(推荐,任意机器可用):**
|
|
31
|
+
|
|
29
32
|
```bash
|
|
33
|
+
pi install npm:@nsyan/db
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**本地源码安装(本仓库开发者):**
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# 在本仓库根目录执行
|
|
30
40
|
pi install ./packages/db
|
|
31
41
|
```
|
|
32
42
|
|
|
33
|
-
> 依赖 `pg` / `mysql2` / `oracledb` / `dmdb` / `ioredis` / `@elastic/elasticsearch` / `es7` / `hive-driver` / `typebox`,`pi install` 自动安装。
|
|
43
|
+
> 依赖 `pg` / `mysql2` / `oracledb` / `dmdb` / `ioredis` / `@elastic/elasticsearch` / `es7` / `mongodb` / `hive-driver` / `typebox`,`pi install` 自动安装。
|
|
34
44
|
|
|
35
45
|
## 🚀 快速开始
|
|
36
46
|
|
|
@@ -41,7 +51,17 @@ pi install ./packages/db
|
|
|
41
51
|
/db config # 查看/修改全局设置
|
|
42
52
|
```
|
|
43
53
|
|
|
44
|
-
AI 侧接入后,直接用 `query_database` / `list_tables` / `describe_table` / `scan_project_configs`
|
|
54
|
+
AI 侧接入后,直接用 `query_database` / `list_tables` / `describe_table` / `scan_project_configs` / `db_connections` 五个工具即可。
|
|
55
|
+
|
|
56
|
+
## 🗂️ 连接管理
|
|
57
|
+
|
|
58
|
+
- **状态摘要列表**:连接选择器展示 `名称 [类型] ⭐默认 [prod·强制只读] · 上次使用 2 天前 · 测试 ✓45ms`,打开/编辑/删除/切默认共用
|
|
59
|
+
- **⚡ 切换默认**:一级菜单直达(选中即切),无需进入连接动作菜单
|
|
60
|
+
- **🧪 测试连接**:连接动作菜单内独立入口;**编辑保存后自动回测**,结果回显
|
|
61
|
+
- **📋 从现有复制**:新增连接第三条路径,复制类型/账号/环境标签等全部设置,仅需改名
|
|
62
|
+
- **环境标签 + 强制只读**:连接可标 `dev/test/prod`;`prod` 建连时主动建议开启**强制只读**——开启后该连接无视全局只读开关,永远只接受查询(AI 侧写入会被拒并提示原因)
|
|
63
|
+
- **db_connections 工具**:AI 可自查连接清单与环境标签,说“在生产库查一下”时先识别哪条是 prod
|
|
64
|
+
- 最近使用时间与测试结果自动回写,无需手工维护
|
|
45
65
|
|
|
46
66
|
## 🗄️ 支持的数据库
|
|
47
67
|
|
|
@@ -53,6 +73,7 @@ AI 侧接入后,直接用 `query_database` / `list_tables` / `describe_table`
|
|
|
53
73
|
| 达梦 DM | 关系型 | `dmdb` | DM8 全系;DM9 未验证 | 纯 JS 依赖,macOS/Linux/Windows 可用 |
|
|
54
74
|
| Redis | KV | `ioredis` | 2.8 ~ 8.x | `SCAN` 需 ≥2.8;`MEMORY USAGE` 需 ≥4.0,低版本自动跳过;生产库一律 SCAN,禁 `KEYS` |
|
|
55
75
|
| Elasticsearch | 搜索 | `@elastic/elasticsearch` v8 + v7(npm 别名 `es7`) | 7.x / 8.x / 更新大版本 | 连接时探测大版本分发对应客户端;未知大版本用最新客户端尝试并给出版本警告 |
|
|
76
|
+
| MongoDB | 文档 | `mongodb`(官方驱动,纯 JS) | **已验证主流区 6.0 / 7.0 / 8.0**;4.2~5.x 可用未验证(4.x 已 EOL) | `query_database` 的 sql 参数填 JSON 命令信封;`mongodb://` 与 Atlas `mongodb+srv://` 双形态;`describe_table` 优先读 `$jsonSchema` validator,缺失时采样 ≤100 文档推断字段(非权威);无 limit 自动补 maxRows |
|
|
56
77
|
| Hive | 大数据 | `hive-driver` | 目标 2.x;4.x 未实测 | HS2 Thrift 协议;无 4.x 环境前声明仅支持 2/3 |
|
|
57
78
|
| Spark(Thrift Server) | 大数据 | `hive-driver`(复用) | 2.x ~ 4.x | 与 Hive 同协议栈;Spark Connect(DataFrame/gRPC)不支持 |
|
|
58
79
|
|
|
@@ -68,11 +89,28 @@ jdbc:mysql://host:3306/db → MySQL
|
|
|
68
89
|
jdbc:oracle:thin:@//host:1521/svc → Oracle(也支持 SID 形式)
|
|
69
90
|
jdbc:dm://host:5236/schema → 达梦
|
|
70
91
|
redis://:pass@host:6379/0 → Redis(含 rediss:// TLS)
|
|
92
|
+
mongodb://user:pass@host:27017/db → MongoDB(含 +srv Atlas 形态,authSource/replicaSet 进 options)
|
|
71
93
|
http://host:9200 → Elasticsearch
|
|
72
94
|
jdbc:hive2://host:10000/db → Hive / Spark
|
|
73
95
|
```
|
|
74
96
|
|
|
75
|
-
逐步填写按家族分支:Redis 无账号要求、需库号(dbIndex);ES
|
|
97
|
+
逐步填写按家族分支:Redis 无账号要求、需库号(dbIndex);MongoDB 账号可空(本地无认证常见);ES/其余 URL + 账号 + 密码。
|
|
98
|
+
|
|
99
|
+
## 🍃 MongoDB 命令信封
|
|
100
|
+
|
|
101
|
+
MongoDB 走 `query_database`,`sql` 参数填 JSON 命令信封(`db.runCommand` 文档形态,单命令一次执行):
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{"find": "users", "filter": {"age": {"$gt": 18}}, "sort": {"created_at": -1}}
|
|
105
|
+
{"count": "users"}
|
|
106
|
+
{"aggregate": "orders", "pipeline": [{"$group": {"_id": "$status", "n": {"$sum": 1}}}]}
|
|
107
|
+
{"insert": "users", "documents": [{"name": "x"}]}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
- 未写 limit 的读命令自动补 `maxRows`,结果超限自动标 `truncated`
|
|
111
|
+
- 返回文档拍平为列(顶层字段并集,封顶 50 列,嵌套转 JSON 字符串)
|
|
112
|
+
- `list_tables` → `listCollections`;`describe_table` → `collStats` + 索引 + validator/采样字段推断
|
|
113
|
+
- 硬限制:`drop*`/`create*` 等管理 DDL 与服务端 JS(`$where`/`$function`/`$accumulator`)恒拒;aggregate 含 `$out`/`$merge` 按写操作处理
|
|
76
114
|
|
|
77
115
|
## 🔎 代码扫描建连 `/db scan`
|
|
78
116
|
|
|
@@ -99,8 +137,12 @@ AI 侧说「连一下这个项目的数据库」等,会调用 `scan_project_co
|
|
|
99
137
|
|
|
100
138
|
- **执行策略同步**:系统提示动态注入当前 AI 只读模式与执行确认设置
|
|
101
139
|
- **逐语句检查**:多语句 SQL 逐条检查,注释剥离后再匹配
|
|
102
|
-
- **硬限制**:DROP TABLE/DATABASE(关系型/大数据)、`FLUSHALL/FLUSHDB/CONFIG/SHUTDOWN`(Redis)、`DELETE <index>`(ES)
|
|
103
|
-
- **只读白名单**:Redis 读命令白名单、ES
|
|
140
|
+
- **硬限制**:DROP TABLE/DATABASE(关系型/大数据)、`FLUSHALL/FLUSHDB/CONFIG/SHUTDOWN`(Redis)、`DELETE <index>`(ES)、`drop*`/`create*`/管理命令/服务端 JS(MongoDB)
|
|
141
|
+
- **只读白名单**:Redis 读命令白名单、ES 读端点白名单、MongoDB 读命令白名单(JS 执行恒拒)
|
|
142
|
+
- **连接级强制只读**:标了 `forceReadonly` 的连接(如生产库)无视全局开关,永远只读
|
|
143
|
+
- **写操作执行理由**:AI 发起写操作必须附 `reason`(动机+影响范围),确认框首行展示、随结果回显;缺失时直接拒绝并引导补充,与确认策略无关
|
|
144
|
+
- **本地审计(默认关闭)**:`/db config → 审计日志` 开启后,写成功追加 `~/.pi/agent/db-audit/<YYYY-MM-DD>.jsonl`(按天分文件,含项目路径/连接/理由/**完整 SQL 不截断**,0600);审计失败不阻断主流程。清理示例:`find ~/.pi/agent/db-audit -name "*.jsonl" -mtime +90 -delete`。理由强校验不受此开关影响
|
|
145
|
+
- **凭据降险**:连接配置含明文密码,保存时自动 `chmod 0600`(仅当前用户可读写);系统钥匙串(keychain)列入远期规划
|
|
104
146
|
- **确认策略**:不确认 / 写操作确认 / 每次确认;无 UI 环境自动取消
|
|
105
147
|
- **手工查询一致**:`/db` 菜单手工执行与 AI 工具使用相同的策略裁决
|
|
106
148
|
|
|
@@ -123,6 +165,10 @@ cd packages/db
|
|
|
123
165
|
NODE_ENV=development pnpm test # 运行全部单测(tsx + node:test)
|
|
124
166
|
```
|
|
125
167
|
|
|
168
|
+
## 📜 更新记录
|
|
169
|
+
|
|
170
|
+
见仓库根目录 [CHANGELOG.md](../../CHANGELOG.md)。当前版本 **1.1.0**(2026-09-10):新增 MongoDB 支持(document 家族);连接管理体验升级(状态摘要/切换默认/从现有复制/环境标签+强制只读/db_connections 工具)。
|
|
171
|
+
|
|
126
172
|
## 📄 许可
|
|
127
173
|
|
|
128
174
|
[MIT](./LICENSE)
|
package/index.ts
CHANGED
|
@@ -2,20 +2,22 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { registry } from "./src/dialects/index.js";
|
|
5
|
-
import { decide } from "./src/core/policy.js";
|
|
5
|
+
import { decide, effectiveReadonly, writeRequiresReason } from "./src/core/policy.js";
|
|
6
|
+
import { appendAuditLog } from "./src/core/audit.js";
|
|
6
7
|
import { scanProject } from "./src/core/scan/candidates.js";
|
|
7
8
|
import type { Candidate, ConnConfig, DbTypeId } from "./src/core/types.js";
|
|
8
9
|
import {
|
|
9
10
|
loadConfigs, saveConfigs, loadPluginConfig, savePluginConfig, getConfigSummary,
|
|
10
11
|
findConfig, toRuntimeConfig, shortTypeLabel, fullTypeLabel,
|
|
11
12
|
parseConnectionString, getDefaultConfig, setDefaultConfig, writeQueryExport,
|
|
13
|
+
recordLastUsed, recordTestResult, connSummaryLine, envTagLabel, formatRelativeTime,
|
|
12
14
|
} from "./src/config.js";
|
|
13
15
|
import type { PluginConfig } from "./src/config.js";
|
|
14
16
|
|
|
15
17
|
// ── URL 解析:遍历 registry 各方言 parseUrl,首个非 null 胜出 ────
|
|
16
18
|
// 关系型 JDBC + 原生 URI 双形态由各方言 parseUrl 兼收(Spec §7)
|
|
17
19
|
|
|
18
|
-
function parseDbUrl(url: string): { dialectId: DbTypeId; host: string; port: number; username?: string; password?: string; database?: string; dbIndex?: number } | null {
|
|
20
|
+
function parseDbUrl(url: string): { dialectId: DbTypeId; host: string; port: number; username?: string; password?: string; database?: string; dbIndex?: number; options?: Record<string, string> } | null {
|
|
19
21
|
for (const d of registry.values()) {
|
|
20
22
|
const p = d.parseUrl(url);
|
|
21
23
|
if (p) return { dialectId: d.id, ...p };
|
|
@@ -37,9 +39,7 @@ function buildDisplayList(configs: ConnConfig[]): { list: string[]; map: Map<str
|
|
|
37
39
|
const map = new Map<string, ConnConfig>();
|
|
38
40
|
const list: string[] = [];
|
|
39
41
|
for (const c of configs) {
|
|
40
|
-
const
|
|
41
|
-
const desc = c.description ? ` - ${c.description}` : "";
|
|
42
|
-
const display = `${c.name} [${typeLabel}]${desc}`;
|
|
42
|
+
const display = connSummaryLine(c);
|
|
43
43
|
list.push(display);
|
|
44
44
|
map.set(display, c);
|
|
45
45
|
}
|
|
@@ -56,6 +56,8 @@ function familyHint(c: ConnConfig): string {
|
|
|
56
56
|
case "mysql":
|
|
57
57
|
case "oracle":
|
|
58
58
|
return "关系型,sql 参数填 SQL";
|
|
59
|
+
case "mongodb":
|
|
60
|
+
return "MongoDB 文档库,sql 参数填 JSON 命令信封(如 {\"find\":\"users\",\"filter\":{}};读命令 find/count/distinct/aggregate)";
|
|
59
61
|
default:
|
|
60
62
|
return `${fullTypeLabel(c.type)},sql 参数填查询或命令`;
|
|
61
63
|
}
|
|
@@ -93,13 +95,15 @@ function buildDbListHint(configs: ConnConfig[], cfg: PluginConfig): string {
|
|
|
93
95
|
}
|
|
94
96
|
const lines = configs.map((c) => {
|
|
95
97
|
const desc = c.description ? ` - ${c.description}` : "";
|
|
96
|
-
|
|
98
|
+
const tag = envTagLabel(c);
|
|
99
|
+
return `- ${c.name}[${shortTypeLabel(c.type)}]${tag ? " " + tag : ""} - ${fullTypeLabel(c.type)},${familyHint(c)}${desc}`;
|
|
97
100
|
});
|
|
98
101
|
return [
|
|
99
102
|
"[数据库工具执行策略]",
|
|
100
103
|
policy,
|
|
101
104
|
confirmation,
|
|
102
105
|
safety,
|
|
106
|
+
"标注 [prod·强制只读] 的连接无论全局只读设置如何均只允许查询。",
|
|
103
107
|
"[可用数据库]",
|
|
104
108
|
...lines,
|
|
105
109
|
"query_database / list_tables / describe_table 的 database 参数必须使用上述名称(不含中括号内容,名称区分大小写)。",
|
|
@@ -209,6 +213,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
209
213
|
const idx = all.findIndex((x) => x.name === name);
|
|
210
214
|
if (idx >= 0) all[idx] = conn; else all.push(conn);
|
|
211
215
|
saveConfigs(all);
|
|
216
|
+
recordTestResult(name, result);
|
|
212
217
|
ctx.ui.notify(`配置已保存: ${name}`, "success");
|
|
213
218
|
}
|
|
214
219
|
};
|
|
@@ -218,32 +223,60 @@ export default function (pi: ExtensionAPI) {
|
|
|
218
223
|
const name = (await ctx.ui.input("连接名称", ""))?.trim();
|
|
219
224
|
if (!name) { ctx.ui.notify("连接名称不能为空", "error"); return; }
|
|
220
225
|
|
|
221
|
-
// 首问:一键连接串 / 逐步填写
|
|
226
|
+
// 首问:一键连接串 / 逐步填写 / 从现有复制(v1.1 UX 共识 Q2)
|
|
222
227
|
const mode = await ctx.ui.select("添加方式", [
|
|
223
228
|
"⚡ 粘贴连接串(一键)",
|
|
224
229
|
"📝 逐步填写",
|
|
230
|
+
"📋 从现有复制",
|
|
225
231
|
]);
|
|
226
232
|
if (!mode) return;
|
|
227
233
|
|
|
234
|
+
if (mode === "📋 从现有复制") {
|
|
235
|
+
const source = await selectDbConfig(ctx, loadConfigs(), "选择要复制的连接");
|
|
236
|
+
if (!source) return;
|
|
237
|
+
let newName = (await ctx.ui.input("新连接名称", `${source.name}-copy`))?.trim();
|
|
238
|
+
while (newName && loadConfigs().some((c) => c.name === newName)) {
|
|
239
|
+
newName = (await ctx.ui.input(`名称已存在: ${newName},请换一个`, `${newName}-2`))?.trim();
|
|
240
|
+
}
|
|
241
|
+
if (!newName) { ctx.ui.notify("已取消复制", "info"); return; }
|
|
242
|
+
const copy: ConnConfig = {
|
|
243
|
+
...source,
|
|
244
|
+
id: randomUUID(),
|
|
245
|
+
name: newName,
|
|
246
|
+
isDefault: false,
|
|
247
|
+
lastUsedAt: undefined,
|
|
248
|
+
lastTest: undefined,
|
|
249
|
+
createdAt: new Date().toISOString(),
|
|
250
|
+
};
|
|
251
|
+
const all = loadConfigs();
|
|
252
|
+
all.push(copy);
|
|
253
|
+
saveConfigs(all);
|
|
254
|
+
ctx.ui.notify(`已复制为 ${newName}(类型/账号/环境标签等设置一并带上)`, "success");
|
|
255
|
+
if (await ctx.ui.confirm("从现有复制", "立即编辑副本(主机/端口/库/标签等)?")) {
|
|
256
|
+
await editDbConfig(ctx, copy);
|
|
257
|
+
}
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
228
261
|
let parsed: ReturnType<typeof parseDbUrl>;
|
|
229
262
|
let username = "";
|
|
230
263
|
let password = "";
|
|
231
264
|
let dbIndex: number | undefined;
|
|
232
265
|
|
|
233
266
|
if (mode === "⚡ 粘贴连接串(一键)") {
|
|
234
|
-
const url = (await ctx.ui.input("连接串", "postgresql://user:pass@host:5432/db 或 jdbc:mysql://... 或 redis://:pass@host:6379/0"))?.trim();
|
|
267
|
+
const url = (await ctx.ui.input("连接串", "postgresql://user:pass@host:5432/db 或 jdbc:mysql://... 或 redis://:pass@host:6379/0 或 mongodb://user:pass@host:27017/db"))?.trim();
|
|
235
268
|
if (!url) { ctx.ui.notify("连接串不能为空", "error"); return; }
|
|
236
269
|
const pcs = parseConnectionString(url);
|
|
237
270
|
if (!pcs) {
|
|
238
|
-
ctx.ui.notify("连接串无法识别。支持: postgresql/mysql/oracle/dm/hive JDBC、redis(s)://、http(s)://host:9200", "error");
|
|
271
|
+
ctx.ui.notify("连接串无法识别。支持: postgresql/mysql/oracle/dm/hive JDBC、redis(s)://、mongodb(srv)://、http(s)://host:9200", "error");
|
|
239
272
|
return;
|
|
240
273
|
}
|
|
241
|
-
parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database };
|
|
274
|
+
parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database, options: pcs.options };
|
|
242
275
|
username = pcs.username ?? "";
|
|
243
276
|
password = pcs.password ?? "";
|
|
244
277
|
dbIndex = pcs.dbIndex;
|
|
245
278
|
} else {
|
|
246
|
-
const url = (await ctx.ui.input("连接 URL", "jdbc:postgresql://host:port/database"))?.trim();
|
|
279
|
+
const url = (await ctx.ui.input("连接 URL", "jdbc:postgresql://host:port/database 或 mongodb://host:27017/db"))?.trim();
|
|
247
280
|
if (!url) { ctx.ui.notify("连接 URL 不能为空", "error"); return; }
|
|
248
281
|
const pcs = parseConnectionString(url);
|
|
249
282
|
if (!pcs) {
|
|
@@ -251,17 +284,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
251
284
|
" PostgreSQL/MySQL/DM/Hive: jdbc:<dialect>://host:port/db\n" +
|
|
252
285
|
" Oracle: jdbc:oracle:thin:@//host:port/service 或 @host:port:SID\n" +
|
|
253
286
|
" Redis: redis://[:password@]host:port[/db]\n" +
|
|
287
|
+
" MongoDB: mongodb://user:pass@host:27017/db 或 mongodb+srv://...\n" +
|
|
254
288
|
" ES: http://host:9200", "error");
|
|
255
289
|
return;
|
|
256
290
|
}
|
|
257
|
-
parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database };
|
|
291
|
+
parsed = { dialectId: pcs.dialectId, host: pcs.host, port: pcs.port, database: pcs.database, options: pcs.options };
|
|
258
292
|
dbIndex = pcs.dbIndex;
|
|
259
293
|
|
|
260
|
-
// 家族分支字段:Redis 无账号要求、需库号;ES
|
|
294
|
+
// 家族分支字段:Redis 无账号要求、需库号;MongoDB 账号可空;ES/其余 账号+密码
|
|
261
295
|
if (parsed.dialectId === "redis") {
|
|
262
296
|
password = (await ctx.ui.input("密码(可空)", ""))?.trim() ?? "";
|
|
263
297
|
const idxInput = (await ctx.ui.input("库号 dbIndex(0-15,缺省 0)", String(dbIndex ?? 0)))?.trim();
|
|
264
298
|
dbIndex = idxInput !== undefined && idxInput !== "" ? parseInt(idxInput, 10) : dbIndex;
|
|
299
|
+
} else if (parsed.dialectId === "mongodb") {
|
|
300
|
+
username = (await ctx.ui.input("账号(可空,本地无认证留空)", ""))?.trim() ?? "";
|
|
301
|
+
password = (await ctx.ui.input("密码(可空)", ""))?.trim() ?? "";
|
|
265
302
|
} else {
|
|
266
303
|
username = (await ctx.ui.input("账号", "root"))?.trim() || "root";
|
|
267
304
|
password = (await ctx.ui.input("密码", ""))?.trim() ?? "";
|
|
@@ -286,6 +323,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
286
323
|
}
|
|
287
324
|
ctx.ui.notify(`连接成功 (${result.version}, ${result.latency})`, "success");
|
|
288
325
|
|
|
326
|
+
// 环境标签(v1.1 UX 共识 Q3):测试通过后再问,失败不浪费输入;prod 主动建议强制只读
|
|
327
|
+
let envTag: ConnConfig["envTag"];
|
|
328
|
+
let forceReadonly: boolean | undefined;
|
|
329
|
+
const envChoice = await ctx.ui.select("环境标签(可跳过)", ["跳过", "dev", "test", "prod"]);
|
|
330
|
+
if (envChoice && envChoice !== "跳过") {
|
|
331
|
+
envTag = envChoice as ConnConfig["envTag"];
|
|
332
|
+
if (envTag === "prod") {
|
|
333
|
+
forceReadonly = await ctx.ui.confirm(
|
|
334
|
+
"生产库安全建议",
|
|
335
|
+
"将此连接设为强制只读?\n开启后该连接无视全局只读开关,永远只接受查询(生产库建议开启)。",
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
289
340
|
const configs = loadConfigs();
|
|
290
341
|
if (configs.some((c) => c.name === name)) {
|
|
291
342
|
ctx.ui.notify(`已存在同名配置: ${name}`, "error");
|
|
@@ -303,13 +354,17 @@ export default function (pi: ExtensionAPI) {
|
|
|
303
354
|
password,
|
|
304
355
|
database: parsed.database,
|
|
305
356
|
dbIndex,
|
|
357
|
+
options: parsed.options,
|
|
358
|
+
envTag,
|
|
359
|
+
forceReadonly,
|
|
306
360
|
isDefault: configs.length === 0, // 首个连接自动设为默认(与提示文案一致)
|
|
307
361
|
createdAt: new Date().toISOString(),
|
|
308
362
|
};
|
|
309
363
|
|
|
310
364
|
configs.push(config);
|
|
311
365
|
saveConfigs(configs);
|
|
312
|
-
|
|
366
|
+
recordTestResult(name, result);
|
|
367
|
+
ctx.ui.notify(`配置已保存: ${name}${envTag ? ` [${envTag}${forceReadonly ? "·强制只读" : ""}]` : ""}${configs.length === 1 ? "(首个连接已设为默认)" : ""}`, "success");
|
|
313
368
|
};
|
|
314
369
|
|
|
315
370
|
// ── 编辑数据库连接 ──────────────────────────────
|
|
@@ -320,6 +375,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
320
375
|
`URL: ${currentUrl}`,
|
|
321
376
|
`账号: ${original.username}`,
|
|
322
377
|
`密码: ${original.password || ""}`,
|
|
378
|
+
`环境标签: ${original.envTag ?? ""}`,
|
|
379
|
+
`强制只读: ${original.forceReadonly ? "是" : "否"}`,
|
|
323
380
|
`说明: ${original.description || ""}`,
|
|
324
381
|
].join("\n");
|
|
325
382
|
|
|
@@ -353,6 +410,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
353
410
|
const descRaw = getValue("说明");
|
|
354
411
|
const description = descRaw === "" ? undefined : (descRaw || original.description);
|
|
355
412
|
|
|
413
|
+
// 环境标签/强制只读(行缺省=保持原值;标签仅接受 dev/test/prod,其余/清空=去除标签)
|
|
414
|
+
const envRaw = getValue("环境标签")?.trim().toLowerCase();
|
|
415
|
+
const envTag = envRaw === undefined || envRaw === ""
|
|
416
|
+
? (envRaw === "" ? undefined : original.envTag)
|
|
417
|
+
: (["dev", "test", "prod"].includes(envRaw) ? envRaw as ConnConfig["envTag"] : original.envTag);
|
|
418
|
+
const roRaw = getValue("强制只读")?.trim();
|
|
419
|
+
const forceReadonly = roRaw === undefined ? original.forceReadonly : roRaw === "是";
|
|
420
|
+
|
|
356
421
|
const updated: ConnConfig = {
|
|
357
422
|
...original,
|
|
358
423
|
name,
|
|
@@ -362,6 +427,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
362
427
|
username,
|
|
363
428
|
password,
|
|
364
429
|
database: parsed.database,
|
|
430
|
+
options: parsed.options ?? original.options, // displayUrl 不带查询参数,保留原 options(authSource 等)
|
|
431
|
+
envTag,
|
|
432
|
+
forceReadonly,
|
|
365
433
|
description,
|
|
366
434
|
};
|
|
367
435
|
|
|
@@ -379,6 +447,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
379
447
|
all[idx] = updated;
|
|
380
448
|
saveConfigs(all);
|
|
381
449
|
ctx.ui.notify(`已更新: ${updated.name}`, "success");
|
|
450
|
+
|
|
451
|
+
// 编辑后自动回测(v1.1 UX 共识 Q1):结果回写 lastTest,不阻塞保存
|
|
452
|
+
const testDialect = registry.get(updated.type)!;
|
|
453
|
+
ctx.ui.notify(`正在测试 ${testDialect.label} 连接...`, "info");
|
|
454
|
+
const test = await testDialect.testConnection(toRuntimeConfig(updated, testDialect.defaultPort));
|
|
455
|
+
recordTestResult(updated.name, test);
|
|
456
|
+
ctx.ui.notify(
|
|
457
|
+
test.success
|
|
458
|
+
? `✓ 连接正常 (${test.version ?? "?"}, ${test.latency ?? "?"})`
|
|
459
|
+
: `⚠ 已保存,但连接测试失败: ${test.error}`,
|
|
460
|
+
test.success ? "success" : "error",
|
|
461
|
+
);
|
|
382
462
|
};
|
|
383
463
|
|
|
384
464
|
// ── 选择数据库公共操作 ────────────────────────────
|
|
@@ -398,10 +478,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
398
478
|
ctx.ui.notify("尚无数据库连接", "info");
|
|
399
479
|
return;
|
|
400
480
|
}
|
|
401
|
-
const lines = configs.map((c) => {
|
|
402
|
-
const desc = c.description ? ` - ${c.description}` : "";
|
|
403
|
-
return ` ${c.name} [${shortTypeLabel(c.type)}]${desc}`;
|
|
404
|
-
});
|
|
481
|
+
const lines = configs.map((c) => ` ${connSummaryLine(c)}`);
|
|
405
482
|
ctx.ui.notify(`数据库连接 (${configs.length}):\n${lines.join("\n")}`, "info");
|
|
406
483
|
};
|
|
407
484
|
|
|
@@ -423,6 +500,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
423
500
|
"📝 执行查询",
|
|
424
501
|
"📋 列出表",
|
|
425
502
|
"🔍 查看详情",
|
|
503
|
+
"🧪 测试连接",
|
|
426
504
|
config.isDefault ? "⭐ 取消默认" : "⭐ 设为默认",
|
|
427
505
|
"✏️ 编辑",
|
|
428
506
|
"🗑️ 删除",
|
|
@@ -441,8 +519,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
441
519
|
if (!sql) return;
|
|
442
520
|
const cfg = loadPluginConfig();
|
|
443
521
|
const dialect = registry.get(config.type)!;
|
|
444
|
-
const
|
|
445
|
-
const
|
|
522
|
+
const effRo = effectiveReadonly(cfg.ai_readonly, config); // 连接级强制只读生效(v1.2 Q3)
|
|
523
|
+
const verdict = dialect.isAllowed(sql, effRo);
|
|
524
|
+
const action = decide(verdict, effRo, cfg.confirm_before_exec);
|
|
446
525
|
if (action === "deny") {
|
|
447
526
|
ctx.ui.notify(`不允许执行: ${verdict.reason}`, "error");
|
|
448
527
|
continue;
|
|
@@ -456,9 +535,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
456
535
|
}
|
|
457
536
|
ctx.ui.notify("正在执行查询...", "info");
|
|
458
537
|
const result = await dialect.executeOn(toRuntimeConfig(config, dialect.defaultPort), sql, {
|
|
459
|
-
readonly:
|
|
538
|
+
readonly: effRo, maxRows: cfg.max_rows, timeoutSec: cfg.query_timeout,
|
|
460
539
|
});
|
|
461
540
|
if (result.success) {
|
|
541
|
+
recordLastUsed(config.name);
|
|
462
542
|
const lines = [`查询完成 (${result.duration})`, `返回 ${result.rowCount} 行`];
|
|
463
543
|
if (result.columns && result.columns.length > 0) {
|
|
464
544
|
lines.push("列: " + result.columns.join(", "));
|
|
@@ -483,6 +563,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
483
563
|
} else {
|
|
484
564
|
ctx.ui.notify(`查询失败: ${result.error}`, "error");
|
|
485
565
|
}
|
|
566
|
+
} else if (choice === "🧪 测试连接") {
|
|
567
|
+
const dialect = registry.get(config.type)!;
|
|
568
|
+
ctx.ui.notify(`正在测试 ${dialect.label} 连接...`, "info");
|
|
569
|
+
const result = await dialect.testConnection(toRuntimeConfig(config, dialect.defaultPort));
|
|
570
|
+
recordTestResult(config.name, result);
|
|
571
|
+
ctx.ui.notify(
|
|
572
|
+
result.success
|
|
573
|
+
? `✓ 连接正常 (${result.version ?? "?"}, ${result.latency ?? "?"})`
|
|
574
|
+
: `✗ 连接失败: ${result.error}`,
|
|
575
|
+
result.success ? "success" : "error",
|
|
576
|
+
);
|
|
577
|
+
continue;
|
|
486
578
|
} else if (choice === "📋 列出表") {
|
|
487
579
|
const dialect = registry.get(config.type)!;
|
|
488
580
|
const result = await dialect.listTables(toRuntimeConfig(config, dialect.defaultPort));
|
|
@@ -555,6 +647,12 @@ export default function (pi: ExtensionAPI) {
|
|
|
555
647
|
label: "查询超时(s)",
|
|
556
648
|
current: String(cfg.query_timeout),
|
|
557
649
|
},
|
|
650
|
+
{
|
|
651
|
+
key: "audit_enabled" as const,
|
|
652
|
+
label: "审计日志",
|
|
653
|
+
current: cfg.audit_enabled ? "是" : "否",
|
|
654
|
+
options: ["是", "否"],
|
|
655
|
+
},
|
|
558
656
|
];
|
|
559
657
|
|
|
560
658
|
// 选择要修改的字段
|
|
@@ -596,7 +694,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
596
694
|
const updatedFields = fields.map((f) => {
|
|
597
695
|
const val = (newCfg as any)[f.key];
|
|
598
696
|
const display =
|
|
599
|
-
f.key === "ai_readonly" ? (val ? "是" : "否") :
|
|
697
|
+
f.key === "ai_readonly" || f.key === "audit_enabled" ? (val ? "是" : "否") :
|
|
600
698
|
f.key === "confirm_before_exec" ?
|
|
601
699
|
(val === "never" ? "不确认" : val === "write" ? "写操作确认" : "每次都确认") :
|
|
602
700
|
String(val);
|
|
@@ -644,13 +742,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
644
742
|
pi.registerTool({
|
|
645
743
|
name: "query_database",
|
|
646
744
|
label: "数据库查询",
|
|
647
|
-
description: "执行 SQL 语句,支持关系型(PostgreSQL/MySQL/Oracle/达梦)/ Redis / Elasticsearch / Hive / Spark
|
|
648
|
-
promptSnippet: "执行 SQL
|
|
745
|
+
description: "执行 SQL 语句,支持关系型(PostgreSQL/MySQL/Oracle/达梦)/ Redis / Elasticsearch / MongoDB / Hive / Spark 九种数据库,返回执行结果。支持读和写,写操作受确认策略约束且必须附 reason 执行理由(动机+影响范围),用户确认框将展示该理由;是否允许写以及是否需确认,以系统提示中的当前数据库工具执行策略为准。DROP TABLE 始终禁止。MongoDB 的 sql 参数填 JSON 命令信封(db.runCommand 形态,如 {\"find\":\"users\",\"filter\":{}})。",
|
|
746
|
+
promptSnippet: "执行 SQL 语句。先根据系统提示中的当前数据库工具执行策略判断是否允许写操作;写操作必须在 reason 参数说明动机与影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\"),否则会被拒绝。database 参数取系统提示「可用数据库」列表中的名称(缺省走默认连接)。使用 list_tables 查看表结构后再编写 SQL。",
|
|
649
747
|
parameters: Type.Object({
|
|
650
748
|
database: Type.Optional(Type.String({ description: "数据库连接名称(取系统提示「可用数据库」列表中的名称;缺省走默认连接)" })),
|
|
651
|
-
sql: Type.String({ description: "SQL
|
|
749
|
+
sql: Type.String({ description: "SQL 语句;MongoDB 填 JSON 命令信封,Redis 填命令,ES 填 DSL" }),
|
|
750
|
+
reason: Type.Optional(Type.String({ description: "执行理由,写操作必填:动机+影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\")。读操作无需填写" })),
|
|
652
751
|
}),
|
|
653
|
-
async execute(_toolCallId: string, params: { database?: string; sql: string }, _signal: any, _onUpdate?: any, ctx?: any) {
|
|
752
|
+
async execute(_toolCallId: string, params: { database?: string; sql: string; reason?: string }, _signal: any, _onUpdate?: any, ctx?: any) {
|
|
654
753
|
const cfg = loadPluginConfig();
|
|
655
754
|
|
|
656
755
|
const target = resolveTargetDb(params.database);
|
|
@@ -661,6 +760,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
661
760
|
}
|
|
662
761
|
const config = target.config;
|
|
663
762
|
|
|
763
|
+
// 生效只读 = 全局只读 ∨ 连接级强制只读(v1.1 UX 共识 Q3)
|
|
764
|
+
const effectiveReadOnly = effectiveReadonly(cfg.ai_readonly, config);
|
|
765
|
+
|
|
664
766
|
// verdict 流程(策略层统一裁决,含 DROP 硬限制与只读检查):
|
|
665
767
|
// verdict = dialect.isAllowed(sql, readonly) → decide → deny/confirm/run
|
|
666
768
|
const dialect = registry.get(config.type);
|
|
@@ -669,11 +771,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
669
771
|
content: [{ type: "text" as const, text: `数据库类型 "${config.type}" 暂不支持。` }],
|
|
670
772
|
};
|
|
671
773
|
}
|
|
672
|
-
const verdict = dialect.isAllowed(params.sql,
|
|
673
|
-
const action = decide(verdict,
|
|
774
|
+
const verdict = dialect.isAllowed(params.sql, effectiveReadOnly);
|
|
775
|
+
const action = decide(verdict, effectiveReadOnly, cfg.confirm_before_exec);
|
|
674
776
|
if (action === "deny") {
|
|
777
|
+
const note = !cfg.ai_readonly && config.forceReadonly === true
|
|
778
|
+
? `(连接 ${config.name} 已设置强制只读)`
|
|
779
|
+
: "";
|
|
780
|
+
return {
|
|
781
|
+
content: [{ type: "text" as const, text: (verdict.reason ?? "该操作不被允许。如需修改,请执行 /db config 更改配置。") + note }],
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
// 写操作强制附执行理由(v1.1 UX 共识 Q1/Q3:与确认策略解耦;缺 reason 拒绝并引导 AI 补充)
|
|
785
|
+
if (writeRequiresReason(verdict, params.reason)) {
|
|
675
786
|
return {
|
|
676
|
-
content: [{ type: "text" as const, text:
|
|
787
|
+
content: [{ type: "text" as const, text: "写操作必须附执行理由:请在 reason 参数中说明动机与影响范围(如\"将status=2的历史订单归档,预计影响1.2万行\"),补充后重试。" }],
|
|
677
788
|
};
|
|
678
789
|
}
|
|
679
790
|
if (action === "confirm") {
|
|
@@ -684,7 +795,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
684
795
|
}
|
|
685
796
|
const ok = await ctx.ui.confirm(
|
|
686
797
|
"SQL 执行确认",
|
|
687
|
-
|
|
798
|
+
`理由: ${params.reason}\n\n${verdict.summary ?? ""}\n\n数据库: ${config.name}\n\nSQL:\n${params.sql}`,
|
|
688
799
|
);
|
|
689
800
|
if (!ok) {
|
|
690
801
|
return {
|
|
@@ -697,7 +808,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
697
808
|
const result = await dialect.executeOn(
|
|
698
809
|
toRuntimeConfig(config, dialect.defaultPort),
|
|
699
810
|
params.sql,
|
|
700
|
-
{ readonly:
|
|
811
|
+
{ readonly: effectiveReadOnly, maxRows: cfg.max_rows, timeoutSec: cfg.query_timeout },
|
|
701
812
|
);
|
|
702
813
|
|
|
703
814
|
if (!result.success) {
|
|
@@ -706,6 +817,20 @@ export default function (pi: ExtensionAPI) {
|
|
|
706
817
|
};
|
|
707
818
|
}
|
|
708
819
|
|
|
820
|
+
recordLastUsed(config.name);
|
|
821
|
+
// 写操作审计(v1.1 UX 共识 Q4 + 可配置修订:默认关闭,cfg.audit_enabled 开启才落盘;失败不阻断主流程)
|
|
822
|
+
if (verdict.isWrite && cfg.audit_enabled) {
|
|
823
|
+
appendAuditLog({
|
|
824
|
+
time: new Date().toISOString(),
|
|
825
|
+
project: process.cwd(),
|
|
826
|
+
connection: config.name,
|
|
827
|
+
type: config.type,
|
|
828
|
+
summary: verdict.summary ?? "",
|
|
829
|
+
reason: (params.reason ?? "").trim(),
|
|
830
|
+
sql: params.sql,
|
|
831
|
+
readonly: effectiveReadOnly,
|
|
832
|
+
});
|
|
833
|
+
}
|
|
709
834
|
let text = `查询完成 (${result.duration}),返回 ${result.rowCount} 行\n`;
|
|
710
835
|
if (result.columns && result.columns.length > 0) {
|
|
711
836
|
text += `列: ${result.columns.join(", ")}\n\n`;
|
|
@@ -730,6 +855,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
730
855
|
} else {
|
|
731
856
|
text += "无数据返回。";
|
|
732
857
|
}
|
|
858
|
+
// 写操作理由随结果回显(v1.1 UX 共识 Q2:说了什么→做了什么闭环)
|
|
859
|
+
if (verdict.isWrite) {
|
|
860
|
+
text += `\n执行理由: ${params.reason}`;
|
|
861
|
+
}
|
|
733
862
|
|
|
734
863
|
return { content: [{ type: "text" as const, text }] };
|
|
735
864
|
},
|
|
@@ -767,6 +896,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
767
896
|
content: [{ type: "text" as const, text: `获取表列表失败: ${result.error}` }],
|
|
768
897
|
};
|
|
769
898
|
}
|
|
899
|
+
recordLastUsed(config.name);
|
|
770
900
|
|
|
771
901
|
const lines = result.tables.map((t) => {
|
|
772
902
|
const schema = t.schema ? `${t.schema}.` : "";
|
|
@@ -812,6 +942,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
812
942
|
content: [{ type: "text" as const, text: `获取表结构失败: ${result.error}` }],
|
|
813
943
|
};
|
|
814
944
|
}
|
|
945
|
+
recordLastUsed(config.name);
|
|
815
946
|
|
|
816
947
|
const lines = [`表: ${params.table}`, `共 ${result.count} 列\n`];
|
|
817
948
|
// 表头
|
|
@@ -861,6 +992,35 @@ export default function (pi: ExtensionAPI) {
|
|
|
861
992
|
},
|
|
862
993
|
});
|
|
863
994
|
|
|
995
|
+
// 工具 5: db_connections(v1.1 UX 共识 Q5:AI 自查连接清单与环境标签,识别生产库)
|
|
996
|
+
pi.registerTool({
|
|
997
|
+
name: "db_connections",
|
|
998
|
+
label: "列出数据库连接",
|
|
999
|
+
description: "列出所有已配置的数据库连接(名称/类型/环境标签/默认标记/最近测试结果/连接地址,不含密码)。用于确认可用连接、识别生产库。增删改连接需用户在终端执行 /db。",
|
|
1000
|
+
promptSnippet: "当用户提到某个环境(如“生产库”)或不确定该用哪个连接时,先调用本工具确认连接清单再查询;database 参数应使用返回列表中的名称。",
|
|
1001
|
+
parameters: Type.Object({}),
|
|
1002
|
+
async execute() {
|
|
1003
|
+
const configs = loadConfigs();
|
|
1004
|
+
if (configs.length === 0) {
|
|
1005
|
+
return {
|
|
1006
|
+
content: [{ type: "text" as const, text: "尚无数据库连接。请引导用户在终端执行 /db add 或 /db scan 建连。" }],
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
const lines = configs.map((c) => {
|
|
1010
|
+
const marks = [c.isDefault ? "⭐默认" : "", envTagLabel(c)].filter(Boolean).join(" ");
|
|
1011
|
+
const test = c.lastTest
|
|
1012
|
+
? `测试 ${formatRelativeTime(c.lastTest.at)} ${c.lastTest.ok ? "✓" : "✗"}${c.lastTest.latency ? " " + c.lastTest.latency : ""}${c.lastTest.version ? " · " + c.lastTest.version : ""}`
|
|
1013
|
+
: "未测试";
|
|
1014
|
+
const used = c.lastUsedAt ? `上次使用 ${formatRelativeTime(c.lastUsedAt)}` : "";
|
|
1015
|
+
const url = registry.get(c.type)?.displayUrl(c) ?? "";
|
|
1016
|
+
return `- ${c.name} [${shortTypeLabel(c.type)}]${marks ? " " + marks : ""} - ${fullTypeLabel(c.type)}${c.database ? " · 库: " + c.database : ""} · ${url} · ${test}${used ? " · " + used : ""}${c.description ? " · " + c.description : ""}`;
|
|
1017
|
+
});
|
|
1018
|
+
return {
|
|
1019
|
+
content: [{ type: "text" as const, text: `共 ${configs.length} 个连接:\n${lines.join("\n")}\n\n标注 [prod·强制只读] 的连接永远只读(即使全局允许写操作)。` }],
|
|
1020
|
+
};
|
|
1021
|
+
},
|
|
1022
|
+
});
|
|
1023
|
+
|
|
864
1024
|
// ── 注册 /db 命令(给用户管理连接) ──────────────
|
|
865
1025
|
pi.registerCommand("db", {
|
|
866
1026
|
description: "AI 接入数据库",
|
|
@@ -872,6 +1032,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
872
1032
|
// 导航菜单:查看 / 编辑 / 新增 / 删除 / 设置
|
|
873
1033
|
const navActions = [
|
|
874
1034
|
"📋 打开连接",
|
|
1035
|
+
"⚡ 切换默认",
|
|
875
1036
|
"✏️ 编辑连接",
|
|
876
1037
|
"➕ 新增连接",
|
|
877
1038
|
"🔎 扫描建连",
|
|
@@ -886,6 +1047,22 @@ export default function (pi: ExtensionAPI) {
|
|
|
886
1047
|
} else if (navChoice === "📋 打开连接") {
|
|
887
1048
|
const config = await selectDbConfig(ctx, configs, "选择连接");
|
|
888
1049
|
if (config) await showDbActions(ctx, config);
|
|
1050
|
+
} else if (navChoice === "⚡ 切换默认") {
|
|
1051
|
+
// v1.1 UX 共识 Q1:两层直达,替代“打开→动作→设为默认”三层路径
|
|
1052
|
+
const configsNow = loadConfigs();
|
|
1053
|
+
if (configsNow.length === 0) {
|
|
1054
|
+
ctx.ui.notify("尚无数据库连接", "info");
|
|
1055
|
+
} else {
|
|
1056
|
+
const target = await selectDbConfig(ctx, configsNow, "设为默认连接(⭐ 为当前默认)");
|
|
1057
|
+
if (target) {
|
|
1058
|
+
if (target.isDefault) {
|
|
1059
|
+
ctx.ui.notify(`已是默认连接: ${target.name}`, "info");
|
|
1060
|
+
} else {
|
|
1061
|
+
saveConfigs(setDefaultConfig(configsNow, target.id));
|
|
1062
|
+
ctx.ui.notify(`默认连接已切换: ${target.name}`, "success");
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
889
1066
|
} else if (navChoice === "✏️ 编辑连接") {
|
|
890
1067
|
const config = await selectDbConfig(ctx, configs, "选择要编辑的连接");
|
|
891
1068
|
if (config) await editDbConfig(ctx, config);
|