dist-mcp / mcp / resultMaterializer.js
dist-mcp / mcp / resultMaterializer.js
import fs from "node:fs/promises";
import path from "node:path";
import { escapeHtml, readMediaState, renderReportShell } from "./core-bundle.mjs";
async function fileExists(absolutePath) {
try {
await fs.access(absolutePath);
return true;
}
catch {
return false;
}
}
/** Snapshot of existing iN indices, taken before handleGenerateImage/handleUpscale runs. */
export async function snapshotImageIndices(scratchpadPath) {
const state = await readMediaState(scratchpadPath);
const indices = state.images.map((record) => record.i).filter((i) => typeof i === "number");
return new Set(indices);
}
/**
* Diffs chat_media_state.json's images list against a before-snapshot to find the iN
* entries handleGenerateImage/handleUpscale just appended via appendImages() — this
* adapter never registers records itself (see planning/generate-image-mcp-plan.md,
* "Ergebnis-Materialisierung").
*/
export async function materializeNewImages(scratchpadPath, before) {
const state = await readMediaState(scratchpadPath);
const newRecords = state.images.filter((record) => typeof record.i === "number" && !before.has(record.i));
const results = [];
for (const record of newRecords) {
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);
}
/** Same compact "<b>Label:</b> value • ..." single-line style as find-images-mcp-bridge's buildMetadataCell(). */
function metadataCell(context, result) {
const parts = [`<span class="rank">${escapeHtml(result.notation)}</span>`];
const add = (label, value) => {
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(" • ");
}
/**
* 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). This is the only place that JSON lives — there is no
* separate structured field for it.
*/
export function extractSummary(result) {
const content = result?.content;
if (!Array.isArray(content) || content.length === 0)
return undefined;
const lastItem = content[content.length - 1];
if (lastItem?.type !== "text" || typeof lastItem.text !== "string")
return undefined;
try {
const parsed = JSON.parse(lastItem.text);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
}
catch {
return undefined;
}
}
/** Writes `generate-<requestId>.html` into the scratchpad and returns its absolute path. */
export async function writeHtmlReport(scratchpadPath, requestId, context, results) {
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, `generate-${requestId}.html`);
await fs.writeFile(reportPath, html, "utf8");
return reportPath;
}
import fs from "node:fs/promises";
import path from "node:path";
import { escapeHtml, readMediaState, renderReportShell } from "./core-bundle.mjs";
async function fileExists(absolutePath) {
try {
await fs.access(absolutePath);
return true;
}
catch {
return false;
}
}
/** Snapshot of existing iN indices, taken before handleGenerateImage/handleUpscale runs. */
export async function snapshotImageIndices(scratchpadPath) {
const state = await readMediaState(scratchpadPath);
const indices = state.images.map((record) => record.i).filter((i) => typeof i === "number");
return new Set(indices);
}
/**
* Diffs chat_media_state.json's images list against a before-snapshot to find the iN
* entries handleGenerateImage/handleUpscale just appended via appendImages() — this
* adapter never registers records itself (see planning/generate-image-mcp-plan.md,
* "Ergebnis-Materialisierung").
*/
export async function materializeNewImages(scratchpadPath, before) {
const state = await readMediaState(scratchpadPath);
const newRecords = state.images.filter((record) => typeof record.i === "number" && !before.has(record.i));
const results = [];
for (const record of newRecords) {
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);
}
/** Same compact "<b>Label:</b> value • ..." single-line style as find-images-mcp-bridge's buildMetadataCell(). */
function metadataCell(context, result) {
const parts = [`<span class="rank">${escapeHtml(result.notation)}</span>`];
const add = (label, value) => {
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(" • ");
}
/**
* 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). This is the only place that JSON lives — there is no
* separate structured field for it.
*/
export function extractSummary(result) {
const content = result?.content;
if (!Array.isArray(content) || content.length === 0)
return undefined;
const lastItem = content[content.length - 1];
if (lastItem?.type !== "text" || typeof lastItem.text !== "string")
return undefined;
try {
const parsed = JSON.parse(lastItem.text);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : undefined;
}
catch {
return undefined;
}
}
/** Writes `generate-<requestId>.html` into the scratchpad and returns its absolute path. */
export async function writeHtmlReport(scratchpadPath, requestId, context, results) {
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, `generate-${requestId}.html`);
await fs.writeFile(reportPath, html, "utf8");
return reportPath;
}