diff --git a/main.ts b/main.ts index d78fbd1..1389bd2 100644 --- a/main.ts +++ b/main.ts @@ -31,6 +31,8 @@ import { } from './src/services/directoryTemplateService'; import type { DirectoryTemplateSettings, ParsedTemplate } from './src/services/directoryTemplateService'; import type { TFile } from 'obsidian'; +import { findImagesForSelection } from './src/services/findImagesService'; +import type { FindImagesSettings } from './src/services/findImagesService'; interface PerplexedPluginSettings { @@ -87,6 +89,9 @@ interface PerplexedPluginSettings { directoryTemplatesRoot: string; directoryTemplatesFrontmatterWhitelist: string[]; directoryTemplatesRequestTimeoutMs: number; + + // Find images for selection + findImagesMaxImages: number; } const DEFAULT_SETTINGS: PerplexedPluginSettings = { @@ -299,7 +304,10 @@ Structure the article as follows: // Directory templates (v0.1 spike defaults) directoryTemplatesRoot: 'zz-cf-lib/templates', directoryTemplatesFrontmatterWhitelist: ['title', 'og_description', 'tags', 'og_image'], - directoryTemplatesRequestTimeoutMs: 300000 + directoryTemplatesRequestTimeoutMs: 300000, + + // Find images for selection + findImagesMaxImages: 3 }; export default class PerplexedPlugin extends Plugin { @@ -511,6 +519,27 @@ export default class PerplexedPlugin extends Plugin { new Notice('Stop requested — finishing current file then halting.'); } }); + + // Find images for the current selection — anchors search on the + // selection's content + the active file's url/site_name frontmatter + // and distributes returned images between paragraphs. + this.addCommand({ + id: 'find-images-for-selection', + name: 'Find images for selection', + editorCallback: (editor: Editor) => { + const file = this.app.workspace.getActiveFile(); + if (!file) { + new Notice('No active file.'); + return; + } + const findSettings: FindImagesSettings = { + perplexityApiKey: this.settings.perplexityApiKey, + perplexityEndpoint: this.settings.perplexityEndpoint, + maxImages: this.settings.findImagesMaxImages, + }; + void findImagesForSelection(this.app, findSettings, file, editor); + } + }); console.debug('Perplexed Plugin: Initialization completed successfully'); new Notice('Perplexed plugin loaded successfully'); @@ -1766,5 +1795,27 @@ class PerplexedSettingTab extends PluginSettingTab { } }) ); + + // Find images for selection + new Setting(containerEl).setName('Find images for selection').setHeading(); + containerEl.createEl('p', { + text: 'Highlight a passage, run "find images for selection". The plugin asks Perplexity for screenshots that visually illustrate the passage, prefers images on the entity\'s domain (frontmatter URL), and embeds them between paragraphs.', + cls: 'setting-item-description' + }); + + new Setting(containerEl) + .setName('Max images') + .setDesc('Maximum number of images to embed per invocation.') + .addText(text => text + .setPlaceholder('3') + .setValue(String(this.plugin.settings.findImagesMaxImages)) + .onChange(async (value: string) => { + const n = parseInt(value, 10); + if (!isNaN(n) && n > 0) { + this.plugin.settings.findImagesMaxImages = n; + await this.plugin.saveSettings(); + } + }) + ); } } diff --git a/src/services/findImagesService.ts b/src/services/findImagesService.ts new file mode 100644 index 0000000..506b5ff --- /dev/null +++ b/src/services/findImagesService.ts @@ -0,0 +1,233 @@ +import type { App, Editor, TFile } from 'obsidian'; +import { Notice, parseYaml, request } from 'obsidian'; + +export interface FindImagesSettings { + perplexityApiKey: string; + perplexityEndpoint: string; + maxImages: number; +} + +interface PerplexityImage { + image_url?: string; + origin_url?: string; +} + +interface PerplexityResponse { + choices?: { message?: { content?: string } }[]; + images?: PerplexityImage[]; +} + +function extractDomain(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return ''; + } +} + +function readFrontmatter(content: string): Record { + const lines = content.replace(/\r\n/g, '\n').split('\n'); + if (lines[0] !== '---') return {}; + let i = 1; + while (i < lines.length && lines[i] !== '---') i++; + if (i >= lines.length) return {}; + const yaml = lines.slice(1, i).join('\n'); + try { + const parsed: unknown = parseYaml(yaml); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + // ignore + } + return {}; +} + +function splitParagraphs(text: string): string[] { + return text + .replace(/\r\n/g, '\n') + .split(/\n\s*\n+/) + .map(p => p.trim()) + .filter(p => p.length > 0); +} + +interface ImagePlacement { + paragraphIndex: number; + description: string; + imageNumber: number; +} + +function parsePlacementMarkers(text: string): ImagePlacement[] { + const re = /\[AFTER\s+(\d+)\]\s*\[IMAGE\s+(\d+):\s*([^\]]+)\]/gi; + const out: ImagePlacement[] = []; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const para = parseInt(m[1] ?? '', 10); + const num = parseInt(m[2] ?? '', 10); + const desc = (m[3] ?? '').trim(); + if (!isNaN(para) && !isNaN(num) && desc) { + out.push({ paragraphIndex: para, description: desc, imageNumber: num }); + } + } + return out; +} + +export async function findImagesForSelection( + app: App, + settings: FindImagesSettings, + activeFile: TFile, + editor: Editor, +): Promise { + const selection = editor.getSelection().trim(); + if (!selection) { + new Notice('Select text first to anchor image search.'); + return; + } + if (!settings.perplexityApiKey) { + new Notice('Perplexity API key is not set. Configure it in perplexed settings.'); + return; + } + + const paragraphs = splitParagraphs(selection); + if (paragraphs.length === 0) { + new Notice('Selection has no paragraphs.'); + return; + } + + const fileContent = await app.vault.cachedRead(activeFile); + const fm = readFrontmatter(fileContent); + const entityName = typeof fm['site_name'] === 'string' + ? fm['site_name'] + : typeof fm['title'] === 'string' + ? fm['title'] + : activeFile.basename; + const entityUrl = typeof fm['url'] === 'string' ? fm['url'] : ''; + const entityDomain = extractDomain(entityUrl); + + const N = Math.max(1, settings.maxImages); + const numberedParagraphs = paragraphs.map((p, i) => `[${(i + 1).toString()}] ${p}`).join('\n\n'); + + const prompt = `You are a visual editor. The passage below is divided into ${paragraphs.length.toString()} numbered paragraphs. Find ${N.toString()} screenshot, product, or feature images that visually illustrate the specific content of this passage. + +Entity: "${entityName}"${entityUrl ? ' at ' + entityUrl : ''}.${entityDomain ? ` Strongly prefer images hosted on ${entityDomain} or its subdomains. Fall back to other web sources only when on-domain images are not available.` : ''} + +For each image you select, output one line in this exact form, with no other prose: + +[AFTER {paragraph_number}] [IMAGE {n}: ] + +Where paragraph_number is the number of the paragraph the image best illustrates, and n is 1-indexed across your selected images. The description must be specific to the image (e.g., "ZAPI dashboard showing API discovery interface"), not generic. Do not return logos, generic company photos, or stock images. Return only the placement lines, one per image, nothing else. + +Passage: + +${numberedParagraphs}`; + + const payload = { + model: 'sonar', + messages: [{ role: 'user', content: prompt }], + stream: false, + return_citations: false, + return_images: true, + return_related_questions: false, + }; + + const loadingNotice = new Notice('Searching for images…', 0); + try { + const responseRaw = await request({ + url: settings.perplexityEndpoint, + method: 'POST', + headers: { + 'Authorization': `Bearer ${settings.perplexityApiKey}`, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(payload), + }); + + const data = JSON.parse(responseRaw) as PerplexityResponse; + const responseText = data.choices?.[0]?.message?.content ?? ''; + const images = data.images ?? []; + + if (images.length === 0) { + new Notice('No images returned.'); + return; + } + + const placements = parsePlacementMarkers(responseText); + + // Domain-prefer ordering of returned images, used as fallback when + // model didn't emit placement markers for an image. + const orderedImages = [...images]; + if (entityDomain) { + orderedImages.sort((a, b) => { + const aOrigin = extractDomain(a.origin_url ?? a.image_url ?? ''); + const bOrigin = extractDomain(b.origin_url ?? b.image_url ?? ''); + const aMatch = aOrigin.endsWith(entityDomain); + const bMatch = bOrigin.endsWith(entityDomain); + if (aMatch && !bMatch) return -1; + if (!aMatch && bMatch) return 1; + return 0; + }); + } + + // Build per-paragraph image queue. + const perParagraphImages: Map = new Map(); + const usedImageIndices = new Set(); + + for (const placement of placements.slice(0, N)) { + const imgIdx = placement.imageNumber - 1; + const img = images[imgIdx]; + if (!img?.image_url) continue; + if (placement.paragraphIndex < 1 || placement.paragraphIndex > paragraphs.length) continue; + const embed = `![${placement.description}](${img.image_url})`; + const list = perParagraphImages.get(placement.paragraphIndex) ?? []; + list.push(embed); + perParagraphImages.set(placement.paragraphIndex, list); + usedImageIndices.add(imgIdx); + } + + // Fallback: if model emitted no placement markers, distribute the top-N + // domain-preferred images evenly across paragraph boundaries. + if (perParagraphImages.size === 0) { + const fallbackImages = orderedImages.slice(0, N); + const step = Math.max(1, Math.ceil(paragraphs.length / fallbackImages.length)); + fallbackImages.forEach((img, i) => { + if (!img.image_url) return; + const paraIdx = Math.min(paragraphs.length, (i + 1) * step); + const desc = `${entityName} — illustration ${(i + 1).toString()}`; + const embed = `![${desc}](${img.image_url})`; + const list = perParagraphImages.get(paraIdx) ?? []; + list.push(embed); + perParagraphImages.set(paraIdx, list); + }); + } + + if (perParagraphImages.size === 0) { + new Notice('No usable images returned.'); + return; + } + + // Reconstruct selection with images interspersed between paragraphs. + const parts: string[] = []; + for (let i = 0; i < paragraphs.length; i++) { + parts.push(paragraphs[i] ?? ''); + const paraNum = i + 1; + const imagesForPara = perParagraphImages.get(paraNum); + if (imagesForPara) { + for (const embed of imagesForPara) { + parts.push(embed); + } + } + } + const reconstructed = parts.join('\n\n'); + + editor.replaceSelection(reconstructed); + + const totalEmbedded = Array.from(perParagraphImages.values()).reduce((acc, arr) => acc + arr.length, 0); + new Notice(`Embedded ${totalEmbedded.toString()} image${totalEmbedded === 1 ? '' : 's'} across ${perParagraphImages.size.toString()} paragraph${perParagraphImages.size === 1 ? '' : 's'}.`); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + new Notice(`Image search failed: ${msg}`); + } finally { + loadingNotice.hide(); + } +}