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

@ -771,3 +771,22 @@
.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

@ -28,6 +28,8 @@ export class AutocompleteManager {
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 ---------------------------------------------------------------
@ -52,10 +54,7 @@ export class AutocompleteManager {
const lowercaseQuery = query.toLowerCase(); const lowercaseQuery = query.toLowerCase();
return allFiles return allFiles
.filter(item => .filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery))
item.name.toLowerCase().includes(lowercaseQuery) ||
item.path.toLowerCase().includes(lowercaseQuery)
)
.slice(0, 10); .slice(0, 10);
} }
@ -79,10 +78,7 @@ export class AutocompleteManager {
const lowercaseQuery = query.toLowerCase(); const lowercaseQuery = query.toLowerCase();
return allPrompts return allPrompts
.filter(item => .filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery))
item.name.toLowerCase().includes(lowercaseQuery) ||
item.path.toLowerCase().includes(lowercaseQuery)
)
.slice(0, 10); .slice(0, 10);
} }
@ -116,7 +112,18 @@ export class AutocompleteManager {
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,
@ -206,4 +213,24 @@ export class AutocompleteManager {
}) })
); );
} }
/**
* Проверяет, находится ли файл в одной из исключенных папок.
* @param file - файл Obsidian.
* @param excludedFolders - массив нормализованных путей исключенных папок.
* @returns true, если файл находится в исключенной папке.
*/
private isFileInExcludedFolder(file: TFile, excludedFolders: string[]): boolean {
const filePath = file.path.toLowerCase(); // Для регистронезависимого сравнения
for (const folder of excludedFolders) {
const normalizedFolder = folder.toLowerCase();
// Проверяем, начинается ли путь файла с пути исключенной папки
// Или если файл находится прямо в корне исключенной папки (н.п. "folder/file.md")
if (filePath.startsWith(normalizedFolder) ) {
return true;
}
}
return false;
}
} }

View File

