feat: publish Agent Studio integrations and setup guide
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { HarnessError } from "../errors/harness-error.js";
|
||||
import { SafeWorkspace } from "../workspace/safe-workspace.js";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const formats = { json: "application/json", html: "text/html; charset=utf-8", pdf: "application/pdf" } as const;
|
||||
type Format = keyof typeof formats;
|
||||
type QueryRecord = { queryId: string; requestedAt: string; arguments: Record<string, unknown>; result: unknown };
|
||||
type ReportInput = { data: Record<string, unknown>; source_query_ids: string[] };
|
||||
export type MinisReportOptions = { workspace: SafeWorkspace; sessionId: string; skillRoot: string; pythonExecutable?: string; chromeExecutable?: string };
|
||||
|
||||
export class MinisReportService {
|
||||
readonly #queries = new Map<string, QueryRecord>();
|
||||
readonly #reports = new Map<string, { root: string; formats: Set<Format> }>();
|
||||
public constructor(private readonly options: MinisReportOptions) {}
|
||||
|
||||
public recordQuery(args: Record<string, unknown>, result: unknown): string {
|
||||
const queryId = `query_${randomUUID()}`;
|
||||
this.#queries.set(queryId, { queryId, requestedAt: new Date().toISOString(), arguments: structuredClone(args), result: structuredClone(result) });
|
||||
return queryId;
|
||||
}
|
||||
|
||||
public async generate(input: ReportInput, signal?: AbortSignal) {
|
||||
if (!input.source_query_ids.length || input.source_query_ids.some((id) => !this.#queries.has(id))) {
|
||||
throw new HarnessError("REPORT_SOURCE_REQUIRED", "Report requires successful swads query IDs from this Session; never use example data");
|
||||
}
|
||||
const { data } = input;
|
||||
for (const key of ["title", "report_type", "period", "comparison_period", "currency", "timezone", "data_status", "conclusion"]) {
|
||||
if (typeof data[key] !== "string" || !data[key].trim()) throw new HarnessError("INVALID_REPORT", `Report requires ${key}`);
|
||||
}
|
||||
if (!["daily", "weekly"].includes(String(data.report_type)) || !Array.isArray(data.cards) || !Array.isArray(data.issues)) throw new HarnessError("INVALID_REPORT", "Invalid report type, cards or issues");
|
||||
if (data.currency !== "USD") throw new HarnessError("REPORT_CURRENCY_UNSUPPORTED", "This renderer labels charts in USD. Keep other currencies separate and do not generate misleading USD charts.");
|
||||
const payload = JSON.stringify({ ...data, source_queries: input.source_query_ids.map((id) => this.#queries.get(id)) }, null, 2);
|
||||
if (Buffer.byteLength(payload) > 4_194_304) throw new HarnessError("REPORT_TOO_LARGE", "Report source data exceeds 4 MiB");
|
||||
const reportId = `rpt_${randomUUID()}`;
|
||||
// A fresh random directory avoids overwrites and untrusted symlink targets.
|
||||
const root = await mkdtemp(join(this.options.workspace.root, ".report-"));
|
||||
const jsonPath = join(root, "report.json");
|
||||
const htmlPath = join(root, "report.html");
|
||||
await writeFile(jsonPath, payload, { mode: 0o600, flag: "wx" });
|
||||
const source = await SafeWorkspace.create(this.options.skillRoot);
|
||||
const script = await source.resolveReadable("scripts/generate_report.py");
|
||||
const env = { PATH: process.env.PATH ?? "/usr/bin:/bin", LANG: "en_US.UTF-8", PYTHONDONTWRITEBYTECODE: "1", PYTHONNOUSERSITE: "1" };
|
||||
try {
|
||||
await exec(this.options.pythonExecutable ?? "python3", ["-I", script, jsonPath, htmlPath], {
|
||||
cwd: root, env, timeout: 30_000, maxBuffer: 65_536, ...(signal === undefined ? {} : { signal }),
|
||||
});
|
||||
await chmod(htmlPath, 0o600);
|
||||
} catch {
|
||||
throw new HarnessError("REPORT_RENDER_FAILED", "HTML generation failed. Check report JSON against the Skill schema and the configured Python runtime.");
|
||||
}
|
||||
const available = new Set<Format>(["json", "html"]);
|
||||
let pdfWarning: string | undefined;
|
||||
const pdfPath = join(root, "report.pdf");
|
||||
const profile = await mkdtemp(join(root, ".chrome-"));
|
||||
try {
|
||||
await exec(this.options.chromeExecutable ?? (process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : "chromium"), [
|
||||
"--headless", "--disable-gpu", "--disable-extensions", "--disable-background-networking", "--no-first-run",
|
||||
"--host-resolver-rules=MAP * ~NOTFOUND", "--no-pdf-header-footer", `--user-data-dir=${profile}`, `--print-to-pdf=${pdfPath}`, pathToFileURL(htmlPath).href,
|
||||
], { cwd: root, env, timeout: 60_000, maxBuffer: 65_536, ...(signal === undefined ? {} : { signal }) });
|
||||
const pdf = await readFile(pdfPath);
|
||||
if (!pdf.subarray(0, 5).equals(Buffer.from("%PDF-")) || pdf.byteLength < 1_000 || !pdf.includes(Buffer.from("%%EOF"))) throw new Error("Invalid PDF output");
|
||||
await chmod(pdfPath, 0o600);
|
||||
available.add("pdf");
|
||||
} catch {
|
||||
pdfWarning = "PDF 生成失败:请检查 Chrome/Chromium 安装与运行权限;HTML 和 JSON 已保留。";
|
||||
} finally {
|
||||
await rm(profile, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
if (signal?.aborted) throw new HarnessError("REPORT_CANCELLED", "Report generation was cancelled");
|
||||
this.#reports.set(reportId, { root, formats: available });
|
||||
return {
|
||||
reportId, title: data.title, pdfStatus: available.has("pdf") ? "ready" : "failed",
|
||||
...(pdfWarning === undefined ? {} : { warning: pdfWarning }),
|
||||
files: await Promise.all([...available].map(async (format) => ({
|
||||
format, url: `/api/sessions/${this.options.sessionId}/reports/${reportId}/${format}`,
|
||||
byteSize: (await stat(join(root, `report.${format}`))).size,
|
||||
}))),
|
||||
};
|
||||
}
|
||||
|
||||
public async read(reportId: string, format: string): Promise<{ bytes: Buffer; contentType: string }> {
|
||||
const report = this.#reports.get(reportId);
|
||||
if (report === undefined || !Object.hasOwn(formats, format) || !report.formats.has(format as Format)) throw new HarnessError("REPORT_NOT_FOUND", "Report file not found");
|
||||
const file = await this.options.workspace.resolveReadable(join(report.root, `report.${format}`));
|
||||
return { bytes: await readFile(file), contentType: formats[format as Format] };
|
||||
}
|
||||
}
|
||||
|
||||
export function createMinisReportTool(service: MinisReportService) {
|
||||
return defineTool({
|
||||
name: "generate_minis_report", label: "Drama Minis HTML / PDF",
|
||||
description: "Generate HTML + A4 PDF + editable JSON using the installed Drama Minis Skill renderer. Read the Skill, references and example JSON structure first, NEVER reuse example values. Pass source_query_ids returned by successful REAL metrics_semantic_query calls in this Session, and freshly normalized report data (title, report_type daily/weekly, period, comparison_period, currency USD, timezone, data_status, conclusion, cards, issues, funnel, countries, country_revenue, country_funnels; weekly trend). Preserve missing values and source/account coverage in footnote. Requires page approval. Only declare PDF success when pdfStatus is ready. This replaces shell execution for the Skill report script.",
|
||||
parameters: Type.Object({ source_query_ids: Type.Array(Type.String(), { minItems: 1 }), data: Type.Record(Type.String(), Type.Unknown()) }, { additionalProperties: false }),
|
||||
executionMode: "sequential",
|
||||
async execute(_id, params, signal) {
|
||||
const details = await service.generate(params, signal);
|
||||
return { content: [{ type: "text" as const, text: JSON.stringify(details) }], details };
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user