Debug faction turns
This commit is contained in:
parent
7d04a7227d
commit
7cb54a1641
63
README.md
Normal file
63
README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# LLM Agent Backend
|
||||
|
||||
Бэкенд-сервер на Python для обеспечения работы интеллектуального агента в Obsidian. Использует LangGraph для управления логикой рассуждений и Flask для взаимодействия с плагином.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
├── llm-agent-backend/ # Корневая директория бэкенда
|
||||
│ ├── app/ # Исходный код приложения
|
||||
│ │ ├── api.py # Flask сервер, HTTP эндпоинты и WebSocket (SocketIO)
|
||||
│ │ ├── run.py # Точка входа для запуска приложения
|
||||
│ │ ├── workflows.py # Определение графов LangGraph и логики потоков
|
||||
│ │ ├── react_agent.py # Реализация ReAct (Reasoning and Acting) агента
|
||||
│ │ ├── llm_client.py # Клиент для работы с моделями (OpenAI, Google, Mistral)
|
||||
│ │ ├── mcp_tools.py # Интеграция с Model Context Protocol (MCP) инструментами
|
||||
│ │ ├── graph_history_manager.py # Управление историей диалогов и состоянием графов в SQLite
|
||||
│ │ ├── voice_service.py # Сервис распознавания речи (Vosk)
|
||||
│ │ ├── title_generator.py # Генерация названий для чатов на основе контекста
|
||||
│ │ ├── heartbeat_monitor.py # Мониторинг активности и состояния подключения
|
||||
│ │ ├── models.py # Описание структур данных и схем
|
||||
│ │ └── services.py # Вспомогательные сервисы
|
||||
│ ├── docs/ # Техническая документация и скриншоты настроек
|
||||
│ ├── env/ # Виртуальное окружение Python (venv)
|
||||
│ ├── build.bat # Скрипт сборки проекта
|
||||
│ ├── requirements.txt # Список зависимостей Python
|
||||
│ ├── run-llm-backend.bat # Батник для быстрого запуска сервера
|
||||
│ └── graph_history.db # БД SQLite с историей графов и сообщений
|
||||
|
||||
## Основные технологии
|
||||
- **Python 3.11+**
|
||||
- **LangChain / LangGraph**: Управление состоянием агента и цепочками рассуждений.
|
||||
- **Flask / Flask-SocketIO**: API и real-time взаимодействие с Obsidian.
|
||||
- **Vosk / SoundDevice**: Локальное распознавание речи.
|
||||
- **SQLite**: Хранение истории взаимодействий.
|
||||
|
||||
## Установка
|
||||
|
||||
1. Убедитесь, что установлен Python 3.11.
|
||||
2. Создайте виртуальное окружение в папке `env`:
|
||||
```bash
|
||||
python -m venv env
|
||||
```
|
||||
3. Активируйте окружение и установите зависимости:
|
||||
```bash
|
||||
env\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
### Вариант 1: Через батник (рекомендуется)
|
||||
Запустите `run-llm-backend.bat` из корня проекта.
|
||||
|
||||
### Вариант 2: Вручную из терминала
|
||||
```bash
|
||||
python app/run.py
|
||||
```
|
||||
Сервер будет доступен по адресу: `http://localhost:5000`
|
||||
|
||||
## Ключевые возможности
|
||||
- **Двустороннее общение**: Агент может инициировать запросы к Obsidian (через API плагина).
|
||||
- **Инструменты (Tools)**: Поддержка вызова функций через MCP и кастомные инструменты.
|
||||
- **Память**: Сохранение состояния графов, что позволяет продолжать диалог с любого места.
|
||||
- **Голосовое управление**: Возможность диктовать команды напрямую в микрофон с транскрибацией в реальном времени.
|
||||
129
app/api.py
129
app/api.py
|
|
@ -12,6 +12,8 @@ from workflows import graph_history_manager, run_agent_streaming
|
|||
from llm_client import DEFAULT_TEMPERATURE, MODELS, get_llm # Добавляем импорт списка моделей
|
||||
import base64
|
||||
from mcp_tools import get_raw_mcp_tools_list
|
||||
import threading
|
||||
import uuid as uuid_lib
|
||||
|
||||
api = Flask(__name__)
|
||||
CORS(
|
||||
|
|
@ -423,7 +425,8 @@ def fetch_mcp_tools_route():
|
|||
return jsonify({"error": "Пустой конфиг сервера"}), 400
|
||||
|
||||
try:
|
||||
tools_list = get_raw_mcp_tools_list(server_config)
|
||||
vault_cwd = current_obsidian_settings.get("vaultAbsolutePath")
|
||||
tools_list = get_raw_mcp_tools_list(server_config, vault_cwd=vault_cwd)
|
||||
return jsonify(tools_list)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
|
@ -488,4 +491,126 @@ def sync_settings():
|
|||
graph_history_manager.title_generator.voice_llm = get_llm(current_obsidian_settings['voiceCommandModel'])
|
||||
|
||||
print(f"✅⚙️ Settings synced: {current_obsidian_settings}")
|
||||
return jsonify({"status": "synced"})
|
||||
return jsonify({"status": "synced"})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vault Query Bridge
|
||||
# Позволяет Python-скриптам читать/писать файлы vault через Obsidian.
|
||||
# Схема: POST /api/vault/query → socketio emit → Obsidian отвечает → возврат.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import threading
|
||||
import uuid as uuid_lib
|
||||
|
||||
# Хранилище ожидающих ответа запросов.
|
||||
# Ключ: request_id (str UUID)
|
||||
# Значение: (threading.Event, dict результата)
|
||||
_vault_query_pending: dict[str, tuple[threading.Event, dict]] = {}
|
||||
|
||||
|
||||
@api.route('/api/vault/query', methods=['POST'])
|
||||
def vault_query_endpoint():
|
||||
"""
|
||||
Проксирует запрос к Obsidian vault через WebSocket.
|
||||
|
||||
Принимает JSON:
|
||||
{
|
||||
"type": "get_frontmatter" | "get_all_factions" | ...,
|
||||
"payload": { ... параметры операции ... }
|
||||
}
|
||||
|
||||
Поддерживаемые типы (обрабатываются на стороне Obsidian VaultQueryHandler):
|
||||
get_frontmatter — YAML + тело одного файла
|
||||
get_all_factions — все файлы в папке (с фильтром по тегу)
|
||||
get_recent_files — последние N файлов (с фильтром по тегам)
|
||||
resolve_wikilink — wikilink → путь к файлу
|
||||
write_frontmatter — перезаписать YAML + тело файла
|
||||
create_file — создать или перезаписать файл
|
||||
|
||||
Блокируется до ответа Obsidian или до истечения timeout (10с).
|
||||
При timeout возвращает 504.
|
||||
"""
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({"error": "Пустое тело запроса"}), 400
|
||||
|
||||
query_type = data.get("type")
|
||||
payload = data.get("payload", {})
|
||||
|
||||
if not query_type:
|
||||
return jsonify({"error": "Поле 'type' обязательно"}), 400
|
||||
|
||||
# Генерируем уникальный ID для сопоставления запрос↔ответ
|
||||
request_id = str(uuid_lib.uuid4())
|
||||
|
||||
# Регистрируем ожидание ответа
|
||||
event = threading.Event()
|
||||
result_holder: dict = {}
|
||||
_vault_query_pending[request_id] = (event, result_holder)
|
||||
|
||||
try:
|
||||
# Отправляем запрос в Obsidian через WebSocket
|
||||
socketio.emit('vault_query_request', {
|
||||
"request_id": request_id,
|
||||
"type": query_type,
|
||||
"payload": payload
|
||||
})
|
||||
|
||||
# Блокируемся — ждём пока handle_vault_query_response вызовет event.set()
|
||||
answered = event.wait(timeout=10.0)
|
||||
|
||||
finally:
|
||||
# Убираем из pending независимо от результата
|
||||
_vault_query_pending.pop(request_id, None)
|
||||
|
||||
if not answered:
|
||||
return jsonify({
|
||||
"error": (
|
||||
"Timeout: Obsidian не ответил за 10 секунд. "
|
||||
"Проверьте что плагин LLM Agent запущен и подключён к WebSocket."
|
||||
)
|
||||
}), 504
|
||||
|
||||
if "error" in result_holder:
|
||||
return jsonify({"error": result_holder["error"]}), 500
|
||||
|
||||
return jsonify(result_holder.get("data"))
|
||||
|
||||
|
||||
@socketio.on('vault_query_response')
|
||||
def handle_vault_query_response(data):
|
||||
"""
|
||||
Получает ответ от Obsidian на vault-запрос.
|
||||
Будит заблокированный поток в vault_query_endpoint().
|
||||
|
||||
data ожидается вида:
|
||||
{ "request_id": "...", "result": <любые данные> }
|
||||
или
|
||||
{ "request_id": "...", "error": "описание ошибки" }
|
||||
"""
|
||||
if not isinstance(data, dict):
|
||||
print(f"⚠️ vault_query_response: неожиданный формат: {type(data)}")
|
||||
return
|
||||
|
||||
request_id = data.get("request_id")
|
||||
if not request_id:
|
||||
print("⚠️ vault_query_response: отсутствует request_id")
|
||||
return
|
||||
|
||||
pending = _vault_query_pending.get(request_id)
|
||||
if not pending:
|
||||
# Дубль после таймаута или успешной обработки — это нормально
|
||||
# если исправлен WebSocketService. До исправления — признак бага.
|
||||
print(f"⚠️ дубль vault_query_response [{request_id[:8]}] — игнорируем")
|
||||
return
|
||||
|
||||
event, result_holder = pending
|
||||
|
||||
if "error" in data:
|
||||
result_holder["error"] = data["error"]
|
||||
else:
|
||||
result_holder["data"] = data.get("result")
|
||||
print(f"✅ vault_query_response [{request_id[:8]}]: тип={type(result_holder['data']).__name__}")
|
||||
|
||||
event.set()
|
||||
# pending будет удалён в finally блока vault_query_endpoint
|
||||
|
|
@ -77,6 +77,18 @@ MODELS: Dict[str, Dict[str, Any]] = {
|
|||
"include_reasoning": True
|
||||
}
|
||||
},
|
||||
"gemini-3.0-flash-routerai": {
|
||||
"name": "google/gemini-3-flash-preview",
|
||||
"provider": "openai",
|
||||
"model_name": "google/gemini-3-flash-preview",
|
||||
"apiBase": "https://routerai.ru/api/v1",
|
||||
"apiKey": "sk-QXEteDDGCbAgfyXgSlGFdU7WXc7jCbwB",
|
||||
"stream": True,
|
||||
"capabilities": ["vision", "reasoning"],
|
||||
"model_kwargs": {
|
||||
"include_reasoning": True
|
||||
}
|
||||
},
|
||||
"gemini-3.1-pro-openrouter": {
|
||||
"name": "google/gemini-3.1-pro-preview",
|
||||
"provider": "openai",
|
||||
|
|
@ -144,6 +156,18 @@ MODELS: Dict[str, Dict[str, Any]] = {
|
|||
"include_reasoning": True
|
||||
}
|
||||
},
|
||||
"claude-sonnet-5-routerai": {
|
||||
"name": "anthropic/claude-sonnet-5",
|
||||
"provider": "openai",
|
||||
"model_name": "anthropic/claude-sonnet-5",
|
||||
"apiBase": "https://routerai.ru/api/v1",
|
||||
"apiKey": "sk-QXEteDDGCbAgfyXgSlGFdU7WXc7jCbwB",
|
||||
"stream": True,
|
||||
"capabilities": ["vision", "reasoning"],
|
||||
"model_kwargs": {
|
||||
"include_reasoning": True
|
||||
}
|
||||
},
|
||||
"deepseek-v4-flash-openrouter": {
|
||||
"name": "deepseek/deepseek-v4-flash",
|
||||
"provider": "openai",
|
||||
|
|
@ -156,6 +180,27 @@ MODELS: Dict[str, Dict[str, Any]] = {
|
|||
"model_kwargs": {
|
||||
"include_reasoning": True
|
||||
}
|
||||
},
|
||||
"deepseek-v4-flash-routerai": {
|
||||
"name": "deepseek/deepseek-chat",
|
||||
"provider": "openai",
|
||||
"model_name": "deepseek/deepseek-chat",
|
||||
"apiBase": "https://routerai.ru/api/v1",
|
||||
"apiKey": "sk-QXEteDDGCbAgfyXgSlGFdU7WXc7jCbwB",
|
||||
"stream": True,
|
||||
"capabilities": ["vision", "reasoning"],
|
||||
"model_kwargs": {
|
||||
"include_reasoning": True
|
||||
}
|
||||
},
|
||||
"deepseek-v3.2-exp-openrouter": {
|
||||
"name": "deepseek/deepseek-v3.2-exp",
|
||||
"provider": "openrouter",
|
||||
"model_name": "deepseek/deepseek-v3.2-exp",
|
||||
"apiBase": "https://openrouter.ai/api/v1",
|
||||
"apiKey": "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",
|
||||
"stream": True,
|
||||
"capabilities": ["chat", "reasoning", "code"]
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ def json_schema_to_pydantic(schema: dict, model_name: str) -> type[BaseModel]:
|
|||
|
||||
return create_model(model_name, **fields)
|
||||
|
||||
def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env, args, debug_callback=None):
|
||||
def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env, args, debug_callback=None, vault_cwd=None):
|
||||
"""Обертка, которая поднимает контейнер/процесс ровно на 1 вызов тула и убивает его"""
|
||||
args_schema = json_schema_to_pydantic(json_schema, f"MCP_{tool_name.replace('-','_')}_Schema")
|
||||
command = server_config.get("command")
|
||||
|
|
@ -48,7 +48,7 @@ def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env,
|
|||
input_json = json.dumps(kwargs, indent=2, ensure_ascii=False)
|
||||
|
||||
async def _run():
|
||||
server_params = StdioServerParameters(command=command, args=args, env=full_env)
|
||||
server_params = StdioServerParameters(command=command, args=args, env=full_env, cwd=vault_cwd)
|
||||
async with stdio_client(server_params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
|
|
@ -115,7 +115,7 @@ def create_mcp_tool(server_config, tool_name, tool_desc, json_schema, full_env,
|
|||
handle_tool_error=True # Разрешает агенту "выжить" после ошибки тула и сгенерировать ответ
|
||||
)
|
||||
|
||||
def fetch_mcp_tools(server_config, debug_callback=None):
|
||||
def fetch_mcp_tools(server_config, debug_callback=None, vault_cwd=None):
|
||||
"""Один раз читает список тулов от сервера и кеширует их схемы"""
|
||||
if not MCP_AVAILABLE:
|
||||
print("⚠️ MCP серверы настроены, но библиотека 'mcp' не установлена. Выполните: pip install mcp")
|
||||
|
|
@ -151,7 +151,7 @@ def fetch_mcp_tools(server_config, debug_callback=None):
|
|||
env_dict[k.strip()] = v.strip()
|
||||
|
||||
full_env = {**os.environ.copy(), **env_dict}
|
||||
server_params = StdioServerParameters(command=command, args=args, env=full_env)
|
||||
server_params = StdioServerParameters(command=command, args=args, env=full_env, cwd=vault_cwd)
|
||||
|
||||
async with stdio_client(server_params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
|
|
@ -180,7 +180,7 @@ def fetch_mcp_tools(server_config, debug_callback=None):
|
|||
else:
|
||||
final_description = t.description
|
||||
|
||||
wrapped_tools.append(create_mcp_tool(server_config, t.name, final_description, t.inputSchema, full_env, args, debug_callback))
|
||||
wrapped_tools.append(create_mcp_tool(server_config, t.name, final_description, t.inputSchema, full_env, args, debug_callback, vault_cwd))
|
||||
|
||||
_MCP_CACHED_TOOLS[config_hash] = wrapped_tools
|
||||
print(f"🔌 Успешно загружено {len(wrapped_tools)} инструментов от MCP-сервера '{server_config.get('name')}'")
|
||||
|
|
@ -189,7 +189,7 @@ def fetch_mcp_tools(server_config, debug_callback=None):
|
|||
print(f"❌ Ошибка инициализации MCP сервера {server_config.get('name')}: {e}")
|
||||
return []
|
||||
|
||||
def get_raw_mcp_tools_list(server_config):
|
||||
def get_raw_mcp_tools_list(server_config, vault_cwd=None):
|
||||
"""Метод для UI: просто возвращает список доступных инструментов в JSON-friendly формате"""
|
||||
if not MCP_AVAILABLE:
|
||||
return []
|
||||
|
|
@ -212,7 +212,7 @@ def get_raw_mcp_tools_list(server_config):
|
|||
env_dict[k.strip()] = v.strip()
|
||||
|
||||
full_env = {**os.environ.copy(), **env_dict}
|
||||
server_params = StdioServerParameters(command=command, args=args, env=full_env)
|
||||
server_params = StdioServerParameters(command=command, args=args, env=full_env, cwd=vault_cwd)
|
||||
|
||||
async with stdio_client(server_params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
|
|
|
|||
|
|
@ -55,6 +55,12 @@ def create_subprocess_tool(script_path: str, method_name: str,
|
|||
spec = importlib.util.spec_from_file_location("user_dynamic_tool", script_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
if not hasattr(module, method_name):
|
||||
error_msg = f"в файле '{script_path}' не найден метод '{method_name}'"
|
||||
print(f"❌ {error_msg}")
|
||||
raise AttributeError(error_msg)
|
||||
|
||||
func = getattr(module, method_name)
|
||||
docstring = inspect.getdoc(func) or f"Выполняет скрипт {method_name}"
|
||||
|
||||
|
|
@ -77,6 +83,12 @@ import sys
|
|||
import importlib.util
|
||||
import os
|
||||
|
||||
# ПРИНУДИТЕЛЬНО устанавливаем кодировку UTF-8 для потоков вывода и ошибок, чтобы могло выводить эмодзи, например, без ошибок
|
||||
if hasattr(sys.stdout, 'reconfigure'):
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
if hasattr(sys.stderr, 'reconfigure'):
|
||||
sys.stderr.reconfigure(encoding='utf-8')
|
||||
|
||||
# Добавляем папку со скриптом в sys.path, чтобы работали локальные импорты (например, norminv)
|
||||
path_to_add = os.path.dirname(os.path.abspath(r'{script_path}'))
|
||||
original_path = sys.path.copy()
|
||||
|
|
@ -174,30 +186,15 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
|
|||
for ct in custom_tools:
|
||||
name = ct.get("name")
|
||||
desc = ct.get("description")
|
||||
full_path = ct.get("scriptPath", "")
|
||||
# Поддерживаем оба ключа, если в JS/TS они не раскрылись
|
||||
hidden_prompt = ct.get("hiddenPromptExpanded") or ct.get("hiddenPrompt", "")
|
||||
|
||||
if not name:
|
||||
continue # Инструмент без имени создать нельзя
|
||||
|
||||
# Парсим путь к скрипту (учитываем Windows-пути с буквой диска)
|
||||
script_path = ""
|
||||
method_name = "main"
|
||||
|
||||
if full_path:
|
||||
parts = full_path.rsplit(":", 1) # Разделяем строку с конца 1 раз
|
||||
|
||||
if len(parts) == 2:
|
||||
# Если слева от разделения ровно 1 символ-буква (например, 'D'),
|
||||
# значит это путь без метода, а двоеточие — от диска Windows.
|
||||
if len(parts[0]) == 1 and parts[0].isalpha():
|
||||
script_path = full_path
|
||||
else:
|
||||
script_path = parts[0]
|
||||
method_name = parts[1]
|
||||
else:
|
||||
script_path = full_path
|
||||
# Достаем путь и метод из отдельных полей, присланных фронтендом
|
||||
script_path = ct.get("scriptPath", "")
|
||||
method_name = ct.get("methodName") or "main"
|
||||
|
||||
# ПРОВЕРКА: Есть ли физический скрипт?
|
||||
is_valid_script = script_path and os.path.exists(script_path)
|
||||
|
|
@ -250,6 +247,8 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
|
|||
)
|
||||
tools.append(help_tool)
|
||||
|
||||
vault_cwd = obsidian_settings.get("vaultAbsolutePath")
|
||||
|
||||
# Инициализация внутренних MCP серверов
|
||||
mcp_servers = obsidian_settings.get("mcpServers", [])
|
||||
for mcp_server in mcp_servers:
|
||||
|
|
@ -257,7 +256,7 @@ def get_dynamic_tools(obsidian_settings: dict, debug_callback=None):
|
|||
if not mcp_server.get("isEnabled", True):
|
||||
continue
|
||||
|
||||
mcp_tools = fetch_mcp_tools(mcp_server, debug_callback)
|
||||
mcp_tools = fetch_mcp_tools(mcp_server, debug_callback, vault_cwd=vault_cwd)
|
||||
tools.extend(mcp_tools)
|
||||
|
||||
return tools
|
||||
|
|
@ -316,12 +315,33 @@ def _generate_commands_list(custom_tools: list[dict[str, Any]]) -> str:
|
|||
if not custom_tools:
|
||||
return "В данный момент нет доступных дополнительных команд. Ты работаешь как обычный собеседник."
|
||||
|
||||
lines = ["Я могу использовать следующие инструменты/команды:\n"]
|
||||
tool_raw_list = ["Я могу использовать следующие инструменты/команды:\n"]
|
||||
for ct in custom_tools:
|
||||
name = ct.get("name")
|
||||
desc = ct.get("description", "Описание отсутствует")
|
||||
if name:
|
||||
lines.append(f"- **{name}**: {desc}")
|
||||
tool_raw_list.append(f"- **{name}**: {desc}")
|
||||
|
||||
lines.append("\nПерескажи этот список пользователю в удобном формате.")
|
||||
return "\n".join(lines)
|
||||
# Приаттачиваем инструкцию формата вывода (hidden prompt для системы)
|
||||
prompt = f"""
|
||||
СПИСОК ДОСТУПНЫХ ИНСТРУМЕНТОВ:
|
||||
{tool_raw_list}
|
||||
|
||||
ТВОЯ ЗАДАЧА:
|
||||
Сгруппируй инструменты по уровням абстракции и выведи их в формате Markdown-таблиц.
|
||||
|
||||
ИСПОЛЬЗУЙ СЛЕДУЮЩИЕ КАТЕГОРИИ:
|
||||
1. 🧩 [Микро-уровень]: Атомарные сущности, единичные объекты, конкретные детали или расчеты.
|
||||
2. 🏗️ [Мезо-уровень]: Процессы, сложные узлы, локальные системы, взаимодействия или окружение.
|
||||
3. 🗺️ [Макро-уровень]: Глобальные структуры, архитектура системы, высокоуровневая логика или долгосрочные стратегии.
|
||||
4. ⚙️ [Мета-уровень]: Управление сессией, диагностика, системная помощь, логирование или работа с контекстом.
|
||||
|
||||
ФОРМАТ ВЫВОДА:
|
||||
Для каждой категории создай заголовок и таблицу:
|
||||
| Команда | Функциональное назначение |
|
||||
|:---|:---|
|
||||
| **название_команды** | Суть работы (1 краткое предложение) |
|
||||
|
||||
Не добавляй никаких приветствий или пояснений вне блока кода. Будь предельно лаконичен.
|
||||
"""
|
||||
return prompt
|
||||
Loading…
Reference in New Issue
Block a user