@yejiming/dsh-data-agent 0.0.10 → 0.0.12
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 +19 -16
- package/README.md +19 -16
- package/lib/client.js +137 -93
- package/lib/client.js.map +1 -1
- package/lib/{command-LFgLb6el.js → command-DuCpwVbl.js} +2 -2
- package/lib/command.js +1 -1
- package/lib/{connections-WmjuUrDj.js → connections-5sfdEDsG.js} +44 -23
- package/lib/index.js +18 -14
- package/lib/routes.js +6 -2
- package/lib/{tool-Dka6RyEp.js → tool-DgL0fBfj.js} +157 -11
- package/lib/tool.js +1 -1
- package/lib/types/analysis-html.d.ts +27 -0
- package/lib/types/analysis.d.ts +13 -1
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/client/persistence.d.ts +1 -1
- package/lib/types/connections.d.ts +10 -0
- package/lib/types/index.d.ts +5 -6
- package/lib/types/presentation-text.d.ts +3 -0
- package/lib/types/storage.d.ts +5 -0
- package/package.json +40 -40
- package/preset/data-agent/agent.cordis.yml +5 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { r as redactSecretText } from "./connections-
|
|
1
|
+
import { r as redactSecretText } from "./connections-5sfdEDsG.js";
|
|
2
2
|
import { RUN_CODE_NAME } from "@deepseek-ai/dsh-tools";
|
|
3
3
|
//#region src/tui-connection-form.ts
|
|
4
4
|
const TUI_DATABASE_TYPES = [
|
|
@@ -691,7 +691,7 @@ function formatConnectionStatus(summary) {
|
|
|
691
691
|
if (summary === void 0) return "数据库状态:未连接。";
|
|
692
692
|
const endpoint = summary.type === "sqlite" ? summary.database : `${summary.host ?? "localhost"}${summary.port !== void 0 ? `:${summary.port}` : ""}`;
|
|
693
693
|
const lines = [
|
|
694
|
-
"数据库状态:已连接",
|
|
694
|
+
summary.reconnectRequired === true ? "数据库状态:需要重新认证" : "数据库状态:已连接",
|
|
695
695
|
`类型:${summary.type}`,
|
|
696
696
|
`地址:${endpoint}`,
|
|
697
697
|
`数据库:${summary.database}`,
|
package/lib/command.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as formatConnectionStatus, c as parseConnectArguments, i as executeDatabaseCommand, l as parseDatabaseAction, n as DATA_AGENT_TOOL_NAMES, o as inject, r as apply, s as name, t as DATABASE_COMMAND_USAGE } from "./command-
|
|
1
|
+
import { a as formatConnectionStatus, c as parseConnectArguments, i as executeDatabaseCommand, l as parseDatabaseAction, n as DATA_AGENT_TOOL_NAMES, o as inject, r as apply, s as name, t as DATABASE_COMMAND_USAGE } from "./command-DuCpwVbl.js";
|
|
2
2
|
export { DATABASE_COMMAND_USAGE, DATA_AGENT_TOOL_NAMES, apply, executeDatabaseCommand, formatConnectionStatus, inject, name, parseConnectArguments, parseDatabaseAction };
|
|
@@ -1305,7 +1305,8 @@ function normalizeConnectionInput(input, cwd = process.cwd()) {
|
|
|
1305
1305
|
if (input.name !== void 0 && input.name.trim().length === 0) throw new Error("name 不能为空");
|
|
1306
1306
|
const connection = {
|
|
1307
1307
|
type: input.type,
|
|
1308
|
-
database: input.type === "sqlite" ? resolve(cwd, input.database) : input.database
|
|
1308
|
+
database: input.type === "sqlite" ? resolve(cwd, input.database) : input.database,
|
|
1309
|
+
credentialMode: input.type === "sqlite" ? "none" : input.passwordRef !== void 0 ? "reference" : input.password !== void 0 && input.password.length > 0 ? "password" : "none"
|
|
1309
1310
|
};
|
|
1310
1311
|
if (input.type !== "sqlite") {
|
|
1311
1312
|
if (input.host !== void 0 && input.host.length > 0) connection.host = input.host;
|
|
@@ -1346,16 +1347,21 @@ function createConnectionService(ctx, options, persistence) {
|
|
|
1346
1347
|
return ctx;
|
|
1347
1348
|
};
|
|
1348
1349
|
const resolveCredential = async (connection) => {
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1350
|
+
const mode = credentialModeOf(connection);
|
|
1351
|
+
if (mode === "reference") {
|
|
1352
|
+
if (connection.passwordRef === void 0) throw new Error("数据库凭据引用缺失,请重新配置连接");
|
|
1353
|
+
const ref = validatedCredentialRef(connection.passwordRef);
|
|
1354
|
+
const hit = await requireContext().credentials.resolve(ref);
|
|
1355
|
+
if (hit === void 0 || hit.value.length === 0) throw new Error(`凭据引用 "${connection.passwordRef}" 未配置`);
|
|
1356
|
+
return {
|
|
1357
|
+
...connection,
|
|
1358
|
+
password: hit.value,
|
|
1359
|
+
tables: copyTables(connection.tables)
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
if (mode === "password" && connection.password === void 0) throw new Error("数据库凭据需要重新输入;请打开数据库配置并重新连接");
|
|
1356
1363
|
return {
|
|
1357
1364
|
...connection,
|
|
1358
|
-
password: hit.value,
|
|
1359
1365
|
tables: copyTables(connection.tables)
|
|
1360
1366
|
};
|
|
1361
1367
|
};
|
|
@@ -1400,18 +1406,29 @@ function createConnectionService(ctx, options, persistence) {
|
|
|
1400
1406
|
}
|
|
1401
1407
|
};
|
|
1402
1408
|
const credentialSummary = async (connection) => {
|
|
1403
|
-
|
|
1404
|
-
if (connection.
|
|
1409
|
+
const mode = credentialModeOf(connection);
|
|
1410
|
+
if (connection.type === "sqlite" || mode === "none") return void 0;
|
|
1411
|
+
if (mode === "password") return connection.password === void 0 ? { configured: false } : {
|
|
1405
1412
|
configured: true,
|
|
1406
1413
|
source: "memory"
|
|
1407
1414
|
};
|
|
1408
|
-
if (connection.passwordRef === void 0) return { configured: false };
|
|
1415
|
+
if (mode !== "reference" || connection.passwordRef === void 0) return { configured: false };
|
|
1409
1416
|
const info = await requireContext().credentials.describe(validatedCredentialRef(connection.passwordRef));
|
|
1410
1417
|
return {
|
|
1411
1418
|
configured: info.configured,
|
|
1412
1419
|
...info.source !== void 0 ? { source: info.source } : {}
|
|
1413
1420
|
};
|
|
1414
1421
|
};
|
|
1422
|
+
const statusSummary = async (connection) => {
|
|
1423
|
+
const summary = summarize(connection);
|
|
1424
|
+
const mode = credentialModeOf(connection);
|
|
1425
|
+
summary.credentialMode = mode;
|
|
1426
|
+
summary.credential = await credentialSummary(connection);
|
|
1427
|
+
const ready = mode === "none" || summary.credential?.configured === true;
|
|
1428
|
+
summary.ready = ready;
|
|
1429
|
+
summary.reconnectRequired = !ready;
|
|
1430
|
+
return summary;
|
|
1431
|
+
};
|
|
1415
1432
|
const service = {
|
|
1416
1433
|
set(sessionId, connection) {
|
|
1417
1434
|
if (connection.password !== void 0 && connection.passwordRef !== void 0) throw new Error("password 与 passwordRef 不能同时提供");
|
|
@@ -1454,9 +1471,7 @@ function createConnectionService(ctx, options, persistence) {
|
|
|
1454
1471
|
async status(sessionId) {
|
|
1455
1472
|
const connection = rawConnection(sessionId);
|
|
1456
1473
|
if (connection === void 0) return void 0;
|
|
1457
|
-
|
|
1458
|
-
summary.credential = await credentialSummary(connection);
|
|
1459
|
-
return summary;
|
|
1474
|
+
return statusSummary(connection);
|
|
1460
1475
|
},
|
|
1461
1476
|
async connect(sessionId, input, signal) {
|
|
1462
1477
|
if (sessionId.length === 0) throw new Error("sessionId 必须是非空字符串");
|
|
@@ -1472,11 +1487,9 @@ function createConnectionService(ctx, options, persistence) {
|
|
|
1472
1487
|
tables
|
|
1473
1488
|
};
|
|
1474
1489
|
runtime.set(sessionId, published);
|
|
1475
|
-
const summary = summarize(published);
|
|
1476
|
-
summary.credential = await credentialSummary(published);
|
|
1477
1490
|
return {
|
|
1478
1491
|
tables,
|
|
1479
|
-
summary
|
|
1492
|
+
summary: await statusSummary(published)
|
|
1480
1493
|
};
|
|
1481
1494
|
},
|
|
1482
1495
|
async disconnect(sessionId) {
|
|
@@ -1491,11 +1504,9 @@ function createConnectionService(ctx, options, persistence) {
|
|
|
1491
1504
|
tables
|
|
1492
1505
|
};
|
|
1493
1506
|
runtime.set(sessionId, published);
|
|
1494
|
-
const summary = summarize(published);
|
|
1495
|
-
summary.credential = await credentialSummary(published);
|
|
1496
1507
|
return {
|
|
1497
1508
|
tables,
|
|
1498
|
-
summary
|
|
1509
|
+
summary: await statusSummary(published)
|
|
1499
1510
|
};
|
|
1500
1511
|
},
|
|
1501
1512
|
async resolveForExecution(sessionId) {
|
|
@@ -1583,7 +1594,8 @@ function connectionFromProfile(profileId, profile) {
|
|
|
1583
1594
|
...profile.port !== void 0 ? { port: profile.port } : {},
|
|
1584
1595
|
...profile.user !== void 0 ? { user: profile.user } : {},
|
|
1585
1596
|
...profile.readonly !== void 0 ? { readonly: profile.readonly } : {},
|
|
1586
|
-
...profile.passwordRef !== void 0 ? { passwordRef: profile.passwordRef } : {}
|
|
1597
|
+
...profile.passwordRef !== void 0 ? { passwordRef: profile.passwordRef } : {},
|
|
1598
|
+
credentialMode: profile.credentialMode ?? (profile.type === "sqlite" ? "none" : profile.passwordRef !== void 0 ? "reference" : "password")
|
|
1587
1599
|
};
|
|
1588
1600
|
}
|
|
1589
1601
|
function profileFromConnection(connection, updatedAt) {
|
|
@@ -1596,9 +1608,18 @@ function profileFromConnection(connection, updatedAt) {
|
|
|
1596
1608
|
...connection.port !== void 0 ? { port: connection.port } : {},
|
|
1597
1609
|
...connection.user !== void 0 ? { user: connection.user } : {},
|
|
1598
1610
|
...connection.readonly !== void 0 ? { readonly: connection.readonly } : {},
|
|
1599
|
-
...connection.passwordRef !== void 0 ? { passwordRef: connection.passwordRef } : {}
|
|
1611
|
+
...connection.passwordRef !== void 0 ? { passwordRef: connection.passwordRef } : {},
|
|
1612
|
+
...connection.credentialMode !== void 0 ? { credentialMode: connection.credentialMode } : {}
|
|
1600
1613
|
};
|
|
1601
1614
|
}
|
|
1615
|
+
/** Infer legacy records while leaving ambiguous secret-less SQL profiles conservative. */
|
|
1616
|
+
function credentialModeOf(connection) {
|
|
1617
|
+
if (connection.credentialMode !== void 0) return connection.credentialMode;
|
|
1618
|
+
if (connection.type === "sqlite") return "none";
|
|
1619
|
+
if (connection.passwordRef !== void 0) return "reference";
|
|
1620
|
+
if (connection.password !== void 0) return "password";
|
|
1621
|
+
return "none";
|
|
1622
|
+
}
|
|
1602
1623
|
function requireIdentifier(type, value, label) {
|
|
1603
1624
|
if (value === void 0 || value.length === 0) throw new Error(`${label} 不能为空`);
|
|
1604
1625
|
sanitizeIdentifier(type, value);
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { o as clientsSchema, t as createConnectionService } from "./connections-
|
|
1
|
+
import { o as clientsSchema, t as createConnectionService } from "./connections-5sfdEDsG.js";
|
|
2
2
|
import { a as DEFAULT_PRESET_ID, i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, r as DEFAULT_MAX_QUERY_CHARS, t as DEFAULT_CONNECT_TIMEOUT_MS } from "./defaults-DP4RyRh1.js";
|
|
3
|
-
import { r as apply$1 } from "./command-
|
|
4
|
-
import { n as apply$2 } from "./tool-
|
|
3
|
+
import { r as apply$1 } from "./command-DuCpwVbl.js";
|
|
4
|
+
import { n as apply$2 } from "./tool-DgL0fBfj.js";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { access, cp, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
7
7
|
import { homedir } from "node:os";
|
|
@@ -43,6 +43,11 @@ const persistedConnectionProfileSchema = z$1.object({
|
|
|
43
43
|
database: z$1.string().min(1),
|
|
44
44
|
readonly: z$1.boolean().optional(),
|
|
45
45
|
passwordRef: z$1.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/).optional(),
|
|
46
|
+
credentialMode: z$1.enum([
|
|
47
|
+
"none",
|
|
48
|
+
"password",
|
|
49
|
+
"reference"
|
|
50
|
+
]).optional(),
|
|
46
51
|
updatedAt: z$1.string().min(1)
|
|
47
52
|
}).strict();
|
|
48
53
|
/** Durable session-to-profile binding schema. */
|
|
@@ -181,12 +186,11 @@ function resolveDshHome(env = process.env) {
|
|
|
181
186
|
/**
|
|
182
187
|
* Install the packaged `preset/data-agent/` directory into
|
|
183
188
|
* `$DSH_HOME/.agent-presets/<presetId>/`. Idempotent: an existing target is
|
|
184
|
-
* normally left untouched.
|
|
185
|
-
* migrated once
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
* the boot.
|
|
189
|
+
* normally left untouched. Exact package-owned legacy compositions are
|
|
190
|
+
* migrated once when their runtime contract changes; user-edited compositions
|
|
191
|
+
* are never overwritten. `installPreset: false` never calls this. Best-effort
|
|
192
|
+
* — a failure logs a warning with manual install instructions instead of
|
|
193
|
+
* failing the boot.
|
|
190
194
|
*/
|
|
191
195
|
async function installPreset(ctx, presetId) {
|
|
192
196
|
const targetDir = join(resolveDshHome(), ".agent-presets", presetId);
|
|
@@ -205,13 +209,13 @@ async function installPreset(ctx, presetId) {
|
|
|
205
209
|
return false;
|
|
206
210
|
}
|
|
207
211
|
}
|
|
208
|
-
/** SHA-256 of
|
|
209
|
-
const
|
|
212
|
+
/** SHA-256 values of unmodified package-owned compositions safe to migrate. */
|
|
213
|
+
const LEGACY_MANAGED_PRESET_SHA256 = /* @__PURE__ */ new Set(["bae875a90d638ea78715030246b0f8a9f1a2c3359ca61febb6ceb59d0fcd930a", "d3c6f4049580069eec1c6b7de101f12c7fb30482ad317434afb69afb08a91fc6"]);
|
|
210
214
|
/** Public for regression tests of the non-destructive preset migration gate. */
|
|
211
215
|
function isLegacyManagedPreset(source) {
|
|
212
|
-
return createHash("sha256").update(source).digest("hex")
|
|
216
|
+
return LEGACY_MANAGED_PRESET_SHA256.has(createHash("sha256").update(source).digest("hex"));
|
|
213
217
|
}
|
|
214
|
-
/** Upgrade only
|
|
218
|
+
/** Upgrade only exact package-owned legacy compositions; preserve every edited preset. */
|
|
215
219
|
async function synchronizeExistingPreset(ctx, targetDir, sourceDir, presetId) {
|
|
216
220
|
const composition = join(targetDir, "agent.cordis.yml");
|
|
217
221
|
try {
|
|
@@ -219,7 +223,7 @@ async function synchronizeExistingPreset(ctx, targetDir, sourceDir, presetId) {
|
|
|
219
223
|
if (isLegacyManagedPreset(current)) {
|
|
220
224
|
const replacement = await readFile(join(sourceDir, "agent.cordis.yml"), "utf8");
|
|
221
225
|
await writeFile(composition, replacement, "utf8");
|
|
222
|
-
ctx.logger.info("data-agent: migrated preset at %s to
|
|
226
|
+
ctx.logger.info("data-agent: migrated package-owned preset at %s to the current runtime contract", composition);
|
|
223
227
|
return true;
|
|
224
228
|
}
|
|
225
229
|
if (current.includes("@yejiming/dsh-data-agent/tool") || current.includes("@yejiming/dsh-data-agent/command")) {
|
package/lib/routes.js
CHANGED
|
@@ -90,8 +90,12 @@ function apply(ctx, _config) {
|
|
|
90
90
|
if (req.method === "GET" && routeIs(segments, "status")) {
|
|
91
91
|
const sessionId = requireString(url.searchParams.get("sessionId"), "sessionId");
|
|
92
92
|
const summary = await scope.dataAgentConnections.status(sessionId);
|
|
93
|
-
writeJson(200, summary === void 0 ? {
|
|
94
|
-
connected:
|
|
93
|
+
writeJson(200, summary === void 0 ? {
|
|
94
|
+
connected: false,
|
|
95
|
+
reconnectRequired: false
|
|
96
|
+
} : {
|
|
97
|
+
connected: summary.ready === true,
|
|
98
|
+
reconnectRequired: summary.reconnectRequired === true,
|
|
95
99
|
summary
|
|
96
100
|
});
|
|
97
101
|
return;
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { a as classifyStatement, c as assertSingleStatement, i as runClientQuery, n as redactQueryResult, o as clientsSchema, r as redactSecretText, s as enforceReadRowLimit } from "./connections-
|
|
1
|
+
import { a as classifyStatement, c as assertSingleStatement, i as runClientQuery, n as redactQueryResult, o as clientsSchema, r as redactSecretText, s as enforceReadRowLimit } from "./connections-5sfdEDsG.js";
|
|
2
2
|
import { i as DEFAULT_MAX_RESULT_CHARS, o as DEFAULT_QUERY_TIMEOUT_MS, r as DEFAULT_MAX_QUERY_CHARS } from "./defaults-DP4RyRh1.js";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { link, mkdir, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { resolve } from "node:path";
|
|
3
6
|
import z from "schemastery";
|
|
4
7
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
5
8
|
const VIEW_KINDS = [
|
|
@@ -203,11 +206,14 @@ function parseAnalysisRequest(input, prefix = "render-analysis") {
|
|
|
203
206
|
if (!isRecord(input)) fail(prefix + ": 请求必须是对象");
|
|
204
207
|
assertOnlyKeys(input, [
|
|
205
208
|
"title",
|
|
209
|
+
"outputName",
|
|
206
210
|
"summary",
|
|
207
211
|
"datasets",
|
|
208
212
|
"views"
|
|
209
213
|
], prefix);
|
|
210
214
|
const title = requireNonEmptyString(input["title"], prefix + ".title");
|
|
215
|
+
const outputName = optionalString(input, "outputName", prefix);
|
|
216
|
+
if (outputName !== void 0 && outputName.trim().length === 0) fail(prefix + ".outputName: 必须是非空字符串");
|
|
211
217
|
const summary = optionalString(input, "summary", prefix);
|
|
212
218
|
const datasets = input["datasets"];
|
|
213
219
|
if (!Array.isArray(datasets) || datasets.length < 1 || datasets.length > 6) fail(prefix + ": datasets 必须是 1-6 个");
|
|
@@ -242,6 +248,7 @@ function parseAnalysisRequest(input, prefix = "render-analysis") {
|
|
|
242
248
|
datasets: parsedDatasets,
|
|
243
249
|
views: parsedViews
|
|
244
250
|
};
|
|
251
|
+
if (outputName !== void 0) request.outputName = outputName;
|
|
245
252
|
if (summary !== void 0) request.summary = summary;
|
|
246
253
|
return request;
|
|
247
254
|
}
|
|
@@ -312,13 +319,15 @@ function rowsToArrays(columns, rows) {
|
|
|
312
319
|
}
|
|
313
320
|
/** JSON-encoded UTF-8 size of the normalized report (the 512 KiB bound). */
|
|
314
321
|
function reportJsonBytes(report) {
|
|
315
|
-
|
|
322
|
+
const { htmlPath: _htmlPath, ...dataReport } = report;
|
|
323
|
+
return new TextEncoder().encode(JSON.stringify(dataReport)).length;
|
|
316
324
|
}
|
|
317
325
|
/** One-line model-facing summary; never re-injects rows into model context (D5). */
|
|
318
326
|
function formatAnalysisSummary(report) {
|
|
319
327
|
const emptyIds = report.datasets.filter((dataset) => dataset.rows.length === 0).map((dataset) => dataset.id);
|
|
320
328
|
let text = "已生成分析报告《" + report.title + "》:" + report.datasets.length + " 个数据集、" + report.views.length + " 个视图(version 1)。";
|
|
321
329
|
if (emptyIds.length > 0) text += "其中 " + emptyIds.length + " 个数据集无数据:" + emptyIds.join("、") + "。";
|
|
330
|
+
if (report.htmlPath !== void 0) text += "Dashboard HTML已保存:" + report.htmlPath;
|
|
322
331
|
return text;
|
|
323
332
|
}
|
|
324
333
|
const BASE_VIEW_PROPERTIES = {
|
|
@@ -508,6 +517,10 @@ const RENDER_ANALYSIS_PARAMETERS = {
|
|
|
508
517
|
required: true,
|
|
509
518
|
description: "报告标题,如「月度经营分析」"
|
|
510
519
|
},
|
|
520
|
+
outputName: {
|
|
521
|
+
type: "string",
|
|
522
|
+
description: "可选语义化HTML文件名(仅basename,可省略.html),如「电商经营全景分析-2023-09至2026-08」;缺省时使用title,不要使用随机ID"
|
|
523
|
+
},
|
|
511
524
|
summary: {
|
|
512
525
|
type: "string",
|
|
513
526
|
description: "可选一句话结论/摘要,显示在报告头部"
|
|
@@ -554,6 +567,10 @@ const ANALYSIS_REPORT_OUTPUT_SCHEMA = {
|
|
|
554
567
|
required: true
|
|
555
568
|
},
|
|
556
569
|
summary: { type: "string" },
|
|
570
|
+
htmlPath: {
|
|
571
|
+
type: "string",
|
|
572
|
+
required: true
|
|
573
|
+
},
|
|
557
574
|
datasets: {
|
|
558
575
|
type: "array",
|
|
559
576
|
required: true,
|
|
@@ -810,6 +827,127 @@ async function runStructuredReadQuery(ctx, connection, sql, resolved, toolName,
|
|
|
810
827
|
};
|
|
811
828
|
}
|
|
812
829
|
//#endregion
|
|
830
|
+
//#region src/analysis-html.ts
|
|
831
|
+
/**
|
|
832
|
+
* Offline HTML artifact for one validated AnalysisReportV1.
|
|
833
|
+
*
|
|
834
|
+
* The generated page has no network/runtime dependencies. Untrusted report
|
|
835
|
+
* strings stay inside escaped JSON and are projected with textContent only;
|
|
836
|
+
* chart geometry is derived from already-validated finite numeric fields.
|
|
837
|
+
* @module @yejiming/dsh-data-agent/analysis-html
|
|
838
|
+
*/
|
|
839
|
+
const ANALYSIS_REPORT_DIRECTORY = "analysis-reports";
|
|
840
|
+
/** Convert a report title/output name into a bounded, readable filename segment. */
|
|
841
|
+
function analysisFileSegment(value, fallback) {
|
|
842
|
+
const sanitize = (candidate) => candidate.normalize("NFKC").replace(/\.html$/i, "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replace(/[^\p{Letter}\p{Number}]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 96);
|
|
843
|
+
return sanitize(value) || sanitize(fallback) || "analysis-report";
|
|
844
|
+
}
|
|
845
|
+
/** Relative path shared by the writer and DSH's mutation presentation. */
|
|
846
|
+
function analysisArtifactRelativePath(title, outputName) {
|
|
847
|
+
const basename = analysisFileSegment(outputName ?? title, "分析报告");
|
|
848
|
+
return `${ANALYSIS_REPORT_DIRECTORY}/${basename}.html`;
|
|
849
|
+
}
|
|
850
|
+
/** Escape JSON so data cannot close its application/json script element. */
|
|
851
|
+
function escapeJsonForHtmlScript(value) {
|
|
852
|
+
return JSON.stringify(value).replace(/&/g, "\\u0026").replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
853
|
+
}
|
|
854
|
+
/** Render one complete, offline Dashboard document. */
|
|
855
|
+
function renderAnalysisHtml(report, generatedAt = (/* @__PURE__ */ new Date()).toISOString()) {
|
|
856
|
+
return `<!doctype html>
|
|
857
|
+
<html lang="zh-CN">
|
|
858
|
+
<head>
|
|
859
|
+
<meta charset="utf-8">
|
|
860
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
861
|
+
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'none'; font-src 'none'; object-src 'none'; frame-src 'none'; base-uri 'none'; form-action 'none'">
|
|
862
|
+
<title>DSH Data Agent Analysis</title>
|
|
863
|
+
<style>
|
|
864
|
+
:root{color-scheme:light dark;--bg:oklch(97.4% .006 255);--panel:oklch(99.2% .003 255);--text:oklch(27% .035 255);--muted:oklch(52% .025 255);--line:oklch(88% .012 255);--grid:oklch(92% .008 255);--accent:oklch(58% .16 255);--palette:#4e79a7,#f28e2b,#59a14f,#e15759,#76b7b2,#edc948,#b07aa1,#9c755f}
|
|
865
|
+
@media(prefers-color-scheme:dark){:root{--bg:oklch(19% .018 255);--panel:oklch(23% .02 255);--text:oklch(93% .012 255);--muted:oklch(72% .018 255);--line:oklch(35% .022 255);--grid:oklch(31% .018 255);--accent:oklch(72% .13 255)}}
|
|
866
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif}main{width:min(1440px,calc(100% - 32px));margin:0 auto;padding:24px 0 48px}header{padding-bottom:16px;margin-bottom:16px;border-bottom:1px solid var(--line)}h1{margin:0;font-size:24px;line-height:1.25;font-weight:650;letter-spacing:-.015em}header p{max-width:1120px;margin:7px 0 0;color:var(--muted)}.report-count{font-size:13px;color:var(--text)}.metric-band{display:flex;flex-wrap:wrap;gap:8px;padding-bottom:12px;margin-bottom:12px;border-bottom:1px solid var(--line)}.metric{flex:1 1 180px;min-width:150px;padding:9px 12px;background:var(--panel);border:1px solid var(--line);border-radius:8px;break-inside:avoid}.metric-label{margin:0;color:var(--muted);font-size:12px}.metric-value{margin:2px 0 0;font-size:18px;line-height:1.35;font-weight:650;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.dashboard{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.view{min-width:0;padding:11px 12px;background:var(--panel);border:1px solid var(--line);border-radius:8px;break-inside:avoid}.view.full,.view.table{grid-column:1/-1}.view h2{font-size:13px;line-height:1.4;font-weight:650;margin:0 0 8px}.empty{padding:28px 12px;text-align:center;color:var(--muted);border:1px dashed var(--line);border-radius:7px}.chart{width:100%;height:auto;min-height:260px;display:block}.axis{stroke:var(--line);stroke-width:1}.grid{stroke:var(--grid);stroke-width:1}.axis-label,.legend{fill:var(--muted);font-size:11px}.legend-row{display:flex;gap:12px;flex-wrap:wrap;color:var(--muted);font-size:12px;margin-top:6px}.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:5px}details{margin-top:10px;border-top:1px solid var(--line);padding-top:8px}summary{cursor:pointer;color:var(--accent);font-size:12px}.table-wrap{overflow:auto;max-height:460px;border:1px solid var(--line);border-radius:6px}table{width:100%;border-collapse:collapse;white-space:nowrap;font-size:12px}th,td{text-align:left;padding:7px 10px;border-bottom:1px solid var(--line);vertical-align:top}th{position:sticky;top:0;background:var(--bg);font-weight:600}td{max-width:420px;white-space:pre-wrap;overflow-wrap:anywhere}.null{color:var(--muted);font-style:italic}footer{margin-top:18px;color:var(--muted);font-size:12px}
|
|
867
|
+
@media(max-width:880px){main{width:min(100% - 20px,1440px);padding-top:18px}h1{font-size:21px}.dashboard{grid-template-columns:1fr}.view{grid-column:1!important}.metric{flex-basis:100%}}
|
|
868
|
+
@media print{:root{color-scheme:light;--bg:oklch(98.5% .003 255);--panel:oklch(99.5% .002 255);--text:oklch(24% .025 255);--muted:oklch(48% .02 255);--line:oklch(84% .01 255);--grid:oklch(90% .008 255)}.dashboard{display:block}.view{margin:0 0 12px}.table-wrap{max-height:none;overflow:visible}details{display:block}details>summary{display:none}details>*{display:block!important}main{width:100%;padding:0}}
|
|
869
|
+
</style>
|
|
870
|
+
</head>
|
|
871
|
+
<body>
|
|
872
|
+
<main>
|
|
873
|
+
<header id="report-header"></header>
|
|
874
|
+
<section id="metric-band" class="metric-band" aria-label="关键指标"></section>
|
|
875
|
+
<section id="dashboard" class="dashboard" aria-label="分析视图"></section>
|
|
876
|
+
<footer id="report-footer"></footer>
|
|
877
|
+
</main>
|
|
878
|
+
<script type="application/json" id="report-data">${escapeJsonForHtmlScript(report)}<\/script>
|
|
879
|
+
<script>
|
|
880
|
+
(()=>{'use strict';
|
|
881
|
+
const report=JSON.parse(document.getElementById('report-data').textContent||'{}');
|
|
882
|
+
const palette=['#4e79a7','#f28e2b','#59a14f','#e15759','#76b7b2','#edc948','#b07aa1','#9c755f'];
|
|
883
|
+
const ns='http://www.w3.org/2000/svg';
|
|
884
|
+
const el=(tag,className,text)=>{const node=document.createElement(tag);if(className)node.className=className;if(text!==undefined)node.textContent=String(text);return node};
|
|
885
|
+
const svgEl=(tag,attrs={})=>{const node=document.createElementNS(ns,tag);for(const [key,value] of Object.entries(attrs))node.setAttribute(key,String(value));return node};
|
|
886
|
+
const datasetFor=id=>report.datasets.find(item=>item.id===id);
|
|
887
|
+
const indexOf=(dataset,field)=>dataset.columns.indexOf(field);
|
|
888
|
+
const number=value=>value===null||value===undefined||value===''?null:(Number.isFinite(Number(value))?Number(value):null);
|
|
889
|
+
const extent=(values,includeZero=false)=>{const finite=values.filter(Number.isFinite);let min=finite.length?Math.min(...finite):0,max=finite.length?Math.max(...finite):1;if(includeZero){min=Math.min(0,min);max=Math.max(0,max)}if(min===max){min-=1;max+=1}return[min,max]};
|
|
890
|
+
const scale=(value,min,max,start,end)=>start+(value-min)/(max-min)*(end-start);
|
|
891
|
+
const titleFor=view=>view.label||({metric:'指标',line:'趋势',bar:'对比',pie:'构成',scatter:'分布',table:'明细'}[view.kind]||view.id);
|
|
892
|
+
const empty=()=>el('div','empty','暂无数据');
|
|
893
|
+
function tableFor(dataset,columns){const selected=(columns&&columns.length?columns:dataset.columns).map(name=>[name,indexOf(dataset,name)]);const wrap=el('div','table-wrap');const table=el('table');const head=el('thead');const hr=el('tr');selected.forEach(([name])=>hr.append(el('th','',name)));head.append(hr);table.append(head);const body=el('tbody');dataset.rows.forEach(row=>{const tr=el('tr');selected.forEach(([,index])=>{const value=row[index];const td=el('td',value===null?'null':'',value===null?'NULL':value);tr.append(td)});body.append(tr)});table.append(body);wrap.append(table);return wrap}
|
|
894
|
+
function detailsFor(dataset,columns){const details=el('details');details.append(el('summary','','查看原始数据('+dataset.rows.length+'行)'));details.append(tableFor(dataset,columns));return details}
|
|
895
|
+
function baseSvg(){const svg=svgEl('svg',{viewBox:'0 0 760 300',role:'img',class:'chart','aria-label':'数据图表'});for(let i=0;i<5;i++){const y=30+i*55;svg.append(svgEl('line',{x1:58,y1:y,x2:738,y2:y,class:'grid'}))}svg.append(svgEl('line',{x1:58,y1:250,x2:738,y2:250,class:'axis'}));svg.append(svgEl('line',{x1:58,y1:20,x2:58,y2:250,class:'axis'}));return svg}
|
|
896
|
+
function axisText(svg,text,x,y,anchor='start'){const node=svgEl('text',{x,y,'text-anchor':anchor,class:'axis-label'});node.textContent=String(text);svg.append(node)}
|
|
897
|
+
function legend(card,names){if(names.length<2)return;const row=el('div','legend-row');names.forEach((name,index)=>{const item=el('span');const dot=el('i','dot');dot.style.background=palette[index%palette.length];item.append(dot,document.createTextNode(String(name)));row.append(item)});card.append(row)}
|
|
898
|
+
function lineChart(card,view,dataset){const xIndex=indexOf(dataset,view.x.field);const grouped=new Map();if(view.seriesField){const groupIndex=indexOf(dataset,view.seriesField),yIndex=indexOf(dataset,view.y[0]);dataset.rows.forEach((row,index)=>{const name=row[groupIndex]??'';if(!grouped.has(name))grouped.set(name,[]);grouped.get(name).push({index,x:row[xIndex],y:number(row[yIndex])})})}else view.y.forEach(field=>{const yIndex=indexOf(dataset,field);grouped.set(field,dataset.rows.map((row,index)=>({index,x:row[xIndex],y:number(row[yIndex])}))) });const all=[...grouped.values()].flat().map(point=>point.y).filter(value=>value!==null);if(!all.length){card.append(empty());return}const [min,max]=extent(all);const svg=baseSvg();axisText(svg,max.toLocaleString(),52,27,'end');axisText(svg,min.toLocaleString(),52,250,'end');axisText(svg,view.x.label||view.x.field,398,286,'middle');const count=Math.max(2,dataset.rows.length);[...grouped.entries()].forEach(([name,points],seriesIndex)=>{let segment=[];const flush=()=>{if(segment.length){svg.append(svgEl('polyline',{points:segment.join(' '),fill:'none',stroke:palette[seriesIndex%palette.length],'stroke-width':3,'stroke-linejoin':'round','stroke-linecap':'round'}));segment=[]}};points.forEach(point=>{if(point.y===null){flush();return}const x=scale(point.index,0,count-1,62,734),y=scale(point.y,min,max,246,24);segment.push(x+','+y);svg.append(svgEl('circle',{cx:x,cy:y,r:3,fill:palette[seriesIndex%palette.length]}))});flush()});card.append(svg);legend(card,[...grouped.keys()])}
|
|
899
|
+
function barChart(card,view,dataset){const xIndex=indexOf(dataset,view.x.field);const series=view.seriesField?[view.seriesField]:view.y;const values=[];const entries=[];if(view.seriesField){const groupIndex=indexOf(dataset,view.seriesField),yIndex=indexOf(dataset,view.y[0]);dataset.rows.forEach((row,index)=>{const value=number(row[yIndex]);if(value!==null){values.push(value);entries.push({index,value,name:row[groupIndex]??'',x:row[xIndex]??''})}})}else dataset.rows.forEach((row,index)=>view.y.forEach((field,seriesIndex)=>{const value=number(row[indexOf(dataset,field)]);if(value!==null){values.push(value);entries.push({index,value,name:field,seriesIndex,x:row[xIndex]??''})}}));if(!values.length){card.append(empty());return}const [min,max]=extent(values,true),svg=baseSvg(),zero=scale(0,min,max,246,24),groups=Math.max(1,dataset.rows.length),barWidth=Math.max(2,Math.min(36,620/(groups*Math.max(1,series.length))));svg.append(svgEl('line',{x1:58,y1:zero,x2:738,y2:zero,stroke:'var(--muted)','stroke-width':1.5}));entries.forEach((entry,entryIndex)=>{const seriesIndex=entry.seriesIndex??Math.max(0,series.indexOf(entry.name));const center=scale(entry.index+.5,0,groups,62,734);const offset=(seriesIndex-(series.length-1)/2)*barWidth;const y=scale(entry.value,min,max,246,24);svg.append(svgEl('rect',{x:center+offset-barWidth*.42,y:Math.min(y,zero),width:barWidth*.84,height:Math.max(1,Math.abs(zero-y)),rx:2,fill:palette[(seriesIndex<0?entryIndex:seriesIndex)%palette.length]}))});axisText(svg,max.toLocaleString(),52,27,'end');axisText(svg,min.toLocaleString(),52,250,'end');axisText(svg,view.x.label||view.x.field,398,286,'middle');card.append(svg);legend(card,series)}
|
|
900
|
+
function pieChart(card,view,dataset){const cIndex=indexOf(dataset,view.categoryField),vIndex=indexOf(dataset,view.valueField);const entries=dataset.rows.map(row=>({name:row[cIndex]??'',value:number(row[vIndex])??0})),total=entries.reduce((sum,item)=>sum+item.value,0);if(total<=0){card.append(empty());return}const svg=svgEl('svg',{viewBox:'0 0 760 300',role:'img',class:'chart','aria-label':'构成图'}),cx=235,cy=150,r=105;let angle=-Math.PI/2;entries.forEach((entry,index)=>{const next=angle+entry.value/total*Math.PI*2,x1=cx+Math.cos(angle)*r,y1=cy+Math.sin(angle)*r,x2=cx+Math.cos(next)*r,y2=cy+Math.sin(next)*r,large=next-angle>Math.PI?1:0;const path=svgEl('path',{d:'M '+cx+' '+cy+' L '+x1+' '+y1+' A '+r+' '+r+' 0 '+large+' 1 '+x2+' '+y2+' Z',fill:palette[index%palette.length]});svg.append(path);angle=next});entries.forEach((entry,index)=>{const y=46+index*25;svg.append(svgEl('circle',{cx:490,cy:y-4,r:5,fill:palette[index%palette.length]}));axisText(svg,entry.name+' '+(entry.value/total*100).toFixed(1)+'%',505,y)});card.append(svg)}
|
|
901
|
+
function scatterChart(card,view,dataset){const xi=indexOf(dataset,view.xField),yi=indexOf(dataset,view.yField),points=dataset.rows.map(row=>[number(row[xi]),number(row[yi])]).filter(point=>point[0]!==null&&point[1]!==null);if(!points.length){card.append(empty());return}const [xmin,xmax]=extent(points.map(point=>point[0])),[ymin,ymax]=extent(points.map(point=>point[1])),svg=baseSvg();points.forEach(point=>svg.append(svgEl('circle',{cx:scale(point[0],xmin,xmax,62,734),cy:scale(point[1],ymin,ymax,246,24),r:4,fill:palette[0],opacity:.82})));axisText(svg,ymax.toLocaleString(),52,27,'end');axisText(svg,ymin.toLocaleString(),52,250,'end');axisText(svg,xmin.toLocaleString(),62,270);axisText(svg,xmax.toLocaleString(),734,270,'end');axisText(svg,view.xField,398,288,'middle');card.append(svg)}
|
|
902
|
+
const header=document.getElementById('report-header');header.append(el('h1','',report.title));if(report.summary)header.append(el('p','',report.summary));header.append(el('p','report-count',report.datasets.length+'个数据集 · '+report.views.length+'个视图'));
|
|
903
|
+
const widths=new Map();let firstChartPlaced=false;report.views.forEach(view=>{if(view.kind==='metric')return;if(view.width){widths.set(view.id,view.width);if(['line','bar','pie','scatter'].includes(view.kind))firstChartPlaced=true;return}const width=view.kind==='table'||!firstChartPlaced?'full':'half';widths.set(view.id,width);if(['line','bar','pie','scatter'].includes(view.kind))firstChartPlaced=true});
|
|
904
|
+
const metricBand=document.getElementById('metric-band');const metrics=report.views.filter(view=>view.kind==='metric');if(metrics.length===0)metricBand.remove();else metrics.forEach(view=>{const dataset=datasetFor(view.datasetId),metric=el('article','metric');metric.append(el('p','metric-label',titleFor(view)));if(!dataset||dataset.rows.length===0)metric.append(el('p','metric-value','—'));else{const value=number(dataset.rows[0][indexOf(dataset,view.field)]);metric.append(el('p','metric-value',value===null?'—':(view.format==='percent'?(value*100).toLocaleString()+'%':value.toLocaleString())))}metricBand.append(metric)});
|
|
905
|
+
const dashboard=document.getElementById('dashboard');report.views.filter(view=>view.kind!=='metric').forEach(view=>{const dataset=datasetFor(view.datasetId),card=el('article','view '+(widths.get(view.id)==='full'?'full ':'')+view.kind);card.append(el('h2','',titleFor(view)));if(!dataset||dataset.rows.length===0)card.append(empty());else if(view.kind==='table')card.append(tableFor(dataset,view.columns));else{if(view.kind==='line')lineChart(card,view,dataset);if(view.kind==='bar')barChart(card,view,dataset);if(view.kind==='pie')pieChart(card,view,dataset);if(view.kind==='scatter')scatterChart(card,view,dataset);card.append(detailsFor(dataset))}dashboard.append(card)});
|
|
906
|
+
document.getElementById('report-footer').textContent='由 DSH Data Agent 生成 · '+${escapeJsonForHtmlScript(generatedAt)}+' · 离线HTML';
|
|
907
|
+
})();
|
|
908
|
+
<\/script>
|
|
909
|
+
</body>
|
|
910
|
+
</html>`;
|
|
911
|
+
}
|
|
912
|
+
/** Atomically persist one report and return the report enriched with htmlPath. */
|
|
913
|
+
async function writeAnalysisHtml(report, options) {
|
|
914
|
+
const directory = resolve(options.cwd, ANALYSIS_REPORT_DIRECTORY);
|
|
915
|
+
const relativePath = analysisArtifactRelativePath(report.title, options.outputName);
|
|
916
|
+
const htmlPath = resolve(options.cwd, relativePath);
|
|
917
|
+
const complete = {
|
|
918
|
+
...report,
|
|
919
|
+
htmlPath
|
|
920
|
+
};
|
|
921
|
+
const basename = analysisFileSegment(options.outputName ?? report.title, "分析报告");
|
|
922
|
+
const temporaryPath = resolve(directory, `.${basename}.${randomUUID()}.tmp`);
|
|
923
|
+
try {
|
|
924
|
+
await mkdir(directory, { recursive: true });
|
|
925
|
+
await writeFile(temporaryPath, renderAnalysisHtml(complete, options.generatedAt), {
|
|
926
|
+
encoding: "utf8",
|
|
927
|
+
flag: "wx"
|
|
928
|
+
});
|
|
929
|
+
await link(temporaryPath, htmlPath);
|
|
930
|
+
await unlink(temporaryPath).catch(() => void 0);
|
|
931
|
+
} catch (error) {
|
|
932
|
+
await unlink(temporaryPath).catch(() => void 0);
|
|
933
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
934
|
+
const detail = error?.code === "EEXIST" ? "目标文件已存在,请使用更具体的outputName" : message;
|
|
935
|
+
throw new Error(`render-analysis: 保存Dashboard HTML失败(${htmlPath}):${detail}`, { cause: error });
|
|
936
|
+
}
|
|
937
|
+
return complete;
|
|
938
|
+
}
|
|
939
|
+
//#endregion
|
|
940
|
+
//#region src/presentation-text.ts
|
|
941
|
+
/** Surface-neutral control-sequence sanitization for generic tool cards. */
|
|
942
|
+
const CONTROL_ESCAPE = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/gu;
|
|
943
|
+
const OSC_SEQUENCE = /\u001b\][\s\S]*?(?:\u0007|\u001b\\)/gu;
|
|
944
|
+
const CSI_SEQUENCE = /\u001b\[[0-?]*[ -/]*[@-~]/gu;
|
|
945
|
+
const ESC_SEQUENCE = /\u001b(?:[@-_]|[ -/]+[@-~]?)/gu;
|
|
946
|
+
/** Remove control effects while keeping their presence visible to the user. */
|
|
947
|
+
function sanitizePresentationText(value) {
|
|
948
|
+
return String(value ?? "").replace(OSC_SEQUENCE, "⟦OSC⟧").replace(CSI_SEQUENCE, "⟦ESC⟧").replace(ESC_SEQUENCE, "⟦ESC⟧").replace(/\r\n?/gu, "\\n").replace(/\n/gu, "\\n").replace(/\t/gu, "\\t").replace(CONTROL_ESCAPE, (character) => `\\x${character.codePointAt(0).toString(16).padStart(2, "0")}`);
|
|
949
|
+
}
|
|
950
|
+
//#endregion
|
|
813
951
|
//#region src/tool.ts
|
|
814
952
|
/** Cordis plugin name (diagnostics only). */
|
|
815
953
|
const name = "data-agent-tool";
|
|
@@ -852,7 +990,7 @@ function validateSingleSql(sql, toolName) {
|
|
|
852
990
|
assertSingleStatement(sql, toolName);
|
|
853
991
|
}
|
|
854
992
|
/**
|
|
855
|
-
* The
|
|
993
|
+
* The surface-neutral render-analysis tool (D1-D5): one call builds one versioned
|
|
856
994
|
* analysis report from 1-6 read-only datasets and 1-8 views. The full report
|
|
857
995
|
* is persisted as presentationMeta; the model only receives a short summary
|
|
858
996
|
* (output.render), never the rows themselves.
|
|
@@ -860,7 +998,7 @@ function validateSingleSql(sql, toolName) {
|
|
|
860
998
|
function defineRenderAnalysisTool(ctx, resolved) {
|
|
861
999
|
return defineTool({
|
|
862
1000
|
name: "render-analysis",
|
|
863
|
-
description: "
|
|
1001
|
+
description: "Render one versioned analysis report (v1) from 1-6 read-only datasets using 1-8 metric, line, bar, pie, scatter, or table views, then save an offline Dashboard HTML file under analysis-reports/ in the current session workspace. First use sql-query to inspect and verify data, then call this tool only when visualization adds value. Use one primary chart for a simple relationship or 3-6 complementary views for multi-metric, time-series, or segmented analysis. Put aggregation, Top N, and sorting in SQL, and add ORDER BY for line or time datasets. Reuse a dataset across views via datasetId; each dataset runs once. Arbitrary chart options, scripts, HTML, CSS, and URLs are not accepted. Empty datasets are valid and render as no-data states.",
|
|
864
1002
|
parameters: RENDER_ANALYSIS_PARAMETERS,
|
|
865
1003
|
output: {
|
|
866
1004
|
schema: ANALYSIS_REPORT_OUTPUT_SCHEMA,
|
|
@@ -872,14 +1010,18 @@ function defineRenderAnalysisTool(ctx, resolved) {
|
|
|
872
1010
|
},
|
|
873
1011
|
presentCall: (args) => ({
|
|
874
1012
|
card: "generic",
|
|
875
|
-
kind: "
|
|
876
|
-
title: "render-analysis《" + args.title + "》",
|
|
877
|
-
rawInput: args.title
|
|
1013
|
+
kind: "edit",
|
|
1014
|
+
title: "render-analysis《" + sanitizePresentationText(args.title) + "》",
|
|
1015
|
+
rawInput: sanitizePresentationText(args.title),
|
|
1016
|
+
locations: [{ path: analysisArtifactRelativePath(args.title, args.outputName) }]
|
|
878
1017
|
}),
|
|
879
1018
|
presentResult: (args, result) => ({
|
|
880
1019
|
card: "generic",
|
|
881
|
-
title: "render-analysis《" + args.title + "》",
|
|
882
|
-
content: result.content
|
|
1020
|
+
title: "render-analysis《" + sanitizePresentationText(args.title) + "》",
|
|
1021
|
+
content: result.content.map((item) => item.type === "text" ? {
|
|
1022
|
+
...item,
|
|
1023
|
+
text: sanitizePresentationText(item.text)
|
|
1024
|
+
} : item)
|
|
883
1025
|
}),
|
|
884
1026
|
async execute(args, exec) {
|
|
885
1027
|
const request = parseAnalysisRequest(args);
|
|
@@ -928,7 +1070,11 @@ function defineRenderAnalysisTool(ctx, resolved) {
|
|
|
928
1070
|
};
|
|
929
1071
|
const bytes = reportJsonBytes(report);
|
|
930
1072
|
if (bytes > 524288) throw new Error("render-analysis: 报告 JSON 超过 524288 字节上限(当前 " + bytes + " 字节);请聚合、筛选或拆分报告,不得静默删减数据");
|
|
931
|
-
|
|
1073
|
+
const sessionCwd = exec.agent?.session?.header?.cwd;
|
|
1074
|
+
return await writeAnalysisHtml(report, {
|
|
1075
|
+
cwd: typeof sessionCwd === "string" && sessionCwd.length > 0 ? sessionCwd : process.cwd(),
|
|
1076
|
+
outputName: request.outputName
|
|
1077
|
+
});
|
|
932
1078
|
}
|
|
933
1079
|
});
|
|
934
1080
|
}
|
|
@@ -1122,7 +1268,7 @@ function apply(ctx, config) {
|
|
|
1122
1268
|
return runRedactedClientQuery(ctx, connection, classifyStatement(args.sql, connection.type) === "read" ? enforceReadRowLimit(args.sql, connection.type, resolved.maxRows) : args.sql, runnerOptions(resolved), exec.signal);
|
|
1123
1269
|
}
|
|
1124
1270
|
}));
|
|
1125
|
-
|
|
1271
|
+
ctx.tools.register(defineRenderAnalysisTool(ctx, resolved));
|
|
1126
1272
|
}
|
|
1127
1273
|
//#endregion
|
|
1128
1274
|
export { name as i, apply as n, inject as r, Config as t };
|
package/lib/tool.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { i as name, n as apply, r as inject, t as Config } from "./tool-
|
|
1
|
+
import { i as name, n as apply, r as inject, t as Config } from "./tool-DgL0fBfj.js";
|
|
2
2
|
export { Config, apply, inject, name };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline HTML artifact for one validated AnalysisReportV1.
|
|
3
|
+
*
|
|
4
|
+
* The generated page has no network/runtime dependencies. Untrusted report
|
|
5
|
+
* strings stay inside escaped JSON and are projected with textContent only;
|
|
6
|
+
* chart geometry is derived from already-validated finite numeric fields.
|
|
7
|
+
* @module @yejiming/dsh-data-agent/analysis-html
|
|
8
|
+
*/
|
|
9
|
+
import type { AnalysisReportV1 } from './analysis.ts';
|
|
10
|
+
export declare const ANALYSIS_REPORT_DIRECTORY = "analysis-reports";
|
|
11
|
+
/** Convert a report title/output name into a bounded, readable filename segment. */
|
|
12
|
+
export declare function analysisFileSegment(value: string, fallback: string): string;
|
|
13
|
+
/** Relative path shared by the writer and DSH's mutation presentation. */
|
|
14
|
+
export declare function analysisArtifactRelativePath(title: string, outputName?: string): string;
|
|
15
|
+
/** Escape JSON so data cannot close its application/json script element. */
|
|
16
|
+
export declare function escapeJsonForHtmlScript(value: unknown): string;
|
|
17
|
+
/** Render one complete, offline Dashboard document. */
|
|
18
|
+
export declare function renderAnalysisHtml(report: AnalysisReportV1, generatedAt?: string): string;
|
|
19
|
+
export interface WriteAnalysisHtmlOptions {
|
|
20
|
+
cwd: string;
|
|
21
|
+
outputName?: string;
|
|
22
|
+
generatedAt?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Atomically persist one report and return the report enriched with htmlPath. */
|
|
25
|
+
export declare function writeAnalysisHtml(report: AnalysisReportV1, options: WriteAnalysisHtmlOptions): Promise<AnalysisReportV1 & {
|
|
26
|
+
htmlPath: string;
|
|
27
|
+
}>;
|
package/lib/types/analysis.d.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface AnalysisDatasetRequestV1 {
|
|
|
87
87
|
/** The wire request accepted by the render-analysis tool. */
|
|
88
88
|
export interface AnalysisRequestV1 {
|
|
89
89
|
title: string;
|
|
90
|
+
/** Semantic output basename; directory is always analysis-reports/. */
|
|
91
|
+
outputName?: string;
|
|
90
92
|
summary?: string;
|
|
91
93
|
datasets: AnalysisDatasetRequestV1[];
|
|
92
94
|
views: AnalysisViewV1[];
|
|
@@ -102,6 +104,8 @@ export interface AnalysisReportV1 {
|
|
|
102
104
|
version: typeof ANALYSIS_REPORT_VERSION;
|
|
103
105
|
title: string;
|
|
104
106
|
summary?: string;
|
|
107
|
+
/** Absolute path of the generated HTML artifact (absent on legacy v1 meta). */
|
|
108
|
+
htmlPath?: string;
|
|
105
109
|
datasets: AnalysisDatasetResultV1[];
|
|
106
110
|
views: AnalysisViewV1[];
|
|
107
111
|
}
|
|
@@ -136,7 +140,7 @@ export declare function rowsToArrays(columns: string[], rows: readonly Record<st
|
|
|
136
140
|
/** JSON-encoded UTF-8 size of the normalized report (the 512 KiB bound). */
|
|
137
141
|
export declare function reportJsonBytes(report: AnalysisReportV1): number;
|
|
138
142
|
/** One-line model-facing summary; never re-injects rows into model context (D5). */
|
|
139
|
-
export declare function formatAnalysisSummary(report: Pick<AnalysisReportV1, 'title' | 'datasets' | 'views'>): string;
|
|
143
|
+
export declare function formatAnalysisSummary(report: Pick<AnalysisReportV1, 'title' | 'datasets' | 'views' | 'htmlPath'>): string;
|
|
140
144
|
/** The view union: exactly the six supported kinds, nothing else. */
|
|
141
145
|
export declare const ANALYSIS_VIEWS_SCHEMA: {
|
|
142
146
|
readonly oneOf: readonly [{
|
|
@@ -423,6 +427,10 @@ export declare const RENDER_ANALYSIS_PARAMETERS: {
|
|
|
423
427
|
readonly required: true;
|
|
424
428
|
readonly description: "报告标题,如「月度经营分析」";
|
|
425
429
|
};
|
|
430
|
+
readonly outputName: {
|
|
431
|
+
readonly type: "string";
|
|
432
|
+
readonly description: "可选语义化HTML文件名(仅basename,可省略.html),如「电商经营全景分析-2023-09至2026-08」;缺省时使用title,不要使用随机ID";
|
|
433
|
+
};
|
|
426
434
|
readonly summary: {
|
|
427
435
|
readonly type: "string";
|
|
428
436
|
readonly description: "可选一句话结论/摘要,显示在报告头部";
|
|
@@ -748,6 +756,10 @@ export declare const ANALYSIS_REPORT_OUTPUT_SCHEMA: {
|
|
|
748
756
|
readonly summary: {
|
|
749
757
|
readonly type: "string";
|
|
750
758
|
};
|
|
759
|
+
readonly htmlPath: {
|
|
760
|
+
readonly type: "string";
|
|
761
|
+
readonly required: true;
|
|
762
|
+
};
|
|
751
763
|
readonly datasets: {
|
|
752
764
|
readonly type: "array";
|
|
753
765
|
readonly required: true;
|