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,10 +34,8 @@ 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();
|
||||
|
||||
@@ -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,10 +96,8 @@ 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();
|
||||
|
||||
@@ -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,10 +156,8 @@ 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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
100
src/GreenHome.Application/Dtos/AI/AIProfileDtos.cs
Normal file
100
src/GreenHome.Application/Dtos/AI/AIProfileDtos.cs
Normal 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; }
|
||||
}
|
||||
60
src/GreenHome.Domain/AIProfile.cs
Normal file
60
src/GreenHome.Domain/AIProfile.cs
Normal 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;
|
||||
}
|
||||
@@ -50,6 +50,21 @@ public class AIQuery
|
||||
/// </summary>
|
||||
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>
|
||||
/// Temperature parameter used
|
||||
/// </summary>
|
||||
|
||||
@@ -20,6 +20,7 @@ public sealed class GreenHomeDbContext : DbContext
|
||||
public DbSet<Domain.AlertNotification> AlertNotifications => Set<Domain.AlertNotification>();
|
||||
public DbSet<Domain.AlertLog> AlertLogs => Set<Domain.AlertLog>();
|
||||
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.UserDailyReport> UserDailyReports => Set<Domain.UserDailyReport>();
|
||||
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.Answer).IsRequired();
|
||||
b.Property(x => x.Model).HasMaxLength(100);
|
||||
b.Property(x => x.CostUsd).HasColumnType("decimal(18,8)");
|
||||
b.HasOne(x => x.Device)
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.DeviceId)
|
||||
@@ -244,11 +246,32 @@ public sealed class GreenHomeDbContext : DbContext
|
||||
.WithMany()
|
||||
.HasForeignKey(x => x.UserId)
|
||||
.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.UserId);
|
||||
b.HasIndex(x => x.ProfileId);
|
||||
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 =>
|
||||
{
|
||||
b.ToTable("DailyReports");
|
||||
|
||||
1681
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.Designer.cs
generated
Normal file
1681
src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,75 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -37,6 +106,9 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
||||
b.Property<int>("CompletionTokens")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("CostUsd")
|
||||
.HasColumnType("decimal(18,8)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -47,6 +119,9 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<int?>("ProfileId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("PromptTokens")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -72,6 +147,8 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
||||
|
||||
b.HasIndex("DeviceId");
|
||||
|
||||
b.HasIndex("ProfileId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AIQueries", (string)null);
|
||||
@@ -1201,6 +1278,11 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
||||
.HasForeignKey("DeviceId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GreenHome.Domain.AIProfile", "Profile")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProfileId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GreenHome.Domain.User", "User")
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
@@ -1208,6 +1290,8 @@ namespace GreenHome.Infrastructure.Data.Migrations
|
||||
|
||||
b.Navigation("Device");
|
||||
|
||||
b.Navigation("Profile");
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
|
||||
@@ -74,6 +74,8 @@ public static class DependencyInjection
|
||||
|
||||
// AI
|
||||
services.AddScoped<IAIQueryService, AIQueryService>();
|
||||
services.AddSingleton<AIChatClient>();
|
||||
services.AddScoped<IAIProfileService, AIProfileService>();
|
||||
|
||||
// Sun Calculator
|
||||
services.AddScoped<ISunCalculatorService, SunCalculatorService>();
|
||||
|
||||
141
src/GreenHome.Infrastructure/Services/AI/AIChatClient.cs
Normal file
141
src/GreenHome.Infrastructure/Services/AI/AIChatClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
348
src/GreenHome.Infrastructure/Services/AI/AIProfileService.cs
Normal file
348
src/GreenHome.Infrastructure/Services/AI/AIProfileService.cs
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using GreenHome.AI.DeepSeek;
|
||||
using GreenHome.Application;
|
||||
using GreenHome.Infrastructure.Data;
|
||||
using GreenHome.Infrastructure.Extensions;
|
||||
using GreenHome.Infrastructure.Services.AI;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -12,16 +13,19 @@ namespace GreenHome.Infrastructure.Services.Report;
|
||||
public sealed class DailyReportService : IDailyReportService
|
||||
{
|
||||
private readonly GreenHomeDbContext _dbContext;
|
||||
private readonly IDeepSeekService _deepSeekService;
|
||||
private readonly IAIProfileService _aiProfileService;
|
||||
private readonly IAIQueryService _aiQueryService;
|
||||
private readonly ILogger<DailyReportService> _logger;
|
||||
|
||||
public DailyReportService(
|
||||
GreenHomeDbContext context,
|
||||
IDeepSeekService deepSeekService,
|
||||
IAIProfileService aiProfileService,
|
||||
IAIQueryService aiQueryService,
|
||||
ILogger<DailyReportService> logger)
|
||||
{
|
||||
_dbContext = context;
|
||||
_deepSeekService = deepSeekService;
|
||||
_aiProfileService = aiProfileService;
|
||||
_aiQueryService = aiQueryService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -128,6 +132,7 @@ public sealed class DailyReportService : IDailyReportService
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
ChatResponse? aiResponse;
|
||||
AIAskResult? aiResult;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -135,18 +140,16 @@ public sealed class DailyReportService : IDailyReportService
|
||||
? $"تو یک متخصص کشاورزی و گلخانه هستی که در کشت {deviceSettings.ProductType} تخصص داری و دادههای تلمتری رو تحلیل میکنی."
|
||||
: "تو یک متخصص کشاورزی و گلخانه هستی که دادههای تلمتری رو تحلیل میکنی.";
|
||||
|
||||
var chatRequest = new ChatRequest
|
||||
{
|
||||
Model = "deepseek-chat",
|
||||
Messages = new List<ChatMessage>
|
||||
aiResult = await _aiProfileService.AskAsync(
|
||||
new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "system", Content = systemMessage },
|
||||
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)
|
||||
{
|
||||
@@ -183,6 +186,8 @@ public sealed class DailyReportService : IDailyReportService
|
||||
_dbContext.DailyReports.Add(dailyReport);
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await LogAiQueryAsync(aiResult!, question, analysis, request.DeviceId, null, 0.7, stopwatch.ElapsedMilliseconds, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"گزارش روزانه جدید برای دستگاه {DeviceId} و تاریخ {Date} ایجاد شد. توکن مصرف شده: {Tokens}",
|
||||
request.DeviceId, request.PersianDate, dailyReport.TotalTokens);
|
||||
@@ -281,24 +286,31 @@ public sealed class DailyReportService : IDailyReportService
|
||||
: "تو یک متخصص کشاورزی و گلخانه هستی که دادههای تلمتری رو تحلیل میکنی.";
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var chatRequest = new ChatRequest
|
||||
{
|
||||
Model = "deepseek-chat",
|
||||
Messages = new List<ChatMessage>
|
||||
var aiResult = await _aiProfileService.AskAsync(
|
||||
new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "system", Content = systemMessage },
|
||||
new() { Role = "user", Content = question }
|
||||
},
|
||||
Temperature = 0.7
|
||||
};
|
||||
|
||||
var aiResponse = await _deepSeekService.AskAsync(chatRequest, cancellationToken);
|
||||
temperature: 0.7,
|
||||
cancellationToken: cancellationToken);
|
||||
var aiResponse = aiResult.Response;
|
||||
stopwatch.Stop();
|
||||
|
||||
if (aiResponse?.Choices == null || aiResponse.Choices.Count == 0 ||
|
||||
string.IsNullOrWhiteSpace(aiResponse.Choices[0].Message?.Content))
|
||||
throw new InvalidOperationException("پاسخ نامعتبر از سرویس هوش مصنوعی");
|
||||
|
||||
await LogAiQueryAsync(
|
||||
aiResult,
|
||||
question,
|
||||
aiResponse.Choices[0].Message!.Content,
|
||||
request.DeviceId,
|
||||
null,
|
||||
0.7,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
cancellationToken);
|
||||
|
||||
return new DailyReportResponse
|
||||
{
|
||||
Id = 0,
|
||||
@@ -378,24 +390,31 @@ public sealed class DailyReportService : IDailyReportService
|
||||
: "تو یک متخصص کشاورزی و گلخانه هستی. تحلیلهای روزانه رو بررسی کن و یک جمعبندی ماهانه جامع ارائه بده.";
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var chatRequest = new ChatRequest
|
||||
{
|
||||
Model = "deepseek-chat",
|
||||
Messages = new List<ChatMessage>
|
||||
var aiResult = await _aiProfileService.AskAsync(
|
||||
new List<ChatMessage>
|
||||
{
|
||||
new() { Role = "system", Content = systemMessage },
|
||||
new() { Role = "user", Content = question }
|
||||
},
|
||||
Temperature = 0.7
|
||||
};
|
||||
|
||||
var aiResponse = await _deepSeekService.AskAsync(chatRequest, cancellationToken);
|
||||
temperature: 0.7,
|
||||
cancellationToken: cancellationToken);
|
||||
var aiResponse = aiResult.Response;
|
||||
stopwatch.Stop();
|
||||
|
||||
if (aiResponse?.Choices == null || aiResponse.Choices.Count == 0 ||
|
||||
string.IsNullOrWhiteSpace(aiResponse.Choices[0].Message?.Content))
|
||||
throw new InvalidOperationException("پاسخ نامعتبر از سرویس هوش مصنوعی");
|
||||
|
||||
await LogAiQueryAsync(
|
||||
aiResult,
|
||||
question,
|
||||
aiResponse.Choices[0].Message!.Content,
|
||||
request.DeviceId,
|
||||
null,
|
||||
0.7,
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
cancellationToken);
|
||||
|
||||
return new DailyReportResponse
|
||||
{
|
||||
Id = 0,
|
||||
@@ -410,4 +429,52 @@ public sealed class DailyReportService : IDailyReportService
|
||||
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 برای گزارش");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import AdminPostsPage from '@/app/admin/posts/page'
|
||||
import AdminUsagePage from '@/app/admin/usage/page'
|
||||
import AdminReportsPage from '@/app/admin/reports/page'
|
||||
import AdminAuditPage from '@/app/admin/audit/page'
|
||||
import AdminAIPage from '@/app/admin/ai/page'
|
||||
|
||||
function DeviceRedirect() {
|
||||
const location = useLocation()
|
||||
@@ -55,6 +56,7 @@ export default function App() {
|
||||
<Route path="usage" element={<AdminUsagePage />} />
|
||||
<Route path="reports" element={<AdminReportsPage />} />
|
||||
<Route path="audit" element={<AdminAuditPage />} />
|
||||
<Route path="ai" element={<AdminAIPage />} />
|
||||
</Route>
|
||||
<Route path="/alert-settings" element={<DeviceRedirect />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet, useLocation } from 'react-router-dom'
|
||||
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 { Badge } from '@/components/common/Badge'
|
||||
import { Button } from '@/components/common/Button'
|
||||
@@ -15,6 +15,7 @@ const NAV_ITEMS = [
|
||||
{ href: '/admin/alerts', label: 'اعلانها', icon: Bell, exact: false },
|
||||
{ href: '/admin/posts', label: 'پستها', icon: MessageSquare, 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/audit', label: 'گزارش اعمال', icon: ScrollText, exact: false },
|
||||
]
|
||||
|
||||
520
src/GreenHome.Web/src/app/admin/ai/page.tsx
Normal file
520
src/GreenHome.Web/src/app/admin/ai/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,10 @@ import type {
|
||||
AdminUserGrowthRow,
|
||||
AdminDeviceUptimeRow,
|
||||
AdminUsageByUserRow,
|
||||
AiProfile,
|
||||
AiProfileRequest,
|
||||
AiBalance,
|
||||
AiProfileUsageDay,
|
||||
} from './types'
|
||||
|
||||
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)}`),
|
||||
adminReportUsageByUser: (q: { from?: string; to?: string }) =>
|
||||
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 }) => {
|
||||
const res = await fetch(`${API_BASE}/api/admin/reports/${path}${ReportParams(q)}&csv=1`, {
|
||||
headers: csvAuthHeaders(),
|
||||
|
||||
@@ -474,3 +474,55 @@ export type AdminAuditLogItem = {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user