Compare commits
No commits in common. "18af4e7d4746d257a8712a364349c5c469243d98" and "205040a5990468875b62bdcca68d3f75905bc922" have entirely different histories.
18af4e7d47
...
205040a599
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,4 +1,5 @@
|
||||||
# vscode
|
# vscode
|
||||||
|
.vscode
|
||||||
|
|
||||||
# Intellij
|
# Intellij
|
||||||
*.iml
|
*.iml
|
||||||
|
|
|
||||||
10
.vscode/extensions.json
vendored
10
.vscode/extensions.json
vendored
|
|
@ -1,10 +0,0 @@
|
||||||
{
|
|
||||||
"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
22
.vscode/launch.json
vendored
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"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}/*"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
С обфускацией
|
|
||||||
```
|
|
||||||
node esbuild.config.mjs production
|
|
||||||
```
|
|
||||||
|
|
||||||
Без обфускации
|
|
||||||
```
|
|
||||||
node esbuild.config.mjs dev
|
|
||||||
```
|
|
||||||
46
DEBUGGING.md
46
DEBUGGING.md
|
|
@ -1,46 +0,0 @@
|
||||||
# Инструкция по отладке плагина 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,69 +1,271 @@
|
||||||
# LLM Agent Plugin для Obsidian
|
## Запрос
|
||||||
|
|
||||||
Интеграция LLM (Large Language Models) в Obsidian с визуализацией диалогов в виде графа и интерактивным чатом.
|
Хочу перенести всё это в Obsidian, сделав его плагином (для личного пользования). ПРи этом в средней колонке, где граф отображаетс яштатный обсидиана или замтеки - тм будет react-flow граф с историей взаимдействия (деревом диалогов), где можно быстро будет попросить сгенерировать картинки и одновременно сразу нагенерить в разных ветках , например, локации и персонажей нескольких для НРИ (однорвременно запустить друг за другом запросы в разных ветках и пусть обрабатываются) впараллельно, я на графе всё увижу). А сам чат чтобы вёлся справа в колонк, гнде теги , бэклинки, проперти или Copilot помощник из известного плагина. сначала скажи можно ли перенести и чтобы бы ты посоветовал (пока без кода), может , предлоишь что-то улучшить? Да, граф в пространстве, где новые заметки открываются, он просто открывается там, в новом табе, где новые заметикти открываются ли графы обсидианы. Сделай перенос. Пусть бэкенд, как есть, а фронт перенеси полностью
|
||||||
|
|
||||||
|
# LLM Agent для Obsidian
|
||||||
|
|
||||||
|
Плагин Obsidian для работы с LLM агентом, включающий граф диалогов и интерфейс чата с поддержкой слеш-команд.
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```text
|
```
|
||||||
llm-agent-plugin/
|
ttrpg-obsidian-vault/
|
||||||
├── src/
|
├── .obsidian/
|
||||||
│ ├── components/ # React-компоненты интерфейса
|
│ └── plugins/
|
||||||
│ │ ├── models/ # Модели данных для компонентов
|
│ └── llm-agent-plugin/
|
||||||
│ │ ├── ChatPanel.ts # Панель основного чата
|
│ ├── main.js
|
||||||
│ │ ├── GraphPanel.ts # Панель визуализации графа (React Flow)
|
│ ├── manifest.json
|
||||||
│ │ ├── HistoryPanel.ts # Панель истории сообщений
|
│ ├── styles.css
|
||||||
│ │ └── VoicePanel.ts # Компонент голосового управления
|
│ └── src/
|
||||||
│ ├── views/ # Представления (Views) Obsidian
|
│ ├── views/
|
||||||
│ │ ├── ChatView.ts # Правая панель чата
|
│ │ ├── GraphView.js
|
||||||
│ │ └── GraphView.ts # Центральная панель графа
|
│ │ └── ChatView.js
|
||||||
│ ├── utils/ # Вспомогательные утилиты и сервисы
|
│ ├── components/
|
||||||
│ │ ├── WebSocketService.ts # Связь с Python бэкендом
|
│ │ ├── ChatPanel.js
|
||||||
│ │ ├── EventBus.ts # Шина событий для обмена данными
|
│ │ ├── CommandInput.js
|
||||||
│ │ ├── fileUtils.ts # Инструменты работы с файлами vault
|
│ │ ├── GraphPanel.js
|
||||||
│ │ └── VaultQueryHandler.ts # Поиск и фильтрация данных в Obsidian
|
│ │ └── HistoryPanel.js
|
||||||
│ ├── references/ # Система упоминаний и связей
|
│ └── utils/
|
||||||
│ │ ├── AutocompleteManager.ts # Логика выпадающего списка
|
│ └── EventBus.js
|
||||||
│ │ ├── ReferenceParser.ts # Парсинг [[ссылок]] в сообщениях
|
├── llm-agent-backend/ # Backend на Python
|
||||||
│ │ └── TemplateEngine.ts # Генерация контента по шаблонам
|
│ ├── env/ # Виртуальное окружение Python
|
||||||
│ ├── modals/ # Всплывающие окна
|
│ ├── app/ # Код приложения
|
||||||
│ │ └── GraphSettingsModal.ts # Настройки визуализации
|
│ │ ├── api.py
|
||||||
│ ├── constants.ts # Глобальные константы и настройки
|
│ │ ├── workflows.py
|
||||||
│ ├── types/ # TypeScript определения и интерфейсы
|
│ │ └── ...
|
||||||
│ ├── css/ # Стили компонентов
|
│ ├── requirements.txt
|
||||||
│ ├── json/ # Конфигурационные JSON файлы
|
│ └── run.py # Точка входа
|
||||||
│ └── docs/ # Техническая документация и спецификации
|
└── env/
|
||||||
├── main.ts # Точка входа: инициализация плагина и команд
|
|
||||||
├── mainBasic.ts # Упрощенная версия плагина
|
|
||||||
├── package.json # Зависимости и скрипты сборки (npm)
|
|
||||||
├── esbuild.config.mjs # Конфигурация сборщика esbuild
|
|
||||||
└── manifest.json # Манифест плагина Obsidian
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Установка
|
## Установка и запуск
|
||||||
|
|
||||||
1. **Предварительные требования**:
|
### 1. Настройка Backend
|
||||||
- Установленный [Node.js](https://nodejs.org/) (рекомендуется LTS).
|
|
||||||
- Запущенный [LLM Backend](https://github.com/user/llm-agent-backend) (по умолчанию на порту 5000).
|
|
||||||
|
|
||||||
2. **Сборка**:
|
1. Перейдите в папку backend:
|
||||||
```bash
|
```bash
|
||||||
npm install # Установка зависимостей
|
cd llm-agent-backend
|
||||||
npm run build # Сборка проекта (создает main.js)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Активация в Obsidian**:
|
2. Активируйте виртуальное окружение:
|
||||||
- Поместите папку `llm-agent-plugin` в `.obsidian/plugins/` вашего хранилища.
|
```bash
|
||||||
- В настройках Obsidian ("Community Plugins") включите "LLM Agent".
|
# Windows
|
||||||
|
env\Scripts\activate
|
||||||
|
|
||||||
## Использование
|
# Linux/macOS
|
||||||
|
source env/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
- **Граф диалогов**: Открывается через иконку 🧠 на левой панели. Позволяет видеть ветвление мыслей LLM.
|
3. Установите зависимости:
|
||||||
- **Интерактивный чат**: Находится в правой боковой панели. Поддерживает слеш-команды (`/imagine`, `/analyze`).
|
```bash
|
||||||
- **Система ссылок**: Используйте `[[название заметки]]` в чате для передачи контекста ваших файлов в LLM.
|
pip install -r requirements.txt
|
||||||
- **Голосовой ввод**: Нажмите иконку микрофона для диктовки сообщений.
|
```
|
||||||
|
|
||||||
## Технические особенности
|
4. Запустите backend сервер:
|
||||||
|
```bash
|
||||||
|
python run.py
|
||||||
|
```
|
||||||
|
|
||||||
- **Связь**: Использует WebSocket для real-time обновления графа и стриминга ответов.
|
Backend будет доступен на `http://localhost:5000`
|
||||||
- **UI**: Построен на базе React и React Flow для отрисовки сложных структур графов.
|
|
||||||
- **Интеграция**: Глубокая интеграция с API Obsidian для доступа к метаданным файлов и поиску.
|
### 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 должен быть запущен перед использованием плагина. При первом запуске может потребоваться установка дополнительных зависимостей.
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
@echo off
|
|
||||||
start "" "%LocalAppData%\Obsidian\Obsidian.exe" --remote-debugging-port=9222
|
|
||||||
exit
|
|
||||||
|
|
@ -90,14 +90,12 @@ const context = await esbuild.context({
|
||||||
renameCssPlugin(),
|
renameCssPlugin(),
|
||||||
copyManifestPlugin(),
|
copyManifestPlugin(),
|
||||||
...(prod || obfuscate ? [ObfuscatorPlugin({
|
...(prod || obfuscate ? [ObfuscatorPlugin({
|
||||||
compact: true, // Уплотняет код в одну строку
|
compact: true,
|
||||||
identifierNamesGenerator: 'mangled', // Заменяет имена переменных на короткие бессмысленные комбинации
|
controlFlowFlattening: true,
|
||||||
renameGlobals: true, // Включает обфускацию глобальных переменных и функций в том числе классов
|
controlFlowFlatteningThreshold: 0.75,
|
||||||
controlFlowFlattening: true, // Усложняет структуру выполнения (перемешивает логику)
|
debugProtection: true,
|
||||||
controlFlowFlatteningThreshold: 0.75, // Вероятность применения изменения потока управления для узлов
|
debugProtectionInterval: true,
|
||||||
debugProtection: true, // Вставляет код, блокирующий инструменты разработчика (DevTools)
|
disableConsoleOutput: true,
|
||||||
debugProtectionInterval: true, // Регулярно перезапускает проверку защиты от отладки
|
|
||||||
disableConsoleOutput: true, // Запрещает вывод логов в консоль (console.log и др.)
|
|
||||||
// Можно добавить filter, чтобы обфусцировать только нужные файлы, например ['**/*.js']
|
// Можно добавить filter, чтобы обфусцировать только нужные файлы, например ['**/*.js']
|
||||||
})] : [])
|
})] : [])
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ import { LLM_DEFAULTS } from 'src/constants';
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
chatHistory: ChatHistoryItem[];
|
chatHistory: ChatHistoryItem[];
|
||||||
onSendMessage: (message: string, selectedModel?: string, attachments?: Attachment[], customMaxTokens?: number, customTemperature?: number, agencyMode?: boolean) => void;
|
onSendMessage: (message: string, selectedModel?: string, attachments?: Attachment[], customMaxTokens?: number, customTemperature?: number) => void;
|
||||||
selectedModel?: string;
|
selectedModel?: string;
|
||||||
plugin: LLMAgentPlugin;
|
plugin: LLMAgentPlugin;
|
||||||
currentGraphCustomSystemPrompt: string | null;
|
currentGraphCustomSystemPrompt: string | null;
|
||||||
|
|
@ -38,8 +38,6 @@ export class ChatPanel {
|
||||||
selectedModel: string;
|
selectedModel: string;
|
||||||
customPromptIndicator: HTMLSpanElement | null = null;
|
customPromptIndicator: HTMLSpanElement | null = null;
|
||||||
currentStreamingNodeId: string | null;
|
currentStreamingNodeId: string | null;
|
||||||
|
|
||||||
branchChatBtn: HTMLButtonElement;
|
|
||||||
stopButton: HTMLButtonElement;
|
stopButton: HTMLButtonElement;
|
||||||
scrollToBottomBtn: HTMLButtonElement;
|
scrollToBottomBtn: HTMLButtonElement;
|
||||||
scrollToTopBtn: HTMLButtonElement;
|
scrollToTopBtn: HTMLButtonElement;
|
||||||
|
|
@ -66,8 +64,6 @@ export class ChatPanel {
|
||||||
dictationLastLen: number = 0;
|
dictationLastLen: number = 0;
|
||||||
hasGlobalStreams: boolean = false;
|
hasGlobalStreams: boolean = false;
|
||||||
|
|
||||||
agencyToggle: HTMLInputElement;
|
|
||||||
|
|
||||||
// ----------------------------------------------- Reference System Fields ---------------------------------------------------------------
|
// ----------------------------------------------- Reference System Fields ---------------------------------------------------------------
|
||||||
cacheManager: CacheManager;
|
cacheManager: CacheManager;
|
||||||
|
|
||||||
|
|
@ -215,13 +211,15 @@ export class ChatPanel {
|
||||||
this.modelDropdown.style.display = 'none';
|
this.modelDropdown.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
async setAgencyToggle(): Promise<void> {
|
handleInput(): void {
|
||||||
if (this.props.plugin.settings) {
|
const value = this.messageInput.textContent || '';
|
||||||
this.props.plugin.settings.agencyModeEnabled = this.agencyToggle.checked;
|
const commands = ['/help', '/imagine', '/analyze', '/subtitles_meet', '/subtitles_teams', '/summarize', '/chat'];
|
||||||
if (typeof this.props.plugin.saveSettings === 'function') {
|
|
||||||
await this.props.plugin.saveSettings(); // TODO: Подумать, насколько это должно быть здесь
|
if (value.startsWith('/')) {
|
||||||
await this.props.plugin.syncSettings();
|
const filteredCommands = commands.filter((cmd) => cmd.startsWith(value.toLowerCase()));
|
||||||
}
|
this.showSuggestions(filteredCommands);
|
||||||
|
} else {
|
||||||
|
this.hideSuggestions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,9 +285,6 @@ export class ChatPanel {
|
||||||
const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' });
|
const contentDiv = messageEl.createDiv({ cls: 'chat-message-content' });
|
||||||
await MarkdownRenderer.renderMarkdown(msg.content, contentDiv, '', this.props.plugin);
|
await MarkdownRenderer.renderMarkdown(msg.content, contentDiv, '', this.props.plugin);
|
||||||
|
|
||||||
// Оживляем плашки в старых сообщениях
|
|
||||||
this.enhanceToolCalls(contentDiv);
|
|
||||||
|
|
||||||
this.createMessageToolset(messageEl, msg.content, msg.node_id, msg.graph_id || '');
|
this.createMessageToolset(messageEl, msg.content, msg.node_id, msg.graph_id || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -462,7 +457,6 @@ export class ChatPanel {
|
||||||
async updateHistory(newHistory: ChatHistoryItem[]): Promise<void> {
|
async updateHistory(newHistory: ChatHistoryItem[]): Promise<void> {
|
||||||
this.chatHistory = newHistory;
|
this.chatHistory = newHistory;
|
||||||
await this.renderHistory();
|
await this.renderHistory();
|
||||||
this.updateScrollButtonsVisibility(); // НОВОЕ: проверяем видимость кнопок
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -473,85 +467,6 @@ export class ChatPanel {
|
||||||
this.processStreamUpdates();
|
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мс
|
* Обрабатывает очередь так, чтобы рендер происходил не чаще чем раз в 300мс
|
||||||
* и дожидался окончания предыдущей отрисовки.
|
* и дожидался окончания предыдущей отрисовки.
|
||||||
|
|
@ -598,9 +513,6 @@ export class ChatPanel {
|
||||||
// Вызываем тяжелый маркдаун Обсидиана и ЖДЕМ
|
// Вызываем тяжелый маркдаун Обсидиана и ЖДЕМ
|
||||||
await MarkdownRenderer.renderMarkdown(content, tempDiv, '', this.props.plugin);
|
await MarkdownRenderer.renderMarkdown(content, tempDiv, '', this.props.plugin);
|
||||||
|
|
||||||
// Оживляем плашки ПЕРЕД переброской их в финальный DOM
|
|
||||||
this.enhanceToolCalls(tempDiv);
|
|
||||||
|
|
||||||
// Моментально перебрасываем готовый DOM
|
// Моментально перебрасываем готовый DOM
|
||||||
contentDiv.innerHTML = '';
|
contentDiv.innerHTML = '';
|
||||||
while (tempDiv.firstChild) {
|
while (tempDiv.firstChild) {
|
||||||
|
|
@ -663,10 +575,6 @@ export class ChatPanel {
|
||||||
<div class="llm-agent-chat-panel">
|
<div class="llm-agent-chat-panel">
|
||||||
|
|
||||||
<div class="sticky-btn-wrapper">
|
<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="Прокрутить к первому сообщению">
|
<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>
|
<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>
|
</button>
|
||||||
|
|
@ -706,15 +614,6 @@ export class ChatPanel {
|
||||||
⚙️
|
⚙️
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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="Температура">
|
<div class="param-select-container" title="Температура">
|
||||||
<span class="param-icon">Temp: </span>
|
<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">
|
<input type="number" id="temp-input" class="param-input" placeholder="${LLM_DEFAULTS.TEMPERATURE}" min="0" max="2" step="0.1">
|
||||||
|
|
@ -751,16 +650,13 @@ export class ChatPanel {
|
||||||
this.modelSelectButton = this.container.querySelector('#model-select-button') as HTMLButtonElement;
|
this.modelSelectButton = this.container.querySelector('#model-select-button') as HTMLButtonElement;
|
||||||
this.modelDropdown = this.container.querySelector('#model-dropdown') as HTMLDivElement;
|
this.modelDropdown = this.container.querySelector('#model-dropdown') as HTMLDivElement;
|
||||||
this.customPromptIndicator = this.container.querySelector('#custom-prompt-indicator') as HTMLSpanElement;
|
this.customPromptIndicator = this.container.querySelector('#custom-prompt-indicator') as HTMLSpanElement;
|
||||||
|
|
||||||
this.branchChatBtn = this.container.querySelector('#branch-chat-btn') as HTMLButtonElement;
|
|
||||||
this.stopButton = this.container.querySelector('#stop-button') as HTMLButtonElement;
|
this.stopButton = this.container.querySelector('#stop-button') as HTMLButtonElement;
|
||||||
|
|
||||||
this.scrollToTopBtn = this.container.querySelector('#scroll-to-top-btn') 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.scrollToBottomBtn = this.container.querySelector('#scroll-to-bottom-btn') as HTMLButtonElement;
|
||||||
|
|
||||||
this.attachedFilesContainer = this.container.querySelector('#attached-files-container') as HTMLDivElement;
|
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.tempInput = this.container.querySelector('#temp-input') as HTMLInputElement;
|
||||||
this.tempResetBtn = this.container.querySelector('#temp-reset') as HTMLButtonElement;
|
this.tempResetBtn = this.container.querySelector('#temp-reset') as HTMLButtonElement;
|
||||||
this.maxTokensInput = this.container.querySelector('#max-tokens-input') as HTMLInputElement;
|
this.maxTokensInput = this.container.querySelector('#max-tokens-input') as HTMLInputElement;
|
||||||
|
|
@ -827,6 +723,7 @@ export class ChatPanel {
|
||||||
});
|
});
|
||||||
|
|
||||||
this.messageInput.addEventListener('input', () => {
|
this.messageInput.addEventListener('input', () => {
|
||||||
|
this.handleInput();
|
||||||
this.handleReferenceInput();
|
this.handleReferenceInput();
|
||||||
this.updatePlaceholder();
|
this.updatePlaceholder();
|
||||||
});
|
});
|
||||||
|
|
@ -844,10 +741,6 @@ export class ChatPanel {
|
||||||
this.toggleModelDropdown();
|
this.toggleModelDropdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
this.stopButton.addEventListener('click', () => this.handleStopGeneration());
|
this.stopButton.addEventListener('click', () => this.handleStopGeneration());
|
||||||
|
|
||||||
this.maxTokensResetBtn.addEventListener('click', () => {
|
this.maxTokensResetBtn.addEventListener('click', () => {
|
||||||
|
|
@ -867,20 +760,12 @@ export class ChatPanel {
|
||||||
this.props.plugin.eventBus.emit('stop-all-streams');
|
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.props.plugin.eventBus.on('global-stream-state-changed', (isStreaming: boolean) => {
|
||||||
this.hasGlobalStreams = isStreaming;
|
this.hasGlobalStreams = isStreaming;
|
||||||
this.updateScrollButtonsVisibility();
|
this.updateScrollButtonsVisibility();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.agencyToggle.addEventListener('change', async () => {
|
|
||||||
this.setAgencyToggle();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) {
|
if (!this.modelSelectButton.contains(e.target as Node) && !this.modelDropdown.contains(e.target as Node)) {
|
||||||
|
|
@ -1251,9 +1136,7 @@ export class ChatPanel {
|
||||||
const valTemp = this.tempInput?.value;
|
const valTemp = this.tempInput?.value;
|
||||||
const parsedTemp = valTemp ? parseFloat(valTemp) : undefined;
|
const parsedTemp = valTemp ? parseFloat(valTemp) : undefined;
|
||||||
|
|
||||||
const isAgencyActive = this.agencyToggle.checked;
|
this.props.onSendMessage(originalText || '', this.selectedModel, allAttachments, parsedMaxTokens, parsedTemp);
|
||||||
|
|
||||||
this.props.onSendMessage(originalText || '', this.selectedModel, allAttachments, parsedMaxTokens, parsedTemp, isAgencyActive);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clearInput();
|
this.clearInput();
|
||||||
|
|
@ -1291,9 +1174,7 @@ export class ChatPanel {
|
||||||
const valTemp = this.tempInput?.value;
|
const valTemp = this.tempInput?.value;
|
||||||
const parsedTemp = valTemp ? parseFloat(valTemp) : undefined;
|
const parsedTemp = valTemp ? parseFloat(valTemp) : undefined;
|
||||||
|
|
||||||
const isAgencyActive = this.agencyToggle.checked;
|
this.props.onSendMessage(message, this.selectedModel, this.currentAttachments, parsedMaxTokens, parsedTemp);
|
||||||
|
|
||||||
this.props.onSendMessage(message, this.selectedModel, this.currentAttachments, parsedMaxTokens, parsedTemp, isAgencyActive);
|
|
||||||
this.clearInput();
|
this.clearInput();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1380,15 +1261,12 @@ export class ChatPanel {
|
||||||
const valTemp = this.tempInput?.value;
|
const valTemp = this.tempInput?.value;
|
||||||
const parsedTemp = valTemp ? parseFloat(valTemp) : undefined;
|
const parsedTemp = valTemp ? parseFloat(valTemp) : undefined;
|
||||||
|
|
||||||
const isAgencyActive = this.agencyToggle.checked;
|
|
||||||
|
|
||||||
this.props.plugin.eventBus.emit('regenerate-message', {
|
this.props.plugin.eventBus.emit('regenerate-message', {
|
||||||
graphId,
|
graphId,
|
||||||
nodeId,
|
nodeId,
|
||||||
model: this.selectedModel,
|
model: this.selectedModel,
|
||||||
maxTokens: parsedMaxTokens,
|
maxTokens: parsedMaxTokens,
|
||||||
temperature: parsedTemp,
|
temperature: parsedTemp
|
||||||
agencyMode: isAgencyActive
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1568,21 +1446,18 @@ export class ChatPanel {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обрабатывает событие сброса внешних файлов (Drag-and-Drop) из ОС
|
* Обрабатывает событие сброса внешних файлов (Drag-and-Drop) из ОС
|
||||||
* и внутренних файлов из файлового менеджера Obsidian
|
|
||||||
*/
|
*/
|
||||||
private async handleDrop(e: DragEvent): Promise<void> {
|
private async handleDrop(e: DragEvent): Promise<void> {
|
||||||
if (!e.dataTransfer) {
|
if (!e.dataTransfer?.files || e.dataTransfer.files.length === 0) {
|
||||||
return; // Игнорируем пустые события
|
return; // Оброшен не файл (например, просто выделенный текст)
|
||||||
}
|
}
|
||||||
|
|
||||||
const isCtrlPressed = e.ctrlKey || e.metaKey;
|
const isCtrlPressed = e.ctrlKey || e.metaKey;
|
||||||
const shouldBePermanent = !isCtrlPressed;
|
const shouldBePermanent = !isCtrlPressed;
|
||||||
|
|
||||||
|
const files = Array.from(e.dataTransfer.files);
|
||||||
let addedCount = 0;
|
let addedCount = 0;
|
||||||
|
|
||||||
// 1. Проверяем перетаскивание внешних файлов из файловой системы ОС
|
|
||||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
|
||||||
const files = Array.from(e.dataTransfer.files);
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
let absolutePath = '';
|
let absolutePath = '';
|
||||||
|
|
||||||
|
|
@ -1611,7 +1486,7 @@ export class ChatPanel {
|
||||||
sourcePath: absolutePath,
|
sourcePath: absolutePath,
|
||||||
cachedPath: cacheResult.cachedPath,
|
cachedPath: cacheResult.cachedPath,
|
||||||
uuid: cacheResult.uuid,
|
uuid: cacheResult.uuid,
|
||||||
permanent: shouldBePermanent,
|
permanent: shouldBePermanent, // D&D файлы прикрепляем разово (как $файл, а не !$)
|
||||||
mimeType: cacheResult.mimeType
|
mimeType: cacheResult.mimeType
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1622,89 +1497,6 @@ export class ChatPanel {
|
||||||
new Notice(`Не удалось прикрепить ${file.name}. ${error.message || 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}.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addedCount > 0) {
|
if (addedCount > 0) {
|
||||||
// Перерисовываем UI "Скрепки" над полем ввода
|
// Перерисовываем UI "Скрепки" над полем ввода
|
||||||
|
|
@ -1764,16 +1556,14 @@ export class ChatPanel {
|
||||||
if (!this.cachedScrollContainer || !this.cachedBtnWrapper) return;
|
if (!this.cachedScrollContainer || !this.cachedBtnWrapper) return;
|
||||||
|
|
||||||
const hasScrollbar = this.cachedScrollContainer.scrollHeight > (this.cachedScrollContainer.clientHeight + 2);
|
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.scrollToTopBtn.style.display = hasScrollbar ? 'flex' : 'none';
|
||||||
this.scrollToBottomBtn.style.display = hasScrollbar ? 'flex' : 'none';
|
this.scrollToBottomBtn.style.display = hasScrollbar ? 'flex' : 'none';
|
||||||
this.globalStopBtn.style.display = this.hasGlobalStreams ? 'flex' : 'none';
|
this.globalStopBtn.style.display = this.hasGlobalStreams ? 'flex' : 'none';
|
||||||
this.branchChatBtn.style.display = hasHistory ? 'flex' : 'none';
|
|
||||||
|
|
||||||
// Обертка видна, если есть либо скроллбар, либо активный стрим
|
// Обертка видна, если есть либо скроллбар, либо активный стрим
|
||||||
if (hasScrollbar || this.hasGlobalStreams || hasHistory) {
|
if (hasScrollbar || this.hasGlobalStreams) {
|
||||||
this.cachedBtnWrapper.classList.add('is-visible');
|
this.cachedBtnWrapper.classList.add('is-visible');
|
||||||
} else {
|
} else {
|
||||||
this.cachedBtnWrapper.classList.remove('is-visible');
|
this.cachedBtnWrapper.classList.remove('is-visible');
|
||||||
|
|
|
||||||
|
|
@ -105,15 +105,13 @@ const BaseNodeWrapper: React.FC<BaseNodeWrapperProps> = ({ data, isConnectable,
|
||||||
// или перелопачивать код и хранить в узлах и бд настройки, использованные при генерации, как будто, ещё хуже
|
// или перелопачивать код и хранить в узлах и бд настройки, использованные при генерации, как будто, ещё хуже
|
||||||
let maxTokens = (data.app as any).plugins?.plugins?.['llm-agent-plugin']?.settings?.maxTokensChatDefault || '';
|
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 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', {
|
eventBus.emit('regenerate-message', {
|
||||||
graphId: data.graphId,
|
graphId: data.graphId,
|
||||||
nodeId: data.nodeId,
|
nodeId: data.nodeId,
|
||||||
model: model, // НОВОЕ: Добавляем модель
|
model: model, // НОВОЕ: Добавляем модель
|
||||||
maxTokens: maxTokens,
|
maxTokens: maxTokens,
|
||||||
temperature: temperature,
|
temperature: temperature
|
||||||
agencyMode: agencyMode
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,13 +21,3 @@ export interface Attachment {
|
||||||
permanent: boolean; // true для !@, !#, !$
|
permanent: boolean; // true для !@, !#, !$
|
||||||
mimeType?: string;
|
mimeType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomTool {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
scriptPath: string;
|
|
||||||
methodName?: string;
|
|
||||||
hiddenPrompt: string;
|
|
||||||
hiddenPromptExpanded?: string;
|
|
||||||
}
|
|
||||||
|
|
@ -309,17 +309,22 @@
|
||||||
|
|
||||||
.chat-controls {
|
.chat-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap; /* Элементы переносятся, если панель узкая */
|
|
||||||
gap: 6px;
|
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 4px;
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.control-buttons-left, .control-buttons-right {
|
|
||||||
|
.control-buttons-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 4px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-buttons-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-select-container {
|
.model-select-container {
|
||||||
|
|
@ -528,8 +533,7 @@
|
||||||
|
|
||||||
/* Добавить общий стиль для обеих кнопок и специфичный для новой */
|
/* Добавить общий стиль для обеих кнопок и специфичный для новой */
|
||||||
.scroll-to-bottom-btn,
|
.scroll-to-bottom-btn,
|
||||||
.scroll-to-top-btn,
|
.scroll-to-top-btn {
|
||||||
.branch-chat-btn {
|
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
border: 2px solid var(--background-primary);
|
border: 2px solid var(--background-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
@ -558,8 +562,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-to-bottom-btn:hover,
|
.scroll-to-bottom-btn:hover,
|
||||||
.scroll-to-top-btn:hover,
|
.scroll-to-top-btn:hover {
|
||||||
.branch-chat-btn:hover {
|
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||||
|
|
@ -567,15 +570,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.scroll-to-bottom-btn:active,
|
.scroll-to-bottom-btn:active,
|
||||||
.scroll-to-top-btn:active,
|
.scroll-to-top-btn:active {
|
||||||
.branch-chat-btn:active {
|
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Иконки кнопок */
|
/* Иконки кнопок */
|
||||||
.scroll-to-bottom-btn svg,
|
.scroll-to-bottom-btn svg,
|
||||||
.scroll-to-top-btn svg,
|
.scroll-to-top-btn svg {
|
||||||
.branch-chat-btn svg {
|
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
stroke-width: 3px;
|
stroke-width: 3px;
|
||||||
|
|
@ -1659,98 +1660,3 @@
|
||||||
100% { transform: scale(1); opacity: 1; }
|
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,22 +43,20 @@ export class AutocompleteManager {
|
||||||
/**
|
/**
|
||||||
* Получает список файлов для автокомплита
|
* Получает список файлов для автокомплита
|
||||||
* @param query - поисковый запрос
|
* @param query - поисковый запрос
|
||||||
* @param filterExtension - опциональный фильтр по расширению (например, 'py')
|
|
||||||
* @returns массив файлов для автокомплита
|
* @returns массив файлов для автокомплита
|
||||||
*/
|
*/
|
||||||
async getFileOptions(query: string, filterExtension?: string): Promise<AutocompleteItem[]> {
|
async getFileOptions(query: string): Promise<AutocompleteItem[]> {
|
||||||
if (!this.plugin.settings.enableFileReferences) return [];
|
if (!this.plugin.settings.enableFileReferences) return [];
|
||||||
|
|
||||||
const lowercaseQuery = query.toLowerCase();
|
const lowercaseQuery = query.toLowerCase();
|
||||||
const virtualItems: AutocompleteItem[] = [];
|
const virtualItems: AutocompleteItem[] = [];
|
||||||
|
|
||||||
// Пункт 1: Если в запросе "cur", добавляем логи в начало
|
// Пункт 1: Если в запросе "cur", добавляем логи в начало
|
||||||
// (Только если мы не ищем скрипты конкретного расширения)
|
if ("current-log".includes(lowercaseQuery)) {
|
||||||
if (!filterExtension && "current-log".includes(lowercaseQuery)) {
|
|
||||||
virtualItems.push({
|
virtualItems.push({
|
||||||
name: "current-log",
|
name: "current-log",
|
||||||
path: "virtual:current-log",
|
path: "virtual:current-log",
|
||||||
type: "file",
|
type: "file", // Для применения иконки @ по умолчанию
|
||||||
icon: "file-text"
|
icon: "file-text"
|
||||||
});
|
});
|
||||||
virtualItems.push({
|
virtualItems.push({
|
||||||
|
|
@ -72,18 +70,10 @@ export class AutocompleteManager {
|
||||||
await this.updateCacheIfNeeded();
|
await this.updateCacheIfNeeded();
|
||||||
const allFiles = this.fileCache.get('all') || [];
|
const allFiles = this.fileCache.get('all') || [];
|
||||||
|
|
||||||
let filteredFiles = allFiles;
|
// Объединяем: виртуальные вначале, потом поиск по файлам
|
||||||
|
const filteredFiles = !query.trim()
|
||||||
// Применяем строгий фильтр расширения (если передан, например для '.py' скриптов)
|
? allFiles
|
||||||
if (filterExtension) {
|
: allFiles.filter((item) => item.name.toLowerCase().includes(lowercaseQuery) || item.path.toLowerCase().includes(lowercaseQuery));
|
||||||
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);
|
return [...virtualItems, ...filteredFiles].slice(0, AutocompleteManager.MAX_TOTAL_ITEMS);
|
||||||
}
|
}
|
||||||
|
|
@ -183,28 +173,26 @@ export class AutocompleteManager {
|
||||||
* Обновляет кеш файлов и промптов
|
* Обновляет кеш файлов и промптов
|
||||||
*/
|
*/
|
||||||
private async updateCache(): Promise<void> {
|
private async updateCache(): Promise<void> {
|
||||||
// Получаем ВСЕ файлы хранилища, чтобы поддерживать скрипты (.py), картинки и др.
|
const files = this.plugin.app.vault.getMarkdownFiles();
|
||||||
const files = this.plugin.app.vault.getFiles();
|
|
||||||
const fileItems: AutocompleteItem[] = [];
|
const fileItems: AutocompleteItem[] = [];
|
||||||
const promptItems: AutocompleteItem[] = [];
|
const promptItems: AutocompleteItem[] = [];
|
||||||
|
|
||||||
|
// Нормализуем пути исключенных папок
|
||||||
const normalizedExcludedFolders = this.plugin.settings.excludedFolders
|
const normalizedExcludedFolders = this.plugin.settings.excludedFolders
|
||||||
.filter((folder) => folder.trim() !== '')
|
.filter((folder) => folder.trim() !== '')
|
||||||
.map((folder) => (folder.endsWith('/') ? folder : folder + '/'))
|
.map((folder) => (folder.endsWith('/') ? folder : folder + '/')) // Убедимся, что заканчивается на слэш
|
||||||
.map((folder) => (folder.startsWith('/') ? folder.substring(1) : folder));
|
.map((folder) => (folder.startsWith('/') ? folder.substring(1) : folder)); // Удаляем начальный слэш
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
|
// Пропускаем файлы из исключенных папок
|
||||||
if (this.isFileInExcludedFolder(file, normalizedExcludedFolders)) {
|
if (this.isFileInExcludedFolder(file, normalizedExcludedFolders)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPrompt = this.isPromptFile(file);
|
|
||||||
const item: AutocompleteItem = {
|
const item: AutocompleteItem = {
|
||||||
// Для промптов оставляем basename (без расширения),
|
name: file.basename,
|
||||||
// а для обычных файлов используем name (с расширением) для точной идентификации
|
|
||||||
name: isPrompt ? file.basename : file.name,
|
|
||||||
path: file.path,
|
path: file.path,
|
||||||
type: isPrompt ? 'prompt' : 'file',
|
type: this.isPromptFile(file) ? 'prompt' : 'file',
|
||||||
icon: this.getFileIcon(file),
|
icon: this.getFileIcon(file),
|
||||||
file: file
|
file: file
|
||||||
};
|
};
|
||||||
|
|
@ -216,6 +204,7 @@ export class AutocompleteManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сортируем по имени
|
||||||
fileItems.sort((a, b) => a.name.localeCompare(b.name));
|
fileItems.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
promptItems.sort((a, b) => a.name.localeCompare(b.name));
|
promptItems.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
|
@ -249,7 +238,6 @@ export class AutocompleteManager {
|
||||||
switch (extension) {
|
switch (extension) {
|
||||||
case 'md':
|
case 'md':
|
||||||
return 'file-text';
|
return 'file-text';
|
||||||
case 'py': return 'file-terminal';
|
|
||||||
case 'js':
|
case 'js':
|
||||||
case 'ts':
|
case 'ts':
|
||||||
return 'code';
|
return 'code';
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,8 @@ export class ReferenceAutocomplete {
|
||||||
* Показывает автокомплит с опциями.
|
* Показывает автокомплит с опциями.
|
||||||
* @param reference - объект ReferenceMatch, определяющий текущий вводимый референс.
|
* @param reference - объект ReferenceMatch, определяющий текущий вводимый референс.
|
||||||
* @param cursorRect - DOMRect объекта курсора, для определения позиции.
|
* @param cursorRect - DOMRect объекта курсора, для определения позиции.
|
||||||
* @param filterExtension - Опциональный фильтр расширений
|
|
||||||
*/
|
*/
|
||||||
async showAutocomplete(reference: ReferenceMatch, cursorRect: DOMRect, filterExtension?: string): Promise<void> {
|
async showAutocomplete(reference: ReferenceMatch, cursorRect: DOMRect): Promise<void> {
|
||||||
this.currentReference = reference;
|
this.currentReference = reference;
|
||||||
|
|
||||||
// Для внешних файлов ($, !$) сразу открываем диалог
|
// Для внешних файлов ($, !$) сразу открываем диалог
|
||||||
|
|
@ -58,10 +57,10 @@ export class ReferenceAutocomplete {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Для файлов и промптов работаем как раньше
|
||||||
let options: AutocompleteItem[] = [];
|
let options: AutocompleteItem[] = [];
|
||||||
if (reference.type === 'file') {
|
if (reference.type === 'file') {
|
||||||
// ИЗМЕНЕНО: Передаем фильтр в менеджер
|
options = await this.autocompleteManager.getFileOptions(reference.name);
|
||||||
options = await this.autocompleteManager.getFileOptions(reference.name, filterExtension);
|
|
||||||
} else if (reference.type === 'prompt') {
|
} else if (reference.type === 'prompt') {
|
||||||
options = await this.autocompleteManager.getPromptOptions(reference.name);
|
options = await this.autocompleteManager.getPromptOptions(reference.name);
|
||||||
} else if (reference.type === 'url') {
|
} else if (reference.type === 'url') {
|
||||||
|
|
|
||||||
|
|
@ -1,466 +0,0 @@
|
||||||
// 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,13 +5,11 @@ export class WebSocketService {
|
||||||
private socket: Socket | null = null;
|
private socket: Socket | null = null;
|
||||||
private eventBus: EventBus;
|
private eventBus: EventBus;
|
||||||
private apiBaseUrl: string;
|
private apiBaseUrl: string;
|
||||||
|
//private receivedEvents: Set<string> = new Set(); // Для отслеживания полученных событий
|
||||||
|
//private reconnectAttempts = 0;
|
||||||
private maxReconnectAttempts = 10;
|
private maxReconnectAttempts = 10;
|
||||||
private pingIntervalId: ReturnType<typeof setInterval> | null = null;
|
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) {
|
constructor(eventBus: EventBus, apiBaseUrl: string) {
|
||||||
this.eventBus = eventBus;
|
this.eventBus = eventBus;
|
||||||
this.apiBaseUrl = apiBaseUrl;
|
this.apiBaseUrl = apiBaseUrl;
|
||||||
|
|
@ -55,15 +53,6 @@ export class WebSocketService {
|
||||||
private setupEventHandlers(): void {
|
private setupEventHandlers(): void {
|
||||||
if (!this.socket) return;
|
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', () => {
|
this.socket.on('connect', () => {
|
||||||
console.log('✅ WebSocket подключен');
|
console.log('✅ WebSocket подключен');
|
||||||
});
|
});
|
||||||
|
|
@ -76,7 +65,7 @@ export class WebSocketService {
|
||||||
console.error('Ошибка подключения WebSocket:', error);
|
console.error('Ошибка подключения WebSocket:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------- Nodes Update Events -------
|
// ----------------------------------------------- Nodes Update Events ---------------------------------------------------------------
|
||||||
|
|
||||||
this.socket.on(
|
this.socket.on(
|
||||||
'node_type_changed',
|
'node_type_changed',
|
||||||
|
|
@ -115,39 +104,6 @@ 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -173,35 +129,36 @@ export class WebSocketService {
|
||||||
* Запускает проактивный пинг для проверки соединения.
|
* Запускает проактивный пинг для проверки соединения.
|
||||||
*/
|
*/
|
||||||
private startProactivePing(): void {
|
private startProactivePing(): void {
|
||||||
this.stopProactivePing();
|
this.stopProactivePing(); // Убедимся, что предыдущий интервал остановлен
|
||||||
this.pingIntervalId = setInterval(() => {
|
this.pingIntervalId = setInterval(() => {
|
||||||
// Если сокет не подключён — Socket.IO сам разберётся,
|
if (this.socket && this.socket.connected) {
|
||||||
// не вмешиваемся принудительно
|
console.log('Client sending proactive ping...');
|
||||||
if (!this.socket?.connected) {
|
let ackTimeout = setTimeout(() => {
|
||||||
console.log('Client proactive ping: socket not connected, skipping.');
|
console.warn('Client: No pong response received for proactive ping within timeout. Forcing reconnect.');
|
||||||
return;
|
this.disconnect();
|
||||||
}
|
this.connect();
|
||||||
|
}, 3000); // Таймаут ожидания ответа на пинг от сервера (3 секунды)
|
||||||
const ackTimeout = setTimeout(() => {
|
|
||||||
console.warn('Pong timeout — forcing reconnect.');
|
|
||||||
// Используем socket.disconnect() + socket.connect()
|
|
||||||
// вместо полного пересоздания сокета
|
|
||||||
this.socket?.disconnect();
|
|
||||||
this.socket?.connect();
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
|
// Используем callback для получения подтверждения от сервера
|
||||||
this.socket.emit('client_proactive_ping', (response: { status: string }) => {
|
this.socket.emit('client_proactive_ping', (response: { status: string }) => {
|
||||||
clearTimeout(ackTimeout);
|
clearTimeout(ackTimeout);
|
||||||
if (!response || response.status !== 'pong') {
|
if (response && response.status === 'pong') {
|
||||||
console.warn('Invalid pong, forcing reconnect.');
|
console.log('Client received proactive pong.');
|
||||||
this.socket?.disconnect();
|
|
||||||
this.socket?.connect();
|
|
||||||
} else {
|
} else {
|
||||||
console.log('Proactive pong received.');
|
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'
|
||||||
}, 5000);
|
// Если сокет не подключен, 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 секунд
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ export class ChatView extends ItemView {
|
||||||
|
|
||||||
// ----------------------------------------------- Event Handlers ---------------------------------------------------------------
|
// ----------------------------------------------- Event Handlers ---------------------------------------------------------------
|
||||||
|
|
||||||
async handleSendMessage(message: string, selectedModel?: string, attachments?: Attachment[], customMaxTokens?: number, customTemperature?: number, agencyMode: boolean = false): Promise<void> {
|
async handleSendMessage(message: string, selectedModel?: string, attachments?: Attachment[], customMaxTokens?: number, customTemperature?: number): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// ЗАЩИТА: Если граф не выбран, создаем его на лету, чтобы не потерять текст
|
// ЗАЩИТА: Если граф не выбран, создаем его на лету, чтобы не потерять текст
|
||||||
if (!this.selectedGraphId) {
|
if (!this.selectedGraphId) {
|
||||||
|
|
@ -213,7 +213,7 @@ export class ChatView extends ItemView {
|
||||||
const systemPromptToUse =
|
const systemPromptToUse =
|
||||||
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
|
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
|
||||||
|
|
||||||
await this.streamLLMResponse(this.selectedGraphId!, userNodeId, selectedModel, null, systemPromptToUse, cacheFolder, customMaxTokens, customTemperature, agencyMode);
|
await this.streamLLMResponse(this.selectedGraphId, userNodeId, selectedModel, null, systemPromptToUse, cacheFolder, customMaxTokens, customTemperature);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Graph ID не определён после отправки сообщения');
|
throw new Error('Graph ID не определён после отправки сообщения');
|
||||||
}
|
}
|
||||||
|
|
@ -226,15 +226,10 @@ export class ChatView extends ItemView {
|
||||||
handleSettingsChanged(): void {
|
handleSettingsChanged(): void {
|
||||||
if (this.chatPanel)
|
if (this.chatPanel)
|
||||||
{
|
{
|
||||||
// 1. Обновляем модель
|
// 1. Обновляем модель (это у вас уже было)
|
||||||
this.chatPanel.setSelectedModel(this.plugin.settings.defaultModel);
|
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);
|
this.chatPanel.updateSettings(this.plugin);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -288,15 +283,6 @@ export class ChatView extends ItemView {
|
||||||
await this.fetchMessagesFromRootToNode(data.currentNode);
|
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.
|
* Стримит ответ LLM.
|
||||||
*/
|
*/
|
||||||
|
|
@ -308,8 +294,7 @@ export class ChatView extends ItemView {
|
||||||
systemPrompt: string | null = null,
|
systemPrompt: string | null = null,
|
||||||
cacheFolder?: string,
|
cacheFolder?: string,
|
||||||
customMaxTokens?: number,
|
customMaxTokens?: number,
|
||||||
customTemperature?: number,
|
customTemperature?: number
|
||||||
agencyMode: boolean = false
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Разворачиваем системный промпт
|
// Разворачиваем системный промпт
|
||||||
let finalSystemPrompt = systemPrompt;
|
let finalSystemPrompt = systemPrompt;
|
||||||
|
|
@ -329,8 +314,7 @@ export class ChatView extends ItemView {
|
||||||
model: selectedModel || this.plugin.settings.defaultModel,
|
model: selectedModel || this.plugin.settings.defaultModel,
|
||||||
cache_folder: cacheFolder,
|
cache_folder: cacheFolder,
|
||||||
temperature: temperature,
|
temperature: temperature,
|
||||||
max_tokens: customMaxTokens,
|
max_tokens: customMaxTokens
|
||||||
agency_mode: agencyMode
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (existingAssistantNodeId) {
|
if (existingAssistantNodeId) {
|
||||||
|
|
@ -392,24 +376,6 @@ export class ChatView extends ItemView {
|
||||||
graphId: graphId,
|
graphId: graphId,
|
||||||
currentNode: assistantNodeId
|
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) {
|
} else if (data.type === 'chunk' && assistantNodeId) {
|
||||||
accumulatedContent += data.content;
|
accumulatedContent += data.content;
|
||||||
this.updateStreamingMessage(assistantNodeId, accumulatedContent);
|
this.updateStreamingMessage(assistantNodeId, accumulatedContent);
|
||||||
|
|
@ -530,7 +496,7 @@ export class ChatView extends ItemView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleRegenerateMessage(data: { graphId: string; nodeId: string; model: string, maxTokens?: number, temperature?: number, agencyMode?: boolean }): Promise<void> {
|
async handleRegenerateMessage(data: { graphId: string; nodeId: string; model: string, maxTokens?: number, temperature?: number }): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const systemPromptToUse =
|
const systemPromptToUse =
|
||||||
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
|
this.currentGraphCustomSystemPrompt !== null ? this.currentGraphCustomSystemPrompt : this.plugin.settings.systemPrompt;
|
||||||
|
|
@ -567,8 +533,7 @@ export class ChatView extends ItemView {
|
||||||
const temperatureFromBackend = result.temperature;
|
const temperatureFromBackend = result.temperature;
|
||||||
|
|
||||||
if (data.graphId) {
|
if (data.graphId) {
|
||||||
await this.streamLLMResponse(data.graphId, userNodeId, modelToUse, existingAssistantNodeId, systemPromptFromBackend,
|
await this.streamLLMResponse(data.graphId, userNodeId, modelToUse, existingAssistantNodeId, systemPromptFromBackend, cacheFolder, maxTokensFromBackend, temperatureFromBackend);
|
||||||
cacheFolder, maxTokensFromBackend, temperatureFromBackend, data.agencyMode || false);
|
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Ошибка регенерации:', error);
|
console.error('Ошибка регенерации:', error);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user