Excluded folders + recursive extraction of prompts
This commit is contained in:
parent
8e54a65fa9
commit
4b31725936
54
main.ts
54
main.ts
|
|
@ -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(); // Перерисовать настройки для обновления списка
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; /* Поле ввода занимает все доступное пространство */
|
||||
}
|
||||
|
|
@ -9,114 +9,121 @@ import LLMAgentPlugin from 'main';
|
|||
// ----------------------------------------------- Interfaces ---------------------------------------------------------------
|
||||
|
||||
export interface AutocompleteItem {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'prompt';
|
||||
icon: string; // Obsidian icon name
|
||||
file?: TFile; // Ссылка на файл Obsidian
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'prompt';
|
||||
icon: string; // Obsidian icon name
|
||||
file?: TFile; // Ссылка на файл Obsidian
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Autocomplete Manager ---------------------------------------------------------------
|
||||
|
||||
export class AutocompleteManager {
|
||||
private plugin: LLMAgentPlugin;
|
||||
private fileCache: Map<string, AutocompleteItem[]> = new Map();
|
||||
private promptCache: Map<string, AutocompleteItem[]> = new Map();
|
||||
private lastCacheUpdate = 0;
|
||||
private readonly CACHE_TTL = 30000; // 30 секунд
|
||||
private plugin: LLMAgentPlugin;
|
||||
private fileCache: Map<string, AutocompleteItem[]> = new Map();
|
||||
private promptCache: Map<string, AutocompleteItem[]> = new Map();
|
||||
private lastCacheUpdate = 0;
|
||||
private readonly CACHE_TTL = 30000; // 30 секунд
|
||||
|
||||
constructor(plugin: LLMAgentPlugin) {
|
||||
this.plugin = plugin;
|
||||
this.setupFileWatcher();
|
||||
}
|
||||
constructor(plugin: LLMAgentPlugin) {
|
||||
this.plugin = plugin;
|
||||
this.setupFileWatcher();
|
||||
// Слушаем изменения настроек для очистки кеша
|
||||
this.plugin.eventBus.on('settings-changed', this.clearCache.bind(this));
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
|
||||
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Получает список файлов для автокомплита
|
||||
* @param query - поисковый запрос
|
||||
* @returns массив файлов для автокомплита
|
||||
*/
|
||||
async getFileOptions(query: string): Promise<AutocompleteItem[]> {
|
||||
if (!this.plugin.settings.enableFileReferences) {
|
||||
return [];
|
||||
}
|
||||
/**
|
||||
* Получает список файлов для автокомплита
|
||||
* @param query - поисковый запрос
|
||||
* @returns массив файлов для автокомплита
|
||||
*/
|
||||
async getFileOptions(query: string): Promise<AutocompleteItem[]> {
|
||||
if (!this.plugin.settings.enableFileReferences) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await this.updateCacheIfNeeded();
|
||||
await this.updateCacheIfNeeded();
|
||||
|
||||
const allFiles = this.fileCache.get('all') || [];
|
||||
const allFiles = this.fileCache.get('all') || [];
|
||||
|
||||
if (!query.trim()) {
|
||||
return allFiles.slice(0, 10); // Показываем первые 10 файлов
|
||||
}
|
||||
if (!query.trim()) {
|
||||
return allFiles.slice(0, 10); // Показываем первые 10 файлов
|
||||
}
|
||||
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return allFiles
|
||||
.filter(item =>
|
||||
item.name.toLowerCase().includes(lowercaseQuery) ||
|
||||
item.path.toLowerCase().includes(lowercaseQuery)
|
||||
)
|
||||
.slice(0, 10);
|
||||
}
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return allFiles
|
||||
.filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery))
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает список промптов для автокомплита
|
||||
* @param query - поисковый запрос
|
||||
* @returns массив промптов для автокомплита
|
||||
*/
|
||||
async getPromptOptions(query: string): Promise<AutocompleteItem[]> {
|
||||
if (!this.plugin.settings.enablePromptReferences) {
|
||||
return [];
|
||||
}
|
||||
/**
|
||||
* Получает список промптов для автокомплита
|
||||
* @param query - поисковый запрос
|
||||
* @returns массив промптов для автокомплита
|
||||
*/
|
||||
async getPromptOptions(query: string): Promise<AutocompleteItem[]> {
|
||||
if (!this.plugin.settings.enablePromptReferences) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await this.updateCacheIfNeeded();
|
||||
await this.updateCacheIfNeeded();
|
||||
|
||||
const allPrompts = this.promptCache.get('all') || [];
|
||||
const allPrompts = this.promptCache.get('all') || [];
|
||||
|
||||
if (!query.trim()) {
|
||||
return allPrompts.slice(0, 10);
|
||||
}
|
||||
if (!query.trim()) {
|
||||
return allPrompts.slice(0, 10);
|
||||
}
|
||||
|
||||
const lowercaseQuery = query.toLowerCase();
|
||||
return allPrompts
|
||||
.filter(item =>
|
||||
item.name.toLowerCase().includes(lowercaseQuery) ||
|
||||
item.path.toLowerCase().includes(lowercaseQuery)
|
||||
)
|
||||
.slice(0, 10);
|
||||
}
|
||||
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;
|
||||
}
|
||||
/**
|
||||
* Очищает кеш (используется при изменении настроек)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.fileCache.clear();
|
||||
this.promptCache.clear();
|
||||
this.lastCacheUpdate = 0;
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
||||
// ----------------------------------------------- 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 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> {
|
||||
const files = this.plugin.app.vault.getMarkdownFiles();
|
||||
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,
|
||||
|
|
@ -140,9 +147,9 @@ export class AutocompleteManager {
|
|||
this.promptCache.set('all', promptItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли файл промптом
|
||||
*/
|
||||
/**
|
||||
* Проверяет, является ли файл промптом
|
||||
*/
|
||||
private isPromptFile(file: TFile): boolean {
|
||||
const promptsFolder = this.plugin.settings.promptsFolder;
|
||||
if (!promptsFolder) return false;
|
||||
|
|
@ -154,56 +161,76 @@ export class AutocompleteManager {
|
|||
file.path === normalizedPromptsPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает иконку для файла на основе Obsidian иконок
|
||||
*/
|
||||
private getFileIcon(file: TFile): string {
|
||||
if (this.isPromptFile(file)) {
|
||||
return 'zap'; // Иконка промпта
|
||||
}
|
||||
/**
|
||||
* Возвращает иконку для файла на основе Obsidian иконок
|
||||
*/
|
||||
private getFileIcon(file: TFile): string {
|
||||
if (this.isPromptFile(file)) {
|
||||
return 'zap'; // Иконка промпта
|
||||
}
|
||||
|
||||
// Определяем иконку по расширению
|
||||
const extension = file.extension.toLowerCase();
|
||||
switch (extension) {
|
||||
case 'md':
|
||||
return 'file-text';
|
||||
case 'js':
|
||||
case 'ts':
|
||||
return 'code';
|
||||
case 'json':
|
||||
return 'brackets';
|
||||
case 'css':
|
||||
return 'palette';
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
return 'image';
|
||||
default:
|
||||
return 'file';
|
||||
}
|
||||
}
|
||||
// Определяем иконку по расширению
|
||||
const extension = file.extension.toLowerCase();
|
||||
switch (extension) {
|
||||
case 'md':
|
||||
return 'file-text';
|
||||
case 'js':
|
||||
case 'ts':
|
||||
return 'code';
|
||||
case 'json':
|
||||
return 'brackets';
|
||||
case 'css':
|
||||
return 'palette';
|
||||
case 'png':
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'gif':
|
||||
return 'image';
|
||||
default:
|
||||
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 {
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.vault.on('create', () => {
|
||||
this.clearCache();
|
||||
})
|
||||
);
|
||||
private isFileInExcludedFolder(file: TFile, excludedFolders: string[]): boolean {
|
||||
const filePath = file.path.toLowerCase(); // Для регистронезависимого сравнения
|
||||
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.vault.on('delete', () => {
|
||||
this.clearCache();
|
||||
})
|
||||
);
|
||||
|
||||
this.plugin.registerEvent(
|
||||
this.plugin.app.vault.on('rename', () => {
|
||||
this.clearCache();
|
||||
})
|
||||
);
|
||||
for (const folder of excludedFolders) {
|
||||
const normalizedFolder = folder.toLowerCase();
|
||||
// Проверяем, начинается ли путь файла с пути исключенной папки
|
||||
// Или если файл находится прямо в корне исключенной папки (н.п. "folder/file.md")
|
||||
if (filePath.startsWith(normalizedFolder) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,140 +6,155 @@
|
|||
// ----------------------------------------------- Interfaces ---------------------------------------------------------------
|
||||
|
||||
export interface ReferenceMatch {
|
||||
fullMatch: string; // "@filename" или "@@filename"
|
||||
prefix: string; // "@" или "@@"
|
||||
name: string; // "filename"
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
type: 'file' | 'prompt';
|
||||
fullMatch: string; // "@filename" или "@@filename"
|
||||
prefix: string; // "@" или "@@"
|
||||
name: string; // "filename"
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
type: 'file' | 'prompt';
|
||||
}
|
||||
|
||||
export interface FileReference {
|
||||
type: 'file' | 'prompt';
|
||||
name: string;
|
||||
path: string;
|
||||
permanent: boolean; // true для @@ и ##
|
||||
// TODO: добавить snapshotId при реализации снапшотов
|
||||
type: 'file' | 'prompt';
|
||||
name: string;
|
||||
path: string;
|
||||
permanent: boolean; // true для @@ и ##
|
||||
// TODO: добавить snapshotId при реализации снапшотов
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Reference Parser ---------------------------------------------------------------
|
||||
|
||||
export class ReferenceParser {
|
||||
/**
|
||||
* Парсит текст и находит все @/@@ и #/## ссылки
|
||||
* @param text - текст для парсинга
|
||||
* @returns массив найденных ссылок
|
||||
*/
|
||||
static parseReferences(text: string): ReferenceMatch[] {
|
||||
const matches: ReferenceMatch[] = [];
|
||||
|
||||
/**
|
||||
* Парсит текст и находит все @/@@ и #/## ссылки
|
||||
* @param text - текст для парсинга
|
||||
* @returns массив найденных ссылок
|
||||
*/
|
||||
static parseReferences(text: string): ReferenceMatch[] {
|
||||
const matches: ReferenceMatch[] = [];
|
||||
// Регулярное выражение для поиска @/@@ и #/## ссылок
|
||||
// Поддерживает имена файлов с пробелами, заключенные в кавычки или без пробелов
|
||||
const regex = /(@{1,2}|#{1,2})(?:"([^"]+)"|([^\s@#"]+))/g;
|
||||
|
||||
// Регулярное выражение для поиска @/@@ и #/## ссылок
|
||||
// Поддерживает имена файлов с пробелами, заключенные в кавычки или без пробелов
|
||||
const regex = /(@{1,2}|#{1,2})(?:"([^"]+)"|([^\s@#"]+))/g;
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const prefix = match[1];
|
||||
const quotedName = match[2];
|
||||
const unquotedName = match[3];
|
||||
const name = quotedName || unquotedName;
|
||||
|
||||
let match;
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
const prefix = match[1];
|
||||
const quotedName = match[2];
|
||||
const unquotedName = match[3];
|
||||
const name = quotedName || unquotedName;
|
||||
matches.push({
|
||||
fullMatch: match[0],
|
||||
prefix: prefix,
|
||||
name: name,
|
||||
startIndex: match.index,
|
||||
endIndex: match.index + match[0].length,
|
||||
type: prefix.startsWith('@') ? 'file' : 'prompt'
|
||||
});
|
||||
}
|
||||
|
||||
matches.push({
|
||||
fullMatch: match[0],
|
||||
prefix: prefix,
|
||||
name: name,
|
||||
startIndex: match.index,
|
||||
endIndex: match.index + match[0].length,
|
||||
type: prefix.startsWith('@') ? 'file' : 'prompt'
|
||||
});
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
/**
|
||||
* Проверяет, является ли позиция курсора внутри незавершённой ссылки
|
||||
* @param text - текст сообщения
|
||||
* @param cursorPosition - позиция курсора
|
||||
* @returns информация о текущей ссылке или null
|
||||
*/
|
||||
static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null {
|
||||
// Ищем незавершенные ссылки перед курсором
|
||||
const beforeCursor = text.substring(0, cursorPosition);
|
||||
|
||||
/**
|
||||
* Проверяет, является ли позиция курсора внутри незавершённой ссылки
|
||||
* @param text - текст сообщения
|
||||
* @param cursorPosition - позиция курсора
|
||||
* @returns информация о текущей ссылке или null
|
||||
*/
|
||||
static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null {
|
||||
// Ищем незавершенные ссылки перед курсором
|
||||
const beforeCursor = text.substring(0, cursorPosition);
|
||||
// Регулярное выражение для поиска начала ссылки без завершения
|
||||
const incompleteRegex = /(@{1,2}|#{1,2})([^\s@#"]*?)$/;
|
||||
const match = beforeCursor.match(incompleteRegex);
|
||||
|
||||
// Регулярное выражение для поиска начала ссылки без завершения
|
||||
const incompleteRegex = /(@{1,2}|#{1,2})([^\s@#"]*?)$/;
|
||||
const match = beforeCursor.match(incompleteRegex);
|
||||
if (match) {
|
||||
const prefix = match[1];
|
||||
const partialName = match[2];
|
||||
const startIndex = beforeCursor.length - match[0].length;
|
||||
|
||||
if (match) {
|
||||
const prefix = match[1];
|
||||
const partialName = match[2];
|
||||
const startIndex = beforeCursor.length - match[0].length;
|
||||
return {
|
||||
fullMatch: match[0],
|
||||
prefix: prefix,
|
||||
name: partialName,
|
||||
startIndex: startIndex,
|
||||
endIndex: cursorPosition,
|
||||
type: prefix.startsWith('@') ? 'file' : 'prompt'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
fullMatch: match[0],
|
||||
prefix: prefix,
|
||||
name: partialName,
|
||||
startIndex: startIndex,
|
||||
endIndex: cursorPosition,
|
||||
type: prefix.startsWith('@') ? 'file' : 'prompt'
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Валидирует промпт на корректность @{...} ссылок
|
||||
* @param promptContent - содержимое промпта
|
||||
* @returns результат валидации
|
||||
*/
|
||||
static validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
/**
|
||||
* Валидирует промпт на корректность @{...} ссылок
|
||||
* @param promptContent - содержимое промпта
|
||||
* @returns результат валидации
|
||||
*/
|
||||
static validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
// Ищем шаблонные ссылки @{...}
|
||||
const templateRegex = /@\{([^}]+)\}/g;
|
||||
const matches = promptContent.matchAll(templateRegex);
|
||||
|
||||
// Ищем шаблонные ссылки @{...}
|
||||
const templateRegex = /@\{([^}]+)\}/g;
|
||||
const matches = promptContent.matchAll(templateRegex);
|
||||
for (const match of matches) {
|
||||
const referenceName = match[1].trim();
|
||||
|
||||
for (const match of matches) {
|
||||
const referenceName = match[1].trim();
|
||||
// Проверяем, что имя ссылки не пустое
|
||||
if (!referenceName) {
|
||||
errors.push(`Пустая ссылка в позиции ${match.index}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем, что имя ссылки не пустое
|
||||
if (!referenceName) {
|
||||
errors.push(`Пустая ссылка в позиции ${match.index}`);
|
||||
continue;
|
||||
}
|
||||
// Проверяем, что имя содержит только допустимые символы
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(referenceName)) {
|
||||
errors.push(`Недопустимые символы в ссылке "${referenceName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем, что имя содержит только допустимые символы
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(referenceName)) {
|
||||
errors.push(`Недопустимые символы в ссылке "${referenceName}"`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Извлекает все шаблонные ссылки из промпта, основываясь на заданных префиксах.
|
||||
* По умолчанию ищет ссылки с префиксом @{...}.
|
||||
* @param promptContent - содержимое промпта
|
||||
* @param prefixes - массив префиксов, по которым нужно искать ссылки (например, ['@'], ['#'], ['@', '#']). По умолчанию ['@'].
|
||||
* @returns массив имен ссылок
|
||||
*/
|
||||
static extractTemplateReferences(promptContent: string, prefixes: Array<'@' | '#'> = ['@']): string[] {
|
||||
const references: string[] = [];
|
||||
let prefixRegexPart: string;
|
||||
|
||||
/**
|
||||
* Извлекает все шаблонные ссылки @{...} из промпта
|
||||
* @param promptContent - содержимое промпта
|
||||
* @returns массив имен ссылок
|
||||
*/
|
||||
static extractTemplateReferences(promptContent: string): string[] {
|
||||
const references: string[] = [];
|
||||
const templateRegex = /@\{([^}]+)\}/g;
|
||||
const matches = promptContent.matchAll(templateRegex);
|
||||
if (prefixes.includes('@') && prefixes.includes('#')) {
|
||||
prefixRegexPart = '[#@]'; // Ищет как @, так и #
|
||||
} else if (prefixes.includes('@')) {
|
||||
prefixRegexPart = '@'; // Ищет только @
|
||||
} else if (prefixes.includes('#')) {
|
||||
prefixRegexPart = '#'; // Ищет только #
|
||||
} else {
|
||||
// Если пустой массив или неподдерживаемые префиксы, не ищем ничего
|
||||
return [];
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
const referenceName = match[1].trim();
|
||||
if (referenceName && !references.includes(referenceName)) {
|
||||
references.push(referenceName);
|
||||
}
|
||||
}
|
||||
// Регулярное выражение для поиска ссылок с динамическим префиксом
|
||||
const templateRegex = new RegExp(`${prefixRegexPart}\\{([^}]+)\\}`, 'g');
|
||||
const matches = promptContent.matchAll(templateRegex);
|
||||
|
||||
return references;
|
||||
}
|
||||
for (const match of matches) {
|
||||
const referenceName = match[1].trim();
|
||||
if (referenceName && !references.includes(referenceName)) {
|
||||
references.push(referenceName);
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
}
|
||||
|
|
@ -10,121 +10,124 @@ import { ReferenceParser } from './ReferenceParser';
|
|||
// ----------------------------------------------- Template Engine ---------------------------------------------------------------
|
||||
|
||||
export class TemplateEngine {
|
||||
private plugin: LLMAgentPlugin;
|
||||
private plugin: LLMAgentPlugin;
|
||||
|
||||
constructor(plugin: LLMAgentPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
constructor(plugin: LLMAgentPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
|
||||
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Рекурсивно разворачивает промпт, заменяя @{...} ссылки
|
||||
* ВАЖНО: НЕ поддерживает рекурсию - только один уровень вложенности
|
||||
* @param promptContent - содержимое промпта
|
||||
* @returns развернутый промпт
|
||||
*/
|
||||
async expandPromptTemplate(promptContent: string): Promise<string> {
|
||||
// Находим все шаблонные ссылки @{...}
|
||||
const templateReferences = ReferenceParser.extractTemplateReferences(promptContent);
|
||||
/**
|
||||
* Рекурсивно разворачивает промпт, заменяя @{...} ссылки.
|
||||
* Обнаруживает и предотвращает бесконечную рекурсию при циклических ссылках.
|
||||
* @param promptContent - содержимое промпта
|
||||
* @param expandedReferences - внутренний Set для отслеживания уже развернутых ссылок в текущей цепочке рекурсии
|
||||
* @returns развернутый промпт
|
||||
*/
|
||||
async expandPromptTemplate(promptContent: string, expandedReferences: Set<string> = new Set<string>()): Promise<string> {
|
||||
// Находим все шаблонные ссылки @{...}
|
||||
const templateReferences = ReferenceParser.extractTemplateReferences(promptContent, ['@', '#']);
|
||||
|
||||
if (templateReferences.length === 0) {
|
||||
return promptContent;
|
||||
}
|
||||
if (templateReferences.length === 0) {
|
||||
return promptContent;
|
||||
}
|
||||
|
||||
let expandedContent = promptContent;
|
||||
// Получаем все файлы в хранилище Obsidian один раз для всей цепочки рекурсии
|
||||
// Это позволяет избежать повторных вызовов vault.getFiles()
|
||||
const allFiles = this.plugin.app.vault.getFiles();
|
||||
|
||||
// Заменяем каждую ссылку на содержимое соответствующего промпта
|
||||
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}" не найден`);
|
||||
}
|
||||
}
|
||||
let expandedContent = promptContent;
|
||||
|
||||
return expandedContent;
|
||||
}
|
||||
// Заменяем каждую ссылку на содержимое соответствующего промпта
|
||||
for (const referenceName of templateReferences) {
|
||||
if (expandedReferences.has(referenceName)) {
|
||||
console.warn(
|
||||
`Обнаружена циклическая ссылка на промпт "${referenceName}". Игнорируем для предотвращения бесконечной рекурсии.`
|
||||
);
|
||||
continue; // Пропускаем циклическую ссылку
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует промпт на корректность @{...} ссылок
|
||||
* @param promptContent - содержимое промпта
|
||||
* @returns результат валидации
|
||||
*/
|
||||
validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } {
|
||||
const parseResult = ReferenceParser.validatePromptReferences(promptContent);
|
||||
const errors = [...parseResult.errors];
|
||||
// Добавляем текущую ссылку в Set для отслеживания циклов
|
||||
expandedReferences.add(referenceName);
|
||||
|
||||
// Дополнительная проверка - существуют ли referenced промпты
|
||||
const templateReferences = ReferenceParser.extractTemplateReferences(promptContent);
|
||||
const referencedContent = await this.loadPromptContent(referenceName, allFiles);
|
||||
if (referencedContent !== null) {
|
||||
// Рекурсивно разворачиваем содержимое вложенного промпта
|
||||
const expandedNestedContent = await this.expandPromptTemplate(referencedContent, expandedReferences);
|
||||
|
||||
for (const referenceName of templateReferences) {
|
||||
const promptFile = this.findPromptFile(referenceName);
|
||||
if (!promptFile) {
|
||||
errors.push(`Промпт "${referenceName}" не найден в папке ${this.plugin.settings.promptsFolder}`);
|
||||
}
|
||||
}
|
||||
// Заменяем все вхождения @{referenceName} или #{referenceName} на содержимое
|
||||
const templatePattern = new RegExp(`[#@]?\\{${this.escapeRegExp(referenceName)}\\}`, 'g');
|
||||
expandedContent = expandedContent.replace(templatePattern, expandedNestedContent);
|
||||
} else {
|
||||
// Если промпт не найден, оставляем ссылку как есть
|
||||
console.warn(`Промпт "${referenceName}" не найден.`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors
|
||||
};
|
||||
}
|
||||
// Удаляем текущую ссылку из Set после её обработки, чтобы не мешать другим веткам рекурсии
|
||||
expandedReferences.delete(referenceName);
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
||||
return expandedContent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает содержимое промпта по имени
|
||||
* @param promptName - имя промпта
|
||||
* @returns содержимое промпта или null если не найден
|
||||
*/
|
||||
private async loadPromptContent(promptName: string): Promise<string | null> {
|
||||
const promptFile = this.findPromptFile(promptName);
|
||||
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
||||
|
||||
if (!promptFile) {
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* Загружает содержимое промпта по имени
|
||||
* @param promptName - имя промпта
|
||||
* @returns содержимое промпта или null если не найден
|
||||
*/
|
||||
private async loadPromptContent(promptName: string, allFiles: TFile[]): Promise<string | null> {
|
||||
const promptFile = this.findPromptFile(promptName, allFiles);
|
||||
|
||||
try {
|
||||
const content = await this.plugin.app.vault.read(promptFile);
|
||||
return content.trim();
|
||||
} catch (error) {
|
||||
console.error(`Ошибка чтения промпта "${promptName}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!promptFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит файл промпта по имени
|
||||
* @param promptName - имя промпта
|
||||
* @returns файл промпта или null
|
||||
*/
|
||||
private findPromptFile(promptName: string): TFile | null {
|
||||
const promptsFolder = this.plugin.settings.promptsFolder;
|
||||
if (!promptsFolder) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const content = await this.plugin.app.vault.read(promptFile);
|
||||
return content.trim();
|
||||
} catch (error) {
|
||||
console.error(`Ошибка чтения промпта "${promptName}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Нормализуем путь к папке промптов
|
||||
const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, '');
|
||||
/**
|
||||
* Находит файл промпта по имени
|
||||
* @param promptName - имя промпта
|
||||
* @returns файл промпта или null
|
||||
*/
|
||||
private findPromptFile(promptName: string, allFiles: TFile[]): TFile | null {
|
||||
/*const promptsFolder = this.plugin.settings.promptsFolder;
|
||||
if (!promptsFolder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ищем файл с именем promptName.md в папке промптов
|
||||
const promptPath = `${normalizedPromptsPath}/${promptName}.md`;
|
||||
const file = this.plugin.app.vault.getAbstractFileByPath(promptPath);
|
||||
// Нормализуем путь к папке промптов, убирая начальные/конечные слеши
|
||||
const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, '');*/
|
||||
|
||||
return (file instanceof TFile) ? file : null;
|
||||
}
|
||||
// Ищем файл, который находится в папке промптов (или ее подпапках), является Markdown-файлом
|
||||
// и его имя (без расширения) совпадает с promptName
|
||||
for (const file of allFiles) {
|
||||
// Проверяем, является ли файл Markdown и находится ли он в папке промптов
|
||||
// Сравниваем имя файла (без расширения) с искомым именем промпта
|
||||
// Пока без этого: file.path.startsWith(`${normalizedPromptsPath}/`
|
||||
if (file.extension === 'md' && file.basename === promptName) {
|
||||
return file;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирует специальные символы для регулярного выражения
|
||||
* @param string - строка для экранирования
|
||||
* @returns экранированная строка
|
||||
*/
|
||||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирует специальные символы для регулярного выражения
|
||||
* @param string - строка для экранирования
|
||||
* @returns экранированная строка
|
||||
*/
|
||||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user