Compare commits
10 Commits
205040a599
...
18af4e7d47
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18af4e7d47 | ||
|
|
44d996e7a4 | ||
|
|
5f7f07ee6f | ||
|
|
1256d64498 | ||
|
|
aa4f8d5342 | ||
|
|
4362e4f491 | ||
|
|
43acb3a868 | ||
|
|
f1dff8f855 | ||
|
|
0acd67ee57 | ||
|
|
768d93700e |
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
|||
# vscode
|
||||
.vscode
|
||||
|
||||
# Intellij
|
||||
*.iml
|
||||
|
|
|
|||
10
.vscode/extensions.json
vendored
Normal file
10
.vscode/extensions.json
vendored
Normal 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
22
.vscode/launch.json
vendored
Normal 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
9
Compile.md
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
С обфускацией
|
||||
```
|
||||
node esbuild.config.mjs production
|
||||
```
|
||||
|
||||
Без обфускации
|
||||
```
|
||||
node esbuild.config.mjs dev
|
||||
```
|
||||
46
DEBUGGING.md
Normal file
46
DEBUGGING.md
Normal 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
310
Readme.md
|
|
@ -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
3
debug-obsidian.bat
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
@echo off
|
||||
start "" "%LocalAppData%\Obsidian\Obsidian.exe" --remote-debugging-port=9222
|
||||
exit
|
||||
|
|
@ -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']
|
||||
})] : [])
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
466
src/utils/VaultQueryHandler.ts
Normal file
466
src/utils/VaultQueryHandler.ts
Normal 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 === '';
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Останавливает проактивный пинг.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user