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; // Новое поле
enableFileReferences: boolean; // Новое поле
enablePromptReferences: boolean; // Новое поле
excludedFolders: string[]; // Новое поле: Список папок для исключения из автокомплита
}
const DEFAULT_SETTINGS: LLMAgentSettings = {
@ -22,7 +23,8 @@ const DEFAULT_SETTINGS: LLMAgentSettings = {
defaultModel: 'gemini-2.5-flash',
promptsFolder: 'prompts', // Папка с промптами по умолчанию
enableFileReferences: true,
enablePromptReferences: true
enablePromptReferences: true,
excludedFolders: ['templates'] // Папка "templates" по умолчанию исключена
};
// ----------------------------------------------- Main Plugin Class ---------------------------------------------------------------
@ -198,5 +200,55 @@ class SampleSettingTab extends PluginSettingTab {
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 {
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) {
this.plugin = plugin;
this.setupFileWatcher();
// Слушаем изменения настроек для очистки кеша
this.plugin.eventBus.on('settings-changed', this.clearCache.bind(this));
}
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
@ -52,10 +54,7 @@ export class AutocompleteManager {
const lowercaseQuery = query.toLowerCase();
return allFiles
.filter(item =>
item.name.toLowerCase().includes(lowercaseQuery) ||
item.path.toLowerCase().includes(lowercaseQuery)
)
.filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery))
.slice(0, 10);
}
@ -79,10 +78,7 @@ export class AutocompleteManager {
const lowercaseQuery = query.toLowerCase();
return allPrompts
.filter(item =>
item.name.toLowerCase().includes(lowercaseQuery) ||
item.path.toLowerCase().includes(lowercaseQuery)
)
.filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery))
.slice(0, 10);
}
@ -116,7 +112,18 @@ export class AutocompleteManager {
const fileItems: 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) {
// Пропускаем файлы из исключенных папок
if (this.isFileInExcludedFolder(file, normalizedExcludedFolders)) {
continue;
}
const item: AutocompleteItem = {
name: file.basename,
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 ---------------------------------------------------------------
export class ReferenceParser {
/**
* Парсит текст и находит все @/@@ и #/## ссылки
* @param text - текст для парсинга
@ -124,13 +123,29 @@ export class ReferenceParser {
}
/**
* Извлекает все шаблонные ссылки @{...} из промпта
* Извлекает все шаблонные ссылки из промпта, основываясь на заданных префиксах.
* По умолчанию ищет ссылки с префиксом @{...}.
* @param promptContent - содержимое промпта
* @param prefixes - массив префиксов, по которым нужно искать ссылки (например, ['@'], ['#'], ['@', '#']). По умолчанию ['@'].
* @returns массив имен ссылок
*/
static extractTemplateReferences(promptContent: string): string[] {
static extractTemplateReferences(promptContent: string, prefixes: Array<'@' | '#'> = ['@']): 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);
for (const match of matches) {

View File

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