@ -25,7 +25,6 @@ export interface FileReference {
// ----------------------------------------------- Reference Parser --------------------------------------------------------------- // ----------------------------------------------- Reference Parser ---------------------------------------------------------------
export class ReferenceParser { export class ReferenceParser {
/** /**
* Парсит текст и находит все @/@@ и #/## ссылки * Парсит текст и находит все @/@@ и #/## ссылки
* @param text - текст для парсинга * @param text - текст для парсинга
@ -124,13 +123,29 @@ export class ReferenceParser {
} }
/** /**
* Извлекает все шаблонные ссылки @{...} из промпта * Извлекает все шаблонные ссылки из промпта, основываясь на заданных префиксах.
* По умолчанию ищет ссылки с префиксом @{...}.
* @param promptContent - содержимое промпта * @param promptContent - содержимое промпта
* @param prefixes - массив префиксов, по которым нужно искать ссылки (например, ['@'], ['#'], ['@', '#']). По умолчанию ['@'].
* @returns массив имен ссылок * @returns массив имен ссылок
*/ */
static extractTemplateReferences(promptContent: string): string[] { static extractTemplateReferences(promptContent: string, prefixes: Array<'@' | '#'> = ['@']): string[] {
const references: string[] = []; const references: string[] = [];
const templateRegex = /@\{([^}]+)\}/g; let prefixRegexPart: string;
if (prefixes.includes('@') && prefixes.includes('#')) {
prefixRegexPart = '[#@]'; // Ищет как @, так и #
} else if (prefixes.includes('@')) {
prefixRegexPart = '@'; // Ищет только @
} else if (prefixes.includes('#')) {
prefixRegexPart = '#'; // Ищет только #
} else {
// Если пустой массив или неподдерживаемые префиксы, не ищем ничего
return [];
}
// Регулярное выражение для поиска ссылок с динамическим префиксом
const templateRegex = new RegExp(`${prefixRegexPart}\\{([^}]+)\\}`, 'g');
const matches = promptContent.matchAll(templateRegex); const matches = promptContent.matchAll(templateRegex);
for (const match of matches) { for (const match of matches) {

View File

@ -19,62 +19,58 @@ export class TemplateEngine {
// ----------------------------------------------- Public Methods --------------------------------------------------------------- // ----------------------------------------------- Public Methods ---------------------------------------------------------------
/** /**
* Рекурсивно разворачивает промпт, заменяя @{...} ссылки * Рекурсивно разворачивает промпт, заменяя @{...} ссылки.
* ВАЖНО: НЕ поддерживает рекурсию - только один уровень вложенности * Обнаруживает и предотвращает бесконечную рекурсию при циклических ссылках.
* @param promptContent - содержимое промпта * @param promptContent - содержимое промпта
* @param expandedReferences - внутренний Set для отслеживания уже развернутых ссылок в текущей цепочке рекурсии
* @returns развернутый промпт * @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) { if (templateReferences.length === 0) {
return promptContent; return promptContent;
} }
// Получаем все файлы в хранилище Obsidian один раз для всей цепочки рекурсии
// Это позволяет избежать повторных вызовов vault.getFiles()
const allFiles = this.plugin.app.vault.getFiles();
let expandedContent = promptContent; let expandedContent = promptContent;
// Заменяем каждую ссылку на содержимое соответствующего промпта // Заменяем каждую ссылку на содержимое соответствующего промпта
for (const referenceName of templateReferences) { for (const referenceName of templateReferences) {
const referencedContent = await this.loadPromptContent(referenceName); if (expandedReferences.has(referenceName)) {
console.warn(
`Обнаружена циклическая ссылка на промпт "${referenceName}". Игнорируем для предотвращения бесконечной рекурсии.`
);
continue; // Пропускаем циклическую ссылку
}
// Добавляем текущую ссылку в Set для отслеживания циклов
expandedReferences.add(referenceName);
const referencedContent = await this.loadPromptContent(referenceName, allFiles);
if (referencedContent !== null) { if (referencedContent !== null) {
// Заменяем все вхождения @{referenceName} на содержимое // Рекурсивно разворачиваем содержимое вложенного промпта
const templatePattern = new RegExp(`@\\{${this.escapeRegExp(referenceName)}\\}`, 'g'); const expandedNestedContent = await this.expandPromptTemplate(referencedContent, expandedReferences);
expandedContent = expandedContent.replace(templatePattern, referencedContent);
// Заменяем все вхождения @{referenceName} или #{referenceName} на содержимое
const templatePattern = new RegExp(`[#@]?\\{${this.escapeRegExp(referenceName)}\\}`, 'g');
expandedContent = expandedContent.replace(templatePattern, expandedNestedContent);
} else { } else {
// Если промпт не найден, оставляем ссылку как есть // Если промпт не найден, оставляем ссылку как есть
console.warn(`Промпт "${referenceName}" не найден`); console.warn(`Промпт "${referenceName}" не найден.`);
} }
// Удаляем текущую ссылку из Set после её обработки, чтобы не мешать другим веткам рекурсии
expandedReferences.delete(referenceName);
} }
return expandedContent; return expandedContent;
} }
/**
* Валидирует промпт на корректность @{...} ссылок
* @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 --------------------------------------------------------------- // ----------------------------------------------- Private Methods ---------------------------------------------------------------
/** /**
@ -82,8 +78,8 @@ export class TemplateEngine {
* @param promptName - имя промпта * @param promptName - имя промпта
* @returns содержимое промпта или null если не найден * @returns содержимое промпта или null если не найден
*/ */
private async loadPromptContent(promptName: string): Promise<string | null> { private async loadPromptContent(promptName: string, allFiles: TFile[]): Promise<string | null> {
const promptFile = this.findPromptFile(promptName); const promptFile = this.findPromptFile(promptName, allFiles);
if (!promptFile) { if (!promptFile) {
return null; return null;
@ -103,20 +99,27 @@ export class TemplateEngine {
* @param promptName - имя промпта * @param promptName - имя промпта
* @returns файл промпта или null * @returns файл промпта или null
*/ */
private findPromptFile(promptName: string): TFile | null { private findPromptFile(promptName: string, allFiles: TFile[]): TFile | null {
const promptsFolder = this.plugin.settings.promptsFolder; /*const promptsFolder = this.plugin.settings.promptsFolder;
if (!promptsFolder) { if (!promptsFolder) {
return null; return null;
} }
// Нормализуем путь к папке промптов // Нормализуем путь к папке промптов, убирая начальные/конечные слеши
const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, ''); const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, '');*/
// Ищем файл с именем promptName.md в папке промптов // Ищем файл, который находится в папке промптов (или ее подпапках), является Markdown-файлом
const promptPath = `${normalizedPromptsPath}/${promptName}.md`; // и его имя (без расширения) совпадает с promptName
const file = this.plugin.app.vault.getAbstractFileByPath(promptPath); for (const file of allFiles) {
// Проверяем, является ли файл Markdown и находится ли он в папке промптов
// Сравниваем имя файла (без расширения) с искомым именем промпта
// Пока без этого: file.path.startsWith(`${normalizedPromptsPath}/`
if (file.extension === 'md' && file.basename === promptName) {
return file;
}
}
return (file instanceof TFile) ? file : null; return null;
} }
/** /**