Compare commits

...

10 Commits

Author SHA1 Message Date
dimitrievgs
18af4e7d47 Update VaultQueryHandler.ts with 3 attempts-write 2026-07-08 23:21:25 +03:00
dimitrievgs
44d996e7a4 Debug vault websocket events 2026-07-06 01:50:38 +03:00
dimitrievgs
5f7f07ee6f Add enable-toggles for mcp servers and particular tools 2026-06-14 23:15:46 +03:00
dimitrievgs
1256d64498 Add customization of prompts for MCP tools 2026-06-14 01:33:49 +03:00
dimitrievgs
aa4f8d5342 Fix display of tool results, add support for MCP servers (basic) 2026-06-14 00:20:48 +03:00
dimitrievgs
4362e4f491 Set better obfuscation, add Compile.md 2026-06-06 23:43:54 +03:00
dimitrievgs
43acb3a868 Add debug info display 2026-06-06 01:08:29 +03:00
dimitrievgs
f1dff8f855 Add new branch button to chat panel 2026-06-03 23:25:25 +03:00
dimitrievgs
0acd67ee57 Add agency 2026-05-30 23:47:20 +03:00
dimitrievgs
768d93700e Add drag'n'drop for inner files 2026-05-28 00:32:22 +03:00
18 changed files with 1833 additions and 627 deletions

1
.gitignore vendored
View File

@ -1,5 +1,4 @@
# vscode
.vscode
# Intellij
*.iml

10
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,10 @@
{
"recommendations": [
"dbaeumer.vscode-eslint", // Проверка ошибок в коде
"esbenp.prettier-vscode", // Автоматическое форматирование кода
"ms-vscode.js-debug", // Современный отладчик (встроен в VS Code, но важен для JS/TS)
"visualstudioexptteam.vscodeintellicode", // Умные подсказки при написании кода
"marp-team.marp-vscode", // Если работаете с презентациями в Markdown
"christian-kohler.path-intellisense" // Автодополнение путей к файлам (удобно для импортов)
]
}

22
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,22 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "chrome",
"request": "attach",
"name": "Attach to Obsidian",
"port": 9222,
"address": "localhost",
"webRoot": "${workspaceFolder}",
"sourceMaps": true,
"internalConsoleOptions": "openOnSessionStart",
"sourceMapPathOverrides": {
"../llm-agent-plugin/*": "${workspaceFolder}/*",
"lib/*": "${workspaceFolder}/lib/*",
"src/*": "${workspaceFolder}/src/*",
"webpack:///./src/*": "${workspaceFolder}/src/*",
"esbuild:///*": "${workspaceFolder}/*"
}
}
]
}

9
Compile.md Normal file
View File

@ -0,0 +1,9 @@
С обфускацией
```
node esbuild.config.mjs production
```
Без обфускации
```
node esbuild.config.mjs dev
```

46
DEBUGGING.md Normal file
View File

@ -0,0 +1,46 @@
# Инструкция по отладке плагина Obsidian
Из-за особенностей Obsidian (загрузка плагинов через `eval()`), прямая привязка точек останова (Breakpoints) в интерфейсе VS Code часто невозможна. Рекомендуется использовать встроенные инструменты разработчика Obsidian (Chrome DevTools).
## 1. Подготовка
Убедитесь, что плагин запущен в режиме разработки. Это вшивает карты кода (Source Maps) прямо в файл, позволяя видеть TypeScript вместо скомпилированного JavaScript.
```bash
npm run dev
```
## 2. Способ «debugger» (рекомендуемый)
Самый надежный способ остановиться в нужном месте кода:
1. В VS Code добавьте ключевое слово `debugger;` в нужную строку:
```typescript
async onload() {
debugger; // Выполнение остановится здесь
console.log("Plugin loaded");
}
```
2. В Obsidian откройте консоль разработчика: `Ctrl + Shift + I`.
3. Перезагрузите плагин (выключите и включите в настройках или нажмите `Ctrl + R` в окне консоли).
4. Obsidian автоматически замерит на этой строке и откроет вкладку **Sources** с вашим оригинальным `.ts` файлом.
## 3. Визуальные точки останова в Obsidian
Если вы не хотите менять код:
1. Откройте консоль (`Ctrl + Shift + I`) в Obsidian.
2. **ВАЖНО**: Кликните по окну консоли и нажмите `Ctrl + R` (или `Cmd + R`) для полной перезагрузки интерфейса. Это очистит кэш Source Maps.
3. Перейдите на вкладку **Sources**.
4. В левой панели навигатора прокрутите в самый низ до появления папки `file://` или виртуальной папки с исходниками вашего проекта.
5. Там будет доступна чистая структура папки `src/` со всеми оригинальными `.ts` файлами. Вы можете открывать их и ставить синие точки останова кликом по номеру строки.
## 4. Быстрый поиск файлов (Ctrl + P)
Самый верный способ найти ваш файл, если дерево не отображается:
1. Кликните в любом месте инструментов разработчика Obsidian.
2. Нажмите `Ctrl + P` (или `Cmd + P`).
3. Введите имя файла, например `main.ts` — он должен появиться в списке с пометкой `[sm]` (source map).
## 5. Если плагин все равно не виден в дереве Sources
Запустите в консоли Obsidian:
```javascript
app.plugins.plugins["llm-agent-plugin"].constructor
```
Кликните по результату (ссылке на функцию), и DevTools сам откроет нужный файл исходного кода.
## Вспомогательное подключение VS Code
Файл `.vscode/launch.json` настроен так, что вы можете нажать **F5**, чтобы видеть логи Obsidian прямо в **Debug Console** VS Code. Это удобно для чтения ошибок, но ставить точки останова через VS Code все равно не получится — используйте методы выше.

310
Readme.md
View File

