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";
export type MaterializedResult = {
index: number;
notation: string;
filename: string;
absolutePath: string;
previewFilename?: string;
isVideo: boolean;
videoFilename?: string;
};
async function fileExists(absolutePath: string): Promise<boolean> {
try {
await fs.access(absolutePath);
return true;
} catch {
return false;
}
}
/** An iN entry handleGenerateImage/handleUpscale registered in chat_media_state.json (`registeredImages` in their result). */
export type RegisteredImage = { i: number; filename?: string; preview?: string };
/**
* Turns the entries a call reports in its own result into materialized results. Taken from the
* call's return value rather than diffed from chat_media_state.json, so concurrent jobs in one
* scratchpad never pick up each other's images. This adapter never registers records itself.
*/
export async function materializeRegisteredImages(scratchpadPath: string, records: RegisteredImage[]): Promise<MaterializedResult[]> {
const results: MaterializedResult[] = [];
for (const record of records) {
const index = record.i;
const filename = record.filename ?? "";
const absolutePath = path.join(scratchpadPath, filename);
// Video results register the last-frame PNG as the iN record; the actual .mov shares its basename (see core/tools.ts).
const videoFilename = filename.replace(/\.(png|jpe?g|webp)$/i, ".mov");
const isVideo = videoFilename !== filename && (await fileExists(path.join(scratchpadPath, videoFilename)));
results.push({
index,
notation: `i${index}`,
filename,
absolutePath,
previewFilename: record.preview,
isVideo,
videoFilename: isVideo ? videoFilename : undefined,
});
}
return results.sort((a, b) => a.index - b.index);
}
/**
* handleGenerateImage/handleUpscale return their summary (width, model_used, mode_effective,
* quality, steps, canvas_source, inference_time_ms, ...) as the LAST text content item,
* JSON-stringified (see core/tools.ts). Returned verbatim (not re-serialized) so callers can
* forward it 1:1. Used by the MCP_MADE_FOR_BIONIC=false (generic) path.
*/
export function extractSummaryText(result: unknown): string | undefined {
const content = (result as { content?: unknown } | undefined)?.content;
if (!Array.isArray(content) || content.length === 0) return undefined;
const lastItem = content[content.length - 1] as { type?: string; text?: unknown } | undefined;
return lastItem?.type === "text" && typeof lastItem.text === "string" ? lastItem.text : undefined;
}
/** Same as extractSummaryText(), but parsed into an object — used by the Bionic-specific (MCP_MADE_FOR_BIONIC=true, the default) HTML report path. */
export function extractSummary(result: unknown): Record<string, unknown> | undefined {
const text = extractSummaryText(result);
if (text === undefined) return undefined;
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
} catch {
return undefined;
}
}
export type ReportContext = {
tool: "generate_image" | "upscale";
prompt?: string;
canvas?: 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 ?? {};
add("Prompt", context.prompt);
const width = summary.width;
const height = summary.height;
if (typeof width === "number" && typeof height === "number") add("Size", `${width}\u00d7${height}`);
add("Model", summary.model_used);
add("Mode", summary.mode_effective);
add("Quality", summary.quality);
add("Steps", summary.steps);
add("Format", summary.image_format);
add("Source", summary.canvas_source ?? context.canvas);
if (typeof summary.inference_time_ms === "number") add("Render time", `${(summary.inference_time_ms / 1000).toFixed(1)}s`);
add("Origin", "Draw Things");
if (result.isVideo && result.videoFilename) add("Video", result.videoFilename);
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;
const preview = result.isVideo
? `<a href="${escapeHtml(result.videoFilename ?? result.filename)}"><img src="${escapeHtml(previewSrc)}" alt="Preview ${escapeHtml(result.notation)}"></a>`
: `<img src="${escapeHtml(previewSrc)}" alt="Preview ${escapeHtml(result.notation)}">`;
return `<tr><td>${preview}</td><td>${metadataCell(context, result)}</td></tr>`;
})
.join("\n");
const html = renderReportShell(`Generate Image ${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";
export type MaterializedResult = {
index: number;
notation: string;
filename: string;
absolutePath: string;
previewFilename?: string;
isVideo: boolean;
videoFilename?: string;
};
async function fileExists(absolutePath: string): Promise<boolean> {
try {
await fs.access(absolutePath);
return true;
} catch {
return false;
}
}
/** An iN entry handleGenerateImage/handleUpscale registered in chat_media_state.json (`registeredImages` in their result). */
export type RegisteredImage = { i: number; filename?: string; preview?: string };
/**
* Turns the entries a call reports in its own result into materialized results. Taken from the
* call's return value rather than diffed from chat_media_state.json, so concurrent jobs in one
* scratchpad never pick up each other's images. This adapter never registers records itself.
*/
export async function materializeRegisteredImages(scratchpadPath: string, records: RegisteredImage[]): Promise<MaterializedResult[]> {
const results: MaterializedResult[] = [];
for (const record of records) {
const index = record.i;
const filename = record.filename ?? "";
const absolutePath = path.join(scratchpadPath, filename);
// Video results register the last-frame PNG as the iN record; the actual .mov shares its basename (see core/tools.ts).
const videoFilename = filename.replace(/\.(png|jpe?g|webp)$/i, ".mov");
const isVideo = videoFilename !== filename && (await fileExists(path.join(scratchpadPath, videoFilename)));
results.push({
index,
notation: `i${index}`,
filename,
absolutePath,
previewFilename: record.preview,
isVideo,
videoFilename: isVideo ? videoFilename : undefined,
});
}
return results.sort((a, b) => a.index - b.index);
}
/**
* handleGenerateImage/handleUpscale return their summary (width, model_used, mode_effective,
* quality, steps, canvas_source, inference_time_ms, ...) as the LAST text content item,
* JSON-stringified (see core/tools.ts). Returned verbatim (not re-serialized) so callers can
* forward it 1:1. Used by the MCP_MADE_FOR_BIONIC=false (generic) path.
*/
export function extractSummaryText(result: unknown): string | undefined {
const content = (result as { content?: unknown } | undefined)?.content;
if (!Array.isArray(content) || content.length === 0) return undefined;
const lastItem = content[content.length - 1] as { type?: string; text?: unknown } | undefined;
return lastItem?.type === "text" && typeof lastItem.text === "string" ? lastItem.text : undefined;
}
/** Same as extractSummaryText(), but parsed into an object — used by the Bionic-specific (MCP_MADE_FOR_BIONIC=true, the default) HTML report path. */
export function extractSummary(result: unknown): Record<string, unknown> | undefined {
const text = extractSummaryText(result);
if (text === undefined) return undefined;
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
} catch {
return undefined;
}
}
export type ReportContext = {
tool: "generate_image" | "upscale";
prompt?: string;
canvas?: 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 ?? {};
add("Prompt", context.prompt);
const width = summary.width;
const height = summary.height;
if (typeof width === "number" && typeof height === "number") add("Size", `${width}\u00d7${height}`);
add("Model", summary.model_used);
add("Mode", summary.mode_effective);
add("Quality", summary.quality);
add("Steps", summary.steps);
add("Format", summary.image_format);
add("Source", summary.canvas_source ?? context.canvas);
if (typeof summary.inference_time_ms === "number") add("Render time", `${(summary.inference_time_ms / 1000).toFixed(1)}s`);
add("Origin", "Draw Things");
if (result.isVideo && result.videoFilename) add("Video", result.videoFilename);
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;
const preview = result.isVideo
? `<a href="${escapeHtml(result.videoFilename ?? result.filename)}"><img src="${escapeHtml(previewSrc)}" alt="Preview ${escapeHtml(result.notation)}"></a>`
: `<img src="${escapeHtml(previewSrc)}" alt="Preview ${escapeHtml(result.notation)}">`;
return `<tr><td>${preview}</td><td>${metadataCell(context, result)}</td></tr>`;
})
.join("\n");
const html = renderReportShell(`Generate Image ${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;
}