Excluded folders + recursive extraction of prompts

This commit is contained in:
dimitrievgs 2025-09-26 01:07:47 +03:00
parent 8e54a65fa9
commit 4b31725936
5 changed files with 493 additions and 377 deletions

54
main.ts
View File

@ -14,6 +14,7 @@ interface LLMAgentSettings {
promptsFolder: string; // Новое поле promptsFolder: string; // Новое поле
enableFileReferences: boolean; // Новое поле enableFileReferences: boolean; // Новое поле
enablePromptReferences: boolean; // Новое поле enablePromptReferences: boolean; // Новое поле
excludedFolders: string[]; // Новое поле: Список папок для исключения из автокомплита
} }
const DEFAULT_SETTINGS: LLMAgentSettings = { const DEFAULT_SETTINGS: LLMAgentSettings = {
@ -22,7 +23,8 @@ const DEFAULT_SETTINGS: LLMAgentSettings = {
defaultModel: 'gemini-2.5-flash', defaultModel: 'gemini-2.5-flash',
promptsFolder: 'prompts', // Папка с промптами по умолчанию promptsFolder: 'prompts', // Папка с промптами по умолчанию
enableFileReferences: true, enableFileReferences: true,
enablePromptReferences: true enablePromptReferences: true,
excludedFolders: ['templates'] // Папка "templates" по умолчанию исключена
}; };
// ----------------------------------------------- Main Plugin Class --------------------------------------------------------------- // ----------------------------------------------- Main Plugin Class ---------------------------------------------------------------
@ -198,5 +200,55 @@ class SampleSettingTab extends PluginSettingTab {
await this.plugin.saveSettings(); await this.plugin.saveSettings();
}) })
); );
// Новое поле для настройки исключенных папок
new Setting(containerEl)
.setName('Исключенные папки для ссылок')
.setDesc('Список относительных путей к папкам, файлы из которых не будут предлагаться в автодополнении ссылок (@/@@).')
.addToggle((toggle) =>
toggle.setValue(this.plugin.settings.enableFileReferences).onChange(async (value) => {
this.plugin.settings.enableFileReferences = value;
await this.plugin.saveSettings();
})
);
// Динамический список исключенных папок
this.plugin.settings.excludedFolders.forEach((folder, index) => {
new Setting(containerEl)
.setClass('llm-agent-excluded-folder-item') // Добавляем класс для стилизации
.addText((text) => {
text.setPlaceholder('Путь к папке (например, templates/)' + index)
.setValue(folder)
.onChange(async (value) => {
this.plugin.settings.excludedFolders[index] = value;
await this.plugin.saveSettings();
// Очищаем кэш автокомплита при изменении настроек
this.plugin.eventBus.emit('settings-changed');
});
})
.addExtraButton((button) => {
button
.setIcon('x')
.setTooltip('Удалить папку')
.onClick(async () => {
this.plugin.settings.excludedFolders.splice(index, 1);
await this.plugin.saveSettings();
this.display(); // Перерисовать настройки для обновления списка
// Очищаем кэш автокомплита при изменении настроек
this.plugin.eventBus.emit('settings-changed');
});
});
});
new Setting(containerEl).addButton((button) => {
button
.setButtonText('Добавить папку')
.setIcon('plus')
.onClick(async () => {
this.plugin.settings.excludedFolders.push(''); // Добавляем пустое поле
await this.plugin.saveSettings();
this.display(); // Перерисовать настройки для обновления списка
});
});
} }
} }

View File

@ -770,4 +770,23 @@
.reference-autocomplete::-webkit-scrollbar-thumb:hover { .reference-autocomplete::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-thumb-bg-hover); background: var(--scrollbar-thumb-bg-hover);
}
/* ----------------------------------------------- Settings Views ------------------------------------------------------------ */
.setting-item.llm-agent-excluded-folder-item {
padding-left: 30px; /* Отступ для элементов списка */
border-left: 2px solid var(--background-modifier-border); /* Разделитель */
margin-bottom: 5px;
margin-top: 5px;
}
.setting-item.llm-agent-excluded-folder-item .setting-item-control {
display: flex;
align-items: center;
gap: 5px;
}
.setting-item.llm-agent-excluded-folder-item .setting-item-control input {
flex-grow: 1; /* Поле ввода занимает все доступное пространство */
} }

View File

