Compare commits
8 Commits
b54676ed8e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d6b68929ca | |||
| d1e9a8e934 | |||
| 2cf553dfc9 | |||
| 6b77bab275 | |||
| d909f00f64 | |||
| 5cdf47fca8 | |||
| 88ca4aecd2 | |||
| fc06456db2 |
@@ -1 +1,5 @@
|
||||
/target
|
||||
config.toml
|
||||
chat_history.db
|
||||
chat_history.db-shm
|
||||
chat_history.db-wal
|
||||
Generated
+1155
-17
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -13,6 +13,10 @@ reqwest = { version = "0.12", features = ["json"] }
|
||||
# Сериализация / Десериализация JSON
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
toml = "0.8"
|
||||
|
||||
# Парсинг аргументов CLI (флаги --session-id и т.д.)
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
|
||||
# Асинхронная работа с SQLite
|
||||
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "sqlite"] }
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
Как запускать и протестировать:
|
||||
Запуск с дефолтными параметрами:
|
||||
|
||||
Bash
|
||||
cargo run
|
||||
Запуск с указанием конкретной сессии и своего URL (например, vLLM / llama.cpp / Ollama):
|
||||
|
||||
Bash
|
||||
cargo run -- --session-id 12345 --api-url "http://localhost:8000/v1/chat/completions" --model "qwen2.5-coder"
|
||||
Справка по доступным флагам:
|
||||
|
||||
Bash
|
||||
cargo run -- --help
|
||||
@@ -0,0 +1,42 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppConfig {
|
||||
pub api_url: String,
|
||||
pub model: String,
|
||||
pub max_history_messages: usize, // сколько последних сообщений подтягивать из БД
|
||||
pub system_prompt: Option<String>, // задел на системный промпт
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
api_url: "http://192.168.0.50:6969/v1/chat/completions".to_string(),
|
||||
model: "qwen2.5-coder".to_string(),
|
||||
max_history_messages: 10,
|
||||
system_prompt: Some("You are a helpful programming assistant.".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
pub fn load_or_create<P: AsRef<Path>>(path: P) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let path = path.as_ref();
|
||||
|
||||
if path.exists() {
|
||||
// Если файл есть — читаем
|
||||
let content = fs::read_to_string(path)?;
|
||||
let config: AppConfig = toml::from_str(&content)?;
|
||||
Ok(config)
|
||||
} else {
|
||||
// Если файла нет — создаём дефолтный
|
||||
let default_config = AppConfig::default();
|
||||
let toml_string = toml::to_string_pretty(&default_config)?;
|
||||
fs::write(path, toml_string)?;
|
||||
println!("[Config]: Создан стандартный файл конфигурации {:?}", path);
|
||||
Ok(default_config)
|
||||
}
|
||||
}
|
||||
}
|
||||
+397
-37
@@ -1,44 +1,293 @@
|
||||
mod config;
|
||||
use config::AppConfig;
|
||||
|
||||
use clap::Parser;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use sqlx::{Pool, Sqlite};
|
||||
use std::io::{self, Write};
|
||||
use std::str::FromStr;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
|
||||
/// CLI-утилита для взаимодействия с LLM в рамках указанной сессии
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Идентификатор сессии/беседы (на будущее для VK peer_id)
|
||||
#[arg(short, long, default_value = "default_session")]
|
||||
session_id: String,
|
||||
#[arg(short, long)]
|
||||
session_id: Option<String>,
|
||||
|
||||
/// URL OpenAI-совместимого эндпоинта (vLLM / llama.cpp / Ollama)
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
default_value = "http://localhost:11434/v1/chat/completions"
|
||||
)]
|
||||
api_url: String,
|
||||
#[arg(long)]
|
||||
api_url: Option<String>,
|
||||
|
||||
/// Название модели (для Ollama/vLLM)
|
||||
#[arg(m_flag, short, long, default_value = "qwen2.5-coder")]
|
||||
model: String,
|
||||
#[arg(short, long)]
|
||||
model: Option<String>,
|
||||
|
||||
#[arg(long)]
|
||||
limit: Option<usize>,
|
||||
|
||||
#[arg(short, long, default_value = "config.toml")]
|
||||
config: String,
|
||||
|
||||
#[arg(long, default_value = "sqlite:chat_history.db?mode=rwc")]
|
||||
db_url: String,
|
||||
}
|
||||
|
||||
/// Функция отправки Stateless-запроса к LLM
|
||||
async fn send_llm_request(
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
struct ChatMessage {
|
||||
role: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
/// Инициализация БД (режим WAL)
|
||||
async fn init_db(db_url: &str) -> Result<Pool<Sqlite>, Box<dyn std::error::Error>> {
|
||||
let options = SqliteConnectOptions::from_str(db_url)?
|
||||
.create_if_missing(true)
|
||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
|
||||
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(10)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_id_id
|
||||
ON messages (session_id, id DESC);
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS session_summaries (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
summary TEXT NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
async fn clear_session_data(pool: &Pool<Sqlite>, session_id: &str) -> Result<(), sqlx::Error> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Удаляем сообщения сессии
|
||||
sqlx::query("DELETE FROM messages WHERE session_id = ?")
|
||||
.bind(session_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Удаляем сжатую память (summary)
|
||||
sqlx::query("DELETE FROM session_summaries WHERE session_id = ?")
|
||||
.bind(session_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn summarize_context(
|
||||
client: &reqwest::Client,
|
||||
api_url: &str,
|
||||
model: &str,
|
||||
user_message: &str,
|
||||
current_summary: Option<&str>,
|
||||
messages_to_summarize: &[ChatMessage],
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut prompt = String::new();
|
||||
|
||||
if let Some(old_sum) = current_summary {
|
||||
prompt.push_str(&format!(
|
||||
"УЖЕ ИЗВЕСТНАЯ ПАМЯТЬ О ПОЛЬЗОВАТЕЛЕ И ЗАДАЧАХ:\n{}\n\n",
|
||||
old_sum
|
||||
));
|
||||
}
|
||||
|
||||
prompt.push_str("НОВЫЕ СООБЩЕНИЯ ДИАЛОГА:\n");
|
||||
for msg in messages_to_summarize {
|
||||
if msg.role == "user" {
|
||||
prompt.push_str(&format!("User: {}\n", msg.content));
|
||||
}
|
||||
}
|
||||
|
||||
prompt.push_str(
|
||||
r#"
|
||||
ИНСТРУКЦИЯ ПО ОБНОВЛЕНИЮ ПАМЯТИ:
|
||||
Проанализируй диалог и обнови накопительный профиль памяти.
|
||||
|
||||
ЧТО НУЖНО СОХРАНЯТЬ (ТОЛЬКО ИЗ СООБЩЕНИЙ ПОЛЬЗОВАТЕЛЯ / USER):
|
||||
1. Личные данные пользователя (имя, возраст, профессия, хобби, предпочтения, ограничения).
|
||||
2. Контекст задачи или темы (над чем работаем, текущие цели, формат ответа, который просит пользователь).
|
||||
3. Любые точные вводные данные (числа, имена, даты, пароли/коды, списки, специфические термины).
|
||||
|
||||
КРИТИЧЕСКИ ВАЖНЫЕ ПРАВИЛА:
|
||||
- ИГНОРИРУЙ справочные ответы Ассистента (Assistant). Не сохраняй в память факты, стихи, статьи или ответы на энциклопедические вопросы, которые сгенерировал Ассистент.
|
||||
- Сохраняй ТОЛЬКО то, что сообщил сам Пользователь, либо явные решения/итоги, к которым пришли.
|
||||
- Пиши максимально кратко, сжато, в виде маркированного списка.
|
||||
- Если новые сообщения не содержат новых фактов о пользователе или его задаче — оставь существующую память без изменений.
|
||||
|
||||
НЕ ЗАПИСЫВАЙ:
|
||||
О чем пользователь спрашивал или интересовался (например: НЕ пиши "Спрашивал про точки Лагранжа" или "Интересуется столицами"). Записывай только утвердительные факты о самом пользователе или данные.
|
||||
"#,
|
||||
);
|
||||
|
||||
let payload = json!({
|
||||
"model": model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": user_message
|
||||
}
|
||||
"role": "system",
|
||||
"content": "Ты — автономный модуль памяти ассистента. Твоя единственная цель — вытаскивать и структурировать личные данные, контекст задач и явные указания ПОЛЬЗОВАТЕЛЯ. Ты не ответишь на вопросы из чата, ты только ведешь блокнот фактов."
|
||||
},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.0
|
||||
});
|
||||
|
||||
let response = client.post(api_url).json(&payload).send().await?;
|
||||
let res_body: serde_json::Value = response.json().await?;
|
||||
|
||||
if let Some(summary) = res_body["choices"][0]["message"]["content"].as_str() {
|
||||
Ok(summary.to_string())
|
||||
} else {
|
||||
Err("Не удалось сгенерировать резюме".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Сохранение реплики
|
||||
async fn save_message(
|
||||
pool: &Pool<Sqlite>,
|
||||
session_id: &str,
|
||||
role: &str,
|
||||
content: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query("INSERT INTO messages (session_id, role, content) VALUES (?, ?, ?)")
|
||||
.bind(session_id)
|
||||
.bind(role)
|
||||
.bind(content)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Сохранение или обновление summary для сессии (UPSERT)
|
||||
async fn save_session_summary(
|
||||
pool: &Pool<Sqlite>,
|
||||
session_id: &str,
|
||||
summary: &str,
|
||||
) -> Result<(), sqlx::Error> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO session_summaries (session_id, summary, updated_at)
|
||||
VALUES (?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
summary = excluded.summary,
|
||||
updated_at = CURRENT_TIMESTAMP;
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(summary)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Выборка последних N сообщений
|
||||
async fn get_recent_history(
|
||||
pool: &Pool<Sqlite>,
|
||||
session_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ChatMessage>, sqlx::Error> {
|
||||
let rows = sqlx::query_as::<_, (String, String)>(
|
||||
r#"
|
||||
SELECT role, content
|
||||
FROM (
|
||||
SELECT role, content, id
|
||||
FROM messages
|
||||
WHERE session_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
)
|
||||
ORDER BY id ASC;
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let history = rows
|
||||
.into_iter()
|
||||
.map(|(role, content)| ChatMessage { role, content })
|
||||
.collect();
|
||||
|
||||
Ok(history)
|
||||
}
|
||||
|
||||
async fn get_session_summary(
|
||||
pool: &Pool<Sqlite>,
|
||||
session_id: &str,
|
||||
) -> Result<Option<String>, sqlx::Error> {
|
||||
let result = sqlx::query_as::<_, (String,)>(
|
||||
"SELECT summary FROM session_summaries WHERE session_id = ?",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|(s,)| s))
|
||||
}
|
||||
|
||||
/// Отправка запроса с контекстом в LLM
|
||||
async fn send_llm_request_with_history(
|
||||
client: &reqwest::Client,
|
||||
api_url: &str,
|
||||
model: &str,
|
||||
system_prompt: Option<&str>,
|
||||
summary: Option<&str>,
|
||||
history: &[ChatMessage],
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let mut full_messages = Vec::new();
|
||||
|
||||
// 1. Системный промпт
|
||||
if let Some(prompt) = system_prompt {
|
||||
if !prompt.trim().is_empty() {
|
||||
full_messages.push(ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: prompt.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Долгосрочная сжатая память (если есть)
|
||||
if let Some(sum) = summary {
|
||||
if !sum.trim().is_empty() {
|
||||
full_messages.push(ChatMessage {
|
||||
role: "system".to_string(),
|
||||
content: format!("[Краткий контекст прошлых сообщений]: {}", sum),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Последние N реплик из истории
|
||||
full_messages.extend_from_slice(history);
|
||||
|
||||
let payload = json!({
|
||||
"model": model,
|
||||
"messages": full_messages,
|
||||
"temperature": 0.7
|
||||
});
|
||||
|
||||
@@ -60,30 +309,47 @@ async fn send_llm_request(
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args = Args::parse();
|
||||
|
||||
// 1. Загружаем или создаём config.toml
|
||||
let file_config = AppConfig::load_or_create(&args.config)?;
|
||||
|
||||
// 2. Объединяем аргументы CLI и значения из конфига
|
||||
let api_url = args.api_url.unwrap_or(file_config.api_url);
|
||||
let model = args.model.unwrap_or(file_config.model);
|
||||
let history_limit = args.limit.unwrap_or(file_config.max_history_messages);
|
||||
let session_id = args
|
||||
.session_id
|
||||
.unwrap_or_else(|| "default_session".to_string());
|
||||
|
||||
// 3. Клиент HTTP и подключение к БД
|
||||
let client = reqwest::Client::new();
|
||||
let db_pool = init_db(&args.db_url).await?;
|
||||
let system_prompt = file_config.system_prompt;
|
||||
|
||||
println!("==================================================");
|
||||
println!(" LLM CLI Agent (Stage 1)");
|
||||
println!(" Session ID: {}", args.session_id);
|
||||
println!(" API URL: {}", args.api_url);
|
||||
println!(" Model: {}", args.model);
|
||||
println!(" LLM CLI Agent with Memory & Config");
|
||||
println!(" Session ID: {}", session_id);
|
||||
println!(" API URL: {}", api_url);
|
||||
println!(" Model: {}", model);
|
||||
println!(" History: последние {} сообщений", history_limit);
|
||||
println!(" Config: {}", args.config);
|
||||
println!("==================================================");
|
||||
println!("Введите сообщение и нажмите Enter. Для выхода наберите 'exit' или 'quit'.\n");
|
||||
println!("Введите сообщение. Служебные команды: /summary, /flush, /help, /exit\n");
|
||||
|
||||
let stdin = tokio::io::stdin();
|
||||
let mut reader = BufReader::new(stdin);
|
||||
|
||||
loop {
|
||||
print!("[Session: {}] > ", args.session_id);
|
||||
print!("[Session: {}] > ", session_id);
|
||||
io::stdout().flush().unwrap();
|
||||
|
||||
let mut input = String::new();
|
||||
let bytes_read = reader.read_line(&mut input).await.unwrap_or(0);
|
||||
|
||||
if bytes_read == 0 {
|
||||
break; // EOF (Ctrl+D)
|
||||
break;
|
||||
}
|
||||
|
||||
let trimmed_input = input.trim();
|
||||
@@ -92,23 +358,117 @@ async fn main() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if trimmed_input.eq_ignore_ascii_case("exit")
|
||||
|| trimmed_input.eq_ignore_ascii_case("quit")
|
||||
{
|
||||
println!("Завершение работы.");
|
||||
break;
|
||||
// Обработка встроенных команд
|
||||
if trimmed_input.starts_with('/') {
|
||||
match trimmed_input {
|
||||
"/flush" => {
|
||||
if let Err(e) = clear_session_data(&db_pool, &session_id).await {
|
||||
println!("[System]: Ошибка при очистке базы: {}", e);
|
||||
} else {
|
||||
println!("[System]: История диалога и память сессии успешно очищены.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
"/summary" => {
|
||||
match get_session_summary(&db_pool, &session_id).await {
|
||||
Ok(Some(summary)) => println!(
|
||||
"\n--- ТЕКУЩАЯ ПАМЯТЬ СЕССИИ ---\n{}\n-----------------------------",
|
||||
summary
|
||||
),
|
||||
Ok(None) => println!("[System]: Память сессии пока пуста."),
|
||||
Err(e) => println!("[System]: Ошибка при получении summary: {}", e),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
"/help" => {
|
||||
println!("\n--- ДОСТУПНЫЕ КОМАНДЫ ---");
|
||||
println!(" /help - Показать это справочное сообщение");
|
||||
println!(" /summary - Посмотреть текущую сжатую память (профиль)");
|
||||
println!(" /flush - Полностью очистить историю и память сессии");
|
||||
println!(" /exit - Завершить работу агента");
|
||||
println!(" /quit - Завершить работу агента");
|
||||
println!("-------------------------\n");
|
||||
continue;
|
||||
}
|
||||
"/exit" | "/quit" => {
|
||||
println!("[System]: Завершение сеанса. До связи!");
|
||||
break;
|
||||
}
|
||||
_ => {
|
||||
println!("[System]: Неизвестная команда. Введите /help для списка команд.");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print!("[Ожидание ответа от LLM...]\r");
|
||||
// 1. Сохраняем запрос юзера в историю
|
||||
if let Err(e) = save_message(&db_pool, &session_id, "user", trimmed_input).await {
|
||||
eprintln!("[Ошибка записи в БД]: {}", e);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Вычитываем историю и существующий summary
|
||||
let history = match get_recent_history(&db_pool, &session_id, history_limit).await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
eprintln!("[Ошибка чтения из БД]: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut summary = match get_session_summary(&db_pool, &session_id).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("[Ошибка чтения summary из БД]: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Авто-суммаризация: сжимаем контекст при накоплении >= 6 сообщений
|
||||
if history.len() >= 6 {
|
||||
print!("[Сжатие контекста в фоновую память...]\r");
|
||||
io::stdout().flush().unwrap();
|
||||
|
||||
if let Ok(new_summary) =
|
||||
summarize_context(&client, &api_url, &model, summary.as_deref(), &history).await
|
||||
{
|
||||
if let Err(e) = save_session_summary(&db_pool, &session_id, &new_summary).await {
|
||||
eprintln!("[Ошибка сохранения summary]: {}", e);
|
||||
} else {
|
||||
summary = Some(new_summary);
|
||||
}
|
||||
}
|
||||
print!("\r\x1B[2K");
|
||||
}
|
||||
|
||||
// 4. Отправляем запрос в LLM
|
||||
print!("[Запрос к LLM (контекст {} сообщ.)...]\r", history.len());
|
||||
io::stdout().flush().unwrap();
|
||||
|
||||
match send_llm_request(&client, &args.api_url, &args.model, trimmed_input).await {
|
||||
match send_llm_request_with_history(
|
||||
&client,
|
||||
&api_url,
|
||||
&model,
|
||||
system_prompt.as_deref(),
|
||||
summary.as_deref(),
|
||||
&history,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reply) => {
|
||||
println!("\r[LLM]: {}\n", reply);
|
||||
print!("\r\x1B[2K");
|
||||
println!("[LLM]: {}\n", reply);
|
||||
|
||||
if let Err(e) = save_message(&db_pool, &session_id, "assistant", &reply).await {
|
||||
eprintln!("[Ошибка записи ответа в БД]: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("\r[Ошибка]: {}\n", e);
|
||||
print!("\r\x1B[2K");
|
||||
eprintln!("[Ошибка LLM]: {}\n", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user