dsh-cloudq 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,910 @@
1
+ import { createRequire } from "node:module";
2
+ import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import Schema from "@deepseek-ai/schemastery";
6
+ import { Buffer as Buffer$1 } from "node:buffer";
7
+ import yaml from "js-yaml";
8
+ import { spawn } from "node:child_process";
9
+ //#region src/http.js
10
+ /** Maximum accepted JSON request body size. */
11
+ const MAX_JSON_BODY_BYTES = 65536;
12
+ /** HTTP failure with a stable client-facing code. */
13
+ var HttpError = class extends Error {
14
+ /**
15
+ * @param {number} statusCode HTTP status code.
16
+ * @param {string} code Stable machine-readable code.
17
+ * @param {string} message Safe client-facing message.
18
+ */
19
+ constructor(statusCode, code, message) {
20
+ super(message);
21
+ this.name = "HttpError";
22
+ this.statusCode = statusCode;
23
+ this.code = code;
24
+ }
25
+ };
26
+ /** Return whether a request originated from the local machine. */
27
+ function isLoopbackRequest(request) {
28
+ const address = request.socket?.remoteAddress;
29
+ return address === "::1" || address === "127.0.0.1" || address === "::ffff:127.0.0.1" || typeof address === "string" && /^::ffff:127\./.test(address);
30
+ }
31
+ /** Reject cross-site, non-loopback, or unexpected-method requests. */
32
+ function assertSafeRequest(request, expectedMethod) {
33
+ if (!isLoopbackRequest(request)) throw new HttpError(403, "forbidden", "CloudQ is available only from the local DSH page.");
34
+ if ((request.method ?? "GET").toUpperCase() !== expectedMethod) throw new HttpError(405, "method-not-allowed", "The request method is not supported.");
35
+ const fetchSite = request.headers["sec-fetch-site"];
36
+ if (typeof fetchSite === "string" && fetchSite !== "same-origin" && fetchSite !== "same-site" && fetchSite !== "none") throw new HttpError(403, "forbidden-origin", "Cross-site requests are not allowed.");
37
+ const origin = request.headers.origin;
38
+ if (origin !== void 0) {
39
+ const host = request.headers.host;
40
+ if (host === void 0) throw new HttpError(403, "forbidden-origin", "The request origin is invalid.");
41
+ let originHost;
42
+ try {
43
+ originHost = new URL(origin).host.toLowerCase();
44
+ } catch {
45
+ throw new HttpError(403, "forbidden-origin", "The request origin is invalid.");
46
+ }
47
+ if (originHost !== host.toLowerCase()) throw new HttpError(403, "forbidden-origin", "Cross-site requests are not allowed.");
48
+ }
49
+ }
50
+ /** Send a JSON response with security and caching headers. */
51
+ function sendJson(response, statusCode, body) {
52
+ response.statusCode = statusCode;
53
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
54
+ response.setHeader("Cache-Control", "no-store");
55
+ response.setHeader("X-Content-Type-Options", "nosniff");
56
+ response.end(JSON.stringify(body));
57
+ }
58
+ /** Send only stable, client-safe error details. */
59
+ function sendError(response, error) {
60
+ if (error instanceof HttpError) {
61
+ sendJson(response, error.statusCode, {
62
+ ok: false,
63
+ error: {
64
+ code: error.code,
65
+ message: error.message
66
+ }
67
+ });
68
+ return;
69
+ }
70
+ sendJson(response, 500, {
71
+ ok: false,
72
+ error: {
73
+ code: "internal",
74
+ message: "The CloudQ request failed."
75
+ }
76
+ });
77
+ }
78
+ function contentLength(request) {
79
+ const value = request.headers["content-length"];
80
+ const raw = Array.isArray(value) ? value[0] : value;
81
+ if (raw === void 0) return void 0;
82
+ if (!/^\d+$/.test(raw)) throw new HttpError(400, "invalid-content-length", "The Content-Length header is invalid.");
83
+ const length = Number(raw);
84
+ if (!Number.isSafeInteger(length)) throw new HttpError(400, "invalid-content-length", "The Content-Length header is invalid.");
85
+ return length;
86
+ }
87
+ /** Read and parse a bounded application/json request body. */
88
+ function readJsonBody(request, { maxBytes = MAX_JSON_BODY_BYTES } = {}) {
89
+ const contentType = request.headers["content-type"];
90
+ if (typeof contentType !== "string" || contentType.split(";", 1)[0].trim().toLowerCase() !== "application/json") {
91
+ request.resume?.();
92
+ return Promise.reject(new HttpError(415, "unsupported-media-type", "Content-Type must be application/json."));
93
+ }
94
+ let declaredLength;
95
+ try {
96
+ declaredLength = contentLength(request);
97
+ } catch (error) {
98
+ request.resume?.();
99
+ return Promise.reject(error);
100
+ }
101
+ if (declaredLength !== void 0 && declaredLength > maxBytes) {
102
+ request.resume?.();
103
+ return Promise.reject(new HttpError(413, "payload-too-large", "The JSON request body is too large."));
104
+ }
105
+ return new Promise((resolveBody, rejectBody) => {
106
+ const chunks = [];
107
+ let bytes = 0;
108
+ let settled = false;
109
+ const rejectOnce = (error) => {
110
+ if (settled) return;
111
+ settled = true;
112
+ chunks.length = 0;
113
+ rejectBody(error);
114
+ };
115
+ request.on("data", (chunk) => {
116
+ if (settled) return;
117
+ const buffer = Buffer$1.isBuffer(chunk) ? chunk : Buffer$1.from(chunk);
118
+ bytes += buffer.byteLength;
119
+ if (bytes > maxBytes) {
120
+ rejectOnce(new HttpError(413, "payload-too-large", "The JSON request body is too large."));
121
+ return;
122
+ }
123
+ chunks.push(buffer);
124
+ });
125
+ request.on("end", () => {
126
+ if (settled) return;
127
+ settled = true;
128
+ if (chunks.length === 0) {
129
+ rejectBody(new HttpError(400, "invalid-json", "The request body must contain JSON."));
130
+ return;
131
+ }
132
+ try {
133
+ resolveBody(JSON.parse(Buffer$1.concat(chunks).toString("utf8")));
134
+ } catch {
135
+ rejectBody(new HttpError(400, "invalid-json", "The request body is not valid JSON."));
136
+ }
137
+ });
138
+ request.on("aborted", () => {
139
+ rejectOnce(new HttpError(400, "request-aborted", "The request body was interrupted."));
140
+ });
141
+ request.on("error", () => {
142
+ rejectOnce(new HttpError(400, "request-read-failed", "The request body could not be read."));
143
+ });
144
+ });
145
+ }
146
+ //#endregion
147
+ //#region src/plugin-manager.js
148
+ /** Host-side management of optional profile bundle entries. */
149
+ const PROTECTED_BUNDLES = /* @__PURE__ */ new Set(["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]);
150
+ function profileDirectory(baseUrl) {
151
+ const url = new URL(".", baseUrl);
152
+ if (url.protocol !== "file:") throw new Error("The active DSH profile URL must use the file protocol.");
153
+ return fileURLToPath(url);
154
+ }
155
+ function patchPath(baseUrl) {
156
+ return resolve(profileDirectory(baseUrl), "cordis.patch.yml");
157
+ }
158
+ function parsePatchList(content) {
159
+ if (!content.trim()) return [];
160
+ const parsed = yaml.load(content);
161
+ if (parsed === null || parsed === void 0) return [];
162
+ if (!Array.isArray(parsed)) throw new Error("The DSH profile patch must contain a top-level array.");
163
+ return parsed;
164
+ }
165
+ function leadingComments(content) {
166
+ return (content.match(/^(#[^\n]*\n)+/) ?? [""])[0];
167
+ }
168
+ function serializePatchList(entries, originalContent) {
169
+ return `${leadingComments(originalContent)}${yaml.dump(entries, {
170
+ noRefs: true,
171
+ lineWidth: 120
172
+ })}`;
173
+ }
174
+ function packageDirectory(profileDir, packageName) {
175
+ const anchor = join(profileDir, "package.json");
176
+ for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) {
177
+ const candidate = join(searchPath, packageName);
178
+ if (existsSync(join(candidate, "package.json"))) return candidate;
179
+ }
180
+ }
181
+ function bundleInsertRows(profileDir, packageName) {
182
+ const packageDir = packageDirectory(profileDir, packageName);
183
+ if (packageDir === void 0) return [];
184
+ const declaredPatch = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8"))?.dsh?.bundle?.patch;
185
+ if (typeof declaredPatch !== "string" || !declaredPatch) return [];
186
+ const bundlePatchPath = join(packageDir, declaredPatch);
187
+ if (!existsSync(bundlePatchPath)) return [];
188
+ const rows = [];
189
+ for (const block of parsePatchList(readFileSync(bundlePatchPath, "utf8"))) if (block && typeof block === "object" && Array.isArray(block.insert)) rows.push(...block.insert);
190
+ return rows;
191
+ }
192
+ function readProfileBundles(profileDir) {
193
+ const bundles = JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"))?.dsh?.profile?.bundles;
194
+ return Array.isArray(bundles) ? bundles.filter((bundle) => typeof bundle === "string") : [];
195
+ }
196
+ function fileSnapshot(path) {
197
+ if (!existsSync(path)) return void 0;
198
+ const stat = statSync(path);
199
+ return {
200
+ size: stat.size,
201
+ mtimeMs: stat.mtimeMs,
202
+ mode: stat.mode
203
+ };
204
+ }
205
+ function sameSnapshot(left, right) {
206
+ if (left === void 0 || right === void 0) return left === right;
207
+ return left.size === right.size && left.mtimeMs === right.mtimeMs;
208
+ }
209
+ function writePatchAtomically(path, content, originalSnapshot) {
210
+ const temporary = join(dirname(path), `.${process.pid}.${Date.now()}.dsh-cloudq.tmp`);
211
+ try {
212
+ writeFileSync(temporary, content, {
213
+ encoding: "utf8",
214
+ flag: "wx",
215
+ mode: originalSnapshot?.mode ?? 384
216
+ });
217
+ if (!sameSnapshot(originalSnapshot, fileSnapshot(path))) throw new Error("The DSH profile patch changed while it was being updated. Retry the operation.");
218
+ renameSync(temporary, path);
219
+ } finally {
220
+ rmSync(temporary, { force: true });
221
+ }
222
+ }
223
+ /**
224
+ * List third-party bundle entries mounted by the active profile.
225
+ * @param {string | URL} baseUrl Loader base URL from `ctx.baseUrl`.
226
+ * @returns {Array<{id: string, name: string, bundle: string, disabled: boolean, self: boolean}>}
227
+ */
228
+ function listPlugins(baseUrl) {
229
+ const profileDir = profileDirectory(baseUrl);
230
+ const userPatchPath = patchPath(baseUrl);
231
+ const overrides = (existsSync(userPatchPath) ? parsePatchList(readFileSync(userPatchPath, "utf8")) : []).filter((entry) => entry && typeof entry === "object" && typeof entry.id === "string");
232
+ const plugins = [];
233
+ for (const bundle of readProfileBundles(profileDir)) {
234
+ if (PROTECTED_BUNDLES.has(bundle)) continue;
235
+ for (const row of bundleInsertRows(profileDir, bundle)) {
236
+ if (!row || typeof row !== "object" || typeof row.id !== "string") continue;
237
+ const override = overrides.find((entry) => entry.id === row.id);
238
+ plugins.push({
239
+ id: row.id,
240
+ name: typeof row.name === "string" ? row.name : row.id,
241
+ bundle,
242
+ disabled: override?.disabled === true,
243
+ self: bundle === "dsh-cloudq"
244
+ });
245
+ }
246
+ }
247
+ return plugins;
248
+ }
249
+ /**
250
+ * Atomically update one mounted plugin's disabled override.
251
+ * @param {string | URL} baseUrl Loader base URL from `ctx.baseUrl`.
252
+ * @param {string} id Mounted plugin entry id.
253
+ * @param {boolean} disabled Desired disabled state.
254
+ * @returns {{ok: true, id: string, disabled: boolean}}
255
+ */
256
+ function setPluginDisabled(baseUrl, id, disabled) {
257
+ const userPatchPath = patchPath(baseUrl);
258
+ if (!listPlugins(baseUrl).some((plugin) => plugin.id === id)) {
259
+ const error = /* @__PURE__ */ new Error(`Unknown plugin entry: ${id}`);
260
+ error.code = "unknown-plugin";
261
+ throw error;
262
+ }
263
+ const originalSnapshot = fileSnapshot(userPatchPath);
264
+ const original = originalSnapshot === void 0 ? "" : readFileSync(userPatchPath, "utf8");
265
+ let patches = parsePatchList(original);
266
+ const target = patches.find((entry) => entry && typeof entry === "object" && entry.id === id);
267
+ if (target) {
268
+ if (disabled) target.disabled = true;
269
+ else {
270
+ delete target.disabled;
271
+ if (Object.keys(target).length === 1) patches = patches.filter((entry) => entry !== target);
272
+ }
273
+ } else if (disabled) patches.push({
274
+ id,
275
+ disabled: true
276
+ });
277
+ else return {
278
+ ok: true,
279
+ id,
280
+ disabled: false
281
+ };
282
+ writePatchAtomically(userPatchPath, serializePatchList(patches, original), originalSnapshot);
283
+ return {
284
+ ok: true,
285
+ id,
286
+ disabled
287
+ };
288
+ }
289
+ //#endregion
290
+ //#region src/script-runner.js
291
+ const MAX_SCRIPT_OUTPUT_BYTES = 1048576;
292
+ function safeCode(value) {
293
+ return typeof value === "string" && /^[a-zA-Z0-9._-]{1,80}$/.test(value) ? value : "script-failed";
294
+ }
295
+ function redact(value, sensitiveValues) {
296
+ let text = String(value ?? "");
297
+ for (const sensitive of sensitiveValues) if (sensitive) text = text.replaceAll(sensitive, "[redacted]");
298
+ return text.replace(/[\r\n\t]+/g, " ").slice(0, 300);
299
+ }
300
+ /**
301
+ * Run one bundled Python helper with bounded output and optional stdin.
302
+ * @param {string} scriptsDirectory Absolute scripts directory.
303
+ * @param {string} scriptName Bundled script filename.
304
+ * @param {string[]} args Non-sensitive command-line arguments.
305
+ * @param {object} options Execution options.
306
+ * @returns {Promise<Record<string, unknown>>} Parsed helper response.
307
+ */
308
+ function runScript$1(scriptsDirectory, scriptName, args, { timeoutMs = 3e4, jsonOnly = true, stdin, sensitiveValues = [], spawnProcess = spawn } = {}) {
309
+ return new Promise((resolveRun, rejectRun) => {
310
+ const child = spawnProcess("python3", [resolve(scriptsDirectory, scriptName), ...args], {
311
+ stdio: [
312
+ stdin === void 0 ? "ignore" : "pipe",
313
+ "pipe",
314
+ "pipe"
315
+ ],
316
+ timeout: timeoutMs
317
+ });
318
+ let stdout = "";
319
+ let stderr = "";
320
+ let outputBytes = 0;
321
+ let settled = false;
322
+ const rejectOnce = (error) => {
323
+ if (settled) return;
324
+ settled = true;
325
+ rejectRun(error);
326
+ };
327
+ const resolveOnce = (value) => {
328
+ if (settled) return;
329
+ settled = true;
330
+ resolveRun(value);
331
+ };
332
+ const collect = (current, chunk) => {
333
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
334
+ outputBytes += buffer.byteLength;
335
+ if (outputBytes > MAX_SCRIPT_OUTPUT_BYTES) {
336
+ child.kill();
337
+ rejectOnce(new HttpError(502, "script-output-too-large", "The CloudQ helper returned too much data."));
338
+ return current;
339
+ }
340
+ return current + buffer.toString("utf8");
341
+ };
342
+ child.stdout.on("data", (chunk) => {
343
+ stdout = collect(stdout, chunk);
344
+ });
345
+ child.stderr.on("data", (chunk) => {
346
+ stderr = collect(stderr, chunk);
347
+ });
348
+ child.on("error", () => {
349
+ rejectOnce(new HttpError(502, "script-launch-failed", "The CloudQ helper could not be started."));
350
+ });
351
+ child.on("close", (code) => {
352
+ if (settled) return;
353
+ const trimmed = stdout.trim();
354
+ if (!jsonOnly) {
355
+ if (code !== 0) rejectOnce(new HttpError(502, "script-failed", "The CloudQ helper failed."));
356
+ else resolveOnce({
357
+ success: true,
358
+ message: redact(trimmed, sensitiveValues)
359
+ });
360
+ return;
361
+ }
362
+ let parsed;
363
+ try {
364
+ parsed = trimmed ? JSON.parse(trimmed) : {};
365
+ } catch {
366
+ rejectOnce(new HttpError(502, "script-invalid-output", "The CloudQ helper returned an invalid response."));
367
+ return;
368
+ }
369
+ if (code !== 0 || parsed?.ok === false || parsed?.success === false) {
370
+ const codeValue = safeCode(parsed?.error?.code ?? parsed?.code);
371
+ rejectOnce(new HttpError(502, codeValue, "The CloudQ helper failed."));
372
+ return;
373
+ }
374
+ resolveOnce(parsed);
375
+ });
376
+ if (stdin !== void 0) {
377
+ child.stdin.on("error", () => {
378
+ rejectOnce(new HttpError(502, "script-input-failed", "The CloudQ helper could not receive its input."));
379
+ });
380
+ child.stdin.end(stdin);
381
+ }
382
+ });
383
+ }
384
+ //#endregion
385
+ //#region src/index.js
386
+ /**
387
+ * dsh-cloudq host half.
388
+ *
389
+ * Registers the CloudQ skill (a multi-cloud AIOps expert driven by the Tencent
390
+ * Cloud SSE conversation API) so that entering "CloudQ mode" routes the model
391
+ * through the bundled skill. The skill body is shipped under `skills/cloudq/`
392
+ * and referenced through a directory `resourceBase`, so every `{baseDir}`
393
+ * placeholder in the body resolves against the installed package directory.
394
+ *
395
+ * It also exposes a small credential-management API (status / authorize-url /
396
+ * save-code / logout) that the client half renders in the settings panel, so
397
+ * the CloudQ login flow no longer requires touching a terminal.
398
+ *
399
+ * @module dsh-cloudq
400
+ */
401
+ const LOGO_PATH = resolve(dirname(fileURLToPath(import.meta.url)), "../assets/cloudq.png");
402
+ const LOGO_BUFFER = readFileSync(LOGO_PATH);
403
+ const __dirname = dirname(fileURLToPath(import.meta.url));
404
+ /** Installed skill directory (SKILL.md + scripts/ + references/). */
405
+ function skillDirectory() {
406
+ return resolve(__dirname, "../skills/cloudq");
407
+ }
408
+ /** Raw SKILL.md body. */
409
+ function rawSkillContent() {
410
+ return readFileSync(resolve(skillDirectory(), "SKILL.md"), "utf8");
411
+ }
412
+ /**
413
+ * Render the skill body with `{baseDir}` substituted by the installed skill
414
+ * directory. The model then runs `python3 <absolute path>/scripts/...`
415
+ * directly, without having to infer the base directory from the rendered
416
+ * `<skill_resources>` block.
417
+ */
418
+ function renderedSkillContent() {
419
+ const baseDir = skillDirectory();
420
+ return rawSkillContent().replace(/\{baseDir\}/g, baseDir);
421
+ }
422
+ /** Run one helper from the bundled CloudQ skill. */
423
+ function runScript(scriptName, args, options) {
424
+ return runScript$1(resolve(skillDirectory(), "scripts"), scriptName, args, options);
425
+ }
426
+ /** Remove host filesystem details before returning credential state to the browser. */
427
+ function publicCredentialStatus(status) {
428
+ if (!status || typeof status !== "object" || Array.isArray(status)) return status;
429
+ const safeStatus = { ...status };
430
+ delete safeStatus.credential_file;
431
+ return safeStatus;
432
+ }
433
+ /** Wrap runScript so failures keep a stable envelope for the client. */
434
+ async function credentialStatus() {
435
+ return publicCredentialStatus(await runScript("login.py", ["--status"]));
436
+ }
437
+ function authorizeUrl() {
438
+ return runScript("login.py", ["--authorize-url"]);
439
+ }
440
+ async function saveAuthCode(code) {
441
+ return publicCredentialStatus(await runScript("login.py", ["--save", "--stdin"], {
442
+ stdin: JSON.stringify({ code }),
443
+ sensitiveValues: [code]
444
+ }));
445
+ }
446
+ function logout() {
447
+ return runScript("logout.py", [], { jsonOnly: false });
448
+ }
449
+ /**
450
+ * Validate a long-lived Tencent Cloud AK/SK pair without persisting it.
451
+ * The script performs one read-only CloudQ call to prove the key works.
452
+ */
453
+ function testAccessKey(secretId, secretKey) {
454
+ return runScript("save_ak.py", ["--test", "--stdin"], {
455
+ stdin: JSON.stringify({
456
+ secretId,
457
+ secretKey
458
+ }),
459
+ sensitiveValues: [secretId, secretKey]
460
+ });
461
+ }
462
+ /** Validate then persist a long-lived AK/SK pair as `type:"ak"`. */
463
+ async function saveAccessKey(secretId, secretKey) {
464
+ return publicCredentialStatus(await runScript("save_ak.py", ["--save", "--stdin"], {
465
+ stdin: JSON.stringify({
466
+ secretId,
467
+ secretKey
468
+ }),
469
+ sensitiveValues: [secretId, secretKey]
470
+ }));
471
+ }
472
+ function cloudqUsageOverview() {
473
+ return runScript("tcloud_api.py", [
474
+ "advisor",
475
+ "advisor.tencentcloudapi.com",
476
+ "DescribeCloudQUsageOverview",
477
+ "2020-07-21",
478
+ "{}"
479
+ ]);
480
+ }
481
+ function cloudqUsageDetail({ startTime, endTime, limit = 20, offset = 0 }) {
482
+ return runScript("tcloud_api.py", [
483
+ "advisor",
484
+ "advisor.tencentcloudapi.com",
485
+ "DescribeCloudQUsageDetail",
486
+ "2020-07-21",
487
+ JSON.stringify({
488
+ StartTime: startTime,
489
+ EndTime: endTime,
490
+ Limit: limit,
491
+ Offset: offset
492
+ })
493
+ ]);
494
+ }
495
+ function cloudqInspirationList() {
496
+ return runScript("tcloud_api.py", [
497
+ "advisor",
498
+ "advisor.tencentcloudapi.com",
499
+ "DescribeCloudQInspirationList",
500
+ "2020-07-21",
501
+ "{\"Category\":0}"
502
+ ]);
503
+ }
504
+ /** Fetch all CloudQ artifact sessions and their archived files. */
505
+ function cloudqArtifactLibrary() {
506
+ return runScript("tcloud_api.py", [
507
+ "advisor",
508
+ "advisor.tencentcloudapi.com",
509
+ "DescribeCloudQArtifactLibrary",
510
+ "2020-07-21",
511
+ "{}"
512
+ ]);
513
+ }
514
+ /** Fetch the architecture directory tree available to the current account. */
515
+ function cloudqArchitectureDirectories() {
516
+ return runScript("tcloud_api.py", [
517
+ "advisor",
518
+ "advisor.tencentcloudapi.com",
519
+ "ListDirectoryV2",
520
+ "2020-07-21",
521
+ JSON.stringify({
522
+ Tags: [],
523
+ TagKeys: []
524
+ })
525
+ ]);
526
+ }
527
+ /** Fetch one page of diagrams in a CloudQ architecture directory. */
528
+ function cloudqArchitectureList({ folderId, pageNumber = 1, pageSize = 30 }) {
529
+ return runScript("tcloud_api.py", [
530
+ "advisor",
531
+ "advisor.tencentcloudapi.com",
532
+ "DescribeArchList",
533
+ "2020-07-21",
534
+ JSON.stringify({
535
+ PageNumber: pageNumber,
536
+ PageSize: pageSize,
537
+ SearchKey: "",
538
+ FolderId: folderId,
539
+ WithSvgURL: true,
540
+ Tags: [],
541
+ TagKeys: []
542
+ })
543
+ ]);
544
+ }
545
+ /**
546
+ * Extract and validate an AK/SK pair from a request body.
547
+ * Both routes (test / save) share the same contract, so the shape check and
548
+ * the error messages live in one place.
549
+ */
550
+ function readAccessKeyBody(body) {
551
+ const secretId = typeof body?.secretId === "string" ? body.secretId.trim() : "";
552
+ const secretKey = typeof body?.secretKey === "string" ? body.secretKey.trim() : "";
553
+ if (!secretId || !secretKey) throw new HttpError(400, "missing-credential", "SecretId 与 SecretKey 均为必填。");
554
+ return {
555
+ secretId,
556
+ secretKey
557
+ };
558
+ }
559
+ /** Join the plain-text blocks carried by one DSH message. */
560
+ function messageText(content) {
561
+ if (!Array.isArray(content)) return "";
562
+ return content.filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text).join("\n");
563
+ }
564
+ /** A durable log proves CloudQ mode through an explicit claim or skill call. */
565
+ function hasCloudqEvidence(events) {
566
+ if (!Array.isArray(events)) return false;
567
+ for (const event of events) {
568
+ if (event?.type === "user/message" && event?.data?.source?.kind === "user") {
569
+ if (/^\s*\/cloudq(?:\s|$)/i.test(messageText(event?.data?.content))) return true;
570
+ }
571
+ if (event?.type !== "tool/call" || event?.data?.name !== "skill") continue;
572
+ let args = event?.data?.arguments;
573
+ if (typeof args === "string") try {
574
+ args = JSON.parse(args);
575
+ } catch {
576
+ args = void 0;
577
+ }
578
+ if ((typeof args?.name === "string" ? args.name : typeof args?.skill === "string" ? args.skill : "").toLowerCase() === "cloudq") return true;
579
+ }
580
+ return false;
581
+ }
582
+ /**
583
+ * Derive CloudQ session ids from their durable DSH logs. This is the source of
584
+ * truth for historical rows; browser storage is only an optimistic cache for
585
+ * a brand-new blank session that has no log yet.
586
+ */
587
+ async function detectCloudqSessionIds(ctx) {
588
+ const query = ctx.get("sessionQuery");
589
+ if (query === void 0) throw new HttpError(503, "session-query-unavailable", "当前 DSH 未启用会话查询服务。");
590
+ const records = await query.listSessions();
591
+ const ids = [];
592
+ const queue = [...records];
593
+ const worker = async () => {
594
+ while (queue.length > 0) {
595
+ const sessionId = queue.shift()?.header?.id;
596
+ if (!sessionId) continue;
597
+ try {
598
+ if (hasCloudqEvidence((await query.readSession(sessionId))?.events)) ids.push(sessionId);
599
+ } catch {}
600
+ }
601
+ };
602
+ await Promise.all(Array.from({ length: Math.min(6, queue.length) }, () => worker()));
603
+ return ids;
604
+ }
605
+ /** Settings namespace for the browser card (must equal client card `key`). */
606
+ const SETTINGS_NAMESPACE = "dsh-cloudq";
607
+ /** Empty schema; the card is credential-focused and stores nothing. */
608
+ const Config = Schema.object({});
609
+ /** Settings namespace for the plugin-manager card. */
610
+ const PLUGIN_MANAGER_NAMESPACE = "dsh-plugin-manager";
611
+ const name = "dsh-cloudq";
612
+ const inject = [
613
+ "skills",
614
+ "webServer",
615
+ "settings",
616
+ "sessionQuery"
617
+ ];
618
+ function apply(ctx) {
619
+ ctx.effect(() => ctx.settings.register(SETTINGS_NAMESPACE, Config, { base: {} }), "dsh-cloudq: settings namespace");
620
+ ctx.effect(() => ctx.settings.register(PLUGIN_MANAGER_NAMESPACE, Config, { base: {} }), "dsh-cloudq: plugin-manager settings namespace");
621
+ ctx.effect(() => ctx.skills.register({
622
+ name: "cloudq",
623
+ description: "用户咨询腾讯云产品资源、AWS、阿里云等多云资源时,查看智能顾问架构图、架构目录、架构详情、架构评估结果、绘制架构图、开通智能顾问时、AI智能巡检、AI容量监测、AI混沌演练、AI云诊断、主动预警、架构健康度、云运维问答、云资源查询、云成本优化、安全合规、云资源盘点、闲置资源检查、云产品最佳实践等AIOps、ChatOps、CloudOps操作时使用。",
624
+ whenToUse: "用户要求进入 CloudQ 模式、使用 CloudQ、咨询云上架构/资源/成本/巡检/诊断等多云或腾讯云运维问题,或点击输入栏 CloudQ 按钮时使用;也适用于架构图查看/评估、AI 巡检、容量监测、混沌演练、云诊断等场景。",
625
+ source: "bundled",
626
+ resourceBase: {
627
+ kind: "directory",
628
+ path: skillDirectory()
629
+ },
630
+ content: renderedSkillContent(),
631
+ invocation: {
632
+ modelInvocable: true,
633
+ userInvocable: true
634
+ }
635
+ }), "dsh-cloudq: CloudQ skill");
636
+ ctx.effect(() => {
637
+ const disposers = [];
638
+ disposers.push(ctx.webServer.register({
639
+ kind: "exact",
640
+ path: "/api/dsh-cloudq/credential",
641
+ handler: async (request, response) => {
642
+ try {
643
+ assertSafeRequest(request, "GET");
644
+ sendJson(response, 200, {
645
+ ok: true,
646
+ status: await credentialStatus()
647
+ });
648
+ } catch (error) {
649
+ sendError(response, error);
650
+ }
651
+ }
652
+ }));
653
+ disposers.push(ctx.webServer.register({
654
+ kind: "exact",
655
+ path: "/api/dsh-cloudq/login/url",
656
+ handler: async (request, response) => {
657
+ try {
658
+ assertSafeRequest(request, "POST");
659
+ const result = await authorizeUrl();
660
+ sendJson(response, 200, {
661
+ ok: true,
662
+ authorize_url: result.authorize_url,
663
+ state: result.state
664
+ });
665
+ } catch (error) {
666
+ sendError(response, error);
667
+ }
668
+ }
669
+ }));
670
+ disposers.push(ctx.webServer.register({
671
+ kind: "exact",
672
+ path: "/api/dsh-cloudq/login/save",
673
+ handler: async (request, response) => {
674
+ try {
675
+ assertSafeRequest(request, "POST");
676
+ const body = await readJsonBody(request);
677
+ const code = typeof body?.code === "string" ? body.code.trim() : "";
678
+ if (!code) throw new HttpError(400, "missing-code", "缺少授权码。");
679
+ sendJson(response, 200, {
680
+ ok: true,
681
+ status: await saveAuthCode(code)
682
+ });
683
+ } catch (error) {
684
+ sendError(response, error);
685
+ }
686
+ }
687
+ }));
688
+ disposers.push(ctx.webServer.register({
689
+ kind: "exact",
690
+ path: "/api/dsh-cloudq/logout",
691
+ handler: async (request, response) => {
692
+ try {
693
+ assertSafeRequest(request, "POST");
694
+ sendJson(response, 200, {
695
+ ok: true,
696
+ status: await logout()
697
+ });
698
+ } catch (error) {
699
+ sendError(response, error);
700
+ }
701
+ }
702
+ }));
703
+ disposers.push(ctx.webServer.register({
704
+ kind: "exact",
705
+ path: "/api/dsh-cloudq/credential/test",
706
+ handler: async (request, response) => {
707
+ try {
708
+ assertSafeRequest(request, "POST");
709
+ const { secretId, secretKey } = readAccessKeyBody(await readJsonBody(request));
710
+ sendJson(response, 200, {
711
+ ok: true,
712
+ result: await testAccessKey(secretId, secretKey)
713
+ });
714
+ } catch (error) {
715
+ sendError(response, error);
716
+ }
717
+ }
718
+ }));
719
+ disposers.push(ctx.webServer.register({
720
+ kind: "exact",
721
+ path: "/api/dsh-cloudq/credential/save",
722
+ handler: async (request, response) => {
723
+ try {
724
+ assertSafeRequest(request, "POST");
725
+ const { secretId, secretKey } = readAccessKeyBody(await readJsonBody(request));
726
+ sendJson(response, 200, {
727
+ ok: true,
728
+ status: await saveAccessKey(secretId, secretKey)
729
+ });
730
+ } catch (error) {
731
+ sendError(response, error);
732
+ }
733
+ }
734
+ }));
735
+ disposers.push(ctx.webServer.register({
736
+ kind: "exact",
737
+ path: "/api/dsh-cloudq/logo.png",
738
+ handler: (request, response) => {
739
+ try {
740
+ assertSafeRequest(request, "GET");
741
+ response.statusCode = 200;
742
+ response.setHeader("Content-Type", "image/png");
743
+ response.setHeader("Cache-Control", "public, max-age=86400");
744
+ response.setHeader("X-Content-Type-Options", "nosniff");
745
+ response.end(LOGO_BUFFER);
746
+ } catch (error) {
747
+ sendError(response, error);
748
+ }
749
+ }
750
+ }));
751
+ disposers.push(ctx.webServer.register({
752
+ kind: "exact",
753
+ path: "/api/dsh-cloudq/plugins",
754
+ handler: (request, response) => {
755
+ try {
756
+ assertSafeRequest(request, "GET");
757
+ sendJson(response, 200, {
758
+ ok: true,
759
+ plugins: listPlugins(ctx.baseUrl)
760
+ });
761
+ } catch (error) {
762
+ sendError(response, error);
763
+ }
764
+ }
765
+ }));
766
+ disposers.push(ctx.webServer.register({
767
+ kind: "exact",
768
+ path: "/api/dsh-cloudq/plugins/toggle",
769
+ handler: async (request, response) => {
770
+ try {
771
+ assertSafeRequest(request, "POST");
772
+ const body = await readJsonBody(request);
773
+ const id = typeof body?.id === "string" ? body.id : "";
774
+ if (!id) throw new HttpError(400, "missing-id", "缺少插件 ID。");
775
+ const disabled = body?.disabled === true;
776
+ sendJson(response, 200, {
777
+ ok: true,
778
+ result: setPluginDisabled(ctx.baseUrl, id, disabled)
779
+ });
780
+ } catch (error) {
781
+ sendError(response, error);
782
+ }
783
+ }
784
+ }));
785
+ disposers.push(ctx.webServer.register({
786
+ kind: "exact",
787
+ path: "/api/dsh-cloudq/usage",
788
+ handler: async (request, response) => {
789
+ try {
790
+ assertSafeRequest(request, "GET");
791
+ const url = new URL(request.url, "http://localhost");
792
+ const startTime = url.searchParams.get("start") ?? "";
793
+ const endTime = url.searchParams.get("end") ?? "";
794
+ const limit = Number(url.searchParams.get("limit") ?? 20);
795
+ const offset = Number(url.searchParams.get("offset") ?? 0);
796
+ const timestampPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
797
+ if (!timestampPattern.test(startTime) || !timestampPattern.test(endTime)) throw new HttpError(400, "invalid-range", "The start and end timestamps are invalid.");
798
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100 || !Number.isSafeInteger(offset) || offset < 0) throw new HttpError(400, "invalid-pagination", "The pagination parameters are invalid.");
799
+ const [overview, detail] = await Promise.all([cloudqUsageOverview(), cloudqUsageDetail({
800
+ startTime,
801
+ endTime,
802
+ limit,
803
+ offset
804
+ })]);
805
+ sendJson(response, 200, {
806
+ ok: true,
807
+ overview: overview?.data ?? {},
808
+ detail: detail?.data?.Detail ?? [],
809
+ total: detail?.data?.TotalCount ?? detail?.data?.Detail?.length ?? 0
810
+ });
811
+ } catch (error) {
812
+ sendError(response, error);
813
+ }
814
+ }
815
+ }));
816
+ disposers.push(ctx.webServer.register({
817
+ kind: "exact",
818
+ path: "/api/dsh-cloudq/inspirations",
819
+ handler: async (request, response) => {
820
+ try {
821
+ assertSafeRequest(request, "GET");
822
+ const result = await cloudqInspirationList();
823
+ sendJson(response, 200, {
824
+ ok: true,
825
+ inspirations: result?.data?.InspirationSet ?? [],
826
+ total: result?.data?.TotalCount ?? 0
827
+ });
828
+ } catch (error) {
829
+ sendError(response, error);
830
+ }
831
+ }
832
+ }));
833
+ disposers.push(ctx.webServer.register({
834
+ kind: "exact",
835
+ path: "/api/dsh-cloudq/artifacts",
836
+ handler: async (request, response) => {
837
+ try {
838
+ assertSafeRequest(request, "GET");
839
+ const result = await cloudqArtifactLibrary();
840
+ const sessions = Array.isArray(result?.data?.Sessions) ? result.data.Sessions : [];
841
+ sendJson(response, 200, {
842
+ ok: true,
843
+ sessions,
844
+ total: Number(result?.data?.TotalCount) || sessions.length
845
+ });
846
+ } catch (error) {
847
+ sendError(response, error);
848
+ }
849
+ }
850
+ }));
851
+ disposers.push(ctx.webServer.register({
852
+ kind: "exact",
853
+ path: "/api/dsh-cloudq/sessions",
854
+ handler: async (request, response) => {
855
+ try {
856
+ assertSafeRequest(request, "GET");
857
+ sendJson(response, 200, {
858
+ ok: true,
859
+ sessionIds: await detectCloudqSessionIds(ctx)
860
+ });
861
+ } catch (error) {
862
+ sendError(response, error);
863
+ }
864
+ }
865
+ }));
866
+ disposers.push(ctx.webServer.register({
867
+ kind: "exact",
868
+ path: "/api/dsh-cloudq/architecture/directories",
869
+ handler: async (request, response) => {
870
+ try {
871
+ assertSafeRequest(request, "GET");
872
+ const result = await cloudqArchitectureDirectories();
873
+ sendJson(response, 200, {
874
+ ok: true,
875
+ folders: Array.isArray(result?.data?.Folders) ? result.data.Folders : [],
876
+ firstFolderId: Number(result?.data?.FirstFolderId) || null
877
+ });
878
+ } catch (error) {
879
+ sendError(response, error);
880
+ }
881
+ }
882
+ }));
883
+ disposers.push(ctx.webServer.register({
884
+ kind: "exact",
885
+ path: "/api/dsh-cloudq/architecture/list",
886
+ handler: async (request, response) => {
887
+ try {
888
+ assertSafeRequest(request, "GET");
889
+ const url = new URL(request.url, "http://localhost");
890
+ const folderId = Number(url.searchParams.get("folderId"));
891
+ if (!Number.isSafeInteger(folderId) || folderId <= 0) throw new HttpError(400, "invalid-folder-id", "folderId 必须是正整数。");
892
+ const result = await cloudqArchitectureList({ folderId });
893
+ const architectures = Array.isArray(result?.data?.ArchList) ? result.data.ArchList : [];
894
+ sendJson(response, 200, {
895
+ ok: true,
896
+ architectures,
897
+ total: Number(result?.data?.TotalCount) || architectures.length
898
+ });
899
+ } catch (error) {
900
+ sendError(response, error);
901
+ }
902
+ }
903
+ }));
904
+ return () => {
905
+ for (const dispose of disposers) dispose();
906
+ };
907
+ }, "dsh-cloudq: credential, plugin-manager and data routes");
908
+ }
909
+ //#endregion
910
+ export { Config, apply, inject, name };