@ -9,114 +9,121 @@ import LLMAgentPlugin from 'main';
// ----------------------------------------------- Interfaces --------------------------------------------------------------- // ----------------------------------------------- Interfaces ---------------------------------------------------------------
export interface AutocompleteItem { export interface AutocompleteItem {
name: string; name: string;
path: string; path: string;
type: 'file' | 'prompt'; type: 'file' | 'prompt';
icon: string; // Obsidian icon name icon: string; // Obsidian icon name
file?: TFile; // Ссылка на файл Obsidian file?: TFile; // Ссылка на файл Obsidian
} }
// ----------------------------------------------- Autocomplete Manager --------------------------------------------------------------- // ----------------------------------------------- Autocomplete Manager ---------------------------------------------------------------
export class AutocompleteManager { export class AutocompleteManager {
private plugin: LLMAgentPlugin; private plugin: LLMAgentPlugin;
private fileCache: Map<string, AutocompleteItem[]> = new Map(); private fileCache: Map<string, AutocompleteItem[]> = new Map();
private promptCache: Map<string, AutocompleteItem[]> = new Map(); private promptCache: Map<string, AutocompleteItem[]> = new Map();
private lastCacheUpdate = 0; private lastCacheUpdate = 0;
private readonly CACHE_TTL = 30000; // 30 секунд private readonly CACHE_TTL = 30000; // 30 секунд
constructor(plugin: LLMAgentPlugin) { constructor(plugin: LLMAgentPlugin) {
this.plugin = plugin; this.plugin = plugin;
this.setupFileWatcher(); this.setupFileWatcher();
} // Слушаем изменения настроек для очистки кеша
this.plugin.eventBus.on('settings-changed', this.clearCache.bind(this));
}
// ----------------------------------------------- Public Methods --------------------------------------------------------------- // ----------------------------------------------- Public Methods ---------------------------------------------------------------
/** /**
* Получает список файлов для автокомплита * Получает список файлов для автокомплита
* @param query - поисковый запрос * @param query - поисковый запрос
* @returns массив файлов для автокомплита * @returns массив файлов для автокомплита
*/ */
async getFileOptions(query: string): Promise<AutocompleteItem[]> { async getFileOptions(query: string): Promise<AutocompleteItem[]> {
if (!this.plugin.settings.enableFileReferences) { if (!this.plugin.settings.enableFileReferences) {
return []; return [];
} }
await this.updateCacheIfNeeded(); await this.updateCacheIfNeeded();
const allFiles = this.fileCache.get('all') || [];
if (!query.trim()) {
return allFiles.slice(0, 10); // Показываем первые 10 файлов
}
const lowercaseQuery = query.toLowerCase(); const allFiles = this.fileCache.get('all') || [];
return allFiles
.filter(item =>
item.name.toLowerCase().includes(lowercaseQuery) ||
item.path.toLowerCase().includes(lowercaseQuery)
)
.slice(0, 10);
}
/** if (!query.trim()) {
* Получает список промптов для автокомплита return allFiles.slice(0, 10); // Показываем первые 10 файлов
* @param query - поисковый запрос }
* @returns массив промптов для автокомплита
*/
async getPromptOptions(query: string): Promise<AutocompleteItem[]> {
if (!this.plugin.settings.enablePromptReferences) {
return [];
}
await this.updateCacheIfNeeded(); const lowercaseQuery = query.toLowerCase();
return allFiles
const allPrompts = this.promptCache.get('all') || []; .filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery))
.slice(0, 10);
if (!query.trim()) { }
return allPrompts.slice(0, 10);
}
const lowercaseQuery = query.toLowerCase(); /**
return allPrompts * Получает список промптов для автокомплита
.filter(item => * @param query - поисковый запрос
item.name.toLowerCase().includes(lowercaseQuery) || * @returns массив промптов для автокомплита
item.path.toLowerCase().includes(lowercaseQuery) */
) async getPromptOptions(query: string): Promise<AutocompleteItem[]> {
.slice(0, 10); if (!this.plugin.settings.enablePromptReferences) {
} return [];
}
/** await this.updateCacheIfNeeded();
* Очищает кеш (используется при изменении настроек)
*/
clearCache(): void {
this.fileCache.clear();
this.promptCache.clear();
this.lastCacheUpdate = 0;
}
// ----------------------------------------------- Private Methods --------------------------------------------------------------- const allPrompts = this.promptCache.get('all') || [];
/** if (!query.trim()) {
* Обновляет кеш, если он устарел return allPrompts.slice(0, 10);
*/ }
private async updateCacheIfNeeded(): Promise<void> {
const now = Date.now();
if (now - this.lastCacheUpdate > this.CACHE_TTL) {
await this.updateCache();
this.lastCacheUpdate = now;
}
}
/** const lowercaseQuery = query.toLowerCase();
* Обновляет кеш файлов и промптов return allPrompts
*/ .filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery))
.slice(0, 10);
}
/**
* Очищает кеш (используется при изменении настроек)
*/
clearCache(): void {
this.fileCache.clear();
this.promptCache.clear();
this.lastCacheUpdate = 0;
}
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
/**
* Обновляет кеш, если он устарел
*/
private async updateCacheIfNeeded(): Promise<void> {
const now = Date.now();
if (now - this.lastCacheUpdate > this.CACHE_TTL) {
await this.updateCache();
this.lastCacheUpdate = now;
}
}
/**
* Обновляет кеш файлов и промптов
*/
private async updateCache(): Promise<void> { private async updateCache(): Promise<void> {
const files = this.plugin.app.vault.getMarkdownFiles(); const files = this.plugin.app.vault.getMarkdownFiles();
const fileItems: AutocompleteItem[] = []; const fileItems: AutocompleteItem[] = [];
const promptItems: AutocompleteItem[] = []; const promptItems: AutocompleteItem[] = [];
// Нормализуем пути исключенных папок
const normalizedExcludedFolders = this.plugin.settings.excludedFolders
.filter(folder => folder.trim() !== '')
.map(folder => folder.endsWith('/') ? folder : folder + '/') // Убедимся, что заканчивается на слэш
.map(folder => folder.startsWith('/') ? folder.substring(1) : folder); // Удаляем начальный слэш
for (const file of files) { for (const file of files) {
// Пропускаем файлы из исключенных папок
if (this.isFileInExcludedFolder(file, normalizedExcludedFolders)) {
continue;
}
const item: AutocompleteItem = { const item: AutocompleteItem = {
name: file.basename, name: file.basename,
path: file.path, path: file.path,
@ -139,10 +146,10 @@ export class AutocompleteManager {
this.fileCache.set('all', fileItems); this.fileCache.set('all', fileItems);
this.promptCache.set('all', promptItems); this.promptCache.set('all', promptItems);
} }
/** /**
* Проверяет, является ли файл промптом * Проверяет, является ли файл промптом
*/ */
private isPromptFile(file: TFile): boolean { private isPromptFile(file: TFile): boolean {
const promptsFolder = this.plugin.settings.promptsFolder; const promptsFolder = this.plugin.settings.promptsFolder;
if (!promptsFolder) return false; if (!promptsFolder) return false;
@ -154,56 +161,76 @@ export class AutocompleteManager {
file.path === normalizedPromptsPath; file.path === normalizedPromptsPath;
} }
/** /**
* Возвращает иконку для файла на основе Obsidian иконок * Возвращает иконку для файла на основе Obsidian иконок
*/ */
private getFileIcon(file: TFile): string { private getFileIcon(file: TFile): string {
if (this.isPromptFile(file)) { if (this.isPromptFile(file)) {
return 'zap'; // Иконка промпта return 'zap'; // Иконка промпта
} }
// Определяем иконку по расширению // Определяем иконку по расширению
const extension = file.extension.toLowerCase(); const extension = file.extension.toLowerCase();
switch (extension) { switch (extension) {
case 'md': case 'md':
return 'file-text'; return 'file-text';
case 'js': case 'js':
case 'ts': case 'ts':
return 'code'; return 'code';
case 'json': case 'json':
return 'brackets'; return 'brackets';
case 'css': case 'css':
return 'palette'; return 'palette';
case 'png': case 'png':
case 'jpg': case 'jpg':
case 'jpeg': case 'jpeg':
case 'gif': case 'gif':
return 'image'; return 'image';
default: default:
return 'file'; return 'file';
} }
} }
/**
* Настраивает отслеживание изменений файлов для обновления кеша
*/
private setupFileWatcher(): void {
this.plugin.registerEvent(
this.plugin.app.vault.on('create', () => {
this.clearCache();
})
);
this.plugin.registerEvent(
this.plugin.app.vault.on('delete', () => {
this.clearCache();
})
);
this.plugin.registerEvent(
this.plugin.app.vault.on('rename', () => {
this.clearCache();
})
);
}
/** /**
* Настраивает отслеживание изменений файлов для обновления кеша * Проверяет, находится ли файл в одной из исключенных папок.
* @param file - файл Obsidian.
* @param excludedFolders - массив нормализованных путей исключенных папок.
* @returns true, если файл находится в исключенной папке.
*/ */
private setupFileWatcher(): void { private isFileInExcludedFolder(file: TFile, excludedFolders: string[]): boolean {
this.plugin.registerEvent( const filePath = file.path.toLowerCase(); // Для регистронезависимого сравнения
this.plugin.app.vault.on('create', () => {
this.clearCache();
})
);
this.plugin.registerEvent( for (const folder of excludedFolders) {
this.plugin.app.vault.on('delete', () => { const normalizedFolder = folder.toLowerCase();
this.clearCache(); // Проверяем, начинается ли путь файла с пути исключенной папки
}) // Или если файл находится прямо в корне исключенной папки (н.п. "folder/file.md")
); if (filePath.startsWith(normalizedFolder) ) {
return true;
this.plugin.registerEvent( }
this.plugin.app.vault.on('rename', () => { }
this.clearCache(); return false;
})
);
} }
} }

View File

@ -6,140 +6,155 @@
// ----------------------------------------------- Interfaces --------------------------------------------------------------- // ----------------------------------------------- Interfaces ---------------------------------------------------------------
export interface ReferenceMatch { export interface ReferenceMatch {
fullMatch: string; // "@filename" или "@@filename" fullMatch: string; // "@filename" или "@@filename"
prefix: string; // "@" или "@@" prefix: string; // "@" или "@@"
name: string; // "filename" name: string; // "filename"
startIndex: number; startIndex: number;
endIndex: number; endIndex: number;
type: 'file' | 'prompt'; type: 'file' | 'prompt';
} }
export interface FileReference { export interface FileReference {
type: 'file' | 'prompt'; type: 'file' | 'prompt';
name: string; name: string;
path: string; path: string;
permanent: boolean; // true для @@ и ## permanent: boolean; // true для @@ и ##
// TODO: добавить snapshotId при реализации снапшотов // TODO: добавить snapshotId при реализации снапшотов
} }
// ----------------------------------------------- Reference Parser --------------------------------------------------------------- // ----------------------------------------------- Reference Parser ---------------------------------------------------------------
export class ReferenceParser { export class ReferenceParser {
/**
/** * Парсит текст и находит все @/@@ и #/## ссылки
* Парсит текст и находит все @/@@ и #/## ссылки * @param text - текст для парсинга
* @param text - текст для парсинга * @returns массив найденных ссылок
* @returns массив найденных ссылок */
*/ static parseReferences(text: string): ReferenceMatch[] {
static parseReferences(text: string): ReferenceMatch[] { const matches: ReferenceMatch[] = [];
const matches: ReferenceMatch[] = [];
// Регулярное выражение для поиска @/@@ и #/## ссылок
// Регулярное выражение для поиска @/@@ и #/## ссылок // Поддерживает имена файлов с пробелами, заключенные в кавычки или без пробелов
// Поддерживает имена файлов с пробелами, заключенные в кавычки или без пробелов const regex = /(@{1,2}|#{1,2})(?:"([^"]+)"|([^\s@#"]+))/g;
const regex = /(@{1,2}|#{1,2})(?:"([^"]+)"|([^\s@#"]+))/g;
let match;
let match; while ((match = regex.exec(text)) !== null) {
while ((match = regex.exec(text)) !== null) { const prefix = match[1];
const prefix = match[1]; const quotedName = match[2];
const quotedName = match[2]; const unquotedName = match[3];
const unquotedName = match[3]; const name = quotedName || unquotedName;
const name = quotedName || unquotedName;
matches.push({
matches.push({ fullMatch: match[0],
fullMatch: match[0], prefix: prefix,
prefix: prefix, name: name,
name: name, startIndex: match.index,
startIndex: match.index, endIndex: match.index + match[0].length,
endIndex: match.index + match[0].length, type: prefix.startsWith('@') ? 'file' : 'prompt'
type: prefix.startsWith('@') ? 'file' : 'prompt' });
}); }
}
return matches;
return matches; }
}
/**
/** * Проверяет, является ли позиция курсора внутри незавершённой ссылки
* Проверяет, является ли позиция курсора внутри незавершённой ссылки * @param text - текст сообщения
* @param text - текст сообщения * @param cursorPosition - позиция курсора
* @param cursorPosition - позиция курсора * @returns информация о текущей ссылке или null
* @returns информация о текущей ссылке или null */
*/ static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null {
static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null { // Ищем незавершенные ссылки перед курсором
// Ищем незавершенные ссылки перед курсором const beforeCursor = text.substring(0, cursorPosition);
const beforeCursor = text.substring(0, cursorPosition);
// Регулярное выражение для поиска начала ссылки без завершения
// Регулярное выражение для поиска начала ссылки без завершения const incompleteRegex = /(@{1,2}|#{1,2})([^\s@#"]*?)$/;
const incompleteRegex = /(@{1,2}|#{1,2})([^\s@#"]*?)$/; const match = beforeCursor.match(incompleteRegex);
const match = beforeCursor.match(incompleteRegex);
if (match) {
if (match) { const prefix = match[1];
const prefix = match[1]; const partialName = match[2];
const partialName = match[2]; const startIndex = beforeCursor.length - match[0].length;
const startIndex = beforeCursor.length - match[0].length;
return {
return { fullMatch: match[0],
fullMatch: match[0], prefix: prefix,
prefix: prefix, name: partialName,
name: partialName, startIndex: startIndex,
startIndex: startIndex, endIndex: cursorPosition,
endIndex: cursorPosition, type: prefix.startsWith('@') ? 'file' : 'prompt'
type: prefix.startsWith('@') ? 'file' : 'prompt' };
}; }
}
return null;
return null; }
}
/**
/** * Валидирует промпт на корректность @{...} ссылок
* Валидирует промпт на корректность @{...} ссылок * @param promptContent - содержимое промпта
* @param promptContent - содержимое промпта * @returns результат валидации
* @returns результат валидации */
*/ static validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } {
static validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } { const errors: string[] = [];
const errors: string[] = [];
// Ищем шаблонные ссылки @{...}
// Ищем шаблонные ссылки @{...} const templateRegex = /@\{([^}]+)\}/g;
const templateRegex = /@\{([^}]+)\}/g; const matches = promptContent.matchAll(templateRegex);
const matches = promptContent.matchAll(templateRegex);
for (const match of matches) {
for (const match of matches) { const referenceName = match[1].trim();
const referenceName = match[1].trim();
// Проверяем, что имя ссылки не пустое
// Проверяем, что имя ссылки не пустое if (!referenceName) {
if (!referenceName) { errors.push(`Пустая ссылка в позиции ${match.index}`);
errors.push(`Пустая ссылка в позиции ${match.index}`); continue;
continue; }
}
// Проверяем, что имя содержит только допустимые символы
// Проверяем, что имя содержит только допустимые символы if (!/^[a-zA-Z0-9_-]+$/.test(referenceName)) {
if (!/^[a-zA-Z0-9_-]+$/.test(referenceName)) { errors.push(`Недопустимые символы в ссылке "${referenceName}"`);
errors.push(`Недопустимые символы в ссылке "${referenceName}"`); }
} }
}
return {
return { valid: errors.length === 0,
valid: errors.length === 0, errors: errors
errors: errors };
}; }
}
/**
/** * Извлекает все шаблонные ссылки из промпта, основываясь на заданных префиксах.
* Извлекает все шаблонные ссылки @{...} из промпта * По умолчанию ищет ссылки с префиксом @{...}.
* @param promptContent - содержимое промпта * @param promptContent - содержимое промпта
* @returns массив имен ссылок * @param prefixes - массив префиксов, по которым нужно искать ссылки (например, ['@'], ['#'], ['@', '#']). По умолчанию ['@'].
*/ * @returns массив имен ссылок
static extractTemplateReferences(promptContent: string): string[] { */
const references: string[] = []; static extractTemplateReferences(promptContent: string, prefixes: Array<'@' | '#'> = ['@']): string[] {
const templateRegex = /@\{([^}]+)\}/g; const references: string[] = [];
const matches = promptContent.matchAll(templateRegex); let prefixRegexPart: string;
for (const match of matches) { if (prefixes.includes('@') && prefixes.includes('#')) {
const referenceName = match[1].trim(); prefixRegexPart = '[#@]'; // Ищет как @, так и #
if (referenceName && !references.includes(referenceName)) { } else if (prefixes.includes('@')) {
references.push(referenceName); prefixRegexPart = '@'; // Ищет только @
} } else if (prefixes.includes('#')) {
} prefixRegexPart = '#'; // Ищет только #
} else {
return references; // Если пустой массив или неподдерживаемые префиксы, не ищем ничего
} return [];
} }
// Регулярное выражение для поиска ссылок с динамическим префиксом
const templateRegex = new RegExp(`${prefixRegexPart}\\{([^}]+)\\}`, 'g');
const matches = promptContent.matchAll(templateRegex);
for (const match of matches) {
const referenceName = match[1].trim();
if (referenceName && !references.includes(referenceName)) {
references.push(referenceName);
}
}
return references;
}
}

