Make prompts parameters not simple text but names of prompt files

This commit is contained in:
dimitrievgs 2026-05-03 01:12:47 +03:00
parent f2afaa4836
commit 06949fc13e
4 changed files with 262 additions and 125 deletions

136
main.ts
View File

@ -1,8 +1,12 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, ItemView, WorkspaceLeaf } from 'obsidian'; import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, ItemView, WorkspaceLeaf, TFile } from 'obsidian';
import { EventBus } from './src/utils/EventBus'; import { EventBus } from './src/utils/EventBus';
import { GraphView, GRAPH_VIEW_TYPE } from './src/views/GraphView'; import { GraphView, GRAPH_VIEW_TYPE } from './src/views/GraphView';
import { ChatView, CHAT_VIEW_TYPE } from './src/views/ChatView'; import { ChatView, CHAT_VIEW_TYPE } from './src/views/ChatView';
import { WebSocketService } from './src/utils/WebSocketService'; import { WebSocketService } from './src/utils/WebSocketService';
import { TemplateEngine } from './src/references/TemplateEngine';
import { AutocompleteManager } from './src/references/AutocompleteManager';
import { ReferenceAutocomplete } from './src/references/ReferenceAutocomplete';
import { ReferenceParser } from './src/references/ReferenceParser';
import 'src/css/styles.css'; import 'src/css/styles.css';
@ -60,10 +64,20 @@ export default class LLMAgentPlugin extends Plugin {
async syncSettings() { async syncSettings() {
try { try {
const engine = new TemplateEngine(this);
// Клонируем настройки, чтобы не испортить исходные пути в конфиге плагина
const settingsToSync = JSON.parse(JSON.stringify(this.settings));
// Собираем промпты (раскрываем все цепочки ссылок)
settingsToSync.systemPrompt = await engine.processRawSettingValue(this.settings.systemPrompt);
settingsToSync.promptRegular = await engine.processRawSettingValue(this.settings.promptRegular);
settingsToSync.promptCommands = await engine.processRawSettingValue(this.settings.promptCommands);
const response = await fetch(`${this.settings.apiBaseUrl}/settings/sync`, { const response = await fetch(`${this.settings.apiBaseUrl}/settings/sync`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.settings) body: JSON.stringify(settingsToSync)
}); });
if (response.ok) { if (response.ok) {
console.log("LLM Agent: Settings synced with backend."); console.log("LLM Agent: Settings synced with backend.");
@ -141,6 +155,17 @@ export default class LLMAgentPlugin extends Plugin {
await this.fetchAvailableModels(); await this.fetchAvailableModels();
// Вызываем один раз при запуске // Вызываем один раз при запуске
await this.syncSettings(); await this.syncSettings();
this.registerEvent(
this.app.vault.on('modify', async (file) => {
// Если изменился любой md файл, перестраховываемся и синхроним настройки
// (Backend получит обновленный текст промптов, если на них есть ссылки)
if (file instanceof TFile && file.extension === 'md' && file.path.startsWith(this.settings.promptsFolder)) {
// TODO: Проверить, что срабатывает
await this.syncSettings();
}
})
);
} }
onunload() { onunload() {
@ -267,19 +292,8 @@ class SampleSettingTab extends PluginSettingTab {
}) })
); );
new Setting(containerEl) // Этот промпт будет отправляться LLM перед каждым диалогом
.setName('Системный промпт') this.addPromptAutocomplete(new Setting(containerEl).setName('Системный промпт'), 'systemPrompt');
.setDesc('Этот промпт будет отправляться LLM перед каждым диалогом.')
.addTextArea((text) =>
text
.setPlaceholder('Ты полезный ИИ ассистент.')
.setValue(this.plugin.settings.systemPrompt)
.onChange(async (value) => {
this.plugin.settings.systemPrompt = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
);
containerEl.createEl('h2', { text: 'Голосовое управление (Voice Service)' }); containerEl.createEl('h2', { text: 'Голосовое управление (Voice Service)' });
@ -350,32 +364,16 @@ class SampleSettingTab extends PluginSettingTab {
}) })
); );
new Setting(containerEl) // Инструкции для Промпта №1.
.setName('Регулярный промпт') this.addPromptAutocomplete(
.setDesc('Инструкции для Промпта №1.') new Setting(containerEl).setName('Регулярный промпт'),
.addTextArea((text) => 'promptRegular'
text
// .setPlaceholder('prompts/regular_analysis.md')
.setValue(this.plugin.settings.promptRegular)
.onChange(async (value) => {
this.plugin.settings.promptRegular = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
); );
new Setting(containerEl) // Инструкции для Промпта №2.
.setName('Промпт команд') this.addPromptAutocomplete(
.setDesc('Инструкции для Промпта №2.') new Setting(containerEl).setName('Промпт команд'),
.addTextArea((text) => 'promptCommands'
text
// .setPlaceholder('prompts/voice_commands.md')
.setValue(this.plugin.settings.promptCommands)
.onChange(async (value) => {
this.plugin.settings.promptCommands = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
})
); );
containerEl.createEl('h2', { text: 'Управление ссылками и файлами' }); containerEl.createEl('h2', { text: 'Управление ссылками и файлами' });
@ -487,4 +485,64 @@ class SampleSettingTab extends PluginSettingTab {
}); });
}); });
} }
private addPromptAutocomplete(setting: Setting, settingKey: keyof LLMAgentSettings) {
const manager = new AutocompleteManager(this.plugin);
setting.addTextArea((text) => {
const el = text.inputEl;
const autocomplete = new ReferenceAutocomplete(el, manager);
autocomplete.onItemSelected(async (item, match) => {
const val = el.value;
const before = val.substring(0, match.startIndex);
const after = val.substring(match.endIndex);
// Если в имени есть пробел — оборачиваем в кавычки
const nameToInsert = item.name.includes(' ') ? `"${item.name}"` : item.name;
const newValue = before + "#" + nameToInsert + after;
// Обновляем визуально в поле
el.value = newValue;
// Обновляем в конфиге (используем приведение типа, чтобы TS не ругался)
(this.plugin.settings as any)[settingKey] = newValue;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
autocomplete.hideAutocomplete();
});
text.setPlaceholder('Впишите текст или начните с # для выбора промпта')
.setValue(String(this.plugin.settings[settingKey]))
.onChange(async (value) => {
// Прямое присваивание через приведение к any для обхода строгой проверки типов
(this.plugin.settings as any)[settingKey] = value;
await this.plugin.saveSettings();
await this.plugin.syncSettings();
const cursor = el.selectionStart;
const textValue = el.value;
const ref = ReferenceParser.getCurrentReference(textValue, cursor);
if (ref && ref.type === 'prompt') {
// Используем getBoundingClientRect для позиционирования меню под полем ввода
const rect = el.getBoundingClientRect();
autocomplete.showAutocomplete(ref, rect);
} else {
autocomplete.hideAutocomplete();
}
});
el.addEventListener('keydown', (e) => {
if (autocomplete.isVisible()) {
if (autocomplete.handleKeydown(e)) {
e.stopPropagation();
}
}
});
});
}
} }

