feat: Add Ai Profiles

This commit is contained in:
2026-09-26 20:26:39 +03:30
parent 4e8b14906f
commit 34f820b3ca
19 changed files with 3548 additions and 144 deletions

View File

@@ -1,25 +1,32 @@
using GreenHome.AI.DeepSeek; using GreenHome.AI.DeepSeek;
using GreenHome.Application; using GreenHome.Application;
using GreenHome.Domain; using GreenHome.Domain;
using GreenHome.Infrastructure.Services.AI;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using System.Diagnostics; using System.Diagnostics;
namespace GreenHome.Api.Controllers; 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] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class AIController : ControllerBase public class AIController : ControllerBase
{ {
private readonly IDeepSeekService deepSeekService; private readonly IAIProfileService aiProfileService;
private readonly IAIQueryService aiQueryService; private readonly IAIQueryService aiQueryService;
private readonly ILogger<AIController> logger; private readonly ILogger<AIController> logger;
public AIController( public AIController(
IDeepSeekService deepSeekService, IAIProfileService aiProfileService,
IAIQueryService aiQueryService, IAIQueryService aiQueryService,
ILogger<AIController> logger) ILogger<AIController> logger)
{ {
this.deepSeekService = deepSeekService; this.aiProfileService = aiProfileService;
this.aiQueryService = aiQueryService; this.aiQueryService = aiQueryService;
this.logger = logger; this.logger = logger;
} }
@@ -27,10 +34,8 @@ public class AIController : ControllerBase
/// <summary> /// <summary>
/// Send a simple question to the AI and get a response /// Send a simple question to the AI and get a response
/// </summary> /// </summary>
/// <param name="request">Question request</param>
/// <returns>AI response</returns>
[HttpPost("ask")] [HttpPost("ask")]
public async Task<IActionResult> AskQuestion([FromBody] SimpleQuestionRequest request) public async Task<IActionResult> AskQuestion([FromBody] SimpleQuestionRequest request, CancellationToken cancellationToken)
{ {
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
@@ -43,7 +48,6 @@ public class AIController : ControllerBase
logger.LogInformation("Processing AI question: {Question}", request.Question); logger.LogInformation("Processing AI question: {Question}", request.Question);
// Build chat request to get token information
var messages = new List<ChatMessage>(); var messages = new List<ChatMessage>();
if (!string.IsNullOrWhiteSpace(request.SystemPrompt)) if (!string.IsNullOrWhiteSpace(request.SystemPrompt))
{ {
@@ -51,33 +55,19 @@ public class AIController : ControllerBase
} }
messages.Add(new ChatMessage { Role = "user", Content = request.Question }); messages.Add(new ChatMessage { Role = "user", Content = request.Question });
var chatRequest = new ChatRequest { Messages = messages }; var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken);
var chatResponse = await deepSeekService.AskAsync(chatRequest);
stopwatch.Stop(); 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" }); 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 = BuildQuery(result, request.Question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds);
var aiQuery = new AIQuery await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
{
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);
return Ok(new return Ok(new
{ {
@@ -90,6 +80,8 @@ public class AIController : ControllerBase
completion = aiQuery.CompletionTokens, completion = aiQuery.CompletionTokens,
total = aiQuery.TotalTokens total = aiQuery.TotalTokens
}, },
costUsd = aiQuery.CostUsd,
profileId = aiQuery.ProfileId,
responseTimeMs = aiQuery.ResponseTimeMs, responseTimeMs = aiQuery.ResponseTimeMs,
timestamp = DateTime.UtcNow timestamp = DateTime.UtcNow
}); });
@@ -104,10 +96,8 @@ public class AIController : ControllerBase
/// <summary> /// <summary>
/// Send a complex chat request with multiple messages to the AI /// Send a complex chat request with multiple messages to the AI
/// </summary> /// </summary>
/// <param name="request">Extended chat request with deviceId</param>
/// <returns>AI chat response</returns>
[HttpPost("chat")] [HttpPost("chat")]
public async Task<IActionResult> Chat([FromBody] ExtendedChatRequest request) public async Task<IActionResult> Chat([FromBody] ExtendedChatRequest request, CancellationToken cancellationToken)
{ {
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
@@ -120,46 +110,41 @@ public class AIController : ControllerBase
logger.LogInformation("Processing AI chat with {MessageCount} messages", request.Messages.Count); logger.LogInformation("Processing AI chat with {MessageCount} messages", request.Messages.Count);
var chatRequest = new 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.
Messages = request.Messages, var result = await aiProfileService.AskAsync(
Model = request.Model, request.Messages,
Temperature = request.Temperature, temperature: request.Temperature,
MaxTokens = request.MaxTokens maxTokens: request.MaxTokens,
}; cancellationToken: cancellationToken);
var response = await deepSeekService.AskAsync(chatRequest);
stopwatch.Stop(); 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" }); return StatusCode(500, new { error = "No response received from AI" });
} }
// Extract question and answer
var userMessage = request.Messages.LastOrDefault(m => m.Role == "user"); var userMessage = request.Messages.LastOrDefault(m => m.Role == "user");
var question = userMessage?.Content ?? "Complex chat"; 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 = BuildQuery(result, question, answer, request.DeviceId, request.UserId, request.Temperature, stopwatch.ElapsedMilliseconds);
var aiQuery = new AIQuery await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
var response = result.Response;
return Ok(new
{ {
Question = question, response.Id,
Answer = answer, response.Object,
DeviceId = request.DeviceId, response.Created,
UserId = request.UserId, response.Model,
Model = response.Model, response.Choices,
Temperature = request.Temperature, response.Usage,
PromptTokens = response.Usage?.PromptTokens ?? 0, costUsd = aiQuery.CostUsd,
CompletionTokens = response.Usage?.CompletionTokens ?? 0, profileId = aiQuery.ProfileId,
TotalTokens = response.Usage?.TotalTokens ?? 0, responseTimeMs = aiQuery.ResponseTimeMs
ResponseTimeMs = stopwatch.ElapsedMilliseconds });
};
await aiQueryService.SaveQueryAsync(aiQuery);
return Ok(response);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -171,10 +156,8 @@ public class AIController : ControllerBase
/// <summary> /// <summary>
/// Get suggestions for smart home automation based on device data /// Get suggestions for smart home automation based on device data
/// </summary> /// </summary>
/// <param name="request">Device context request</param>
/// <returns>AI suggestions</returns>
[HttpPost("suggest")] [HttpPost("suggest")]
public async Task<IActionResult> GetSuggestions([FromBody] SuggestionRequest request) public async Task<IActionResult> GetSuggestions([FromBody] SuggestionRequest request, CancellationToken cancellationToken)
{ {
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
@@ -193,33 +176,19 @@ public class AIController : ControllerBase
new ChatMessage { Role = "user", Content = question } new ChatMessage { Role = "user", Content = question }
}; };
var chatRequest = new ChatRequest { Messages = messages }; var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken);
var chatResponse = await deepSeekService.AskAsync(chatRequest);
stopwatch.Stop(); 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" }); 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 = BuildQuery(result, question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds);
var aiQuery = new AIQuery await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken);
{
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);
return Ok(new return Ok(new
{ {
@@ -231,6 +200,8 @@ public class AIController : ControllerBase
completion = aiQuery.CompletionTokens, completion = aiQuery.CompletionTokens,
total = aiQuery.TotalTokens total = aiQuery.TotalTokens
}, },
costUsd = aiQuery.CostUsd,
profileId = aiQuery.ProfileId,
responseTimeMs = aiQuery.ResponseTimeMs, responseTimeMs = aiQuery.ResponseTimeMs,
timestamp = DateTime.UtcNow timestamp = DateTime.UtcNow
}); });
@@ -246,12 +217,12 @@ public class AIController : ControllerBase
/// Get AI query history for a device /// Get AI query history for a device
/// </summary> /// </summary>
[HttpGet("history/device/{deviceId}")] [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 try
{ {
var queries = await aiQueryService.GetDeviceQueriesAsync(deviceId, take); var queries = await aiQueryService.GetDeviceQueriesAsync(deviceId, take, cancellationToken);
var totalTokens = await aiQueryService.GetDeviceTotalTokensAsync(deviceId); var totalTokens = await aiQueryService.GetDeviceTotalTokensAsync(deviceId, cancellationToken);
return Ok(new return Ok(new
{ {
@@ -263,7 +234,9 @@ public class AIController : ControllerBase
q.TotalTokens, q.TotalTokens,
q.PromptTokens, q.PromptTokens,
q.CompletionTokens, q.CompletionTokens,
q.CostUsd,
q.Model, q.Model,
q.ProfileId,
q.ResponseTimeMs, q.ResponseTimeMs,
q.CreatedAt q.CreatedAt
}), }),
@@ -281,11 +254,11 @@ public class AIController : ControllerBase
/// Get AI query statistics /// Get AI query statistics
/// </summary> /// </summary>
[HttpGet("stats")] [HttpGet("stats")]
public async Task<IActionResult> GetStats() public async Task<IActionResult> GetStats(CancellationToken cancellationToken)
{ {
try try
{ {
var stats = await aiQueryService.GetStatsAsync(); var stats = await aiQueryService.GetStatsAsync(cancellationToken);
return Ok(stats); return Ok(stats);
} }
catch (Exception ex) catch (Exception ex)
@@ -294,6 +267,42 @@ public class AIController : ControllerBase
return StatusCode(500, new { error = "An error occurred" }); 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> /// <summary>
@@ -301,24 +310,9 @@ public class AIController : ControllerBase
/// </summary> /// </summary>
public class SimpleQuestionRequest public class SimpleQuestionRequest
{ {
/// <summary>
/// The question to ask the AI
/// </summary>
public required string Question { get; set; } public required string Question { get; set; }
/// <summary>
/// Optional system prompt to set context for the AI
/// </summary>
public string? SystemPrompt { get; set; } public string? SystemPrompt { get; set; }
/// <summary>
/// Optional device ID to associate with this query
/// </summary>
public int? DeviceId { get; set; } public int? DeviceId { get; set; }
/// <summary>
/// Optional user ID to associate with this query
/// </summary>
public int? UserId { get; set; } public int? UserId { get; set; }
} }
@@ -328,7 +322,7 @@ public class SimpleQuestionRequest
public class ExtendedChatRequest public class ExtendedChatRequest
{ {
public required List<ChatMessage> Messages { get; set; } 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 double? Temperature { get; set; }
public int? MaxTokens { get; set; } public int? MaxTokens { get; set; }
public int? DeviceId { get; set; } public int? DeviceId { get; set; }
@@ -340,19 +334,7 @@ public class ExtendedChatRequest
/// </summary> /// </summary>
public class SuggestionRequest public class SuggestionRequest
{ {
/// <summary>
/// Context about devices and their current state
/// </summary>
public required string DeviceContext { get; set; } public required string DeviceContext { get; set; }
/// <summary>
/// Device ID for this suggestion request
/// </summary>
public int? DeviceId { get; set; } public int? DeviceId { get; set; }
/// <summary>
/// User ID for this suggestion request
/// </summary>
public int? UserId { get; set; } public int? UserId { get; set; }
} }

View File

@@ -4,6 +4,7 @@ using GreenHome.Api.Filters;
using GreenHome.Application; using GreenHome.Application;
using GreenHome.Application.Dtos.Admin; using GreenHome.Application.Dtos.Admin;
using GreenHome.Domain; using GreenHome.Domain;
using GreenHome.Infrastructure.Services.AI;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace GreenHome.Api.Controllers; namespace GreenHome.Api.Controllers;
@@ -14,10 +15,12 @@ namespace GreenHome.Api.Controllers;
public sealed class AdminController : ControllerBase public sealed class AdminController : ControllerBase
{ {
private readonly IAdminService _adminService; private readonly IAdminService _adminService;
private readonly IAIProfileService _aiProfileService;
public AdminController(IAdminService adminService) public AdminController(IAdminService adminService, IAIProfileService aiProfileService)
{ {
_adminService = adminService; _adminService = adminService;
_aiProfileService = aiProfileService;
} }
// ==================== Dashboard ==================== // ==================== Dashboard ====================
@@ -338,4 +341,116 @@ public sealed class AdminController : ControllerBase
var result = await _adminService.GetAuditLogsAsync(filter, cancellationToken); var result = await _adminService.GetAuditLogsAsync(filter, cancellationToken);
return Ok(result); 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);
}
} }

View File

@@ -0,0 +1,100 @@
using GreenHome.Domain;
namespace GreenHome.Application;
// ==================== AI Profile ====================
/// <summary>
/// An AI profile as shown in the admin list, with aggregated usage.
/// </summary>
public sealed class AIProfileDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public AIProvider Provider { get; set; }
public string BaseUrl { get; set; } = string.Empty;
public string Model { get; set; } = string.Empty;
public bool IsActive { get; set; }
public double? Temperature { get; set; }
public decimal? PromptPricePer1M { get; set; }
public decimal? CompletionPricePer1M { get; set; }
// Last known balance (may be null if the provider has no balance endpoint)
public decimal? BalanceUsd { get; set; }
public string? BalanceCurrency { get; set; }
public DateTime? BalanceUpdatedAt { get; set; }
// Aggregated usage for this profile (from the AIQueries log)
public int QueryCount { get; set; }
public int TotalPromptTokens { get; set; }
public int TotalCompletionTokens { get; set; }
public int TotalTokens { get; set; }
public decimal TotalCostUsd { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public sealed class AIProfileCreateRequest
{
public required string Name { get; set; }
public AIProvider Provider { get; set; }
public string? BaseUrl { get; set; }
public required string ApiKey { get; set; }
public required string Model { get; set; }
public double? Temperature { get; set; }
public int? MaxTokens { get; set; }
public decimal? PromptPricePer1M { get; set; }
public decimal? CompletionPricePer1M { get; set; }
}
public sealed class AIProfileUpdateRequest
{
public required string Name { get; set; }
public AIProvider Provider { get; set; }
public string? BaseUrl { get; set; }
public required string ApiKey { get; set; }
public required string Model { get; set; }
public double? Temperature { get; set; }
public int? MaxTokens { get; set; }
public decimal? PromptPricePer1M { get; set; }
public decimal? CompletionPricePer1M { get; set; }
}
/// <summary>
/// Best-effort account balance for a provider (null = unavailable).
/// </summary>
public sealed class AIBalance
{
public decimal Amount { get; set; }
public string? Currency { get; set; }
}
// ==================== AI Profile Usage ====================
/// <summary>
/// Aggregated usage for a single profile.
/// </summary>
public sealed class AIProfileUsageDto
{
public int QueryCount { get; set; }
public int TotalPromptTokens { get; set; }
public int TotalCompletionTokens { get; set; }
public int TotalTokens { get; set; }
public decimal TotalCostUsd { get; set; }
public decimal? BalanceUsd { get; set; }
public string? BalanceCurrency { get; set; }
}
/// <summary>
/// One day of AI usage for a profile (for charting).
/// </summary>
public sealed class AIProfileUsageDayDto
{
public DateOnly Date { get; set; }
public int QueryCount { get; set; }
public int PromptTokens { get; set; }
public int CompletionTokens { get; set; }
public int TotalTokens { get; set; }
public decimal CostUsd { get; set; }
}

View File

@@ -0,0 +1,60 @@
namespace GreenHome.Domain;
/// <summary>
/// Supported AI providers.
/// DeepSeek and OpenAI are first-party; OpenAICompatible covers any OpenAI-compatible endpoint.
/// </summary>
public enum AIProvider
{
DeepSeek = 0,
OpenAI = 1,
OpenAICompatible = 2
}
/// <summary>
/// A configured AI account used for analysis.
/// Only one profile can be active at a time; analysis always runs against the active profile.
/// </summary>
public class AIProfile
{
public int Id { get; set; }
/// <summary>Human-friendly name chosen by the admin.</summary>
public required string Name { get; set; }
public AIProvider Provider { get; set; }
/// <summary>
/// API root. DeepSeek: https://api.deepseek.com | OpenAI: https://api.openai.com/v1 | Compatible: user-provided.
/// </summary>
public string BaseUrl { get; set; } = string.Empty;
public required string ApiKey { get; set; }
public required string Model { get; set; }
public double? Temperature { get; set; }
public int? MaxTokens { get; set; }
/// <summary>True for the single profile used for all analysis.</summary>
public bool IsActive { get; set; }
/// <summary>Input (prompt) price per 1M tokens, in USD. Null = unknown.</summary>
public decimal? PromptPricePer1M { get; set; }
/// <summary>Output (completion) price per 1M tokens, in USD. Null = unknown.</summary>
public decimal? CompletionPricePer1M { get; set; }
/// <summary>Last known account balance (best-effort, provider dependent).</summary>
public decimal? BalanceUsd { get; set; }
/// <summary>Currency code of BalanceUsd (e.g. USD, CNY). Null = unknown / unavailable.</summary>
public string? BalanceCurrency { get; set; }
public DateTime? BalanceUpdatedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -50,6 +50,21 @@ public class AIQuery
/// </summary> /// </summary>
public string? Model { get; set; } public string? Model { get; set; }
/// <summary>
/// AI profile used for this query
/// </summary>
public int? ProfileId { get; set; }
/// <summary>
/// Navigation property to AIProfile
/// </summary>
public AIProfile? Profile { get; set; }
/// <summary>
/// Estimated cost of the query in USD (0 = not calculable)
/// </summary>
public decimal CostUsd { get; set; }
/// <summary> /// <summary>
/// Temperature parameter used /// Temperature parameter used
/// </summary> /// </summary>

View File

@@ -20,6 +20,7 @@ public sealed class GreenHomeDbContext : DbContext
public DbSet<Domain.AlertNotification> AlertNotifications => Set<Domain.AlertNotification>(); public DbSet<Domain.AlertNotification> AlertNotifications => Set<Domain.AlertNotification>();
public DbSet<Domain.AlertLog> AlertLogs => Set<Domain.AlertLog>(); public DbSet<Domain.AlertLog> AlertLogs => Set<Domain.AlertLog>();
public DbSet<Domain.AIQuery> AIQueries => Set<Domain.AIQuery>(); public DbSet<Domain.AIQuery> AIQueries => Set<Domain.AIQuery>();
public DbSet<Domain.AIProfile> AIProfiles => Set<Domain.AIProfile>();
public DbSet<Domain.DailyReport> DailyReports => Set<Domain.DailyReport>(); public DbSet<Domain.DailyReport> DailyReports => Set<Domain.DailyReport>();
public DbSet<Domain.UserDailyReport> UserDailyReports => Set<Domain.UserDailyReport>(); public DbSet<Domain.UserDailyReport> UserDailyReports => Set<Domain.UserDailyReport>();
public DbSet<Domain.ReportImage> ReportImages => Set<Domain.ReportImage>(); public DbSet<Domain.ReportImage> ReportImages => Set<Domain.ReportImage>();
@@ -236,6 +237,7 @@ public sealed class GreenHomeDbContext : DbContext
b.Property(x => x.Question).IsRequired(); b.Property(x => x.Question).IsRequired();
b.Property(x => x.Answer).IsRequired(); b.Property(x => x.Answer).IsRequired();
b.Property(x => x.Model).HasMaxLength(100); b.Property(x => x.Model).HasMaxLength(100);
b.Property(x => x.CostUsd).HasColumnType("decimal(18,8)");
b.HasOne(x => x.Device) b.HasOne(x => x.Device)
.WithMany() .WithMany()
.HasForeignKey(x => x.DeviceId) .HasForeignKey(x => x.DeviceId)
@@ -244,11 +246,32 @@ public sealed class GreenHomeDbContext : DbContext
.WithMany() .WithMany()
.HasForeignKey(x => x.UserId) .HasForeignKey(x => x.UserId)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne(x => x.Profile)
.WithMany()
.HasForeignKey(x => x.ProfileId)
.OnDelete(DeleteBehavior.SetNull);
b.HasIndex(x => x.DeviceId); b.HasIndex(x => x.DeviceId);
b.HasIndex(x => x.UserId); b.HasIndex(x => x.UserId);
b.HasIndex(x => x.ProfileId);
b.HasIndex(x => x.CreatedAt); b.HasIndex(x => x.CreatedAt);
}); });
modelBuilder.Entity<Domain.AIProfile>(b =>
{
b.ToTable("AIProfiles");
b.HasKey(x => x.Id);
b.Property(x => x.Name).IsRequired().HasMaxLength(100);
b.Property(x => x.Provider).IsRequired().HasConversion<int>();
b.Property(x => x.BaseUrl).IsRequired().HasMaxLength(300);
b.Property(x => x.ApiKey).IsRequired().HasMaxLength(500);
b.Property(x => x.Model).IsRequired().HasMaxLength(100);
b.Property(x => x.PromptPricePer1M).HasColumnType("decimal(18,6)");
b.Property(x => x.CompletionPricePer1M).HasColumnType("decimal(18,6)");
b.Property(x => x.BalanceUsd).HasColumnType("decimal(18,6)");
b.Property(x => x.BalanceCurrency).HasMaxLength(10);
b.HasIndex(x => x.IsActive);
});
modelBuilder.Entity<Domain.DailyReport>(b => modelBuilder.Entity<Domain.DailyReport>(b =>
{ {
b.ToTable("DailyReports"); b.ToTable("DailyReports");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,96 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GreenHome.Infrastructure.Data.Migrations.Migrations
{
/// <inheritdoc />
public partial class AddAIProfiles : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "CostUsd",
table: "AIQueries",
type: "decimal(18,8)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<int>(
name: "ProfileId",
table: "AIQueries",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "AIProfiles",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Provider = table.Column<int>(type: "int", nullable: false),
BaseUrl = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
ApiKey = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false),
Model = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
Temperature = table.Column<double>(type: "float", nullable: true),
MaxTokens = table.Column<int>(type: "int", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
PromptPricePer1M = table.Column<decimal>(type: "decimal(18,6)", nullable: true),
CompletionPricePer1M = table.Column<decimal>(type: "decimal(18,6)", nullable: true),
BalanceUsd = table.Column<decimal>(type: "decimal(18,6)", nullable: true),
BalanceCurrency = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
BalanceUpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AIProfiles", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_AIQueries_ProfileId",
table: "AIQueries",
column: "ProfileId");
migrationBuilder.CreateIndex(
name: "IX_AIProfiles_IsActive",
table: "AIProfiles",
column: "IsActive");
migrationBuilder.AddForeignKey(
name: "FK_AIQueries_AIProfiles_ProfileId",
table: "AIQueries",
column: "ProfileId",
principalTable: "AIProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AIQueries_AIProfiles_ProfileId",
table: "AIQueries");
migrationBuilder.DropTable(
name: "AIProfiles");
migrationBuilder.DropIndex(
name: "IX_AIQueries_ProfileId",
table: "AIQueries");
migrationBuilder.DropColumn(
name: "CostUsd",
table: "AIQueries");
migrationBuilder.DropColumn(
name: "ProfileId",
table: "AIQueries");
}
}
}

View File

@@ -22,6 +22,75 @@ namespace GreenHome.Infrastructure.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("GreenHome.Domain.AIProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ApiKey")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("BalanceCurrency")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<DateTime?>("BalanceUpdatedAt")
.HasColumnType("datetime2");
b.Property<decimal?>("BalanceUsd")
.HasColumnType("decimal(18,6)");
b.Property<string>("BaseUrl")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<decimal?>("CompletionPricePer1M")
.HasColumnType("decimal(18,6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<int?>("MaxTokens")
.HasColumnType("int");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<decimal?>("PromptPricePer1M")
.HasColumnType("decimal(18,6)");
b.Property<int>("Provider")
.HasColumnType("int");
b.Property<double?>("Temperature")
.HasColumnType("float");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("IsActive");
b.ToTable("AIProfiles", (string)null);
});
modelBuilder.Entity("GreenHome.Domain.AIQuery", b => modelBuilder.Entity("GreenHome.Domain.AIQuery", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -37,6 +106,9 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.Property<int>("CompletionTokens") b.Property<int>("CompletionTokens")
.HasColumnType("int"); .HasColumnType("int");
b.Property<decimal>("CostUsd")
.HasColumnType("decimal(18,8)");
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -47,6 +119,9 @@ namespace GreenHome.Infrastructure.Data.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("nvarchar(100)"); .HasColumnType("nvarchar(100)");
b.Property<int?>("ProfileId")
.HasColumnType("int");
b.Property<int>("PromptTokens") b.Property<int>("PromptTokens")
.HasColumnType("int"); .HasColumnType("int");
@@ -72,6 +147,8 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.HasIndex("DeviceId"); b.HasIndex("DeviceId");
b.HasIndex("ProfileId");
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("AIQueries", (string)null); b.ToTable("AIQueries", (string)null);
@@ -1201,6 +1278,11 @@ namespace GreenHome.Infrastructure.Data.Migrations
.HasForeignKey("DeviceId") .HasForeignKey("DeviceId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("GreenHome.Domain.AIProfile", "Profile")
.WithMany()
.HasForeignKey("ProfileId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("GreenHome.Domain.User", "User") b.HasOne("GreenHome.Domain.User", "User")
.WithMany() .WithMany()
.HasForeignKey("UserId") .HasForeignKey("UserId")
@@ -1208,6 +1290,8 @@ namespace GreenHome.Infrastructure.Data.Migrations
b.Navigation("Device"); b.Navigation("Device");
b.Navigation("Profile");
b.Navigation("User"); b.Navigation("User");
}); });

View File

@@ -74,6 +74,8 @@ public static class DependencyInjection
// AI // AI
services.AddScoped<IAIQueryService, AIQueryService>(); services.AddScoped<IAIQueryService, AIQueryService>();
services.AddSingleton<AIChatClient>();
services.AddScoped<IAIProfileService, AIProfileService>();
// Sun Calculator // Sun Calculator
services.AddScoped<ISunCalculatorService, SunCalculatorService>(); services.AddScoped<ISunCalculatorService, SunCalculatorService>();

View File

@@ -0,0 +1,141 @@
using System.Net.Http.Json;
using System.Text.Json;
using GreenHome.AI.DeepSeek;
using GreenHome.Application;
using GreenHome.Domain;
using Microsoft.Extensions.Logging;
namespace GreenHome.Infrastructure.Services.AI;
/// <summary>
/// Generic OpenAI-compatible chat client. All three supported providers (DeepSeek, OpenAI,
/// any OpenAI-compatible endpoint) speak the same protocol, so one client covers them all.
/// The profile's BaseUrl is the API root including /v1 (e.g. https://api.deepseek.com/v1).
/// </summary>
public sealed class AIChatClient
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<AIChatClient> _logger;
public AIChatClient(IHttpClientFactory httpClientFactory, ILogger<AIChatClient> logger)
{
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public async Task<ChatResponse?> AskAsync(
AIProfile profile,
List<ChatMessage> messages,
double? temperature,
int? maxTokens,
CancellationToken cancellationToken = default)
{
var client = CreateClient(profile);
var request = new ChatRequest
{
Model = profile.Model,
Messages = messages,
Temperature = temperature ?? profile.Temperature,
MaxTokens = maxTokens ?? profile.MaxTokens
};
var response = await client.PostAsJsonAsync("chat/completions", request, cancellationToken);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ChatResponse>(cancellationToken: cancellationToken);
}
/// <summary>
/// Lists model ids available on the provider's /models endpoint.
/// </summary>
public async Task<List<string>> GetModelsAsync(AIProfile profile, CancellationToken cancellationToken = default)
{
var client = CreateClient(profile);
var response = await client.GetAsync("models", cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var ids = new List<string>();
if (doc.RootElement.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Array)
{
foreach (var item in data.EnumerateArray())
{
if (item.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String)
ids.Add(id.GetString()!);
}
}
return ids;
}
/// <summary>
/// Best-effort account balance. DeepSeek: GET balance (data.total_balance, CNY).
/// OpenAI: GET balance (sum of data[].amount, USD). Compatible: unavailable (null).
/// Any failure returns null — balance is informational only.
/// </summary>
public async Task<AIBalance?> GetBalanceAsync(AIProfile profile, CancellationToken cancellationToken = default)
{
if (profile.Provider == AIProvider.OpenAICompatible)
return null;
try
{
var client = CreateClient(profile);
var response = await client.GetAsync("balance", cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Balance endpoint for profile {ProfileId} returned {Status}", profile.Id, (int)response.StatusCode);
return null;
}
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var root = doc.RootElement;
if (profile.Provider == AIProvider.DeepSeek)
{
// { "object": "credit_list", "data": { "total_balance": 12.5, "balance": 12.5 } }
if (root.TryGetProperty("data", out var data) &&
data.TryGetProperty("total_balance", out var total) &&
total.TryGetDecimal(out var amount))
return new AIBalance { Amount = amount, Currency = "CNY" };
}
else if (profile.Provider == AIProvider.OpenAI)
{
// { "object": "list", "data": [ { "object": "balance", "amount": 100, "currency": "usd" } ] }
if (root.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Array)
{
decimal sum = 0;
string? currency = null;
foreach (var item in data.EnumerateArray())
{
if (item.TryGetProperty("amount", out var amount) && amount.TryGetDecimal(out var a))
{
sum += a;
if (item.TryGetProperty("currency", out var cur) && cur.ValueKind == JsonValueKind.String)
currency = cur.GetString()?.ToUpperInvariant();
}
}
if (currency != null)
return new AIBalance { Amount = sum, Currency = currency };
}
}
return null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch balance for profile {ProfileId}", profile.Id);
return null;
}
}
private HttpClient CreateClient(AIProfile profile)
{
var client = _httpClientFactory.CreateClient();
var baseAddress = profile.BaseUrl.TrimEnd('/');
if (!baseAddress.EndsWith("/"))
baseAddress += "/";
client.BaseAddress = new Uri(baseAddress);
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", profile.ApiKey);
client.Timeout = TimeSpan.FromSeconds(120);
return client;
}
}

View File

@@ -0,0 +1,348 @@
using GreenHome.AI.DeepSeek;
using GreenHome.Application;
using GreenHome.Domain;
using GreenHome.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace GreenHome.Infrastructure.Services.AI;
/// <summary>
/// AI profile management + runtime routing. All analysis flows through the active
/// profile; when none is active it falls back to the legacy DeepSeek config so
/// the app keeps working out of the box.
/// </summary>
public sealed class AIProfileService : IAIProfileService
{
// Default API roots (including /v1) per first-party provider
private const string DeepSeekDefaultBaseUrl = "https://api.deepseek.com/v1";
private const string OpenAIDefaultBaseUrl = "https://api.openai.com/v1";
private readonly GreenHomeDbContext _dbContext;
private readonly AIChatClient _chatClient;
private readonly IDeepSeekService _deepSeekService;
private readonly ILogger<AIProfileService> _logger;
public AIProfileService(
GreenHomeDbContext dbContext,
AIChatClient chatClient,
IDeepSeekService deepSeekService,
ILogger<AIProfileService> logger)
{
_dbContext = dbContext;
_chatClient = chatClient;
_deepSeekService = deepSeekService;
_logger = logger;
}
// ==================== CRUD ====================
public async Task<List<AIProfileDto>> GetProfilesAsync(CancellationToken cancellationToken = default)
{
var profiles = await _dbContext.AIProfiles
.OrderBy(p => p.Id)
.ToListAsync(cancellationToken);
var usageMap = await GetUsageMapAsync(cancellationToken);
return profiles.Select(p => ToDto(p, usageMap)).ToList();
}
public async Task<AIProfileDto?> GetProfileAsync(int id, CancellationToken cancellationToken = default)
{
var profile = await _dbContext.AIProfiles.FindAsync(new object[] { id }, cancellationToken);
if (profile == null) return null;
var usageMap = await GetUsageMapAsync(cancellationToken);
return ToDto(profile, usageMap);
}
public async Task<AIProfileDto> CreateProfileAsync(AIProfileCreateRequest request, int currentAdminId, CancellationToken cancellationToken = default)
{
var profile = new AIProfile
{
Name = request.Name.Trim(),
Provider = request.Provider,
BaseUrl = ResolveBaseUrl(request.Provider, request.BaseUrl),
ApiKey = request.ApiKey.Trim(),
Model = request.Model.Trim(),
Temperature = request.Temperature,
MaxTokens = request.MaxTokens,
PromptPricePer1M = request.PromptPricePer1M,
CompletionPricePer1M = request.CompletionPricePer1M,
// First profile becomes active so analysis works immediately
IsActive = !await _dbContext.AIProfiles.AnyAsync(cancellationToken)
};
_dbContext.AIProfiles.Add(profile);
AddAuditLog(currentAdminId, "AIProfileCreate", $"{profile.Id}", profile.Name);
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("AI profile {Id} ({Name}, {Provider}) created", profile.Id, profile.Name, profile.Provider);
var usageMap = await GetUsageMapAsync(cancellationToken);
return ToDto(profile, usageMap);
}
public async Task<AIProfileDto?> UpdateProfileAsync(int id, AIProfileUpdateRequest request, int currentAdminId, CancellationToken cancellationToken = default)
{
var profile = await _dbContext.AIProfiles.FindAsync(new object[] { id }, cancellationToken);
if (profile == null) return null;
var baseUrlChanged = request.BaseUrl != null && request.BaseUrl.Trim() != profile.BaseUrl;
profile.Name = request.Name.Trim();
profile.Provider = request.Provider;
profile.BaseUrl = ResolveBaseUrl(request.Provider, request.BaseUrl);
profile.ApiKey = request.ApiKey.Trim();
profile.Model = request.Model.Trim();
profile.Temperature = request.Temperature;
profile.MaxTokens = request.MaxTokens;
profile.PromptPricePer1M = request.PromptPricePer1M;
profile.CompletionPricePer1M = request.CompletionPricePer1M;
profile.UpdatedAt = DateTime.UtcNow;
AddAuditLog(currentAdminId, "AIProfileUpdate", $"{profile.Id}", profile.Name);
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("AI profile {Id} updated", profile.Id);
var usageMap = await GetUsageMapAsync(cancellationToken);
return ToDto(profile, usageMap);
}
public async Task<bool> DeleteProfileAsync(int id, int currentAdminId, CancellationToken cancellationToken = default)
{
var profile = await _dbContext.AIProfiles.FindAsync(new object[] { id }, cancellationToken);
if (profile == null) return false;
var name = profile.Name;
_dbContext.AIProfiles.Remove(profile);
AddAuditLog(currentAdminId, "AIProfileDelete", $"{id}", name);
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("AI profile {Id} ({Name}) deleted", id, name);
return true;
}
public async Task<AIProfileDto> ActivateProfileAsync(int id, int currentAdminId, CancellationToken cancellationToken = default)
{
var profile = await _dbContext.AIProfiles
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken)
?? throw new KeyNotFoundException($"AI profile {id} not found");
var others = await _dbContext.AIProfiles
.Where(p => p.IsActive && p.Id != id)
.ToListAsync(cancellationToken);
foreach (var other in others)
other.IsActive = false;
profile.IsActive = true;
profile.UpdatedAt = DateTime.UtcNow;
AddAuditLog(currentAdminId, "AIProfileActivate", $"{id}", profile.Name);
await _dbContext.SaveChangesAsync(cancellationToken);
_logger.LogInformation("AI profile {Id} ({Name}) activated", id, profile.Name);
var usageMap = await GetUsageMapAsync(cancellationToken);
return ToDto(profile, usageMap);
}
// ==================== Runtime routing ====================
public async Task<AIProfile?> GetActiveProfileAsync(CancellationToken cancellationToken = default)
{
return await _dbContext.AIProfiles
.FirstOrDefaultAsync(p => p.IsActive, cancellationToken);
}
public async Task<AIAskResult> AskAsync(
List<ChatMessage> messages,
double? temperature = null,
int? maxTokens = null,
CancellationToken cancellationToken = default)
{
var profile = await GetActiveProfileAsync(cancellationToken);
if (profile == null)
{
// ponytail: legacy fallback uses the appsettings DeepSeek config; callers still
// get a valid ChatResponse and log the query with ProfileId = null.
var legacy = await _deepSeekService.AskAsync(new ChatRequest
{
Messages = messages,
Temperature = temperature,
MaxTokens = maxTokens
}, cancellationToken);
return new AIAskResult { Profile = null, Response = legacy };
}
var response = await _chatClient.AskAsync(profile, messages, temperature, maxTokens, cancellationToken);
return new AIAskResult { Profile = profile, Response = response };
}
// ==================== Provider metadata ====================
public async Task<List<string>> GetModelsAsync(int id, CancellationToken cancellationToken = default)
{
var profile = await _dbContext.AIProfiles
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken)
?? throw new KeyNotFoundException($"AI profile {id} not found");
return await _chatClient.GetModelsAsync(profile, cancellationToken);
}
public async Task<AIBalance?> RefreshBalanceAsync(int id, CancellationToken cancellationToken = default)
{
var profile = await _dbContext.AIProfiles
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken)
?? throw new KeyNotFoundException($"AI profile {id} not found");
var balance = await _chatClient.GetBalanceAsync(profile, cancellationToken);
if (balance != null)
{
profile.BalanceUsd = balance.Amount;
profile.BalanceCurrency = balance.Currency;
profile.BalanceUpdatedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync(cancellationToken);
}
return balance;
}
// ==================== Usage ====================
public async Task<AIProfileUsageDto> GetUsageAsync(int id, DateTime from, DateTime to, CancellationToken cancellationToken = default)
{
var agg = await _dbContext.AIQueries
.Where(q => q.ProfileId == id && q.CreatedAt >= from && q.CreatedAt < to)
.Select(q => new
{
q.PromptTokens,
q.CompletionTokens,
q.TotalTokens,
q.CostUsd
})
.ToListAsync(cancellationToken);
var profile = await _dbContext.AIProfiles
.Where(p => p.Id == id)
.Select(p => new { p.BalanceUsd, p.BalanceCurrency })
.FirstOrDefaultAsync(cancellationToken);
return new AIProfileUsageDto
{
QueryCount = agg.Count,
TotalPromptTokens = agg.Sum(a => a.PromptTokens),
TotalCompletionTokens = agg.Sum(a => a.CompletionTokens),
TotalTokens = agg.Sum(a => a.TotalTokens),
TotalCostUsd = agg.Sum(a => a.CostUsd),
BalanceUsd = profile?.BalanceUsd,
BalanceCurrency = profile?.BalanceCurrency
};
}
public async Task<List<AIProfileUsageDayDto>> GetUsageByDayAsync(int id, DateTime from, DateTime to, CancellationToken cancellationToken = default)
{
var rows = await _dbContext.AIQueries
.Where(q => q.ProfileId == id && q.CreatedAt >= from && q.CreatedAt < to)
.GroupBy(q => q.CreatedAt.Date)
.Select(g => new
{
Date = g.Key,
QueryCount = g.Count(),
PromptTokens = g.Sum(q => q.PromptTokens),
CompletionTokens = g.Sum(q => q.CompletionTokens),
TotalTokens = g.Sum(q => q.TotalTokens),
CostUsd = g.Sum(q => q.CostUsd)
})
.ToListAsync(cancellationToken);
return rows
.OrderBy(r => r.Date)
.Select(r => new AIProfileUsageDayDto
{
Date = DateOnly.FromDateTime(r.Date),
QueryCount = r.QueryCount,
PromptTokens = r.PromptTokens,
CompletionTokens = r.CompletionTokens,
TotalTokens = r.TotalTokens,
CostUsd = r.CostUsd
})
.ToList();
}
// ==================== Helpers ====================
/// <summary>
/// Per-profile usage totals (all time) keyed by profile id.
/// </summary>
private async Task<Dictionary<int, (int QueryCount, int Prompt, int Completion, int Total, decimal Cost)>> GetUsageMapAsync(CancellationToken cancellationToken)
{
var rows = await _dbContext.AIQueries
.Where(q => q.ProfileId != null)
.GroupBy(q => q.ProfileId!.Value)
.Select(g => new
{
g.Key,
QueryCount = g.Count(),
Prompt = g.Sum(q => q.PromptTokens),
Completion = g.Sum(q => q.CompletionTokens),
Total = g.Sum(q => q.TotalTokens),
Cost = g.Sum(q => q.CostUsd)
})
.ToListAsync(cancellationToken);
return rows.ToDictionary(r => r.Key, r => (r.QueryCount, r.Prompt, r.Completion, r.Total, r.Cost));
}
private static AIProfileDto ToDto(AIProfile p, Dictionary<int, (int QueryCount, int Prompt, int Completion, int Total, decimal Cost)> usageMap)
{
usageMap.TryGetValue(p.Id, out var u);
return new AIProfileDto
{
Id = p.Id,
Name = p.Name,
Provider = p.Provider,
BaseUrl = p.BaseUrl,
Model = p.Model,
IsActive = p.IsActive,
Temperature = p.Temperature,
PromptPricePer1M = p.PromptPricePer1M,
CompletionPricePer1M = p.CompletionPricePer1M,
BalanceUsd = p.BalanceUsd,
BalanceCurrency = p.BalanceCurrency,
BalanceUpdatedAt = p.BalanceUpdatedAt,
QueryCount = u.QueryCount,
TotalPromptTokens = u.Prompt,
TotalCompletionTokens = u.Completion,
TotalTokens = u.Total,
TotalCostUsd = u.Cost,
CreatedAt = p.CreatedAt,
UpdatedAt = p.UpdatedAt
};
}
/// <summary>
/// Resolves the effective base URL: explicit user value wins; otherwise the
/// provider's default root. Compatible always requires an explicit URL.
/// </summary>
private static string ResolveBaseUrl(AIProvider provider, string? baseUrl)
{
if (!string.IsNullOrWhiteSpace(baseUrl))
return baseUrl.Trim().TrimEnd('/');
return provider switch
{
AIProvider.DeepSeek => DeepSeekDefaultBaseUrl,
AIProvider.OpenAI => OpenAIDefaultBaseUrl,
_ => throw new ArgumentException("BaseUrl is required for OpenAI-compatible providers")
};
}
private void AddAuditLog(int adminUserId, string action, string targetId, string details = "")
{
_dbContext.AdminAuditLogs.Add(new Domain.AdminAuditLog
{
AdminUserId = adminUserId,
Action = action,
TargetId = targetId,
Details = details,
At = DateTime.UtcNow
});
}
}

View File

@@ -0,0 +1,95 @@
using GreenHome.AI.DeepSeek;
using GreenHome.Application;
using GreenHome.Domain;
namespace GreenHome.Infrastructure.Services.AI;
/// <summary>
/// Result of an AI ask routed through the profile system.
/// Profile is null when the legacy DeepSeek fallback was used (no active profile).
/// </summary>
public sealed class AIAskResult
{
public AIProfile? Profile { get; set; }
public ChatResponse? Response { get; set; }
}
/// <summary>
/// Manages AI provider profiles (CRUD, activation, models, balance) and routes
/// AI calls through the active profile. Lives in Infrastructure because it
/// returns GreenHome.AI.DeepSeek response types.
/// </summary>
public interface IAIProfileService
{
// ==================== CRUD ====================
Task<List<AIProfileDto>> GetProfilesAsync(CancellationToken cancellationToken = default);
Task<AIProfileDto?> GetProfileAsync(int id, CancellationToken cancellationToken = default);
Task<AIProfileDto> CreateProfileAsync(AIProfileCreateRequest request, int currentAdminId, CancellationToken cancellationToken = default);
Task<AIProfileDto?> UpdateProfileAsync(int id, AIProfileUpdateRequest request, int currentAdminId, CancellationToken cancellationToken = default);
Task<bool> DeleteProfileAsync(int id, int currentAdminId, CancellationToken cancellationToken = default);
/// <summary>
/// Sets the given profile as the single active one (deactivates the rest).
/// </summary>
Task<AIProfileDto> ActivateProfileAsync(int id, int currentAdminId, CancellationToken cancellationToken = default);
// ==================== Runtime routing ====================
/// <summary>
/// The currently active profile, or null if none is set.
/// </summary>
Task<AIProfile?> GetActiveProfileAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Routes a chat request through the active profile; falls back to the
/// legacy configured DeepSeek service when no profile is active.
/// </summary>
Task<AIAskResult> AskAsync(
List<ChatMessage> messages,
double? temperature = null,
int? maxTokens = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Estimated cost in USD for a query given per-1M-token prices. 0 when prices are unknown.
/// </summary>
static decimal EstimateCost(decimal? promptPricePer1M, decimal? completionPricePer1M, int promptTokens, int completionTokens)
{
if (!promptPricePer1M.HasValue && !completionPricePer1M.HasValue)
return 0m;
var cost = (promptPricePer1M ?? 0m) * promptTokens / 1_000_000m
+ (completionPricePer1M ?? 0m) * completionTokens / 1_000_000m;
return Math.Round(cost, 8);
}
// ==================== Provider metadata ====================
/// <summary>
/// Fetches the model list from the provider's /models endpoint.
/// </summary>
Task<List<string>> GetModelsAsync(int id, CancellationToken cancellationToken = default);
/// <summary>
/// Fetches the account balance (best-effort), persists it on the profile, and returns it.
/// Returns null when the provider has no balance endpoint or the call fails.
/// </summary>
Task<AIBalance?> RefreshBalanceAsync(int id, CancellationToken cancellationToken = default);
// ==================== Usage (from the AIQueries log) ====================
/// <summary>
/// Aggregated usage for a profile within a date range (inclusive of `from`, exclusive of `to`).
/// </summary>
Task<AIProfileUsageDto> GetUsageAsync(int id, DateTime from, DateTime to, CancellationToken cancellationToken = default);
/// <summary>
/// Per-day usage for a profile within a date range — for charting.
/// </summary>
Task<List<AIProfileUsageDayDto>> GetUsageByDayAsync(int id, DateTime from, DateTime to, CancellationToken cancellationToken = default);
}

View File

@@ -4,6 +4,7 @@ using GreenHome.AI.DeepSeek;
using GreenHome.Application; using GreenHome.Application;
using GreenHome.Infrastructure.Data; using GreenHome.Infrastructure.Data;
using GreenHome.Infrastructure.Extensions; using GreenHome.Infrastructure.Extensions;
using GreenHome.Infrastructure.Services.AI;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -12,16 +13,19 @@ namespace GreenHome.Infrastructure.Services.Report;
public sealed class DailyReportService : IDailyReportService public sealed class DailyReportService : IDailyReportService
{ {
private readonly GreenHomeDbContext _dbContext; private readonly GreenHomeDbContext _dbContext;
private readonly IDeepSeekService _deepSeekService; private readonly IAIProfileService _aiProfileService;
private readonly IAIQueryService _aiQueryService;
private readonly ILogger<DailyReportService> _logger; private readonly ILogger<DailyReportService> _logger;
public DailyReportService( public DailyReportService(
GreenHomeDbContext context, GreenHomeDbContext context,
IDeepSeekService deepSeekService, IAIProfileService aiProfileService,
IAIQueryService aiQueryService,
ILogger<DailyReportService> logger) ILogger<DailyReportService> logger)
{ {
_dbContext = context; _dbContext = context;
_deepSeekService = deepSeekService; _aiProfileService = aiProfileService;
_aiQueryService = aiQueryService;
_logger = logger; _logger = logger;
} }
@@ -128,6 +132,7 @@ public sealed class DailyReportService : IDailyReportService
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
ChatResponse? aiResponse; ChatResponse? aiResponse;
AIAskResult? aiResult;
try try
{ {
@@ -135,18 +140,16 @@ public sealed class DailyReportService : IDailyReportService
? $"تو یک متخصص کشاورزی و گلخانه هستی که در کشت {deviceSettings.ProductType} تخصص داری و داده‌های تلمتری رو تحلیل می‌کنی." ? $"تو یک متخصص کشاورزی و گلخانه هستی که در کشت {deviceSettings.ProductType} تخصص داری و داده‌های تلمتری رو تحلیل می‌کنی."
: "تو یک متخصص کشاورزی و گلخانه هستی که داده‌های تلمتری رو تحلیل می‌کنی."; : "تو یک متخصص کشاورزی و گلخانه هستی که داده‌های تلمتری رو تحلیل می‌کنی.";
var chatRequest = new ChatRequest aiResult = await _aiProfileService.AskAsync(
{ new List<ChatMessage>
Model = "deepseek-chat",
Messages = new List<ChatMessage>
{ {
new() { Role = "system", Content = systemMessage }, new() { Role = "system", Content = systemMessage },
new() { Role = "user", Content = question } new() { Role = "user", Content = question }
}, },
Temperature = 0.7 temperature: 0.7,
}; cancellationToken: cancellationToken);
aiResponse = await _deepSeekService.AskAsync(chatRequest, cancellationToken); aiResponse = aiResult.Response;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -183,6 +186,8 @@ public sealed class DailyReportService : IDailyReportService
_dbContext.DailyReports.Add(dailyReport); _dbContext.DailyReports.Add(dailyReport);
await _dbContext.SaveChangesAsync(cancellationToken); await _dbContext.SaveChangesAsync(cancellationToken);
await LogAiQueryAsync(aiResult!, question, analysis, request.DeviceId, null, 0.7, stopwatch.ElapsedMilliseconds, cancellationToken);
_logger.LogInformation( _logger.LogInformation(
"گزارش روزانه جدید برای دستگاه {DeviceId} و تاریخ {Date} ایجاد شد. توکن مصرف شده: {Tokens}", "گزارش روزانه جدید برای دستگاه {DeviceId} و تاریخ {Date} ایجاد شد. توکن مصرف شده: {Tokens}",
request.DeviceId, request.PersianDate, dailyReport.TotalTokens); request.DeviceId, request.PersianDate, dailyReport.TotalTokens);
@@ -281,24 +286,31 @@ public sealed class DailyReportService : IDailyReportService
: "تو یک متخصص کشاورزی و گلخانه هستی که داده‌های تلمتری رو تحلیل می‌کنی."; : "تو یک متخصص کشاورزی و گلخانه هستی که داده‌های تلمتری رو تحلیل می‌کنی.";
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
var chatRequest = new ChatRequest var aiResult = await _aiProfileService.AskAsync(
{ new List<ChatMessage>
Model = "deepseek-chat",
Messages = new List<ChatMessage>
{ {
new() { Role = "system", Content = systemMessage }, new() { Role = "system", Content = systemMessage },
new() { Role = "user", Content = question } new() { Role = "user", Content = question }
}, },
Temperature = 0.7 temperature: 0.7,
}; cancellationToken: cancellationToken);
var aiResponse = aiResult.Response;
var aiResponse = await _deepSeekService.AskAsync(chatRequest, cancellationToken);
stopwatch.Stop(); stopwatch.Stop();
if (aiResponse?.Choices == null || aiResponse.Choices.Count == 0 || if (aiResponse?.Choices == null || aiResponse.Choices.Count == 0 ||
string.IsNullOrWhiteSpace(aiResponse.Choices[0].Message?.Content)) string.IsNullOrWhiteSpace(aiResponse.Choices[0].Message?.Content))
throw new InvalidOperationException("پاسخ نامعتبر از سرویس هوش مصنوعی"); throw new InvalidOperationException("پاسخ نامعتبر از سرویس هوش مصنوعی");
await LogAiQueryAsync(
aiResult,
question,
aiResponse.Choices[0].Message!.Content,
request.DeviceId,
null,
0.7,
stopwatch.ElapsedMilliseconds,
cancellationToken);
return new DailyReportResponse return new DailyReportResponse
{ {
Id = 0, Id = 0,
@@ -378,24 +390,31 @@ public sealed class DailyReportService : IDailyReportService
: "تو یک متخصص کشاورزی و گلخانه هستی. تحلیل‌های روزانه رو بررسی کن و یک جمع‌بندی ماهانه جامع ارائه بده."; : "تو یک متخصص کشاورزی و گلخانه هستی. تحلیل‌های روزانه رو بررسی کن و یک جمع‌بندی ماهانه جامع ارائه بده.";
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
var chatRequest = new ChatRequest var aiResult = await _aiProfileService.AskAsync(
{ new List<ChatMessage>
Model = "deepseek-chat",
Messages = new List<ChatMessage>
{ {
new() { Role = "system", Content = systemMessage }, new() { Role = "system", Content = systemMessage },
new() { Role = "user", Content = question } new() { Role = "user", Content = question }
}, },
Temperature = 0.7 temperature: 0.7,
}; cancellationToken: cancellationToken);
var aiResponse = aiResult.Response;
var aiResponse = await _deepSeekService.AskAsync(chatRequest, cancellationToken);
stopwatch.Stop(); stopwatch.Stop();
if (aiResponse?.Choices == null || aiResponse.Choices.Count == 0 || if (aiResponse?.Choices == null || aiResponse.Choices.Count == 0 ||
string.IsNullOrWhiteSpace(aiResponse.Choices[0].Message?.Content)) string.IsNullOrWhiteSpace(aiResponse.Choices[0].Message?.Content))
throw new InvalidOperationException("پاسخ نامعتبر از سرویس هوش مصنوعی"); throw new InvalidOperationException("پاسخ نامعتبر از سرویس هوش مصنوعی");
await LogAiQueryAsync(
aiResult,
question,
aiResponse.Choices[0].Message!.Content,
request.DeviceId,
null,
0.7,
stopwatch.ElapsedMilliseconds,
cancellationToken);
return new DailyReportResponse return new DailyReportResponse
{ {
Id = 0, Id = 0,
@@ -410,4 +429,52 @@ public sealed class DailyReportService : IDailyReportService
FromCache = false FromCache = false
}; };
} }
// ==================== Helpers ====================
/// <summary>
/// Saves an AIQuery log row for an analysis call (profile + tokens + cost),
/// so per-profile usage totals and the daily usage chart include report
/// analyses. Never throws — logging must not break report generation.
/// </summary>
private async Task LogAiQueryAsync(
AIAskResult result,
string question,
string answer,
int? deviceId,
int? userId,
double temperature,
long responseTimeMs,
CancellationToken cancellationToken)
{
try
{
var profile = result.Profile;
var usage = result.Response?.Usage;
var query = new Domain.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
};
await _aiQueryService.SaveQueryAsync(query, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "خطا در ذخیره لاگ کوئری AI برای گزارش");
}
}
} }

View File

@@ -21,6 +21,7 @@ import AdminPostsPage from '@/app/admin/posts/page'
import AdminUsagePage from '@/app/admin/usage/page' import AdminUsagePage from '@/app/admin/usage/page'
import AdminReportsPage from '@/app/admin/reports/page' import AdminReportsPage from '@/app/admin/reports/page'
import AdminAuditPage from '@/app/admin/audit/page' import AdminAuditPage from '@/app/admin/audit/page'
import AdminAIPage from '@/app/admin/ai/page'
function DeviceRedirect() { function DeviceRedirect() {
const location = useLocation() const location = useLocation()
@@ -55,6 +56,7 @@ export default function App() {
<Route path="usage" element={<AdminUsagePage />} /> <Route path="usage" element={<AdminUsagePage />} />
<Route path="reports" element={<AdminReportsPage />} /> <Route path="reports" element={<AdminReportsPage />} />
<Route path="audit" element={<AdminAuditPage />} /> <Route path="audit" element={<AdminAuditPage />} />
<Route path="ai" element={<AdminAIPage />} />
</Route> </Route>
<Route path="/alert-settings" element={<DeviceRedirect />} /> <Route path="/alert-settings" element={<DeviceRedirect />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -3,7 +3,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Outlet, useLocation } from 'react-router-dom' import { Outlet, useLocation } from 'react-router-dom'
import { Link, useRouter } from '@/lib/router' import { Link, useRouter } from '@/lib/router'
import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight, Bell, MessageSquare, ScrollText, LogOut, FileBarChart } from 'lucide-react' import { LayoutDashboard, Users, Cpu, BarChart3, ArrowRight, Bell, MessageSquare, ScrollText, LogOut, FileBarChart, BrainCircuit } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { Badge } from '@/components/common/Badge' import { Badge } from '@/components/common/Badge'
import { Button } from '@/components/common/Button' import { Button } from '@/components/common/Button'
@@ -15,6 +15,7 @@ const NAV_ITEMS = [
{ href: '/admin/alerts', label: 'اعلان‌ها', icon: Bell, exact: false }, { href: '/admin/alerts', label: 'اعلان‌ها', icon: Bell, exact: false },
{ href: '/admin/posts', label: 'پست‌ها', icon: MessageSquare, exact: false }, { href: '/admin/posts', label: 'پست‌ها', icon: MessageSquare, exact: false },
{ href: '/admin/usage', label: 'مصرف', icon: BarChart3, exact: false }, { href: '/admin/usage', label: 'مصرف', icon: BarChart3, exact: false },
{ href: '/admin/ai', label: 'هوش مصنوعی', icon: BrainCircuit, exact: false },
{ href: '/admin/reports', label: 'گزارش‌ها', icon: FileBarChart, exact: false }, { href: '/admin/reports', label: 'گزارش‌ها', icon: FileBarChart, exact: false },
{ href: '/admin/audit', label: 'گزارش اعمال', icon: ScrollText, exact: false }, { href: '/admin/audit', label: 'گزارش اعمال', icon: ScrollText, exact: false },
] ]

View File

@@ -0,0 +1,520 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import {
BrainCircuit, Plus, Pencil, Trash2, RefreshCw, Check, DollarSign,
Hash, Zap, List, Loader2, Wallet,
} from 'lucide-react'
import { api, AiProfile, AiProfileRequest, AiProfileUsageDay, AiBalance } from '@/lib/api'
import { toPersianDigits } from '@/lib/format/persian-digits'
import { formatPersianDateFromIso } from '@/lib/date/persian-date'
import { Button } from '@/components/common/Button'
import { Badge } from '@/components/common/Badge'
import { Modal } from '@/components/common/Modal'
import { FormInput } from '@/components/forms/FormInput'
import { Select } from '@/components/forms/Select'
import { LineChart } from '@/components/Charts'
const PROVIDER_LABELS: Record<number, string> = {
0: 'DeepSeek',
1: 'OpenAI',
2: 'سازگار با OpenAI',
}
function toIsoDate(d: Date): string {
return d.toISOString().slice(0, 10)
}
function daysAgo(n: number): string {
const d = new Date()
d.setDate(d.getDate() - n)
return toIsoDate(d)
}
function providerOf(p: number): string {
return PROVIDER_LABELS[p] ?? 'نامشخص'
}
// ==================== Main Page ====================
export default function AdminAIPage() {
const [profiles, setProfiles] = useState<AiProfile[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [formOpen, setFormOpen] = useState(false)
const [editing, setEditing] = useState<AiProfile | null>(null)
const [saving, setSaving] = useState(false)
const [busyId, setBusyId] = useState<number | null>(null)
const [expandedId, setExpandedId] = useState<number | null>(null)
const fetchProfiles = useCallback(async () => {
setLoading(true)
setError('')
try {
const data = await api.adminAiProfiles()
setProfiles(data)
} catch (e) {
setError((e as Error).message || 'خطا در دریافت پروفایل‌ها')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchProfiles()
}, [fetchProfiles])
const handleSave = async (req: AiProfileRequest) => {
setSaving(true)
setError('')
try {
if (editing) {
await api.adminAiProfileUpdate(editing.id, req)
} else {
await api.adminAiProfileCreate(req)
}
setFormOpen(false)
setEditing(null)
await fetchProfiles()
} catch (e) {
setError((e as Error).message || 'خطا در ذخیره پروفایل')
} finally {
setSaving(false)
}
}
const handleActivate = async (id: number) => {
setBusyId(id)
setError('')
try {
await api.adminAiProfileActivate(id)
await fetchProfiles()
} catch (e) {
setError((e as Error).message || 'خطا در فعال‌سازی پروفایل')
} finally {
setBusyId(null)
}
}
const handleDelete = async (id: number) => {
if (!window.confirm('آیا از حذف این پروفایل مطمئن هستید؟')) return
setBusyId(id)
setError('')
try {
await api.adminAiProfileDelete(id)
if (expandedId === id) setExpandedId(null)
await fetchProfiles()
} catch (e) {
setError((e as Error).message || 'خطا در حذف پروفایل')
} finally {
setBusyId(null)
}
}
const handleRefreshBalance = async (id: number) => {
setBusyId(id)
setError('')
try {
const res = await api.adminAiProfileRefreshBalance(id)
if (!res.success && res.message) {
setError(res.message)
}
await fetchProfiles()
} catch (e) {
setError((e as Error).message || 'خطا در دریافت موجودی')
} finally {
setBusyId(null)
}
}
const totals = profiles.reduce(
(acc, p) => ({
queries: acc.queries + p.queryCount,
tokens: acc.tokens + p.totalTokens,
cost: acc.cost + p.totalCostUsd,
}),
{ queries: 0, tokens: 0, cost: 0 }
)
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<h2 className="text-base font-bold text-gray-900">پروفایل‌های هوش مصنوعی</h2>
<Button
size="sm"
variant="success"
onClick={() => { setEditing(null); setFormOpen(true) }}
>
<Plus className="w-4 h-4" />
افزودن پروفایل
</Button>
</div>
<div className="grid grid-cols-3 gap-3">
<SummaryCard icon={Hash} label="کل درخواست AI" value={toPersianDigits(totals.queries)} />
<SummaryCard icon={Zap} label="کل توکن AI" value={toPersianDigits(totals.tokens)} />
<SummaryCard icon={DollarSign} label="کل هزینه (دلار)" value={totals.cost.toLocaleString('en', { maximumFractionDigits: 4 })} />
</div>
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl p-3">{error}</p>}
{loading ? (
<div className="bg-white rounded-xl border border-gray-200 divide-y divide-gray-100">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="p-4 animate-pulse">
<div className="h-4 w-1/3 bg-gray-100 rounded" />
</div>
))}
</div>
) : profiles.length > 0 ? (
<div className="space-y-3">
{profiles.map((p) => (
<ProfileCard
key={p.id}
profile={p}
busy={busyId === p.id}
expanded={expandedId === p.id}
onToggleExpand={() => setExpandedId(expandedId === p.id ? null : p.id)}
onActivate={() => handleActivate(p.id)}
onDelete={() => handleDelete(p.id)}
onRefreshBalance={() => handleRefreshBalance(p.id)}
onEdit={() => { setEditing(p); setFormOpen(true) }}
/>
))}
</div>
) : (
<div className="bg-white rounded-xl border border-gray-200 p-8 text-center">
<BrainCircuit className="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p className="text-sm text-gray-500">هنوز پروفایلی ثبت نشده است.</p>
<p className="text-xs text-gray-400 mt-1">اولین پروفایل AI را اضافه کنید تا تحلیل‌ها از طریق آن انجام شود.</p>
</div>
)}
<ProfileFormModal
isOpen={formOpen}
editing={editing}
saving={saving}
onClose={() => { setFormOpen(false); setEditing(null) }}
onSave={handleSave}
/>
</div>
)
}
function SummaryCard({ icon: Icon, label, value }: { icon: typeof BrainCircuit; label: string; value: string }) {
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gray-50 flex items-center justify-center">
<Icon className="w-5 h-5 text-gray-500" />
</div>
<div>
<p className="text-xl font-bold text-gray-900 leading-tight">{value}</p>
<p className="text-xs text-gray-500">{label}</p>
</div>
</div>
</div>
)
}
// ==================== Profile Card ====================
function ProfileCard({
profile, busy, expanded, onToggleExpand, onActivate, onDelete, onRefreshBalance, onEdit,
}: {
profile: AiProfile
busy: boolean
expanded: boolean
onToggleExpand: () => void
onActivate: () => void
onDelete: () => void
onRefreshBalance: () => void
onEdit: () => void
}) {
const balanceText = profile.balanceUsd != null
? `${profile.balanceUsd.toLocaleString('en', { maximumFractionDigits: 4 })} ${profile.balanceCurrency ?? ''}`
: null
return (
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden">
<div className="p-4">
<div className="flex items-start justify-between gap-3 flex-wrap">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-bold text-gray-900">{profile.name}</h3>
{profile.isActive && <Badge variant="success">فعال</Badge>}
<Badge variant="default">{providerOf(profile.provider)}</Badge>
</div>
<p className="text-xs text-gray-500 mt-1 truncate" dir="ltr" style={{ textAlign: 'right' }}>
{profile.model}
</p>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{balanceText && (
<Badge variant="default" icon={Wallet}>{balanceText}</Badge>
)}
<Button size="sm" variant="default" onClick={onToggleExpand}>
<List className="w-4 h-4" />
مصرف
</Button>
<Button size="sm" variant="default" onClick={onRefreshBalance} disabled={busy} title="به‌روزرسانی موجودی">
{busy ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
</Button>
<Button size="sm" variant="default" onClick={onEdit} disabled={busy}>
<Pencil className="w-4 h-4" />
</Button>
<Button size="sm" variant="danger" onClick={onDelete} disabled={busy}>
<Trash2 className="w-4 h-4" />
</Button>
{!profile.isActive && (
<Button size="sm" variant="success" onClick={onActivate} disabled={busy}>
<Check className="w-4 h-4" />
فعال‌سازی
</Button>
)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mt-4 text-center">
<Stat label="درخواست" value={toPersianDigits(profile.queryCount)} />
<Stat label="توکن" value={toPersianDigits(profile.totalTokens)} />
<Stat label="هزینه (دلار)" value={profile.totalCostUsd.toLocaleString('en', { maximumFractionDigits: 4 })} />
<Stat
label="به‌روزرسانی موجودی"
value={profile.balanceUpdatedAt ? formatPersianDateFromIso(profile.balanceUpdatedAt) : '—'}
/>
</div>
</div>
{expanded && <UsageSection profileId={profile.id} profileName={profile.name} />}
</div>
)
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div className="bg-gray-50 rounded-lg p-2">
<p className="text-sm font-bold text-gray-900">{value}</p>
<p className="text-[11px] text-gray-500">{label}</p>
</div>
)
}
// ==================== Usage (chart + table) ====================
function UsageSection({ profileId, profileName }: { profileId: number; profileName: string }) {
const [days, setDays] = useState<AiProfileUsageDay[] | null>(null)
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
api.adminAiProfileUsageDaily(profileId, { from: daysAgo(29), to: toIsoDate(new Date()) })
.then((data) => { if (!cancelled) setDays(data) })
.catch((e) => { if (!cancelled) setError((e as Error).message || 'خطا در دریافت مصرف') })
return () => { cancelled = true }
}, [profileId])
if (error) return <p className="text-sm text-red-600 px-4 pb-4">{error}</p>
if (!days) {
return (
<div className="px-4 pb-4">
<div className="h-48 bg-gray-50 rounded-lg animate-pulse" />
</div>
)
}
if (days.length === 0) {
return <p className="text-xs text-gray-500 px-4 pb-4">در ۳۰ روز اخیر مصرفی برای «{profileName}» ثبت نشده است.</p>
}
const labels = days.map((d) => formatPersianDateFromIso(d.date))
const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date))
return (
<div className="border-t border-gray-100 p-4 space-y-3">
<h4 className="text-sm font-bold text-gray-700">مصرف روزانه ۳۰ روز اخیر</h4>
<LineChart
labels={labels}
series={[
{ label: 'توکن', data: sorted.map((d) => d.totalTokens), borderColor: '#16a34a', fill: true },
{ label: 'هزینه (دلار)', data: sorted.map((d) => d.costUsd), borderColor: '#f59e0b' },
]}
/>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 border-b border-gray-200">
<th className="text-right font-medium py-2 px-3">تاریخ</th>
<th className="text-left font-medium py-2 px-3">درخواست</th>
<th className="text-left font-medium py-2 px-3">توکن ورودی</th>
<th className="text-left font-medium py-2 px-3">توکن خروجی</th>
<th className="text-left font-medium py-2 px-3">کل توکن</th>
<th className="text-left font-medium py-2 px-3">هزینه (دلار)</th>
</tr>
</thead>
<tbody>
{[...days].reverse().map((d) => (
<tr key={d.date} className="border-b border-gray-100 last:border-0">
<td className="py-2 px-3 text-gray-900">{formatPersianDateFromIso(d.date)}</td>
<td className="py-2 px-3 text-left tabular-nums text-gray-700">{toPersianDigits(d.queryCount)}</td>
<td className="py-2 px-3 text-left tabular-nums text-gray-700">{toPersianDigits(d.promptTokens)}</td>
<td className="py-2 px-3 text-left tabular-nums text-gray-700">{toPersianDigits(d.completionTokens)}</td>
<td className="py-2 px-3 text-left tabular-nums text-gray-700">{toPersianDigits(d.totalTokens)}</td>
<td className="py-2 px-3 text-left tabular-nums text-gray-700">{d.costUsd.toLocaleString('en', { maximumFractionDigits: 6 })}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ==================== Form Modal ====================
const emptyForm = {
name: '', provider: '0', baseUrl: '', apiKey: '', model: '',
temperature: '', maxTokens: '', promptPrice: '', completionPrice: '',
}
function ProfileFormModal({
isOpen, editing, saving, onClose, onSave,
}: {
isOpen: boolean
editing: AiProfile | null
saving: boolean
onClose: () => void
onSave: (req: AiProfileRequest) => void
}) {
const [form, setForm] = useState(emptyForm)
const [error, setError] = useState('')
const [models, setModels] = useState<string[]>([])
const [fetchingModels, setFetchingModels] = useState(false)
useEffect(() => {
if (!isOpen) return
setError('')
setModels([])
if (editing) {
setForm({
name: editing.name,
provider: String(editing.provider),
baseUrl: editing.baseUrl ?? '',
apiKey: '',
model: editing.model,
temperature: editing.temperature != null ? String(editing.temperature) : '',
maxTokens: '',
promptPrice: editing.promptPricePer1M != null ? String(editing.promptPricePer1M) : '',
completionPrice: editing.completionPricePer1M != null ? String(editing.completionPricePer1M) : '',
})
} else {
setForm(emptyForm)
}
}, [isOpen, editing])
const set = (key: keyof typeof emptyForm) => (v: string) => setForm((f) => ({ ...f, [key]: v }))
const handleFetchModels = async () => {
// For a new profile we can't call the models endpoint (no id yet).
// ponytail: fetch-models only supported on existing profiles; new profiles use a free-text model field.
if (!editing) return
setFetchingModels(true)
setError('')
try {
const list = await api.adminAiProfileModels(editing.id)
setModels(list)
if (list.length === 0) setError('لیست مدل‌های خالی برگشت داده شد.')
} catch (e) {
setError((e as Error).message || 'خطا در دریافت لیست مدل‌ها')
} finally {
setFetchingModels(false)
}
}
const handleSubmit = () => {
if (!form.name.trim()) return setError('نام پروفایل الزامی است')
if (!form.apiKey.trim()) return setError('کلید API الزامی است')
if (!form.model.trim()) return setError('مدل الزامی است')
const req: AiProfileRequest = {
name: form.name.trim(),
provider: Number(form.provider) as 0 | 1 | 2,
baseUrl: form.baseUrl.trim() || null,
apiKey: form.apiKey.trim(),
model: form.model.trim(),
temperature: form.temperature ? Number(form.temperature) : null,
maxTokens: form.maxTokens ? Number(form.maxTokens) : null,
promptPricePer1M: form.promptPrice ? Number(form.promptPrice) : null,
completionPricePer1M: form.completionPrice ? Number(form.completionPrice) : null,
}
onSave(req)
}
return (
<Modal isOpen={isOpen} onClose={onClose} title={editing ? 'ویرایش پروفایل AI' : 'افزودن پروفایل AI'} size="lg">
<div className="space-y-4">
{error && <p className="text-sm text-red-600 bg-red-50 rounded-xl p-3">{error}</p>}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormInput label="نام پروفایل" value={form.name} onChange={(e) => set('name')(e.target.value)} placeholder="مثلاً DeepSeek اصلی" />
<Select label="نوع سرویس" value={form.provider} onChange={(e) => set('provider')(e.target.value)}>
<option value={0}>DeepSeek</option>
<option value={1}>OpenAI</option>
<option value={2}>سازگار با OpenAI</option>
</Select>
</div>
<FormInput
label="Base URL"
value={form.baseUrl}
onChange={(e) => set('baseUrl')(e.target.value)}
placeholder="https://api.deepseek.com/v1"
helperText="برای DeepSeek و OpenAI پیش‌فرض وجود دارد؛ برای سرویس سازگار با OpenAI الزامی است (با /v1)."
/>
<FormInput
label="کلید API"
type="password"
value={form.apiKey}
onChange={(e) => set('apiKey')(e.target.value)}
placeholder="sk-..."
helperText={editing ? 'خالی بگذارید تا کلید فعلی حفظ شود.' : undefined}
/>
<div>
<div className="flex items-end gap-2">
<div className="flex-1">
{models.length > 0 ? (
<Select label="مدل" value={form.model} onChange={(e) => set('model')(e.target.value)}>
<option value="" disabled>انتخاب مدل…</option>
{models.map((m) => <option key={m} value={m}>{m}</option>)}
</Select>
) : (
<FormInput label="مدل" value={form.model} onChange={(e) => set('model')(e.target.value)} placeholder="deepseek-chat" />
)}
</div>
{editing && (
<Button size="sm" variant="default" onClick={handleFetchModels} disabled={fetchingModels} className="mb-0.5">
{fetchingModels ? <Loader2 className="w-4 h-4 animate-spin" /> : <List className="w-4 h-4" />}
مدل‌ها
</Button>
)}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<FormInput label="دما (Temperature)" type="number" step="0.1" value={form.temperature} onChange={(e) => set('temperature')(e.target.value)} placeholder="0.7" />
<FormInput label="حداکثر توکن" type="number" value={form.maxTokens} onChange={(e) => set('maxTokens')(e.target.value)} />
<FormInput label="قیمت توکن ورودی (به ازای ۱M)" type="number" step="0.0001" value={form.promptPrice} onChange={(e) => set('promptPrice')(e.target.value)} />
<FormInput label="قیمت توکن خروجی (به ازای ۱M)" type="number" step="0.0001" value={form.completionPrice} onChange={(e) => set('completionPrice')(e.target.value)} />
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-100">
<Button variant="default" onClick={onClose}>انصراف</Button>
<Button variant="success" onClick={handleSubmit} disabled={saving}>
{saving && <Loader2 className="w-4 h-4 animate-spin" />}
{editing ? 'ذخیره تغییرات' : 'افزودن'}
</Button>
</div>
</div>
</Modal>
)
}

View File

@@ -35,6 +35,10 @@ import type {
AdminUserGrowthRow, AdminUserGrowthRow,
AdminDeviceUptimeRow, AdminDeviceUptimeRow,
AdminUsageByUserRow, AdminUsageByUserRow,
AiProfile,
AiProfileRequest,
AiBalance,
AiProfileUsageDay,
} from './types' } from './types'
const API_BASE = import.meta.env.VITE_API_URL ?? '' const API_BASE = import.meta.env.VITE_API_URL ?? ''
@@ -297,6 +301,22 @@ export const api = {
http<AdminDeviceUptimeRow[]>(`${API_BASE}/api/admin/reports/devices-uptime${ReportParams(q)}`), http<AdminDeviceUptimeRow[]>(`${API_BASE}/api/admin/reports/devices-uptime${ReportParams(q)}`),
adminReportUsageByUser: (q: { from?: string; to?: string }) => adminReportUsageByUser: (q: { from?: string; to?: string }) =>
http<AdminUsageByUserRow[]>(`${API_BASE}/api/admin/reports/usage-by-user${ReportParams(q)}`), http<AdminUsageByUserRow[]>(`${API_BASE}/api/admin/reports/usage-by-user${ReportParams(q)}`),
// AI Profiles
adminAiProfiles: () => http<AiProfile[]>(`${API_BASE}/api/admin/ai-profiles`),
adminAiProfileCreate: (req: AiProfileRequest) =>
http<AiProfile>(`${API_BASE}/api/admin/ai-profiles`, { method: 'POST', body: JSON.stringify(req) }),
adminAiProfileUpdate: (id: number, req: AiProfileRequest) =>
http<AiProfile>(`${API_BASE}/api/admin/ai-profiles/${id}`, { method: 'PUT', body: JSON.stringify(req) }),
adminAiProfileDelete: (id: number) =>
http<void>(`${API_BASE}/api/admin/ai-profiles/${id}`, { method: 'DELETE' }),
adminAiProfileActivate: (id: number) =>
http<AiProfile>(`${API_BASE}/api/admin/ai-profiles/${id}/activate`, { method: 'POST' }),
adminAiProfileModels: (id: number) =>
http<string[]>(`${API_BASE}/api/admin/ai-profiles/${id}/models`),
adminAiProfileRefreshBalance: (id: number) =>
http<{ success: boolean; balance?: AiBalance; message?: string }>(`${API_BASE}/api/admin/ai-profiles/${id}/refresh-balance`, { method: 'POST' }),
adminAiProfileUsageDaily: (id: number, q: { from?: string; to?: string }) =>
http<AiProfileUsageDay[]>(`${API_BASE}/api/admin/ai-profiles/${id}/usage/daily${ReportParams(q)}`),
adminReportCsv: async (path: string, q: { from?: string; to?: string }) => { adminReportCsv: async (path: string, q: { from?: string; to?: string }) => {
const res = await fetch(`${API_BASE}/api/admin/reports/${path}${ReportParams(q)}&csv=1`, { const res = await fetch(`${API_BASE}/api/admin/reports/${path}${ReportParams(q)}&csv=1`, {
headers: csvAuthHeaders(), headers: csvAuthHeaders(),

View File

@@ -474,3 +474,55 @@ export type AdminAuditLogItem = {
at: string at: string
} }
// ==================== AI Profiles ====================
export type AiProvider = 0 | 1 | 2 // 0=DeepSeek, 1=OpenAI, 2=OpenAICompatible
export type AiProfile = {
id: number
name: string
provider: AiProvider
baseUrl: string
model: string
isActive: boolean
temperature?: number | null
promptPricePer1M?: number | null
completionPricePer1M?: number | null
balanceUsd?: number | null
balanceCurrency?: string | null
balanceUpdatedAt?: string | null
queryCount: number
totalPromptTokens: number
totalCompletionTokens: number
totalTokens: number
totalCostUsd: number
createdAt: string
updatedAt: string
}
export type AiProfileRequest = {
name: string
provider: AiProvider
baseUrl?: string | null
apiKey: string
model: string
temperature?: number | null
maxTokens?: number | null
promptPricePer1M?: number | null
completionPricePer1M?: number | null
}
export type AiBalance = {
amount: number
currency?: string | null
}
export type AiProfileUsageDay = {
date: string
queryCount: number
promptTokens: number
completionTokens: number
totalTokens: number
costUsd: number
}