View File

@ -10,121 +10,124 @@ import { ReferenceParser } from './ReferenceParser';
// ----------------------------------------------- Template Engine --------------------------------------------------------------- // ----------------------------------------------- Template Engine ---------------------------------------------------------------
export class TemplateEngine { export class TemplateEngine {
private plugin: LLMAgentPlugin; private plugin: LLMAgentPlugin;
constructor(plugin: LLMAgentPlugin) { constructor(plugin: LLMAgentPlugin) {
this.plugin = plugin; this.plugin = plugin;
} }
// ----------------------------------------------- Public Methods --------------------------------------------------------------- // ----------------------------------------------- Public Methods ---------------------------------------------------------------
/** /**
* Рекурсивно разворачивает промпт, заменяя @{...} ссылки * Рекурсивно разворачивает промпт, заменяя @{...} ссылки.
* ВАЖНО: НЕ поддерживает рекурсию - только один уровень вложенности * Обнаруживает и предотвращает бесконечную рекурсию при циклических ссылках.
* @param promptContent - содержимое промпта * @param promptContent - содержимое промпта
* @returns развернутый промпт * @param expandedReferences - внутренний Set для отслеживания уже развернутых ссылок в текущей цепочке рекурсии
*/ * @returns развернутый промпт
async expandPromptTemplate(promptContent: string): Promise<string> { */
// Находим все шаблонные ссылки @{...} async expandPromptTemplate(promptContent: string, expandedReferences: Set<string> = new Set<string>()): Promise<string> {
const templateReferences = ReferenceParser.extractTemplateReferences(promptContent); // Находим все шаблонные ссылки @{...}
const templateReferences = ReferenceParser.extractTemplateReferences(promptContent, ['@', '#']);
if (templateReferences.length === 0) {
return promptContent;
}
let expandedContent = promptContent;
// Заменяем каждую ссылку на содержимое соответствующего промпта
for (const referenceName of templateReferences) {
const referencedContent = await this.loadPromptContent(referenceName);
if (referencedContent !== null) {
// Заменяем все вхождения @{referenceName} на содержимое
const templatePattern = new RegExp(`@\\{${this.escapeRegExp(referenceName)}\\}`, 'g');
expandedContent = expandedContent.replace(templatePattern, referencedContent);
} else {
// Если промпт не найден, оставляем ссылку как есть
console.warn(`Промпт "${referenceName}" не найден`);
}
}
return expandedContent;
}
/** if (templateReferences.length === 0) {
* Валидирует промпт на корректность @{...} ссылок return promptContent;
* @param promptContent - содержимое промпта }
* @returns результат валидации
*/
validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } {
const parseResult = ReferenceParser.validatePromptReferences(promptContent);
const errors = [...parseResult.errors];
// Дополнительная проверка - существуют ли referenced промпты
const templateReferences = ReferenceParser.extractTemplateReferences(promptContent);
for (const referenceName of templateReferences) {
const promptFile = this.findPromptFile(referenceName);
if (!promptFile) {
errors.push(`Промпт "${referenceName}" не найден в папке ${this.plugin.settings.promptsFolder}`);
}
}
return {
valid: errors.length === 0,
errors: errors
};
}
// ----------------------------------------------- Private Methods --------------------------------------------------------------- // Получаем все файлы в хранилище Obsidian один раз для всей цепочки рекурсии
// Это позволяет избежать повторных вызовов vault.getFiles()
const allFiles = this.plugin.app.vault.getFiles();
/** let expandedContent = promptContent;
* Загружает содержимое промпта по имени
* @param promptName - имя промпта
* @returns содержимое промпта или null если не найден
*/
private async loadPromptContent(promptName: string): Promise<string | null> {
const promptFile = this.findPromptFile(promptName);
if (!promptFile) {
return null;
}
try {
const content = await this.plugin.app.vault.read(promptFile);
return content.trim();
} catch (error) {
console.error(`Ошибка чтения промпта "${promptName}":`, error);
return null;
}
}
/** // Заменяем каждую ссылку на содержимое соответствующего промпта
* Находит файл промпта по имени for (const referenceName of templateReferences) {
* @param promptName - имя промпта if (expandedReferences.has(referenceName)) {
* @returns файл промпта или null console.warn(
*/ `Обнаружена циклическая ссылка на промпт "${referenceName}". Игнорируем для предотвращения бесконечной рекурсии.`
private findPromptFile(promptName: string): TFile | null { );
const promptsFolder = this.plugin.settings.promptsFolder; continue; // Пропускаем циклическую ссылку
if (!promptsFolder) { }
return null;
}
// Нормализуем путь к папке промптов
const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, '');
// Ищем файл с именем promptName.md в папке промптов
const promptPath = `${normalizedPromptsPath}/${promptName}.md`;
const file = this.plugin.app.vault.getAbstractFileByPath(promptPath);
return (file instanceof TFile) ? file : null;
}
/** // Добавляем текущую ссылку в Set для отслеживания циклов
* Экранирует специальные символы для регулярного выражения expandedReferences.add(referenceName);
* @param string - строка для экранирования
* @returns экранированная строка const referencedContent = await this.loadPromptContent(referenceName, allFiles);
*/ if (referencedContent !== null) {
private escapeRegExp(string: string): string { // Рекурсивно разворачиваем содержимое вложенного промпта
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const expandedNestedContent = await this.expandPromptTemplate(referencedContent, expandedReferences);
}
} // Заменяем все вхождения @{referenceName} или #{referenceName} на содержимое
const templatePattern = new RegExp(`[#@]?\\{${this.escapeRegExp(referenceName)}\\}`, 'g');
expandedContent = expandedContent.replace(templatePattern, expandedNestedContent);
} else {
// Если промпт не найден, оставляем ссылку как есть
console.warn(`Промпт "${referenceName}" не найден.`);
}
// Удаляем текущую ссылку из Set после её обработки, чтобы не мешать другим веткам рекурсии
expandedReferences.delete(referenceName);
}
return expandedContent;
}
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
/**
* Загружает содержимое промпта по имени
* @param promptName - имя промпта
* @returns содержимое промпта или null если не найден
*/
private async loadPromptContent(promptName: string, allFiles: TFile[]): Promise<string | null> {
const promptFile = this.findPromptFile(promptName, allFiles);
if (!promptFile) {
return null;
}
try {
const content = await this.plugin.app.vault.read(promptFile);
return content.trim();
} catch (error) {
console.error(`Ошибка чтения промпта "${promptName}":`, error);
return null;
}
}
/**
* Находит файл промпта по имени
* @param promptName - имя промпта
* @returns файл промпта или null
*/
private findPromptFile(promptName: string, allFiles: TFile[]): TFile | null {
/*const promptsFolder = this.plugin.settings.promptsFolder;
if (!promptsFolder) {
return null;
}
// Нормализуем путь к папке промптов, убирая начальные/конечные слеши
const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, '');*/
// Ищем файл, который находится в папке промптов (или ее подпапках), является Markdown-файлом
// и его имя (без расширения) совпадает с promptName
for (const file of allFiles) {
// Проверяем, является ли файл Markdown и находится ли он в папке промптов
// Сравниваем имя файла (без расширения) с искомым именем промпта
// Пока без этого: file.path.startsWith(`${normalizedPromptsPath}/`
if (file.extension === 'md' && file.basename === promptName) {
return file;
}
}
return null;
}
/**
* Экранирует специальные символы для регулярного выражения
* @param string - строка для экранирования
* @returns экранированная строка
*/
private escapeRegExp(string: string): string {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
}