fix(directory-templates): match article-generator's image-fallback behavior

The article-generator path has worked for images all along because it has a
fallback: when the [IMAGE N: …] regex doesn't replace anything but Perplexity
returned images, it still surfaces them as a `## Images` section. The
directory-template flow had no such fallback — if the regex missed for any
reason (model emitted markers in an unexpected shape, model didn't emit
markers at all), the images dropped silently.

Three changes:

1. processContentWithImages now returns { content, replaced } so the caller
   can branch on whether any markers actually got replaced.

2. New buildFallbackImagesSection emits a `# Images` block (heading +
   embed + source line) appended just before the sources footer when
   replaced === 0 && images.length > 0.

3. Permissive regex: also matches `[Image N: …]` (case-insensitive was
   already there) plus the markdown-image-shaped `![IMAGE N](…)` and
   `[IMAGE N](…)` forms that some models emit when they try to anticipate
   the embed.

Diagnostics: console.debug logs the images.length / replaced count / model
on every run so the failure mode is observable in dev tools. Notice fires
when the fallback inserts images so the user knows what happened.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mpstaton 2026-05-09 21:49:49 -05:00
parent ac29da3bc3
commit 223a9a080e

View file

@ -68,17 +68,38 @@ function wrapThinkBlocks(text: string): string {
});
}
function processContentWithImages(content: string, images: PerplexityImage[]): string {
if (!images || images.length === 0) return content;
const imageRegex = /\[IMAGE\s+(\d+):\s*([^\]]*?)\]/gi;
return content.replace(imageRegex, (match, numStr: string, desc: string): string => {
function processContentWithImages(
content: string,
images: PerplexityImage[],
): { content: string; replaced: number } {
if (!images || images.length === 0) return { content, replaced: 0 };
// Permissive regex: matches `[IMAGE N: desc]`, `[Image N: desc]`, also
// tolerates the markdown-image-shaped `![IMAGE N](...)` and `[IMAGE N](...)`
// forms some models emit when they try to anticipate the embed.
const imageRegex = /!?\[IMAGE\s+(\d+)(?::\s*([^\]]*?))?\](?:\([^)]*\))?/gi;
let replaced = 0;
const next = content.replace(imageRegex, (match, numStr: string, desc?: string): string => {
const idx = parseInt(numStr, 10) - 1;
if (isNaN(idx) || idx < 0 || idx >= images.length) return match;
const img = images[idx];
if (!img?.image_url) return match;
const cleanDesc = (desc ?? '').trim() || 'Image';
const cleanDesc = (desc ?? '').trim() || `Image ${(idx + 1).toString()}`;
replaced++;
return `![${cleanDesc}](${img.image_url})`;
});
return { content: next, replaced };
}
function buildFallbackImagesSection(images: PerplexityImage[]): string {
if (!images || images.length === 0) return '';
const lines = ['', '# Images', ''];
images.forEach((img, i) => {
if (!img.image_url) return;
lines.push(`![Image ${(i + 1).toString()}](${img.image_url})`);
if (img.origin_url) lines.push(`_Source: ${img.origin_url}_`);
lines.push('');
});
return lines.join('\n');
}
function stripUnreplacedImagePlaceholders(content: string): string {
@ -515,33 +536,38 @@ export async function applyTemplate(
const runModelLabel = `${providerLabel} ${modelName}`.trim();
// Post-write cleanup: wrap <think> blocks, swap [IMAGE N: …] markers for
// real embeds, strip any unreplaced placeholder bullets, append sources
// footer.
// real embeds (fall back to an Images section when markers don't match
// but images came back), strip any unreplaced placeholder bullets,
// append sources footer.
const trimmedStreamed = streamed.replace(/^\s+/, '').replace(/\s+$/, '');
let cleanedStreamed = wrapThinkBlocks(trimmedStreamed);
let fallbackImagesSection = '';
if (wantsImages) {
const markerRe = /\[IMAGE\s+\d+:\s*[^\]]*?\]/gi;
const markersBefore = (cleanedStreamed.match(markerRe) ?? []).length;
cleanedStreamed = processContentWithImages(cleanedStreamed, images);
const result = processContentWithImages(cleanedStreamed, images);
cleanedStreamed = result.content;
cleanedStreamed = stripUnreplacedImagePlaceholders(cleanedStreamed);
const markersAfter = (cleanedStreamed.match(markerRe) ?? []).length;
// Diagnostics: when the model emitted markers but Perplexity returned
// no images (common on sonar-deep-research), surface so the user knows
// why image embeds didn't appear. console keeps the detail; Notice is
// skipped in batch (quiet) mode.
if (markersBefore > 0 && markersAfter > 0) {
console.warn(
`[directoryTemplateService] ${markersAfter.toString()} of ${markersBefore.toString()} [IMAGE N: …] markers were not replaced. images.length=${images.length.toString()}, model=${modelName}.`
);
console.debug(
`[directoryTemplateService] images.length=${images.length.toString()}, markers replaced=${result.replaced.toString()}, model=${modelName}`,
);
// Mirror the article-generator fallback: if Perplexity returned
// images but no [IMAGE N: …] markers were replaced (model didn't
// emit them, or emitted them in a shape the regex missed), still
// surface the images as a section so they don't vanish silently.
if (result.replaced === 0 && images.length > 0) {
fallbackImagesSection = buildFallbackImagesSection(images);
if (!quiet) {
new Notice(
`${markersAfter.toString()} image markers left unreplaced — Perplexity returned ${images.length.toString()} image(s).`
`Inserted ${images.length.toString()} image${images.length === 1 ? '' : 's'} as a fallback section — model didn't emit [IMAGE N: …] markers.`,
);
}
} else if (result.replaced === 0 && images.length === 0) {
console.warn(
`[directoryTemplateService] return-images is true but Perplexity returned no images. model=${modelName}. Likely an API limitation for this model on this query.`,
);
}
}
const sourcesFooter = buildSourcesFooter(sources);
const finalContent = `${initialContent}${cleanedStreamed}\n${sourcesFooter}`;
const finalContent = `${initialContent}${cleanedStreamed}\n${fallbackImagesSection}${sourcesFooter}`;
await app.vault.modify(target, finalContent);
// Stamp run metadata in the target's frontmatter so files can be