@ -1,271 +1,69 @@
## Запрос
# LLM Agent Plugin для Obsidian
Хочу перенести всё это в Obsidian, сделав его плагином (для личного пользования). ПРи этом в средней колонке, где граф отображаетс яштатный обсидиана или замтеки - тм будет react-flow граф с историей взаимдействия (деревом диалогов), где можно быстро будет попросить сгенерировать картинки и одновременно сразу нагенерить в разных ветках , например, локации и персонажей нескольких для НРИ (однорвременно запустить друг за другом запросы в разных ветках и пусть обрабатываются) впараллельно, я на графе всё увижу). А сам чат чтобы вёлся справа в колонк, гнде теги , бэклинки, проперти или Copilot помощник из известного плагина. сначала скажи можно ли перенести и чтобы бы ты посоветовал (пока без кода), может , предлоишь что-то улучшить? Да, граф в пространстве, где новые заметки открываются, он просто открывается там, в новом табе, где новые заметикти открываются ли графы обсидианы. Сделай перенос. Пусть бэкенд, как есть, а фронт перенеси полностью
# LLM Agent для Obsidian
Плагин Obsidian для работы с LLM агентом, включающий граф диалогов и интерфейс чата с поддержкой слеш-команд.
Интеграция LLM (Large Language Models) в Obsidian с визуализацией диалогов в виде графа и интерактивным чатом.
## Структура проекта
```
ttrpg-obsidian-vault/
├── .obsidian/
│ └── plugins/
│ └── llm-agent-plugin/
│ ├── main.js
│ ├── manifest.json
│ ├── styles.css
│ └── src/
│ ├── views/
│ │ ├── GraphView.js
│ │ └── ChatView.js
│ ├── components/
│ │ ├── ChatPanel.js
│ │ ├── CommandInput.js
│ │ ├── GraphPanel.js
│ │ └── HistoryPanel.js
│ └── utils/
│ └── EventBus.js
├── llm-agent-backend/ # Backend на Python
│ ├── env/ # Виртуальное окружение Python
│ ├── app/ # Код приложения
│ │ ├── api.py
│ │ ├── workflows.py
│ │ └── ...
│ ├── requirements.txt
│ └── run.py # Точка входа
└── env/
```text
llm-agent-plugin/
├── src/
│ ├── components/ # React-компоненты интерфейса
│ │ ├── models/ # Модели данных для компонентов
│ │ ├── ChatPanel.ts # Панель основного чата
│ │ ├── GraphPanel.ts # Панель визуализации графа (React Flow)
│ │ ├── HistoryPanel.ts # Панель истории сообщений
│ │ └── VoicePanel.ts # Компонент голосового управления
│ ├── views/ # Представления (Views) Obsidian
│ │ ├── ChatView.ts # Правая панель чата
│ │ └── GraphView.ts # Центральная панель графа
│ ├── utils/ # Вспомогательные утилиты и сервисы
│ │ ├── WebSocketService.ts # Связь с Python бэкендом
│ │ ├── EventBus.ts # Шина событий для обмена данными
│ │ ├── fileUtils.ts # Инструменты работы с файлами vault
│ │ └── VaultQueryHandler.ts # Поиск и фильтрация данных в Obsidian
│ ├── references/ # Система упоминаний и связей
│ │ ├── AutocompleteManager.ts # Логика выпадающего списка
│ │ ├── ReferenceParser.ts # Парсинг [[ссылок]] в сообщениях
│ │ └── TemplateEngine.ts # Генерация контента по шаблонам
│ ├── modals/ # Всплывающие окна
│ │ └── GraphSettingsModal.ts # Настройки визуализации
│ ├── constants.ts # Глобальные константы и настройки
│ ├── types/ # TypeScript определения и интерфейсы
│ ├── css/ # Стили компонентов
│ ├── json/ # Конфигурационные JSON файлы
│ └── docs/ # Техническая документация и спецификации
├── main.ts # Точка входа: инициализация плагина и команд
├── mainBasic.ts # Упрощенная версия плагина
├── package.json # Зависимости и скрипты сборки (npm)
├── esbuild.config.mjs # Конфигурация сборщика esbuild
└── manifest.json # Манифест плагина Obsidian
```
## Установка и запуск
## Установка
### 1. Настройка Backend
1. **Предварительные требования**:
- Установленный [Node.js](https://nodejs.org/) (рекомендуется LTS).
- Запущенный [LLM Backend](https://github.com/user/llm-agent-backend) (по умолчанию на порту 5000).
1. Перейдите в папку backend:
2. **Сборка**:
```bash
cd llm-agent-backend
npm install # Установка зависимостей
npm run build # Сборка проекта (создает main.js)
```
2. Активируйте виртуальное окружение:
```bash
# Windows
env\Scripts\activate
# Linux/macOS
source env/bin/activate
```
3. **Активация в Obsidian**:
- Поместите папку `llm-agent-plugin` в `.obsidian/plugins/` вашего хранилища.
- В настройках Obsidian ("Community Plugins") включите "LLM Agent".
3. Установите зависимости:
```bash
pip install -r requirements.txt
```
## Использование
4. Запустите backend сервер:
```bash
python run.py
```
- **Граф диалогов**: Открывается через иконку 🧠 на левой панели. Позволяет видеть ветвление мыслей LLM.
- **Интерактивный чат**: Находится в правой боковой панели. Поддерживает слеш-команды (`/imagine`, `/analyze`).
- **Система ссылок**: Используйте `[[название заметки]]` в чате для передачи контекста ваших файлов в LLM.
- **Голосовой ввод**: Нажмите иконку микрофона для диктовки сообщений.
Backend будет доступен на `http://localhost:5000`
## Технические особенности
### 2. Установка плагина в Obsidian
1. **Автоматическая установка (если плагин уже находится в правильной папке):**
- Откройте Obsidian с этим хранилищем
- Перейдите в Настройки → Плагины сообщества
- Включите "Небезопасные плагины" (если требуется)
- Найдите "LLM Agent" в списке установленных плагинов
- Включите плагин
2. **Ручная установка (если нужно скопировать файлы):**
```bash
# Убедитесь, что файлы плагина находятся в:
.obsidian/plugins/llm-agent-plugin/
├── main.js
├── manifest.json
├── styles.css
└── src/
```
### 3. Использование
1. **Запуск интерфейса:**
- Нажмите иконку 🧠 в левой панели Obsidian
- Или используйте команду "Открыть граф диалогов LLM Agent" (Ctrl+P)
2. **Доступные слеш-команды:**
- `/help` - Список доступных команд
- `/imagine [промпт]` - Генерация изображений
- `/analyze [url] [промпт]` - Анализ изображения
- `/subtitles_meet` - Получение субтитров Google Meet
- `/subtitles_teams` - Получение субтитров MS Teams
- `/summarize` - Суммаризация истории диалога
- `/chat` - Обычный диалог с LLM
3. **Работа с графом:**
- Граф отображается в центральной области Obsidian
- Чат и история - в правой боковой панели
- Кликайте по узлам графа для навигации по веткам диалога
- Используйте "Новый чат" для создания нового диалога
## Конфигурация
### Backend API URL
По умолчанию плагин использует `http://localhost:5000/api`. Для изменения:
1. Откройте файл `.obsidian/plugins/llm-agent-plugin/main.js`
2. Найдите строку:
```javascript
const DEFAULT_SETTINGS = {
apiBaseUrl: 'http://localhost:5000/api'
};
```
3. Измените URL на нужный
### LLM модели
Конфигурация моделей находится в `backend/llm-agent-backend/app/llm_client.py`:
```python
MODELS = {
"gemini-2.5-flash": {
"apiBase": "https://your-api-endpoint.com",
"apiKey": "your-api-key",
# ...
}
}
```
## Устранение неполадок
### Backend не запускается
1. Проверьте, что виртуальное окружение активировано
2. Убедитесь, что все зависимости установлены:
```bash
pip install flask flask-cors langgraph langchain-core requests
```
3. Проверьте порт 5000 - он должен быть свободен
### Плагин не появляется в Obsidian
1. Убедитесь, что файлы находятся в правильной папке:
```
.obsidian/plugins/llm-agent-plugin/main.js
```
2. Проверьте, что включены "Плагины сообщества" в настройках
3. Перезапустите Obsidian
### Ошибки подключения к API
1. Убедитесь, что backend запущен на порту 5000
2. Проверьте консоль разработчика в Obsidian (Ctrl+Shift+I)
3. Проверьте настройки CORS в `backend/llm-agent-backend/app/api.py`
### Граф не отображается
1. Текущая версия использует упрощенную визуализацию
2. Для полноценного React Flow потребуется дополнительная настройка bundler'а
## Разработка
### Структура кода плагина
- `main.js` - Основной класс плагина
- `src/views/` - Views для Obsidian (GraphView, ChatView)
- `src/components/` - React-like компоненты
- `src/utils/` - Утилиты (EventBus)
### Отладка
1. Откройте консоль разработчика в Obsidian (Ctrl+Shift+I)
2. Логи плагина доступны в консоли
3. Backend логи выводятся в терминал где запущен сервер
### Добавление новых команд
1. Добавьте команду в `backend/llm-agent-backend/app/nodes.py` в `CommandManager`
2. Создайте соответствующий узел обработки
3. Обновите маршрутизацию в `workflows.py`
## API Documentation
### Основные эндпоинты
- `POST /api/chat` - Отправка сообщения
- `GET /api/graphs` - Список всех графов
- `GET /api/graphs/{id}` - Данные конкретного графа
- `DELETE /api/graphs/{id}` - Удаление графа
- `GET /api/messages/{graph_id}/{node_id}` - Сообщения от корня до узла
## Лицензия
Для личного использования.
```
**`backend/llm-agent-backend/requirements.txt`:**
```txt
flask==2.3.3
flask-cors==4.0.0
langgraph==0.0.40
langchain-core==0.1.45
requests==2.31.0
sqlite3
openai==1.3.0
```
**`backend/llm-agent-backend/run.py`:**
```python
"""
Точка входа для запуска LLM Agent backend сервера.
Запускает Flask приложение с настройками для разработки.
"""
import os
import sys
# Добавляем папку app в Python path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app'))
from api import app2
if __name__ == "__main__":
print("🚀 Запуск LLM Agent Backend сервера...")
print("📍 API будет доступно на: http://localhost:5000/api")
print("📊 Список графов: http://localhost:5000/api/graphs")
print("💬 Чат эндпоинт: http://localhost:5000/api/chat")
print("\n⚠ Для остановки нажмите Ctrl+C\n")
app2.run(
debug=True,
port=5000,
host='localhost'
)
```
**`backend/llm-agent-backend/app/__init__.py`:**
```python
# Пустой файл для создания Python пакета
```
## Быстрый старт:
1. **Запуск backend:**
```bash
cd backend/llm-agent-backend
env\Scripts\activate # Windows
python run.py
```
2. **Запуск Obsidian:**
- Откройте хранилище в Obsidian
- Включите плагин "LLM Agent" в настройках
- Нажмите иконку 🧠 в левой панели
3. **Проверка работы:**
- В правой панели должна появиться история диалогов
- В центре - область для графа
- Отправьте сообщение `/help` для проверки
**Важно:** Backend должен быть запущен перед использованием плагина. При первом запуске может потребоваться установка дополнительных зависимостей.
- **Связь**: Использует WebSocket для real-time обновления графа и стриминга ответов.
- **UI**: Построен на базе React и React Flow для отрисовки сложных структур графов.
- **Интеграция**: Глубокая интеграция с API Obsidian для доступа к метаданным файлов и поиску.

3
debug-obsidian.bat Normal file
View File

@ -0,0 +1,3 @@
@echo off
start "" "%LocalAppData%\Obsidian\Obsidian.exe" --remote-debugging-port=9222
exit

View File

@ -90,12 +90,14 @@ const context = await esbuild.context({
renameCssPlugin(),
copyManifestPlugin(),
...(prod || obfuscate ? [ObfuscatorPlugin({
compact: true,
controlFlowFlattening: true,
controlFlowFlatteningThreshold: 0.75,
debugProtection: true,
debugProtectionInterval: true,
disableConsoleOutput: true,
compact: true, // Уплотняет код в одну строку
identifierNamesGenerator: 'mangled', // Заменяет имена переменных на короткие бессмысленные комбинации
renameGlobals: true, // Включает обфускацию глобальных переменных и функций в том числе классов
controlFlowFlattening: true, // Усложняет структуру выполнения (перемешивает логику)
controlFlowFlatteningThreshold: 0.75, // Вероятность применения изменения потока управления для узлов
debugProtection: true, // Вставляет код, блокирующий инструменты разработчика (DevTools)
debugProtectionInterval: true, // Регулярно перезапускает проверку защиты от отладки
disableConsoleOutput: true, // Запрещает вывод логов в консоль (console.log и др.)
// Можно добавить filter, чтобы обфусцировать только нужные файлы, например ['**/*.js']
})] : [])
]

908
main.ts

File diff suppressed because it is too large Load Diff

View File

@ -20,7 +20,7 @@ import { LLM_DEFAULTS } from 'src/constants';
interface ChatPanelProps {
chatHistory: ChatHistoryItem[];
onSendMessage: (message: string, selectedModel?: string, attachments?: Attachment[], customMaxTokens?: number, customTemperature?: number) => void;
onSendMessage: (message: string, selectedModel?: string, attachments?: Attachment[], customMaxTokens?: number, customTemperature?: number, agencyMode?: boolean) => void;
selectedModel?: string;
plugin: LLMAgentPlugin;
currentGraphCustomSystemPrompt: string | null;
@ -38,6 +38,8 @@ export class ChatPanel {
selectedModel: string;
customPromptIndicator: HTMLSpanElement | null = null;
currentStreamingNodeId: string | null;
branchChatBtn: HTMLButtonElement;
stopButton: HTMLButtonElement;
scrollToBottomBtn: HTMLButtonElement;
scrollToTopBtn: HTMLButtonElement;
@ -64,6 +66,8 @@ export class ChatPanel {
dictationLastLen: number = 0;
hasGlobalStreams: boolean = false;
agencyToggle: HTMLInputElement;
// ----------------------------------------------- Reference System Fields ---------------------------------------------------------------
cacheManager: CacheManager;
@ -211,15 +215,13 @@ export class ChatPanel {
this.modelDropdown.style.display = 'none';
}
handleInput(): void {
const value = this.messageInput.textContent || '';
const commands = ['/help', '/imagine', '/analyze', '/subtitles_meet', '/subtitles_teams', '/summarize', '/chat'];
if (value.startsWith('/')) {
const filteredCommands = commands.filter((cmd) => cmd.startsWith(value.toLowerCase()));
this.showSuggestions(filteredCommands);
} else {
this.hideSuggestions();
async setAgencyToggle(): Promise<void> {
if (this.props.plugin.settings) {
this.props.plugin.settings.agencyModeEnabled = this.agencyToggle.checked;
if (typeof this.props.plugin.saveSettings === 'function') {
await this.props.plugin.saveSettings(); // TODO: Подумать, насколько это должно быть здесь
await this.props.plugin.syncSettings();
}
}
}
@ -285,6 +287,9 @@ export class ChatPanel {
const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' });
await MarkdownRenderer.renderMarkdown(msg.content, contentDiv, '', this.props.plugin);
// Оживляем плашки в старых сообщениях
this.enhanceToolCalls(contentDiv);
this.createMessageToolset(messageEl, msg.content, msg.node_id, msg.graph_id || '');
}
@ -457,6 +462,7 @@ export class ChatPanel {
async updateHistory(newHistory: ChatHistoryItem[]): Promise<void> {
this.chatHistory = newHistory;
await this.renderHistory();
this.updateScrollButtonsVisibility(); // НОВОЕ: проверяем видимость кнопок
}
/**
@ -467,6 +473,85 @@ export class ChatPanel {
this.processStreamUpdates();
}
/**
* Превращает скрытые маркеры .agent-tool-call в интерактивный UI.
*/
private enhanceToolCalls(container: HTMLElement): void {
const toolDivs = container.querySelectorAll('.agent-tool-call');
toolDivs.forEach((div) => {
const name = div.getAttribute('data-name') || 'Tool';
const step = div.getAttribute('data-step') || '*';
const status = div.getAttribute('data-status'); // 'start', 'success', 'error'
// Меняем класс, чтобы не обрабатывать див повторно при следующих тиках стриминга
div.className = 'tool-call-ui-block';
// Иконка загрузки, успеха или ошибки
let statusIcon = '<span class="tool-status-icon ring-loader" style="font-size:12px;">⏳</span>';
if (status === 'success') statusIcon = '<span class="tool-status-icon success" style="color:var(--text-success); font-size:14px;">✅</span>';
if (status === 'error') statusIcon = '<span class="tool-status-icon error" style="color:var(--text-error); font-size:14px;">❌</span>';
// Рендерим HTML компонент. Стили зашиты инлайн для стабильности
div.innerHTML = `
<div class="tool-header" style="display:flex; justify-content:space-between; align-items:center; padding: 6px 12px; background: var(--background-secondary); border: 1px solid var(--background-modifier-border); border-radius: 6px; cursor: ${status === 'start' ? 'default' : 'pointer'}; margin-top: 8px; margin-bottom: 8px;">
<span class="tool-title" style="font-size: 13px; font-weight: 500; font-family: var(--font-interface);">🛠 Шаг ${step}: <b>${name}</b></span>
${statusIcon}
</div>
<div class="tool-body" style="display: none; border: 1px solid var(--background-modifier-border); border-top: none; border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; margin-top: -8px; margin-bottom: 8px; background: var(--background-primary);">
<div class="tool-tabs" style="display:flex; border-bottom: 1px solid var(--background-modifier-border);">
<button class="tool-tab-btn active" data-tab="request" style="flex:1; border:none; border-right: 1px solid var(--background-modifier-border); border-radius: 0; background: var(--background-modifier-hover); padding: 4px; font-size: 12px; box-shadow: none;">Request</button>
<button class="tool-tab-btn" data-tab="response" style="flex:1; border:none; border-radius: 0; background: transparent; padding: 4px; font-size: 12px; box-shadow: none;">Response</button>
</div>
<pre class="tool-code-block" style="margin: 0; padding: 8px; font-size: 11px; max-height: 250px; overflow-y: auto; white-space: pre-wrap; background: var(--background-primary); border: none;"></pre>
</div>
`;
if (status !== 'start') {
const reqBase64 = div.getAttribute('data-req') || '';
const resBase64 = div.getAttribute('data-res') || '';
let reqText = '';
let resText = '';
try { reqText = reqBase64 ? decodeURIComponent(escape(atob(reqBase64))) : '{ }'; } catch(e) { reqText = 'Ошибка декодирования'; }
try { resText = resBase64 ? decodeURIComponent(escape(atob(resBase64))) : 'Нет данных'; } catch(e) { resText = 'Ошибка декодирования'; }
const header = div.querySelector('.tool-header') as HTMLElement;
const body = div.querySelector('.tool-body') as HTMLElement;
const codeBlock = div.querySelector('.tool-code-block') as HTMLElement;
const tabBtns = div.querySelectorAll('.tool-tab-btn');
// Дефолтно показываем Request
codeBlock.textContent = reqText;
// Обработка сворачивания/разворачивания аккордеона
header.addEventListener('click', () => {
const isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
header.style.borderBottomLeftRadius = isHidden ? '0' : '6px';
header.style.borderBottomRightRadius = isHidden ? '0' : '6px';
});
// Обработка переключения вкладок внутри
tabBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
tabBtns.forEach(b => {
(b as HTMLElement).style.background = 'transparent';
b.classList.remove('active');
});
(btn as HTMLElement).style.background = 'var(--background-modifier-hover)';
btn.classList.add('active');
const targetTab = btn.getAttribute('data-tab');
codeBlock.textContent = targetTab === 'request' ? reqText : resText;
});
});
}
});
}
/**
* Обрабатывает очередь так, чтобы рендер происходил не чаще чем раз в 300мс
* и дожидался окончания предыдущей отрисовки.
@ -513,6 +598,9 @@ export class ChatPanel {
// Вызываем тяжелый маркдаун Обсидиана и ЖДЕМ
await MarkdownRenderer.renderMarkdown(content, tempDiv, '', this.props.plugin);
// Оживляем плашки ПЕРЕД переброской их в финальный DOM
this.enhanceToolCalls(tempDiv);
// Моментально перебрасываем готовый DOM
contentDiv.innerHTML = '';
while (tempDiv.firstChild) {
@ -575,6 +663,10 @@ export class ChatPanel {
<div class="llm-agent-chat-panel">
<div class="sticky-btn-wrapper">
<button class="branch-chat-btn" id="branch-chat-btn" title="Новая независимая ветвь" aria-label="Сбросить историю и начать ветку от корня графа">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-git-branch"><line x1="6" x2="6" y1="3" y2="15"></line><circle cx="18" cy="6" r="3"></circle><circle cx="6" cy="18" r="3"></circle><path d="M18 9a9 9 0 0 1-9 9"></path></svg>
</button>
<button class="scroll-to-top-btn" id="scroll-to-top-btn" title="Прокрутить вверх" aria-label="Прокрутить к первому сообщению">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="19" x2="12" y2="5"></line><polyline points="5 12 12 5 19 12"></polyline></svg>
</button>
@ -614,6 +706,15 @@ export class ChatPanel {
</span>
</div>
<div class="agency-toggle-container" title="Включить Агентность (Вызов скриптов)">
<label class="switch">
<input type="checkbox" id="agency-toggle" ${this.props.plugin.settings.agencyModeEnabled ? 'checked' : ''}>
<span class="slider"></span>
</label>
<span>🤖 Агент</span>
</div>
<div class="param-select-container" title="Температура">
<span class="param-icon">Temp: </span>
<input type="number" id="temp-input" class="param-input" placeholder="${LLM_DEFAULTS.TEMPERATURE}" min="0" max="2" step="0.1">
@ -650,13 +751,16 @@ export class ChatPanel {
this.modelSelectButton = this.container.querySelector('#model-select-button') as HTMLButtonElement;
this.modelDropdown = this.container.querySelector('#model-dropdown') as HTMLDivElement;
this.customPromptIndicator = this.container.querySelector('#custom-prompt-indicator') as HTMLSpanElement;
this.stopButton = this.container.querySelector('#stop-button') as HTMLButtonElement;
this.branchChatBtn = this.container.querySelector('#branch-chat-btn') as HTMLButtonElement;
this.stopButton = this.container.querySelector('#stop-button') as HTMLButtonElement;
this.scrollToTopBtn = this.container.querySelector('#scroll-to-top-btn') as HTMLButtonElement;
this.scrollToBottomBtn = this.container.querySelector('#scroll-to-bottom-btn') as HTMLButtonElement;
this.attachedFilesContainer = this.container.querySelector('#attached-files-container') as HTMLDivElement;
this.agencyToggle = this.container.querySelector('#agency-toggle') as HTMLInputElement;
this.tempInput = this.container.querySelector('#temp-input') as HTMLInputElement;
this.tempResetBtn = this.container.querySelector('#temp-reset') as HTMLButtonElement;
this.maxTokensInput = this.container.querySelector('#max-tokens-input') as HTMLInputElement;
@ -723,7 +827,6 @@ export class ChatPanel {
});
this.messageInput.addEventListener('input', () => {
this.handleInput();
this.handleReferenceInput();
this.updatePlaceholder();
});
@ -741,6 +844,10 @@ export class ChatPanel {
this.toggleModelDropdown();
});
this.stopButton.addEventListener('click', () => this.handleStopGeneration());
this.maxTokensResetBtn.addEventListener('click', () => {
@ -760,12 +867,20 @@ export class ChatPanel {
this.props.plugin.eventBus.emit('stop-all-streams');
});
this.branchChatBtn.addEventListener('click', () => {
this.props.plugin.eventBus.emit('start-new-independent-chat');
});
// Подписка на изменение стримов
this.props.plugin.eventBus.on('global-stream-state-changed', (isStreaming: boolean) => {
this.hasGlobalStreams = isStreaming;
this.updateScrollButtonsVisibility();
});
this.agencyToggle.addEventListener('change', async () => {
this.setAgencyToggle();
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) {
@ -1136,7 +1251,9 @@ export class ChatPanel {
const valTemp = this.tempInput?.value;
const parsedTemp = valTemp ? parseFloat(valTemp) : undefined;
this.props.onSendMessage(originalText || '', this.selectedModel, allAttachments, parsedMaxTokens, parsedTemp);
const isAgencyActive = this.agencyToggle.checked;
this.props.onSendMessage(originalText || '', this.selectedModel, allAttachments, parsedMaxTokens, parsedTemp, isAgencyActive);
}
this.clearInput();
@ -1174,7 +1291,9 @@ export class ChatPanel {
const valTemp = this.tempInput?.value;
const parsedTemp = valTemp ? parseFloat(valTemp) : undefined;
this.props.onSendMessage(message, this.selectedModel, this.currentAttachments, parsedMaxTokens, parsedTemp);
const isAgencyActive = this.agencyToggle.checked;
this.props.onSendMessage(message, this.selectedModel, this.currentAttachments, parsedMaxTokens, parsedTemp, isAgencyActive);
this.clearInput();
}
}
@ -1260,13 +1379,16 @@ export class ChatPanel {
const valTemp = this.tempInput?.value;
const parsedTemp = valTemp ? parseFloat(valTemp) : undefined;
const isAgencyActive = this.agencyToggle.checked;
this.props.plugin.eventBus.emit('regenerate-message', {
graphId,
nodeId,
model: this.selectedModel,
maxTokens: parsedMaxTokens,
temperature: parsedTemp
temperature: parsedTemp,
agencyMode: isAgencyActive
});
});
@ -1445,56 +1567,142 @@ export class ChatPanel {
}
/**
* Обрабатывает событие сброса внешних файлов (Drag-and-Drop) из ОС
* Обрабатывает событие сброса внешних файлов (Drag-and-Drop) из ОС
* и внутренних файлов из файлового менеджера Obsidian
*/
private async handleDrop(e: DragEvent): Promise<void> {
if (!e.dataTransfer?.files || e.dataTransfer.files.length === 0) {
return; // Оброшен не файл (например, просто выделенный текст)
if (!e.dataTransfer) {
return; // Игнорируем пустые события
}
const isCtrlPressed = e.ctrlKey || e.metaKey;
const shouldBePermanent = !isCtrlPressed;
const files = Array.from(e.dataTransfer.files);
let addedCount = 0;
for (const file of files) {
let absolutePath = '';
// 1. Проверяем перетаскивание внешних файлов из файловой системы ОС
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const files = Array.from(e.dataTransfer.files);
for (const file of files) {
let absolutePath = '';
// 1. Попытка через скрытое свойство 'path' (иногда оно есть, но не перечисляемо)
absolutePath = (file as any).path;
// 2. Попытка через Electron (если доступен window.require)
if (!absolutePath && window.require) {
try {
const electron = window.require('electron');
// 1. Попытка через скрытое свойство 'path' (иногда оно есть, но не перечисляемо)
absolutePath = (file as any).path;
// 2. Попытка через Electron (если доступен window.require)
if (!absolutePath && window.require) {
try {
const electron = window.require('electron');
// В некоторых версиях путь можно достать через webUtils из модуля
if (electron.webUtils) {
absolutePath = electron.webUtils.getPathForFile(file);
}
} catch (err) {}
}
if (electron.webUtils) {
absolutePath = electron.webUtils.getPathForFile(file);
}
} catch (err) {}
}
try {
try {
// Кэшируем файл (копируем в папку плагина) и получаем метаинформацию
const cacheResult = await this.cacheManager.cacheExternalFile(absolutePath);
const cacheResult = await this.cacheManager.cacheExternalFile(absolutePath);
// Формируем Attachment объект
const newAttachment: Attachment = {
type: 'external',
name: cacheResult.name,
sourcePath: absolutePath,
cachedPath: cacheResult.cachedPath,
uuid: cacheResult.uuid,
permanent: shouldBePermanent, // D&D файлы прикрепляем разово (как $файл, а не !$)
mimeType: cacheResult.mimeType
};
const newAttachment: Attachment = {
type: 'external',
name: cacheResult.name,
sourcePath: absolutePath,
cachedPath: cacheResult.cachedPath,
uuid: cacheResult.uuid,
permanent: shouldBePermanent,
mimeType: cacheResult.mimeType
};
this.currentAttachments.push(newAttachment);
addedCount++;
} catch (error: any) {
console.error(`Ошибка обработки D&D файла ${file.name}:`, error);
new Notice(`Не удалось прикрепить ${file.name}. ${error.message || error}`);
this.currentAttachments.push(newAttachment);
addedCount++;
} catch (error: any) {
console.error(`Ошибка обработки D&D файла ${file.name}:`, error);
new Notice(`Не удалось прикрепить ${file.name}. ${error.message || error}`);
}
}
}
// TODO: эту ветку не разбирал, некогда было
// 2. Проверяем перетаскивание внутренних файлов Obsidian
else {
const textData = e.dataTransfer.getData('text/plain');
if (textData) {
const pathsToProcess: string[] = [];
// А) Извлекаем пути, если перетянута системная ссылка Obsidian (из Проводника файлов)
const lines = textData.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('obsidian://open')) {
try {
const url = new URL(trimmed);
const filePath = url.searchParams.get('file'); // Попутно автоматически декодирует %D0...
if (filePath) {
pathsToProcess.push(filePath);
}
} catch (error) {
console.warn("Не удалось распарсить ссылку Obsidian:", trimmed);
}
}
}
// Б) Оставляем поддержку, если перетащили текст, содержащий [[вики-ссылки]]
const linkRegex = /\[\[(.*?)\]\]/g;
let match;
while ((match = linkRegex.exec(textData)) !== null) {
let linkText = match[1];
if (linkText.includes('|')) linkText = linkText.split('|')[0];
pathsToProcess.push(linkText);
}
// Убираем дубликаты
const uniquePaths = [...new Set(pathsToProcess)];
for (const targetPath of uniquePaths) {
// Ищем файл. Если это был obsidian:// URL, у нас сразу есть точный путь
let file = this.props.plugin.app.vault.getAbstractFileByPath(targetPath);
// Если точный путь не найден (например, это просто имя из вики-ссылки), ищем по кэшу
if (!file) {
file = this.props.plugin.app.metadataCache.getFirstLinkpathDest(targetPath, '');
}
if (file instanceof TFile) {
try {
// Отправляем найденный в Vault файл на кеширование
const cacheResult = await this.cacheManager.cacheVaultFile(file);
const promptsFolder = this.props.plugin.settings.promptsFolder;
const normalizedPromptsPath = promptsFolder ? promptsFolder.replace(/^\/+|\/+$/g, '') : null;
const isPrompt = normalizedPromptsPath && (file.path.startsWith(normalizedPromptsPath + '/') || file.path === normalizedPromptsPath);
const ext = file.extension.toLowerCase();
let mimeType = 'application/octet-stream';
if (['md', 'txt', 'csv'].includes(ext)) mimeType = ext === 'md' ? 'text/markdown' : 'text/plain';
else if (['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'].includes(ext)) mimeType = `image/${ext === 'svg' ? 'svg+xml' : ext === 'jpg' ? 'jpeg' : ext}`;
else if (ext === 'pdf') mimeType = 'application/pdf';
else if (ext === 'json') mimeType = 'application/json';
// Формируем Attachment объект
const newAttachment: Attachment = {
type: isPrompt ? 'prompt' : 'file',
name: file.name,
sourcePath: file.path,
cachedPath: cacheResult.cachedPath,
uuid: cacheResult.uuid,
permanent: shouldBePermanent,
mimeType: mimeType
};
this.currentAttachments.push(newAttachment);
addedCount++;
} catch (error: any) {
console.error(`Ошибка обработки внутреннего D&D файла ${file.name}:`, error);
new Notice(`Не удалось прикрепить ${file.name}.`);
}
}
}
}
}
@ -1504,7 +1712,7 @@ export class ChatPanel {
new Notice(`Прикреплено файлов: ${addedCount}`, 2000);
}
}
// ----------------------------------------------- Scroll Observers ---------------------------------------------------------------
/**
@ -1556,14 +1764,16 @@ export class ChatPanel {
if (!this.cachedScrollContainer || !this.cachedBtnWrapper) return;
const hasScrollbar = this.cachedScrollContainer.scrollHeight > (this.cachedScrollContainer.clientHeight + 2);
const hasHistory = this.chatHistory && this.chatHistory.length > 0;
// Управляем видимостью кнопок скролла (скрываем их индивидуально)
this.scrollToTopBtn.style.display = hasScrollbar ? 'flex' : 'none';
this.scrollToBottomBtn.style.display = hasScrollbar ? 'flex' : 'none';
this.globalStopBtn.style.display = this.hasGlobalStreams ? 'flex' : 'none';
this.branchChatBtn.style.display = hasHistory ? 'flex' : 'none';
// Обертка видна, если есть либо скроллбар, либо активный стрим
if (hasScrollbar || this.hasGlobalStreams) {
if (hasScrollbar || this.hasGlobalStreams || hasHistory) {
this.cachedBtnWrapper.classList.add('is-visible');
} else {
this.cachedBtnWrapper.classList.remove('is-visible');

View File

@ -105,13 +105,15 @@ const BaseNodeWrapper: React.FC<BaseNodeWrapperProps> = ({ data, isConnectable,
// или перелопачивать код и хранить в узлах и бд настройки, использованные при генерации, как будто, ещё хуже
let maxTokens = (data.app as any).plugins?.plugins?.['llm-agent-plugin']?.settings?.maxTokensChatDefault || '';
let temperature = (data.app as any).plugins?.plugins?.['llm-agent-plugin']?.settings?.temperatureChat || LLM_DEFAULTS.TEMPERATURE;
let agencyMode = (data.app as any).plugins?.plugins?.['llm-agent-plugin']?.settings?.agencyModeEnabled || false;
eventBus.emit('regenerate-message', {
graphId: data.graphId,
nodeId: data.nodeId,
model: model, // НОВОЕ: Добавляем модель
maxTokens: maxTokens,
temperature: temperature
temperature: temperature,
agencyMode: agencyMode
});
}
};

View File

@ -20,4 +20,14 @@ export interface Attachment {
uuid: string; // UUID для идентификации кеша
permanent: boolean; // true для !@, !#, !$
mimeType?: string;
}
export interface CustomTool {
id: string;
name: string;
description: string;
scriptPath: string;
methodName?: string;
hiddenPrompt: string;
hiddenPromptExpanded?: string;
}

View File

@ -308,23 +308,18 @@
}
.chat-controls {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-muted);
display: flex;
flex-wrap: wrap; /* Элементы переносятся, если панель узкая */
gap: 6px;
justify-content: space-between;
align-items: center;
padding-top: 4px;
}
.control-buttons-left {
display: flex;
align-items: center;
gap: 8px;
}
.control-buttons-right {
display: flex;
align-items: center;
gap: 8px;
.control-buttons-left, .control-buttons-right {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
}
.model-select-container {
@ -533,7 +528,8 @@
/* Добавить общий стиль для обеих кнопок и специфичный для новой */
.scroll-to-bottom-btn,
.scroll-to-top-btn {
.scroll-to-top-btn,
.branch-chat-btn {
pointer-events: auto;
border: 2px solid var(--background-primary);
border-radius: 50%;
@ -562,7 +558,8 @@
}
.scroll-to-bottom-btn:hover,
.scroll-to-top-btn:hover {
.scroll-to-top-btn:hover,
.branch-chat-btn:hover {
opacity: 1;
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
@ -570,13 +567,15 @@
}
.scroll-to-bottom-btn:active,
.scroll-to-top-btn:active {
.scroll-to-top-btn:active,
.branch-chat-btn:active {
transform: scale(0.95);
}
/* Иконки кнопок */
.scroll-to-bottom-btn svg,
.scroll-to-top-btn svg {
.scroll-to-top-btn svg,
.branch-chat-btn svg {
width: 18px;
height: 18px;
stroke-width: 3px;
@ -1660,3 +1659,98 @@
100% { transform: scale(1); opacity: 1; }
}
/* ----------------------------------------------- Agency toggle --------------------------------------------------------------- */
.agency-toggle-container {
display: flex;
align-items: center;
gap: 6px;
margin-right: 4px;
font-size: 11px;
color: var(--text-muted);
}
.switch {
position: relative;
display: inline-block;
width: 28px;
height: 16px;
}
.switch input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
cursor: pointer;
top: 0; left: 0; right: 0; bottom: 0;
background-color: var(--background-modifier-border);
transition: .2s;
border-radius: 16px;
}
.slider:before {
position: absolute;
content: "";
height: 12px; width: 12px;
left: 2px; bottom: 2px;
background-color: white;
transition: .2s;
border-radius: 50%;
}
input:checked + .slider { background-color: var(--interactive-accent); }
input:checked + .slider:before { transform: translateX(12px); }
/* Визуальный блок работы инструмента (Scratchpad-нода) */
.tool-execution-block {
margin: 8px 0;
padding: 8px;
border-radius: 6px;
background-color: var(--background-secondary-alt);
border-left: 3px solid var(--interactive-accent);
font-family: var(--font-monospace);
font-size: 0.85em;
color: var(--text-muted);
animation: pulse-bg 1.5s infinite;
}
.tool-execution-block.finished {
animation: none;
border-left: 3px solid var(--text-success);
opacity: 0.8;
}
@keyframes pulse-bg {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}
/* --- Индикаторы работы Агента (ReAct Steps) --- */
.agent-steps-info {
margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
.step-badge {
display: inline-flex;
padding: 6px 10px;
background-color: var(--background-secondary-alt);
border-left: 3px solid var(--interactive-accent);
border-radius: 4px;
font-family: var(--font-monospace);
font-size: 0.85em;
color: var(--text-muted);
animation: pulse-bg 1.5s infinite;
}
.step-badge.success {
animation: none;
border-left-color: var(--text-success);
opacity: 0.8;
}
@keyframes pulse-bg {
0% { opacity: 0.6; }
50% { opacity: 1; }
100% { opacity: 0.6; }
}

View File

@ -43,20 +43,22 @@ export class AutocompleteManager {
/**
* Получает список файлов для автокомплита
* @param query - поисковый запрос
* @param filterExtension - опциональный фильтр по расширению (например, 'py')
* @returns массив файлов для автокомплита
*/
async getFileOptions(query: string): Promise<AutocompleteItem[]> {
async getFileOptions(query: string, filterExtension?: string): Promise<AutocompleteItem[]> {
if (!this.plugin.settings.enableFileReferences) return [];
const lowercaseQuery = query.toLowerCase();
const virtualItems: AutocompleteItem[] = [];
// Пункт 1: Если в запросе "cur", добавляем логи в начало
if ("current-log".includes(lowercaseQuery)) {
// (Только если мы не ищем скрипты конкретного расширения)
if (!filterExtension && "current-log".includes(lowercaseQuery)) {
virtualItems.push({
name: "current-log",
path: "virtual:current-log",
type: "file", // Для применения иконки @ по умолчанию
type: "file",
icon: "file-text"
});
virtualItems.push({
@ -70,10 +72,18 @@ export class AutocompleteManager {
await this.updateCacheIfNeeded();
const allFiles = this.fileCache.get('all') || [];
// Объединяем: виртуальные вначале, потом поиск по файлам
const filteredFiles = !query.trim()
? allFiles
: allFiles.filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery));
let filteredFiles = allFiles;
// Применяем строгий фильтр расширения (если передан, например для '.py' скриптов)
if (filterExtension) {
const ext = `.${filterExtension.toLowerCase()}`;
filteredFiles = filteredFiles.filter(item => item.path.toLowerCase().endsWith(ext));
}
// Фильтруем по введенному запросу пользователя
filteredFiles = !query.trim()
? filteredFiles
: filteredFiles.filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery));
return [...virtualItems, ...filteredFiles].slice(0, AutocompleteManager.MAX_TOTAL_ITEMS);
}
@ -173,26 +183,28 @@ export class AutocompleteManager {
* Обновляет кеш файлов и промптов
*/
private async updateCache(): Promise<void> {
const files = this.plugin.app.vault.getMarkdownFiles();
// Получаем ВСЕ файлы хранилища, чтобы поддерживать скрипты (.py), картинки и др.
const files = this.plugin.app.vault.getFiles();
const fileItems: AutocompleteItem[] = [];
const promptItems: AutocompleteItem[] = [];
// Нормализуем пути исключенных папок
const normalizedExcludedFolders = this.plugin.settings.excludedFolders
.filter((folder) => folder.trim() !== '')
.map((folder) => (folder.endsWith('/') ? folder : folder + '/')) // Убедимся, что заканчивается на слэш
.map((folder) => (folder.startsWith('/') ? folder.substring(1) : folder)); // Удаляем начальный слэш
.map((folder) => (folder.endsWith('/') ? folder : folder + '/'))
.map((folder) => (folder.startsWith('/') ? folder.substring(1) : folder));
for (const file of files) {
// Пропускаем файлы из исключенных папок
if (this.isFileInExcludedFolder(file, normalizedExcludedFolders)) {
continue;
}
const isPrompt = this.isPromptFile(file);
const item: AutocompleteItem = {
name: file.basename,
// Для промптов оставляем basename (без расширения),
// а для обычных файлов используем name (с расширением) для точной идентификации
name: isPrompt ? file.basename : file.name,
path: file.path,
type: this.isPromptFile(file) ? 'prompt' : 'file',
type: isPrompt ? 'prompt' : 'file',
icon: this.getFileIcon(file),
file: file
};
@ -204,7 +216,6 @@ export class AutocompleteManager {
}
}
// Сортируем по имени
fileItems.sort((a, b) => a.name.localeCompare(b.name));
promptItems.sort((a, b) => a.name.localeCompare(b.name));
@ -238,6 +249,7 @@ export class AutocompleteManager {
switch (extension) {
case 'md':
return 'file-text';
case 'py': return 'file-terminal';
case 'js':
case 'ts':
return 'code';

View File

@ -44,8 +44,9 @@ export class ReferenceAutocomplete {
* Показывает автокомплит с опциями.
* @param reference - объект ReferenceMatch, определяющий текущий вводимый референс.
* @param cursorRect - DOMRect объекта курсора, для определения позиции.
* @param filterExtension - Опциональный фильтр расширений
*/
async showAutocomplete(reference: ReferenceMatch, cursorRect: DOMRect): Promise<void> {
async showAutocomplete(reference: ReferenceMatch, cursorRect: DOMRect, filterExtension?: string): Promise<void> {
this.currentReference = reference;
// Для внешних файлов ($, !$) сразу открываем диалог
@ -57,10 +58,10 @@ export class ReferenceAutocomplete {
return;
}
// Для файлов и промптов работаем как раньше
let options: AutocompleteItem[] = [];
if (reference.type === 'file') {
options = await this.autocompleteManager.getFileOptions(reference.name);
// ИЗМЕНЕНО: Передаем фильтр в менеджер
options = await this.autocompleteManager.getFileOptions(reference.name, filterExtension);
} else if (reference.type === 'prompt') {
options = await this.autocompleteManager.getPromptOptions(reference.name);
} else if (reference.type === 'url') {

View File

@ -0,0 +1,466 @@
// src/utils/VaultQueryHandler.ts
/**
* VaultQueryHandler обработчик запросов к Obsidian vault от бэкенда.
*
* Бэкенд (Flask) отправляет событие 'vault_query_request' через WebSocket.
* Этот класс выполняет операцию внутри Obsidian и возвращает результат
* обратно через 'vault_query_response'.
*
* Поддерживаемые типы запросов:
* get_frontmatter YAML + тело одного файла
* get_all_factions все файлы в папке (с фильтром по тегу)
* get_recent_files последние N файлов (с фильтром по тегам)
* resolve_wikilink wikilink путь к файлу (с поддержкой aliases)
* write_frontmatter перезаписать YAML + тело файла
* create_file создать или перезаписать файл
*/
import { App, TFile, TFolder } from 'obsidian';
// ---------------------------------------------------------------------------
// Типы запросов и ответов
// ---------------------------------------------------------------------------
interface VaultQueryRequest {
request_id: string;
type: string;
payload: Record<string, any>;
}
interface GetFrontmatterResult {
path: string;
name: string;
frontmatter: Record<string, any>;
body: string;
}
interface GetAllFactionsResult {
path: string;
name: string;
frontmatter: Record<string, any>;
}
interface WriteResult {
success: boolean;
path: string;
}
// ---------------------------------------------------------------------------
// Класс
// ---------------------------------------------------------------------------
export class VaultQueryHandler {
constructor(private app: App) {}
/**
* Главная точка входа. Диспетчеризует запрос по типу.
* Вызывается из main.ts при получении 'vault_query_request'.
*/
async handle(req: VaultQueryRequest): Promise<any> {
const { type, payload } = req;
switch (type) {
case 'get_frontmatter':
return this._getFrontmatter(payload.path as string);
case 'get_all_factions':
return this._getAllFilesInFolder(
payload.folder as string,
payload.tag_filter as string | undefined
);
case 'get_recent_files':
return this._getRecentFiles(
payload.folder as string,
(payload.limit as number) ?? 10,
(payload.tags as string[]) ?? []
);
case 'resolve_wikilink':
return this._resolveWikilink(payload.name as string);
case 'write_frontmatter':
return this._writeFrontmatter(
payload.path as string,
payload.frontmatter as Record<string, any>,
payload.body as string
);
case 'create_file':
return this._createFile(
payload.path as string,
payload.content as string
);
default:
throw new Error(`VaultQueryHandler: неизвестный тип запроса '${type}'`);
}
}
// -------------------------------------------------------------------------
// get_frontmatter
// -------------------------------------------------------------------------
/**
* Возвращает YAML frontmatter и тело (body) одного .md файла.
* Использует кэш метаданных Obsidian не читает файл с диска лишний раз.
*/
private async _getFrontmatter(path: string): Promise<GetFrontmatterResult> {
const file = this.app.vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
throw new Error(`Файл не найден: ${path}`);
}
// Кэш метаданных — быстро, без I/O
const cache = this.app.metadataCache.getFileCache(file);
const frontmatter: Record<string, any> = { ...(cache?.frontmatter ?? {}) };
// Удаляем служебное поле Obsidian
delete frontmatter['position'];
// Тело файла нужно для записи обратно (сохраняем лор-текст нетронутым)
const raw = await this.app.vault.read(file);
const body = this._extractBody(raw);
return {
path: file.path,
name: file.basename,
frontmatter,
body
};
}
// -------------------------------------------------------------------------
// get_all_factions
// -------------------------------------------------------------------------
/**
* Возвращает все .md файлы из папки (рекурсивно).
* Опциональный tag_filter: "#faction" оставит только файлы с этим тегом.
* Использует только кэш метаданных быстро на 2000+ файлах.
*/
private _getAllFilesInFolder(
folderPath: string,
tagFilter?: string
): GetAllFactionsResult[] {
const folder = this.app.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof TFolder)) {
console.warn(`VaultQueryHandler: папка не найдена: ${folderPath}`);
return [];
}
const results: GetAllFactionsResult[] = [];
// Нормализуем фильтр: "faction" → "#faction"
const normalizedFilter = tagFilter
? tagFilter.startsWith('#') ? tagFilter : `#${tagFilter}`
: null;
const collect = (f: TFolder) => {
for (const child of f.children) {
if (child instanceof TFile && child.extension === 'md') {
const cache = this.app.metadataCache.getFileCache(child);
const frontmatter: Record<string, any> = { ...(cache?.frontmatter ?? {}) };
delete frontmatter['position'];
if (normalizedFilter) {
// Теги могут быть в cache.tags (инлайн #tag) и в frontmatter.tags (список)
const inlineTags = (cache?.tags ?? []).map(t => t.tag);
const fmTags: string[] = (frontmatter.tags ?? []).map((t: string) =>
t.startsWith('#') ? t : `#${t}`
);
const allTags = [...inlineTags, ...fmTags];
if (!allTags.includes(normalizedFilter)) continue;
}
results.push({
path: child.path,
name: child.basename,
frontmatter
});
} else if (child instanceof TFolder) {
collect(child);
}
}
};
collect(folder);
return results;
}
// -------------------------------------------------------------------------
// get_recent_files
// -------------------------------------------------------------------------
/**
* Возвращает последние N файлов из папки, отсортированных по дате изменения.
* tags массив тегов для фильтрации (хотя бы один должен совпасть).
* Теги сравниваются без учёта # и регистра.
*/
private _getRecentFiles(
folderPath: string,
limit: number,
tags: string[]
): GetAllFactionsResult[] {
const all = this._getAllFilesInFolder(folderPath);
let filtered = all;
if (tags.length > 0) {
// Нормализуем фильтр-теги для сравнения
const normalizedFilterTags = tags.map(t =>
t.replace(/^#/, '').toLowerCase()
);
filtered = all.filter(item => {
const fmTags: string[] = (item.frontmatter.tags ?? []).map((t: string) =>
t.replace(/^#/, '').toLowerCase()
);
return normalizedFilterTags.some(ft => fmTags.includes(ft));
});
}
// Сортируем по mtime файла (свежие первые)
const withMtime = filtered.map(item => {
const file = this.app.vault.getAbstractFileByPath(item.path);
const mtime = file instanceof TFile ? file.stat.mtime : 0;
return { ...item, mtime };
});
withMtime.sort((a, b) => b.mtime - a.mtime);
return withMtime
.slice(0, limit)
.map(({ mtime: _mtime, ...rest }) => rest);
}
// -------------------------------------------------------------------------
// resolve_wikilink
// -------------------------------------------------------------------------
/**
* Резолвит wikilink-имя в путь к файлу.
* Obsidian metadataCache знает об aliases работает корректно.
* Принимает "Waterdeep" или "[[Waterdeep]]" или "Waterdeep|Город Плащей".
*
* Возвращает строку-путь или null если файл не найден.
*/
private _resolveWikilink(name: string): string | null {
// Чистим: убираем [[ ]], берём часть до |
const clean = name
.replace(/^\[\[/, '')
.replace(/\]\]$/, '')
.split('|')[0]
.trim();
const file = this.app.metadataCache.getFirstLinkpathDest(clean, '');
return file ? file.path : null;
}
// -------------------------------------------------------------------------
// write_frontmatter
// -------------------------------------------------------------------------
/**
* Перезаписывает файл: сериализует frontmatter в YAML и сохраняет с телом.
* Тело (body) берётся из payload Python присылает его обратно без изменений.
*
* YAML сериализуется вручную через простую функцию не тянем js-yaml,
* т.к. нужна только сериализация плоских структур (строки, числа, списки).
*/
private async _writeFrontmatter(
path: string,
frontmatter: Record<string, any>,
body: string
): Promise<WriteResult> {
const yamlStr = this._serializeYaml(frontmatter);
const newContent = `---\n${yamlStr}---\n\n${body.trim()}\n`;
return this._writeFileWithRetry(path, newContent);
}
// -------------------------------------------------------------------------
// create_file
// -------------------------------------------------------------------------
/**
* Создаёт новый файл или перезаписывает существующий.
* Автоматически создаёт промежуточные папки.
*/
private async _createFile(
path: string,
content: string
): Promise<WriteResult> {
return this._writeFileWithRetry(path, content);
}
// -------------------------------------------------------------------------
// Общий метод записи с повторными попытками
// -------------------------------------------------------------------------
private async _writeFileWithRetry(
path: string,
content: string,
maxAttempts = 3
): Promise<WriteResult> {
let lastError: Error | null = null;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
// --- Шаг 1: Запись ---
const existing = this.app.vault.getAbstractFileByPath(path);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, content);
} else {
// Создать папки при необходимости
const folderPath = path.includes('/')
? path.substring(0, path.lastIndexOf('/'))
: null;
if (folderPath) {
const folderExists = this.app.vault.getAbstractFileByPath(folderPath);
if (!folderExists) {
await this.app.vault.createFolder(folderPath);
}
}
await this.app.vault.create(path, content);
}
// --- Шаг 2: Проверка (перечитываем и сравниваем) ---
const writtenFile = this.app.vault.getAbstractFileByPath(path);
if (!(writtenFile instanceof TFile)) {
throw new Error(`Файл не найден сразу после записи: ${path}`);
}
const writtenContent = await this.app.vault.read(writtenFile);
// Сравниваем, игнорируя возможные конечные переводы строк
if (writtenContent.trim() === content.trim()) {
// Успех
return { success: true, path };
} else {
// Содержимое не совпало — ошибка
throw new Error(
`Содержимое файла не совпадает с записанным (попытка ${attempt})`
);
}
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
console.warn(
`VaultQueryHandler: попытка ${attempt} записи ${path} не удалась:`,
lastError.message
);
if (attempt < maxAttempts) {
// Экспоненциальная задержка: 100, 200, 300 мс
const delay = 100 * attempt;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw new Error(
`Не удалось записать файл ${path} после ${maxAttempts} попыток: ${lastError?.message || 'неизвестная ошибка'}`
);
}
// -------------------------------------------------------------------------
// Вспомогательные методы
// -------------------------------------------------------------------------
/**
* Извлекает тело файла всё что после закрывающего ---.
*/
private _extractBody(raw: string): string {
const match = raw.match(/^---[\s\S]*?---\n?([\s\S]*)$/);
return match ? match[1].trim() : raw.trim();
}
/**
* Простая сериализация объекта в YAML-строку.
* Поддерживает: строки, числа, булевы, null, списки строк/чисел,
* вложенные объекты (один уровень для assets).
*
* Не использует внешние библиотеки.
* Obsidian имеет js-yaml глобально, но его API нестабильно между версиями.
*/
private _serializeYaml(obj: Record<string, any>, indent = 0): string {
const pad = ' '.repeat(indent);
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) {
lines.push(`${pad}${key}:`);
} else if (typeof value === 'boolean') {
lines.push(`${pad}${key}: ${value}`);
} else if (typeof value === 'number') {
lines.push(`${pad}${key}: ${value}`);
} else if (typeof value === 'string') {
// Строки с двоеточием, решёткой или спецсимволами — в кавычки
if (this._needsQuoting(value)) {
const escaped = value.replace(/"/g, '\\"');
lines.push(`${pad}${key}: "${escaped}"`);
} else {
lines.push(`${pad}${key}: ${value}`);
}
} else if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${pad}${key}: []`);
} else if (value.every(v => typeof v !== 'object' || v === null)) {
// Простой список скаляров
lines.push(`${pad}${key}:`);
for (const item of value) {
if (typeof item === 'string' && this._needsQuoting(item)) {
lines.push(`${pad} - "${item.replace(/"/g, '\\"')}"`);
} else {
lines.push(`${pad} - ${item}`);
}
}
} else {
// Список объектов (assets)
lines.push(`${pad}${key}:`);
for (const item of value) {
if (typeof item === 'object' && item !== null) {
const entries = Object.entries(item);
if (entries.length === 0) {
lines.push(`${pad} - {}`);
continue;
}
const [firstKey, firstVal] = entries[0];
const firstValStr = this._scalarToYaml(firstVal);
lines.push(`${pad} - ${firstKey}: ${firstValStr}`);
for (const [k, v] of entries.slice(1)) {
lines.push(`${pad} ${k}: ${this._scalarToYaml(v)}`);
}
} else {
lines.push(`${pad} - ${item}`);
}
}
}
} else if (typeof value === 'object') {
// Вложенный объект (travel-distance, nested maps)
lines.push(`${pad}${key}:`);
for (const [k, v] of Object.entries(value)) {
const keyStr = this._needsQuoting(k) ? `"${k}"` : k;
lines.push(`${pad} ${keyStr}: ${this._scalarToYaml(v)}`);
}
}
}
return lines.join('\n') + '\n';
}
private _scalarToYaml(value: any): string {
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return String(value);
if (typeof value === 'number') return String(value);
if (typeof value === 'string') {
if (this._needsQuoting(value)) return `"${value.replace(/"/g, '\\"')}"`;
return value;
}
return String(value);
}
private _needsQuoting(s: string): boolean {
// Квотируем если: содержит : # [ ] { } , & * ? | > ' " % @ ` или начинается с пробела/цифры в некоторых контекстах
return /[:#\[\]{},&*?|>'"@`]/.test(s) || s.trim() !== s || s === '';
}
}

