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

224
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';
@ -20,12 +24,12 @@ interface LLMAgentSettings {
summarizationModel: string; summarizationModel: string;
voiceCommandModel: string; voiceCommandModel: string;
voiceInterval: number; voiceInterval: number;
voiceDisplayLimit: number; // 3000 voiceDisplayLimit: number; // 3000
voiceContextLimit: number; // N2 символов для LLM voiceContextLimit: number; // N2 символов для LLM
promptRegular: string; promptRegular: string;
promptCommands: string; promptCommands: string;
markerStart: string; // "старт" markerStart: string; // "старт"
markerStop: string; // "стоп" markerStop: string; // "стоп"
} }
@ -58,20 +62,30 @@ export default class LLMAgentPlugin extends Plugin {
activeStreamControllers: Map<string, AbortController>; activeStreamControllers: Map<string, AbortController>;
availableModels: string[]; availableModels: string[];
async syncSettings() { async syncSettings() {
try { try {
const response = await fetch(`${this.settings.apiBaseUrl}/settings/sync`, { const engine = new TemplateEngine(this);
method: 'POST',
headers: { 'Content-Type': 'application/json' }, // Клонируем настройки, чтобы не испортить исходные пути в конфиге плагина
body: JSON.stringify(this.settings) const settingsToSync = JSON.parse(JSON.stringify(this.settings));
});
if (response.ok) { // Собираем промпты (раскрываем все цепочки ссылок)
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`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settingsToSync)
});
if (response.ok) {
console.log("LLM Agent: Settings synced with backend."); console.log("LLM Agent: Settings synced with backend.");
} }
} catch (e) { } catch (e) {
console.error("LLM Agent: Failed to sync settings", e); console.error("LLM Agent: Failed to sync settings", e);
} }
} }
/** /**
* Загружает список доступных моделей с бэкенда. * Загружает список доступных моделей с бэкенда.
@ -140,7 +154,18 @@ 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() {
@ -234,10 +259,10 @@ class SampleSettingTab extends PluginSettingTab {
this.plugin.availableModels.forEach(model => dropdown.addOption(model, model)); this.plugin.availableModels.forEach(model => dropdown.addOption(model, model));
dropdown.setValue(this.plugin.settings.defaultModel) dropdown.setValue(this.plugin.settings.defaultModel)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.defaultModel = value; this.plugin.settings.defaultModel = value;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
await this.plugin.syncSettings(); await this.plugin.syncSettings();
}); });
}); });
new Setting(containerEl) new Setting(containerEl)
@ -247,10 +272,10 @@ class SampleSettingTab extends PluginSettingTab {
this.plugin.availableModels.forEach(model => dropdown.addOption(model, model)); this.plugin.availableModels.forEach(model => dropdown.addOption(model, model));
dropdown.setValue(this.plugin.settings.summarizationModel) dropdown.setValue(this.plugin.settings.summarizationModel)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.summarizationModel = value; this.plugin.settings.summarizationModel = value;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
await this.plugin.syncSettings(); await this.plugin.syncSettings();
}); });
}); });
new Setting(containerEl) new Setting(containerEl)
@ -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)' });
@ -290,10 +304,10 @@ class SampleSettingTab extends PluginSettingTab {
this.plugin.availableModels.forEach(model => dropdown.addOption(model, model)); this.plugin.availableModels.forEach(model => dropdown.addOption(model, model));
dropdown.setValue(this.plugin.settings.voiceCommandModel) dropdown.setValue(this.plugin.settings.voiceCommandModel)
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.voiceCommandModel = value; this.plugin.settings.voiceCommandModel = value;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
await this.plugin.syncSettings(); await this.plugin.syncSettings();
}); });
}); });
new Setting(containerEl) new Setting(containerEl)
@ -303,10 +317,10 @@ class SampleSettingTab extends PluginSettingTab {
text text
.setValue(String(this.plugin.settings.voiceInterval)) .setValue(String(this.plugin.settings.voiceInterval))
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.voiceInterval = Number(value); this.plugin.settings.voiceInterval = Number(value);
await this.plugin.saveSettings(); await this.plugin.saveSettings();
await this.plugin.syncSettings(); await this.plugin.syncSettings();
}) })
); );
new Setting(containerEl) new Setting(containerEl)
@ -316,10 +330,10 @@ class SampleSettingTab extends PluginSettingTab {
text text
.setValue(String(this.plugin.settings.voiceContextLimit)) .setValue(String(this.plugin.settings.voiceContextLimit))
.onChange(async (value) => { .onChange(async (value) => {
this.plugin.settings.voiceContextLimit = Number(value); this.plugin.settings.voiceContextLimit = Number(value);
await this.plugin.saveSettings(); await this.plugin.saveSettings();
await this.plugin.syncSettings(); await this.plugin.syncSettings();
}) })
); );
new Setting(containerEl) new Setting(containerEl)
@ -350,33 +364,17 @@ 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: 'Управление ссылками и файлами' });
@ -439,9 +437,9 @@ class SampleSettingTab extends PluginSettingTab {
.addToggle((toggle) => .addToggle((toggle) =>
toggle.setValue(this.plugin.settings.enableFileReferences).onChange(async (value) => { toggle.setValue(this.plugin.settings.enableFileReferences).onChange(async (value) => {
this.plugin.settings.enableFileReferences = value; this.plugin.settings.enableFileReferences = value;
await this.plugin.saveSettings(); await this.plugin.saveSettings();
await this.plugin.syncSettings(); await this.plugin.syncSettings();
}) })
); );
// Динамический список исключенных папок // Динамический список исключенных папок
@ -458,7 +456,7 @@ class SampleSettingTab extends PluginSettingTab {
await this.plugin.syncSettings(); await this.plugin.syncSettings();
// Очищаем кэш автокомплита при изменении настроек // Очищаем кэш автокомплита при изменении настроек
this.plugin.eventBus.emit('settings-changed'); this.plugin.eventBus.emit('settings-changed');
}); });
}) })
.addExtraButton((button) => { .addExtraButton((button) => {
button button
@ -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,43 +249,78 @@ 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;
// Позиционируем относительно inputElement, т.к. он уже в потоке документа if (!isChat) {
// Левый край дропдауна совпадает с левым краем курсора // Позиционируем относительно inputElement, т.к. он уже в потоке документа
let left = cursorRect.left - containerRect.left; // Левый край дропдауна совпадает с левым краем курсора
left = containerRect.x - 35; // 35 - эмпирика, но что там должно быть, не понял
console.log("left: " + 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;
const spaceAbove = cursorRect.top; const spaceAbove = cursorRect.top;
if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) { if (spaceBelow < dropdownRect.height + 10 && spaceAbove > dropdownRect.height + 10) {
// Если снизу мало места и сверху достаточно, размещаем сверху // Если снизу мало места и сверху достаточно, размещаем сверху
top = cursorRect.top - containerRect.top - dropdownRect.height - 5; // -5px отступ top = cursorRect.top - containerRect.top - dropdownRect.height - 5; // -5px отступ
} }
// Убедимся, что дропдаун не выходит за границы контейнера справа dropdownMinWidth = 200;
const maxRight = inputRect.right - containerRect.left; } else {
if (left + dropdownRect.width > maxRight) { // Старая логика для чата (Relative к container)
left = maxRight - dropdownRect.width;
if (left < 0) left = 0; // Если дропдаун шире контейнера, прижимаем к левому краю // Позиционируем относительно inputElement, т.к. он уже в потоке документа
} // Левый край дропдауна совпадает с левым краем курсора
// Убедимся, что дропдаун не выходит за границы контейнера слева left = cursorRect.left - containerRect.left;
if (left < 0) {
left = 0; // Дефолтное положение - под курсором
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 отступ
}
// Убедимся, что дропдаун не выходит за границы контейнера справа
const maxRight = inputRect.right - containerRect.left;
if (left + dropdownRect.width > maxRight) {
left = maxRight - dropdownRect.width;
if (left < 0) left = 0; // Если дропдаун шире контейнера, прижимаем к левому краю
}
// Убедимся, что дропдаун не выходит за границы контейнера слева
if (left < 0) {
left = 0;
}
// Устанавливаем ширину дропдауна равной ширине inputElement, если он больше
// Это предотвратит слишком узкий дропдаун, если имя файла короткое
dropdownMinWidth = inputRect.width / 2; // Минимум половина ширины инпута
} }
this.dropdown.style.top = `${top}px`; this.dropdown.style.top = `${top}px`;
this.dropdown.style.left = `${left}px`; this.dropdown.style.left = `${left}px`;
// Устанавливаем ширину дропдауна равной ширине inputElement, если он больше this.dropdown.style.minWidth = `${dropdownMinWidth}px`;
// Это предотвратит слишком узкий дропдаун, если имя файла короткое
this.dropdown.style.minWidth = `${inputRect.width / 2}px`; // Минимум половина ширины инпута
this.dropdown.style.width = 'auto'; // Позволяем контенту определить ширину, но не меньше minWidth this.dropdown.style.width = 'auto'; // Позволяем контенту определить ширину, но не меньше minWidth
} }

View File

@ -6,21 +6,21 @@
// ----------------------------------------------- Interfaces --------------------------------------------------------------- // ----------------------------------------------- Interfaces ---------------------------------------------------------------
export interface ReferenceMatch { export interface ReferenceMatch {
fullMatch: string; fullMatch: string;
prefix: string; // Теперь может быть @, !@, #, !#, $, !$ prefix: string; // Теперь может быть @, !@, #, !#, $, !$
name: string; name: string;
startIndex: number; startIndex: number;
endIndex: number; endIndex: number;
type: 'file' | 'prompt' | 'external'; type: 'file' | 'prompt' | 'external';
permanent: boolean; permanent: boolean;
} }
export interface FileReference { export interface FileReference {
type: 'file' | 'prompt' | 'external'; type: 'file' | 'prompt' | 'external';
name: string; name: string;
path: string; path: string;
permanent: boolean; permanent: boolean;
uuid?: string; uuid?: string;
} }
// ----------------------------------------------- Reference Parser --------------------------------------------------------------- // ----------------------------------------------- Reference Parser ---------------------------------------------------------------
@ -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 ---------------------------------------------------------------
/** /**