feat(directory-templates): port [IMAGE N: ...] marker pattern from article generator

Lifts the existing image-placement pattern from PerplexityService into the
directory-template streaming flow:

- Capture `images` from the SSE stream alongside `search_results`
- When the cft block sets `return-images: true`, append an image-placement
  directive to the user prompt instructing the model to emit
  [IMAGE N: <description>] markers (replacing any "Image embed placeholder"
  bullets in the skeleton)
- Post-stream, swap each marker for ![desc](image_url) using the captured
  images array; strip any unreplaced placeholder bullets

Concept-profile flips return-images to true to benefit from this. Vocabulary
already had it on. The "Find images for selection" command remains as a
manual fallback.

This is the same pattern Generate One-Page Article has been using. Avoids
new infrastructure (no URL-path ranking, no multimodal re-rank, no headless
screenshots) — see context-v/issues/Nudgeing-AI-Search-to-Return-Contextually-Appriate-Images.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mpstaton 2026-05-09 20:02:04 -05:00
parent 6f51302664
commit 80abed585b
2 changed files with 48 additions and 9 deletions

View file

@ -16,7 +16,7 @@ # About this template
model: sonar-deep-research
search-recency: year
return-citations: true
return-images: false
return-images: true
system: |
You are a research analyst writing an encyclopedia-style entry for the
concept named "{{basename}}". Use Perplexity's web search aggressively for

View file

@ -41,8 +41,15 @@ export interface PerplexitySource {
last_updated?: string;
}
export interface PerplexityImage {
image_url?: string;
origin_url?: string;
}
const INLINE_CITATION_DIRECTIVE = "When you make any factual claim that comes from a web search result, append a numeric citation marker like [1], [2], etc. immediately after the claim. The numbers MUST correspond 1:1 to the order of the search results returned by the search tool (first result = [1], second = [2], and so on). You may cite the same source multiple times. Do not list the sources at the end — only inline markers. Do not invent sources; only cite results actually used.";
const IMAGE_PLACEMENT_DIRECTIVE = "IMAGE PLACEMENT — where an image would clarify or illustrate a section, insert a marker of the form [IMAGE N: <specific description>] on its own line. Numbering starts at 1 and increments globally across all images. Descriptions must be specific (e.g., \"ZAPI dashboard showing API discovery interface\"), never generic. If the section skeleton already contains an image-placeholder bullet (any line containing the phrase \"Image embed placeholder\"), REPLACE that bullet entirely with a [IMAGE N: ...] marker — do not leave the placeholder text in your output. Limit to 2-4 markers across the whole response. Markers will be swapped for real image embeds in post-processing.";
function buildResearchFraming(title: string, fmYaml: string): string {
return `Research the entity "${title}" using web search. Use the metadata below as context, then produce a structured profile that follows the markdown skeleton at the end of this prompt. Every factual claim in your output must be immediately followed by an inline [N] citation marker corresponding to a returned search result. Quote phrasing from sources where useful.
@ -61,6 +68,23 @@ 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 => {
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';
return `![${cleanDesc}](${img.image_url})`;
});
}
function stripUnreplacedImagePlaceholders(content: string): string {
return content.replace(/^.*Image embed placeholder.*$\n?/gim, '');
}
function buildSourcesFooter(
sources: PerplexitySource[],
runTimestamp: string,
@ -293,7 +317,7 @@ async function streamPerplexityToFile(
file: TFile,
initialContent: string,
isCancelled: () => boolean,
): Promise<{ streamed: string; sources: PerplexitySource[] }> {
): Promise<{ streamed: string; sources: PerplexitySource[]; images: PerplexityImage[] }> {
payload.stream = true;
const controller = new AbortController();
const timer = activeWindow.setTimeout(() => controller.abort(), timeoutMs);
@ -332,6 +356,7 @@ async function streamPerplexityToFile(
let sseBuffer = '';
let streamed = '';
let sources: PerplexitySource[] = [];
let images: PerplexityImage[] = [];
let lastFlush = 0;
const FLUSH_MS = 500;
@ -370,6 +395,11 @@ async function streamPerplexityToFile(
sources = sr.filter((x): x is PerplexitySource =>
typeof x === 'object' && x !== null);
}
const imgs = obj['images'];
if (Array.isArray(imgs)) {
images = imgs.filter((x): x is PerplexityImage =>
typeof x === 'object' && x !== null);
}
} catch {
// partial JSON; skip
}
@ -393,7 +423,7 @@ async function streamPerplexityToFile(
// Final flush of raw stream content before post-processing
await app.vault.modify(file, initialContent + streamed);
return { streamed, sources };
return { streamed, sources, images };
}
export type ApplyOutcome =
@ -449,8 +479,12 @@ export async function applyTemplate(
: INLINE_CITATION_DIRECTIVE;
// User prompt: research framing prepended to the skeleton so the model treats this as a
// research task, not a writing brief.
const userPrompt = buildResearchFraming(title, fmYaml) + interpolatedSkeleton;
// research task, not a writing brief. When the template requests images, append the
// image-placement directive so the model emits [IMAGE N: …] markers we can swap for
// real embeds post-stream.
const wantsImages = template.cftConfig['return-images'] === true;
const imageDirective = wantsImages ? `\n\n${IMAGE_PLACEMENT_DIRECTIVE}` : '';
const userPrompt = buildResearchFraming(title, fmYaml) + interpolatedSkeleton + imageDirective;
// Initial file content the stream will append to.
const fmBlock = fmRaw.length > 0 ? `---\n${fmRaw}\n---\n` : '';
@ -468,7 +502,7 @@ export async function applyTemplate(
// Set initial state before streaming begins.
await app.vault.modify(target, initialContent);
const { streamed, sources } = await streamPerplexityToFile(
const { streamed, sources, images } = await streamPerplexityToFile(
app,
settings.perplexityApiKey,
settings.perplexityEndpoint,
@ -493,10 +527,15 @@ export async function applyTemplate(
const runTimestamp = new Date().toISOString();
const runModelLabel = `${providerLabel} ${modelName}`.trim();
// Post-write cleanup: wrap <think> blocks, append sources footer with
// provenance line.
// Post-write cleanup: wrap <think> blocks, swap [IMAGE N: …] markers for
// real embeds, strip any unreplaced placeholder bullets, append sources
// footer with provenance line.
const trimmedStreamed = streamed.replace(/^\s+/, '').replace(/\s+$/, '');
const cleanedStreamed = wrapThinkBlocks(trimmedStreamed);
let cleanedStreamed = wrapThinkBlocks(trimmedStreamed);
if (wantsImages) {
cleanedStreamed = processContentWithImages(cleanedStreamed, images);
cleanedStreamed = stripUnreplacedImagePlaceholders(cleanedStreamed);
}
const sourcesFooter = buildSourcesFooter(sources, runTimestamp, runModelLabel);
const finalContent = `${initialContent}${cleanedStreamed}\n${sourcesFooter}`;
await app.vault.modify(target, finalContent);