@yejiming/dsh-data-agent 0.0.1
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/LICENSE +21 -0
- package/README.en.md +185 -0
- package/README.md +185 -0
- package/cordis.patch.yml +16 -0
- package/lib/client.js +1157 -0
- package/lib/client.js.map +1 -0
- package/lib/defaults-D__D30ED.js +283 -0
- package/lib/index.js +153 -0
- package/lib/invariant.js +22 -0
- package/lib/query-DAjhNTo8.js +86 -0
- package/lib/routes.js +250 -0
- package/lib/tool.js +107 -0
- package/lib/types/client/DataAgentWorkbench.d.ts +22 -0
- package/lib/types/client/index.d.ts +27 -0
- package/lib/types/client/locales.d.ts +95 -0
- package/lib/types/client/persistence.d.ts +37 -0
- package/lib/types/clients.d.ts +96 -0
- package/lib/types/connections.d.ts +70 -0
- package/lib/types/defaults.d.ts +20 -0
- package/lib/types/index.d.ts +143 -0
- package/lib/types/invariant.d.ts +15 -0
- package/lib/types/query.d.ts +60 -0
- package/lib/types/routes.d.ts +107 -0
- package/lib/types/tool.d.ts +61 -0
- package/package.json +102 -0
- package/preset/data-agent/agent.cordis.yml +45 -0
- package/preset/data-agent/preset.yml +3 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { c as buildIntrospectTemplate, s as buildClientTemplate } from "./defaults-D__D30ED.js";
|
|
2
|
+
//#region src/query.ts
|
|
3
|
+
/** Read one collected stream from offset 0. */
|
|
4
|
+
function readCaptured(reader) {
|
|
5
|
+
if (reader === void 0) return {
|
|
6
|
+
text: "",
|
|
7
|
+
truncated: false
|
|
8
|
+
};
|
|
9
|
+
const read = reader.readFrom(0);
|
|
10
|
+
return {
|
|
11
|
+
text: read.text,
|
|
12
|
+
truncated: read.lossy
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Run one SQL text through the type's CLI client. The SQL is written to the
|
|
17
|
+
* child's stdin (`{ data }` batch disposition) so it never appears in argv;
|
|
18
|
+
* passwords travel in the env entries built by the template.
|
|
19
|
+
*
|
|
20
|
+
* Failure classification:
|
|
21
|
+
* - the caller's external signal (e.g. the tool exec signal) aborts → the
|
|
22
|
+
* abort reason propagates;
|
|
23
|
+
* - the internal timeout fires → an Error naming the deadline is thrown;
|
|
24
|
+
* - the executable cannot be resolved → an Error naming the command is thrown;
|
|
25
|
+
* - the process runs to completion → `{ exitCode, stdout, stderr, truncated }`
|
|
26
|
+
* is returned even for a non-zero exit (the caller decides what that means).
|
|
27
|
+
* @param ctx - context exposing the subprocess service.
|
|
28
|
+
* @param connection - the stored connection (password included).
|
|
29
|
+
* @param sql - the SQL text (or client command) to run.
|
|
30
|
+
* @param options - timeouts, caps, client overrides.
|
|
31
|
+
* @param externalSignal - caller-owned cancellation (the tool exec signal).
|
|
32
|
+
* @param introspect - use the machine-readable introspection flag set.
|
|
33
|
+
* @returns the captured outcome.
|
|
34
|
+
*/
|
|
35
|
+
async function runClientQuery(ctx, connection, sql, options, externalSignal, introspect = false) {
|
|
36
|
+
const template = introspect ? buildIntrospectTemplate(connection.type, connection, options.clients[connection.type]) : buildClientTemplate(connection.type, connection, options.clients[connection.type]);
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timer = setTimeout(() => controller.abort(/* @__PURE__ */ new Error(`查询超过 ${options.timeoutMs}ms 未完成,已终止客户端进程`)), options.timeoutMs);
|
|
39
|
+
const onExternalAbort = () => {
|
|
40
|
+
controller.abort(externalSignal.reason);
|
|
41
|
+
};
|
|
42
|
+
if (externalSignal.aborted) controller.abort(externalSignal.reason);
|
|
43
|
+
else externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
44
|
+
try {
|
|
45
|
+
let executable;
|
|
46
|
+
try {
|
|
47
|
+
executable = await ctx.subprocess.resolveExecutable(template.command, template.env, controller.signal);
|
|
48
|
+
} catch (error) {
|
|
49
|
+
controller.signal.throwIfAborted();
|
|
50
|
+
throw new Error(`无法解析数据库客户端 "${template.command}"(${error instanceof Error ? error.message : String(error)});请确认客户端已安装,或在 data-agent 插件配置的 clients 中覆盖命令名/路径`);
|
|
51
|
+
}
|
|
52
|
+
const handle = ctx.subprocess.spawn({
|
|
53
|
+
argv: [executable, ...template.args],
|
|
54
|
+
cwd: process.cwd(),
|
|
55
|
+
stdio: {
|
|
56
|
+
stdin: { data: `${template.stdinPrefix}${sql}\n` },
|
|
57
|
+
stdout: { maxBytes: options.maxResultChars },
|
|
58
|
+
stderr: { maxBytes: options.maxResultChars }
|
|
59
|
+
},
|
|
60
|
+
graceMs: options.graceMs ?? 5e3,
|
|
61
|
+
signal: controller.signal,
|
|
62
|
+
env: template.env
|
|
63
|
+
});
|
|
64
|
+
let outcome;
|
|
65
|
+
try {
|
|
66
|
+
outcome = await handle.done;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
controller.signal.throwIfAborted();
|
|
69
|
+
throw new Error(`启动数据库客户端失败:${error instanceof Error ? error.message : String(error)}`);
|
|
70
|
+
}
|
|
71
|
+
if (controller.signal.aborted) controller.signal.throwIfAborted();
|
|
72
|
+
const stdout = readCaptured(handle.collected.stdout);
|
|
73
|
+
const stderr = readCaptured(handle.collected.stderr);
|
|
74
|
+
return {
|
|
75
|
+
exitCode: outcome.exitCode,
|
|
76
|
+
stdout: stdout.text,
|
|
77
|
+
stderr: stderr.text,
|
|
78
|
+
truncated: stdout.truncated || stderr.truncated
|
|
79
|
+
};
|
|
80
|
+
} finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
externalSignal.removeEventListener("abort", onExternalAbort);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
export { runClientQuery as t };
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { d as parseColumns, f as parseListing, i as DEFAULT_MAX_RESULT_CHARS, m as tableListingSql, o as DEFAULT_QUERY_TIMEOUT_MS, p as parseTableListing, r as DEFAULT_MAX_QUERY_CHARS, t as DEFAULT_CONNECT_TIMEOUT_MS, u as metadataQuery } from "./defaults-D__D30ED.js";
|
|
2
|
+
import { t as runClientQuery } from "./query-DAjhNTo8.js";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import z from "schemastery";
|
|
5
|
+
//#region src/routes.ts
|
|
6
|
+
/** Cordis plugin name (diagnostics only). */
|
|
7
|
+
const name = "data-agent-routes";
|
|
8
|
+
/**
|
|
9
|
+
* No top-level `inject` export: the row must ACTIVATE even in headless
|
|
10
|
+
* profiles where `webServer` never exists (a permanently pending entry
|
|
11
|
+
* breaks one-shot runs). The routes register through a nested inject fiber
|
|
12
|
+
* the moment the webserver and the connection store are both available.
|
|
13
|
+
*/
|
|
14
|
+
const inject = [];
|
|
15
|
+
/** Route prefix owned by this plugin (the browser half calls under it). */
|
|
16
|
+
const DATA_AGENT_PATH = "/plugins/data-agent";
|
|
17
|
+
/** Loader schema with deployment defaults (no library defaults). */
|
|
18
|
+
const Config = z.object({
|
|
19
|
+
connectTimeoutMs: z.number().step(1).min(1e3).default(DEFAULT_CONNECT_TIMEOUT_MS),
|
|
20
|
+
introspectMaxTables: z.number().step(1).min(1).default(500),
|
|
21
|
+
maxResultChars: z.number().step(1).min(1024).default(DEFAULT_MAX_RESULT_CHARS),
|
|
22
|
+
queryTimeoutMs: z.number().step(1).min(1e3).default(DEFAULT_QUERY_TIMEOUT_MS),
|
|
23
|
+
maxQueryChars: z.number().step(1).min(1024).default(DEFAULT_MAX_QUERY_CHARS)
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Validate an untrusted /connect body; sqlite paths resolve to absolute
|
|
27
|
+
* (the client resolves the path relative to its own cwd, so the server pins
|
|
28
|
+
* it at connect time). Oracle/Hive/Impala follow the mysql/postgres shape:
|
|
29
|
+
* host/port/user/database (Oracle database = service name/SID, Hive/Impala
|
|
30
|
+
* database = default schema).
|
|
31
|
+
*/
|
|
32
|
+
function validateConnectBody(value, cwd = process.cwd()) {
|
|
33
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error("请求体必须是 JSON 对象");
|
|
34
|
+
const candidate = value;
|
|
35
|
+
const sessionId = candidate.sessionId;
|
|
36
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
|
|
37
|
+
const type = candidate.type;
|
|
38
|
+
if (type !== "mysql" && type !== "postgres" && type !== "sqlite" && type !== "oracle" && type !== "hive" && type !== "impala") throw new Error("type 必须是 \"mysql\"、\"postgres\"、\"sqlite\"、\"oracle\"、\"hive\" 或 \"impala\"");
|
|
39
|
+
const database = candidate.database;
|
|
40
|
+
if (typeof database !== "string" || database.length === 0) throw new Error("database 必须是非空字符串" + (type === "sqlite" ? "(SQLite 为数据库文件路径)" : ""));
|
|
41
|
+
if (type === "sqlite") return {
|
|
42
|
+
sessionId,
|
|
43
|
+
type,
|
|
44
|
+
database: resolve(cwd, database)
|
|
45
|
+
};
|
|
46
|
+
const host = candidate.host;
|
|
47
|
+
if (host !== void 0 && typeof host !== "string") throw new Error("host 必须是字符串");
|
|
48
|
+
const port = candidate.port;
|
|
49
|
+
if (port !== void 0 && (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)) throw new Error("port 必须是 1-65535 的整数");
|
|
50
|
+
const user = candidate.user;
|
|
51
|
+
if (user !== void 0 && typeof user !== "string") throw new Error("user 必须是字符串");
|
|
52
|
+
const password = candidate.password;
|
|
53
|
+
if (password !== void 0 && typeof password !== "string") throw new Error("password 必须是字符串");
|
|
54
|
+
const connection = {
|
|
55
|
+
type,
|
|
56
|
+
database
|
|
57
|
+
};
|
|
58
|
+
if (typeof host === "string" && host.length > 0) connection.host = host;
|
|
59
|
+
if (port !== void 0) connection.port = port;
|
|
60
|
+
if (typeof user === "string" && user.length > 0) connection.user = user;
|
|
61
|
+
if (typeof password === "string" && password.length > 0) connection.password = password;
|
|
62
|
+
return {
|
|
63
|
+
sessionId,
|
|
64
|
+
type,
|
|
65
|
+
database,
|
|
66
|
+
...connection.host !== void 0 ? { host: connection.host } : {},
|
|
67
|
+
...connection.port !== void 0 ? { port: connection.port } : {},
|
|
68
|
+
...connection.user !== void 0 ? { user: connection.user } : {},
|
|
69
|
+
...connection.password !== void 0 ? { password: connection.password } : {}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Identifier whitelist for schema/table names in metadata queries. */
|
|
73
|
+
const IDENTIFIER_PATTERN = /^[A-Za-z0-9_$#.-]+$/;
|
|
74
|
+
/** Validate one schema/table identifier (rejects any injection-shaped input). */
|
|
75
|
+
function requireIdentifier(value, label) {
|
|
76
|
+
if (value === null || value.length === 0) throw new Error(`${label} 不能为空`);
|
|
77
|
+
if (!IDENTIFIER_PATTERN.test(value)) throw new Error(`${label} 含非法字符(仅允许字母、数字与 _ $ # . -)`);
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Mount the data-agent routes against the host webserver, when one exists.
|
|
82
|
+
* The registration rides a nested inject fiber so this row activates in every
|
|
83
|
+
* profile; headless profiles simply never get routes.
|
|
84
|
+
* @param ctx - host cordis context.
|
|
85
|
+
* @param config - validated loader configuration.
|
|
86
|
+
*/
|
|
87
|
+
function apply(ctx, config) {
|
|
88
|
+
ctx.inject([
|
|
89
|
+
"webServer",
|
|
90
|
+
"subprocess",
|
|
91
|
+
"dataAgentConnections"
|
|
92
|
+
], (scope) => {
|
|
93
|
+
const store = scope.dataAgentConnections;
|
|
94
|
+
const connectOptions = {
|
|
95
|
+
clients: {},
|
|
96
|
+
timeoutMs: config.connectTimeoutMs,
|
|
97
|
+
maxResultChars: config.maxResultChars
|
|
98
|
+
};
|
|
99
|
+
const queryOptions = {
|
|
100
|
+
clients: {},
|
|
101
|
+
timeoutMs: config.queryTimeoutMs,
|
|
102
|
+
maxResultChars: config.maxResultChars
|
|
103
|
+
};
|
|
104
|
+
const introspectMaxTables = config.introspectMaxTables;
|
|
105
|
+
/** Collect the request body into a parsed JSON value. */
|
|
106
|
+
const readJson = async (req) => {
|
|
107
|
+
const chunks = [];
|
|
108
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
109
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
110
|
+
if (raw.length === 0) return {};
|
|
111
|
+
return JSON.parse(raw);
|
|
112
|
+
};
|
|
113
|
+
/** The stored connection for one session, failing loud when absent. */
|
|
114
|
+
const requireConnection = (sessionId) => {
|
|
115
|
+
const connection = store.getWithSecret(sessionId);
|
|
116
|
+
if (connection === void 0) throw new Error("请先连接数据库(未找到当前会话的连接),再执行该操作");
|
|
117
|
+
return connection;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Run one metadata query in machine-readable mode and return its stdout;
|
|
121
|
+
* a non-zero exit throws with the client's stderr as the message.
|
|
122
|
+
*/
|
|
123
|
+
const runMetadata = async (connection, kind, schema, table) => {
|
|
124
|
+
const result = await runClientQuery(scope, connection, metadataQuery(kind, connection.type, schema, table), queryOptions, new AbortController().signal, true);
|
|
125
|
+
if (result.exitCode !== 0) {
|
|
126
|
+
const detail = result.stderr.trim() !== "" ? result.stderr.trim() : result.stdout.trim();
|
|
127
|
+
throw new Error(`元数据查询失败(exit ${result.exitCode}):${detail}`);
|
|
128
|
+
}
|
|
129
|
+
return result.stdout;
|
|
130
|
+
};
|
|
131
|
+
scope.effect(() => {
|
|
132
|
+
const dispose = scope.webServer.register({
|
|
133
|
+
kind: "prefix",
|
|
134
|
+
path: DATA_AGENT_PATH,
|
|
135
|
+
handler: async (req, res) => {
|
|
136
|
+
const writeJson = (status, body) => {
|
|
137
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
138
|
+
res.end(JSON.stringify(body));
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
const url = new URL(req.url ?? "/", "http://dsh.internal");
|
|
142
|
+
const segments = url.pathname.slice(19).split("/").filter(Boolean);
|
|
143
|
+
if (req.method === "POST" && segments.length === 1 && segments[0] === "connect") {
|
|
144
|
+
const request = validateConnectBody(await readJson(req));
|
|
145
|
+
const connection = {
|
|
146
|
+
type: request.type,
|
|
147
|
+
database: request.database,
|
|
148
|
+
...request.host !== void 0 ? { host: request.host } : {},
|
|
149
|
+
...request.port !== void 0 ? { port: request.port } : {},
|
|
150
|
+
...request.user !== void 0 ? { user: request.user } : {},
|
|
151
|
+
...request.password !== void 0 ? { password: request.password } : {}
|
|
152
|
+
};
|
|
153
|
+
const listing = await runClientQuery(scope, connection, tableListingSql(connection.type, connection), connectOptions, new AbortController().signal, true);
|
|
154
|
+
if (listing.exitCode !== 0) {
|
|
155
|
+
const detail = listing.stderr.trim() !== "" ? listing.stderr.trim() : listing.stdout.trim();
|
|
156
|
+
writeJson(200, {
|
|
157
|
+
ok: false,
|
|
158
|
+
error: `数据库连接验证失败(exit ${listing.exitCode}):${detail}`
|
|
159
|
+
});
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const tables = parseTableListing(connection.type, listing.stdout).slice(0, introspectMaxTables);
|
|
163
|
+
connection.tables = tables;
|
|
164
|
+
store.set(request.sessionId, connection);
|
|
165
|
+
writeJson(200, {
|
|
166
|
+
ok: true,
|
|
167
|
+
tables
|
|
168
|
+
});
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (req.method === "POST" && segments.length === 1 && segments[0] === "disconnect") {
|
|
172
|
+
const sessionId = (await readJson(req)).sessionId;
|
|
173
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
|
|
174
|
+
store.clear(sessionId);
|
|
175
|
+
writeJson(200, { ok: true });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (req.method === "GET" && segments.length === 1 && segments[0] === "status") {
|
|
179
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
180
|
+
const summary = store.get(sessionId);
|
|
181
|
+
writeJson(200, summary === void 0 ? { connected: false } : {
|
|
182
|
+
connected: true,
|
|
183
|
+
summary
|
|
184
|
+
});
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (req.method === "GET" && segments.length === 1 && segments[0] === "schemas") {
|
|
188
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
189
|
+
if (sessionId.length === 0) throw new Error("sessionId 不能为空");
|
|
190
|
+
const connection = requireConnection(sessionId);
|
|
191
|
+
const stdout = await runMetadata(connection, "schemas");
|
|
192
|
+
writeJson(200, {
|
|
193
|
+
ok: true,
|
|
194
|
+
schemas: parseListing(connection.type, stdout).slice(0, introspectMaxTables)
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (req.method === "GET" && segments.length === 1 && segments[0] === "tables") {
|
|
199
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
200
|
+
if (sessionId.length === 0) throw new Error("sessionId 不能为空");
|
|
201
|
+
const connection = requireConnection(sessionId);
|
|
202
|
+
const schema = connection.type === "sqlite" ? void 0 : requireIdentifier(url.searchParams.get("schema"), "schema");
|
|
203
|
+
const stdout = await runMetadata(connection, "tables", schema);
|
|
204
|
+
writeJson(200, {
|
|
205
|
+
ok: true,
|
|
206
|
+
tables: parseListing(connection.type, stdout).slice(0, introspectMaxTables)
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (req.method === "GET" && segments.length === 1 && segments[0] === "describe") {
|
|
211
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
212
|
+
if (sessionId.length === 0) throw new Error("sessionId 不能为空");
|
|
213
|
+
const connection = requireConnection(sessionId);
|
|
214
|
+
const schema = connection.type === "sqlite" ? void 0 : requireIdentifier(url.searchParams.get("schema"), "schema");
|
|
215
|
+
const table = requireIdentifier(url.searchParams.get("table"), "table");
|
|
216
|
+
const stdout = await runMetadata(connection, "describe", schema, table);
|
|
217
|
+
writeJson(200, {
|
|
218
|
+
ok: true,
|
|
219
|
+
columns: parseColumns(connection.type, stdout)
|
|
220
|
+
});
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (req.method === "POST" && segments.length === 1 && segments[0] === "query") {
|
|
224
|
+
const body = await readJson(req);
|
|
225
|
+
const sessionId = body.sessionId;
|
|
226
|
+
if (typeof sessionId !== "string" || sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
|
|
227
|
+
const sql = body.sql;
|
|
228
|
+
if (typeof sql !== "string" || sql.trim().length === 0) throw new Error("sql 必须是非空字符串");
|
|
229
|
+
if (sql.length > config.maxQueryChars) throw new Error(`sql 超过长度上限(${config.maxQueryChars} 字符)`);
|
|
230
|
+
const connection = requireConnection(sessionId);
|
|
231
|
+
writeJson(200, {
|
|
232
|
+
ok: true,
|
|
233
|
+
result: await runClientQuery(scope, connection, sql, queryOptions, new AbortController().signal)
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
writeJson(404, { error: "unknown data-agent route" });
|
|
238
|
+
} catch (error) {
|
|
239
|
+
writeJson(400, { error: error instanceof Error ? error.message : String(error) });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
return () => {
|
|
244
|
+
dispose();
|
|
245
|
+
};
|
|
246
|
+
}, "data-agent-routes: routes");
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
//#endregion
|
|
250
|
+
export { Config, DATA_AGENT_PATH, apply, inject, name, validateConnectBody };
|
package/lib/tool.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { i as DEFAULT_MAX_RESULT_CHARS, l as clientsSchema, o as DEFAULT_QUERY_TIMEOUT_MS } from "./defaults-D__D30ED.js";
|
|
2
|
+
import { t as runClientQuery } from "./query-DAjhNTo8.js";
|
|
3
|
+
import z from "schemastery";
|
|
4
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
5
|
+
//#region src/tool.ts
|
|
6
|
+
/** Cordis plugin name (diagnostics only). */
|
|
7
|
+
const name = "data-agent-tool";
|
|
8
|
+
/** Services required before the tool can register. */
|
|
9
|
+
const inject = [
|
|
10
|
+
"tools",
|
|
11
|
+
"subprocess",
|
|
12
|
+
"dataAgentConnections"
|
|
13
|
+
];
|
|
14
|
+
/** Loader schema with deployment defaults (no library defaults). */
|
|
15
|
+
const Config = z.object({
|
|
16
|
+
queryTimeoutMs: z.number().step(1).min(1e3).default(DEFAULT_QUERY_TIMEOUT_MS),
|
|
17
|
+
maxResultChars: z.number().step(1).min(1024).default(DEFAULT_MAX_RESULT_CHARS),
|
|
18
|
+
maxRows: z.number().step(1).min(1).default(100),
|
|
19
|
+
clients: clientsSchema
|
|
20
|
+
});
|
|
21
|
+
/** One-line sqlcmd label for the terminal card (newlines collapsed). */
|
|
22
|
+
function oneLine(sql) {
|
|
23
|
+
const line = sql.replace(/\s+/g, " ").trim();
|
|
24
|
+
return line.length > 80 ? `${line.slice(0, 77)}...` : line;
|
|
25
|
+
}
|
|
26
|
+
/** Format the canonical result as a monospace text block. */
|
|
27
|
+
function formatResult(value) {
|
|
28
|
+
const parts = [];
|
|
29
|
+
if (value.stdout.length > 0) parts.push(value.stdout);
|
|
30
|
+
if (value.stderr.length > 0) parts.push(`[stderr]\n${value.stderr}`);
|
|
31
|
+
if (value.truncated) parts.push("… 输出超过上限,已截断(可缩小查询或增加 maxResultChars)");
|
|
32
|
+
if (value.exitCode !== 0) parts.push(`[exit code: ${value.exitCode ?? "signal"}]`);
|
|
33
|
+
return parts.join("\n");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Mount the sqlcmd tool: register it into the current agent's tool registry.
|
|
37
|
+
* @param ctx - the preset-scoped agent context.
|
|
38
|
+
* @param config - validated loader configuration.
|
|
39
|
+
*/
|
|
40
|
+
function apply(ctx, config) {
|
|
41
|
+
const resolved = {
|
|
42
|
+
queryTimeoutMs: config.queryTimeoutMs,
|
|
43
|
+
maxResultChars: config.maxResultChars,
|
|
44
|
+
maxRows: config.maxRows,
|
|
45
|
+
clients: config.clients
|
|
46
|
+
};
|
|
47
|
+
ctx.tools.register(defineTool({
|
|
48
|
+
name: "sqlcmd",
|
|
49
|
+
description: `在已连接的数据库上执行 SQL 或客户端命令(如 SHOW TABLES、DESCRIBE users、SELECT * FROM orders LIMIT ${resolved.maxRows})。需要先在「数据库」标签页连接数据库;SQL 经 stdin 传给客户端(mysql/psql/sqlite3),无 shell 层。结果包含 exitCode 与 stdout/stderr 文本。`,
|
|
50
|
+
parameters: { sql: {
|
|
51
|
+
type: "string",
|
|
52
|
+
required: true,
|
|
53
|
+
description: "要执行的 SQL 文本(或客户端命令),如 \"SHOW TABLES;\"、\"DESCRIBE users;\"、\"SELECT * FROM orders LIMIT 5;\""
|
|
54
|
+
} },
|
|
55
|
+
output: {
|
|
56
|
+
schema: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: {
|
|
59
|
+
exitCode: {
|
|
60
|
+
oneOf: [{ type: "integer" }, { type: "null" }],
|
|
61
|
+
required: true
|
|
62
|
+
},
|
|
63
|
+
stdout: {
|
|
64
|
+
type: "string",
|
|
65
|
+
required: true
|
|
66
|
+
},
|
|
67
|
+
stderr: {
|
|
68
|
+
type: "string",
|
|
69
|
+
required: true
|
|
70
|
+
},
|
|
71
|
+
truncated: {
|
|
72
|
+
type: "boolean",
|
|
73
|
+
required: true
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
additionalProperties: false
|
|
77
|
+
},
|
|
78
|
+
render: (_args, value) => [{
|
|
79
|
+
type: "text",
|
|
80
|
+
text: formatResult(value)
|
|
81
|
+
}]
|
|
82
|
+
},
|
|
83
|
+
presentCall: (args) => ({
|
|
84
|
+
card: "terminal",
|
|
85
|
+
title: `sqlcmd ${oneLine(args.sql)}`,
|
|
86
|
+
description: "在数据库客户端执行 SQL"
|
|
87
|
+
}),
|
|
88
|
+
presentResult: (args, result) => ({
|
|
89
|
+
card: "terminal",
|
|
90
|
+
title: `sqlcmd ${oneLine(args.sql)}`,
|
|
91
|
+
content: result.content
|
|
92
|
+
}),
|
|
93
|
+
async execute(args, exec) {
|
|
94
|
+
const sessionId = exec.agent?.id;
|
|
95
|
+
if (sessionId === void 0) throw new Error("sqlcmd: 缺少会话上下文(agent loop 未注入)");
|
|
96
|
+
const connection = ctx.dataAgentConnections.getWithSecret(sessionId);
|
|
97
|
+
if (connection === void 0) throw new Error("请先在「数据库」标签页连接数据库,再使用 sqlcmd(未找到当前会话的连接)");
|
|
98
|
+
return runClientQuery(ctx, connection, args.sql, {
|
|
99
|
+
clients: resolved.clients,
|
|
100
|
+
timeoutMs: resolved.queryTimeoutMs,
|
|
101
|
+
maxResultChars: resolved.maxResultChars
|
|
102
|
+
}, exec.signal);
|
|
103
|
+
}
|
|
104
|
+
}));
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
export { Config, apply, inject, name };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots';
|
|
2
|
+
/** Database kinds offered by the connection form. */
|
|
3
|
+
export type DatabaseType = 'mysql' | 'postgres' | 'sqlite' | 'oracle' | 'hive' | 'impala';
|
|
4
|
+
/** The sessions-list slice the workbench needs (structural; avoids a runtime import). */
|
|
5
|
+
export interface SessionListLike {
|
|
6
|
+
byId: Record<string, {
|
|
7
|
+
agentPreset?: string;
|
|
8
|
+
}>;
|
|
9
|
+
}
|
|
10
|
+
/** Registration-side business face: the sessions-list observable becomes `useSessions`. */
|
|
11
|
+
export interface DataAgentWorkbenchInjected {
|
|
12
|
+
hooks: {
|
|
13
|
+
sessions: {
|
|
14
|
+
getSnapshot(): SessionListLike;
|
|
15
|
+
subscribe(fn: () => void): () => void;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** The workbench's full component props: the dock seat + the locale seat + the injected sessions hook. */
|
|
20
|
+
export type DataAgentWorkbenchProps = PropsRuntime<'conversation.input.dock'> & PropsLocale<'data-agent'> & InjectFace<DataAgentWorkbenchInjected>;
|
|
21
|
+
/** The database workbench body. */
|
|
22
|
+
export declare function DataAgentWorkbench({ sessionId, useSessions, t }: DataAgentWorkbenchProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Data Agent browser half, plugin entry: registers the database workbench
|
|
3
|
+
* into the composer input dock (the strip ABOVE the input bar) for
|
|
4
|
+
* data-agent sessions, and the `data-agent` dictionaries. The old
|
|
5
|
+
* conversation-view tab is gone — the workbench lives inside the session.
|
|
6
|
+
* Connection state lives in the server-side connection store, so layout and
|
|
7
|
+
* session switches never lose it — the view only mirrors what
|
|
8
|
+
* `/plugins/data-agent/status` reports.
|
|
9
|
+
* @module @yejiming/dsh-data-agent/client
|
|
10
|
+
*/
|
|
11
|
+
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client';
|
|
12
|
+
import { type DataAgentKey } from './locales.ts';
|
|
13
|
+
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
14
|
+
interface LocaleNamespaceMap {
|
|
15
|
+
/** The database workbench copy. */
|
|
16
|
+
'data-agent': DataAgentKey;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Required services: the locale service, the slot registry, and the sessions list. */
|
|
20
|
+
export declare const inject: string[];
|
|
21
|
+
/**
|
|
22
|
+
* Client plugin body: register the data-agent dictionaries and the database
|
|
23
|
+
* workbench into the composer input dock. The registration rides the slot
|
|
24
|
+
* service's effect wrapper, so plugin unload removes it.
|
|
25
|
+
* @param ctx - client root context.
|
|
26
|
+
*/
|
|
27
|
+
export declare function apply(ctx: ClientContext): void;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/** `data-agent` namespace dictionaries for the database workbench. */
|
|
2
|
+
/** Dictionary namespace owned by this plugin. */
|
|
3
|
+
export declare const NS = "data-agent";
|
|
4
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
5
|
+
export declare const zh: {
|
|
6
|
+
'form.title': string;
|
|
7
|
+
'form.type': string;
|
|
8
|
+
'type.mysql': string;
|
|
9
|
+
'type.postgres': string;
|
|
10
|
+
'type.sqlite': string;
|
|
11
|
+
'type.oracle': string;
|
|
12
|
+
'type.hive': string;
|
|
13
|
+
'type.impala': string;
|
|
14
|
+
'form.host': string;
|
|
15
|
+
'form.port': string;
|
|
16
|
+
'form.user': string;
|
|
17
|
+
'form.password': string;
|
|
18
|
+
'form.database': string;
|
|
19
|
+
'form.database.oracle': string;
|
|
20
|
+
'form.database.hive': string;
|
|
21
|
+
'form.database.impala': string;
|
|
22
|
+
'form.database.sqlite': string;
|
|
23
|
+
'form.database.sqlite.placeholder': string;
|
|
24
|
+
'action.connect': string;
|
|
25
|
+
'action.disconnect': string;
|
|
26
|
+
'state.connected': string;
|
|
27
|
+
'state.disconnected': string;
|
|
28
|
+
'state.checking': string;
|
|
29
|
+
'state.reconnecting': string;
|
|
30
|
+
'wb.schemas': string;
|
|
31
|
+
'wb.tables': string;
|
|
32
|
+
'wb.columns': string;
|
|
33
|
+
'wb.sql': string;
|
|
34
|
+
'wb.sql.placeholder': string;
|
|
35
|
+
'wb.sql.run': string;
|
|
36
|
+
'wb.sql.running': string;
|
|
37
|
+
'wb.sql.shortcut': string;
|
|
38
|
+
'wb.sql.empty': string;
|
|
39
|
+
'wb.loading': string;
|
|
40
|
+
'wb.empty': string;
|
|
41
|
+
'wb.modal.title': string;
|
|
42
|
+
'wb.hint.click': string;
|
|
43
|
+
'action.config': string;
|
|
44
|
+
'action.browse': string;
|
|
45
|
+
'action.close': string;
|
|
46
|
+
'action.collapse': string;
|
|
47
|
+
'error.title': string;
|
|
48
|
+
};
|
|
49
|
+
/** The data-agent namespace key union. */
|
|
50
|
+
export type DataAgentKey = keyof typeof zh;
|
|
51
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
52
|
+
export declare const en: {
|
|
53
|
+
'form.title': string;
|
|
54
|
+
'form.type': string;
|
|
55
|
+
'type.mysql': string;
|
|
56
|
+
'type.postgres': string;
|
|
57
|
+
'type.sqlite': string;
|
|
58
|
+
'type.oracle': string;
|
|
59
|
+
'type.hive': string;
|
|
60
|
+
'type.impala': string;
|
|
61
|
+
'form.host': string;
|
|
62
|
+
'form.port': string;
|
|
63
|
+
'form.user': string;
|
|
64
|
+
'form.password': string;
|
|
65
|
+
'form.database': string;
|
|
66
|
+
'form.database.oracle': string;
|
|
67
|
+
'form.database.hive': string;
|
|
68
|
+
'form.database.impala': string;
|
|
69
|
+
'form.database.sqlite': string;
|
|
70
|
+
'form.database.sqlite.placeholder': string;
|
|
71
|
+
'action.connect': string;
|
|
72
|
+
'action.disconnect': string;
|
|
73
|
+
'state.connected': string;
|
|
74
|
+
'state.disconnected': string;
|
|
75
|
+
'state.checking': string;
|
|
76
|
+
'state.reconnecting': string;
|
|
77
|
+
'wb.schemas': string;
|
|
78
|
+
'wb.tables': string;
|
|
79
|
+
'wb.columns': string;
|
|
80
|
+
'wb.sql': string;
|
|
81
|
+
'wb.sql.placeholder': string;
|
|
82
|
+
'wb.sql.run': string;
|
|
83
|
+
'wb.sql.running': string;
|
|
84
|
+
'wb.sql.shortcut': string;
|
|
85
|
+
'wb.sql.empty': string;
|
|
86
|
+
'wb.loading': string;
|
|
87
|
+
'wb.empty': string;
|
|
88
|
+
'wb.modal.title': string;
|
|
89
|
+
'wb.hint.click': string;
|
|
90
|
+
'action.config': string;
|
|
91
|
+
'action.browse': string;
|
|
92
|
+
'action.close': string;
|
|
93
|
+
'action.collapse': string;
|
|
94
|
+
'error.title': string;
|
|
95
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Connection-config persistence for the database workbench. The most recent
|
|
3
|
+
* successful connection (type/host/port/user/database/password) is kept in
|
|
4
|
+
* localStorage under one key so remounts and restarts can restore the form
|
|
5
|
+
* and auto-reconnect (the server-side connection store stays in-memory).
|
|
6
|
+
*
|
|
7
|
+
* Security note: the password is persisted in PLAIN TEXT by explicit user
|
|
8
|
+
* decision (local single-user scenario) — see README 安全说明. The storage
|
|
9
|
+
* key is versioned so a future shape change can migrate or ignore old data.
|
|
10
|
+
* @module @yejiming/dsh-data-agent/persistence
|
|
11
|
+
*/
|
|
12
|
+
import type { DatabaseType } from './DataAgentWorkbench.tsx';
|
|
13
|
+
/** localStorage key holding the most recent connection configuration. */
|
|
14
|
+
export declare const CONNECTION_STORAGE_KEY = "dsh-data-agent.connection.v1";
|
|
15
|
+
/** The persisted connection configuration (password included). */
|
|
16
|
+
export interface SavedConnection {
|
|
17
|
+
type: DatabaseType;
|
|
18
|
+
host?: string;
|
|
19
|
+
port?: number;
|
|
20
|
+
user?: string;
|
|
21
|
+
database: string;
|
|
22
|
+
password?: string;
|
|
23
|
+
/** Diagnostic timestamp of the save. */
|
|
24
|
+
savedAt: string;
|
|
25
|
+
}
|
|
26
|
+
/** Runtime storage face (injectable for tests). */
|
|
27
|
+
export interface StorageLike {
|
|
28
|
+
getItem(key: string): string | null;
|
|
29
|
+
setItem(key: string, value: string): void;
|
|
30
|
+
removeItem(key: string): void;
|
|
31
|
+
}
|
|
32
|
+
/** Save one connection configuration (best-effort; storage failures degrade silently). */
|
|
33
|
+
export declare function saveConnection(connection: SavedConnection, storage?: StorageLike | undefined): void;
|
|
34
|
+
/** Load the saved connection configuration; null when absent or malformed. */
|
|
35
|
+
export declare function loadConnection(storage?: StorageLike | undefined): SavedConnection | null;
|
|
36
|
+
/** Remove the saved connection configuration. */
|
|
37
|
+
export declare function clearConnection(storage?: StorageLike | undefined): void;
|