diff --git a/.gitignore b/.gitignore index 60a35da..d895902 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ # vscode -.vscode # Intellij *.iml diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..4eb962e --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,10 @@ +{ + "recommendations": [ + "dbaeumer.vscode-eslint", // Проверка ошибок в коде + "esbenp.prettier-vscode", // Автоматическое форматирование кода + "ms-vscode.js-debug", // Современный отладчик (встроен в VS Code, но важен для JS/TS) + "visualstudioexptteam.vscodeintellicode", // Умные подсказки при написании кода + "marp-team.marp-vscode", // Если работаете с презентациями в Markdown + "christian-kohler.path-intellisense" // Автодополнение путей к файлам (удобно для импортов) + ] +} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ba5ede3 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,22 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "chrome", + "request": "attach", + "name": "Attach to Obsidian", + "port": 9222, + "address": "localhost", + "webRoot": "${workspaceFolder}", + "sourceMaps": true, + "internalConsoleOptions": "openOnSessionStart", + "sourceMapPathOverrides": { + "../llm-agent-plugin/*": "${workspaceFolder}/*", + "lib/*": "${workspaceFolder}/lib/*", + "src/*": "${workspaceFolder}/src/*", + "webpack:///./src/*": "${workspaceFolder}/src/*", + "esbuild:///*": "${workspaceFolder}/*" + } + } + ] +} \ No newline at end of file diff --git a/DEBUGGING.md b/DEBUGGING.md new file mode 100644 index 0000000..429d2b4 --- /dev/null +++ b/DEBUGGING.md @@ -0,0 +1,46 @@ +# Инструкция по отладке плагина Obsidian + +Из-за особенностей Obsidian (загрузка плагинов через `eval()`), прямая привязка точек останова (Breakpoints) в интерфейсе VS Code часто невозможна. Рекомендуется использовать встроенные инструменты разработчика Obsidian (Chrome DevTools). + +## 1. Подготовка +Убедитесь, что плагин запущен в режиме разработки. Это вшивает карты кода (Source Maps) прямо в файл, позволяя видеть TypeScript вместо скомпилированного JavaScript. +```bash +npm run dev +``` + +## 2. Способ «debugger» (рекомендуемый) +Самый надежный способ остановиться в нужном месте кода: +1. В VS Code добавьте ключевое слово `debugger;` в нужную строку: + ```typescript + async onload() { + debugger; // Выполнение остановится здесь + console.log("Plugin loaded"); + } + ``` +2. В Obsidian откройте консоль разработчика: `Ctrl + Shift + I`. +3. Перезагрузите плагин (выключите и включите в настройках или нажмите `Ctrl + R` в окне консоли). +4. Obsidian автоматически замерит на этой строке и откроет вкладку **Sources** с вашим оригинальным `.ts` файлом. + +## 3. Визуальные точки останова в Obsidian +Если вы не хотите менять код: +1. Откройте консоль (`Ctrl + Shift + I`) в Obsidian. +2. **ВАЖНО**: Кликните по окну консоли и нажмите `Ctrl + R` (или `Cmd + R`) для полной перезагрузки интерфейса. Это очистит кэш Source Maps. +3. Перейдите на вкладку **Sources**. +4. В левой панели навигатора прокрутите в самый низ до появления папки `file://` или виртуальной папки с исходниками вашего проекта. +5. Там будет доступна чистая структура папки `src/` со всеми оригинальными `.ts` файлами. Вы можете открывать их и ставить синие точки останова кликом по номеру строки. + +## 4. Быстрый поиск файлов (Ctrl + P) +Самый верный способ найти ваш файл, если дерево не отображается: +1. Кликните в любом месте инструментов разработчика Obsidian. +2. Нажмите `Ctrl + P` (или `Cmd + P`). +3. Введите имя файла, например `main.ts` — он должен появиться в списке с пометкой `[sm]` (source map). + +## 5. Если плагин все равно не виден в дереве Sources +Запустите в консоли Obsidian: +```javascript +app.plugins.plugins["llm-agent-plugin"].constructor +``` +Кликните по результату (ссылке на функцию), и DevTools сам откроет нужный файл исходного кода. + +## Вспомогательное подключение VS Code +Файл `.vscode/launch.json` настроен так, что вы можете нажать **F5**, чтобы видеть логи Obsidian прямо в **Debug Console** VS Code. Это удобно для чтения ошибок, но ставить точки останова через VS Code все равно не получится — используйте методы выше. diff --git a/Readme.md b/Readme.md index 91e3cba..a2011c9 100644 --- a/Readme.md +++ b/Readme.md @@ -1,271 +1,69 @@ -## Запрос +# LLM Agent Plugin для Obsidian -Хочу перенести всё это в Obsidian, сделав его плагином (для личного пользования). ПРи этом в средней колонке, где граф отображаетс яштатный обсидиана или замтеки - тм будет react-flow граф с историей взаимдействия (деревом диалогов), где можно быстро будет попросить сгенерировать картинки и одновременно сразу нагенерить в разных ветках , например, локации и персонажей нескольких для НРИ (однорвременно запустить друг за другом запросы в разных ветках и пусть обрабатываются) впараллельно, я на графе всё увижу). А сам чат чтобы вёлся справа в колонк, гнде теги , бэклинки, проперти или Copilot помощник из известного плагина. сначала скажи можно ли перенести и чтобы бы ты посоветовал (пока без кода), может , предлоишь что-то улучшить? Да, граф в пространстве, где новые заметки открываются, он просто открывается там, в новом табе, где новые заметикти открываются ли графы обсидианы. Сделай перенос. Пусть бэкенд, как есть, а фронт перенеси полностью - -# LLM Agent для Obsidian - -Плагин Obsidian для работы с LLM агентом, включающий граф диалогов и интерфейс чата с поддержкой слеш-команд. +Интеграция LLM (Large Language Models) в Obsidian с визуализацией диалогов в виде графа и интерактивным чатом. ## Структура проекта -``` -ttrpg-obsidian-vault/ -├── .obsidian/ -│ └── plugins/ -│ └── llm-agent-plugin/ -│ ├── main.js -│ ├── manifest.json -│ ├── styles.css -│ └── src/ -│ ├── views/ -│ │ ├── GraphView.js -│ │ └── ChatView.js -│ ├── components/ -│ │ ├── ChatPanel.js -│ │ ├── CommandInput.js -│ │ ├── GraphPanel.js -│ │ └── HistoryPanel.js -│ └── utils/ -│ └── EventBus.js -├── llm-agent-backend/ # Backend на Python -│ ├── env/ # Виртуальное окружение Python -│ ├── app/ # Код приложения -│ │ ├── api.py -│ │ ├── workflows.py -│ │ └── ... -│ ├── requirements.txt -│ └── run.py # Точка входа -└── env/ +```text +llm-agent-plugin/ +├── src/ +│ ├── components/ # React-компоненты интерфейса +│ │ ├── models/ # Модели данных для компонентов +│ │ ├── ChatPanel.ts # Панель основного чата +│ │ ├── GraphPanel.ts # Панель визуализации графа (React Flow) +│ │ ├── HistoryPanel.ts # Панель истории сообщений +│ │ └── VoicePanel.ts # Компонент голосового управления +│ ├── views/ # Представления (Views) Obsidian +│ │ ├── ChatView.ts # Правая панель чата +│ │ └── GraphView.ts # Центральная панель графа +│ ├── utils/ # Вспомогательные утилиты и сервисы +│ │ ├── WebSocketService.ts # Связь с Python бэкендом +│ │ ├── EventBus.ts # Шина событий для обмена данными +│ │ ├── fileUtils.ts # Инструменты работы с файлами vault +│ │ └── VaultQueryHandler.ts # Поиск и фильтрация данных в Obsidian +│ ├── references/ # Система упоминаний и связей +│ │ ├── AutocompleteManager.ts # Логика выпадающего списка +│ │ ├── ReferenceParser.ts # Парсинг [[ссылок]] в сообщениях +│ │ └── TemplateEngine.ts # Генерация контента по шаблонам +│ ├── modals/ # Всплывающие окна +│ │ └── GraphSettingsModal.ts # Настройки визуализации +│ ├── constants.ts # Глобальные константы и настройки +│ ├── types/ # TypeScript определения и интерфейсы +│ ├── css/ # Стили компонентов +│ ├── json/ # Конфигурационные JSON файлы +│ └── docs/ # Техническая документация и спецификации +├── main.ts # Точка входа: инициализация плагина и команд +├── mainBasic.ts # Упрощенная версия плагина +├── package.json # Зависимости и скрипты сборки (npm) +├── esbuild.config.mjs # Конфигурация сборщика esbuild +└── manifest.json # Манифест плагина Obsidian ``` -## Установка и запуск +## Установка -### 1. Настройка Backend +1. **Предварительные требования**: + - Установленный [Node.js](https://nodejs.org/) (рекомендуется LTS). + - Запущенный [LLM Backend](https://github.com/user/llm-agent-backend) (по умолчанию на порту 5000). -1. Перейдите в папку backend: +2. **Сборка**: ```bash - cd llm-agent-backend + npm install # Установка зависимостей + npm run build # Сборка проекта (создает main.js) ``` -2. Активируйте виртуальное окружение: - ```bash - # Windows - env\Scripts\activate - - # Linux/macOS - source env/bin/activate - ``` +3. **Активация в Obsidian**: + - Поместите папку `llm-agent-plugin` в `.obsidian/plugins/` вашего хранилища. + - В настройках Obsidian ("Community Plugins") включите "LLM Agent". -3. Установите зависимости: - ```bash - pip install -r requirements.txt - ``` +## Использование -4. Запустите backend сервер: - ```bash - python run.py - ``` +- **Граф диалогов**: Открывается через иконку 🧠 на левой панели. Позволяет видеть ветвление мыслей LLM. +- **Интерактивный чат**: Находится в правой боковой панели. Поддерживает слеш-команды (`/imagine`, `/analyze`). +- **Система ссылок**: Используйте `[[название заметки]]` в чате для передачи контекста ваших файлов в LLM. +- **Голосовой ввод**: Нажмите иконку микрофона для диктовки сообщений. - Backend будет доступен на `http://localhost:5000` +## Технические особенности -### 2. Установка плагина в Obsidian - -1. **Автоматическая установка (если плагин уже находится в правильной папке):** - - Откройте Obsidian с этим хранилищем - - Перейдите в Настройки → Плагины сообщества - - Включите "Небезопасные плагины" (если требуется) - - Найдите "LLM Agent" в списке установленных плагинов - - Включите плагин - -2. **Ручная установка (если нужно скопировать файлы):** - ```bash - # Убедитесь, что файлы плагина находятся в: - .obsidian/plugins/llm-agent-plugin/ - ├── main.js - ├── manifest.json - ├── styles.css - └── src/ - ``` - -### 3. Использование - -1. **Запуск интерфейса:** - - Нажмите иконку 🧠 в левой панели Obsidian - - Или используйте команду "Открыть граф диалогов LLM Agent" (Ctrl+P) - -2. **Доступные слеш-команды:** - - `/help` - Список доступных команд - - `/imagine [промпт]` - Генерация изображений - - `/analyze [url] [промпт]` - Анализ изображения - - `/subtitles_meet` - Получение субтитров Google Meet - - `/subtitles_teams` - Получение субтитров MS Teams - - `/summarize` - Суммаризация истории диалога - - `/chat` - Обычный диалог с LLM - -3. **Работа с графом:** - - Граф отображается в центральной области Obsidian - - Чат и история - в правой боковой панели - - Кликайте по узлам графа для навигации по веткам диалога - - Используйте "Новый чат" для создания нового диалога - -## Конфигурация - -### Backend API URL - -По умолчанию плагин использует `http://localhost:5000/api`. Для изменения: - -1. Откройте файл `.obsidian/plugins/llm-agent-plugin/main.js` -2. Найдите строку: - ```javascript - const DEFAULT_SETTINGS = { - apiBaseUrl: 'http://localhost:5000/api' - }; - ``` -3. Измените URL на нужный - -### LLM модели - -Конфигурация моделей находится в `backend/llm-agent-backend/app/llm_client.py`: - -```python -MODELS = { - "gemini-2.5-flash": { - "apiBase": "https://your-api-endpoint.com", - "apiKey": "your-api-key", - # ... - } -} -``` - -## Устранение неполадок - -### Backend не запускается - -1. Проверьте, что виртуальное окружение активировано -2. Убедитесь, что все зависимости установлены: - ```bash - pip install flask flask-cors langgraph langchain-core requests - ``` -3. Проверьте порт 5000 - он должен быть свободен - -### Плагин не появляется в Obsidian - -1. Убедитесь, что файлы находятся в правильной папке: - ``` - .obsidian/plugins/llm-agent-plugin/main.js - ``` -2. Проверьте, что включены "Плагины сообщества" в настройках -3. Перезапустите Obsidian - -### Ошибки подключения к API - -1. Убедитесь, что backend запущен на порту 5000 -2. Проверьте консоль разработчика в Obsidian (Ctrl+Shift+I) -3. Проверьте настройки CORS в `backend/llm-agent-backend/app/api.py` - -### Граф не отображается - -1. Текущая версия использует упрощенную визуализацию -2. Для полноценного React Flow потребуется дополнительная настройка bundler'а - -## Разработка - -### Структура кода плагина - -- `main.js` - Основной класс плагина -- `src/views/` - Views для Obsidian (GraphView, ChatView) -- `src/components/` - React-like компоненты -- `src/utils/` - Утилиты (EventBus) - -### Отладка - -1. Откройте консоль разработчика в Obsidian (Ctrl+Shift+I) -2. Логи плагина доступны в консоли -3. Backend логи выводятся в терминал где запущен сервер - -### Добавление новых команд - -1. Добавьте команду в `backend/llm-agent-backend/app/nodes.py` в `CommandManager` -2. Создайте соответствующий узел обработки -3. Обновите маршрутизацию в `workflows.py` - -## API Documentation - -### Основные эндпоинты - -- `POST /api/chat` - Отправка сообщения -- `GET /api/graphs` - Список всех графов -- `GET /api/graphs/{id}` - Данные конкретного графа -- `DELETE /api/graphs/{id}` - Удаление графа -- `GET /api/messages/{graph_id}/{node_id}` - Сообщения от корня до узла - -## Лицензия - -Для личного использования. -``` - -**`backend/llm-agent-backend/requirements.txt`:** -```txt -flask==2.3.3 -flask-cors==4.0.0 -langgraph==0.0.40 -langchain-core==0.1.45 -requests==2.31.0 -sqlite3 -openai==1.3.0 -``` - -**`backend/llm-agent-backend/run.py`:** -```python -""" -Точка входа для запуска LLM Agent backend сервера. -Запускает Flask приложение с настройками для разработки. -""" - -import os -import sys - -# Добавляем папку app в Python path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'app')) - -from api import app2 - -if __name__ == "__main__": - print("🚀 Запуск LLM Agent Backend сервера...") - print("📍 API будет доступно на: http://localhost:5000/api") - print("📊 Список графов: http://localhost:5000/api/graphs") - print("💬 Чат эндпоинт: http://localhost:5000/api/chat") - print("\n⚠️ Для остановки нажмите Ctrl+C\n") - - app2.run( - debug=True, - port=5000, - host='localhost' - ) -``` - -**`backend/llm-agent-backend/app/__init__.py`:** -```python -# Пустой файл для создания Python пакета -``` - -## Быстрый старт: - -1. **Запуск backend:** - ```bash - cd backend/llm-agent-backend - env\Scripts\activate # Windows - python run.py - ``` - -2. **Запуск Obsidian:** - - Откройте хранилище в Obsidian - - Включите плагин "LLM Agent" в настройках - - Нажмите иконку 🧠 в левой панели - -3. **Проверка работы:** - - В правой панели должна появиться история диалогов - - В центре - область для графа - - Отправьте сообщение `/help` для проверки - -**Важно:** Backend должен быть запущен перед использованием плагина. При первом запуске может потребоваться установка дополнительных зависимостей. \ No newline at end of file +- **Связь**: Использует WebSocket для real-time обновления графа и стриминга ответов. +- **UI**: Построен на базе React и React Flow для отрисовки сложных структур графов. +- **Интеграция**: Глубокая интеграция с API Obsidian для доступа к метаданным файлов и поиску. diff --git a/debug-obsidian.bat b/debug-obsidian.bat new file mode 100644 index 0000000..ede7f5b --- /dev/null +++ b/debug-obsidian.bat @@ -0,0 +1,3 @@ +@echo off +start "" "%LocalAppData%\Obsidian\Obsidian.exe" --remote-debugging-port=9222 +exit diff --git a/main.ts b/main.ts index 601bfde..e536b66 100644 --- a/main.ts +++ b/main.ts @@ -8,6 +8,7 @@ import { AutocompleteManager } from './src/references/AutocompleteManager'; import { ReferenceAutocomplete } from './src/references/ReferenceAutocomplete'; import { ReferenceParser } from './src/references/ReferenceParser'; import { CustomTool } from './src/components/models/ChatHistoryItem'; +import { VaultQueryHandler } from 'src/utils/VaultQueryHandler'; import { v4 as uuidv4 } from 'uuid'; import 'src/css/styles.css'; @@ -129,6 +130,8 @@ export default class LLMAgentPlugin extends Plugin { activeStreamControllers: Map; availableModels: string[]; + vaultQueryHandler: VaultQueryHandler | undefined; + async syncSettings() { try { const engine = new TemplateEngine(this); @@ -161,6 +164,9 @@ export default class LLMAgentPlugin extends Plugin { const adapter = this.app.vault.adapter; if (adapter instanceof FileSystemAdapter) { + const vaultPath = adapter.getBasePath(); + settingsToSync.vaultAbsolutePath = vaultPath; + // Соединяем базовый путь хранилища и относительный путь к логам settingsToSync.absoluteLogsPath = adapter.getBasePath() + (this.settings.logsFolder ? '/' + this.settings.logsFolder : ''); @@ -205,6 +211,41 @@ export default class LLMAgentPlugin extends Plugin { this.webSocketService = new WebSocketService(this.eventBus, this.settings.apiBaseUrl); this.webSocketService.connect(); + // Инициализируем обработчик vault-запросов от бэкенда + this.vaultQueryHandler = new VaultQueryHandler(this.app); + + // Регистрируем хук: бэкенд просит Obsidian выполнить операцию с vault + this.webSocketService.onServerEvent( + 'vault_query_request', + async (data: { request_id: string; type: string; payload: Record }) => { + const { request_id, type, payload } = data; + console.log(`🔍 vault_query_request: type=${type}, id=${request_id}`); + + try { + const result = await this.vaultQueryHandler.handle({ + request_id, + type, + payload + }); + + this.webSocketService.emit('vault_query_response', { + request_id, + result + }); + + console.log(`✅ vault_query_response отправлен: id=${request_id}`); + } catch (e: any) { + const errorMsg = e?.message ?? String(e); + console.error(`❌ vault_query_request ошибка: ${errorMsg}`); + + this.webSocketService.emit('vault_query_response', { + request_id, + error: errorMsg + }); + } + } + ); + // Регистрируем кастомные views this.registerView(GRAPH_VIEW_TYPE, (leaf) => new GraphView(leaf, this)); this.registerView(CHAT_VIEW_TYPE, (leaf) => new ChatView(leaf, this)); @@ -644,11 +685,31 @@ class SampleSettingTab extends PluginSettingTab { }); }); - const scriptSetting = new Setting(toolDiv) - .setName('Скрипт и Метод') - .setDesc('путь/скрипт.py:название_метода (Используйте @)'); + const scriptRow = toolDiv.createDiv({ attr: { style: 'display: flex; gap: 10px; align-items: flex-end;' } }); + + const scriptSetting = new Setting(scriptRow) + .setName('Скрипт') + .setDesc('Путь к .py (используйте @)'); + scriptSetting.settingEl.style.flex = '3'; + scriptSetting.settingEl.style.border = 'none'; this.addPromptAutocompleteStrict(scriptSetting, tool, 'scriptPath', 'py'); + const methodSetting = new Setting(scriptRow) + .setName('Метод') + .setDesc('Имя функции') + .addText(t => t + .setPlaceholder('') // 'main' + .setValue(tool.methodName || '') // 'main' + .onChange(async v => { + tool.methodName = v.trim(); + await this.plugin.saveSettings(); + }) + ); + methodSetting.settingEl.style.flex = '1'; + methodSetting.settingEl.style.border = 'none'; + methodSetting.infoEl.style.display = 'none'; // Скроем описание метода для компактности + + const formatSetting = new Setting(toolDiv) .setName('Форматирующий промпт (Пост-процессинг)') .setDesc('Стиль ответа, прикладываемый к результату работы скрипта (Используйте #)'); @@ -656,7 +717,7 @@ class SampleSettingTab extends PluginSettingTab { }); new Setting(container).addButton(b => b.setButtonText('➕ Добавить новый инструмент').setCta().onClick(async () => { - this.plugin.settings.customTools.push({ id: uuidv4(), name: 'New_Tool', description: '', scriptPath: '', hiddenPrompt: '' }); + this.plugin.settings.customTools.push({ id: uuidv4(), name: 'New_Tool', description: '', scriptPath: '', methodName: '', hiddenPrompt: '' }); // methodName: 'main' await this.plugin.saveSettings(); this.display(); })); diff --git a/src/components/models/ChatHistoryItem.ts b/src/components/models/ChatHistoryItem.ts index 4d74a29..a6b43cd 100644 --- a/src/components/models/ChatHistoryItem.ts +++ b/src/components/models/ChatHistoryItem.ts @@ -27,6 +27,7 @@ export interface CustomTool { name: string; description: string; scriptPath: string; + methodName?: string; hiddenPrompt: string; hiddenPromptExpanded?: string; } \ No newline at end of file diff --git a/src/utils/VaultQueryHandler.ts b/src/utils/VaultQueryHandler.ts new file mode 100644 index 0000000..ea300d3 --- /dev/null +++ b/src/utils/VaultQueryHandler.ts @@ -0,0 +1,423 @@ +// 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; +} + +interface GetFrontmatterResult { + path: string; + name: string; + frontmatter: Record; + body: string; +} + +interface GetAllFactionsResult { + path: string; + name: string; + frontmatter: Record; +} + +interface WriteResult { + success: boolean; + path: string; +} + +// --------------------------------------------------------------------------- +// Класс +// --------------------------------------------------------------------------- + +export class VaultQueryHandler { + constructor(private app: App) {} + + /** + * Главная точка входа. Диспетчеризует запрос по типу. + * Вызывается из main.ts при получении 'vault_query_request'. + */ + async handle(req: VaultQueryRequest): Promise { + 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, + 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 { + 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 = { ...(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 = { ...(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, + body: string + ): Promise { + const file = this.app.vault.getAbstractFileByPath(path); + if (!(file instanceof TFile)) { + throw new Error(`Файл не найден для записи: ${path}`); + } + + const yamlStr = this._serializeYaml(frontmatter); + const newContent = `---\n${yamlStr}---\n\n${body.trim()}\n`; + + await this.app.vault.modify(file, newContent); + return { success: true, path }; + } + + // ------------------------------------------------------------------------- + // create_file + // ------------------------------------------------------------------------- + + /** + * Создаёт новый файл или перезаписывает существующий. + * Автоматически создаёт промежуточные папки. + */ + private async _createFile( + path: string, + content: string + ): Promise { + // Создаём папки если нужно + 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); + } + } + + const existing = this.app.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await this.app.vault.modify(existing, content); + } else { + await this.app.vault.create(path, content); + } + + return { success: true, path }; + } + + // ------------------------------------------------------------------------- + // Вспомогательные методы + // ------------------------------------------------------------------------- + + /** + * Извлекает тело файла — всё что после закрывающего ---. + */ + 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, 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 === ''; + } +} \ No newline at end of file diff --git a/src/utils/WebSocketService.ts b/src/utils/WebSocketService.ts index a0e19cb..d7b2441 100644 --- a/src/utils/WebSocketService.ts +++ b/src/utils/WebSocketService.ts @@ -5,11 +5,13 @@ export class WebSocketService { private socket: Socket | null = null; private eventBus: EventBus; private apiBaseUrl: string; - //private receivedEvents: Set = new Set(); // Для отслеживания полученных событий - //private reconnectAttempts = 0; private maxReconnectAttempts = 10; private pingIntervalId: ReturnType | null = null; + // Прямые подписчики на серверные события (минуя EventBus). + // Используется для vault_query_request и других низкоуровневых хуков. + private serverEventHandlers: Map void> = new Map(); + constructor(eventBus: EventBus, apiBaseUrl: string) { this.eventBus = eventBus; this.apiBaseUrl = apiBaseUrl; @@ -53,6 +55,15 @@ export class WebSocketService { private setupEventHandlers(): void { if (!this.socket) return; + // Снимаем все старые обработчики перед навешиванием новых + this.socket.off('connect'); + this.socket.off('disconnect'); + this.socket.off('connect_error'); + this.socket.off('node_type_changed'); + this.socket.off('graph_title_updated'); + this.socket.off('node_title_updated'); + this.socket.off('vault_query_request'); + this.socket.on('connect', () => { console.log('✅ WebSocket подключен'); }); @@ -65,7 +76,7 @@ export class WebSocketService { console.error('Ошибка подключения WebSocket:', error); }); - // ----------------------------------------------- Nodes Update Events --------------------------------------------------------------- + // ------- Nodes Update Events ------- this.socket.on( 'node_type_changed', @@ -104,6 +115,39 @@ export class WebSocketService { } } ); + + // ------- Vault Query Hook ------- + // Бэкенд просит Obsidian выполнить запрос к vault (чтение файлов, резолв wikilinks и т.д.) + + this.socket.on('vault_query_request', (data: any) => { + console.log('📡 WebSocket получено: vault_query_request', data?.type, data?.request_id); + const handler = this.serverEventHandlers.get('vault_query_request'); + if (handler) { + handler(data); + } else { + console.warn('⚠️ vault_query_request получен, но обработчик не зарегистрирован.'); + } + }); + } + + /** + * Регистрирует обработчик для конкретного серверного события. + * Используется для низкоуровневых хуков (vault_query_request и т.п.). + * В отличие от EventBus — один обработчик на событие (последний побеждает). + */ + onServerEvent(event: string, handler: (data: any) => void): void { + this.serverEventHandlers.set(event, handler); + } + + /** + * Отправляет событие на сервер через WebSocket. + */ + emit(event: string, data: any): void { + if (!this.socket?.connected) { + console.warn(`⚠️ WebSocket не подключён, событие '${event}' не отправлено.`); + return; + } + this.socket.emit(event, data); } /** @@ -128,38 +172,37 @@ export class WebSocketService { /** * Запускает проактивный пинг для проверки соединения. */ - private startProactivePing(): void { - this.stopProactivePing(); // Убедимся, что предыдущий интервал остановлен - this.pingIntervalId = setInterval(() => { - if (this.socket && this.socket.connected) { - console.log('Client sending proactive ping...'); - let ackTimeout = setTimeout(() => { - console.warn('Client: No pong response received for proactive ping within timeout. Forcing reconnect.'); - this.disconnect(); - this.connect(); - }, 3000); // Таймаут ожидания ответа на пинг от сервера (3 секунды) + private startProactivePing(): void { + this.stopProactivePing(); + this.pingIntervalId = setInterval(() => { + // Если сокет не подключён — Socket.IO сам разберётся, + // не вмешиваемся принудительно + if (!this.socket?.connected) { + console.log('Client proactive ping: socket not connected, skipping.'); + return; + } - // Используем callback для получения подтверждения от сервера - this.socket.emit('client_proactive_ping', (response: { status: string }) => { - clearTimeout(ackTimeout); - if (response && response.status === 'pong') { - console.log('Client received proactive pong.'); - } else { - console.warn('Client: Invalid pong response or no response for proactive ping. Forcing reconnect.'); - this.disconnect(); - this.connect(); - } - }); - } else if (this.socket && !this.socket.connected) { // Здесь убираем '&& !this.socket.connecting' - // Если сокет не подключен, Socket.IO должен сам управлять переподключением, - // так как reconnection: true. Ручной вызов connect() может быть избыточным. - console.log('Client proactive ping: Socket not connected. Relying on Socket.IO\'s auto-reconnect logic.'); - // Если вы все же хотите агрессивно вызвать this.connect() при каждом обнаружении отключения, - // оставьте следующую строку: - this.connect(); - } - }, 5000); // Проверять каждые 5 секунд - } + const ackTimeout = setTimeout(() => { + console.warn('Pong timeout — forcing reconnect.'); + // Используем socket.disconnect() + socket.connect() + // вместо полного пересоздания сокета + this.socket?.disconnect(); + this.socket?.connect(); + }, 3000); + + this.socket.emit('client_proactive_ping', (response: { status: string }) => { + clearTimeout(ackTimeout); + if (!response || response.status !== 'pong') { + console.warn('Invalid pong, forcing reconnect.'); + this.socket?.disconnect(); + this.socket?.connect(); + } else { + console.log('Proactive pong received.'); + } + }); + + }, 5000); + } /** * Останавливает проактивный пинг.