Debug vault websocket events

This commit is contained in:
dimitrievgs 2026-07-06 01:50:38 +03:00
parent 5f7f07ee6f
commit 44d996e7a4
10 changed files with 701 additions and 295 deletions

1
.gitignore vendored
View File

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

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

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

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

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

46
DEBUGGING.md Normal file
View File

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

310
Readme.md
View File

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

3
debug-obsidian.bat Normal file
View File

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

69
main.ts
View File

@ -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<string, AbortController>;
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<string, any> }) => {
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();
}));

View File

@ -27,6 +27,7 @@ export interface CustomTool {
name: string;
description: string;
scriptPath: string;
methodName?: string;
hiddenPrompt: string;
hiddenPromptExpanded?: string;
}

View File

@ -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<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 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<WriteResult> {
// Создаём папки если нужно
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<string, any>, indent = 0): string {
const pad = ' '.repeat(indent);
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) {
lines.push(`${pad}${key}:`);
} else if (typeof value === 'boolean') {
lines.push(`${pad}${key}: ${value}`);
} else if (typeof value === 'number') {
lines.push(`${pad}${key}: ${value}`);
} else if (typeof value === 'string') {
// Строки с двоеточием, решёткой или спецсимволами — в кавычки
if (this._needsQuoting(value)) {
const escaped = value.replace(/"/g, '\\"');
lines.push(`${pad}${key}: "${escaped}"`);
} else {
lines.push(`${pad}${key}: ${value}`);
}
} else if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${pad}${key}: []`);
} else if (value.every(v => typeof v !== 'object' || v === null)) {
// Простой список скаляров
lines.push(`${pad}${key}:`);
for (const item of value) {
if (typeof item === 'string' && this._needsQuoting(item)) {
lines.push(`${pad} - "${item.replace(/"/g, '\\"')}"`);
} else {
lines.push(`${pad} - ${item}`);
}
}
} else {
// Список объектов (assets)
lines.push(`${pad}${key}:`);
for (const item of value) {
if (typeof item === 'object' && item !== null) {
const entries = Object.entries(item);
if (entries.length === 0) {
lines.push(`${pad} - {}`);
continue;
}
const [firstKey, firstVal] = entries[0];
const firstValStr = this._scalarToYaml(firstVal);
lines.push(`${pad} - ${firstKey}: ${firstValStr}`);
for (const [k, v] of entries.slice(1)) {
lines.push(`${pad} ${k}: ${this._scalarToYaml(v)}`);
}
} else {
lines.push(`${pad} - ${item}`);
}
}
}
} else if (typeof value === 'object') {
// Вложенный объект (travel-distance, nested maps)
lines.push(`${pad}${key}:`);
for (const [k, v] of Object.entries(value)) {
const keyStr = this._needsQuoting(k) ? `"${k}"` : k;
lines.push(`${pad} ${keyStr}: ${this._scalarToYaml(v)}`);
}
}
}
return lines.join('\n') + '\n';
}
private _scalarToYaml(value: any): string {
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return String(value);
if (typeof value === 'number') return String(value);
if (typeof value === 'string') {
if (this._needsQuoting(value)) return `"${value.replace(/"/g, '\\"')}"`;
return value;
}
return String(value);
}
private _needsQuoting(s: string): boolean {
// Квотируем если: содержит : # [ ] { } , & * ? | > ' " % @ ` или начинается с пробела/цифры в некоторых контекстах
return /[:#\[\]{},&*?|>'"@`]/.test(s) || s.trim() !== s || s === '';
}
}

View File

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