@yejiming/dsh-data-agent 0.0.13 → 0.1.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/README.en.md +46 -8
- package/README.md +46 -8
- package/conformance/dsh-ecosystem/inventory.json +26 -3
- package/conformance/dsh-ecosystem/restrictions.json +2 -2
- package/cordis.patch.yml +6 -3
- package/dsh-plugin.json +9 -4
- package/lib/catalog-DEJqOXRo.js +1944 -0
- package/lib/catalog-identity-CVftmvQL.js +96 -0
- package/lib/client.js +2214 -106
- package/lib/client.js.map +1 -1
- package/lib/command-CzzSPmag.js +1719 -0
- package/lib/command.js +2 -2
- package/lib/{connections-CHY4uB6z.js → connections-CFXOZTHZ.js} +223 -9
- package/lib/index.js +366 -16
- package/lib/routes.js +257 -4
- package/lib/{tool-ZTOS4B33.js → tool-DNkywSph.js} +364 -3
- package/lib/tool.js +1 -1
- package/lib/types/catalog-adapters.d.ts +52 -0
- package/lib/types/catalog-ai.d.ts +49 -0
- package/lib/types/catalog-command.d.ts +28 -0
- package/lib/types/catalog-identity.d.ts +23 -0
- package/lib/types/catalog-storage.d.ts +265 -0
- package/lib/types/catalog-tools.d.ts +5 -0
- package/lib/types/catalog-tui.d.ts +18 -0
- package/lib/types/catalog-types.d.ts +1376 -0
- package/lib/types/catalog.d.ts +59 -0
- package/lib/types/client/CatalogPanel.d.ts +15 -0
- package/lib/types/client/catalog-client.d.ts +57 -0
- package/lib/types/client/locales.d.ts +242 -0
- package/lib/types/command.d.ts +14 -3
- package/lib/types/connections.d.ts +9 -0
- package/lib/types/defaults.d.ts +22 -0
- package/lib/types/index.d.ts +42 -5
- package/lib/types/tui-connection-form.d.ts +11 -5
- package/package.json +4 -2
- package/preset/data-agent/agent.cordis.yml +9 -1
- package/lib/command-utC5MHd9.js +0 -916
- package/lib/defaults-Cngd8Tf8.js +0 -131
|
@@ -0,0 +1,1719 @@
|
|
|
1
|
+
import { C as isDatabaseType, S as defaultDatabasePort$1, b as DATABASE_TYPES, i as validatePasswordRef, r as redactSecretText, x as databaseTypeLabel$1 } from "./connections-CFXOZTHZ.js";
|
|
2
|
+
import { RUN_CODE_NAME } from "@deepseek-ai/dsh-tools";
|
|
3
|
+
//#region src/tui-connection-form.ts
|
|
4
|
+
/**
|
|
5
|
+
* Short-lived ANSI connection form used by `/database connect` in dsh-tui.
|
|
6
|
+
*
|
|
7
|
+
* dsh-tui 0.6.x exposes commands but no public custom-form/sensitive-input
|
|
8
|
+
* slot. This adapter therefore owns a small terminal form and only activates
|
|
9
|
+
* after the command adapter has detected an active `dsh-tui` runtime. It
|
|
10
|
+
* snapshots the host's `readable` listeners, consumes input for the lifetime
|
|
11
|
+
* of the form, then restores the listeners exactly. It never imports dsh-tui,
|
|
12
|
+
* React, or Ink.
|
|
13
|
+
* @module @yejiming/dsh-data-agent/tui-connection-form
|
|
14
|
+
*/
|
|
15
|
+
const TUI_DATABASE_TYPES = DATABASE_TYPES;
|
|
16
|
+
/** Initial form intentionally leaves host/port empty so placeholders are real defaults. */
|
|
17
|
+
function createTuiConnectionFormState(initialDraft) {
|
|
18
|
+
return {
|
|
19
|
+
type: initialDraft?.type ?? "mysql",
|
|
20
|
+
host: initialDraft?.host ?? "",
|
|
21
|
+
port: initialDraft?.port ?? "",
|
|
22
|
+
user: initialDraft?.user ?? "",
|
|
23
|
+
database: initialDraft?.database ?? "",
|
|
24
|
+
password: "",
|
|
25
|
+
passwordRef: initialDraft?.passwordRef ?? "",
|
|
26
|
+
secure: initialDraft?.secure ?? false,
|
|
27
|
+
readonly: initialDraft?.readonly ?? false,
|
|
28
|
+
focus: "type",
|
|
29
|
+
cursor: 0
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Project form state onto the only values allowed to cross the durable seam. */
|
|
33
|
+
function connectionFormDraft(state) {
|
|
34
|
+
return {
|
|
35
|
+
type: state.type,
|
|
36
|
+
host: state.host,
|
|
37
|
+
port: state.port,
|
|
38
|
+
user: state.user,
|
|
39
|
+
database: state.database,
|
|
40
|
+
readonly: state.readonly,
|
|
41
|
+
...state.type === "clickhouse" ? { secure: state.secure } : {}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Relevant focus order for the selected database kind. */
|
|
45
|
+
function tuiConnectionFields(type) {
|
|
46
|
+
if (type === "sqlite") return [
|
|
47
|
+
"type",
|
|
48
|
+
"database",
|
|
49
|
+
"readonly",
|
|
50
|
+
"confirm",
|
|
51
|
+
"cancel"
|
|
52
|
+
];
|
|
53
|
+
const fields = [
|
|
54
|
+
"type",
|
|
55
|
+
"host",
|
|
56
|
+
"port",
|
|
57
|
+
"user",
|
|
58
|
+
"database",
|
|
59
|
+
"password",
|
|
60
|
+
"passwordRef"
|
|
61
|
+
];
|
|
62
|
+
if (type === "clickhouse") fields.push("secure");
|
|
63
|
+
return [
|
|
64
|
+
...fields,
|
|
65
|
+
"readonly",
|
|
66
|
+
"confirm",
|
|
67
|
+
"cancel"
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
/** Default network port shown as a placeholder and applied only at submit time. */
|
|
71
|
+
function defaultDatabasePort(type, secure = false) {
|
|
72
|
+
return defaultDatabasePort$1(type, secure);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Check only terminal capability. The command adapter already proves that the
|
|
76
|
+
* actual dsh-tui plugin is loaded before it exposes `/database`; repeating a
|
|
77
|
+
* profile-name or argv heuristic here would reject custom profiles that use
|
|
78
|
+
* dsh-tui and admit profiles that merely happen to be named `dsh-tui`.
|
|
79
|
+
*/
|
|
80
|
+
function isDshTuiTerminal(input = process.stdin, output = process.stdout) {
|
|
81
|
+
return input.isTTY === true && output.isTTY === true;
|
|
82
|
+
}
|
|
83
|
+
/** Pure keyboard reducer, kept separate from terminal ownership for regression tests. */
|
|
84
|
+
function updateTuiConnectionForm(current, key) {
|
|
85
|
+
let state = {
|
|
86
|
+
...current,
|
|
87
|
+
error: void 0
|
|
88
|
+
};
|
|
89
|
+
if (state.selector !== void 0) return updateOpenSelector(state, key);
|
|
90
|
+
if (key.name === "escape") return {
|
|
91
|
+
kind: "cancelled",
|
|
92
|
+
state: clearPassword(state)
|
|
93
|
+
};
|
|
94
|
+
if (key.name === "tab" || key.name === "backtab") {
|
|
95
|
+
state = moveFocus(state, key.name === "tab" ? 1 : -1);
|
|
96
|
+
return {
|
|
97
|
+
kind: "editing",
|
|
98
|
+
state
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (state.focus === "confirm") {
|
|
102
|
+
if (key.name !== "enter") return {
|
|
103
|
+
kind: "editing",
|
|
104
|
+
state
|
|
105
|
+
};
|
|
106
|
+
const validated = validateTuiConnectionForm(state);
|
|
107
|
+
return validated.error !== void 0 ? {
|
|
108
|
+
kind: "editing",
|
|
109
|
+
state: {
|
|
110
|
+
...state,
|
|
111
|
+
error: validated.error
|
|
112
|
+
}
|
|
113
|
+
} : {
|
|
114
|
+
kind: "submitted",
|
|
115
|
+
state: clearPassword(state),
|
|
116
|
+
input: validated.input
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
if (state.focus === "cancel") return key.name === "enter" ? {
|
|
120
|
+
kind: "cancelled",
|
|
121
|
+
state: clearPassword(state)
|
|
122
|
+
} : {
|
|
123
|
+
kind: "editing",
|
|
124
|
+
state
|
|
125
|
+
};
|
|
126
|
+
if (state.focus === "type") {
|
|
127
|
+
if (key.name === "enter" || key.name === "space") state = {
|
|
128
|
+
...state,
|
|
129
|
+
selector: {
|
|
130
|
+
field: "type",
|
|
131
|
+
index: TUI_DATABASE_TYPES.indexOf(state.type)
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
return {
|
|
135
|
+
kind: "editing",
|
|
136
|
+
state
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
if (state.focus === "readonly" || state.focus === "secure") {
|
|
140
|
+
if (key.name === "enter" || key.name === "space") state = {
|
|
141
|
+
...state,
|
|
142
|
+
selector: {
|
|
143
|
+
field: state.focus,
|
|
144
|
+
index: state[state.focus] ? 1 : 0
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
kind: "editing",
|
|
149
|
+
state
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
if (key.name === "enter" || key.name === "up" || key.name === "down") return {
|
|
153
|
+
kind: "editing",
|
|
154
|
+
state
|
|
155
|
+
};
|
|
156
|
+
return {
|
|
157
|
+
kind: "editing",
|
|
158
|
+
state: editTextField(state, key)
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
/** Rendered value is masked before it reaches the ANSI string. */
|
|
162
|
+
function renderTuiConnectionForm(state, columns = 80) {
|
|
163
|
+
const width = Math.max(20, Math.min(72, columns - 8));
|
|
164
|
+
const lines = [
|
|
165
|
+
"\x1B[2J\x1B[H\x1B[?25l",
|
|
166
|
+
`${bold("Data Agent · 数据库连接")}`,
|
|
167
|
+
dim("Tab/Shift+Tab 切换 · Enter 展开/确认选项 · ↑/↓ 选择 · Esc 返回"),
|
|
168
|
+
""
|
|
169
|
+
];
|
|
170
|
+
for (const field of tuiConnectionFields(state.type)) lines.push(...renderField(state, field, width));
|
|
171
|
+
if (state.error !== void 0) lines.push("", red(`! ${state.error}`));
|
|
172
|
+
lines.push("", dim(state.type === "sqlite" ? "SQLite 连接不收集数据库凭据。" : "临时密码不持久化;凭据引用可随非敏感连接 profile 恢复。"));
|
|
173
|
+
return lines.join("\n");
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Own the terminal only for the form lifetime. `undefined` means user cancel.
|
|
177
|
+
* The returned password has never crossed stdout, argv, env, or a DSH event.
|
|
178
|
+
*/
|
|
179
|
+
function runTuiConnectionForm(options = {}) {
|
|
180
|
+
const input = options.input ?? process.stdin;
|
|
181
|
+
const output = options.output ?? process.stdout;
|
|
182
|
+
if (input.isTTY !== true || output.isTTY !== true) return Promise.reject(/* @__PURE__ */ new Error("数据库连接表单需要交互式 TTY"));
|
|
183
|
+
const originalListeners = input.listeners("readable");
|
|
184
|
+
const wasRaw = input.isRaw === true;
|
|
185
|
+
let state = createTuiConnectionFormState(options.initialDraft);
|
|
186
|
+
let settled = false;
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const redraw = () => output.write(renderTuiConnectionForm(state, output.columns ?? 80));
|
|
189
|
+
const cleanup = () => {
|
|
190
|
+
input.removeListener("readable", onReadable);
|
|
191
|
+
output.removeListener?.("resize", redraw);
|
|
192
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
193
|
+
if (!wasRaw) input.setRawMode?.(false);
|
|
194
|
+
output.write("\x1B[0m\x1B[?25h\x1B[2J\x1B[H");
|
|
195
|
+
for (const listener of originalListeners) input.on("readable", listener);
|
|
196
|
+
requestHostFullRedraw(input, output);
|
|
197
|
+
};
|
|
198
|
+
const finish = async (value, error) => {
|
|
199
|
+
if (settled) return;
|
|
200
|
+
settled = true;
|
|
201
|
+
const draft = connectionFormDraft(state);
|
|
202
|
+
state = clearPassword(state);
|
|
203
|
+
cleanup();
|
|
204
|
+
try {
|
|
205
|
+
await options.persistDraft?.(draft);
|
|
206
|
+
if (error !== void 0) reject(error);
|
|
207
|
+
else resolve(value);
|
|
208
|
+
} catch (persistError) {
|
|
209
|
+
reject(persistError);
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
const onAbort = () => {
|
|
213
|
+
finish(void 0, options.signal?.reason instanceof Error ? options.signal.reason : /* @__PURE__ */ new Error("数据库连接已取消"));
|
|
214
|
+
};
|
|
215
|
+
const onReadable = () => {
|
|
216
|
+
if (settled) return;
|
|
217
|
+
try {
|
|
218
|
+
let chunk;
|
|
219
|
+
while ((chunk = input.read()) !== null) {
|
|
220
|
+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
221
|
+
for (const key of decodeTuiFormInput(text)) {
|
|
222
|
+
const transition = updateTuiConnectionForm(state, key);
|
|
223
|
+
state = transition.state;
|
|
224
|
+
if (transition.kind === "submitted") {
|
|
225
|
+
finish(transition.input);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (transition.kind === "cancelled") {
|
|
229
|
+
finish(void 0);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
redraw();
|
|
235
|
+
} catch (error) {
|
|
236
|
+
finish(void 0, error);
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
try {
|
|
240
|
+
for (const listener of originalListeners) input.removeListener("readable", listener);
|
|
241
|
+
input.setRawMode?.(true);
|
|
242
|
+
input.ref?.();
|
|
243
|
+
input.on("readable", onReadable);
|
|
244
|
+
output.on?.("resize", redraw);
|
|
245
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
246
|
+
if (options.signal?.aborted === true) onAbort();
|
|
247
|
+
else redraw();
|
|
248
|
+
} catch (error) {
|
|
249
|
+
settled = true;
|
|
250
|
+
state = clearPassword(state);
|
|
251
|
+
cleanup();
|
|
252
|
+
reject(error);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Ask dsh-tui to invalidate Ink's cached frame after our direct ANSI drawing.
|
|
258
|
+
*
|
|
259
|
+
* A same-size `resize` event does not invalidate Ink's physical-frame cache,
|
|
260
|
+
* so unchanged rows such as the prompt remain blank after the form clears the
|
|
261
|
+
* screen. Ctrl+L is dsh-tui's documented redraw shortcut and reaches the host
|
|
262
|
+
* only after its original readable listener has been restored.
|
|
263
|
+
*/
|
|
264
|
+
function requestHostFullRedraw(input, output) {
|
|
265
|
+
try {
|
|
266
|
+
if (input.push !== void 0) {
|
|
267
|
+
input.push("\f");
|
|
268
|
+
input.emit?.("readable");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
} catch {}
|
|
272
|
+
output.emit?.("resize");
|
|
273
|
+
}
|
|
274
|
+
/** Decode the keyboard subset owned by the form; unknown terminal reports are ignored. */
|
|
275
|
+
function decodeTuiFormInput(value) {
|
|
276
|
+
const keys = [];
|
|
277
|
+
let index = 0;
|
|
278
|
+
while (index < value.length) {
|
|
279
|
+
const rest = value.slice(index);
|
|
280
|
+
const known = KNOWN_SEQUENCES.find(([sequence]) => rest.startsWith(sequence));
|
|
281
|
+
if (known !== void 0) {
|
|
282
|
+
keys.push({ name: known[1] });
|
|
283
|
+
index += known[0].length;
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const character = value[index];
|
|
287
|
+
if (character === "" || character === "\x1B") {
|
|
288
|
+
if (character === "\x1B" && value[index + 1] === "[") {
|
|
289
|
+
index += 2;
|
|
290
|
+
while (index < value.length && !/[\x40-\x7E]/.test(value[index])) index += 1;
|
|
291
|
+
index += 1;
|
|
292
|
+
} else {
|
|
293
|
+
keys.push({ name: "escape" });
|
|
294
|
+
index += 1;
|
|
295
|
+
}
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (character === " ") keys.push({ name: "tab" });
|
|
299
|
+
else if (character === "\r" || character === "\n") keys.push({ name: "enter" });
|
|
300
|
+
else if (character === "" || character === "\b") keys.push({ name: "backspace" });
|
|
301
|
+
else if (character === " ") keys.push({ name: "space" });
|
|
302
|
+
else if (character >= " ") keys.push({
|
|
303
|
+
name: "text",
|
|
304
|
+
text: character
|
|
305
|
+
});
|
|
306
|
+
index += 1;
|
|
307
|
+
}
|
|
308
|
+
return keys;
|
|
309
|
+
}
|
|
310
|
+
const KNOWN_SEQUENCES = [
|
|
311
|
+
["\x1B[Z", "backtab"],
|
|
312
|
+
["\x1B[A", "up"],
|
|
313
|
+
["\x1B[B", "down"],
|
|
314
|
+
["\x1B[C", "right"],
|
|
315
|
+
["\x1B[D", "left"],
|
|
316
|
+
["\x1B[H", "home"],
|
|
317
|
+
["\x1B[F", "end"],
|
|
318
|
+
["\x1B[1~", "home"],
|
|
319
|
+
["\x1B[4~", "end"],
|
|
320
|
+
["\x1B[3~", "delete"]
|
|
321
|
+
];
|
|
322
|
+
function validateTuiConnectionForm(state) {
|
|
323
|
+
const database = state.database.trim();
|
|
324
|
+
if (database === "") return { error: state.type === "sqlite" ? "SQLite 数据库文件路径不能为空" : "数据库名不能为空" };
|
|
325
|
+
if (state.type === "sqlite") return { input: {
|
|
326
|
+
type: "sqlite",
|
|
327
|
+
database,
|
|
328
|
+
readonly: state.readonly
|
|
329
|
+
} };
|
|
330
|
+
const portText = state.port.trim();
|
|
331
|
+
const port = portText === "" ? defaultDatabasePort(state.type, state.secure) : Number(portText);
|
|
332
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return { error: "端口必须是 1–65535 的整数,或留空使用默认值" };
|
|
333
|
+
const input = {
|
|
334
|
+
type: state.type,
|
|
335
|
+
host: state.host.trim() || "127.0.0.1",
|
|
336
|
+
port,
|
|
337
|
+
database,
|
|
338
|
+
readonly: state.readonly
|
|
339
|
+
};
|
|
340
|
+
if (state.type === "clickhouse") input.secure = state.secure;
|
|
341
|
+
const user = state.user.trim();
|
|
342
|
+
if (user !== "") input.user = user;
|
|
343
|
+
const passwordRef = state.passwordRef.trim();
|
|
344
|
+
if (state.password !== "" && passwordRef !== "") return { error: "临时密码与凭据引用不能同时填写" };
|
|
345
|
+
if (passwordRef !== "") {
|
|
346
|
+
try {
|
|
347
|
+
validatePasswordRef(passwordRef);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
350
|
+
}
|
|
351
|
+
input.passwordRef = passwordRef;
|
|
352
|
+
} else if (state.password !== "") input.password = state.password;
|
|
353
|
+
return { input };
|
|
354
|
+
}
|
|
355
|
+
function editTextField(state, key) {
|
|
356
|
+
if (!isTextField(state.focus)) return state;
|
|
357
|
+
const value = state[state.focus];
|
|
358
|
+
if (key.name === "text") return replaceField(state, value.slice(0, state.cursor) + key.text + value.slice(state.cursor), state.cursor + key.text.length);
|
|
359
|
+
if (key.name === "space") return replaceField(state, value.slice(0, state.cursor) + " " + value.slice(state.cursor), state.cursor + 1);
|
|
360
|
+
if (key.name === "backspace" && state.cursor > 0) return replaceField(state, value.slice(0, state.cursor - 1) + value.slice(state.cursor), state.cursor - 1);
|
|
361
|
+
if (key.name === "delete" && state.cursor < value.length) return replaceField(state, value.slice(0, state.cursor) + value.slice(state.cursor + 1), state.cursor);
|
|
362
|
+
if (key.name === "left") return {
|
|
363
|
+
...state,
|
|
364
|
+
cursor: Math.max(0, state.cursor - 1)
|
|
365
|
+
};
|
|
366
|
+
if (key.name === "right") return {
|
|
367
|
+
...state,
|
|
368
|
+
cursor: Math.min(value.length, state.cursor + 1)
|
|
369
|
+
};
|
|
370
|
+
if (key.name === "home") return {
|
|
371
|
+
...state,
|
|
372
|
+
cursor: 0
|
|
373
|
+
};
|
|
374
|
+
if (key.name === "end") return {
|
|
375
|
+
...state,
|
|
376
|
+
cursor: value.length
|
|
377
|
+
};
|
|
378
|
+
return state;
|
|
379
|
+
}
|
|
380
|
+
function replaceField(state, value, cursor) {
|
|
381
|
+
if (!isTextField(state.focus)) return state;
|
|
382
|
+
return {
|
|
383
|
+
...state,
|
|
384
|
+
[state.focus]: value,
|
|
385
|
+
cursor
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
function isTextField(field) {
|
|
389
|
+
return field === "host" || field === "port" || field === "user" || field === "database" || field === "password" || field === "passwordRef";
|
|
390
|
+
}
|
|
391
|
+
function moveFocus(state, delta) {
|
|
392
|
+
const fields = tuiConnectionFields(state.type);
|
|
393
|
+
const focus = fields[(Math.max(0, fields.indexOf(state.focus)) + delta + fields.length) % fields.length];
|
|
394
|
+
return {
|
|
395
|
+
...state,
|
|
396
|
+
focus,
|
|
397
|
+
cursor: isTextField(focus) ? state[focus].length : 0
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
function clearPassword(state) {
|
|
401
|
+
return {
|
|
402
|
+
...state,
|
|
403
|
+
password: "",
|
|
404
|
+
cursor: state.focus === "password" ? 0 : state.cursor
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
function updateOpenSelector(state, key) {
|
|
408
|
+
const selector = state.selector;
|
|
409
|
+
const optionCount = selector.field === "type" ? TUI_DATABASE_TYPES.length : 2;
|
|
410
|
+
if (key.name === "escape") return {
|
|
411
|
+
kind: "editing",
|
|
412
|
+
state: closeSelector(state)
|
|
413
|
+
};
|
|
414
|
+
if (key.name === "enter") {
|
|
415
|
+
let selected;
|
|
416
|
+
if (selector.field === "type") {
|
|
417
|
+
const type = TUI_DATABASE_TYPES[selector.index];
|
|
418
|
+
const previousDefault = state.type === "sqlite" ? "" : String(defaultDatabasePort(state.type, state.secure));
|
|
419
|
+
const secure = type === "clickhouse" && state.secure;
|
|
420
|
+
const port = state.port === "" || state.port === previousDefault ? type === "sqlite" ? "" : String(defaultDatabasePort(type, secure)) : state.port;
|
|
421
|
+
selected = {
|
|
422
|
+
...state,
|
|
423
|
+
type,
|
|
424
|
+
secure,
|
|
425
|
+
port
|
|
426
|
+
};
|
|
427
|
+
} else if (selector.field === "secure") {
|
|
428
|
+
const secure = selector.index === 1;
|
|
429
|
+
const previousDefault = String(defaultDatabasePort("clickhouse", state.secure));
|
|
430
|
+
const port = state.port === "" || state.port === previousDefault ? String(defaultDatabasePort("clickhouse", secure)) : state.port;
|
|
431
|
+
selected = {
|
|
432
|
+
...state,
|
|
433
|
+
secure,
|
|
434
|
+
port
|
|
435
|
+
};
|
|
436
|
+
} else selected = {
|
|
437
|
+
...state,
|
|
438
|
+
readonly: selector.index === 1
|
|
439
|
+
};
|
|
440
|
+
return {
|
|
441
|
+
kind: "editing",
|
|
442
|
+
state: closeSelector(selected)
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
let delta = 0;
|
|
446
|
+
if (key.name === "up" || key.name === "left") delta = -1;
|
|
447
|
+
if (key.name === "down" || key.name === "right") delta = 1;
|
|
448
|
+
if (delta === 0 && key.name !== "home" && key.name !== "end") return {
|
|
449
|
+
kind: "editing",
|
|
450
|
+
state
|
|
451
|
+
};
|
|
452
|
+
const index = key.name === "home" ? 0 : key.name === "end" ? optionCount - 1 : (selector.index + delta + optionCount) % optionCount;
|
|
453
|
+
return {
|
|
454
|
+
kind: "editing",
|
|
455
|
+
state: {
|
|
456
|
+
...state,
|
|
457
|
+
selector: {
|
|
458
|
+
...selector,
|
|
459
|
+
index
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function closeSelector(state) {
|
|
465
|
+
const { selector: _selector, ...rest } = state;
|
|
466
|
+
return rest;
|
|
467
|
+
}
|
|
468
|
+
function renderField(state, field, width) {
|
|
469
|
+
const focused = state.focus === field;
|
|
470
|
+
const pointer = focused ? cyan("›") : " ";
|
|
471
|
+
if (field === "confirm" || field === "cancel") {
|
|
472
|
+
const label = field === "confirm" ? "确定并连接" : "取消";
|
|
473
|
+
return [`${pointer} ${focused ? cyan(bold(`[ ${label} ]`)) : `[ ${label} ]`}`];
|
|
474
|
+
}
|
|
475
|
+
const label = fieldLabel(field, state.type);
|
|
476
|
+
let value;
|
|
477
|
+
let placeholder = false;
|
|
478
|
+
if (field === "type") value = databaseTypeLabel(state.type);
|
|
479
|
+
else if (field === "readonly") value = state.readonly ? "是" : "否";
|
|
480
|
+
else if (field === "secure") value = state.secure ? "是" : "否";
|
|
481
|
+
else {
|
|
482
|
+
const raw = state[field];
|
|
483
|
+
if (field === "password") value = "*".repeat([...raw].length);
|
|
484
|
+
else value = raw;
|
|
485
|
+
if (value === "") {
|
|
486
|
+
placeholder = true;
|
|
487
|
+
value = field === "host" ? "127.0.0.1(默认)" : field === "port" ? `${defaultDatabasePort(state.type, state.secure)}(默认)` : field === "password" ? "可留空" : field === "passwordRef" ? "例如 DB_PASSWORD,可留空" : field === "user" ? "可留空" : "请输入";
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
const maxValue = Math.max(8, width - 20);
|
|
491
|
+
const shown = truncate(value.replace(/[\r\n\u001B]/g, " "), maxValue);
|
|
492
|
+
const content = placeholder ? dim(shown) : shown;
|
|
493
|
+
const expandable = field === "type" || field === "readonly" || field === "secure";
|
|
494
|
+
const line = `${pointer} ${label.padEnd(8, " ")} [ ${focused ? cyan(content) : content}${expandable ? " ▾" : ""} ]`;
|
|
495
|
+
if (state.selector?.field !== field) return [line];
|
|
496
|
+
return [line, ...renderSelectorOptions(state)];
|
|
497
|
+
}
|
|
498
|
+
function renderSelectorOptions(state) {
|
|
499
|
+
const selector = state.selector;
|
|
500
|
+
const labels = selector.field === "type" ? TUI_DATABASE_TYPES.map(databaseTypeLabel) : ["否", "是"];
|
|
501
|
+
const selectedIndex = selector.field === "type" ? TUI_DATABASE_TYPES.indexOf(state.type) : state[selector.field] ? 1 : 0;
|
|
502
|
+
return labels.map((label, index) => {
|
|
503
|
+
return ` ${index === selector.index ? cyan("›") : " "} ${index === selectedIndex ? cyan("●") : dim("○")} ${index === selector.index ? cyan(bold(label)) : label}`;
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
function databaseTypeLabel(type) {
|
|
507
|
+
return databaseTypeLabel$1(type);
|
|
508
|
+
}
|
|
509
|
+
function fieldLabel(field, type) {
|
|
510
|
+
switch (field) {
|
|
511
|
+
case "type": return "数据库类型";
|
|
512
|
+
case "host": return "数据库主机";
|
|
513
|
+
case "port": return "数据库端口";
|
|
514
|
+
case "user": return "数据库用户";
|
|
515
|
+
case "database": return type === "sqlite" ? "文件路径" : "数据库名";
|
|
516
|
+
case "password": return "密码";
|
|
517
|
+
case "passwordRef": return "凭据引用";
|
|
518
|
+
case "secure": return "HTTPS";
|
|
519
|
+
case "readonly": return "只读模式";
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function truncate(value, width) {
|
|
523
|
+
const characters = [...value];
|
|
524
|
+
return characters.length <= width ? value : `…${characters.slice(-(width - 1)).join("")}`;
|
|
525
|
+
}
|
|
526
|
+
function bold(value) {
|
|
527
|
+
return `\u001B[1m${value}\u001B[22m`;
|
|
528
|
+
}
|
|
529
|
+
function dim(value) {
|
|
530
|
+
return `\u001B[2m${value}\u001B[22m`;
|
|
531
|
+
}
|
|
532
|
+
function cyan(value) {
|
|
533
|
+
return `\u001B[36m${value}\u001B[39m`;
|
|
534
|
+
}
|
|
535
|
+
function red(value) {
|
|
536
|
+
return `\u001B[31m${value}\u001B[39m`;
|
|
537
|
+
}
|
|
538
|
+
//#endregion
|
|
539
|
+
//#region src/catalog-command.ts
|
|
540
|
+
const CATALOG_COMMAND_USAGE = [
|
|
541
|
+
"用法:",
|
|
542
|
+
" /catalog scan --all",
|
|
543
|
+
" /catalog scan --schema <name>",
|
|
544
|
+
" /catalog scan --schema <name> --table <name>",
|
|
545
|
+
" /catalog status [--run <run-id>]",
|
|
546
|
+
" /catalog cancel [--run <run-id>]",
|
|
547
|
+
" /catalog diff [--from <run-id> --to <run-id>]",
|
|
548
|
+
" /catalog view",
|
|
549
|
+
"说明:无参数 scan 仅在有交互 provider 时选择范围;不会隐式执行全库扫描。"
|
|
550
|
+
].join("\n");
|
|
551
|
+
function registerCatalogCommand(ctx, presentation) {
|
|
552
|
+
return ctx.commands.register({
|
|
553
|
+
name: "catalog",
|
|
554
|
+
description: "扫描、查看或比较 data-agent 数据目录",
|
|
555
|
+
input: { hint: "scan | status | cancel | diff | view" },
|
|
556
|
+
recordInput: false,
|
|
557
|
+
handler: async (invocation) => executeCatalogCommand(ctx, invocation, presentation)
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
async function executeCatalogCommand(ctx, invocation, presentation) {
|
|
561
|
+
try {
|
|
562
|
+
const action = parseCatalogAction(invocation.rawInput);
|
|
563
|
+
const sessionId = String(invocation.agent.id);
|
|
564
|
+
switch (action.kind) {
|
|
565
|
+
case "scan": {
|
|
566
|
+
const scope = action.scope ?? await askForCatalogScope(ctx, invocation);
|
|
567
|
+
if (scope === void 0) return {
|
|
568
|
+
kind: "error",
|
|
569
|
+
text: `当前界面没有可用的问答 provider;未开始扫描。\n\n${CATALOG_COMMAND_USAGE}`
|
|
570
|
+
};
|
|
571
|
+
const run = await ctx.dataAgentCatalogScanner.start({
|
|
572
|
+
sessionId,
|
|
573
|
+
scope
|
|
574
|
+
});
|
|
575
|
+
presentation?.watch(run);
|
|
576
|
+
return {
|
|
577
|
+
kind: "success",
|
|
578
|
+
text: `Catalog 扫描已进入后台队列。\nrun: ${run.id}\nsource: ${run.sourceId}\nscope: ${formatScope(run.scope)}\nAI model: ${run.enrichment?.provider ?? "未配置"}/${run.enrichment?.model ?? "未配置"}\n使用 /catalog status 查看技术扫描和 AI 业务含义进度。`
|
|
579
|
+
};
|
|
580
|
+
}
|
|
581
|
+
case "status": {
|
|
582
|
+
const sourceId = await resolveCommandSourceId(ctx, sessionId);
|
|
583
|
+
if (sourceId === void 0) return {
|
|
584
|
+
kind: "success",
|
|
585
|
+
text: `当前没有Catalog source或扫描记录。\n\n${CATALOG_COMMAND_USAGE}`
|
|
586
|
+
};
|
|
587
|
+
const status = ctx.dataAgentCatalog.status(sourceId);
|
|
588
|
+
if (status === void 0) return {
|
|
589
|
+
kind: "success",
|
|
590
|
+
text: `source ${sourceId} 尚未扫描。\n\n${CATALOG_COMMAND_USAGE}`
|
|
591
|
+
};
|
|
592
|
+
const run = action.runId === void 0 ? status.activeRun ?? status.latestRun : ctx.dataAgentCatalog.listRuns(sourceId, 200).find((candidate) => candidate.id === action.runId);
|
|
593
|
+
if (action.runId !== void 0 && run === void 0) return {
|
|
594
|
+
kind: "error",
|
|
595
|
+
text: `未找到 Catalog run ${action.runId}(仅查询最近 200 条记录)。`
|
|
596
|
+
};
|
|
597
|
+
const lines = [`Catalog source: ${status.source.name} (${status.source.id})`, `资产: ${status.counts.assets},字段: ${status.counts.fields},待确认: ${status.counts.needsReview}`];
|
|
598
|
+
if (run !== void 0) {
|
|
599
|
+
lines.push(`run: ${run.id}`, `状态: ${run.status}`, `范围: ${formatScope(run.scope)}`, `进度: ${run.progress.schemas} schema / ${run.progress.relations} 表或视图 / ${run.progress.fields} 字段`);
|
|
600
|
+
if (run.error !== void 0) lines.push(`错误: ${run.error}`);
|
|
601
|
+
if (run.enrichment !== void 0) {
|
|
602
|
+
lines.push(`AI 业务含义: ${run.enrichment.status}`, `AI 模型: ${run.enrichment.provider}/${run.enrichment.model}`, `AI 进度: ${run.enrichment.tablesCompleted}/${run.enrichment.tablesTotal} 表,${run.enrichment.candidatesGenerated} 个候选,${run.enrichment.tablesFailed} 个失败`);
|
|
603
|
+
if (run.enrichment.error !== void 0) lines.push(`AI 错误: ${run.enrichment.error}`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
return {
|
|
607
|
+
kind: "success",
|
|
608
|
+
text: lines.join("\n")
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
case "cancel": {
|
|
612
|
+
const sourceId = await requireCommandSourceId(ctx, sessionId);
|
|
613
|
+
const run = await ctx.dataAgentCatalogScanner.cancel(sourceId, action.runId);
|
|
614
|
+
return {
|
|
615
|
+
kind: "success",
|
|
616
|
+
text: `已请求取消 Catalog run ${run.id};当前状态 ${run.status}。`
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
case "diff": {
|
|
620
|
+
const sourceId = await requireCommandSourceId(ctx, sessionId);
|
|
621
|
+
return {
|
|
622
|
+
kind: "success",
|
|
623
|
+
text: formatCatalogDiff(ctx.dataAgentCatalog.diff(sourceId, action.fromRunId, action.toRunId, void 0, 50))
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
case "view":
|
|
627
|
+
if (presentation?.open(sessionId) !== true) return {
|
|
628
|
+
kind: "error",
|
|
629
|
+
text: "当前dsh-tui未提供Catalog全屏scene能力;请升级dsh-tui,或暂用 /catalog status 和Web数据目录查看。"
|
|
630
|
+
};
|
|
631
|
+
return { kind: "success" };
|
|
632
|
+
}
|
|
633
|
+
} catch (error) {
|
|
634
|
+
return {
|
|
635
|
+
kind: "error",
|
|
636
|
+
text: error instanceof Error ? error.message : String(error)
|
|
637
|
+
};
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
function parseCatalogAction(rawInput) {
|
|
641
|
+
const tokens = splitCommandLine$1(rawInput.trim());
|
|
642
|
+
if (tokens.length === 0) throw new Error(`必须提供 Catalog 子命令。\n\n${CATALOG_COMMAND_USAGE}`);
|
|
643
|
+
const [subcommand, ...args] = tokens;
|
|
644
|
+
if (subcommand === "status") {
|
|
645
|
+
const values = parseNamedArguments(args, /* @__PURE__ */ new Set(["run"]));
|
|
646
|
+
return {
|
|
647
|
+
kind: "status",
|
|
648
|
+
...values.get("run") !== void 0 ? { runId: values.get("run") } : {}
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
if (subcommand === "scan") return parseScan(args);
|
|
652
|
+
if (subcommand === "view") {
|
|
653
|
+
if (args.length > 0) throw new Error(`view 不接受额外参数。\n\n${CATALOG_COMMAND_USAGE}`);
|
|
654
|
+
return { kind: "view" };
|
|
655
|
+
}
|
|
656
|
+
if (subcommand === "cancel") {
|
|
657
|
+
const values = parseNamedArguments(args, /* @__PURE__ */ new Set(["run"]));
|
|
658
|
+
return {
|
|
659
|
+
kind: "cancel",
|
|
660
|
+
...values.get("run") !== void 0 ? { runId: values.get("run") } : {}
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (subcommand === "diff") {
|
|
664
|
+
const values = parseNamedArguments(args, /* @__PURE__ */ new Set(["from", "to"]));
|
|
665
|
+
const fromRunId = values.get("from");
|
|
666
|
+
const toRunId = values.get("to");
|
|
667
|
+
if (fromRunId === void 0 !== (toRunId === void 0)) throw new Error("diff 的 --from 与 --to 必须同时提供");
|
|
668
|
+
return {
|
|
669
|
+
kind: "diff",
|
|
670
|
+
...fromRunId !== void 0 ? { fromRunId } : {},
|
|
671
|
+
...toRunId !== void 0 ? { toRunId } : {}
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
throw new Error(`未知 catalog 子命令:${subcommand}\n\n${CATALOG_COMMAND_USAGE}`);
|
|
675
|
+
}
|
|
676
|
+
function parseScan(args) {
|
|
677
|
+
if (args.length === 0) return { kind: "scan" };
|
|
678
|
+
let all = false;
|
|
679
|
+
const named = [];
|
|
680
|
+
for (const token of args) if (token === "--all") all = true;
|
|
681
|
+
else named.push(token);
|
|
682
|
+
const values = parseNamedArguments(named, /* @__PURE__ */ new Set(["schema", "table"]));
|
|
683
|
+
const schema = values.get("schema");
|
|
684
|
+
const table = values.get("table");
|
|
685
|
+
if (all && (schema !== void 0 || table !== void 0)) throw new Error("--all 不能与 --schema/--table 同时使用");
|
|
686
|
+
if (table !== void 0 && schema === void 0) throw new Error("--table 必须与 --schema 同时提供");
|
|
687
|
+
if (all) return {
|
|
688
|
+
kind: "scan",
|
|
689
|
+
scope: { kind: "source" }
|
|
690
|
+
};
|
|
691
|
+
if (schema !== void 0 && table !== void 0) return {
|
|
692
|
+
kind: "scan",
|
|
693
|
+
scope: {
|
|
694
|
+
kind: "table",
|
|
695
|
+
schema,
|
|
696
|
+
table
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
if (schema !== void 0) return {
|
|
700
|
+
kind: "scan",
|
|
701
|
+
scope: {
|
|
702
|
+
kind: "schema",
|
|
703
|
+
schema
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
throw new Error(`scan 必须显式提供 --all 或 --schema。\n\n${CATALOG_COMMAND_USAGE}`);
|
|
707
|
+
}
|
|
708
|
+
async function askForCatalogScope(ctx, invocation) {
|
|
709
|
+
const questions = ctx.get("userQuestions");
|
|
710
|
+
if (questions === void 0) return void 0;
|
|
711
|
+
try {
|
|
712
|
+
const kind = answerValue$1(await questions.ask({
|
|
713
|
+
agent: invocation.agent,
|
|
714
|
+
signal: invocation.signal,
|
|
715
|
+
questions: [{
|
|
716
|
+
id: "scope",
|
|
717
|
+
header: "扫描范围",
|
|
718
|
+
question: "选择 Catalog 扫描范围(不会读取业务明细)",
|
|
719
|
+
options: [
|
|
720
|
+
{ label: "单表" },
|
|
721
|
+
{ label: "Schema" },
|
|
722
|
+
{ label: "全库" }
|
|
723
|
+
]
|
|
724
|
+
}]
|
|
725
|
+
}), "scope");
|
|
726
|
+
if (kind === "全库") {
|
|
727
|
+
if (answerValue$1(await questions.ask({
|
|
728
|
+
agent: invocation.agent,
|
|
729
|
+
signal: invocation.signal,
|
|
730
|
+
questions: [{
|
|
731
|
+
id: "confirm",
|
|
732
|
+
header: "确认全库",
|
|
733
|
+
question: "确认扫描当前数据源的全部可见Schema和对象?",
|
|
734
|
+
options: [{ label: "取消" }, { label: "确认" }]
|
|
735
|
+
}]
|
|
736
|
+
}), "confirm") !== "确认") throw new Error("已取消 Catalog 扫描。");
|
|
737
|
+
return { kind: "source" };
|
|
738
|
+
}
|
|
739
|
+
if (kind !== "Schema" && kind !== "单表") throw new Error("未选择有效的扫描范围");
|
|
740
|
+
const detail = await questions.ask({
|
|
741
|
+
agent: invocation.agent,
|
|
742
|
+
signal: invocation.signal,
|
|
743
|
+
questions: [{
|
|
744
|
+
id: "schema",
|
|
745
|
+
header: "Schema",
|
|
746
|
+
question: "输入要扫描的 Schema / database 名称"
|
|
747
|
+
}, ...kind === "单表" ? [{
|
|
748
|
+
id: "table",
|
|
749
|
+
header: "表或视图",
|
|
750
|
+
question: "输入要扫描的表或视图名称"
|
|
751
|
+
}] : []]
|
|
752
|
+
});
|
|
753
|
+
const schema = answerValue$1(detail, "schema")?.trim();
|
|
754
|
+
if (schema === void 0 || schema.length === 0) throw new Error("Schema 不能为空");
|
|
755
|
+
if (kind === "Schema") return {
|
|
756
|
+
kind: "schema",
|
|
757
|
+
schema
|
|
758
|
+
};
|
|
759
|
+
const table = answerValue$1(detail, "table")?.trim();
|
|
760
|
+
if (table === void 0 || table.length === 0) throw new Error("表或视图名称不能为空");
|
|
761
|
+
return {
|
|
762
|
+
kind: "table",
|
|
763
|
+
schema,
|
|
764
|
+
table
|
|
765
|
+
};
|
|
766
|
+
} catch (error) {
|
|
767
|
+
if (error.code === "NO_PROVIDER") return void 0;
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
async function resolveCommandSourceId(ctx, sessionId) {
|
|
772
|
+
const connected = ctx.dataAgentConnections.get(sessionId)?.profileId;
|
|
773
|
+
if (connected !== void 0 && ctx.dataAgentCatalog.status(connected) !== void 0) return connected;
|
|
774
|
+
const sources = ctx.dataAgentCatalog.listSources();
|
|
775
|
+
return sources.length === 1 ? sources[0].id : void 0;
|
|
776
|
+
}
|
|
777
|
+
async function requireCommandSourceId(ctx, sessionId) {
|
|
778
|
+
const sourceId = await resolveCommandSourceId(ctx, sessionId);
|
|
779
|
+
if (sourceId === void 0) throw new Error("无法确定 Catalog source;请连接对应profile或在Web选择source");
|
|
780
|
+
return sourceId;
|
|
781
|
+
}
|
|
782
|
+
function formatScope(scope) {
|
|
783
|
+
if (scope.kind === "source") return "全库";
|
|
784
|
+
if (scope.kind === "schema") return `Schema ${scope.schema}`;
|
|
785
|
+
return `${scope.schema}.${scope.table}`;
|
|
786
|
+
}
|
|
787
|
+
function formatCatalogDiff(diff) {
|
|
788
|
+
const groups = /* @__PURE__ */ new Map();
|
|
789
|
+
for (const item of diff.items) groups.set(item.kind, (groups.get(item.kind) ?? 0) + 1);
|
|
790
|
+
const summary = [
|
|
791
|
+
"added",
|
|
792
|
+
"changed",
|
|
793
|
+
"missing",
|
|
794
|
+
"restored",
|
|
795
|
+
"unavailable"
|
|
796
|
+
].map((kind) => `${kind}: ${groups.get(kind) ?? 0}`).join(",");
|
|
797
|
+
const details = diff.items.slice(0, 20).map((item) => `- [${item.kind}] ${item.path}: ${item.summary.join("; ")}`);
|
|
798
|
+
return [
|
|
799
|
+
`Catalog diff ${diff.fromRunId} → ${diff.toRunId}`,
|
|
800
|
+
`范围: ${formatScope(diff.scope)}`,
|
|
801
|
+
summary,
|
|
802
|
+
...details,
|
|
803
|
+
...diff.truncated ? ["结果已截断;请在Web数据目录继续查看。"] : []
|
|
804
|
+
].join("\n");
|
|
805
|
+
}
|
|
806
|
+
function parseNamedArguments(tokens, allowed) {
|
|
807
|
+
const values = /* @__PURE__ */ new Map();
|
|
808
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
809
|
+
const token = tokens[index];
|
|
810
|
+
if (/password|secret|credential/i.test(token)) throw new Error("Catalog 命令不接受任何secret或credential参数");
|
|
811
|
+
if (!token.startsWith("--")) throw new Error(`无法解析参数:${token}\n\n${CATALOG_COMMAND_USAGE}`);
|
|
812
|
+
const assignment = token.slice(2).split("=", 2);
|
|
813
|
+
const key = assignment[0];
|
|
814
|
+
if (!allowed.has(key)) throw new Error(`未知 Catalog 参数:--${key}\n\n${CATALOG_COMMAND_USAGE}`);
|
|
815
|
+
const value = assignment.length === 2 ? assignment[1] : tokens[++index];
|
|
816
|
+
if (value === void 0 || value.startsWith("--") || value.length === 0 || value.length > 256) throw new Error(`参数 --${key} 缺少有效值`);
|
|
817
|
+
if (values.has(key)) throw new Error(`参数 --${key} 不能重复`);
|
|
818
|
+
values.set(key, value);
|
|
819
|
+
}
|
|
820
|
+
return values;
|
|
821
|
+
}
|
|
822
|
+
function answerValue$1(answer, id) {
|
|
823
|
+
const item = answer.answers.find((candidate) => candidate.id === id);
|
|
824
|
+
return item?.custom ?? item?.selected[0];
|
|
825
|
+
}
|
|
826
|
+
function splitCommandLine$1(value) {
|
|
827
|
+
const tokens = [];
|
|
828
|
+
let token = "";
|
|
829
|
+
let quote;
|
|
830
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
831
|
+
const char = value[index];
|
|
832
|
+
if (quote !== void 0) {
|
|
833
|
+
if (char === quote) quote = void 0;
|
|
834
|
+
else token += char;
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
if (char === "\"" || char === "'") quote = char;
|
|
838
|
+
else if (/\s/.test(char)) {
|
|
839
|
+
if (token.length > 0) {
|
|
840
|
+
tokens.push(token);
|
|
841
|
+
token = "";
|
|
842
|
+
}
|
|
843
|
+
} else token += char;
|
|
844
|
+
}
|
|
845
|
+
if (quote !== void 0) throw new Error("Catalog 命令包含未闭合引号");
|
|
846
|
+
if (token.length > 0) tokens.push(token);
|
|
847
|
+
return tokens;
|
|
848
|
+
}
|
|
849
|
+
//#endregion
|
|
850
|
+
//#region src/catalog-tui.ts
|
|
851
|
+
const STATUS_KEY = "data-agent:catalog";
|
|
852
|
+
const SCENE_ID = "data-agent-catalog";
|
|
853
|
+
const POLL_INTERVAL_MS = 750;
|
|
854
|
+
const SEARCH_PAGE_SIZE = 50;
|
|
855
|
+
const DETAIL_PAGE_SIZE = 200;
|
|
856
|
+
/** Create an adapter only from public optional services exposed by dsh-tui. */
|
|
857
|
+
function createCatalogTuiAdapter(ctx) {
|
|
858
|
+
let sessionId;
|
|
859
|
+
let runId;
|
|
860
|
+
let sourceId;
|
|
861
|
+
let timer;
|
|
862
|
+
let clearStatus;
|
|
863
|
+
let disposed = false;
|
|
864
|
+
const replaceStatus = (text) => {
|
|
865
|
+
clearStatus?.();
|
|
866
|
+
clearStatus = void 0;
|
|
867
|
+
if (text === void 0 || disposed) return;
|
|
868
|
+
const statusService = optionalService(ctx, "tuiStatus", (value) => typeof value.set === "function");
|
|
869
|
+
if (statusService === void 0) return;
|
|
870
|
+
try {
|
|
871
|
+
clearStatus = statusService.set(STATUS_KEY, text, ctx);
|
|
872
|
+
} catch (error) {
|
|
873
|
+
ctx.logger.warn("data-agent: unable to update dsh-tui Catalog status: %s", error);
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
const stopPolling = () => {
|
|
877
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
878
|
+
timer = void 0;
|
|
879
|
+
};
|
|
880
|
+
const poll = () => {
|
|
881
|
+
stopPolling();
|
|
882
|
+
if (disposed || runId === void 0 || sourceId === void 0) return;
|
|
883
|
+
try {
|
|
884
|
+
const current = ctx.dataAgentCatalog.listRuns(sourceId, 200).find((candidate) => candidate.id === runId);
|
|
885
|
+
if (current === void 0) {
|
|
886
|
+
replaceStatus("Catalog · 无法找到扫描记录 · /catalog status");
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
replaceStatus(formatCatalogTuiStatus(current));
|
|
890
|
+
if (isCatalogRunSettled(current)) return;
|
|
891
|
+
} catch (error) {
|
|
892
|
+
replaceStatus("Catalog · 状态读取失败 · /catalog status");
|
|
893
|
+
ctx.logger.warn("data-agent: unable to poll dsh-tui Catalog status: %s", error);
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
|
897
|
+
timer.unref?.();
|
|
898
|
+
};
|
|
899
|
+
let scenesService;
|
|
900
|
+
let disposeScene;
|
|
901
|
+
const ensureScene = () => {
|
|
902
|
+
if (disposeScene !== void 0) return true;
|
|
903
|
+
scenesService = optionalService(ctx, "tuiScenes", (value) => typeof value.register === "function" && typeof value.open === "function");
|
|
904
|
+
if (scenesService === void 0) return false;
|
|
905
|
+
try {
|
|
906
|
+
disposeScene = scenesService.register({
|
|
907
|
+
id: SCENE_ID,
|
|
908
|
+
title: "Data Catalog",
|
|
909
|
+
component: (props) => props.React.createElement(CatalogScene, {
|
|
910
|
+
...props,
|
|
911
|
+
ctx,
|
|
912
|
+
sessionId
|
|
913
|
+
})
|
|
914
|
+
}, ctx);
|
|
915
|
+
} catch (error) {
|
|
916
|
+
ctx.logger.warn("data-agent: unable to register dsh-tui Catalog scene: %s", error);
|
|
917
|
+
return false;
|
|
918
|
+
}
|
|
919
|
+
return true;
|
|
920
|
+
};
|
|
921
|
+
ensureScene();
|
|
922
|
+
return {
|
|
923
|
+
watch(run) {
|
|
924
|
+
runId = run.id;
|
|
925
|
+
sourceId = run.sourceId;
|
|
926
|
+
replaceStatus(formatCatalogTuiStatus(run));
|
|
927
|
+
if (!isCatalogRunSettled(run)) {
|
|
928
|
+
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
|
929
|
+
timer.unref?.();
|
|
930
|
+
}
|
|
931
|
+
},
|
|
932
|
+
open(nextSessionId) {
|
|
933
|
+
if (!ensureScene() || scenesService === void 0) return false;
|
|
934
|
+
sessionId = nextSessionId;
|
|
935
|
+
stopPolling();
|
|
936
|
+
replaceStatus(void 0);
|
|
937
|
+
try {
|
|
938
|
+
return scenesService.open(SCENE_ID);
|
|
939
|
+
} catch (error) {
|
|
940
|
+
ctx.logger.warn("data-agent: unable to open dsh-tui Catalog scene: %s", error);
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
},
|
|
944
|
+
dispose() {
|
|
945
|
+
if (disposed) return;
|
|
946
|
+
disposed = true;
|
|
947
|
+
stopPolling();
|
|
948
|
+
replaceStatus(void 0);
|
|
949
|
+
disposeScene?.();
|
|
950
|
+
disposeScene = void 0;
|
|
951
|
+
}
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
/** One bounded status-line projection shared by the adapter and tests. */
|
|
955
|
+
function formatCatalogTuiStatus(run) {
|
|
956
|
+
const progress = `${run.progress.schemas} Schema · ${run.progress.relations} 表/视图 · ${run.progress.fields} 字段`;
|
|
957
|
+
if (run.status === "queued") return "Catalog · 等待扫描 · 0 Schema · 0 表/视图 · 0 字段";
|
|
958
|
+
if (run.status === "running") return `Catalog · 正在读取技术元数据 · ${progress}`;
|
|
959
|
+
if (run.status === "applying") return `Catalog · 正在发布技术目录 · ${progress}`;
|
|
960
|
+
if (run.status === "failed") return "Catalog · ✕ 技术扫描失败 · /catalog status";
|
|
961
|
+
if (run.status === "cancelled") return "Catalog · 已取消技术扫描 · /catalog status";
|
|
962
|
+
if (run.status === "interrupted") return "Catalog · 扫描被中断 · /catalog status";
|
|
963
|
+
const enrichment = run.enrichment;
|
|
964
|
+
if (enrichment === void 0) return `Catalog · ✓ 技术目录完成 · ${progress} · /catalog view`;
|
|
965
|
+
const aiProgress = `${enrichment.tablesCompleted}/${enrichment.tablesTotal} 表 · ${enrichment.candidatesGenerated} 候选`;
|
|
966
|
+
if (enrichment.status === "queued") return `Catalog · 等待生成AI业务含义 · ${aiProgress}`;
|
|
967
|
+
if (enrichment.status === "running") return `Catalog · 正在生成AI业务含义 · ${aiProgress}${enrichment.tablesFailed > 0 ? ` · ${enrichment.tablesFailed} 失败` : ""}`;
|
|
968
|
+
if (enrichment.status === "succeeded") return `Catalog · ✓ 完成 · ${aiProgress} · /catalog view`;
|
|
969
|
+
if (enrichment.status === "partial") return `Catalog · ⚠ 技术目录完成,AI部分成功 · ${aiProgress} · ${enrichment.tablesFailed} 失败 · /catalog view`;
|
|
970
|
+
if (enrichment.status === "cancelled") return `Catalog · 技术目录完成,AI已取消 · ${aiProgress} · /catalog view`;
|
|
971
|
+
return `Catalog · 技术目录完成,AI生成失败 · ${aiProgress} · /catalog view`;
|
|
972
|
+
}
|
|
973
|
+
function isCatalogRunSettled(run) {
|
|
974
|
+
if (run.status === "queued" || run.status === "running" || run.status === "applying") return false;
|
|
975
|
+
if (run.status !== "succeeded") return true;
|
|
976
|
+
return run.enrichment === void 0 || run.enrichment.status !== "queued" && run.enrichment.status !== "running";
|
|
977
|
+
}
|
|
978
|
+
/** Text projection for the independently scrollable right pane. */
|
|
979
|
+
function buildCatalogTuiDetailLines(detail) {
|
|
980
|
+
const meaningByAsset = /* @__PURE__ */ new Map();
|
|
981
|
+
for (const semantic of detail.semantics) if (semantic.definition.kind === "meaning") meaningByAsset.set(semantic.definition.targetAssetId, semantic);
|
|
982
|
+
const tableMeaning = meaningByAsset.get(detail.asset.assetId);
|
|
983
|
+
const lines = [
|
|
984
|
+
detail.asset.payload.name,
|
|
985
|
+
detail.asset.payload.path,
|
|
986
|
+
`状态 ${detail.asset.status} · ${detail.fields.length}${detail.truncated ? "+" : ""} 字段 · ${detail.relations.length} 关系`
|
|
987
|
+
];
|
|
988
|
+
if (detail.asset.payload.comment !== void 0) lines.push(`数据库注释:${detail.asset.payload.comment}`);
|
|
989
|
+
lines.push("", "表业务含义");
|
|
990
|
+
if (tableMeaning?.definition.kind === "meaning") lines.push(`[${tableMeaning.definition.status}] ${tableMeaning.definition.description}`, `AI来源 ${tableMeaning.definition.generatedBy.provider}/${tableMeaning.definition.generatedBy.model} · ${tableMeaning.definition.generatedBy.runId}`);
|
|
991
|
+
else lines.push("— 尚无表级业务含义候选");
|
|
992
|
+
lines.push("", "字段业务含义");
|
|
993
|
+
for (const field of detail.fields) {
|
|
994
|
+
const meaning = meaningByAsset.get(field.assetId);
|
|
995
|
+
const type = field.payload.dataType ?? "类型未知";
|
|
996
|
+
const nullable = field.payload.nullable === void 0 ? "" : field.payload.nullable ? " · 可空" : " · 非空";
|
|
997
|
+
lines.push(`${field.payload.name} · ${type}${nullable}`);
|
|
998
|
+
lines.push(meaning?.definition.kind === "meaning" ? ` [${meaning.definition.status}] ${meaning.definition.description}` : " — 尚无AI业务含义");
|
|
999
|
+
}
|
|
1000
|
+
if (detail.fields.length === 0) lines.push("— 没有字段");
|
|
1001
|
+
if (detail.truncated) lines.push("", "字段过多,当前只显示有界详情页;可在Web数据目录查看其余字段。");
|
|
1002
|
+
if (detail.relations.length > 0) {
|
|
1003
|
+
lines.push("", "关系");
|
|
1004
|
+
for (const relation of detail.relations) lines.push(`${relation.kind}${relation.name === void 0 ? "" : ` · ${relation.name}`} · ${relation.columnAssetIds.length} 字段`);
|
|
1005
|
+
}
|
|
1006
|
+
return lines;
|
|
1007
|
+
}
|
|
1008
|
+
function CatalogScene(props) {
|
|
1009
|
+
const { React, ui, close, ctx, sessionId } = props;
|
|
1010
|
+
const h = React.createElement;
|
|
1011
|
+
const { columns, rows } = ui.useTerminalSize();
|
|
1012
|
+
const [source, setSource] = React.useState();
|
|
1013
|
+
const [status, setStatus] = React.useState();
|
|
1014
|
+
const [items, setItems] = React.useState([]);
|
|
1015
|
+
const [nextCursor, setNextCursor] = React.useState();
|
|
1016
|
+
const [selected, setSelected] = React.useState(0);
|
|
1017
|
+
const [detail, setDetail] = React.useState();
|
|
1018
|
+
const [focus, setFocus] = React.useState("list");
|
|
1019
|
+
const [detailScroll, setDetailScroll] = React.useState(0);
|
|
1020
|
+
const [query, setQuery] = React.useState("");
|
|
1021
|
+
const [queryDraft, setQueryDraft] = React.useState("");
|
|
1022
|
+
const [queryOpen, setQueryOpen] = React.useState(false);
|
|
1023
|
+
const [allSchemas, setAllSchemas] = React.useState(false);
|
|
1024
|
+
const [loading, setLoading] = React.useState(true);
|
|
1025
|
+
const [loadingMore, setLoadingMore] = React.useState(false);
|
|
1026
|
+
const [error, setError] = React.useState();
|
|
1027
|
+
const [refreshNonce, setRefreshNonce] = React.useState(0);
|
|
1028
|
+
const [liveNonce, setLiveNonce] = React.useState(0);
|
|
1029
|
+
const searchRequest = React.useCallback((selectedSource, cursor) => ({
|
|
1030
|
+
query: query.trim() === "" ? "*" : query.trim(),
|
|
1031
|
+
filters: {
|
|
1032
|
+
sourceId: selectedSource.id,
|
|
1033
|
+
...!allSchemas && defaultBrowseSchema(selectedSource) !== "" ? { schema: defaultBrowseSchema(selectedSource) } : {},
|
|
1034
|
+
assetKinds: ["table", "view"],
|
|
1035
|
+
assetStatuses: ["observed"],
|
|
1036
|
+
includeInferred: true
|
|
1037
|
+
},
|
|
1038
|
+
...cursor !== void 0 ? { cursor } : {},
|
|
1039
|
+
pageSize: SEARCH_PAGE_SIZE
|
|
1040
|
+
}), [query, allSchemas]);
|
|
1041
|
+
React.useEffect(() => {
|
|
1042
|
+
let cancelled = false;
|
|
1043
|
+
setLoading(true);
|
|
1044
|
+
setError(void 0);
|
|
1045
|
+
if (sessionId === void 0) {
|
|
1046
|
+
setError("无法确定当前data-agent会话。");
|
|
1047
|
+
setLoading(false);
|
|
1048
|
+
return () => {
|
|
1049
|
+
cancelled = true;
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
ctx.dataAgentCatalog.resolveSource(sessionId).then(async (nextSource) => {
|
|
1053
|
+
const page = await ctx.dataAgentCatalog.search(searchRequest(nextSource));
|
|
1054
|
+
if (cancelled) return;
|
|
1055
|
+
setSource(nextSource);
|
|
1056
|
+
setStatus(ctx.dataAgentCatalog.status(nextSource.id));
|
|
1057
|
+
setItems(page.items);
|
|
1058
|
+
setNextCursor(page.nextCursor);
|
|
1059
|
+
setSelected((previous) => Math.min(previous, Math.max(0, page.items.length - 1)));
|
|
1060
|
+
}).catch((cause) => {
|
|
1061
|
+
if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
|
|
1062
|
+
}).finally(() => {
|
|
1063
|
+
if (!cancelled) setLoading(false);
|
|
1064
|
+
});
|
|
1065
|
+
return () => {
|
|
1066
|
+
cancelled = true;
|
|
1067
|
+
};
|
|
1068
|
+
}, [
|
|
1069
|
+
ctx,
|
|
1070
|
+
sessionId,
|
|
1071
|
+
searchRequest,
|
|
1072
|
+
refreshNonce
|
|
1073
|
+
]);
|
|
1074
|
+
const selectedItem = items[selected];
|
|
1075
|
+
React.useEffect(() => {
|
|
1076
|
+
if (source === void 0 || selectedItem === void 0) {
|
|
1077
|
+
setDetail(void 0);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
try {
|
|
1081
|
+
setDetail(ctx.dataAgentCatalog.getAsset(source.id, selectedItem.id, void 0, DETAIL_PAGE_SIZE));
|
|
1082
|
+
setError(void 0);
|
|
1083
|
+
} catch (cause) {
|
|
1084
|
+
setDetail(void 0);
|
|
1085
|
+
setError(cause instanceof Error ? cause.message : String(cause));
|
|
1086
|
+
}
|
|
1087
|
+
}, [
|
|
1088
|
+
ctx,
|
|
1089
|
+
source,
|
|
1090
|
+
selectedItem?.id,
|
|
1091
|
+
refreshNonce,
|
|
1092
|
+
liveNonce
|
|
1093
|
+
]);
|
|
1094
|
+
React.useEffect(() => setDetailScroll(0), [selectedItem?.id]);
|
|
1095
|
+
React.useEffect(() => {
|
|
1096
|
+
if (source === void 0) return;
|
|
1097
|
+
const timer = setInterval(() => {
|
|
1098
|
+
const next = ctx.dataAgentCatalog.status(source.id);
|
|
1099
|
+
setStatus(next);
|
|
1100
|
+
const run = next?.latestRun;
|
|
1101
|
+
if (run !== void 0 && !isCatalogRunSettled(run)) setLiveNonce((value) => value + 1);
|
|
1102
|
+
}, 1e3);
|
|
1103
|
+
return () => clearInterval(timer);
|
|
1104
|
+
}, [ctx, source]);
|
|
1105
|
+
const loadMore = React.useCallback(() => {
|
|
1106
|
+
if (source === void 0 || nextCursor === void 0 || loadingMore) return;
|
|
1107
|
+
setLoadingMore(true);
|
|
1108
|
+
ctx.dataAgentCatalog.search(searchRequest(source, nextCursor)).then((page) => {
|
|
1109
|
+
setItems((previous) => [...previous, ...page.items]);
|
|
1110
|
+
setNextCursor(page.nextCursor);
|
|
1111
|
+
}).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause))).finally(() => setLoadingMore(false));
|
|
1112
|
+
}, [
|
|
1113
|
+
ctx,
|
|
1114
|
+
source,
|
|
1115
|
+
nextCursor,
|
|
1116
|
+
loadingMore,
|
|
1117
|
+
searchRequest
|
|
1118
|
+
]);
|
|
1119
|
+
const compact = columns < 92;
|
|
1120
|
+
const contentRows = Math.max(6, rows - 4);
|
|
1121
|
+
const listRows = compact ? Math.max(3, Math.floor(contentRows * .42)) : contentRows;
|
|
1122
|
+
const detailRows = compact ? Math.max(3, contentRows - listRows) : contentRows;
|
|
1123
|
+
const leftWidth = compact ? columns - 2 : Math.max(30, Math.min(48, Math.floor(columns * .34)));
|
|
1124
|
+
const rightWidth = compact ? columns - 2 : Math.max(30, columns - leftWidth - 3);
|
|
1125
|
+
const visibleListRows = Math.max(1, listRows - 2);
|
|
1126
|
+
const visibleDetailRows = Math.max(1, detailRows - 2);
|
|
1127
|
+
const listStart = Math.max(0, Math.min(selected - Math.floor(visibleListRows / 2), items.length - visibleListRows));
|
|
1128
|
+
const visibleItems = items.slice(listStart, listStart + visibleListRows);
|
|
1129
|
+
const detailLines = detail === void 0 ? [] : buildCatalogTuiDetailLines(detail);
|
|
1130
|
+
const maxDetailScroll = Math.max(0, detailLines.length - visibleDetailRows);
|
|
1131
|
+
const clampedDetailScroll = Math.min(detailScroll, maxDetailScroll);
|
|
1132
|
+
const visibleDetail = detailLines.slice(clampedDetailScroll, clampedDetailScroll + visibleDetailRows);
|
|
1133
|
+
const moveList = React.useCallback((delta) => {
|
|
1134
|
+
setSelected((previous) => {
|
|
1135
|
+
const next = Math.max(0, Math.min(items.length - 1, previous + delta));
|
|
1136
|
+
if (next >= items.length - 3 && nextCursor !== void 0) loadMore();
|
|
1137
|
+
return next;
|
|
1138
|
+
});
|
|
1139
|
+
}, [
|
|
1140
|
+
items.length,
|
|
1141
|
+
nextCursor,
|
|
1142
|
+
loadMore
|
|
1143
|
+
]);
|
|
1144
|
+
ui.useInput((input, key) => {
|
|
1145
|
+
if (queryOpen) {
|
|
1146
|
+
if (key.escape) {
|
|
1147
|
+
setQueryOpen(false);
|
|
1148
|
+
setQueryDraft(query);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
if (key.return) {
|
|
1152
|
+
setQuery(queryDraft.trim());
|
|
1153
|
+
setSelected(0);
|
|
1154
|
+
setQueryOpen(false);
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
if (key.backspace || key.delete) {
|
|
1158
|
+
setQueryDraft((previous) => previous.slice(0, -1));
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
if (input !== "" && !key.ctrl && !key.meta && !key.super) setQueryDraft((previous) => (previous + input.replace(/[\r\n\u0000-\u001f\u007f]/g, "")).slice(0, 120));
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
if (key.escape || input === "q") return close();
|
|
1165
|
+
if (input === "/") {
|
|
1166
|
+
setQueryDraft(query);
|
|
1167
|
+
setQueryOpen(true);
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
1170
|
+
if (input === "r") {
|
|
1171
|
+
setRefreshNonce((value) => value + 1);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
if (input === "a") {
|
|
1175
|
+
setAllSchemas((previous) => !previous);
|
|
1176
|
+
setSelected(0);
|
|
1177
|
+
return;
|
|
1178
|
+
}
|
|
1179
|
+
if (key.tab || focus === "list" && (key.rightArrow || key.return) || focus === "detail" && key.leftArrow) {
|
|
1180
|
+
setFocus((previous) => previous === "list" ? "detail" : "list");
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
if (focus === "list") {
|
|
1184
|
+
if (key.upArrow || input === "k") return moveList(-1);
|
|
1185
|
+
if (key.downArrow || input === "j") return moveList(1);
|
|
1186
|
+
if (key.pageUp) return moveList(-visibleListRows);
|
|
1187
|
+
if (key.pageDown) return moveList(visibleListRows);
|
|
1188
|
+
if (key.home || input === "g") return setSelected(0);
|
|
1189
|
+
if (key.end || input === "G") {
|
|
1190
|
+
setSelected(Math.max(0, items.length - 1));
|
|
1191
|
+
loadMore();
|
|
1192
|
+
}
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (key.upArrow || input === "k") return setDetailScroll((previous) => Math.max(0, previous - 1));
|
|
1196
|
+
if (key.downArrow || input === "j") return setDetailScroll((previous) => Math.min(maxDetailScroll, previous + 1));
|
|
1197
|
+
if (key.pageUp) return setDetailScroll((previous) => Math.max(0, previous - visibleDetailRows));
|
|
1198
|
+
if (key.pageDown) return setDetailScroll((previous) => Math.min(maxDetailScroll, previous + visibleDetailRows));
|
|
1199
|
+
if (key.home || input === "g") return setDetailScroll(0);
|
|
1200
|
+
if (key.end || input === "G") setDetailScroll(maxDetailScroll);
|
|
1201
|
+
});
|
|
1202
|
+
const latestRun = status?.latestRun;
|
|
1203
|
+
const headerStatus = latestRun === void 0 ? "尚无扫描" : formatCatalogTuiStatus(latestRun).replace(/^Catalog · /, "");
|
|
1204
|
+
const sourceLabel = source === void 0 ? "正在解析数据源…" : `${source.name} · ${status?.counts.assets ?? 0} 资产 · ${status?.counts.needsReview ?? 0} 待确认`;
|
|
1205
|
+
const searchLabel = queryOpen ? `/ ${queryDraft}▌` : `${query === "" ? "全部表与视图" : `搜索:${query}`} · ${allSchemas || source === void 0 || defaultBrowseSchema(source) === "" ? "全部Schema" : defaultBrowseSchema(source)}`;
|
|
1206
|
+
const listPane = h(ui.Box, {
|
|
1207
|
+
flexDirection: "column",
|
|
1208
|
+
width: compact ? "100%" : leftWidth,
|
|
1209
|
+
height: listRows,
|
|
1210
|
+
borderStyle: "single",
|
|
1211
|
+
borderColor: focus === "list" ? "permission" : "subtle",
|
|
1212
|
+
paddingX: 1
|
|
1213
|
+
}, h(ui.Text, {
|
|
1214
|
+
bold: true,
|
|
1215
|
+
color: focus === "list" ? "permission" : void 0,
|
|
1216
|
+
wrap: "truncate"
|
|
1217
|
+
}, `表与视图 · ${items.length}${nextCursor === void 0 ? "" : "+"}`), ...visibleItems.map((item, index) => {
|
|
1218
|
+
const active = listStart + index === selected;
|
|
1219
|
+
return h(ui.Text, {
|
|
1220
|
+
key: item.id,
|
|
1221
|
+
inverse: active,
|
|
1222
|
+
bold: active,
|
|
1223
|
+
wrap: "truncate"
|
|
1224
|
+
}, `${active ? "›" : " "} ${item.name} [${item.status}]`);
|
|
1225
|
+
}), ...items.length === 0 && !loading ? [h(ui.Text, {
|
|
1226
|
+
key: "empty",
|
|
1227
|
+
color: "subtle"
|
|
1228
|
+
}, "(没有匹配的表或视图)")] : [], ...loading || loadingMore ? [h(ui.Text, {
|
|
1229
|
+
key: "loading",
|
|
1230
|
+
color: "suggestion"
|
|
1231
|
+
}, loadingMore ? "继续加载…" : "加载目录…")] : []);
|
|
1232
|
+
const rightPane = h(ui.Box, {
|
|
1233
|
+
flexDirection: "column",
|
|
1234
|
+
width: compact ? "100%" : rightWidth,
|
|
1235
|
+
height: detailRows,
|
|
1236
|
+
borderStyle: "single",
|
|
1237
|
+
borderColor: focus === "detail" ? "permission" : "subtle",
|
|
1238
|
+
paddingX: 1
|
|
1239
|
+
}, h(ui.Text, {
|
|
1240
|
+
bold: true,
|
|
1241
|
+
color: focus === "detail" ? "permission" : void 0,
|
|
1242
|
+
wrap: "truncate"
|
|
1243
|
+
}, selectedItem === void 0 ? "表详情" : `${selectedItem.name} · ${clampedDetailScroll + 1}/${Math.max(1, detailLines.length)}`), ...visibleDetail.map((line, index) => h(ui.Text, {
|
|
1244
|
+
key: `${clampedDetailScroll + index}:${line}`,
|
|
1245
|
+
color: line.startsWith("表业务含义") || line.startsWith("字段业务含义") || line === "关系" ? "claude" : void 0,
|
|
1246
|
+
bold: line === detail?.asset.payload.name || line.startsWith("表业务含义") || line.startsWith("字段业务含义") || line === "关系",
|
|
1247
|
+
wrap: "truncate"
|
|
1248
|
+
}, line === "" ? " " : line)), ...selectedItem !== void 0 && detail === void 0 && !loading ? [h(ui.Text, {
|
|
1249
|
+
key: "detail-loading",
|
|
1250
|
+
color: "suggestion"
|
|
1251
|
+
}, "加载详情…")] : [], ...selectedItem === void 0 && !loading ? [h(ui.Text, {
|
|
1252
|
+
key: "detail-empty",
|
|
1253
|
+
color: "subtle"
|
|
1254
|
+
}, "从左侧选择一张表查看AI业务含义。")] : []);
|
|
1255
|
+
return h(ui.Box, {
|
|
1256
|
+
flexDirection: "column",
|
|
1257
|
+
width: "100%",
|
|
1258
|
+
paddingX: 1
|
|
1259
|
+
}, h(ui.Text, {
|
|
1260
|
+
bold: true,
|
|
1261
|
+
color: "claude",
|
|
1262
|
+
wrap: "truncate"
|
|
1263
|
+
}, `✦ 数据目录 ${sourceLabel}`), h(ui.Text, {
|
|
1264
|
+
color: latestRun?.status === "failed" ? "error" : "subtle",
|
|
1265
|
+
wrap: "truncate"
|
|
1266
|
+
}, headerStatus), h(ui.Text, {
|
|
1267
|
+
color: queryOpen ? "suggestion" : "subtle",
|
|
1268
|
+
wrap: "truncate"
|
|
1269
|
+
}, searchLabel), ...error === void 0 ? [] : [h(ui.Text, {
|
|
1270
|
+
key: "error",
|
|
1271
|
+
color: "error",
|
|
1272
|
+
wrap: "truncate"
|
|
1273
|
+
}, `错误:${error}`)], h(ui.Box, {
|
|
1274
|
+
flexDirection: compact ? "column" : "row",
|
|
1275
|
+
width: "100%",
|
|
1276
|
+
height: contentRows,
|
|
1277
|
+
gap: compact ? 0 : 1
|
|
1278
|
+
}, listPane, rightPane), h(ui.Text, {
|
|
1279
|
+
dimColor: true,
|
|
1280
|
+
italic: true,
|
|
1281
|
+
wrap: "truncate"
|
|
1282
|
+
}, "↑↓/jk 滚动 · Tab/←→ 切换区域 · / 搜索 · a 全部Schema · r 刷新 · Esc/q 返回 · 只读,确认/删除请使用Web"));
|
|
1283
|
+
}
|
|
1284
|
+
function optionalService(ctx, name, validate) {
|
|
1285
|
+
const value = ctx.get(name);
|
|
1286
|
+
if (value === void 0 || value === null || typeof value !== "object") return void 0;
|
|
1287
|
+
return validate(value) ? value : void 0;
|
|
1288
|
+
}
|
|
1289
|
+
function defaultBrowseSchema(source) {
|
|
1290
|
+
return [
|
|
1291
|
+
"mysql",
|
|
1292
|
+
"clickhouse",
|
|
1293
|
+
"doris",
|
|
1294
|
+
"hive",
|
|
1295
|
+
"impala"
|
|
1296
|
+
].includes(source.type) ? source.database : "";
|
|
1297
|
+
}
|
|
1298
|
+
//#endregion
|
|
1299
|
+
//#region src/command.ts
|
|
1300
|
+
const name = "data-agent-database-command";
|
|
1301
|
+
const inject = [
|
|
1302
|
+
"commands",
|
|
1303
|
+
"dataAgentConnections",
|
|
1304
|
+
"dataAgentCatalog",
|
|
1305
|
+
"dataAgentCatalogScanner",
|
|
1306
|
+
"tools"
|
|
1307
|
+
];
|
|
1308
|
+
const DATABASE_COMMAND_USAGE = [
|
|
1309
|
+
"用法:",
|
|
1310
|
+
" /database status",
|
|
1311
|
+
` /database connect --type <${DATABASE_TYPES.join("|")}> --database <name|path> [--host <host>] [--port <port>] [--user <user>] [--password-ref <REF>] [--readonly] [--secure]`,
|
|
1312
|
+
" /database test",
|
|
1313
|
+
" /database disconnect",
|
|
1314
|
+
"安全提示:TUI 无参数 connect 可输入掩码临时密码;命令参数不接受 --password,请使用 --password-ref。"
|
|
1315
|
+
].join("\n");
|
|
1316
|
+
const DATA_AGENT_TOOL_NAMES = [
|
|
1317
|
+
"str_replace_editor",
|
|
1318
|
+
"sql-query",
|
|
1319
|
+
"sql-write",
|
|
1320
|
+
"sql-cmd",
|
|
1321
|
+
"catalog-search",
|
|
1322
|
+
"catalog-get",
|
|
1323
|
+
"metric-get"
|
|
1324
|
+
];
|
|
1325
|
+
const DATA_AGENT_OWN_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
1326
|
+
...DATA_AGENT_TOOL_NAMES,
|
|
1327
|
+
"render-analysis",
|
|
1328
|
+
RUN_CODE_NAME
|
|
1329
|
+
]);
|
|
1330
|
+
const defaultInteraction = {
|
|
1331
|
+
isTuiFormAvailable: () => isDshTuiTerminal(),
|
|
1332
|
+
collectTuiConnection: (signal, options) => runTuiConnectionForm({
|
|
1333
|
+
signal,
|
|
1334
|
+
...options.initialDraft !== void 0 ? { initialDraft: options.initialDraft } : {},
|
|
1335
|
+
persistDraft: options.persistDraft
|
|
1336
|
+
})
|
|
1337
|
+
};
|
|
1338
|
+
/** Official Cordis runtime name exported by `@deepseek-harness-tui/dsh-tui`. */
|
|
1339
|
+
const DSH_TUI_PLUGIN_RUNTIME_NAME = "dsh-tui";
|
|
1340
|
+
/**
|
|
1341
|
+
* Detect actual plugin usage from Cordis' live registry. Package installation,
|
|
1342
|
+
* argv and profile labels are deliberately irrelevant.
|
|
1343
|
+
*/
|
|
1344
|
+
function isDshTuiPluginLoaded(ctx) {
|
|
1345
|
+
for (const runtime of ctx.registry.values()) {
|
|
1346
|
+
if (runtime.name !== "dsh-tui") continue;
|
|
1347
|
+
for (const fiber of runtime.fibers) if (fiber.uid !== null) return true;
|
|
1348
|
+
}
|
|
1349
|
+
return false;
|
|
1350
|
+
}
|
|
1351
|
+
/** Mount both human commands and return one symmetric disposer. */
|
|
1352
|
+
function registerDshTuiCommands(ctx) {
|
|
1353
|
+
const catalogTui = createCatalogTuiAdapter(ctx);
|
|
1354
|
+
const disposeDatabase = ctx.commands.register({
|
|
1355
|
+
name: "database",
|
|
1356
|
+
description: "查看、连接、测试或断开 data-agent 数据库连接",
|
|
1357
|
+
input: { hint: "status | connect | test | disconnect" },
|
|
1358
|
+
recordInput: false,
|
|
1359
|
+
handler: async (invocation) => executeDatabaseCommand(ctx, invocation)
|
|
1360
|
+
});
|
|
1361
|
+
const disposeCatalog = registerCatalogCommand(ctx, catalogTui);
|
|
1362
|
+
const refreshTimer = setTimeout(() => ctx.emit("commands/change"), 0);
|
|
1363
|
+
return () => {
|
|
1364
|
+
clearTimeout(refreshTimer);
|
|
1365
|
+
disposeCatalog();
|
|
1366
|
+
catalogTui.dispose();
|
|
1367
|
+
disposeDatabase();
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
/** Keep the tool boundary everywhere; follow the actual dsh-tui runtime lifecycle for commands. */
|
|
1371
|
+
function apply(ctx, options = {}) {
|
|
1372
|
+
const inheritedToolNames = ctx.tools.schemas().map((schema) => schema.name).filter((toolName) => !DATA_AGENT_OWN_TOOL_NAMES.has(toolName));
|
|
1373
|
+
ctx.tools.restrict({ deny: inheritedToolNames });
|
|
1374
|
+
const detect = options.isDshTuiPluginLoaded ?? isDshTuiPluginLoaded;
|
|
1375
|
+
let disposeCommands;
|
|
1376
|
+
const reconcile = () => {
|
|
1377
|
+
const shouldRegister = detect(ctx);
|
|
1378
|
+
if (shouldRegister && disposeCommands === void 0) disposeCommands = registerDshTuiCommands(ctx);
|
|
1379
|
+
else if (!shouldRegister && disposeCommands !== void 0) {
|
|
1380
|
+
disposeCommands();
|
|
1381
|
+
disposeCommands = void 0;
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
reconcile();
|
|
1385
|
+
if (options.isDshTuiPluginLoaded === void 0) ctx.on("internal/plugin", reconcile, { global: true });
|
|
1386
|
+
ctx.effect(() => () => {
|
|
1387
|
+
disposeCommands?.();
|
|
1388
|
+
disposeCommands = void 0;
|
|
1389
|
+
}, "data-agent: dsh-tui human command adapters");
|
|
1390
|
+
}
|
|
1391
|
+
/** Public for focused command tests and alternate command adapters. */
|
|
1392
|
+
async function executeDatabaseCommand(ctx, invocation, interaction = defaultInteraction) {
|
|
1393
|
+
let transientPassword;
|
|
1394
|
+
try {
|
|
1395
|
+
const action = parseDatabaseAction(invocation.rawInput);
|
|
1396
|
+
const sessionId = String(invocation.agent.id);
|
|
1397
|
+
switch (action.kind) {
|
|
1398
|
+
case "status": {
|
|
1399
|
+
const summary = await ctx.dataAgentConnections.status(sessionId);
|
|
1400
|
+
const tools = ctx.tools.schemas(invocation.agent).map((schema) => schema.name).sort();
|
|
1401
|
+
return {
|
|
1402
|
+
kind: "success",
|
|
1403
|
+
text: `${formatConnectionStatus(summary)}\n模型工具:${tools.join(", ") || "(无)"}\n\n${DATABASE_COMMAND_USAGE}`
|
|
1404
|
+
};
|
|
1405
|
+
}
|
|
1406
|
+
case "connect": {
|
|
1407
|
+
const input = action.input ?? await askForConnection(ctx, invocation, interaction);
|
|
1408
|
+
if (input === void 0) return {
|
|
1409
|
+
kind: "error",
|
|
1410
|
+
text: `当前界面没有可用的问答 provider。\n\n${DATABASE_COMMAND_USAGE}`
|
|
1411
|
+
};
|
|
1412
|
+
transientPassword = input.password;
|
|
1413
|
+
return {
|
|
1414
|
+
kind: "success",
|
|
1415
|
+
text: `数据库连接成功。\n${formatConnectionStatus((await ctx.dataAgentConnections.connect(sessionId, input, invocation.signal)).summary)}`
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
case "test": {
|
|
1419
|
+
const result = await ctx.dataAgentConnections.test(sessionId, invocation.signal);
|
|
1420
|
+
return {
|
|
1421
|
+
kind: "success",
|
|
1422
|
+
text: `数据库连接测试成功,发现 ${result.tables.length} 张表。\n${formatConnectionStatus(result.summary)}`
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
case "disconnect":
|
|
1426
|
+
await ctx.dataAgentConnections.disconnect(sessionId);
|
|
1427
|
+
return {
|
|
1428
|
+
kind: "success",
|
|
1429
|
+
text: "当前会话已断开数据库连接;可复用的非敏感 connection profile 已保留。"
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
} catch (error) {
|
|
1433
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1434
|
+
return {
|
|
1435
|
+
kind: "error",
|
|
1436
|
+
text: redactSecretText(message, [transientPassword])
|
|
1437
|
+
};
|
|
1438
|
+
} finally {
|
|
1439
|
+
transientPassword = void 0;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
/** Parse one command's raw input without ever accepting a plaintext password. */
|
|
1443
|
+
function parseDatabaseAction(rawInput) {
|
|
1444
|
+
const tokens = splitCommandLine(rawInput.trim());
|
|
1445
|
+
if (tokens.length === 0 || tokens[0] === "status") {
|
|
1446
|
+
if (tokens.length > 1) throw new Error(`status 不接受额外参数。\n\n${DATABASE_COMMAND_USAGE}`);
|
|
1447
|
+
return { kind: "status" };
|
|
1448
|
+
}
|
|
1449
|
+
const subcommand = tokens[0];
|
|
1450
|
+
if (subcommand === "test" || subcommand === "disconnect") {
|
|
1451
|
+
if (tokens.length > 1) throw new Error(`${subcommand} 不接受额外参数。\n\n${DATABASE_COMMAND_USAGE}`);
|
|
1452
|
+
return { kind: subcommand };
|
|
1453
|
+
}
|
|
1454
|
+
if (subcommand !== "connect") throw new Error(`未知 database 子命令:${subcommand}\n\n${DATABASE_COMMAND_USAGE}`);
|
|
1455
|
+
if (tokens.length === 1) return { kind: "connect" };
|
|
1456
|
+
return {
|
|
1457
|
+
kind: "connect",
|
|
1458
|
+
input: parseConnectArguments(tokens.slice(1))
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
/** Non-interactive `connect` argument grammar. */
|
|
1462
|
+
function parseConnectArguments(tokens) {
|
|
1463
|
+
const values = /* @__PURE__ */ new Map();
|
|
1464
|
+
let readonly;
|
|
1465
|
+
let secure;
|
|
1466
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
1467
|
+
const token = tokens[index];
|
|
1468
|
+
if (token === "--password" || token.startsWith("--password=") || token.startsWith("password=")) throw new Error("安全限制:/database 不接受明文密码参数;请改用 --password-ref <REF>。");
|
|
1469
|
+
if (token === "--readonly") {
|
|
1470
|
+
readonly = true;
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
if (token === "--readwrite") {
|
|
1474
|
+
readonly = false;
|
|
1475
|
+
continue;
|
|
1476
|
+
}
|
|
1477
|
+
if (token === "--secure") {
|
|
1478
|
+
secure = true;
|
|
1479
|
+
continue;
|
|
1480
|
+
}
|
|
1481
|
+
if (token === "--insecure") {
|
|
1482
|
+
secure = false;
|
|
1483
|
+
continue;
|
|
1484
|
+
}
|
|
1485
|
+
const assignment = token.startsWith("--") ? token.slice(2).split("=", 2) : token.split("=", 2);
|
|
1486
|
+
let key;
|
|
1487
|
+
let value;
|
|
1488
|
+
if (assignment.length === 2) {
|
|
1489
|
+
key = normalizeArgumentName(assignment[0]);
|
|
1490
|
+
value = assignment[1];
|
|
1491
|
+
} else {
|
|
1492
|
+
if (!token.startsWith("--")) throw new Error(`无法解析参数:${token}\n\n${DATABASE_COMMAND_USAGE}`);
|
|
1493
|
+
key = normalizeArgumentName(token.slice(2));
|
|
1494
|
+
const next = tokens[index + 1];
|
|
1495
|
+
if (next === void 0 || next.startsWith("--")) throw new Error(`参数 --${key} 缺少值`);
|
|
1496
|
+
value = next;
|
|
1497
|
+
index += 1;
|
|
1498
|
+
}
|
|
1499
|
+
if (key === "password") throw new Error("安全限制:/database 不接受明文密码参数;请改用 --password-ref <REF>。");
|
|
1500
|
+
if (!CONNECT_ARGUMENTS.has(key)) throw new Error(`未知连接参数:--${key}\n\n${DATABASE_COMMAND_USAGE}`);
|
|
1501
|
+
values.set(key, value);
|
|
1502
|
+
}
|
|
1503
|
+
const type = values.get("type");
|
|
1504
|
+
if (!isDatabaseType(type)) throw new Error("connect 必须提供有效的 --type");
|
|
1505
|
+
const database = values.get("database");
|
|
1506
|
+
if (database === void 0 || database.length === 0) throw new Error("connect 必须提供 --database");
|
|
1507
|
+
const input = {
|
|
1508
|
+
type,
|
|
1509
|
+
database
|
|
1510
|
+
};
|
|
1511
|
+
copyNonEmpty(values, "host", (value) => {
|
|
1512
|
+
input.host = value;
|
|
1513
|
+
});
|
|
1514
|
+
copyNonEmpty(values, "user", (value) => {
|
|
1515
|
+
input.user = value;
|
|
1516
|
+
});
|
|
1517
|
+
copyNonEmpty(values, "passwordRef", (value) => {
|
|
1518
|
+
input.passwordRef = value;
|
|
1519
|
+
});
|
|
1520
|
+
copyNonEmpty(values, "profileId", (value) => {
|
|
1521
|
+
input.profileId = value;
|
|
1522
|
+
});
|
|
1523
|
+
copyNonEmpty(values, "name", (value) => {
|
|
1524
|
+
input.name = value;
|
|
1525
|
+
});
|
|
1526
|
+
const port = values.get("port");
|
|
1527
|
+
if (port !== void 0) {
|
|
1528
|
+
const number = Number(port);
|
|
1529
|
+
if (!Number.isInteger(number) || number < 1 || number > 65535) throw new Error("--port 必须是 1-65535 的整数");
|
|
1530
|
+
input.port = number;
|
|
1531
|
+
}
|
|
1532
|
+
if (readonly !== void 0) input.readonly = readonly;
|
|
1533
|
+
if (type === "clickhouse" && secure !== void 0) input.secure = secure;
|
|
1534
|
+
return input;
|
|
1535
|
+
}
|
|
1536
|
+
/** Render a public summary; no password-bearing field exists in the type. */
|
|
1537
|
+
function formatConnectionStatus(summary) {
|
|
1538
|
+
if (summary === void 0) return "数据库状态:未连接。";
|
|
1539
|
+
const endpoint = summary.type === "sqlite" ? summary.database : `${summary.host ?? "localhost"}${summary.port !== void 0 ? `:${summary.port}` : ""}`;
|
|
1540
|
+
const lines = [
|
|
1541
|
+
summary.reconnectRequired === true ? "数据库状态:需要重新认证" : "数据库状态:已连接",
|
|
1542
|
+
`类型:${summary.type}`,
|
|
1543
|
+
`地址:${endpoint}`,
|
|
1544
|
+
`数据库:${summary.database}`,
|
|
1545
|
+
`只读:${summary.readonly === true ? "是" : "否"}`
|
|
1546
|
+
];
|
|
1547
|
+
if (summary.type === "clickhouse") lines.push(`HTTPS:${summary.secure === true ? "是" : "否"}`);
|
|
1548
|
+
if (summary.user !== void 0) lines.push(`用户:${summary.user}`);
|
|
1549
|
+
if (summary.profileId !== void 0) lines.push(`Profile:${summary.name ?? summary.profileId}`);
|
|
1550
|
+
if (summary.passwordRef !== void 0) lines.push(`凭据引用:${summary.passwordRef}`);
|
|
1551
|
+
if (summary.credential !== void 0) lines.push(`凭据:${summary.credential.configured ? `已配置${summary.credential.source !== void 0 ? `(${summary.credential.source})` : ""}` : "未配置"}`);
|
|
1552
|
+
if (summary.tables !== void 0) lines.push(`表:${summary.tables.length} 张`);
|
|
1553
|
+
return lines.join("\n");
|
|
1554
|
+
}
|
|
1555
|
+
async function askForConnection(ctx, invocation, interaction) {
|
|
1556
|
+
if (interaction.isTuiFormAvailable()) {
|
|
1557
|
+
const sessionId = String(invocation.agent.id);
|
|
1558
|
+
const initialDraft = ctx.dataAgentConnections.getFormDraft?.(sessionId);
|
|
1559
|
+
const input = await interaction.collectTuiConnection(invocation.signal, {
|
|
1560
|
+
...initialDraft !== void 0 ? { initialDraft } : {},
|
|
1561
|
+
persistDraft: async (draft) => {
|
|
1562
|
+
await ctx.dataAgentConnections.saveFormDraft?.(sessionId, draft);
|
|
1563
|
+
}
|
|
1564
|
+
});
|
|
1565
|
+
if (input === void 0) throw new Error("已取消数据库连接。");
|
|
1566
|
+
return input;
|
|
1567
|
+
}
|
|
1568
|
+
const questions = ctx.get("userQuestions");
|
|
1569
|
+
if (questions === void 0) return void 0;
|
|
1570
|
+
try {
|
|
1571
|
+
const typeValue = answerValue(await questions.ask({
|
|
1572
|
+
agent: invocation.agent,
|
|
1573
|
+
signal: invocation.signal,
|
|
1574
|
+
questions: [{
|
|
1575
|
+
id: "type",
|
|
1576
|
+
header: "数据库类型",
|
|
1577
|
+
question: "选择要连接的数据库类型",
|
|
1578
|
+
options: DATABASE_TYPES.map((label) => ({ label }))
|
|
1579
|
+
}]
|
|
1580
|
+
}), "type");
|
|
1581
|
+
if (!isDatabaseType(typeValue)) throw new Error("未选择有效的数据库类型");
|
|
1582
|
+
const detailQuestions = typeValue === "sqlite" ? [{
|
|
1583
|
+
id: "database",
|
|
1584
|
+
header: "文件路径",
|
|
1585
|
+
question: "SQLite 数据库文件路径"
|
|
1586
|
+
}, {
|
|
1587
|
+
id: "readonly",
|
|
1588
|
+
header: "只读",
|
|
1589
|
+
question: "是否启用只读模式?",
|
|
1590
|
+
options: [{ label: "是" }, { label: "否" }]
|
|
1591
|
+
}] : [
|
|
1592
|
+
{
|
|
1593
|
+
id: "host",
|
|
1594
|
+
header: "主机",
|
|
1595
|
+
question: "数据库主机(留空使用 127.0.0.1)"
|
|
1596
|
+
},
|
|
1597
|
+
{
|
|
1598
|
+
id: "port",
|
|
1599
|
+
header: "端口",
|
|
1600
|
+
question: typeValue === "clickhouse" ? `数据库端口(留空使用HTTP ${defaultDatabasePort$1("clickhouse")};HTTPS ${defaultDatabasePort$1("clickhouse", true)})` : `数据库端口(留空使用 ${defaultDatabasePort$1(typeValue)})`
|
|
1601
|
+
},
|
|
1602
|
+
{
|
|
1603
|
+
id: "user",
|
|
1604
|
+
header: "用户",
|
|
1605
|
+
question: "数据库用户名"
|
|
1606
|
+
},
|
|
1607
|
+
{
|
|
1608
|
+
id: "database",
|
|
1609
|
+
header: "数据库",
|
|
1610
|
+
question: "数据库名 / Oracle 服务名"
|
|
1611
|
+
},
|
|
1612
|
+
{
|
|
1613
|
+
id: "passwordRef",
|
|
1614
|
+
header: "凭据引用",
|
|
1615
|
+
question: "DSH credential reference(可留空)"
|
|
1616
|
+
},
|
|
1617
|
+
...typeValue === "clickhouse" ? [{
|
|
1618
|
+
id: "secure",
|
|
1619
|
+
header: "HTTPS",
|
|
1620
|
+
question: "是否使用HTTPS并验证服务器证书?",
|
|
1621
|
+
options: [{ label: "是" }, { label: "否" }]
|
|
1622
|
+
}] : [],
|
|
1623
|
+
{
|
|
1624
|
+
id: "readonly",
|
|
1625
|
+
header: "只读",
|
|
1626
|
+
question: "是否启用只读模式?",
|
|
1627
|
+
options: [{ label: "是" }, { label: "否" }]
|
|
1628
|
+
}
|
|
1629
|
+
];
|
|
1630
|
+
const details = await questions.ask({
|
|
1631
|
+
agent: invocation.agent,
|
|
1632
|
+
signal: invocation.signal,
|
|
1633
|
+
questions: detailQuestions
|
|
1634
|
+
});
|
|
1635
|
+
const database = answerValue(details, "database")?.trim();
|
|
1636
|
+
if (database === void 0 || database.length === 0) throw new Error("database 不能为空");
|
|
1637
|
+
const input = {
|
|
1638
|
+
type: typeValue,
|
|
1639
|
+
database,
|
|
1640
|
+
readonly: answerValue(details, "readonly") === "是"
|
|
1641
|
+
};
|
|
1642
|
+
if (typeValue !== "sqlite") {
|
|
1643
|
+
input.host = answerValue(details, "host")?.trim() || "127.0.0.1";
|
|
1644
|
+
if (typeValue === "clickhouse") input.secure = answerValue(details, "secure") === "是";
|
|
1645
|
+
const portText = answerValue(details, "port")?.trim();
|
|
1646
|
+
input.port = portText === void 0 || portText === "" ? defaultDatabasePort$1(typeValue, input.secure === true) : Number(portText);
|
|
1647
|
+
const user = answerValue(details, "user")?.trim();
|
|
1648
|
+
if (user !== void 0 && user !== "") input.user = user;
|
|
1649
|
+
const passwordRef = answerValue(details, "passwordRef")?.trim();
|
|
1650
|
+
if (passwordRef !== void 0 && passwordRef !== "") input.passwordRef = passwordRef;
|
|
1651
|
+
}
|
|
1652
|
+
return input;
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
if (error.code === "NO_PROVIDER") return void 0;
|
|
1655
|
+
throw error;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
const CONNECT_ARGUMENTS = /* @__PURE__ */ new Set([
|
|
1659
|
+
"type",
|
|
1660
|
+
"host",
|
|
1661
|
+
"port",
|
|
1662
|
+
"user",
|
|
1663
|
+
"database",
|
|
1664
|
+
"passwordRef",
|
|
1665
|
+
"profileId",
|
|
1666
|
+
"name"
|
|
1667
|
+
]);
|
|
1668
|
+
function normalizeArgumentName(value) {
|
|
1669
|
+
return value.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase());
|
|
1670
|
+
}
|
|
1671
|
+
function copyNonEmpty(values, key, apply) {
|
|
1672
|
+
const value = values.get(key);
|
|
1673
|
+
if (value !== void 0 && value.length > 0) apply(value);
|
|
1674
|
+
}
|
|
1675
|
+
function answerValue(answer, id) {
|
|
1676
|
+
const item = answer.answers.find((candidate) => candidate.id === id);
|
|
1677
|
+
return item?.custom ?? item?.selected[0];
|
|
1678
|
+
}
|
|
1679
|
+
/** Minimal shell-like splitter for quoted command arguments; no expansion. */
|
|
1680
|
+
function splitCommandLine(value) {
|
|
1681
|
+
const tokens = [];
|
|
1682
|
+
let token = "";
|
|
1683
|
+
let quote;
|
|
1684
|
+
let escaping = false;
|
|
1685
|
+
for (const character of value) {
|
|
1686
|
+
if (escaping) {
|
|
1687
|
+
token += character;
|
|
1688
|
+
escaping = false;
|
|
1689
|
+
continue;
|
|
1690
|
+
}
|
|
1691
|
+
if (character === "\\" && quote !== "'") {
|
|
1692
|
+
escaping = true;
|
|
1693
|
+
continue;
|
|
1694
|
+
}
|
|
1695
|
+
if (quote !== void 0) {
|
|
1696
|
+
if (character === quote) quote = void 0;
|
|
1697
|
+
else token += character;
|
|
1698
|
+
continue;
|
|
1699
|
+
}
|
|
1700
|
+
if (character === "\"" || character === "'") {
|
|
1701
|
+
quote = character;
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
if (/\s/.test(character)) {
|
|
1705
|
+
if (token.length > 0) {
|
|
1706
|
+
tokens.push(token);
|
|
1707
|
+
token = "";
|
|
1708
|
+
}
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
token += character;
|
|
1712
|
+
}
|
|
1713
|
+
if (escaping) token += "\\";
|
|
1714
|
+
if (quote !== void 0) throw new Error("命令参数包含未闭合的引号");
|
|
1715
|
+
if (token.length > 0) tokens.push(token);
|
|
1716
|
+
return tokens;
|
|
1717
|
+
}
|
|
1718
|
+
//#endregion
|
|
1719
|
+
export { executeDatabaseCommand as a, isDshTuiPluginLoaded as c, parseDatabaseAction as d, apply as i, name as l, DATA_AGENT_TOOL_NAMES as n, formatConnectionStatus as o, DSH_TUI_PLUGIN_RUNTIME_NAME as r, inject as s, DATABASE_COMMAND_USAGE as t, parseConnectArguments as u };
|