View File

@ -9,6 +9,9 @@ import { setIcon } from 'obsidian';
// ----------------------------------------------- Reference Autocomplete Component --------------------------------------------------------------- // ----------------------------------------------- Reference Autocomplete Component ---------------------------------------------------------------
const CHAT_CONTAINER_SELECTOR = 'chat-input-container';
const SETTINGS_CONTAINER_SELECTOR = 'vertical-tab-content-container';
export class ReferenceAutocomplete { export class ReferenceAutocomplete {
private container: HTMLElement; private container: HTMLElement;
private dropdown: HTMLElement; private dropdown: HTMLElement;
@ -23,7 +26,13 @@ export class ReferenceAutocomplete {
constructor(inputElement: HTMLElement, autocompleteManager: AutocompleteManager) { constructor(inputElement: HTMLElement, autocompleteManager: AutocompleteManager) {
this.inputElement = inputElement; this.inputElement = inputElement;
this.autocompleteManager = autocompleteManager; this.autocompleteManager = autocompleteManager;
this.container = this.inputElement.closest('.chat-input-container') as HTMLElement;
// Ищем контейнер чата, а если не находим (мы в настройках),
// используем родителя элемента или body
const chatContainer = this.inputElement.closest(`.${CHAT_CONTAINER_SELECTOR}`);
const settingsContainer = this.inputElement.closest(`.${SETTINGS_CONTAINER_SELECTOR}`);
this.container = (chatContainer || settingsContainer || document.body) as HTMLElement;
this.createDropdown(); this.createDropdown();
this.setupEventListeners(); this.setupEventListeners();
@ -143,6 +152,11 @@ export class ReferenceAutocomplete {
this.dropdown.className = 'reference-autocomplete'; this.dropdown.className = 'reference-autocomplete';
this.dropdown.style.display = 'none'; this.dropdown.style.display = 'none';
// Если мы в настройках, добавляем фиксированное позиционирование
if (this.container === document.body) {
this.dropdown.style.position = 'fixed';
}
// Добавляем dropdown в контейнер // Добавляем dropdown в контейнер
this.container.appendChild(this.dropdown); this.container.appendChild(this.dropdown);
} }
@ -235,17 +249,49 @@ export class ReferenceAutocomplete {
// Чтобы получить корректные размеры, устанавливаем display: 'block' // Чтобы получить корректные размеры, устанавливаем display: 'block'
this.dropdown.style.display = 'block'; this.dropdown.style.display = 'block';
const dropdownRect = this.dropdown.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
let top: number;
let left: number;
let dropdownMinWidth: number;
const isChat = this.container.classList.contains(CHAT_CONTAINER_SELECTOR);
console.log("isChat: " + isChat)
const inputRect = this.inputElement.getBoundingClientRect(); const inputRect = this.inputElement.getBoundingClientRect();
const containerRect = this.container.getBoundingClientRect(); const containerRect = this.container.getBoundingClientRect();
const dropdownRect = this.dropdown.getBoundingClientRect(); // Получаем размеры дропдауна
const viewportHeight = window.innerHeight; if (!isChat) {
// Позиционируем относительно inputElement, т.к. он уже в потоке документа
// Левый край дропдауна совпадает с левым краем курсора
left = containerRect.x - 35; // 35 - эмпирика, но что там должно быть, не понял
console.log("left: " + left)
// Дефолтное положение - под курсором
top = cursorRect.bottom - containerRect.top + 5; // +5px отступ
// Проверяем, есть ли место снизу
const spaceBelow = viewportHeight - cursorRect.bottom;
const spaceAbove = cursorRect.top;
if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) {
// Если снизу мало места и сверху достаточно, размещаем сверху
top = cursorRect.top - containerRect.top - dropdownRect.height - 5; // -5px отступ
}
dropdownMinWidth = 200;
} else {
// Старая логика для чата (Relative к container)
// Позиционируем относительно inputElement, т.к. он уже в потоке документа // Позиционируем относительно inputElement, т.к. он уже в потоке документа
// Левый край дропдауна совпадает с левым краем курсора // Левый край дропдауна совпадает с левым краем курсора
let left = cursorRect.left - containerRect.left; left = cursorRect.left - containerRect.left;
// Дефолтное положение - под курсором // Дефолтное положение - под курсором
let top = cursorRect.bottom - containerRect.top + 5; // +5px отступ top = cursorRect.bottom - containerRect.top + 5; // +5px отступ
// Проверяем, есть ли место снизу // Проверяем, есть ли место снизу
const spaceBelow = viewportHeight - cursorRect.bottom; const spaceBelow = viewportHeight - cursorRect.bottom;
@ -267,11 +313,14 @@ export class ReferenceAutocomplete {
left = 0; left = 0;
} }
this.dropdown.style.top = `${top}px`;
this.dropdown.style.left = `${left}px`;
// Устанавливаем ширину дропдауна равной ширине inputElement, если он больше // Устанавливаем ширину дропдауна равной ширине inputElement, если он больше
// Это предотвратит слишком узкий дропдаун, если имя файла короткое // Это предотвратит слишком узкий дропдаун, если имя файла короткое
this.dropdown.style.minWidth = `${inputRect.width / 2}px`; // Минимум половина ширины инпута dropdownMinWidth = inputRect.width / 2; // Минимум половина ширины инпута
}
this.dropdown.style.top = `${top}px`;
this.dropdown.style.left = `${left}px`;
this.dropdown.style.minWidth = `${dropdownMinWidth}px`;
this.dropdown.style.width = 'auto'; // Позволяем контенту определить ширину, но не меньше minWidth this.dropdown.style.width = 'auto'; // Позволяем контенту определить ширину, но не меньше minWidth
} }

View File

@ -74,13 +74,17 @@ export class ReferenceParser {
static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null { static getCurrentReference(text: string, cursorPosition: number): ReferenceMatch | null {
const beforeCursor = text.substring(0, cursorPosition); const beforeCursor = text.substring(0, cursorPosition);
// ИЗМЕНЕНО: Поддержка !@, !#, !$ // Измененная регулярка: теперь она понимает, если после символа # идет кавычка
const incompleteRegex = /(![@#$]|[@#$])([^\s@#$"!]*?)$/; // Группа 1: Префикс, Группа 2: Имя в кавычках, Группа 3: Имя без кавычек
const incompleteRegex = /(![@#$]|[@#$])(?:"([^"]*)|([^\s@#$"!]*?))$/;
const match = beforeCursor.match(incompleteRegex); const match = beforeCursor.match(incompleteRegex);
if (match) { if (match) {
const prefix = match[1]; const prefix = match[1];
const partialName = match[2]; const nameInQuotes = match[2]; // текст внутри кавычек
const nameWithoutQuotes = match[3]; // текст до первого пробела
const partialName = nameInQuotes !== undefined ? nameInQuotes : nameWithoutQuotes;
const startIndex = beforeCursor.length - match[0].length; const startIndex = beforeCursor.length - match[0].length;
const typeChar = prefix.replace('!', ''); const typeChar = prefix.replace('!', '');

View File

@ -71,6 +71,32 @@ export class TemplateEngine {
return expandedContent; return expandedContent;
} }
/**
* Главный метод для "сборки" промпта.
* Принимает либо чистый текст, либо путь вида #filename или просто текст с вкраплениями @{file}
*/
async processRawSettingValue(value: string): Promise<string> {
if (!value) return "";
// Регулярка теперь поддерживает опциональные кавычки:
// ^# - начинается на решетку
// (?:"([^"]+)"|([^\s]+)) - либо "имя с пробелом", либо имя_без_пробела
const fullLinkMatch = value.trim().match(/^#(?:"([^"]+)"|([^\s]+))$/);
if (fullLinkMatch) {
const promptName = fullLinkMatch[1] || fullLinkMatch[2]; // Берем из той группы, которая сработала
const allFiles = this.plugin.app.vault.getFiles();
const content = await this.loadPromptContent(promptName, allFiles);
if (content) {
return await this.expandPromptTemplate(content);
}
}
// Если это не прямая ссылка на файл, а просто текст,
// всё равно прогоняем через расширитель (на случай если там внутри есть @{...})
return await this.expandPromptTemplate(value);
}
// ----------------------------------------------- Private Methods --------------------------------------------------------------- // ----------------------------------------------- Private Methods ---------------------------------------------------------------
/** /**