View File

@ -5,11 +5,13 @@ export class WebSocketService {
private socket: Socket | null = null;
private eventBus: EventBus;
private apiBaseUrl: string;
//private receivedEvents: Set<string> = new Set(); // Для отслеживания полученных событий
//private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private pingIntervalId: ReturnType<typeof setInterval> | null = null;
// Прямые подписчики на серверные события (минуя EventBus).
// Используется для vault_query_request и других низкоуровневых хуков.
private serverEventHandlers: Map<string, (data: any) => void> = new Map();
constructor(eventBus: EventBus, apiBaseUrl: string) {
this.eventBus = eventBus;
this.apiBaseUrl = apiBaseUrl;
@ -53,6 +55,15 @@ export class WebSocketService {
private setupEventHandlers(): void {
if (!this.socket) return;
// Снимаем все старые обработчики перед навешиванием новых
this.socket.off('connect');
this.socket.off('disconnect');
this.socket.off('connect_error');
this.socket.off('node_type_changed');
this.socket.off('graph_title_updated');
this.socket.off('node_title_updated');
this.socket.off('vault_query_request');
this.socket.on('connect', () => {
console.log('✅ WebSocket подключен');
});
@ -65,7 +76,7 @@ export class WebSocketService {
console.error('Ошибка подключения WebSocket:', error);
});
// ----------------------------------------------- Nodes Update Events ---------------------------------------------------------------
// ------- Nodes Update Events -------
this.socket.on(
'node_type_changed',
@ -104,6 +115,39 @@ export class WebSocketService {
}
}
);
// ------- Vault Query Hook -------
// Бэкенд просит Obsidian выполнить запрос к vault (чтение файлов, резолв wikilinks и т.д.)
this.socket.on('vault_query_request', (data: any) => {
console.log('📡 WebSocket получено: vault_query_request', data?.type, data?.request_id);
const handler = this.serverEventHandlers.get('vault_query_request');
if (handler) {
handler(data);
} else {
console.warn('⚠️ vault_query_request получен, но обработчик не зарегистрирован.');
}
});
}
/**
* Регистрирует обработчик для конкретного серверного события.
* Используется для низкоуровневых хуков (vault_query_request и т.п.).
* В отличие от EventBus один обработчик на событие (последний побеждает).
*/
onServerEvent(event: string, handler: (data: any) => void): void {
this.serverEventHandlers.set(event, handler);
}
/**
* Отправляет событие на сервер через WebSocket.
*/
emit(event: string, data: any): void {
if (!this.socket?.connected) {
console.warn(`⚠️ WebSocket не подключён, событие '${event}' не отправлено.`);
return;
}
this.socket.emit(event, data);
}
/**
@ -128,38 +172,37 @@ export class WebSocketService {
/**
* Запускает проактивный пинг для проверки соединения.
*/
private startProactivePing(): void {
this.stopProactivePing(); // Убедимся, что предыдущий интервал остановлен
this.pingIntervalId = setInterval(() => {
if (this.socket && this.socket.connected) {
console.log('Client sending proactive ping...');
let ackTimeout = setTimeout(() => {
console.warn('Client: No pong response received for proactive ping within timeout. Forcing reconnect.');
this.disconnect();
this.connect();
}, 3000); // Таймаут ожидания ответа на пинг от сервера (3 секунды)
private startProactivePing(): void {
this.stopProactivePing();
this.pingIntervalId = setInterval(() => {
// Если сокет не подключён — Socket.IO сам разберётся,
// не вмешиваемся принудительно
if (!this.socket?.connected) {
console.log('Client proactive ping: socket not connected, skipping.');
return;
}
// Используем callback для получения подтверждения от сервера
this.socket.emit('client_proactive_ping', (response: { status: string }) => {
clearTimeout(ackTimeout);
if (response && response.status === 'pong') {
console.log('Client received proactive pong.');
} else {
console.warn('Client: Invalid pong response or no response for proactive ping. Forcing reconnect.');
this.disconnect();
this.connect();
}
});
} else if (this.socket && !this.socket.connected) { // Здесь убираем '&& !this.socket.connecting'
// Если сокет не подключен, Socket.IO должен сам управлять переподключением,
// так как reconnection: true. Ручной вызов connect() может быть избыточным.
console.log('Client proactive ping: Socket not connected. Relying on Socket.IO\'s auto-reconnect logic.');
// Если вы все же хотите агрессивно вызвать this.connect() при каждом обнаружении отключения,
// оставьте следующую строку:
this.connect();
}
}, 5000); // Проверять каждые 5 секунд
}
const ackTimeout = setTimeout(() => {
console.warn('Pong timeout — forcing reconnect.');
// Используем socket.disconnect() + socket.connect()
// вместо полного пересоздания сокета
this.socket?.disconnect();
this.socket?.connect();
}, 3000);
this.socket.emit('client_proactive_ping', (response: { status: string }) => {
clearTimeout(ackTimeout);
if (!response || response.status !== 'pong') {
console.warn('Invalid pong, forcing reconnect.');
this.socket?.disconnect();
this.socket?.connect();
} else {
console.log('Proactive pong received.');
}
});
}, 5000);
}
/**
* Останавливает проактивный пинг.

View File

@ -158,7 +158,7 @@ export class ChatView extends ItemView {
// ----------------------------------------------- Event Handlers ---------------------------------------------------------------
async handleSendMessage(message: string, selectedModel?: string, attachments?: Attachment[], customMaxTokens?: number, customTemperature?: number): Promise<void> {
async handleSendMessage(message: string, selectedModel?: string, attachments?: Attachment[], customMaxTokens?: number, customTemperature?: number, agencyMode: boolean = false): Promise<void> {
try {
// ЗАЩИТА: Если граф не выбран, создаем его на лету, чтобы не потерять текст
if (!this.selectedGraphId) {
@ -213,7 +213,7 @@ export class ChatView extends ItemView {
const systemPromptToUse =
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
await this.streamLLMResponse(this.selectedGraphId, userNodeId, selectedModel, null, systemPromptToUse, cacheFolder, customMaxTokens, customTemperature);
await this.streamLLMResponse(this.selectedGraphId!, userNodeId, selectedModel, null, systemPromptToUse, cacheFolder, customMaxTokens, customTemperature, agencyMode);
} else {
throw new Error('Graph ID не определён после отправки сообщения');
}
@ -226,10 +226,15 @@ export class ChatView extends ItemView {
handleSettingsChanged(): void {
if (this.chatPanel)
{
// 1. Обновляем модель (это у вас уже было)
// 1. Обновляем модель
this.chatPanel.setSelectedModel(this.plugin.settings.defaultModel);
// 2. Уведомляем панель об обновлении всего объекта плагина (и настроек внутри)
// 2. Обновляем состояние тумблера из настроек плагина
if (this.chatPanel.agencyToggle) {
this.chatPanel.agencyToggle.checked = !!this.plugin.settings.agencyModeEnabled;
}
// 3. Уведомляем панель об обновлении всего объекта плагина (и настроек внутри)
this.chatPanel.updateSettings(this.plugin);
}
}
@ -283,6 +288,15 @@ export class ChatView extends ItemView {
await this.fetchMessagesFromRootToNode(data.currentNode);
}
private encodeBase64Safe(str: string): string {
try {
// Безопасное кодирование UTF-8 / юникода
return btoa(unescape(encodeURIComponent(str)));
} catch (e) {
return btoa(unescape(encodeURIComponent("Ошибка кодирования данных")));
}
}
/**
* Стримит ответ LLM.
*/
@ -294,7 +308,8 @@ export class ChatView extends ItemView {
systemPrompt: string | null = null,
cacheFolder?: string,
customMaxTokens?: number,
customTemperature?: number
customTemperature?: number,
agencyMode: boolean = false
): Promise<void> {
// Разворачиваем системный промпт
let finalSystemPrompt = systemPrompt;
@ -314,7 +329,8 @@ export class ChatView extends ItemView {
model: selectedModel || this.plugin.settings.defaultModel,
cache_folder: cacheFolder,
temperature: temperature,
max_tokens: customMaxTokens
max_tokens: customMaxTokens,
agency_mode: agencyMode
};
if (existingAssistantNodeId) {
@ -376,6 +392,24 @@ export class ChatView extends ItemView {
graphId: graphId,
currentNode: assistantNodeId
});
}
else if (data.type === 'tool_start' && assistantNodeId) {
accumulatedContent += `\n<div class="agent-tool-call" data-name="${data.name}" data-step="${data.step}" data-status="start"></div>\n`;
this.updateStreamingMessage(assistantNodeId, accumulatedContent);
} else if (data.type === 'tool_end' && assistantNodeId) {
const startMarker = `<div class="agent-tool-call" data-name="${data.name}" data-step="${data.step}" data-status="start"></div>`;
const encodedReq = this.encodeBase64Safe(data.request || "{}");
const encodedRes = this.encodeBase64Safe(data.response || "No data");
const isError = data.is_error ? "error" : "success";
const endMarker = `<div class="agent-tool-call" data-name="${data.name}" data-step="${data.step}" data-status="${isError}" data-req="${encodedReq}" data-res="${encodedRes}"></div>`;
if (accumulatedContent.includes(startMarker)) {
accumulatedContent = accumulatedContent.replace(startMarker, endMarker);
} else {
accumulatedContent += `\n${endMarker}\n`; // На крайний случай (фоллбек)
}
this.updateStreamingMessage(assistantNodeId, accumulatedContent);
} else if (data.type === 'chunk' && assistantNodeId) {
accumulatedContent += data.content;
this.updateStreamingMessage(assistantNodeId, accumulatedContent);
@ -496,7 +530,7 @@ export class ChatView extends ItemView {
}
}
async handleRegenerateMessage(data: { graphId: string; nodeId: string; model: string, maxTokens?: number, temperature?: number }): Promise<void> {
async handleRegenerateMessage(data: { graphId: string; nodeId: string; model: string, maxTokens?: number, temperature?: number, agencyMode?: boolean }): Promise<void> {
try {
const systemPromptToUse =
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
@ -533,7 +567,8 @@ export class ChatView extends ItemView {
const temperatureFromBackend = result.temperature;
if (data.graphId) {
await this.streamLLMResponse(data.graphId, userNodeId, modelToUse, existingAssistantNodeId, systemPromptFromBackend, cacheFolder, maxTokensFromBackend, temperatureFromBackend);
await this.streamLLMResponse(data.graphId, userNodeId, modelToUse, existingAssistantNodeId, systemPromptFromBackend,
cacheFolder, maxTokensFromBackend, temperatureFromBackend, data.agencyMode || false);
}
} catch (error: any) {
console.error('Ошибка регенерации:', error);