mirror of
https://github.com/lossless-group/perplexed-plugin.git
synced 2026-07-22 06:49:50 +00:00
feat(directory-templates): v0.1 spike — apply Perplexity-driven profile templates per directory
Solves the immediate workload of populating ~1600 nearly-empty profile files
under Tooling/, Sources/, and Vocabulary/ with a consistent outline + body.
The user picks a template from a fuzzy palette (filtered by glob match against
the active file's path), and the plugin sends a single Perplexity Deep Research
request driven by the template's heading skeleton + per-section bullet
instructions. The response is buffered, then written into the empty body of the
target file. Frontmatter is byte-identical before and after.
Template anatomy (per context-v/specs/Per-Directory-Profile-Templates.md):
- Frontmatter — title, applies-to-paths globs, description.
- Pre-`cft` prose — explainer for humans, ignored at runtime.
- One `cft` codefence — YAML config + `system:` field. Config-only; no prompt
body inside the fence.
- Post-`cft` skeleton — heading outline with model-facing bullet instructions.
Sent as the user prompt with `{{title}}` and `{{frontmatter}}` interpolated.
- A `***` line below the skeleton terminates the user prompt; everything below
is authoring scratch and never reaches the API.
New files:
- src/services/directoryTemplateService.ts — template loader, parser, glob
matcher, frontmatter whitelist filter, prompt assembly, non-streaming
Perplexity call, file writer.
- src/modals/DirectoryTemplatePickerModal.ts — FuzzySuggestModal wrapping the
glob-matched template list.
- src/docs/templates/{tooling-profile,source-profile}.md — seed templates
users can copy into their vault.
main.ts changes:
- New settings: directoryTemplatesRoot (default "zz-cf-lib/templates"),
directoryTemplatesFrontmatterWhitelist (default ["title", "og_description",
"tags", "og_image"]), directoryTemplatesRequestTimeoutMs (default 300000).
- New command: "Apply directory template to current file".
- New settings UI section under "Directory templates".
Out of scope for v0.1 — captured in the spec for later iterations:
- Folder-batch run with concurrency/progress/resume (v0.2).
- `cf` codefence runtime in user notes (v0.3).
- Section-aware writing + output modes (v0.4).
- Citation/backlink preservation (v0.5).
- Multi-provider, image generation, verb registry split (later).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4423052f04
commit
181a23e5e5
5 changed files with 583 additions and 1 deletions
123
main.ts
123
main.ts
|
|
@ -17,6 +17,15 @@ import { URLUpdateModal } from './src/modals/URLUpdateModal';
|
|||
import { ArticleGeneratorModal } from './src/modals/ArticleGeneratorModal';
|
||||
import { TextEnhancementModal } from './src/modals/TextEnhancementModal';
|
||||
import { TextEnhancementWithImagesModal } from './src/modals/TextEnhancementWithImagesModal';
|
||||
import { DirectoryTemplatePickerModal } from './src/modals/DirectoryTemplatePickerModal';
|
||||
|
||||
import {
|
||||
applyTemplate as applyDirectoryTemplate,
|
||||
listTemplates as listDirectoryTemplates,
|
||||
loadTemplate as loadDirectoryTemplate,
|
||||
pathMatchesGlobs,
|
||||
} from './src/services/directoryTemplateService';
|
||||
import type { DirectoryTemplateSettings } from './src/services/directoryTemplateService';
|
||||
|
||||
|
||||
interface PerplexedPluginSettings {
|
||||
|
|
@ -68,6 +77,11 @@ interface PerplexedPluginSettings {
|
|||
// Text enhancement with images prompt
|
||||
enhanceWithImagesPrompt: string;
|
||||
};
|
||||
|
||||
// Directory templates (v0.1 spike — see context-v/specs/Per-Directory-Profile-Templates.md)
|
||||
directoryTemplatesRoot: string;
|
||||
directoryTemplatesFrontmatterWhitelist: string[];
|
||||
directoryTemplatesRequestTimeoutMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: PerplexedPluginSettings = {
|
||||
|
|
@ -275,7 +289,12 @@ Structure the article as follows:
|
|||
|
||||
// Text enhancement with images prompt
|
||||
enhanceWithImagesPrompt: "Please provide 1-3 relevant images for the following text. Return ONLY the image markers in the format [IMAGE 1: description], [IMAGE 2: description], etc. Each image should illustrate a key concept, example, or visual representation related to the text. Do not include any other text or explanation:\n\n{TEXT}"
|
||||
}
|
||||
},
|
||||
|
||||
// Directory templates (v0.1 spike defaults)
|
||||
directoryTemplatesRoot: 'zz-cf-lib/templates',
|
||||
directoryTemplatesFrontmatterWhitelist: ['title', 'og_description', 'tags', 'og_image'],
|
||||
directoryTemplatesRequestTimeoutMs: 300000
|
||||
};
|
||||
|
||||
export default class PerplexedPlugin extends Plugin {
|
||||
|
|
@ -458,6 +477,15 @@ export default class PerplexedPlugin extends Plugin {
|
|||
await this.reinitializeServices();
|
||||
}
|
||||
});
|
||||
|
||||
// Directory templates (v0.1 spike). See context-v/specs/Per-Directory-Profile-Templates.md
|
||||
this.addCommand({
|
||||
id: 'apply-directory-template-to-current-file',
|
||||
name: 'Apply directory template to current file',
|
||||
callback: async () => {
|
||||
await this.runApplyDirectoryTemplate();
|
||||
}
|
||||
});
|
||||
|
||||
console.debug('Perplexed Plugin: Initialization completed successfully');
|
||||
new Notice('Perplexed plugin loaded successfully');
|
||||
|
|
@ -999,6 +1027,50 @@ export default class PerplexedPlugin extends Plugin {
|
|||
new Notice('Failed to reinitialize services. Check console for details.');
|
||||
}
|
||||
}
|
||||
|
||||
private async runApplyDirectoryTemplate(): Promise<void> {
|
||||
const target = this.app.workspace.getActiveFile();
|
||||
if (!target) {
|
||||
new Notice('No active file.');
|
||||
return;
|
||||
}
|
||||
if (!this.settings.perplexityApiKey) {
|
||||
new Notice('Perplexity API key is not set. Configure it in perplexed settings.');
|
||||
return;
|
||||
}
|
||||
|
||||
const root = this.settings.directoryTemplatesRoot;
|
||||
const all = await listDirectoryTemplates(this.app, root);
|
||||
if (all.length === 0) {
|
||||
new Notice(`No templates found under "${root}".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const matching = all.filter(t => pathMatchesGlobs(target.path, t.appliesToPaths));
|
||||
if (matching.length === 0) {
|
||||
new Notice("No template's applies-to-paths matches this file.");
|
||||
return;
|
||||
}
|
||||
|
||||
const dirSettings: DirectoryTemplateSettings = {
|
||||
perplexityApiKey: this.settings.perplexityApiKey,
|
||||
perplexityEndpoint: this.settings.perplexityEndpoint,
|
||||
templatesRoot: this.settings.directoryTemplatesRoot,
|
||||
frontmatterWhitelist: this.settings.directoryTemplatesFrontmatterWhitelist,
|
||||
requestTimeoutMs: this.settings.directoryTemplatesRequestTimeoutMs,
|
||||
};
|
||||
|
||||
new DirectoryTemplatePickerModal(this.app, matching, (chosen) => {
|
||||
void (async () => {
|
||||
const parsed = await loadDirectoryTemplate(this.app, chosen.file);
|
||||
if (!parsed) {
|
||||
new Notice('Template parse error: missing or malformed cft block.');
|
||||
return;
|
||||
}
|
||||
await applyDirectoryTemplate(this.app, dirSettings, target, parsed);
|
||||
})();
|
||||
}).open();
|
||||
}
|
||||
}
|
||||
|
||||
class PerplexedSettingTab extends PluginSettingTab {
|
||||
|
|
@ -1510,5 +1582,54 @@ class PerplexedSettingTab extends PluginSettingTab {
|
|||
})());
|
||||
|
||||
enhanceWithImagesPromptSetting.settingEl.appendChild(enhanceWithImagesPromptTextArea);
|
||||
|
||||
// Directory Templates Section (v0.1 spike)
|
||||
new Setting(containerEl).setName('Directory templates').setHeading();
|
||||
containerEl.createEl('p', {
|
||||
text: 'Apply a template (heading skeleton + per-section bullets) to fill a file via Perplexity deep research. Templates live in a vault folder and are matched to files by glob.',
|
||||
cls: 'setting-item-description'
|
||||
});
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Templates root')
|
||||
.setDesc('Vault-relative folder where directory templates live.')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Zz-cf-lib/templates')
|
||||
.setValue(this.plugin.settings.directoryTemplatesRoot)
|
||||
.onChange(async (value: string) => {
|
||||
this.plugin.settings.directoryTemplatesRoot = value.trim();
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Frontmatter whitelist')
|
||||
.setDesc('Comma-separated list of frontmatter keys passed to the template as {{frontmatter}}. Other keys are filtered out.')
|
||||
.addText(text => text
|
||||
.setPlaceholder('Title, og_description, tags, og_image')
|
||||
.setValue(this.plugin.settings.directoryTemplatesFrontmatterWhitelist.join(', '))
|
||||
.onChange(async (value: string) => {
|
||||
this.plugin.settings.directoryTemplatesFrontmatterWhitelist = value
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Request timeout (ms)')
|
||||
.setDesc('Maximum time to wait for the Perplexity deep research response. Default 300000 (5 min).')
|
||||
.addText(text => text
|
||||
.setPlaceholder('300000')
|
||||
.setValue(String(this.plugin.settings.directoryTemplatesRequestTimeoutMs))
|
||||
.onChange(async (value: string) => {
|
||||
const n = parseInt(value, 10);
|
||||
if (!isNaN(n) && n > 0) {
|
||||
this.plugin.settings.directoryTemplatesRequestTimeoutMs = n;
|
||||
await this.plugin.saveSettings();
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
src/docs/templates/source-profile.md
vendored
Normal file
56
src/docs/templates/source-profile.md
vendored
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
---
|
||||
title: Source Profile (Person / Publication / Channel)
|
||||
applies-to-paths:
|
||||
- "Sources/**"
|
||||
description: Generates a structured profile for a source — person, publication, or channel.
|
||||
---
|
||||
|
||||
# About this template
|
||||
|
||||
Use this for files under `Sources/` whose body is empty or near-empty. The bullets under each heading are model-facing instructions, not literal output.
|
||||
|
||||
```cft
|
||||
provider: perplexity
|
||||
model: sonar-deep-research
|
||||
search-recency: month
|
||||
return-citations: true
|
||||
return-images: false
|
||||
system: |
|
||||
You are filling out a structured profile for the source named "{{title}}".
|
||||
Existing metadata:
|
||||
{{frontmatter}}
|
||||
Follow the heading skeleton and per-section bullet instructions in the
|
||||
user prompt. Use inline citations. Prefer first-party sources.
|
||||
```
|
||||
|
||||
# Identity
|
||||
- One paragraph summarizing who or what this source is.
|
||||
|
||||
## Type
|
||||
- Person, publication, podcast, YouTube channel, newsletter, organization, etc. Pick one.
|
||||
|
||||
## Reach / Audience
|
||||
- Estimated audience size, geography, and primary platforms. Cite sources where possible.
|
||||
|
||||
# Topical Coverage
|
||||
|
||||
## Primary Beats
|
||||
- 3–6 topics this source covers regularly. One bullet each.
|
||||
|
||||
## Notable Recent Pieces
|
||||
- 5 most-cited or most-relevant pieces from the past 12 months. Title, date, URL.
|
||||
|
||||
# Track Record
|
||||
- Brief assessment of credibility and editorial style. Two short paragraphs.
|
||||
|
||||
## Known Biases or Editorial Stance
|
||||
- Disclosed funding, ownership, or political stance if any.
|
||||
|
||||
# Adjacent Sources
|
||||
- 3–5 sources that cover similar ground from different angles.
|
||||
|
||||
***
|
||||
|
||||
# User Notes
|
||||
|
||||
Authoring scratch zone — not sent to the model.
|
||||
78
src/docs/templates/tooling-profile.md
vendored
Normal file
78
src/docs/templates/tooling-profile.md
vendored
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
---
|
||||
title: Tooling Profile (Company / Service / App)
|
||||
applies-to-paths:
|
||||
- "Tooling/**"
|
||||
description: Generates a structured profile for a company / service / app / open-source repo.
|
||||
---
|
||||
|
||||
# About this template
|
||||
|
||||
Use this for files under `Tooling/` whose body is empty or near-empty. Run "Apply directory template to current file" while viewing the target. The runtime sends the heading skeleton below as the user prompt to Perplexity Deep Research; the model returns markdown that follows the structure with inline citations.
|
||||
|
||||
The bullets under each heading are *instructions to the model*, not literal content. The model fills each section based on those instructions.
|
||||
|
||||
```cft
|
||||
provider: perplexity
|
||||
model: sonar-deep-research
|
||||
search-recency: month
|
||||
return-citations: true
|
||||
return-images: false
|
||||
system: |
|
||||
You are filling out a structured profile for the entity named "{{title}}".
|
||||
Existing metadata for this entity:
|
||||
{{frontmatter}}
|
||||
Produce markdown that follows the heading skeleton and per-section bullet
|
||||
instructions in the user prompt. Every heading must be present, even if a
|
||||
section reads "limited public information." Use inline citations. Prefer
|
||||
first-party sources.
|
||||
```
|
||||
|
||||
# Features
|
||||
- Describe the core product features in 2–3 sentences each.
|
||||
- Bullet 5–8 features in priority order.
|
||||
|
||||
## Screenshots
|
||||
- If three official screenshots are publicly available, list their URLs as bullets.
|
||||
- For each, write a 1-sentence caption. If none are publicly available, write "No publicly available screenshots."
|
||||
|
||||
## Product Roadmap / Announcements
|
||||
- Public roadmap items and product announcements from the past 6 months.
|
||||
- Use dated bullets, most recent first. Cite each item.
|
||||
|
||||
## Recent Developments
|
||||
- News and developments from the past 90 days. Cite sources inline.
|
||||
|
||||
# History and Origin Story
|
||||
- Founding story, founders, key inflection points. One short paragraph.
|
||||
|
||||
## Fundraising History
|
||||
- Search for Pre-Seed, Seed, Series A, etc. announcements.
|
||||
- Produce a markdown table with columns: Round | Date | Amount | Lead investor.
|
||||
- Add a Total row at the bottom with estimated or reported total funding.
|
||||
- Below the table, list each investor in alphabetical order, one per line.
|
||||
|
||||
## Notable Team Members
|
||||
- Founders and notable leadership; one short paragraph each.
|
||||
|
||||
# Market Sizing
|
||||
|
||||
## Pricing
|
||||
- Markdown table of pricing tiers if published.
|
||||
- Note "no public pricing" if not.
|
||||
|
||||
## Revenue Trajectory Estimates
|
||||
- Estimated or reported revenue / ARR. Cite source per figure.
|
||||
|
||||
# Competitive Landscape
|
||||
|
||||
## Who it's for, who it's not for
|
||||
- Two short paragraphs. Be concrete about ICP and anti-ICP.
|
||||
|
||||
## Viable Alternatives
|
||||
- 3–5 alternatives, one bullet each, with a brief rationale.
|
||||
|
||||
***
|
||||
|
||||
# User Notes
|
||||
|
||||
Anything below the `***` line is excluded from the request. Use this zone for tuning notes, prior outputs, or examples while iterating on the template.
|
||||
32
src/modals/DirectoryTemplatePickerModal.ts
Normal file
32
src/modals/DirectoryTemplatePickerModal.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { App } from 'obsidian';
|
||||
import { FuzzySuggestModal } from 'obsidian';
|
||||
import type { TemplateFile } from '../services/directoryTemplateService';
|
||||
|
||||
export class DirectoryTemplatePickerModal extends FuzzySuggestModal<TemplateFile> {
|
||||
private items: TemplateFile[];
|
||||
private callback: (chosen: TemplateFile) => void;
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
items: TemplateFile[],
|
||||
callback: (chosen: TemplateFile) => void,
|
||||
) {
|
||||
super(app);
|
||||
this.items = items;
|
||||
this.callback = callback;
|
||||
this.setPlaceholder('Pick a directory template…');
|
||||
}
|
||||
|
||||
getItems(): TemplateFile[] {
|
||||
return this.items;
|
||||
}
|
||||
|
||||
getItemText(item: TemplateFile): string {
|
||||
const desc = item.description ? ` — ${item.description}` : '';
|
||||
return `${item.title}${desc}`;
|
||||
}
|
||||
|
||||
onChooseItem(item: TemplateFile): void {
|
||||
this.callback(item);
|
||||
}
|
||||
}
|
||||
295
src/services/directoryTemplateService.ts
Normal file
295
src/services/directoryTemplateService.ts
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
import type { App, TFile } from 'obsidian';
|
||||
import { Notice, parseYaml, request, stringifyYaml } from 'obsidian';
|
||||
|
||||
export interface DirectoryTemplateSettings {
|
||||
perplexityApiKey: string;
|
||||
perplexityEndpoint: string;
|
||||
templatesRoot: string;
|
||||
frontmatterWhitelist: string[];
|
||||
requestTimeoutMs: number;
|
||||
}
|
||||
|
||||
export interface TemplateFile {
|
||||
file: TFile;
|
||||
frontmatter: Record<string, unknown>;
|
||||
title: string;
|
||||
description: string;
|
||||
appliesToPaths: string[];
|
||||
}
|
||||
|
||||
export interface ParsedTemplate {
|
||||
file: TFile;
|
||||
cftConfig: Record<string, unknown>;
|
||||
cftSystem: string;
|
||||
userSkeleton: string;
|
||||
}
|
||||
|
||||
interface PerplexityChoice {
|
||||
message?: { content?: string };
|
||||
}
|
||||
|
||||
interface PerplexityResponseShape {
|
||||
choices?: PerplexityChoice[];
|
||||
}
|
||||
|
||||
interface PerplexityPayload {
|
||||
model: string;
|
||||
messages: { role: string; content: string }[];
|
||||
stream: boolean;
|
||||
return_citations: boolean;
|
||||
return_images: boolean;
|
||||
return_related_questions: boolean;
|
||||
search_recency_filter?: string;
|
||||
}
|
||||
|
||||
const FRONTMATTER_FENCE = '---';
|
||||
const CFT_OPEN_RE = /^```cft\b\s*$/;
|
||||
const FENCE_CLOSE_RE = /^```\s*$/;
|
||||
const SCRATCH_TERMINATOR_RE = /^\*\*\*\s*$/;
|
||||
|
||||
function splitFrontmatter(content: string): { frontmatter: string; body: string } {
|
||||
const normalized = content.replace(/\r\n/g, '\n');
|
||||
const lines = normalized.split('\n');
|
||||
if (lines[0] !== FRONTMATTER_FENCE) {
|
||||
return { frontmatter: '', body: normalized };
|
||||
}
|
||||
let i = 1;
|
||||
while (i < lines.length && lines[i] !== FRONTMATTER_FENCE) i++;
|
||||
if (i >= lines.length) return { frontmatter: '', body: normalized };
|
||||
const fm = lines.slice(1, i).join('\n');
|
||||
const body = lines.slice(i + 1).join('\n');
|
||||
return { frontmatter: fm, body };
|
||||
}
|
||||
|
||||
function safeParseYaml(yaml: string): Record<string, unknown> {
|
||||
if (!yaml.trim()) return {};
|
||||
try {
|
||||
const parsed: unknown = parseYaml(yaml);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
return {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function globToRegExp(glob: string): RegExp {
|
||||
let re = '^';
|
||||
let i = 0;
|
||||
while (i < glob.length) {
|
||||
const c = glob[i] ?? '';
|
||||
if (c === '*' && glob[i + 1] === '*') {
|
||||
if (glob[i + 2] === '/') {
|
||||
re += '(?:.*/)?';
|
||||
i += 3;
|
||||
} else {
|
||||
re += '.*';
|
||||
i += 2;
|
||||
}
|
||||
} else if (c === '*') {
|
||||
re += '[^/]*';
|
||||
i++;
|
||||
} else if (c === '?') {
|
||||
re += '[^/]';
|
||||
i++;
|
||||
} else if ('.+^$|(){}[]\\'.includes(c)) {
|
||||
re += '\\' + c;
|
||||
i++;
|
||||
} else {
|
||||
re += c;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
re += '$';
|
||||
return new RegExp(re);
|
||||
}
|
||||
|
||||
export function pathMatchesGlobs(path: string, globs: string[]): boolean {
|
||||
if (!globs.length) return false;
|
||||
return globs.some(g => globToRegExp(g).test(path));
|
||||
}
|
||||
|
||||
export async function listTemplates(app: App, root: string): Promise<TemplateFile[]> {
|
||||
const normalizedRoot = root.replace(/\/$/, '');
|
||||
if (!normalizedRoot) return [];
|
||||
const all = app.vault.getMarkdownFiles();
|
||||
const results: TemplateFile[] = [];
|
||||
for (const file of all) {
|
||||
if (!file.path.startsWith(normalizedRoot + '/')) continue;
|
||||
const cache = app.metadataCache.getFileCache(file);
|
||||
const fm = (cache?.frontmatter ?? {}) as Record<string, unknown>;
|
||||
const appliesToRaw = fm['applies-to-paths'];
|
||||
const appliesToPaths = Array.isArray(appliesToRaw)
|
||||
? appliesToRaw.filter((g): g is string => typeof g === 'string')
|
||||
: typeof appliesToRaw === 'string' ? [appliesToRaw] : [];
|
||||
results.push({
|
||||
file,
|
||||
frontmatter: fm,
|
||||
title: typeof fm.title === 'string' ? fm.title : file.basename,
|
||||
description: typeof fm.description === 'string' ? fm.description : '',
|
||||
appliesToPaths,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function loadTemplate(app: App, file: TFile): Promise<ParsedTemplate | null> {
|
||||
const content = await app.vault.read(file);
|
||||
const { body } = splitFrontmatter(content);
|
||||
const lines = body.split('\n');
|
||||
|
||||
let cftStart = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (CFT_OPEN_RE.test(lines[i] ?? '')) { cftStart = i; break; }
|
||||
}
|
||||
if (cftStart < 0) return null;
|
||||
|
||||
let cftEnd = -1;
|
||||
for (let i = cftStart + 1; i < lines.length; i++) {
|
||||
if (FENCE_CLOSE_RE.test(lines[i] ?? '')) { cftEnd = i; break; }
|
||||
}
|
||||
if (cftEnd < 0) return null;
|
||||
|
||||
const cftYaml = lines.slice(cftStart + 1, cftEnd).join('\n');
|
||||
const cftParsed = safeParseYaml(cftYaml);
|
||||
const systemPrompt = typeof cftParsed.system === 'string' ? cftParsed.system : '';
|
||||
const cftConfig = { ...cftParsed };
|
||||
delete cftConfig.system;
|
||||
|
||||
let skeletonEnd = lines.length;
|
||||
for (let i = cftEnd + 1; i < lines.length; i++) {
|
||||
if (SCRATCH_TERMINATOR_RE.test(lines[i] ?? '')) { skeletonEnd = i; break; }
|
||||
}
|
||||
const userSkeleton = lines.slice(cftEnd + 1, skeletonEnd).join('\n').trim();
|
||||
|
||||
return { file, cftConfig, cftSystem: systemPrompt, userSkeleton };
|
||||
}
|
||||
|
||||
export function buildFrontmatterPayload(
|
||||
fm: Record<string, unknown>,
|
||||
whitelist: string[],
|
||||
): string {
|
||||
const filtered: Record<string, unknown> = {};
|
||||
for (const key of whitelist) {
|
||||
if (key in fm) filtered[key] = fm[key];
|
||||
}
|
||||
if (Object.keys(filtered).length === 0) return '(none)';
|
||||
return stringifyYaml(filtered).trim();
|
||||
}
|
||||
|
||||
export function interpolate(text: string, ctx: { title: string; frontmatter: string }): string {
|
||||
return text.replace(/\{\{\s*(title|frontmatter)\s*\}\}/g, (_, key: string) => {
|
||||
if (key === 'title') return ctx.title;
|
||||
return ctx.frontmatter;
|
||||
});
|
||||
}
|
||||
|
||||
function buildPayload(
|
||||
template: ParsedTemplate,
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
): PerplexityPayload {
|
||||
const cfg = template.cftConfig;
|
||||
const model = typeof cfg.model === 'string' ? cfg.model : 'sonar-deep-research';
|
||||
const recency = typeof cfg['search-recency'] === 'string'
|
||||
? cfg['search-recency']
|
||||
: undefined;
|
||||
const returnCitations = cfg['return-citations'] !== false;
|
||||
const returnImages = cfg['return-images'] === true;
|
||||
|
||||
const payload: PerplexityPayload = {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
stream: false,
|
||||
return_citations: returnCitations,
|
||||
return_images: returnImages,
|
||||
return_related_questions: false,
|
||||
};
|
||||
if (recency) payload.search_recency_filter = recency;
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function callPerplexity(
|
||||
apiKey: string,
|
||||
endpoint: string,
|
||||
payload: PerplexityPayload,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
const requestPromise = request({
|
||||
url: endpoint,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
activeWindow.setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
|
||||
const response = await Promise.race([requestPromise, timeoutPromise]);
|
||||
const data = JSON.parse(response) as PerplexityResponseShape;
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) throw new Error('Perplexity returned no content.');
|
||||
return content;
|
||||
}
|
||||
|
||||
export async function applyTemplate(
|
||||
app: App,
|
||||
settings: DirectoryTemplateSettings,
|
||||
target: TFile,
|
||||
template: ParsedTemplate,
|
||||
): Promise<void> {
|
||||
if (!settings.perplexityApiKey) {
|
||||
new Notice('Perplexity API key is not set.');
|
||||
return;
|
||||
}
|
||||
if (!template.userSkeleton) {
|
||||
new Notice('Template has no skeleton (no content below the cft block).');
|
||||
return;
|
||||
}
|
||||
|
||||
const targetContent = await app.vault.read(target);
|
||||
const { frontmatter: fmRaw, body } = splitFrontmatter(targetContent);
|
||||
if (body.trim().length > 0) {
|
||||
new Notice('File has existing body. Edit manually or delete body to re-run.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fm = safeParseYaml(fmRaw);
|
||||
const title = typeof fm.title === 'string' ? fm.title : target.basename;
|
||||
const fmYaml = buildFrontmatterPayload(fm, settings.frontmatterWhitelist);
|
||||
|
||||
const ctx = { title, frontmatter: fmYaml };
|
||||
const systemPrompt = interpolate(template.cftSystem, ctx);
|
||||
const userPrompt = interpolate(template.userSkeleton, ctx);
|
||||
|
||||
const payload = buildPayload(template, systemPrompt, userPrompt);
|
||||
|
||||
const loadingNotice = new Notice('Applying template via Perplexity deep research…', 0);
|
||||
try {
|
||||
const content = await callPerplexity(
|
||||
settings.perplexityApiKey,
|
||||
settings.perplexityEndpoint,
|
||||
payload,
|
||||
settings.requestTimeoutMs,
|
||||
);
|
||||
const trimmedContent = content.replace(/^\s+/, '').replace(/\s+$/, '');
|
||||
const fmBlock = fmRaw.length > 0 ? `---\n${fmRaw}\n---\n` : '';
|
||||
const newFile = `${fmBlock}\n${trimmedContent}\n`;
|
||||
await app.vault.modify(target, newFile);
|
||||
new Notice(`Applied "${template.file.basename}" to ${target.basename}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
new Notice(`Perplexity error: ${msg}`);
|
||||
} finally {
|
||||
loadingNotice.hide();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue