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; // Новое поле
|
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(); // Перерисовать настройки для обновления списка
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; /* Поле ввода занимает все доступное пространство */
|
||||||
|
}
|
||||||
|
|
@ -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') || [];
|
const allFiles = this.fileCache.get('all') || [];
|
||||||
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return allFiles.slice(0, 10); // Показываем первые 10 файлов
|
return allFiles.slice(0, 10); // Показываем первые 10 файлов
|
||||||
}
|
}
|
||||||
|
|
||||||
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) ||
|
.slice(0, 10);
|
||||||
item.path.toLowerCase().includes(lowercaseQuery)
|
}
|
||||||
)
|
|
||||||
.slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получает список промптов для автокомплита
|
* Получает список промптов для автокомплита
|
||||||
* @param query - поисковый запрос
|
* @param query - поисковый запрос
|
||||||
* @returns массив промптов для автокомплита
|
* @returns массив промптов для автокомплита
|
||||||
*/
|
*/
|
||||||
async getPromptOptions(query: string): Promise<AutocompleteItem[]> {
|
async getPromptOptions(query: string): Promise<AutocompleteItem[]> {
|
||||||
if (!this.plugin.settings.enablePromptReferences) {
|
if (!this.plugin.settings.enablePromptReferences) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateCacheIfNeeded();
|
await this.updateCacheIfNeeded();
|
||||||
|
|
||||||
const allPrompts = this.promptCache.get('all') || [];
|
const allPrompts = this.promptCache.get('all') || [];
|
||||||
|
|
||||||
if (!query.trim()) {
|
if (!query.trim()) {
|
||||||
return allPrompts.slice(0, 10);
|
return allPrompts.slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) ||
|
.slice(0, 10);
|
||||||
item.path.toLowerCase().includes(lowercaseQuery)
|
}
|
||||||
)
|
|
||||||
.slice(0, 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Очищает кеш (используется при изменении настроек)
|
* Очищает кеш (используется при изменении настроек)
|
||||||
*/
|
*/
|
||||||
clearCache(): void {
|
clearCache(): void {
|
||||||
this.fileCache.clear();
|
this.fileCache.clear();
|
||||||
this.promptCache.clear();
|
this.promptCache.clear();
|
||||||
this.lastCacheUpdate = 0;
|
this.lastCacheUpdate = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновляет кеш, если он устарел
|
* Обновляет кеш, если он устарел
|
||||||
*/
|
*/
|
||||||
private async updateCacheIfNeeded(): Promise<void> {
|
private async updateCacheIfNeeded(): Promise<void> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - this.lastCacheUpdate > this.CACHE_TTL) {
|
if (now - this.lastCacheUpdate > this.CACHE_TTL) {
|
||||||
await this.updateCache();
|
await this.updateCache();
|
||||||
this.lastCacheUpdate = now;
|
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,
|
||||||
|
|
@ -140,9 +147,9 @@ export class AutocompleteManager {
|
||||||
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;
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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 - текст для парсинга
|
||||||
|
* @returns массив найденных ссылок
|
||||||
|
*/
|
||||||
|
static parseReferences(text: string): ReferenceMatch[] {
|
||||||
|
const matches: ReferenceMatch[] = [];
|
||||||
|
|
||||||
/**
|
// Регулярное выражение для поиска @/@@ и #/## ссылок
|
||||||
* Парсит текст и находит все @/@@ и #/## ссылки
|
// Поддерживает имена файлов с пробелами, заключенные в кавычки или без пробелов
|
||||||
* @param text - текст для парсинга
|
const regex = /(@{1,2}|#{1,2})(?:"([^"]+)"|([^\s@#"]+))/g;
|
||||||
* @returns массив найденных ссылок
|
|
||||||
*/
|
|
||||||
static parseReferences(text: string): ReferenceMatch[] {
|
|
||||||
const matches: ReferenceMatch[] = [];
|
|
||||||
|
|
||||||
// Регулярное выражение для поиска @/@@ и #/## ссылок
|
let match;
|
||||||
// Поддерживает имена файлов с пробелами, заключенные в кавычки или без пробелов
|
while ((match = regex.exec(text)) !== null) {
|
||||||
const regex = /(@{1,2}|#{1,2})(?:"([^"]+)"|([^\s@#"]+))/g;
|
const prefix = match[1];
|
||||||
|
const quotedName = match[2];
|
||||||
|
const unquotedName = match[3];
|
||||||
|
const name = quotedName || unquotedName;
|
||||||
|
|
||||||
let match;
|
matches.push({
|
||||||
while ((match = regex.exec(text)) !== null) {
|
fullMatch: match[0],
|
||||||
const prefix = match[1];
|
prefix: prefix,
|
||||||
const quotedName = match[2];
|
name: name,
|
||||||
const unquotedName = match[3];
|
startIndex: match.index,
|
||||||
const name = quotedName || unquotedName;
|
endIndex: match.index + match[0].length,
|
||||||
|
type: prefix.startsWith('@') ? 'file' : 'prompt'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
matches.push({
|
return matches;
|
||||||
fullMatch: match[0],
|
}
|
||||||
prefix: prefix,
|
|
||||||
name: name,
|
|
||||||
startIndex: match.index,
|
|
||||||
endIndex: match.index + match[0].length,
|
|
||||||
type: prefix.startsWith('@') ? 'file' : 'prompt'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches;
|
/**
|
||||||
}
|
* Проверяет, является ли позиция курсора внутри незавершённой ссылки
|
||||||
|
* @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@#"]*?)$/;
|
||||||
* @param text - текст сообщения
|
const match = beforeCursor.match(incompleteRegex);
|
||||||
* @param cursorPosition - позиция курсора
|
|
||||||
* @returns информация о текущей ссылке или null
|
|
||||||
*/
|
|
||||||
static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null {
|
|
||||||
// Ищем незавершенные ссылки перед курсором
|
|
||||||
const beforeCursor = text.substring(0, cursorPosition);
|
|
||||||
|
|
||||||
// Регулярное выражение для поиска начала ссылки без завершения
|
if (match) {
|
||||||
const incompleteRegex = /(@{1,2}|#{1,2})([^\s@#"]*?)$/;
|
const prefix = match[1];
|
||||||
const match = beforeCursor.match(incompleteRegex);
|
const partialName = match[2];
|
||||||
|
const startIndex = beforeCursor.length - match[0].length;
|
||||||
|
|
||||||
if (match) {
|
return {
|
||||||
const prefix = match[1];
|
fullMatch: match[0],
|
||||||
const partialName = match[2];
|
prefix: prefix,
|
||||||
const startIndex = beforeCursor.length - match[0].length;
|
name: partialName,
|
||||||
|
startIndex: startIndex,
|
||||||
|
endIndex: cursorPosition,
|
||||||
|
type: prefix.startsWith('@') ? 'file' : 'prompt'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return null;
|
||||||
fullMatch: match[0],
|
}
|
||||||
prefix: prefix,
|
|
||||||
name: partialName,
|
|
||||||
startIndex: startIndex,
|
|
||||||
endIndex: cursorPosition,
|
|
||||||
type: prefix.startsWith('@') ? 'file' : 'prompt'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
/**
|
||||||
}
|
* Валидирует промпт на корректность @{...} ссылок
|
||||||
|
* @param promptContent - содержимое промпта
|
||||||
|
* @returns результат валидации
|
||||||
|
*/
|
||||||
|
static validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } {
|
||||||
|
const errors: string[] = [];
|
||||||
|
|
||||||
/**
|
// Ищем шаблонные ссылки @{...}
|
||||||
* Валидирует промпт на корректность @{...} ссылок
|
const templateRegex = /@\{([^}]+)\}/g;
|
||||||
* @param promptContent - содержимое промпта
|
const matches = promptContent.matchAll(templateRegex);
|
||||||
* @returns результат валидации
|
|
||||||
*/
|
|
||||||
static validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } {
|
|
||||||
const errors: string[] = [];
|
|
||||||
|
|
||||||
// Ищем шаблонные ссылки @{...}
|
for (const match of matches) {
|
||||||
const templateRegex = /@\{([^}]+)\}/g;
|
const referenceName = match[1].trim();
|
||||||
const matches = promptContent.matchAll(templateRegex);
|
|
||||||
|
|
||||||
for (const match of matches) {
|
// Проверяем, что имя ссылки не пустое
|
||||||
const referenceName = match[1].trim();
|
if (!referenceName) {
|
||||||
|
errors.push(`Пустая ссылка в позиции ${match.index}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем, что имя ссылки не пустое
|
// Проверяем, что имя содержит только допустимые символы
|
||||||
if (!referenceName) {
|
if (!/^[a-zA-Z0-9_-]+$/.test(referenceName)) {
|
||||||
errors.push(`Пустая ссылка в позиции ${match.index}`);
|
errors.push(`Недопустимые символы в ссылке "${referenceName}"`);
|
||||||
continue;
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем, что имя содержит только допустимые символы
|
return {
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(referenceName)) {
|
valid: errors.length === 0,
|
||||||
errors.push(`Недопустимые символы в ссылке "${referenceName}"`);
|
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;
|
||||||
|
|
||||||
/**
|
if (prefixes.includes('@') && prefixes.includes('#')) {
|
||||||
* Извлекает все шаблонные ссылки @{...} из промпта
|
prefixRegexPart = '[#@]'; // Ищет как @, так и #
|
||||||
* @param promptContent - содержимое промпта
|
} else if (prefixes.includes('@')) {
|
||||||
* @returns массив имен ссылок
|
prefixRegexPart = '@'; // Ищет только @
|
||||||
*/
|
} else if (prefixes.includes('#')) {
|
||||||
static extractTemplateReferences(promptContent: string): string[] {
|
prefixRegexPart = '#'; // Ищет только #
|
||||||
const references: string[] = [];
|
} else {
|
||||||
const templateRegex = /@\{([^}]+)\}/g;
|
// Если пустой массив или неподдерживаемые префиксы, не ищем ничего
|
||||||
const matches = promptContent.matchAll(templateRegex);
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
for (const match of matches) {
|
// Регулярное выражение для поиска ссылок с динамическим префиксом
|
||||||
const referenceName = match[1].trim();
|
const templateRegex = new RegExp(`${prefixRegexPart}\\{([^}]+)\\}`, 'g');
|
||||||
if (referenceName && !references.includes(referenceName)) {
|
const matches = promptContent.matchAll(templateRegex);
|
||||||
references.push(referenceName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 ---------------------------------------------------------------
|
// ----------------------------------------------- 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) {
|
if (templateReferences.length === 0) {
|
||||||
return promptContent;
|
return promptContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
let expandedContent = 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 (referencedContent !== null) {
|
|
||||||
// Заменяем все вхождения @{referenceName} на содержимое
|
|
||||||
const templatePattern = new RegExp(`@\\{${this.escapeRegExp(referenceName)}\\}`, 'g');
|
|
||||||
expandedContent = expandedContent.replace(templatePattern, referencedContent);
|
|
||||||
} else {
|
|
||||||
// Если промпт не найден, оставляем ссылку как есть
|
|
||||||
console.warn(`Промпт "${referenceName}" не найден`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return expandedContent;
|
// Заменяем каждую ссылку на содержимое соответствующего промпта
|
||||||
}
|
for (const referenceName of templateReferences) {
|
||||||
|
if (expandedReferences.has(referenceName)) {
|
||||||
|
console.warn(
|
||||||
|
`Обнаружена циклическая ссылка на промпт "${referenceName}". Игнорируем для предотвращения бесконечной рекурсии.`
|
||||||
|
);
|
||||||
|
continue; // Пропускаем циклическую ссылку
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
// Добавляем текущую ссылку в Set для отслеживания циклов
|
||||||
* Валидирует промпт на корректность @{...} ссылок
|
expandedReferences.add(referenceName);
|
||||||
* @param promptContent - содержимое промпта
|
|
||||||
* @returns результат валидации
|
|
||||||
*/
|
|
||||||
validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } {
|
|
||||||
const parseResult = ReferenceParser.validatePromptReferences(promptContent);
|
|
||||||
const errors = [...parseResult.errors];
|
|
||||||
|
|
||||||
// Дополнительная проверка - существуют ли referenced промпты
|
const referencedContent = await this.loadPromptContent(referenceName, allFiles);
|
||||||
const templateReferences = ReferenceParser.extractTemplateReferences(promptContent);
|
if (referencedContent !== null) {
|
||||||
|
// Рекурсивно разворачиваем содержимое вложенного промпта
|
||||||
|
const expandedNestedContent = await this.expandPromptTemplate(referencedContent, expandedReferences);
|
||||||
|
|
||||||
for (const referenceName of templateReferences) {
|
// Заменяем все вхождения @{referenceName} или #{referenceName} на содержимое
|
||||||
const promptFile = this.findPromptFile(referenceName);
|
const templatePattern = new RegExp(`[#@]?\\{${this.escapeRegExp(referenceName)}\\}`, 'g');
|
||||||
if (!promptFile) {
|
expandedContent = expandedContent.replace(templatePattern, expandedNestedContent);
|
||||||
errors.push(`Промпт "${referenceName}" не найден в папке ${this.plugin.settings.promptsFolder}`);
|
} else {
|
||||||
}
|
// Если промпт не найден, оставляем ссылку как есть
|
||||||
}
|
console.warn(`Промпт "${referenceName}" не найден.`);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
// Удаляем текущую ссылку из Set после её обработки, чтобы не мешать другим веткам рекурсии
|
||||||
valid: errors.length === 0,
|
expandedReferences.delete(referenceName);
|
||||||
errors: errors
|
}
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
return expandedContent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
||||||
* Загружает содержимое промпта по имени
|
|
||||||
* @param promptName - имя промпта
|
|
||||||
* @returns содержимое промпта или null если не найден
|
|
||||||
*/
|
|
||||||
private async loadPromptContent(promptName: string): Promise<string | null> {
|
|
||||||
const promptFile = this.findPromptFile(promptName);
|
|
||||||
|
|
||||||
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 {
|
if (!promptFile) {
|
||||||
const content = await this.plugin.app.vault.read(promptFile);
|
return null;
|
||||||
return content.trim();
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(`Ошибка чтения промпта "${promptName}":`, error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
try {
|
||||||
* Находит файл промпта по имени
|
const content = await this.plugin.app.vault.read(promptFile);
|
||||||
* @param promptName - имя промпта
|
return content.trim();
|
||||||
* @returns файл промпта или null
|
} catch (error) {
|
||||||
*/
|
console.error(`Ошибка чтения промпта "${promptName}":`, error);
|
||||||
private findPromptFile(promptName: string): TFile | null {
|
return null;
|
||||||
const promptsFolder = this.plugin.settings.promptsFolder;
|
}
|
||||||
if (!promptsFolder) {
|
}
|
||||||
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 normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, '');*/
|
||||||
const file = this.plugin.app.vault.getAbstractFileByPath(promptPath);
|
|
||||||
|
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
return null;
|
||||||
* Экранирует специальные символы для регулярного выражения
|
}
|
||||||
* @param string - строка для экранирования
|
|
||||||
* @returns экранированная строка
|
/**
|
||||||
*/
|
* Экранирует специальные символы для регулярного выражения
|
||||||
private escapeRegExp(string: string): string {
|
* @param string - строка для экранирования
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
* @returns экранированная строка
|
||||||
}
|
*/
|
||||||
|
private escapeRegExp(string: string): string {
|
||||||
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user