feat: Add Ai Profiles
This commit is contained in:
@@ -1,25 +1,32 @@
|
||||
using GreenHome.AI.DeepSeek;
|
||||
using GreenHome.Application;
|
||||
using GreenHome.Domain;
|
||||
using GreenHome.Infrastructure.Services.AI;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace GreenHome.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// AI endpoints. Every request is routed through the active AI profile
|
||||
/// (see admin → AI Profiles); when no profile is active it falls back to the
|
||||
/// configured legacy DeepSeek service. Each query is logged with its tokens
|
||||
/// and estimated cost (CostUsd) for per-profile usage reporting.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AIController : ControllerBase
|
||||
{
|
||||
private readonly IDeepSeekService deepSeekService;
|
||||
private readonly IAIProfileService aiProfileService;
|
||||
private readonly IAIQueryService aiQueryService;
|
||||
private readonly ILogger<AIController> logger;
|
||||
|
||||
public AIController(
|
||||
IDeepSeekService deepSeekService,
|
||||
IAIProfileService aiProfileService,
|
||||
IAIQueryService aiQueryService,
|
||||
ILogger<AIController> logger)
|
||||
{
|
||||
this.deepSeekService = deepSeekService;
|
||||
this.aiProfileService = aiProfileService;
|
||||
this.aiQueryService = aiQueryService;
|
||||
this.logger = logger;
|
||||
}
|
||||
@@ -27,13 +34,11 @@ public class AIController : ControllerBase
|
||||
/// <summary>
|
||||
/// Send a simple question to the AI and get a response
|
||||
/// </summary>
|
||||
/// <param name="request">Question request</param>
|
||||
/// <returns>AI response</returns>
|
||||
[HttpPost("ask")]
|
||||
public async Task<IActionResult> AskQuestion([FromBody] SimpleQuestionRequest request)
|
||||
public async Task<IActionResult> AskQuestion([FromBody] SimpleQuestionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Question))
|
||||
@@ -43,7 +48,6 @@ public class AIController : ControllerBase
|
||||
|
||||
logger.LogInformation("Processing AI question: {Question}", request.Question);
|
||||
|
||||
// Build chat request to get token information
|
||||
var messages = new List<ChatMessage>();
|
||||
if (!string.IsNullOrWhiteSpace(request.SystemPrompt))
|
||||
{
|
||||
@@ -51,33 +55,19 @@ public class AIController : ControllerBase
|
||||
}
|
||||
messages.Add(new ChatMessage { Role = "user", Content = request.Question });
|
||||
|
||||
var chatRequest = new ChatRequest { Messages = messages };
|
||||
var chatResponse = await deepSeekService.AskAsync(chatRequest);
|
||||
var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (chatResponse == null || chatResponse.Choices == null || !chatResponse.Choices.Any())
|
||||
if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any())
|
||||
{
|
||||
return StatusCode(500, new { error = "No response received from AI" });
|
||||
}
|
||||
|
||||
var answer = chatResponse.Choices.FirstOrDefault()?.Message?.Content ?? "";
|
||||
var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? "";
|
||||
|
||||
// Save to database
|
||||
var aiQuery = new AIQuery
|
||||
{
|
||||
Question = request.Question,
|
||||
Answer = answer,
|
||||
DeviceId = request.DeviceId,
|
||||
UserId = request.UserId,
|
||||
Model = chatResponse.Model,
|
||||
PromptTokens = chatResponse.Usage?.PromptTokens ?? 0,
|
||||
CompletionTokens = chatResponse.Usage?.CompletionTokens ?? 0,
|
||||
TotalTokens = chatResponse.Usage?.TotalTokens ?? 0,
|
||||
ResponseTimeMs = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
await aiQueryService.SaveQueryAsync(aiQuery);
|
||||
var aiQuery = BuildQuery(result, request.Question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds);
|
||||
await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -90,6 +80,8 @@ public class AIController : ControllerBase
|
||||
completion = aiQuery.CompletionTokens,
|
||||
total = aiQuery.TotalTokens
|
||||
},
|
||||
costUsd = aiQuery.CostUsd,
|
||||
profileId = aiQuery.ProfileId,
|
||||
responseTimeMs = aiQuery.ResponseTimeMs,
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
@@ -104,13 +96,11 @@ public class AIController : ControllerBase
|
||||
/// <summary>
|
||||
/// Send a complex chat request with multiple messages to the AI
|
||||
/// </summary>
|
||||
/// <param name="request">Extended chat request with deviceId</param>
|
||||
/// <returns>AI chat response</returns>
|
||||
[HttpPost("chat")]
|
||||
public async Task<IActionResult> Chat([FromBody] ExtendedChatRequest request)
|
||||
public async Task<IActionResult> Chat([FromBody] ExtendedChatRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
if (request.Messages == null || !request.Messages.Any())
|
||||
@@ -120,46 +110,41 @@ public class AIController : ControllerBase
|
||||
|
||||
logger.LogInformation("Processing AI chat with {MessageCount} messages", request.Messages.Count);
|
||||
|
||||
var chatRequest = new ChatRequest
|
||||
{
|
||||
Messages = request.Messages,
|
||||
Model = request.Model,
|
||||
Temperature = request.Temperature,
|
||||
MaxTokens = request.MaxTokens
|
||||
};
|
||||
|
||||
var response = await deepSeekService.AskAsync(chatRequest);
|
||||
// ponytail: the active profile's model wins over request.Model — analysis is
|
||||
// always supposed to run on the configured profile; temperature/maxTokens still apply.
|
||||
var result = await aiProfileService.AskAsync(
|
||||
request.Messages,
|
||||
temperature: request.Temperature,
|
||||
maxTokens: request.MaxTokens,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (response == null || response.Choices == null || !response.Choices.Any())
|
||||
if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any())
|
||||
{
|
||||
return StatusCode(500, new { error = "No response received from AI" });
|
||||
}
|
||||
|
||||
// Extract question and answer
|
||||
var userMessage = request.Messages.LastOrDefault(m => m.Role == "user");
|
||||
var question = userMessage?.Content ?? "Complex chat";
|
||||
var answer = response.Choices.FirstOrDefault()?.Message?.Content ?? "";
|
||||
var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? "";
|
||||
|
||||
// Save to database
|
||||
var aiQuery = new AIQuery
|
||||
var aiQuery = BuildQuery(result, question, answer, request.DeviceId, request.UserId, request.Temperature, stopwatch.ElapsedMilliseconds);
|
||||
await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
|
||||
|
||||
var response = result.Response;
|
||||
return Ok(new
|
||||
{
|
||||
Question = question,
|
||||
Answer = answer,
|
||||
DeviceId = request.DeviceId,
|
||||
UserId = request.UserId,
|
||||
Model = response.Model,
|
||||
Temperature = request.Temperature,
|
||||
PromptTokens = response.Usage?.PromptTokens ?? 0,
|
||||
CompletionTokens = response.Usage?.CompletionTokens ?? 0,
|
||||
TotalTokens = response.Usage?.TotalTokens ?? 0,
|
||||
ResponseTimeMs = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
await aiQueryService.SaveQueryAsync(aiQuery);
|
||||
|
||||
return Ok(response);
|
||||
response.Id,
|
||||
response.Object,
|
||||
response.Created,
|
||||
response.Model,
|
||||
response.Choices,
|
||||
response.Usage,
|
||||
costUsd = aiQuery.CostUsd,
|
||||
profileId = aiQuery.ProfileId,
|
||||
responseTimeMs = aiQuery.ResponseTimeMs
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -171,13 +156,11 @@ public class AIController : ControllerBase
|
||||
/// <summary>
|
||||
/// Get suggestions for smart home automation based on device data
|
||||
/// </summary>
|
||||
/// <param name="request">Device context request</param>
|
||||
/// <returns>AI suggestions</returns>
|
||||
[HttpPost("suggest")]
|
||||
public async Task<IActionResult> GetSuggestions([FromBody] SuggestionRequest request)
|
||||
public async Task<IActionResult> GetSuggestions([FromBody] SuggestionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
var systemPrompt = @"شما یک مشاور هوشمند خانه هوشمند هستید. بر اساس دادههای دستگاههای IoT، پیشنهادهای عملی و مفید برای بهینهسازی مصرف انرژی، راحتی و امنیت ارائه دهید. پاسخ را به زبان فارسی و به صورت خلاصه و کاربردی بنویسید.";
|
||||
@@ -193,33 +176,19 @@ public class AIController : ControllerBase
|
||||
new ChatMessage { Role = "user", Content = question }
|
||||
};
|
||||
|
||||
var chatRequest = new ChatRequest { Messages = messages };
|
||||
var chatResponse = await deepSeekService.AskAsync(chatRequest);
|
||||
var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (chatResponse == null || chatResponse.Choices == null || !chatResponse.Choices.Any())
|
||||
if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any())
|
||||
{
|
||||
return StatusCode(500, new { error = "No suggestions received from AI" });
|
||||
}
|
||||
|
||||
var answer = chatResponse.Choices.FirstOrDefault()?.Message?.Content ?? "";
|
||||
var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? "";
|
||||
|
||||
// Save to database
|
||||
var aiQuery = new AIQuery
|
||||
{
|
||||
Question = question,
|
||||
Answer = answer,
|
||||
DeviceId = request.DeviceId,
|
||||
UserId = request.UserId,
|
||||
Model = chatResponse.Model,
|
||||
PromptTokens = chatResponse.Usage?.PromptTokens ?? 0,
|
||||
CompletionTokens = chatResponse.Usage?.CompletionTokens ?? 0,
|
||||
TotalTokens = chatResponse.Usage?.TotalTokens ?? 0,
|
||||
ResponseTimeMs = stopwatch.ElapsedMilliseconds
|
||||
};
|
||||
|
||||
await aiQueryService.SaveQueryAsync(aiQuery);
|
||||
var aiQuery = BuildQuery(result, question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds);
|
||||
await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -231,6 +200,8 @@ public class AIController : ControllerBase
|
||||
completion = aiQuery.CompletionTokens,
|
||||
total = aiQuery.TotalTokens
|
||||
},
|
||||
costUsd = aiQuery.CostUsd,
|
||||
profileId = aiQuery.ProfileId,
|
||||
responseTimeMs = aiQuery.ResponseTimeMs,
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
@@ -246,12 +217,12 @@ public class AIController : ControllerBase
|
||||
/// Get AI query history for a device
|
||||
/// </summary>
|
||||
[HttpGet("history/device/{deviceId}")]
|
||||
public async Task<IActionResult> GetDeviceHistory(int deviceId, [FromQuery] int take = 50)
|
||||
public async Task<IActionResult> GetDeviceHistory(int deviceId, [FromQuery] int take = 50, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var queries = await aiQueryService.GetDeviceQueriesAsync(deviceId, take);
|
||||
var totalTokens = await aiQueryService.GetDeviceTotalTokensAsync(deviceId);
|
||||
var queries = await aiQueryService.GetDeviceQueriesAsync(deviceId, take, cancellationToken);
|
||||
var totalTokens = await aiQueryService.GetDeviceTotalTokensAsync(deviceId, cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
@@ -263,7 +234,9 @@ public class AIController : ControllerBase
|
||||
q.TotalTokens,
|
||||
q.PromptTokens,
|
||||
q.CompletionTokens,
|
||||
q.CostUsd,
|
||||
q.Model,
|
||||
q.ProfileId,
|
||||
q.ResponseTimeMs,
|
||||
q.CreatedAt
|
||||
}),
|
||||
@@ -281,11 +254,11 @@ public class AIController : ControllerBase
|
||||
/// Get AI query statistics
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<IActionResult> GetStats()
|
||||
public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var stats = await aiQueryService.GetStatsAsync();
|
||||
var stats = await aiQueryService.GetStatsAsync(cancellationToken);
|
||||
return Ok(stats);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -294,6 +267,42 @@ public class AIController : ControllerBase
|
||||
return StatusCode(500, new { error = "An error occurred" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an AIQuery row from a profile ask result, attaching profile,
|
||||
/// tokens and the estimated cost.
|
||||
/// </summary>
|
||||
private static AIQuery BuildQuery(
|
||||
AIAskResult result,
|
||||
string question,
|
||||
string answer,
|
||||
int? deviceId,
|
||||
int? userId,
|
||||
double? temperature,
|
||||
long responseTimeMs)
|
||||
{
|
||||
var profile = result.Profile;
|
||||
var usage = result.Response!.Usage;
|
||||
|
||||
var query = new AIQuery
|
||||
{
|
||||
Question = question,
|
||||
Answer = answer,
|
||||
DeviceId = deviceId,
|
||||
UserId = userId,
|
||||
Model = result.Response.Model,
|
||||
ProfileId = profile?.Id,
|
||||
CostUsd = profile != null
|
||||
? IAIProfileService.EstimateCost(profile.PromptPricePer1M, profile.CompletionPricePer1M, usage?.PromptTokens ?? 0, usage?.CompletionTokens ?? 0)
|
||||
: 0m,
|
||||
Temperature = temperature,
|
||||
PromptTokens = usage?.PromptTokens ?? 0,
|
||||
CompletionTokens = usage?.CompletionTokens ?? 0,
|
||||
TotalTokens = usage?.TotalTokens ?? 0,
|
||||
ResponseTimeMs = responseTimeMs
|
||||
};
|
||||
return query;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -301,24 +310,9 @@ public class AIController : ControllerBase
|
||||
/// </summary>
|
||||
public class SimpleQuestionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// The question to ask the AI
|
||||
/// </summary>
|
||||
public required string Question { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional system prompt to set context for the AI
|
||||
/// </summary>
|
||||
public string? SystemPrompt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional device ID to associate with this query
|
||||
/// </summary>
|
||||
public int? DeviceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional user ID to associate with this query
|
||||
/// </summary>
|
||||
public int? UserId { get; set; }
|
||||
}
|
||||
|
||||
@@ -328,7 +322,7 @@ public class SimpleQuestionRequest
|
||||
public class ExtendedChatRequest
|
||||
{
|
||||
public required List<ChatMessage> Messages { get; set; }
|
||||
public string Model { get; set; } = "deepseek-chat";
|
||||
public string? Model { get; set; }
|
||||
public double? Temperature { get; set; }
|
||||
public int? MaxTokens { get; set; }
|
||||
public int? DeviceId { get; set; }
|
||||
@@ -340,19 +334,7 @@ public class ExtendedChatRequest
|
||||
/// </summary>
|
||||
public class SuggestionRequest
|
||||
{
|
||||
/// <summary>
|
||||
/// Context about devices and their current state
|
||||
/// </summary>
|
||||
public required string DeviceContext { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Device ID for this suggestion request
|
||||
/// </summary>
|
||||
public int? DeviceId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// User ID for this suggestion request
|
||||
/// </summary>
|
||||
public int? UserId { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using GreenHome.Api.Filters;
|
||||
using GreenHome.Application;
|
||||
using GreenHome.Application.Dtos.Admin;
|
||||
using GreenHome.Domain;
|
||||
using GreenHome.Infrastructure.Services.AI;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace GreenHome.Api.Controllers;
|
||||
@@ -14,10 +15,12 @@ namespace GreenHome.Api.Controllers;
|
||||
public sealed class AdminController : ControllerBase
|
||||
{
|
||||
private readonly IAdminService _adminService;
|
||||
private readonly IAIProfileService _aiProfileService;
|
||||
|
||||
public AdminController(IAdminService adminService)
|
||||
public AdminController(IAdminService adminService, IAIProfileService aiProfileService)
|
||||
{
|
||||
_adminService = adminService;
|
||||
_aiProfileService = aiProfileService;
|
||||
}
|
||||
|
||||
// ==================== Dashboard ====================
|
||||
@@ -338,4 +341,116 @@ public sealed class AdminController : ControllerBase
|
||||
var result = await _adminService.GetAuditLogsAsync(filter, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ==================== AI Profiles ====================
|
||||
|
||||
[HttpGet("ai-profiles")]
|
||||
public async Task<ActionResult<List<AIProfileDto>>> AiProfiles(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await _aiProfileService.GetProfilesAsync(cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("ai-profiles")]
|
||||
public async Task<IActionResult> CreateAiProfile([FromBody] AIProfileCreateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||
try
|
||||
{
|
||||
var result = await _aiProfileService.CreateProfileAsync(request, currentAdminId, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("ai-profiles/{id:int}")]
|
||||
public async Task<IActionResult> UpdateAiProfile(int id, [FromBody] AIProfileUpdateRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||
try
|
||||
{
|
||||
var result = await _aiProfileService.UpdateProfileAsync(id, request, currentAdminId, cancellationToken);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("ai-profiles/{id:int}")]
|
||||
public async Task<IActionResult> DeleteAiProfile(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||
var success = await _aiProfileService.DeleteProfileAsync(id, currentAdminId, cancellationToken);
|
||||
return success ? Ok(new { success = true }) : NotFound();
|
||||
}
|
||||
|
||||
[HttpPost("ai-profiles/{id:int}/activate")]
|
||||
public async Task<IActionResult> ActivateAiProfile(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var currentAdminId = ((UserDto)HttpContext.Items["AdminUser"]!).Id;
|
||||
try
|
||||
{
|
||||
var result = await _aiProfileService.ActivateProfileAsync(id, currentAdminId, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("ai-profiles/{id:int}/models")]
|
||||
public async Task<IActionResult> GetAiProfileModels(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var models = await _aiProfileService.GetModelsAsync(id, cancellationToken);
|
||||
return Ok(models);
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return StatusCode(502, new { message = "خطا در دریافت لیست مدلها از سرویس" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("ai-profiles/{id:int}/refresh-balance")]
|
||||
public async Task<IActionResult> RefreshAiProfileBalance(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var balance = await _aiProfileService.RefreshBalanceAsync(id, cancellationToken);
|
||||
return Ok(balance is null
|
||||
? new { success = false, message = "دریافت موجودی برای این سرویس در دسترس نیست" }
|
||||
: new { success = true, balance });
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return StatusCode(502, new { message = "خطا در دریافت موجودی از سرویس" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("ai-profiles/{id:int}/usage/daily")]
|
||||
public async Task<ActionResult<List<AIProfileUsageDayDto>>> GetAiProfileUsage(
|
||||
int id,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var (f, t) = ResolveReportRange(from, to);
|
||||
var result = await _aiProfileService.GetUsageByDayAsync(id, f, t.AddDays(1), cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user