src / mcp / resultMaterializer.ts
src / mcp / resultMaterializer.ts
import fs from "node:fs/promises";
import path from "node:path";
import { escapeHtml, renderReportShell } from "./core-bundle.mjs";
import type { MaterializedResult } from "./core-bundle.mjs";
export { snapshotImageIndices, materializeNewImages, extractSummary, extractSummaryText } from "./core-bundle.mjs";
export type { MaterializedResult } from "./core-bundle.mjs";
export type ReportContext = {
tool: "detect_object" | "annotate_image";
task?: string;
source?: string;
/** The tool's own returned summary object (last JSON text item of its content array) — the single source for everything shown here. */
summary?: Record<string, unknown>;
};
/** Same compact "<b>Label:</b> value • ..." single-line style as find-images-mcp-bridge's buildMetadataCell(). */
function metadataCell(context: ReportContext, result: MaterializedResult): string {
const parts = [`<span class="rank">${escapeHtml(result.notation)}</span>`];
const add = (label: string, value: unknown): void => {
if (value === undefined || value === null || value === "") return;
parts.push(`<strong>${escapeHtml(label)}:</strong> ${escapeHtml(String(value))}`);
};
const summary = context.summary ?? {};
// Shared summary shape (see core/tools.ts's handleDetectObject/handleAnnotateImage):
// { source, imageWidth, imageHeight, detections: [{label, bbox, ...}], ... }.
if (typeof summary.imageWidth === "number" && typeof summary.imageHeight === "number") add("Size", `${summary.imageWidth}\u00d7${summary.imageHeight}`);
const detections = summary.detections as Array<{ label?: string }> | undefined;
if (Array.isArray(detections)) {
add("Objects", detections.length);
const labels = [...new Set(detections.map((d) => d.label).filter((l): l is string => typeof l === "string" && l.length > 0))];
if (labels.length > 0) add("Labels", labels.join(", "));
}
if (context.tool === "annotate_image") {
add("Mode", summary.redraw === true ? "redraw" : "detect");
add("Color", summary.color);
}
add("Task", context.task);
add("Source", summary.source ?? context.source);
if (typeof summary.inferenceTimeMs === "number") add("Inference time", `${Math.round(summary.inferenceTimeMs)}ms`);
return parts.join(" • ");
}
/** Writes `<requestId>.html` into the scratchpad and returns its absolute path. */
export async function writeHtmlReport(scratchpadPath: string, requestId: string, context: ReportContext, results: MaterializedResult[]): Promise<string> {
const rows = results
.map((result) => {
const previewSrc = result.previewFilename ?? result.filename;
return `<tr><td><img src="${escapeHtml(previewSrc)}" alt="Preview ${escapeHtml(result.notation)}"></td><td>${metadataCell(context, result)}</td></tr>`;
})
.join("\n");
const html = renderReportShell(`${context.tool} ${requestId}`, "<tr><th>Preview</th><th>Details</th></tr>", rows);
const reportPath = path.join(scratchpadPath, `${requestId}.html`);
await fs.writeFile(reportPath, html, "utf8");
return reportPath;
}
import fs from "node:fs/promises";
import path from "node:path";
import { escapeHtml, renderReportShell } from "./core-bundle.mjs";
import type { MaterializedResult } from "./core-bundle.mjs";
export { snapshotImageIndices, materializeNewImages, extractSummary, extractSummaryText } from "./core-bundle.mjs";
export type { MaterializedResult } from "./core-bundle.mjs";
export type ReportContext = {
tool: "detect_object" | "annotate_image";
task?: string;
source?: string;
/** The tool's own returned summary object (last JSON text item of its content array) — the single source for everything shown here. */
summary?: Record<string, unknown>;
};
/** Same compact "<b>Label:</b> value • ..." single-line style as find-images-mcp-bridge's buildMetadataCell(). */
function metadataCell(context: ReportContext, result: MaterializedResult): string {
const parts = [`<span class="rank">${escapeHtml(result.notation)}</span>`];
const add = (label: string, value: unknown): void => {
if (value === undefined || value === null || value === "") return;
parts.push(`<strong>${escapeHtml(label)}:</strong> ${escapeHtml(String(value))}`);
};
const summary = context.summary ?? {};
// Shared summary shape (see core/tools.ts's handleDetectObject/handleAnnotateImage):
// { source, imageWidth, imageHeight, detections: [{label, bbox, ...}], ... }.
if (typeof summary.imageWidth === "number" && typeof summary.imageHeight === "number") add("Size", `${summary.imageWidth}\u00d7${summary.imageHeight}`);
const detections = summary.detections as Array<{ label?: string }> | undefined;
if (Array.isArray(detections)) {
add("Objects", detections.length);
const labels = [...new Set(detections.map((d) => d.label).filter((l): l is string => typeof l === "string" && l.length > 0))];
if (labels.length > 0) add("Labels", labels.join(", "));
}
if (context.tool === "annotate_image") {
add("Mode", summary.redraw === true ? "redraw" : "detect");
add("Color", summary.color);
}
add("Task", context.task);
add("Source", summary.source ?? context.source);
if (typeof summary.inferenceTimeMs === "number") add("Inference time", `${Math.round(summary.inferenceTimeMs)}ms`);
return parts.join(" • ");
}
/** Writes `<requestId>.html` into the scratchpad and returns its absolute path. */
export async function writeHtmlReport(scratchpadPath: string, requestId: string, context: ReportContext, results: MaterializedResult[]): Promise<string> {
const rows = results
.map((result) => {
const previewSrc = result.previewFilename ?? result.filename;
return `<tr><td><img src="${escapeHtml(previewSrc)}" alt="Preview ${escapeHtml(result.notation)}"></td><td>${metadataCell(context, result)}</td></tr>`;
})
.join("\n");
const html = renderReportShell(`${context.tool} ${requestId}`, "<tr><th>Preview</th><th>Details</th></tr>", rows);
const reportPath = path.join(scratchpadPath, `${requestId}.html`);
await fs.writeFile(reportPath, html, "utf8");
return reportPath;
}