Draft solution for file/prompt hot inserting
This commit is contained in:
parent
d65996539a
commit
24ac848ab9
44
main.ts
44
main.ts
|
|
@ -10,11 +10,19 @@ import 'src/css/styles.css'; // Путь к твоему новому CSS фай
|
|||
interface LLMAgentSettings {
|
||||
apiBaseUrl: string;
|
||||
systemPrompt: string;
|
||||
defaultModel: string;
|
||||
promptsFolder: string; // Новое поле
|
||||
enableFileReferences: boolean; // Новое поле
|
||||
enablePromptReferences: boolean; // Новое поле
|
||||
}
|
||||
|
||||
const DEFAULT_SETTINGS: LLMAgentSettings = {
|
||||
apiBaseUrl: 'http://localhost:5000/api',
|
||||
systemPrompt: 'Ты полезный ИИ ассистент.',
|
||||
defaultModel: 'gemini-2.5-flash',
|
||||
promptsFolder: 'prompts', // Папка с промптами по умолчанию
|
||||
enableFileReferences: true,
|
||||
enablePromptReferences: true
|
||||
};
|
||||
|
||||
// ----------------------------------------------- Main Plugin Class ---------------------------------------------------------------
|
||||
|
|
@ -154,5 +162,41 @@ class SampleSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
|
||||
// Новое поле для папки промптов
|
||||
new Setting(containerEl)
|
||||
.setName('Папка с промптами')
|
||||
.setDesc('Путь к папке, содержащей промпты для команд #/##')
|
||||
.addText((text) =>
|
||||
text
|
||||
.setPlaceholder('prompts')
|
||||
.setValue(this.plugin.settings.promptsFolder)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.promptsFolder = value;
|
||||
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();
|
||||
})
|
||||
);
|
||||
|
||||
// Настройки ссылок на промпты
|
||||
new Setting(containerEl)
|
||||
.setName('Включить ссылки на промпты')
|
||||
.setDesc('Разрешить использование #/## ссылок на промпты')
|
||||
.addToggle((toggle) =>
|
||||
toggle.setValue(this.plugin.settings.enablePromptReferences).onChange(async (value) => {
|
||||
this.plugin.settings.enablePromptReferences = value;
|
||||
await this.plugin.saveSettings();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,15 @@
|
|||
|
||||
import { ChatHistoryItem } from './models/ChatHistoryItem';
|
||||
|
||||
import { AutocompleteManager } from '../references/AutocompleteManager';
|
||||
import { ReferenceAutocomplete } from '../references/ReferenceAutocomplete';
|
||||
import { ReferenceExpander } from '../references/ReferenceExpander';
|
||||
import { TemplateEngine } from '../references/TemplateEngine';
|
||||
import { ReferenceParser, FileReference, ReferenceMatch } from '../references/ReferenceParser';
|
||||
import { ReferenceBox } from '../references/ReferenceBox';
|
||||
import { AutocompleteItem } from '../references/AutocompleteManager';
|
||||
import LLMAgentPlugin from 'main';
|
||||
|
||||
export class ChatPanel {
|
||||
container: HTMLElement;
|
||||
props: {
|
||||
|
|
@ -12,6 +21,7 @@ export class ChatPanel {
|
|||
onSendMessage: (message: string, selectedModel?: string) => void;
|
||||
availableModels?: string[];
|
||||
selectedModel?: string;
|
||||
plugin: LLMAgentPlugin;
|
||||
};
|
||||
chatHistory: ChatHistoryItem[];
|
||||
messageInput: HTMLDivElement;
|
||||
|
|
@ -21,6 +31,14 @@ export class ChatPanel {
|
|||
modelDropdown: HTMLDivElement;
|
||||
selectedModel: string;
|
||||
|
||||
// ----------------------------------------------- Reference System Fields ---------------------------------------------------------------
|
||||
private autocompleteManager: AutocompleteManager;
|
||||
private referenceAutocomplete: ReferenceAutocomplete;
|
||||
private referenceExpander: ReferenceExpander;
|
||||
private templateEngine: TemplateEngine;
|
||||
private currentReferences: FileReference[] = [];
|
||||
private isProcessingReference = false;
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
props: {
|
||||
|
|
@ -28,6 +46,7 @@ export class ChatPanel {
|
|||
onSendMessage: (message: string, selectedModel?: string) => void;
|
||||
availableModels?: string[];
|
||||
selectedModel?: string;
|
||||
plugin: LLMAgentPlugin; // Обновленный тип
|
||||
}
|
||||
) {
|
||||
this.container = container;
|
||||
|
|
@ -35,107 +54,12 @@ export class ChatPanel {
|
|||
this.chatHistory = props.chatHistory || [];
|
||||
this.selectedModel = props.selectedModel || 'gemini-2.5-flash';
|
||||
|
||||
// Инициализация системы ссылок
|
||||
this.initializeReferenceSystem();
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init(): void {
|
||||
this.container.innerHTML = `
|
||||
<div class="llm-agent-chat-panel">
|
||||
<div class="chat-history" id="chat-history"></div>
|
||||
<div class="chat-input-container">
|
||||
<div class="scroll-container">
|
||||
<div
|
||||
contenteditable="true"
|
||||
data-testid="editor-input-main"
|
||||
class="message-input-editor"
|
||||
tabindex="0"
|
||||
data-placeholder="Введите сообщение или команду (/)"
|
||||
></div>
|
||||
</div>
|
||||
<div class="chat-controls">
|
||||
<div class="control-buttons-left">
|
||||
<div class="model-select-container">
|
||||
<button class="model-select-button" id="model-select-button">
|
||||
<span class="model-name" id="selected-model-name">${this.selectedModel}</span>
|
||||
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="model-dropdown" id="model-dropdown" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-buttons-right">
|
||||
<button class="submit-button" id="send-button" data-tooltip="Enter">
|
||||
<span class="submit-text">⏎ Enter</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="suggestions" id="suggestions" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.messageInput = this.container.querySelector('.message-input-editor') as HTMLDivElement;
|
||||
this.sendButton = this.container.querySelector('#send-button') as HTMLButtonElement;
|
||||
this.suggestionsContainer = this.container.querySelector('#suggestions') as HTMLDivElement;
|
||||
this.modelSelectButton = this.container.querySelector('#model-select-button') as HTMLButtonElement;
|
||||
this.modelDropdown = this.container.querySelector('#model-dropdown') as HTMLDivElement;
|
||||
|
||||
this.setupEventListeners();
|
||||
this.renderHistory();
|
||||
this.updateModelDropdown();
|
||||
this.updatePlaceholder();
|
||||
}
|
||||
|
||||
setupEventListeners(): void {
|
||||
this.sendButton.addEventListener('click', () => this.handleSend());
|
||||
|
||||
this.messageInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (this.suggestionsContainer.style.display === 'block') {
|
||||
const firstSuggestion = this.suggestionsContainer.querySelector('.suggestion-item');
|
||||
if (firstSuggestion) {
|
||||
this.messageInput.textContent = firstSuggestion.textContent || '';
|
||||
this.hideSuggestions();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.handleSend();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
this.messageInput.addEventListener('input', () => {
|
||||
this.handleInput();
|
||||
this.updatePlaceholder();
|
||||
});
|
||||
|
||||
this.messageInput.addEventListener('focus', () => {
|
||||
this.updatePlaceholder();
|
||||
});
|
||||
|
||||
this.messageInput.addEventListener('blur', () => {
|
||||
this.updatePlaceholder();
|
||||
});
|
||||
|
||||
// Model selection
|
||||
this.modelSelectButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleModelDropdown();
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) {
|
||||
this.modelDropdown.style.display = 'none';
|
||||
}
|
||||
if (!this.suggestionsContainer.contains(e.target as Node)) {
|
||||
this.hideSuggestions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updatePlaceholder(): void {
|
||||
const isEmpty = this.messageInput.textContent?.trim() === '';
|
||||
const isFocused = document.activeElement === this.messageInput;
|
||||
|
|
@ -159,24 +83,35 @@ export class ChatPanel {
|
|||
|
||||
const modelSelectButtonRect = this.modelSelectButton.getBoundingClientRect();
|
||||
const modelDropdownRect = this.modelDropdown.getBoundingClientRect();
|
||||
const chatPanelRect = this.container.getBoundingClientRect(); // Общие границы чат-панели
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
|
||||
// ----------------------------------------------- Секция логики позиционирования дропдауна ----------------------------------------------------
|
||||
// Определяем, достаточно ли места снизу
|
||||
// Позиция относительно контейнера chatPanel (или inputElement)
|
||||
let top = modelSelectButtonRect.bottom - chatPanelRect.top + 4; // Отступ 4px от кнопки
|
||||
let left = modelSelectButtonRect.left - chatPanelRect.left;
|
||||
|
||||
// Проверяем, помещается ли dropdown снизу
|
||||
const spaceBelow = viewportHeight - modelSelectButtonRect.bottom;
|
||||
// Определяем, достаточно ли места сверху
|
||||
const spaceAbove = modelSelectButtonRect.top;
|
||||
|
||||
if (spaceBelow >= modelDropdownRect.height || spaceBelow >= spaceAbove) {
|
||||
// Открываем вниз, если есть достаточно места или места снизу больше
|
||||
this.modelDropdown.style.top = `${modelSelectButtonRect.height + 4}px`; // 4px отступ от кнопки
|
||||
this.modelDropdown.style.bottom = 'auto';
|
||||
} else {
|
||||
// Открываем вверх
|
||||
this.modelDropdown.style.bottom = `${modelSelectButtonRect.height + 4}px`; // 4px отступ от кнопки
|
||||
this.modelDropdown.style.top = 'auto';
|
||||
if (spaceBelow < modelDropdownRect.height + 10 && spaceAbove > modelDropdownRect.height + 10) {
|
||||
// Если снизу мало места и сверху достаточно, размещаем сверху
|
||||
top = modelSelectButtonRect.top - chatPanelRect.top - modelDropdownRect.height - 4; // Отступ 4px от кнопки
|
||||
}
|
||||
this.modelDropdown.style.left = '0';
|
||||
|
||||
// Убедимся, что дропдаун не выходит за границы контейнера справа
|
||||
const maxRight = chatPanelRect.width;
|
||||
if (left + modelDropdownRect.width > maxRight) {
|
||||
left = maxRight - modelDropdownRect.width;
|
||||
}
|
||||
// Убедимся, что дропдаун не выходит за границы контейнера слева
|
||||
if (left < 0) {
|
||||
left = 0;
|
||||
}
|
||||
|
||||
this.modelDropdown.style.top = `${top}px`;
|
||||
this.modelDropdown.style.left = `${left}px`;
|
||||
this.modelDropdown.style.right = 'auto'; // Сброс right, чтобы left работал корректно
|
||||
// ----------------------------------------------- End of Секция логики позиционирования дропдауна ---------------------------------------------
|
||||
}
|
||||
|
|
@ -250,16 +185,6 @@ export class ChatPanel {
|
|||
this.suggestionsContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
handleSend(): void {
|
||||
const message = this.messageInput.textContent?.trim() || '';
|
||||
if (message && this.props.onSendMessage) {
|
||||
this.props.onSendMessage(message, this.selectedModel);
|
||||
this.messageInput.textContent = '';
|
||||
this.updatePlaceholder();
|
||||
this.hideSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
renderHistory(): void {
|
||||
const historyContainer = this.container.querySelector('#chat-history') as HTMLElement;
|
||||
historyContainer.innerHTML = this.chatHistory
|
||||
|
|
@ -327,6 +252,652 @@ export class ChatPanel {
|
|||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.referenceAutocomplete) {
|
||||
this.referenceAutocomplete.destroy();
|
||||
}
|
||||
this.container.innerHTML = '';
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Reference System Initialization ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Инициализирует систему ссылок
|
||||
*/
|
||||
private initializeReferenceSystem(): void {
|
||||
if (!this.props.plugin) {
|
||||
console.warn('Plugin не передан в ChatPanel, система ссылок отключена');
|
||||
return;
|
||||
}
|
||||
|
||||
this.autocompleteManager = new AutocompleteManager(this.props.plugin);
|
||||
this.templateEngine = new TemplateEngine(this.props.plugin);
|
||||
this.referenceExpander = new ReferenceExpander(this.props.plugin, this.templateEngine);
|
||||
}
|
||||
|
||||
// Обновить метод init():
|
||||
init(): void {
|
||||
this.container.innerHTML = `
|
||||
<div class="llm-agent-chat-panel">
|
||||
<div class="chat-history" id="chat-history"></div>
|
||||
<div class="chat-input-container">
|
||||
<div class="scroll-container">
|
||||
<div
|
||||
contenteditable="true"
|
||||
data-testid="editor-input-main"
|
||||
class="message-input-editor"
|
||||
tabindex="0"
|
||||
data-placeholder="Введите сообщение, @файл, #промпт или команду (/)"
|
||||
></div>
|
||||
</div>
|
||||
<div class="chat-controls">
|
||||
<div class="control-buttons-left">
|
||||
<div class="model-select-container">
|
||||
<button class="model-select-button" id="model-select-button">
|
||||
<span class="model-name" id="selected-model-name">${this.selectedModel}</span>
|
||||
<svg class="dropdown-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="model-dropdown" id="model-dropdown" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-buttons-right">
|
||||
<button class="submit-button" id="send-button" data-tooltip="Enter">
|
||||
<span class="submit-text">⏎ Enter</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="suggestions" id="suggestions" style="display: none;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.messageInput = this.container.querySelector('.message-input-editor') as HTMLDivElement;
|
||||
this.sendButton = this.container.querySelector('#send-button') as HTMLButtonElement;
|
||||
this.suggestionsContainer = this.container.querySelector('#suggestions') as HTMLDivElement;
|
||||
this.modelSelectButton = this.container.querySelector('#model-select-button') as HTMLButtonElement;
|
||||
this.modelDropdown = this.container.querySelector('#model-dropdown') as HTMLDivElement;
|
||||
|
||||
this.setupEventListeners();
|
||||
this.setupReferenceSystem();
|
||||
this.renderHistory();
|
||||
this.updateModelDropdown();
|
||||
this.updatePlaceholder();
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Reference System Setup ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Настраивает систему ссылок
|
||||
*/
|
||||
private setupReferenceSystem(): void {
|
||||
if (!this.autocompleteManager) return;
|
||||
|
||||
// Инициализируем автокомплит
|
||||
this.referenceAutocomplete = new ReferenceAutocomplete(this.messageInput, this.autocompleteManager);
|
||||
|
||||
// Настраиваем обработчик выбора элемента
|
||||
this.referenceAutocomplete.onItemSelected((item: AutocompleteItem, match: ReferenceMatch) => {
|
||||
this.replaceReferenceWithBox(match, item);
|
||||
});
|
||||
}
|
||||
|
||||
// Обновить метод setupEventListeners():
|
||||
setupEventListeners(): void {
|
||||
this.sendButton.addEventListener('click', () => this.handleSendWithReferences());
|
||||
|
||||
this.messageInput.addEventListener('keydown', (e) => {
|
||||
// Сначала проверяем автокомплит ссылок
|
||||
if (this.referenceAutocomplete && this.referenceAutocomplete.handleKeydown(e)) {
|
||||
return; // Событие обработано автокомплитом
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
if (this.suggestionsContainer.style.display === 'block') {
|
||||
const firstSuggestion = this.suggestionsContainer.querySelector('.suggestion-item');
|
||||
if (firstSuggestion) {
|
||||
this.messageInput.textContent = firstSuggestion.textContent || '';
|
||||
this.hideSuggestions();
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.handleSendWithReferences();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
this.messageInput.addEventListener('input', () => {
|
||||
this.handleInput();
|
||||
this.handleReferenceInput();
|
||||
this.updatePlaceholder();
|
||||
});
|
||||
|
||||
this.messageInput.addEventListener('focus', () => {
|
||||
this.updatePlaceholder();
|
||||
});
|
||||
|
||||
this.messageInput.addEventListener('blur', () => {
|
||||
this.updatePlaceholder();
|
||||
});
|
||||
|
||||
// Model selection
|
||||
this.modelSelectButton.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleModelDropdown();
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) {
|
||||
this.modelDropdown.style.display = 'none';
|
||||
}
|
||||
if (!this.suggestionsContainer.contains(e.target as Node)) {
|
||||
this.hideSuggestions();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Reference Processing ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Обрабатывает ввод для автокомплита ссылок
|
||||
*/
|
||||
private handleReferenceInput(): void {
|
||||
if (!this.referenceAutocomplete || this.isProcessingReference) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
this.referenceAutocomplete.hideAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const cursorPosition = this.getCursorPositionInText();
|
||||
|
||||
if (cursorPosition === -1) {
|
||||
this.referenceAutocomplete.hideAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const text = this.getTextContent();
|
||||
const currentRef = ReferenceParser.getCurrentReference(text, cursorPosition);
|
||||
|
||||
if (currentRef) {
|
||||
// Получаем позицию курсора на экране
|
||||
const cursorRect = range.getBoundingClientRect();
|
||||
this.referenceAutocomplete.showAutocomplete(currentRef, cursorRect);
|
||||
} else {
|
||||
this.referenceAutocomplete.hideAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Заменяет текст ссылки на визуальный бокс
|
||||
*/
|
||||
private replaceReferenceWithBox(match: ReferenceMatch, item: AutocompleteItem): void {
|
||||
this.isProcessingReference = true;
|
||||
|
||||
const reference: FileReference = {
|
||||
type: item.type,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
permanent: match.prefix.length === 2 // @@ или ##
|
||||
};
|
||||
|
||||
// Создаем бокс
|
||||
const box = ReferenceBox.createBox(reference);
|
||||
|
||||
// Используем текущий Range для замены
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) {
|
||||
this.isProcessingReference = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentRange = selection.getRangeAt(0);
|
||||
|
||||
// Создаем новый диапазон для замены, основываясь на match.startIndex и match.endIndex
|
||||
// Необходимо получить текстовые узлы, соответствующие этим индексам
|
||||
const { startNode, startOffset, endNode, endOffset } = this.findNodesAndOffsetsForMatch(match);
|
||||
|
||||
if (!startNode || !endNode) {
|
||||
console.error("Не удалось найти узлы для замены ссылки", match);
|
||||
this.isProcessingReference = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const replacementRange = document.createRange();
|
||||
replacementRange.setStart(startNode, startOffset);
|
||||
replacementRange.setEnd(endNode, endOffset);
|
||||
|
||||
replacementRange.deleteContents(); // Удаляем текст ссылки
|
||||
replacementRange.insertNode(box); // Вставляем бокс
|
||||
|
||||
// Очищаем выделение
|
||||
selection.removeAllRanges();
|
||||
|
||||
// Добавляем ссылку в список текущих ссылок
|
||||
this.currentReferences.push(reference);
|
||||
|
||||
this.isProcessingReference = false;
|
||||
|
||||
// Вставляем пробел после бокса и перемещаем курсор за него
|
||||
this.insertSpaceAndSetCursorAfterElement(box);
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит узлы и смещения для заданного match в contenteditable элементе.
|
||||
* @param match - объект ReferenceMatch.
|
||||
* @returns Объект с startNode, startOffset, endNode, endOffset.
|
||||
*/
|
||||
private findNodesAndOffsetsForMatch(match: ReferenceMatch): {
|
||||
startNode: Node | null,
|
||||
startOffset: number,
|
||||
endNode: Node | null,
|
||||
endOffset: number
|
||||
} {
|
||||
let startNode: Node | null = null;
|
||||
let startOffset = 0;
|
||||
let endNode: Node | null = null;
|
||||
let endOffset = 0;
|
||||
let currentLength = 0;
|
||||
|
||||
const walker = document.createTreeWalker(this.messageInput, NodeFilter.SHOW_ALL); // SHOW_ALL, чтобы учитывать и элементы
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
const nodeTextLength = this.getNodeEquivalentLengthForReferenceParsing(node);
|
||||
|
||||
// Проверяем, начинается ли ссылка в этом узле
|
||||
if (currentLength <= match.startIndex && match.startIndex < currentLength + nodeTextLength) {
|
||||
startNode = node;
|
||||
startOffset = match.startIndex - currentLength;
|
||||
}
|
||||
|
||||
// Проверяем, заканчивается ли ссылка в этом узле
|
||||
if (currentLength <= match.endIndex && match.endIndex <= currentLength + nodeTextLength) {
|
||||
endNode = node;
|
||||
endOffset = match.endIndex - currentLength;
|
||||
break; // Нашли оба, выходим
|
||||
}
|
||||
|
||||
// Если ссылка охватывает несколько узлов, может быть, endNode еще не найден,
|
||||
// но мы уже прошли startNode.
|
||||
if (startNode && !endNode && match.endIndex <= currentLength + nodeTextLength) {
|
||||
// Это случай, когда endMatch.endIndex может быть на границе или внутри следующего узла
|
||||
endNode = node;
|
||||
endOffset = match.endIndex - currentLength;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
currentLength += nodeTextLength;
|
||||
}
|
||||
|
||||
// Если endNode не найден, возможно, match.endIndex указывает на конец inputElement
|
||||
if (!endNode && startNode && currentLength <= match.endIndex) {
|
||||
if (this.messageInput.lastChild) {
|
||||
endNode = this.messageInput.lastChild;
|
||||
endOffset = this.messageInput.lastChild.textContent?.length || 0; // Или конец всего контента
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { startNode, startOffset, endNode, endOffset };
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет эквивалентную текстовую длину узла для целей парсинга ссылок.
|
||||
* Это отличается от getNodeEquivalentLength, так как здесь reference-box уже считается как его текстовое представление,
|
||||
* а не 0, так как мы ищем где находится match.
|
||||
* @param node - узел DOM.
|
||||
* @returns эквивалентная текстовая длина узла.
|
||||
*/
|
||||
private getNodeEquivalentLengthForReferenceParsing(node: Node): number {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent?.length || 0;
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).classList.contains('reference-box')) {
|
||||
const reference = ReferenceBox.extractReference(node as HTMLElement);
|
||||
if (reference) {
|
||||
// Если это reference-box, его "текстовая длина" для парсинга - это его исходная @/@@#name
|
||||
return (reference.permanent ? 2 : 1) + reference.name.length;
|
||||
}
|
||||
}
|
||||
return 0; // Другие элементы не влияют на текстовую длину для этого контекста
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставляет пробел после указанного элемента и устанавливает курсор.
|
||||
* @param element - HTML элемент, после которого нужно вставить пробел и установить курсор.
|
||||
*/
|
||||
private insertSpaceAndSetCursorAfterElement(element: HTMLElement): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStartAfter(element);
|
||||
range.collapse(true); // Схлопываем диапазон до одной точки
|
||||
|
||||
// Вставляем неразрывный пробел
|
||||
const spaceNode = document.createTextNode('\u00A0');
|
||||
range.insertNode(spaceNode);
|
||||
|
||||
// Перемещаем курсор после вставленного пробела
|
||||
range.setStartAfter(spaceNode);
|
||||
range.collapse(true);
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Заменяет текст на HTML элемент
|
||||
*/
|
||||
private replaceTextWithElement(match: ReferenceMatch, element: HTMLElement): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return;
|
||||
|
||||
// Находим текстовый узел с совпадением
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(this.messageInput);
|
||||
range.setEnd(selection.focusNode!, selection.focusOffset); // Устанавливаем конец диапазона на текущую позицию курсора
|
||||
|
||||
let textBeforeMatch = '';
|
||||
let nodeBeforeMatch: Node | null = null;
|
||||
let offsetBeforeMatch = 0;
|
||||
|
||||
// Проходим по дочерним элементам до match.startIndex
|
||||
const walker = document.createTreeWalker(this.messageInput, NodeFilter.SHOW_TEXT);
|
||||
let node: Node | null;
|
||||
let currentLength = 0;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
const nodeText = node.textContent || '';
|
||||
if (currentLength + nodeText.length >= match.startIndex) {
|
||||
nodeBeforeMatch = node;
|
||||
offsetBeforeMatch = match.startIndex - currentLength;
|
||||
break;
|
||||
}
|
||||
currentLength += nodeText.length;
|
||||
}
|
||||
|
||||
if (!nodeBeforeMatch) {
|
||||
console.warn("Не удалось найти текстовый узел для начала замены.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Удаляем диапазон, соответствующий тексту ссылки
|
||||
const deleteRange = document.createRange();
|
||||
deleteRange.setStart(nodeBeforeMatch, offsetBeforeMatch);
|
||||
|
||||
// Расширяем диапазон удаления до конца 'fullMatch'
|
||||
let remainingLengthToDelete = match.fullMatch.length;
|
||||
let currentNode = nodeBeforeMatch;
|
||||
let currentOffset = offsetBeforeMatch;
|
||||
|
||||
while (currentNode && remainingLengthToDelete > 0) {
|
||||
const nodeText = currentNode.textContent || '';
|
||||
const canDelete = Math.min(remainingLengthToDelete, nodeText.length - currentOffset);
|
||||
remainingLengthToDelete -= canDelete;
|
||||
currentOffset += canDelete;
|
||||
|
||||
if (remainingLengthToDelete > 0) {
|
||||
// Если не всё удалено в текущем узле, переходим к следующему
|
||||
currentNode = walker.nextNode();
|
||||
currentOffset = 0; // Начинаем с начала следующего узла
|
||||
}
|
||||
}
|
||||
deleteRange.setEnd(currentNode || nodeBeforeMatch, currentOffset);
|
||||
deleteRange.deleteContents();
|
||||
|
||||
|
||||
// Вставляем новый элемент на место удаленного текста
|
||||
deleteRange.insertNode(element);
|
||||
|
||||
// Очищаем выделение
|
||||
selection.removeAllRanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Вставляет пробел после указанного элемента.
|
||||
* @param element - HTML элемент, после которого нужно вставить пробел.
|
||||
*/
|
||||
private insertSpaceAfterElement(element: HTMLElement): void {
|
||||
const spaceNode = document.createTextNode('\u00A0'); // Неразрывный пробел
|
||||
if (element.nextSibling) {
|
||||
element.parentNode?.insertBefore(spaceNode, element.nextSibling);
|
||||
} else {
|
||||
element.parentNode?.appendChild(spaceNode);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает курсор после указанного элемента.
|
||||
* @param element - HTML элемент, после которого нужно установить курсор.
|
||||
*/
|
||||
private setCursorAfterElement(element: HTMLElement): void {
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
|
||||
// Проверяем, есть ли после элемента текстовый узел (который мы только что вставили)
|
||||
let targetNode = element.nextSibling;
|
||||
if (targetNode?.nodeType === Node.TEXT_NODE) {
|
||||
const range = document.createRange();
|
||||
range.setStart(targetNode, targetNode.textContent!.length);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
} else {
|
||||
// Если нет текстового узла (такого быть не должно после insertSpaceAfterElement),
|
||||
// ставим курсор максимально близко к элементу.
|
||||
const range = document.createRange();
|
||||
range.setStartAfter(element);
|
||||
range.collapse(true);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает позицию курсора в тексте
|
||||
* (теперь используем findNodesAndOffsetsForMatch для более точного определения текстового смещения)
|
||||
*/
|
||||
private getCursorPositionInText(): number {
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.rangeCount === 0) return -1;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
// Получаем "абсолютный" индекс курсора в контексте всего текстового содержимого `messageInput`
|
||||
// с учетом того, что reference-box'ы представлены их текстовыми аналогами в getTextContent()
|
||||
let currentTextOffset = 0;
|
||||
const walker = document.createTreeWalker(this.messageInput, NodeFilter.SHOW_ALL);
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node === range.endContainer) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
currentTextOffset += range.endOffset;
|
||||
} else if (node === this.messageInput) {
|
||||
// Cлучай, когда курсор в корневом элементе, но не в текстовом узле.
|
||||
// Может быть, курсор находится между элементами или в конце.
|
||||
// Нам нужно определить, сколько узлов до курсора.
|
||||
let nodeBeforeCursorElementCount = 0;
|
||||
for (let i = 0; i < range.endOffset; i++) {
|
||||
const childNode = this.messageInput.childNodes[i];
|
||||
currentTextOffset += this.getNodeEquivalentLengthForReferenceParsing(childNode);
|
||||
}
|
||||
} else {
|
||||
// Если endContainer - это другой элемент (например, <br>),
|
||||
// то курсор находится до него.
|
||||
// Мы не должны добавлять его длину, но должны учесть, сколько текста было до него.
|
||||
// В данном случае `range.endOffset` будет 0, если курсор перед ним.
|
||||
// Если курсор внутри элемента, нужно по-другому считать.
|
||||
// В контексте `contenteditable` это обычно означает, что мы либо в текстовом узле,
|
||||
// либо `endContainer` является родителем, а `endOffset` - индексом дочернего элемента.
|
||||
// Для `reference-box` курсор внутри быть не должен (т.к. он `<span contenteditable="false">`).
|
||||
// Для других элементов, если курсор внутри них, `range.endOffset` будет смещением.
|
||||
}
|
||||
break;
|
||||
} else {
|
||||
currentTextOffset += this.getNodeEquivalentLengthForReferenceParsing(node);
|
||||
}
|
||||
}
|
||||
|
||||
return currentTextOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычисляет эквивалентную текстовую длину HTML-элемента, такую как reference-box.
|
||||
* @param node - HTML-элемент.
|
||||
* @returns эквивалентная текстовая длина элемента.
|
||||
*/
|
||||
private getNodeEquivalentLength(node: Node): number {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
return node.textContent?.length || 0;
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).classList.contains('reference-box')) {
|
||||
return 1; // Учитываем как 1 позицию, т.к. курсор не может быть внутри
|
||||
}
|
||||
// Для других элементов, считаем 0, если они не содержат текст
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает текстовое содержимое input элемента (без HTML),
|
||||
* заменяя reference-box их текстовым представлением.
|
||||
*/
|
||||
private getTextContent(): string {
|
||||
let result = '';
|
||||
const walker = document.createTreeWalker(this.messageInput, NodeFilter.SHOW_ALL); // SHOW_ALL для корректного обхода
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
result += node.textContent || '';
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).classList.contains('reference-box')) {
|
||||
const reference = ReferenceBox.extractReference(node as HTMLElement);
|
||||
if (reference) {
|
||||
const prefix = reference.permanent
|
||||
? reference.type === 'file' ? '@@' : '##'
|
||||
: reference.type === 'file' ? '@' : '#';
|
||||
result += prefix + reference.name;
|
||||
}
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
// Для других элементов, которые могут быть в contenteditable (например, <br>),
|
||||
// нужно решить, как их представлять в текстовом содержимом.
|
||||
// В данном случае, если это <br>, его можно игнорировать или заменить на \n.
|
||||
// Для простоты, пока игнорируем, если это не бокс.
|
||||
// Если внутри inputElement есть другие элементы, они также будут частью getTextContent,
|
||||
// поэтому walker.nextNode() будет их учитывать.
|
||||
// Возможно, здесь нужно быть более точным, основываясь на ожидаемой структуре.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает текст с заменой боксов на исходные ссылки
|
||||
*/
|
||||
private extractTextWithReferences(): string {
|
||||
let result = '';
|
||||
|
||||
const childNodes = Array.from(this.messageInput.childNodes);
|
||||
|
||||
for (const childNode of childNodes) {
|
||||
if (childNode.nodeType === Node.TEXT_NODE) {
|
||||
result += childNode.textContent;
|
||||
} else if (childNode.nodeType === Node.ELEMENT_NODE) {
|
||||
const element = childNode as HTMLElement;
|
||||
|
||||
if (element.classList.contains('reference-box')) {
|
||||
// Это бокс ссылки - заменяем на исходную ссылку
|
||||
const reference = ReferenceBox.extractReference(element);
|
||||
if (reference) {
|
||||
const prefix = reference.permanent
|
||||
? reference.type === 'file'
|
||||
? '@@'
|
||||
: '##'
|
||||
: reference.type === 'file'
|
||||
? '@'
|
||||
: '#';
|
||||
result += `${prefix}${reference.name}`;
|
||||
}
|
||||
} else {
|
||||
// Обычный элемент - берем текстовое содержимое
|
||||
result += element.textContent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка отправки сообщения с раскрытием ссылок
|
||||
*/
|
||||
private async handleSendWithReferences(): Promise<void> {
|
||||
const rawText = this.extractTextWithReferences();
|
||||
const message = rawText.trim();
|
||||
|
||||
if (!message) return;
|
||||
|
||||
if (!this.referenceExpander) {
|
||||
// Если система ссылок не инициализирована, отправляем как обычно
|
||||
this.handleSend();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Раскрываем ссылки перед отправкой
|
||||
const expandedText = await this.referenceExpander.expandReferences(rawText, this.currentReferences);
|
||||
|
||||
// Отправляем expandedText вместо rawText
|
||||
if (this.props.onSendMessage) {
|
||||
this.props.onSendMessage(expandedText, this.selectedModel);
|
||||
}
|
||||
|
||||
// Очищаем состояние
|
||||
this.clearInput();
|
||||
} catch (error) {
|
||||
console.error('Ошибка при раскрытии ссылок:', error);
|
||||
// В случае ошибки отправляем исходный текст
|
||||
if (this.props.onSendMessage) {
|
||||
this.props.onSendMessage(rawText, this.selectedModel);
|
||||
}
|
||||
this.clearInput();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает поле ввода и сбрасывает состояние ссылок
|
||||
*/
|
||||
private clearInput(): void {
|
||||
this.messageInput.innerHTML = '';
|
||||
this.currentReferences = [];
|
||||
this.updatePlaceholder();
|
||||
this.hideSuggestions();
|
||||
if (this.referenceAutocomplete) {
|
||||
this.referenceAutocomplete.hideAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
/*handleSend(): void {
|
||||
const message = this.messageInput.textContent?.trim() || '';
|
||||
if (message && this.props.onSendMessage) {
|
||||
this.props.onSendMessage(message, this.selectedModel);
|
||||
this.messageInput.textContent = '';
|
||||
this.updatePlaceholder();
|
||||
this.hideSuggestions();
|
||||
}
|
||||
}*/
|
||||
|
||||
// Обновить метод handleSend() для совместимости:
|
||||
handleSend(): void {
|
||||
const message = this.messageInput.textContent?.trim() || '';
|
||||
if (message && this.props.onSendMessage) {
|
||||
this.props.onSendMessage(message, this.selectedModel);
|
||||
this.clearInput();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,4 +7,8 @@ export interface ChatHistoryItem {
|
|||
role: string;
|
||||
content: string;
|
||||
type?: 'user' | 'llm' | 'tool' | 'error' | 'default';
|
||||
}
|
||||
node_id?: string;
|
||||
// TODO: добавить поля для снапшотов файлов при реализации
|
||||
// fileSnapshots?: Record<string, string>;
|
||||
// expandedContent?: string; // содержимое после раскрытия ссылок
|
||||
}
|
||||
|
|
|
|||
|
|
@ -286,7 +286,7 @@
|
|||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.message-input-editor {
|
||||
/*.message-input-editor {
|
||||
outline: none;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
|
|
@ -294,6 +294,14 @@
|
|||
padding: 8px 0;
|
||||
color: var(--text-normal);
|
||||
position: relative;
|
||||
}*/
|
||||
|
||||
.message-input-editor {
|
||||
min-height: 20px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.message-input-editor.is-empty::before {
|
||||
|
|
@ -303,6 +311,34 @@
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.message-input-editor[data-placeholder]::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.message-input-editor.is-empty[data-placeholder]::before {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.message-input-editor:not(.is-empty)[data-placeholder]::before,
|
||||
.message-input-editor:focus[data-placeholder]::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-input-editor .reference-box {
|
||||
display: inline-flex !important;
|
||||
vertical-align: middle;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.message-input-editor .reference-box:focus {
|
||||
outline: 2px solid var(--interactive-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.chat-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -396,6 +432,11 @@
|
|||
.model-option:hover {
|
||||
}
|
||||
|
||||
.model-option.selected {
|
||||
background-color: var(--background-modifier-selected); /* Или другой подходящий цвет */
|
||||
color: var(--text-highlighted); /* Или другой подходящий цвет */
|
||||
}
|
||||
|
||||
.model-option:first-child {
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
|
@ -455,6 +496,11 @@
|
|||
.suggestion-item:hover {
|
||||
}
|
||||
|
||||
.suggestion-item.selected {
|
||||
background-color: var(--background-modifier-selected);
|
||||
color: var(--text-highlighted);
|
||||
}
|
||||
|
||||
/* Удаляем старые стили для chat-input-form и message-input */
|
||||
.chat-input-form {
|
||||
display: none;
|
||||
|
|
@ -486,6 +532,15 @@
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Стили для заголовков графов в панели истории */
|
||||
.graph-title-generated {
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
.graph-title-pending {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------- Toggle Button Styles --------------------------------------------------- */
|
||||
|
||||
.history-toggle-button {
|
||||
|
|
@ -521,3 +576,198 @@
|
|||
position: relative;
|
||||
background-color: var(--background-primary);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------- Reference System Styles --------------------------------------------------- */
|
||||
|
||||
/* Reference Boxes */
|
||||
.reference-box {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
margin: 0 2px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
vertical-align: middle;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.reference-box:hover {
|
||||
opacity: 0.8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.reference-box-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.reference-box-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Файлы - одинарная ссылка @ */
|
||||
.reference-box.file.single {
|
||||
background: rgba(100, 150, 255, 0.1);
|
||||
border: 1px solid rgba(100, 150, 255, 0.3);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* Файлы - постоянная ссылка @@ */
|
||||
.reference-box.file.permanent {
|
||||
background: rgba(100, 150, 255, 0.2);
|
||||
border: 1px solid rgba(100, 150, 255, 0.6);
|
||||
color: var(--text-normal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reference-box.file.permanent .reference-box-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Перебиваем стиль Obsidian для prompt*/
|
||||
.reference-box.prompt {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
|
||||
box-shadow: var(--shadow-l);
|
||||
|
||||
z-index: auto;
|
||||
position: static;
|
||||
top: auto;
|
||||
width: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Промпты - одинарная ссылка # */
|
||||
.reference-box.prompt.single {
|
||||
background: rgba(255, 150, 100, 0.1);
|
||||
border: 1px solid rgba(255, 150, 100, 0.3);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
|
||||
/* Промпты - постоянная ссылка ## */
|
||||
.reference-box.prompt.permanent {
|
||||
background: rgba(238, 122, 69, 0.2);
|
||||
border: 1px solid rgba(255, 150, 100, 0.6);
|
||||
color: var(--text-normal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.reference-box.prompt.permanent .reference-box-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Reference Autocomplete */
|
||||
.reference-autocomplete {
|
||||
position: absolute;
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 1000;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
min-width: 250px;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.reference-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text-normal);
|
||||
transition: background-color 0.2s;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.reference-option:hover,
|
||||
.reference-option.selected {
|
||||
background-color: var(--background-modifier-hover);
|
||||
}
|
||||
|
||||
.reference-option:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.reference-option-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.reference-option-name {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reference-option-path {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
margin-left: 8px;
|
||||
max-width: 150px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Темная тема */
|
||||
.theme-dark .reference-autocomplete {
|
||||
background: var(--background-primary);
|
||||
border-color: var(--border-color);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.theme-dark .reference-option:hover,
|
||||
.theme-dark .reference-option.selected {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Светлая тема */
|
||||
.theme-light .reference-autocomplete {
|
||||
background: #ffffff;
|
||||
border-color: #e0e0e0;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.theme-light .reference-option:hover,
|
||||
.theme-light .reference-option.selected {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
/* Скроллбар для автокомплита */
|
||||
.reference-autocomplete::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.reference-autocomplete::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.reference-autocomplete::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb-bg);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.reference-autocomplete::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-bg-hover);
|
||||
}
|
||||
0
src/docs/Reference-Spec.md
Normal file
0
src/docs/Reference-Spec.md
Normal file
209
src/references/AutocompleteManager.ts
Normal file
209
src/references/AutocompleteManager.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
/**
|
||||
* Управляет автокомплитом для ссылок на файлы и промпты.
|
||||
* Обеспечивает поиск файлов в хранилище и кеширование результатов.
|
||||
*/
|
||||
|
||||
import { TFile } from 'obsidian';
|
||||
import LLMAgentPlugin from 'main';
|
||||
|
||||
// ----------------------------------------------- Interfaces ---------------------------------------------------------------
|
||||
|
||||
export interface AutocompleteItem {
|
||||
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 секунд
|
||||
|
||||
constructor(plugin: LLMAgentPlugin) {
|
||||
this.plugin = plugin;
|
||||
this.setupFileWatcher();
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Получает список файлов для автокомплита
|
||||
* @param query - поисковый запрос
|
||||
* @returns массив файлов для автокомплита
|
||||
*/
|
||||
async getFileOptions(query: string): Promise<AutocompleteItem[]> {
|
||||
if (!this.plugin.settings.enableFileReferences) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await this.updateCacheIfNeeded();
|
||||
|
||||
const allFiles = this.fileCache.get('all') || [];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Получает список промптов для автокомплита
|
||||
* @param query - поисковый запрос
|
||||
* @returns массив промптов для автокомплита
|
||||
*/
|
||||
async getPromptOptions(query: string): Promise<AutocompleteItem[]> {
|
||||
if (!this.plugin.settings.enablePromptReferences) {
|
||||
return [];
|
||||
}
|
||||
|
||||
await this.updateCacheIfNeeded();
|
||||
|
||||
const allPrompts = this.promptCache.get('all') || [];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает кеш (используется при изменении настроек)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.fileCache.clear();
|
||||
this.promptCache.clear();
|
||||
this.lastCacheUpdate = 0;
|
||||
}
|
||||
|
||||
// ----------------------------------------------- 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 updateCache(): Promise<void> {
|
||||
const files = this.plugin.app.vault.getMarkdownFiles();
|
||||
const fileItems: AutocompleteItem[] = [];
|
||||
const promptItems: AutocompleteItem[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const item: AutocompleteItem = {
|
||||
name: file.basename,
|
||||
path: file.path,
|
||||
type: this.isPromptFile(file) ? 'prompt' : 'file',
|
||||
icon: this.getFileIcon(file),
|
||||
file: file
|
||||
};
|
||||
|
||||
if (item.type === 'prompt') {
|
||||
promptItems.push(item);
|
||||
} else {
|
||||
fileItems.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
// Сортируем по имени
|
||||
fileItems.sort((a, b) => a.name.localeCompare(b.name));
|
||||
promptItems.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
this.fileCache.set('all', fileItems);
|
||||
this.promptCache.set('all', promptItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли файл промптом
|
||||
*/
|
||||
private isPromptFile(file: TFile): boolean {
|
||||
const promptsFolder = this.plugin.settings.promptsFolder;
|
||||
if (!promptsFolder) return false;
|
||||
|
||||
// Нормализуем путь к папке промптов
|
||||
const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
return file.path.startsWith(normalizedPromptsPath + '/') ||
|
||||
file.path === normalizedPromptsPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает иконку для файла на основе 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';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Настраивает отслеживание изменений файлов для обновления кеша
|
||||
*/
|
||||
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();
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
320
src/references/ReferenceAutocomplete.ts
Normal file
320
src/references/ReferenceAutocomplete.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
/**
|
||||
* Компонент автокомплита для ссылок. Показывает выпадающий список файлов и промптов
|
||||
* при вводе @/@@ и #/## с умным позиционированием относительно курсора.
|
||||
*/
|
||||
|
||||
import { AutocompleteItem, AutocompleteManager } from './AutocompleteManager';
|
||||
import { ReferenceMatch } from './ReferenceParser';
|
||||
import { setIcon } from 'obsidian';
|
||||
|
||||
// ----------------------------------------------- Reference Autocomplete Component ---------------------------------------------------------------
|
||||
|
||||
export class ReferenceAutocomplete {
|
||||
private container: HTMLElement;
|
||||
private dropdown: HTMLElement;
|
||||
private inputElement: HTMLElement;
|
||||
private autocompleteManager: AutocompleteManager;
|
||||
private currentReference: ReferenceMatch | null = null;
|
||||
private selectedIndex = 0;
|
||||
private currentOptions: AutocompleteItem[] = [];
|
||||
private onItemSelectedCallback?: (item: AutocompleteItem, match: ReferenceMatch) => void;
|
||||
private mouseIsOverDropdown = false; // Флаг для отслеживания наведения мыши
|
||||
|
||||
constructor(inputElement: HTMLElement, autocompleteManager: AutocompleteManager) {
|
||||
this.inputElement = inputElement;
|
||||
this.autocompleteManager = autocompleteManager;
|
||||
this.container = this.inputElement.closest('.chat-input-container') as HTMLElement;
|
||||
|
||||
this.createDropdown();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Показывает автокомплит с опциями.
|
||||
* @param reference - объект ReferenceMatch, определяющий текущий вводимый референс.
|
||||
* @param cursorRect - DOMRect объекта курсора, для определения позиции.
|
||||
*/
|
||||
async showAutocomplete(reference: ReferenceMatch, cursorRect: DOMRect): Promise<void> {
|
||||
this.currentReference = reference;
|
||||
|
||||
// Получаем опции в зависимости от типа ссылки
|
||||
const options = reference.type === 'file'
|
||||
? await this.autocompleteManager.getFileOptions(reference.name)
|
||||
: await this.autocompleteManager.getPromptOptions(reference.name);
|
||||
|
||||
if (options.length === 0) {
|
||||
this.hideAutocomplete();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentOptions = options;
|
||||
// Сбрасываем selection, если новые опции не содержат предыдущий выбранный элемент
|
||||
if (this.selectedIndex >= this.currentOptions.length) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
this.renderOptions();
|
||||
this.positionDropdown(cursorRect);
|
||||
this.dropdown.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Скрывает автокомплит
|
||||
*/
|
||||
hideAutocomplete(): void {
|
||||
this.dropdown.style.display = 'none';
|
||||
this.currentReference = null;
|
||||
this.currentOptions = [];
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает нажатия клавиш для навигации
|
||||
* @param event - событие клавиатуры
|
||||
* @returns true если событие было обработано
|
||||
*/
|
||||
handleKeydown(event: KeyboardEvent): boolean {
|
||||
// Мы хотим, чтобы стрелки и Enter обрабатывались только если автокомплит видим
|
||||
if (!this.isVisible()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault(); // Предотвращаем прокрутку страницы
|
||||
this.moveSelection(1);
|
||||
return true;
|
||||
|
||||
case 'ArrowUp':
|
||||
event.preventDefault(); // Предотвращаем прокрутку страницы
|
||||
this.moveSelection(-1);
|
||||
return true;
|
||||
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
// Разрешаем Tab завершать автокомплит
|
||||
event.preventDefault();
|
||||
this.selectCurrentItem();
|
||||
return true;
|
||||
|
||||
case 'Escape':
|
||||
event.preventDefault();
|
||||
this.hideAutocomplete();
|
||||
return true;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Устанавливает коллбек для выбора элемента
|
||||
* @param callback - функция обратного вызова
|
||||
*/
|
||||
onItemSelected(callback: (item: AutocompleteItem, match: ReferenceMatch) => void): void {
|
||||
this.onItemSelectedCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, виден ли автокомплит
|
||||
* @returns true если виден
|
||||
*/
|
||||
isVisible(): boolean {
|
||||
return this.dropdown.style.display === 'block';
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Создает элемент выпадающего списка
|
||||
*/
|
||||
private createDropdown(): void {
|
||||
this.dropdown = document.createElement('div');
|
||||
this.dropdown.className = 'reference-autocomplete';
|
||||
this.dropdown.style.display = 'none';
|
||||
|
||||
// Добавляем dropdown в контейнер
|
||||
this.container.appendChild(this.dropdown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Настраивает обработчики событий
|
||||
*/
|
||||
private setupEventListeners(): void {
|
||||
// Закрытие при клике вне dropdown
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.dropdown.contains(e.target as Node) && !this.inputElement.contains(e.target as Node)) {
|
||||
this.hideAutocomplete();
|
||||
}
|
||||
});
|
||||
|
||||
// Отслеживание наведения мыши для предотвращения скрытия по blur
|
||||
this.dropdown.addEventListener('mouseenter', () => {
|
||||
this.mouseIsOverDropdown = true;
|
||||
});
|
||||
this.dropdown.addEventListener('mouseleave', () => {
|
||||
this.mouseIsOverDropdown = false;
|
||||
});
|
||||
|
||||
// Скрытие при потере фокуса input элементом, если мышь не над дропдауном
|
||||
this.inputElement.addEventListener('blur', () => {
|
||||
// Задержка позволяет клику на элемент дропдауна сработать
|
||||
if (!this.mouseIsOverDropdown) {
|
||||
this.hideAutocomplete();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Отрисовывает опции в dropdown
|
||||
*/
|
||||
private renderOptions(): void {
|
||||
this.dropdown.innerHTML = '';
|
||||
|
||||
this.currentOptions.forEach((option, index) => {
|
||||
const optionElement = this.createOptionElement(option, index === this.selectedIndex);
|
||||
this.dropdown.appendChild(optionElement);
|
||||
});
|
||||
this.scrollToSelected(); // Убеждаемся, что выбранный элемент виден
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает элемент опции
|
||||
* @param option - опция для отображения
|
||||
* @param isSelected - выбрана ли опция
|
||||
* @returns HTML элемент опции
|
||||
*/
|
||||
private createOptionElement(option: AutocompleteItem, isSelected: boolean): HTMLElement {
|
||||
const optionDiv = document.createElement('div');
|
||||
optionDiv.className = `reference-option ${isSelected ? 'selected' : ''}`;
|
||||
|
||||
// Иконка
|
||||
const iconSpan = document.createElement('span');
|
||||
iconSpan.className = 'reference-option-icon';
|
||||
setIcon(iconSpan, option.icon);
|
||||
|
||||
// Имя файла
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'reference-option-name';
|
||||
nameSpan.textContent = option.name;
|
||||
|
||||
// Путь к файлу
|
||||
const pathSpan = document.createElement('span');
|
||||
pathSpan.className = 'reference-option-path';
|
||||
pathSpan.textContent = option.path;
|
||||
|
||||
optionDiv.appendChild(iconSpan);
|
||||
optionDiv.appendChild(nameSpan);
|
||||
optionDiv.appendChild(pathSpan);
|
||||
|
||||
// Обработчик клика
|
||||
optionDiv.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.selectItem(option);
|
||||
});
|
||||
|
||||
return optionDiv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Позиционирует dropdown относительно курсора
|
||||
* @param position - желаемая позиция
|
||||
*/
|
||||
private positionDropdown(cursorRect: DOMRect): void {
|
||||
const inputRect = this.inputElement.getBoundingClientRect();
|
||||
const containerRect = this.container.getBoundingClientRect();
|
||||
const dropdownRect = this.dropdown.getBoundingClientRect(); // Получаем размеры дропдауна
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Позиционируем относительно inputElement, т.к. он уже в потоке документа
|
||||
// Левый край дропдауна совпадает с левым краем курсора
|
||||
let left = cursorRect.left - containerRect.left;
|
||||
|
||||
// Дефолтное положение - под курсором
|
||||
let 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;
|
||||
}
|
||||
|
||||
this.dropdown.style.top = `${top}px`;
|
||||
this.dropdown.style.left = `${left}px`;
|
||||
// Устанавливаем ширину дропдауна равной ширине inputElement, если он больше
|
||||
// Это предотвратит слишком узкий дропдаун, если имя файла короткое
|
||||
this.dropdown.style.minWidth = `${inputRect.width / 2}px`; // Минимум половина ширины инпута
|
||||
this.dropdown.style.width = 'auto'; // Позволяем контенту определить ширину, но не меньше minWidth
|
||||
}
|
||||
|
||||
/**
|
||||
* Перемещает выделение в списке
|
||||
* @param direction - направление (1 = вниз, -1 = вверх)
|
||||
*/
|
||||
private moveSelection(direction: number): void {
|
||||
this.selectedIndex += direction;
|
||||
|
||||
if (this.selectedIndex < 0) {
|
||||
this.selectedIndex = this.currentOptions.length - 1;
|
||||
} else if (this.selectedIndex >= this.currentOptions.length) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
this.renderOptions();
|
||||
this.scrollToSelected();
|
||||
}
|
||||
|
||||
/**
|
||||
* Прокручивает список к выбранному элементу
|
||||
*/
|
||||
private scrollToSelected(): void {
|
||||
const selectedElement = this.dropdown.querySelector('.reference-option.selected') as HTMLElement;
|
||||
if (selectedElement) {
|
||||
selectedElement.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбирает текущий элемент
|
||||
*/
|
||||
private selectCurrentItem(): void {
|
||||
if (this.selectedIndex >= 0 && this.selectedIndex < this.currentOptions.length) {
|
||||
this.selectItem(this.currentOptions[this.selectedIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выбирает указанный элемент
|
||||
* @param item - выбранный элемент
|
||||
*/
|
||||
private selectItem(item: AutocompleteItem): void {
|
||||
if (this.onItemSelectedCallback && this.currentReference) {
|
||||
this.onItemSelectedCallback(item, this.currentReference);
|
||||
}
|
||||
this.hideAutocomplete();
|
||||
}
|
||||
|
||||
/**
|
||||
* Очищает ресурсы
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.dropdown.parentNode) {
|
||||
this.dropdown.parentNode.removeChild(this.dropdown);
|
||||
}
|
||||
}
|
||||
}
|
||||
140
src/references/ReferenceBox.ts
Normal file
140
src/references/ReferenceBox.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Создает и управляет визуальными боксами для отображения ссылок в чате.
|
||||
* Поддерживает различные стили для файлов/промптов и одинарных/постоянных ссылок.
|
||||
*/
|
||||
|
||||
import { FileReference } from './ReferenceParser';
|
||||
import { setIcon } from 'obsidian';
|
||||
|
||||
// ----------------------------------------------- Reference Box Component ---------------------------------------------------------------
|
||||
|
||||
export class ReferenceBox {
|
||||
|
||||
/**
|
||||
* Создаёт визуальный бокс для отображения ссылки в сообщении
|
||||
* @param reference - информация о ссылке
|
||||
* @returns HTML элемент бокса
|
||||
*/
|
||||
static createBox(reference: FileReference): HTMLElement {
|
||||
const box = document.createElement('span');
|
||||
box.className = this.getBoxClasses(reference);
|
||||
box.setAttribute('data-reference-type', reference.type);
|
||||
box.setAttribute('data-reference-name', reference.name);
|
||||
box.setAttribute('data-reference-path', reference.path);
|
||||
box.setAttribute('data-reference-permanent', reference.permanent.toString());
|
||||
|
||||
// Создаем иконку
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'reference-box-icon';
|
||||
setIcon(icon, this.getIconName(reference));
|
||||
|
||||
// Создаем текст с именем
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'reference-box-name';
|
||||
nameSpan.textContent = reference.name;
|
||||
|
||||
box.appendChild(icon);
|
||||
box.appendChild(nameSpan);
|
||||
|
||||
// Добавляем обработчик клика для предпросмотра (TODO: будущая функциональность)
|
||||
box.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// TODO: показать предпросмотр содержимого файла
|
||||
console.log('Clicked reference:', reference);
|
||||
});
|
||||
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обновляет существующий бокс
|
||||
* @param boxElement - элемент бокса
|
||||
* @param reference - новая информация о ссылке
|
||||
*/
|
||||
static updateBox(boxElement: HTMLElement, reference: FileReference): void {
|
||||
boxElement.className = this.getBoxClasses(reference);
|
||||
boxElement.setAttribute('data-reference-type', reference.type);
|
||||
boxElement.setAttribute('data-reference-name', reference.name);
|
||||
boxElement.setAttribute('data-reference-path', reference.path);
|
||||
boxElement.setAttribute('data-reference-permanent', reference.permanent.toString());
|
||||
|
||||
const iconElement = boxElement.querySelector('.reference-box-icon');
|
||||
const nameElement = boxElement.querySelector('.reference-box-name');
|
||||
|
||||
if (iconElement) {
|
||||
iconElement.innerHTML = '';
|
||||
setIcon(iconElement as HTMLElement, this.getIconName(reference));
|
||||
}
|
||||
|
||||
if (nameElement) {
|
||||
nameElement.textContent = reference.name;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает информацию о ссылке из элемента бокса
|
||||
* @param boxElement - элемент бокса
|
||||
* @returns информация о ссылке
|
||||
*/
|
||||
static extractReference(boxElement: HTMLElement): FileReference | null {
|
||||
const type = boxElement.getAttribute('data-reference-type') as 'file' | 'prompt';
|
||||
const name = boxElement.getAttribute('data-reference-name');
|
||||
const path = boxElement.getAttribute('data-reference-path');
|
||||
const permanent = boxElement.getAttribute('data-reference-permanent') === 'true';
|
||||
|
||||
if (!type || !name || !path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type,
|
||||
name,
|
||||
path,
|
||||
permanent
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Возвращает CSS классы для бокса
|
||||
*/
|
||||
private static getBoxClasses(reference: FileReference): string {
|
||||
const baseClass = 'reference-box';
|
||||
const typeClass = reference.type;
|
||||
const permanentClass = reference.permanent ? 'permanent' : 'single';
|
||||
|
||||
return `${baseClass} ${typeClass} ${permanentClass}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Возвращает имя иконки для ссылки
|
||||
*/
|
||||
private static getIconName(reference: FileReference): string {
|
||||
if (reference.type === 'prompt') {
|
||||
return 'zap';
|
||||
}
|
||||
|
||||
// Определяем иконку по расширению файла
|
||||
const extension = reference.path.split('.').pop()?.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';
|
||||
}
|
||||
}
|
||||
}
|
||||
249
src/references/ReferenceExpander.ts
Normal file
249
src/references/ReferenceExpander.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* Расширяет ссылки на файлы и промпты, заменяя их содержимым перед отправкой на сервер.
|
||||
* Обрабатывает как одинарные (@/#), так и постоянные (@@/##) ссылки.
|
||||
*/
|
||||
|
||||
import { TFile } from 'obsidian';
|
||||
import LLMAgentPlugin from 'main';
|
||||
import { FileReference, ReferenceParser } from './ReferenceParser';
|
||||
import { TemplateEngine } from './TemplateEngine';
|
||||
|
||||
// ----------------------------------------------- Reference Expander ---------------------------------------------------------------
|
||||
|
||||
export class ReferenceExpander {
|
||||
private plugin: LLMAgentPlugin;
|
||||
private templateEngine: TemplateEngine;
|
||||
|
||||
constructor(plugin: LLMAgentPlugin, templateEngine: TemplateEngine) {
|
||||
this.plugin = plugin;
|
||||
this.templateEngine = templateEngine;
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Заменяет все ссылки на содержимое файлов (перед отправкой на сервер)
|
||||
* @param text - текст с ссылками
|
||||
* @param references - массив ссылок для обработки
|
||||
* @returns текст с развернутыми ссылками
|
||||
*/
|
||||
async expandReferences(text: string, references: FileReference[]): Promise<string> {
|
||||
if (references.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let expandedText = text;
|
||||
|
||||
// Обрабатываем ссылки от конца к началу, чтобы не сбивать индексы
|
||||
const sortedReferences = [...references].sort((a, b) => {
|
||||
const aMatches = ReferenceParser.parseReferences(text).filter(m => m.name === a.name);
|
||||
const bMatches = ReferenceParser.parseReferences(text).filter(m => m.name === b.name);
|
||||
const aIndex = aMatches.length > 0 ? aMatches[aMatches.length - 1].startIndex : 0;
|
||||
const bIndex = bMatches.length > 0 ? bMatches[bMatches.length - 1].startIndex : 0;
|
||||
return bIndex - aIndex;
|
||||
});
|
||||
|
||||
for (const reference of sortedReferences) {
|
||||
expandedText = await this.expandSingleReference(expandedText, reference);
|
||||
}
|
||||
|
||||
return expandedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Обрабатывает историю сообщений, раскрывая постоянные ссылки (@@/##)
|
||||
* @param messages - массив сообщений
|
||||
* @returns обработанные сообщения
|
||||
*/
|
||||
async expandMessageHistory(messages: any[]): Promise<any[]> {
|
||||
const expandedMessages = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === 'user') {
|
||||
const expandedContent = await this.expandPermanentReferences(message.content);
|
||||
expandedMessages.push({
|
||||
...message,
|
||||
content: expandedContent
|
||||
});
|
||||
} else {
|
||||
expandedMessages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
return expandedMessages;
|
||||
}
|
||||
|
||||
// TODO: При реализации снапшотов - сохранять содержимое файлов
|
||||
// async expandReferencesWithSnapshots(text: string, references: FileReference[], messageId: string): Promise<{expandedText: string, snapshots: Record<string, string>}> {
|
||||
// const snapshots: Record<string, string> = {};
|
||||
// let expandedText = text;
|
||||
//
|
||||
// for (const reference of references) {
|
||||
// const content = await this.loadFileContent(reference);
|
||||
// if (content !== null) {
|
||||
// snapshots[reference.path] = content;
|
||||
// expandedText = await this.expandSingleReference(expandedText, reference, content);
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return { expandedText, snapshots };
|
||||
// }
|
||||
|
||||
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Раскрывает одну ссылку в тексте
|
||||
* @param text - текст для обработки
|
||||
* @param reference - ссылка для раскрытия
|
||||
* @param preloadedContent - предзагруженное содержимое (для снапшотов)
|
||||
* @returns текст с раскрытой ссылкой
|
||||
*/
|
||||
private async expandSingleReference(text: string, reference: FileReference, preloadedContent?: string): Promise<string> {
|
||||
const content = preloadedContent || await this.loadFileContent(reference);
|
||||
|
||||
if (content === null) {
|
||||
console.warn(`Не удалось загрузить содержимое для ссылки: ${reference.name}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
// Определяем паттерн для замены
|
||||
const prefix = reference.permanent
|
||||
? (reference.type === 'file' ? '@@' : '##')
|
||||
: (reference.type === 'file' ? '@' : '#');
|
||||
|
||||
// Создаем регулярное выражение для поиска ссылки
|
||||
const escapedName = this.escapeRegExp(reference.name);
|
||||
const pattern = new RegExp(`${this.escapeRegExp(prefix)}(?:"${escapedName}"|${escapedName})(?=\\s|$|[^\\w-])`, 'g');
|
||||
|
||||
// Форматируем содержимое для вставки
|
||||
const formattedContent = this.formatContentForInsertion(content, reference);
|
||||
|
||||
return text.replace(pattern, formattedContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскрывает только постоянные ссылки (@@/##) в тексте
|
||||
* @param text - текст для обработки
|
||||
* @returns текст с раскрытыми постоянными ссылками
|
||||
*/
|
||||
private async expandPermanentReferences(text: string): Promise<string> {
|
||||
const matches = ReferenceParser.parseReferences(text);
|
||||
const permanentMatches = matches.filter(match => match.prefix.length === 2); // @@ или ##
|
||||
|
||||
if (permanentMatches.length === 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
let expandedText = text;
|
||||
|
||||
// Обрабатываем ссылки от конца к началу
|
||||
const sortedMatches = [...permanentMatches].sort((a, b) => b.startIndex - a.startIndex);
|
||||
|
||||
for (const match of sortedMatches) {
|
||||
const reference: FileReference = {
|
||||
type: match.type,
|
||||
name: match.name,
|
||||
path: await this.resolveFilePath(match.name, match.type),
|
||||
permanent: true
|
||||
};
|
||||
|
||||
const content = await this.loadFileContent(reference);
|
||||
if (content !== null) {
|
||||
const formattedContent = this.formatContentForInsertion(content, reference);
|
||||
expandedText = expandedText.substring(0, match.startIndex) +
|
||||
formattedContent +
|
||||
expandedText.substring(match.endIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return expandedText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Загружает содержимое файла
|
||||
* @param reference - ссылка на файл
|
||||
* @returns содержимое файла или null если не найден
|
||||
*/
|
||||
private async loadFileContent(reference: FileReference): Promise<string | null> {
|
||||
try {
|
||||
const file = this.findFile(reference.path, reference.type);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await this.plugin.app.vault.read(file);
|
||||
|
||||
// Для промптов обрабатываем шаблоны
|
||||
if (reference.type === 'prompt') {
|
||||
return await this.templateEngine.expandPromptTemplate(content);
|
||||
}
|
||||
|
||||
return content;
|
||||
} catch (error) {
|
||||
console.error(`Ошибка загрузки файла ${reference.path}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит файл по пути
|
||||
* @param path - путь к файлу
|
||||
* @param type - тип файла
|
||||
* @returns файл или null
|
||||
*/
|
||||
private findFile(path: string, type: 'file' | 'prompt'): TFile | null {
|
||||
let file = this.plugin.app.vault.getAbstractFileByPath(path);
|
||||
|
||||
if (!file && type === 'prompt') {
|
||||
// Попробуем найти в папке промптов
|
||||
const promptsFolder = this.plugin.settings.promptsFolder;
|
||||
if (promptsFolder) {
|
||||
const fullPath = `${promptsFolder}/${path}`;
|
||||
file = this.plugin.app.vault.getAbstractFileByPath(fullPath);
|
||||
}
|
||||
}
|
||||
|
||||
return (file instanceof TFile) ? file : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Разрешает полный путь к файлу по имени
|
||||
* @param name - имя файла
|
||||
* @param type - тип файла
|
||||
* @returns полный путь к файлу
|
||||
*/
|
||||
private async resolveFilePath(name: string, type: 'file' | 'prompt'): Promise<string> {
|
||||
if (type === 'prompt') {
|
||||
const promptsFolder = this.plugin.settings.promptsFolder;
|
||||
return promptsFolder ? `${promptsFolder}/${name}.md` : `${name}.md`;
|
||||
} else {
|
||||
// Ищем файл в хранилище
|
||||
const files = this.plugin.app.vault.getMarkdownFiles();
|
||||
const matchingFile = files.find(f => f.basename === name);
|
||||
return matchingFile ? matchingFile.path : `${name}.md`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Форматирует содержимое для вставки в сообщение
|
||||
* @param content - содержимое файла
|
||||
* @param reference - информация о ссылке
|
||||
* @returns отформатированное содержимое
|
||||
*/
|
||||
private formatContentForInsertion(content: string, reference: FileReference): string {
|
||||
// Добавляем заголовок с информацией о файле
|
||||
const header = `\n\n--- Содержимое ${reference.type === 'prompt' ? 'промпта' : 'файла'}: ${reference.name} ---\n`;
|
||||
const footer = `\n--- Конец ${reference.type === 'prompt' ? 'промпта' : 'файла'}: ${reference.name} ---\n\n`;
|
||||
|
||||
return header + content.trim() + footer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирует специальные символы для регулярного выражения
|
||||
* @param string - строка для экранирования
|
||||
* @returns экранированная строка
|
||||
*/
|
||||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
145
src/references/ReferenceParser.ts
Normal file
145
src/references/ReferenceParser.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Парсер для обработки ссылок на файлы (@/@@) и промпты (#/##) в тексте сообщений.
|
||||
* Поддерживает поиск активных ссылок при вводе и валидацию промптов.
|
||||
*/
|
||||
|
||||
// ----------------------------------------------- Interfaces ---------------------------------------------------------------
|
||||
|
||||
export interface ReferenceMatch {
|
||||
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 при реализации снапшотов
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Reference Parser ---------------------------------------------------------------
|
||||
|
||||
export class ReferenceParser {
|
||||
|
||||
/**
|
||||
* Парсит текст и находит все @/@@ и #/## ссылки
|
||||
* @param text - текст для парсинга
|
||||
* @returns массив найденных ссылок
|
||||
*/
|
||||
static parseReferences(text: string): ReferenceMatch[] {
|
||||
const matches: ReferenceMatch[] = [];
|
||||
|
||||
// Регулярное выражение для поиска @/@@ и #/## ссылок
|
||||
// Поддерживает имена файлов с пробелами, заключенные в кавычки или без пробелов
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверяет, является ли позиция курсора внутри незавершённой ссылки
|
||||
* @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);
|
||||
|
||||
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 null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует промпт на корректность @{...} ссылок
|
||||
* @param promptContent - содержимое промпта
|
||||
* @returns результат валидации
|
||||
*/
|
||||
static validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Ищем шаблонные ссылки @{...}
|
||||
const templateRegex = /@\{([^}]+)\}/g;
|
||||
const matches = promptContent.matchAll(templateRegex);
|
||||
|
||||
for (const match of matches) {
|
||||
const referenceName = match[1].trim();
|
||||
|
||||
// Проверяем, что имя ссылки не пустое
|
||||
if (!referenceName) {
|
||||
errors.push(`Пустая ссылка в позиции ${match.index}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Проверяем, что имя содержит только допустимые символы
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(referenceName)) {
|
||||
errors.push(`Недопустимые символы в ссылке "${referenceName}"`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Извлекает все шаблонные ссылки @{...} из промпта
|
||||
* @param promptContent - содержимое промпта
|
||||
* @returns массив имен ссылок
|
||||
*/
|
||||
static extractTemplateReferences(promptContent: string): string[] {
|
||||
const references: string[] = [];
|
||||
const templateRegex = /@\{([^}]+)\}/g;
|
||||
const matches = promptContent.matchAll(templateRegex);
|
||||
|
||||
for (const match of matches) {
|
||||
const referenceName = match[1].trim();
|
||||
if (referenceName && !references.includes(referenceName)) {
|
||||
references.push(referenceName);
|
||||
}
|
||||
}
|
||||
|
||||
return references;
|
||||
}
|
||||
}
|
||||
130
src/references/TemplateEngine.ts
Normal file
130
src/references/TemplateEngine.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
/**
|
||||
* Движок шаблонов для обработки промптов с ссылками @{...}.
|
||||
* Поддерживает один уровень вложенности и валидацию ссылок.
|
||||
*/
|
||||
|
||||
import { TFile } from 'obsidian';
|
||||
import LLMAgentPlugin from 'main';
|
||||
import { ReferenceParser } from './ReferenceParser';
|
||||
|
||||
// ----------------------------------------------- Template Engine ---------------------------------------------------------------
|
||||
|
||||
export class TemplateEngine {
|
||||
private plugin: LLMAgentPlugin;
|
||||
|
||||
constructor(plugin: LLMAgentPlugin) {
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Public Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Рекурсивно разворачивает промпт, заменяя @{...} ссылки
|
||||
* ВАЖНО: НЕ поддерживает рекурсию - только один уровень вложенности
|
||||
* @param promptContent - содержимое промпта
|
||||
* @returns развернутый промпт
|
||||
*/
|
||||
async expandPromptTemplate(promptContent: string): Promise<string> {
|
||||
// Находим все шаблонные ссылки @{...}
|
||||
const templateReferences = ReferenceParser.extractTemplateReferences(promptContent);
|
||||
|
||||
if (templateReferences.length === 0) {
|
||||
return promptContent;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Валидирует промпт на корректность @{...} ссылок
|
||||
* @param promptContent - содержимое промпта
|
||||
* @returns результат валидации
|
||||
*/
|
||||
validatePromptReferences(promptContent: string): { valid: boolean; errors: string[] } {
|
||||
const parseResult = ReferenceParser.validatePromptReferences(promptContent);
|
||||
const errors = [...parseResult.errors];
|
||||
|
||||
// Дополнительная проверка - существуют ли referenced промпты
|
||||
const templateReferences = ReferenceParser.extractTemplateReferences(promptContent);
|
||||
|
||||
for (const referenceName of templateReferences) {
|
||||
const promptFile = this.findPromptFile(referenceName);
|
||||
if (!promptFile) {
|
||||
errors.push(`Промпт "${referenceName}" не найден в папке ${this.plugin.settings.promptsFolder}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors: errors
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------- Private Methods ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Загружает содержимое промпта по имени
|
||||
* @param promptName - имя промпта
|
||||
* @returns содержимое промпта или null если не найден
|
||||
*/
|
||||
private async loadPromptContent(promptName: string): Promise<string | null> {
|
||||
const promptFile = this.findPromptFile(promptName);
|
||||
|
||||
if (!promptFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await this.plugin.app.vault.read(promptFile);
|
||||
return content.trim();
|
||||
} catch (error) {
|
||||
console.error(`Ошибка чтения промпта "${promptName}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Находит файл промпта по имени
|
||||
* @param promptName - имя промпта
|
||||
* @returns файл промпта или null
|
||||
*/
|
||||
private findPromptFile(promptName: string): TFile | null {
|
||||
const promptsFolder = this.plugin.settings.promptsFolder;
|
||||
if (!promptsFolder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Нормализуем путь к папке промптов
|
||||
const normalizedPromptsPath = promptsFolder.replace(/^\/+|\/+$/g, '');
|
||||
|
||||
// Ищем файл с именем promptName.md в папке промптов
|
||||
const promptPath = `${normalizedPromptsPath}/${promptName}.md`;
|
||||
const file = this.plugin.app.vault.getAbstractFileByPath(promptPath);
|
||||
|
||||
return (file instanceof TFile) ? file : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Экранирует специальные символы для регулярного выражения
|
||||
* @param string - строка для экранирования
|
||||
* @returns экранированная строка
|
||||
*/
|
||||
private escapeRegExp(string: string): string {
|
||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
}
|
||||
|
|
@ -56,20 +56,20 @@ export class ChatView extends ItemView {
|
|||
// Загружаем доступные модели
|
||||
await this.fetchAvailableModels();
|
||||
|
||||
// Инициализируем только чат панель
|
||||
// Инициализируем только чат панель с передачей plugin
|
||||
this.chatPanel = new ChatPanel(this.chatContainer, {
|
||||
chatHistory: this.chatHistory,
|
||||
onSendMessage: this.handleSendMessage.bind(this),
|
||||
availableModels: this.availableModels,
|
||||
selectedModel: this.plugin.settings.defaultModel || 'gemini-2.5-flash'
|
||||
selectedModel: this.plugin.settings.defaultModel || 'gemini-2.5-flash',
|
||||
plugin: this.plugin // Добавляем передачу plugin
|
||||
});
|
||||
|
||||
// Подписываемся на события
|
||||
// Остальной код без изменений...
|
||||
this.plugin.eventBus.on('node-selected', this.handleNodeSelected.bind(this));
|
||||
this.plugin.eventBus.on('new-chat', this.handleNewChat.bind(this));
|
||||
this.plugin.eventBus.on('graph-selected', this.handleGraphSelected.bind(this));
|
||||
}
|
||||
|
||||
async onClose(): Promise<void> {
|
||||
if (this.chatPanel) {
|
||||
this.chatPanel.destroy();
|
||||
|
|
|
|||
|
|
@ -303,6 +303,7 @@ export class GraphView extends ItemView {
|
|||
*/
|
||||
async handleGraphSelected(data: { graphId: string; currentNode: string | null }): Promise<void> {
|
||||
this.selectedGraphId = data.graphId;
|
||||
console.log("11111")
|
||||
// currentNode здесь может быть неактуальным, fetchGraphData загрузит актуальный current_node_id
|
||||
await this.fetchGraphData(data.graphId);
|
||||
// Note: Больше не нужно эмитить 'graph-selected' обратно в ChatView, т.к. ChatView уже инициировал это.
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user