diff --git a/src/GreenHome.Api/Controllers/AIController.cs b/src/GreenHome.Api/Controllers/AIController.cs index 22dd726..f06faaf 100644 --- a/src/GreenHome.Api/Controllers/AIController.cs +++ b/src/GreenHome.Api/Controllers/AIController.cs @@ -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; +/// +/// 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. +/// [ApiController] [Route("api/[controller]")] public class AIController : ControllerBase { - private readonly IDeepSeekService deepSeekService; + private readonly IAIProfileService aiProfileService; private readonly IAIQueryService aiQueryService; private readonly ILogger logger; public AIController( - IDeepSeekService deepSeekService, + IAIProfileService aiProfileService, IAIQueryService aiQueryService, ILogger logger) { - this.deepSeekService = deepSeekService; + this.aiProfileService = aiProfileService; this.aiQueryService = aiQueryService; this.logger = logger; } @@ -27,13 +34,11 @@ public class AIController : ControllerBase /// /// Send a simple question to the AI and get a response /// - /// Question request - /// AI response [HttpPost("ask")] - public async Task AskQuestion([FromBody] SimpleQuestionRequest request) + public async Task AskQuestion([FromBody] SimpleQuestionRequest request, CancellationToken cancellationToken) { var stopwatch = Stopwatch.StartNew(); - + try { if (string.IsNullOrWhiteSpace(request.Question)) @@ -43,7 +48,6 @@ public class AIController : ControllerBase logger.LogInformation("Processing AI question: {Question}", request.Question); - // Build chat request to get token information var messages = new List(); if (!string.IsNullOrWhiteSpace(request.SystemPrompt)) { @@ -51,33 +55,19 @@ public class AIController : ControllerBase } messages.Add(new ChatMessage { Role = "user", Content = request.Question }); - var chatRequest = new ChatRequest { Messages = messages }; - var chatResponse = await deepSeekService.AskAsync(chatRequest); + var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken); stopwatch.Stop(); - if (chatResponse == null || chatResponse.Choices == null || !chatResponse.Choices.Any()) + if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any()) { return StatusCode(500, new { error = "No response received from AI" }); } - var answer = chatResponse.Choices.FirstOrDefault()?.Message?.Content ?? ""; + var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? ""; - // Save to database - var aiQuery = new AIQuery - { - Question = request.Question, - Answer = answer, - DeviceId = request.DeviceId, - UserId = request.UserId, - Model = chatResponse.Model, - PromptTokens = chatResponse.Usage?.PromptTokens ?? 0, - CompletionTokens = chatResponse.Usage?.CompletionTokens ?? 0, - TotalTokens = chatResponse.Usage?.TotalTokens ?? 0, - ResponseTimeMs = stopwatch.ElapsedMilliseconds - }; - - await aiQueryService.SaveQueryAsync(aiQuery); + var aiQuery = BuildQuery(result, request.Question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds); + await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken); return Ok(new { @@ -90,6 +80,8 @@ public class AIController : ControllerBase completion = aiQuery.CompletionTokens, total = aiQuery.TotalTokens }, + costUsd = aiQuery.CostUsd, + profileId = aiQuery.ProfileId, responseTimeMs = aiQuery.ResponseTimeMs, timestamp = DateTime.UtcNow }); @@ -104,13 +96,11 @@ public class AIController : ControllerBase /// /// Send a complex chat request with multiple messages to the AI /// - /// Extended chat request with deviceId - /// AI chat response [HttpPost("chat")] - public async Task Chat([FromBody] ExtendedChatRequest request) + public async Task Chat([FromBody] ExtendedChatRequest request, CancellationToken cancellationToken) { var stopwatch = Stopwatch.StartNew(); - + try { if (request.Messages == null || !request.Messages.Any()) @@ -120,46 +110,41 @@ public class AIController : ControllerBase logger.LogInformation("Processing AI chat with {MessageCount} messages", request.Messages.Count); - var chatRequest = new ChatRequest - { - Messages = request.Messages, - Model = request.Model, - Temperature = request.Temperature, - MaxTokens = request.MaxTokens - }; - - var response = await deepSeekService.AskAsync(chatRequest); + // ponytail: the active profile's model wins over request.Model — analysis is + // always supposed to run on the configured profile; temperature/maxTokens still apply. + var result = await aiProfileService.AskAsync( + request.Messages, + temperature: request.Temperature, + maxTokens: request.MaxTokens, + cancellationToken: cancellationToken); stopwatch.Stop(); - if (response == null || response.Choices == null || !response.Choices.Any()) + if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any()) { return StatusCode(500, new { error = "No response received from AI" }); } - // Extract question and answer var userMessage = request.Messages.LastOrDefault(m => m.Role == "user"); var question = userMessage?.Content ?? "Complex chat"; - var answer = response.Choices.FirstOrDefault()?.Message?.Content ?? ""; + var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? ""; - // Save to database - var aiQuery = new AIQuery + var aiQuery = BuildQuery(result, question, answer, request.DeviceId, request.UserId, request.Temperature, stopwatch.ElapsedMilliseconds); + await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken); + + var response = result.Response; + return Ok(new { - Question = question, - Answer = answer, - DeviceId = request.DeviceId, - UserId = request.UserId, - Model = response.Model, - Temperature = request.Temperature, - PromptTokens = response.Usage?.PromptTokens ?? 0, - CompletionTokens = response.Usage?.CompletionTokens ?? 0, - TotalTokens = response.Usage?.TotalTokens ?? 0, - ResponseTimeMs = stopwatch.ElapsedMilliseconds - }; - - await aiQueryService.SaveQueryAsync(aiQuery); - - return Ok(response); + response.Id, + response.Object, + response.Created, + response.Model, + response.Choices, + response.Usage, + costUsd = aiQuery.CostUsd, + profileId = aiQuery.ProfileId, + responseTimeMs = aiQuery.ResponseTimeMs + }); } catch (Exception ex) { @@ -171,13 +156,11 @@ public class AIController : ControllerBase /// /// Get suggestions for smart home automation based on device data /// - /// Device context request - /// AI suggestions [HttpPost("suggest")] - public async Task GetSuggestions([FromBody] SuggestionRequest request) + public async Task GetSuggestions([FromBody] SuggestionRequest request, CancellationToken cancellationToken) { var stopwatch = Stopwatch.StartNew(); - + try { var systemPrompt = @"شما یک مشاور هوشمند خانه هوشمند هستید. بر اساس داده‌های دستگاه‌های IoT، پیشنهادهای عملی و مفید برای بهینه‌سازی مصرف انرژی، راحتی و امنیت ارائه دهید. پاسخ را به زبان فارسی و به صورت خلاصه و کاربردی بنویسید."; @@ -193,33 +176,19 @@ public class AIController : ControllerBase new ChatMessage { Role = "user", Content = question } }; - var chatRequest = new ChatRequest { Messages = messages }; - var chatResponse = await deepSeekService.AskAsync(chatRequest); + var result = await aiProfileService.AskAsync(messages, cancellationToken: cancellationToken); stopwatch.Stop(); - if (chatResponse == null || chatResponse.Choices == null || !chatResponse.Choices.Any()) + if (result.Response == null || result.Response.Choices == null || !result.Response.Choices.Any()) { return StatusCode(500, new { error = "No suggestions received from AI" }); } - var answer = chatResponse.Choices.FirstOrDefault()?.Message?.Content ?? ""; + var answer = result.Response.Choices.FirstOrDefault()?.Message?.Content ?? ""; - // Save to database - var aiQuery = new AIQuery - { - Question = question, - Answer = answer, - DeviceId = request.DeviceId, - UserId = request.UserId, - Model = chatResponse.Model, - PromptTokens = chatResponse.Usage?.PromptTokens ?? 0, - CompletionTokens = chatResponse.Usage?.CompletionTokens ?? 0, - TotalTokens = chatResponse.Usage?.TotalTokens ?? 0, - ResponseTimeMs = stopwatch.ElapsedMilliseconds - }; - - await aiQueryService.SaveQueryAsync(aiQuery); + var aiQuery = BuildQuery(result, question, answer, request.DeviceId, request.UserId, null, stopwatch.ElapsedMilliseconds); + await aiQueryService.SaveQueryAsync(aiQuery, cancellationToken); return Ok(new { @@ -231,6 +200,8 @@ public class AIController : ControllerBase completion = aiQuery.CompletionTokens, total = aiQuery.TotalTokens }, + costUsd = aiQuery.CostUsd, + profileId = aiQuery.ProfileId, responseTimeMs = aiQuery.ResponseTimeMs, timestamp = DateTime.UtcNow }); @@ -246,12 +217,12 @@ public class AIController : ControllerBase /// Get AI query history for a device /// [HttpGet("history/device/{deviceId}")] - public async Task GetDeviceHistory(int deviceId, [FromQuery] int take = 50) + public async Task 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 /// [HttpGet("stats")] - public async Task GetStats() + public async Task 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" }); } } + + /// + /// Builds an AIQuery row from a profile ask result, attaching profile, + /// tokens and the estimated cost. + /// + 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; + } } /// @@ -301,24 +310,9 @@ public class AIController : ControllerBase /// public class SimpleQuestionRequest { - /// - /// The question to ask the AI - /// public required string Question { get; set; } - - /// - /// Optional system prompt to set context for the AI - /// public string? SystemPrompt { get; set; } - - /// - /// Optional device ID to associate with this query - /// public int? DeviceId { get; set; } - - /// - /// Optional user ID to associate with this query - /// public int? UserId { get; set; } } @@ -328,7 +322,7 @@ public class SimpleQuestionRequest public class ExtendedChatRequest { public required List 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 /// public class SuggestionRequest { - /// - /// Context about devices and their current state - /// public required string DeviceContext { get; set; } - - /// - /// Device ID for this suggestion request - /// public int? DeviceId { get; set; } - - /// - /// User ID for this suggestion request - /// public int? UserId { get; set; } } - diff --git a/src/GreenHome.Api/Controllers/AdminController.cs b/src/GreenHome.Api/Controllers/AdminController.cs index 500dabf..975a570 100644 --- a/src/GreenHome.Api/Controllers/AdminController.cs +++ b/src/GreenHome.Api/Controllers/AdminController.cs @@ -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>> AiProfiles(CancellationToken cancellationToken) + { + var result = await _aiProfileService.GetProfilesAsync(cancellationToken); + return Ok(result); + } + + [HttpPost("ai-profiles")] + public async Task 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 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 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 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 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 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>> 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); + } } diff --git a/src/GreenHome.Application/Dtos/AI/AIProfileDtos.cs b/src/GreenHome.Application/Dtos/AI/AIProfileDtos.cs new file mode 100644 index 0000000..7098ee8 --- /dev/null +++ b/src/GreenHome.Application/Dtos/AI/AIProfileDtos.cs @@ -0,0 +1,100 @@ +using GreenHome.Domain; + +namespace GreenHome.Application; + +// ==================== AI Profile ==================== + +/// +/// An AI profile as shown in the admin list, with aggregated usage. +/// +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; } +} + +/// +/// Best-effort account balance for a provider (null = unavailable). +/// +public sealed class AIBalance +{ + public decimal Amount { get; set; } + public string? Currency { get; set; } +} + +// ==================== AI Profile Usage ==================== + +/// +/// Aggregated usage for a single profile. +/// +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; } +} + +/// +/// One day of AI usage for a profile (for charting). +/// +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; } +} diff --git a/src/GreenHome.Domain/AIProfile.cs b/src/GreenHome.Domain/AIProfile.cs new file mode 100644 index 0000000..8ac00a6 --- /dev/null +++ b/src/GreenHome.Domain/AIProfile.cs @@ -0,0 +1,60 @@ +namespace GreenHome.Domain; + +/// +/// Supported AI providers. +/// DeepSeek and OpenAI are first-party; OpenAICompatible covers any OpenAI-compatible endpoint. +/// +public enum AIProvider +{ + DeepSeek = 0, + OpenAI = 1, + OpenAICompatible = 2 +} + +/// +/// A configured AI account used for analysis. +/// Only one profile can be active at a time; analysis always runs against the active profile. +/// +public class AIProfile +{ + public int Id { get; set; } + + /// Human-friendly name chosen by the admin. + public required string Name { get; set; } + + public AIProvider Provider { get; set; } + + /// + /// API root. DeepSeek: https://api.deepseek.com | OpenAI: https://api.openai.com/v1 | Compatible: user-provided. + /// + 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; } + + /// True for the single profile used for all analysis. + public bool IsActive { get; set; } + + /// Input (prompt) price per 1M tokens, in USD. Null = unknown. + public decimal? PromptPricePer1M { get; set; } + + /// Output (completion) price per 1M tokens, in USD. Null = unknown. + public decimal? CompletionPricePer1M { get; set; } + + /// Last known account balance (best-effort, provider dependent). + public decimal? BalanceUsd { get; set; } + + /// Currency code of BalanceUsd (e.g. USD, CNY). Null = unknown / unavailable. + public string? BalanceCurrency { get; set; } + + public DateTime? BalanceUpdatedAt { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/GreenHome.Domain/AIQuery.cs b/src/GreenHome.Domain/AIQuery.cs index 0ace18b..7729cab 100644 --- a/src/GreenHome.Domain/AIQuery.cs +++ b/src/GreenHome.Domain/AIQuery.cs @@ -50,6 +50,21 @@ public class AIQuery /// public string? Model { get; set; } + /// + /// AI profile used for this query + /// + public int? ProfileId { get; set; } + + /// + /// Navigation property to AIProfile + /// + public AIProfile? Profile { get; set; } + + /// + /// Estimated cost of the query in USD (0 = not calculable) + /// + public decimal CostUsd { get; set; } + /// /// Temperature parameter used /// diff --git a/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs b/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs index 8197cb2..577dfe6 100644 --- a/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs +++ b/src/GreenHome.Infrastructure/Data/GreenHomeDbContext.cs @@ -20,6 +20,7 @@ public sealed class GreenHomeDbContext : DbContext public DbSet AlertNotifications => Set(); public DbSet AlertLogs => Set(); public DbSet AIQueries => Set(); + public DbSet AIProfiles => Set(); public DbSet DailyReports => Set(); public DbSet UserDailyReports => Set(); public DbSet ReportImages => Set(); @@ -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(b => + { + b.ToTable("AIProfiles"); + b.HasKey(x => x.Id); + b.Property(x => x.Name).IsRequired().HasMaxLength(100); + b.Property(x => x.Provider).IsRequired().HasConversion(); + 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(b => { b.ToTable("DailyReports"); diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.Designer.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.Designer.cs new file mode 100644 index 0000000..4f5afac --- /dev/null +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.Designer.cs @@ -0,0 +1,1681 @@ +// +using System; +using GreenHome.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace GreenHome.Infrastructure.Data.Migrations.Migrations +{ + [DbContext(typeof(GreenHomeDbContext))] + [Migration("20260926160156_AddAIProfiles")] + partial class AddAIProfiles + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("GreenHome.Domain.AIProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("BalanceCurrency") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("BalanceUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("BalanceUsd") + .HasColumnType("decimal(18,6)"); + + b.Property("BaseUrl") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CompletionPricePer1M") + .HasColumnType("decimal(18,6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("MaxTokens") + .HasColumnType("int"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PromptPricePer1M") + .HasColumnType("decimal(18,6)"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("Temperature") + .HasColumnType("float"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.ToTable("AIProfiles", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AIQuery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompletionTokens") + .HasColumnType("int"); + + b.Property("CostUsd") + .HasColumnType("decimal(18,8)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ProfileId") + .HasColumnType("int"); + + b.Property("PromptTokens") + .HasColumnType("int"); + + b.Property("Question") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ResponseTimeMs") + .HasColumnType("bigint"); + + b.Property("Temperature") + .HasColumnType("float"); + + b.Property("TotalTokens") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DeviceId"); + + b.HasIndex("ProfileId"); + + b.HasIndex("UserId"); + + b.ToTable("AIQueries", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("AdminUserId") + .HasColumnType("int"); + + b.Property("At") + .HasColumnType("datetime2"); + + b.Property("Details") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.HasKey("Id"); + + b.HasIndex("AdminUserId"); + + b.HasIndex("At"); + + b.ToTable("AdminAuditLogs", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CallCooldownMinutes") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("NotificationType") + .HasColumnType("int"); + + b.Property("SmsCooldownMinutes") + .HasColumnType("int"); + + b.Property("TimeType") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.ToTable("AlertConditions", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertConditionId") + .HasColumnType("int"); + + b.Property("AlertType") + .HasColumnType("int"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("NotificationType") + .HasColumnType("int"); + + b.Property("PhoneNumber") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("nvarchar(20)"); + + b.Property("ProcessingTimeMs") + .HasColumnType("bigint"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("Status") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AlertConditionId"); + + b.HasIndex("AlertType"); + + b.HasIndex("Status"); + + b.HasIndex("DeviceId", "SentAt"); + + b.HasIndex("UserId", "SentAt"); + + b.ToTable("AlertLogs", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertConditionId") + .HasColumnType("int"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("ErrorMessage") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsSent") + .HasColumnType("bit"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("MessageOutboxIds") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("NotificationType") + .HasColumnType("int"); + + b.Property("SentAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AlertConditionId"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "UserId", "AlertConditionId", "SentAt"); + + b.ToTable("AlertNotifications", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AlertConditionId") + .HasColumnType("int"); + + b.Property("ComparisonType") + .HasColumnType("int"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("SensorType") + .HasColumnType("int"); + + b.Property("Value1") + .HasColumnType("decimal(18,2)"); + + b.Property("Value2") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("AlertConditionId"); + + b.ToTable("AlertRules", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.Checklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatedByUserId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("DeviceId", "IsActive"); + + b.ToTable("Checklists", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChecklistId") + .HasColumnType("int"); + + b.Property("CompletedAt") + .HasColumnType("datetime2"); + + b.Property("CompletedByUserId") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.HasKey("Id"); + + b.HasIndex("CompletedByUserId"); + + b.HasIndex("ChecklistId", "PersianDate"); + + b.ToTable("ChecklistCompletions", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChecklistId") + .HasColumnType("int"); + + b.Property("Description") + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("IsRequired") + .HasColumnType("bit"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.HasKey("Id"); + + b.HasIndex("ChecklistId", "Order"); + + b.ToTable("ChecklistItems", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistItemCompletion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ChecklistCompletionId") + .HasColumnType("int"); + + b.Property("ChecklistItemId") + .HasColumnType("int"); + + b.Property("IsChecked") + .HasColumnType("bit"); + + b.Property("Note") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("ChecklistCompletionId"); + + b.HasIndex("ChecklistItemId"); + + b.ToTable("ChecklistItemCompletions", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DailyReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Analysis") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("CompletionTokens") + .HasColumnType("int"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("Model") + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianDay") + .HasColumnType("int"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("PromptTokens") + .HasColumnType("int"); + + b.Property("RecordCount") + .HasColumnType("int"); + + b.Property("ResponseTimeMs") + .HasColumnType("bigint"); + + b.Property("SampledRecordCount") + .HasColumnType("int"); + + b.Property("TotalTokens") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("DeviceId", "PersianDate") + .IsUnique(); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("DailyReports", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("Location") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("nvarchar(250)"); + + b.Property("NeshanLocation") + .IsRequired() + .HasMaxLength(80) + .HasColumnType("nvarchar(80)"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Devices", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AuthorUserId") + .HasColumnType("int"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(5000) + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("PostType") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("VoiceDurationSeconds") + .HasColumnType("int"); + + b.Property("VoiceFilePath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorUserId"); + + b.HasIndex("DeviceId", "CreatedAt"); + + b.ToTable("DevicePosts", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePostImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("DevicePostId") + .HasColumnType("int"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("UploadedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DevicePostId"); + + b.ToTable("DevicePostImages", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceSettingsId") + .HasColumnType("int"); + + b.Property("IsEnabled") + .HasColumnType("bit"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RelayIndex") + .HasColumnType("int"); + + b.Property("RelayKind") + .HasColumnType("int"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("DeviceSettingsId", "RelayIndex") + .IsUnique(); + + b.ToTable("DeviceRelays", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AreaSquareMeters") + .HasColumnType("decimal(18,2)"); + + b.Property("City") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("DeviceId1") + .HasColumnType("int"); + + b.Property("DevicePhoneNumber") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Latitude") + .HasColumnType("decimal(9,6)"); + + b.Property("Longitude") + .HasColumnType("decimal(9,6)"); + + b.Property("MinimumCallIntervalMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(60); + + b.Property("MinimumSmsIntervalMinutes") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(15); + + b.Property("ProductType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Province") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("RelayCount") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(4); + + b.Property("SimCardType") + .HasColumnType("int"); + + b.Property("SmsRecievers") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("TokenCode") + .HasColumnType("nvarchar(max)"); + + b.Property("TokenExpiresAt") + .HasColumnType("datetime2"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UploadIntervalMin") + .HasColumnType("int"); + + b.Property("VerificationCode") + .HasColumnType("nvarchar(max)"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.HasIndex("DeviceId1"); + + b.ToTable("DeviceSettings", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("AvatarPath") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("Family") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("ReceiveAlerts") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(true); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("DeviceId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceUsers", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.PowerOutageLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CurrentPower") + .HasColumnType("tinyint"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("EventType") + .HasColumnType("int"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("PreviousPower") + .HasColumnType("tinyint"); + + b.Property("ServerTimestampUtc") + .HasColumnType("datetime2"); + + b.Property("TimestampUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("EventType"); + + b.HasIndex("DeviceId", "TimestampUtc"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("PowerOutageLogs", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleCondition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ComparisonType") + .HasColumnType("int"); + + b.Property("ConditionType") + .HasColumnType("int"); + + b.Property("EndMinutes") + .HasColumnType("int"); + + b.Property("Order") + .HasColumnType("int"); + + b.Property("RelayRuleGroupId") + .HasColumnType("int"); + + b.Property("SensorType") + .HasColumnType("int"); + + b.Property("StartMinutes") + .HasColumnType("int"); + + b.Property("Value1") + .HasColumnType("decimal(18,2)"); + + b.Property("Value2") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("RelayRuleGroupId"); + + b.ToTable("RelayRuleConditions", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Action") + .HasColumnType("int"); + + b.Property("DeviceRelayId") + .HasColumnType("int"); + + b.Property("Label") + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("Order") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("DeviceRelayId"); + + b.ToTable("RelayRuleGroups", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.ReportImage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("FilePath") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("UploadedAt") + .HasColumnType("datetime2"); + + b.Property("UserDailyReportId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserDailyReportId"); + + b.ToTable("ReportImages", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.TelemetryRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("BatteryPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("BatteryVoltage") + .HasColumnType("decimal(18,2)"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("GasPPM") + .HasColumnType("int"); + + b.Property("HumidityPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("Lux") + .HasColumnType("decimal(18,2)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("Power") + .HasColumnType("tinyint"); + + b.Property("RelayStates") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ServerTimestampUtc") + .HasColumnType("datetime2"); + + b.Property("SoilPercent") + .HasColumnType("decimal(18,2)"); + + b.Property("TemperatureC") + .HasColumnType("decimal(18,2)"); + + b.Property("TimestampUtc") + .HasColumnType("datetime2"); + + b.Property("Voltage") + .HasColumnType("decimal(18,2)"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId", "ServerTimestampUtc"); + + b.HasIndex("DeviceId", "TimestampUtc"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("Telemetry", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.UsageCounter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AiQueryCount") + .HasColumnType("int"); + + b.Property("AiTokenCount") + .HasColumnType("int"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("SmsCount") + .HasColumnType("int"); + + b.Property("UserId") + .HasColumnType("int"); + + b.Property("VoiceCallCount") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("UserId", "Date") + .IsUnique(); + + b.ToTable("UsageCounters", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("Family") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("IsDisabled") + .HasColumnType("bit"); + + b.Property("LastLoginAt") + .HasColumnType("datetime2"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Role") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("Mobile") + .IsUnique(); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("DeviceId") + .HasColumnType("int"); + + b.Property("Notes") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Observations") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Operations") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("PersianDate") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("PersianDay") + .HasColumnType("int"); + + b.Property("PersianMonth") + .HasColumnType("int"); + + b.Property("PersianYear") + .HasColumnType("int"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("nvarchar(200)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("UserId") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("DeviceId", "PersianDate"); + + b.HasIndex("DeviceId", "PersianYear", "PersianMonth"); + + b.ToTable("UserDailyReports", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Code") + .IsRequired() + .HasMaxLength(4) + .HasColumnType("nvarchar(4)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("IsUsed") + .HasColumnType("bit"); + + b.Property("Mobile") + .IsRequired() + .HasMaxLength(11) + .HasColumnType("nvarchar(11)"); + + b.Property("UsedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("Mobile", "Code", "IsUsed"); + + b.ToTable("VerificationCodes", (string)null); + }); + + modelBuilder.Entity("GreenHome.Domain.AIQuery", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .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") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Device"); + + b.Navigation("Profile"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AdminAuditLog", b => + { + b.HasOne("GreenHome.Domain.User", "AdminUser") + .WithMany() + .HasForeignKey("AdminUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AdminUser"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertCondition", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertLog", b => + { + b.HasOne("GreenHome.Domain.AlertCondition", "AlertCondition") + .WithMany() + .HasForeignKey("AlertConditionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AlertCondition"); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertNotification", b => + { + b.HasOne("GreenHome.Domain.AlertCondition", "AlertCondition") + .WithMany() + .HasForeignKey("AlertConditionId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("AlertCondition"); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertRule", b => + { + b.HasOne("GreenHome.Domain.AlertCondition", "AlertCondition") + .WithMany("Rules") + .HasForeignKey("AlertConditionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AlertCondition"); + }); + + modelBuilder.Entity("GreenHome.Domain.Checklist", b => + { + b.HasOne("GreenHome.Domain.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistCompletion", b => + { + b.HasOne("GreenHome.Domain.Checklist", "Checklist") + .WithMany("Completions") + .HasForeignKey("ChecklistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "CompletedByUser") + .WithMany() + .HasForeignKey("CompletedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Checklist"); + + b.Navigation("CompletedByUser"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistItem", b => + { + b.HasOne("GreenHome.Domain.Checklist", "Checklist") + .WithMany("Items") + .HasForeignKey("ChecklistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Checklist"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistItemCompletion", b => + { + b.HasOne("GreenHome.Domain.ChecklistCompletion", "ChecklistCompletion") + .WithMany("ItemCompletions") + .HasForeignKey("ChecklistCompletionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.ChecklistItem", "ChecklistItem") + .WithMany() + .HasForeignKey("ChecklistItemId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ChecklistCompletion"); + + b.Navigation("ChecklistItem"); + }); + + modelBuilder.Entity("GreenHome.Domain.DailyReport", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePost", b => + { + b.HasOne("GreenHome.Domain.User", "AuthorUser") + .WithMany() + .HasForeignKey("AuthorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AuthorUser"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePostImage", b => + { + b.HasOne("GreenHome.Domain.DevicePost", "DevicePost") + .WithMany("Images") + .HasForeignKey("DevicePostId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DevicePost"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b => + { + b.HasOne("GreenHome.Domain.DeviceSettings", "DeviceSettings") + .WithMany("Relays") + .HasForeignKey("DeviceSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DeviceSettings"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.Device", null) + .WithMany("DeviceSettings") + .HasForeignKey("DeviceId1"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceUser", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany("DeviceUsers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany("DeviceUsers") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.PowerOutageLog", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleCondition", b => + { + b.HasOne("GreenHome.Domain.RelayRuleGroup", "RelayRuleGroup") + .WithMany("Conditions") + .HasForeignKey("RelayRuleGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RelayRuleGroup"); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => + { + b.HasOne("GreenHome.Domain.DeviceRelay", "DeviceRelay") + .WithMany("RuleGroups") + .HasForeignKey("DeviceRelayId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DeviceRelay"); + }); + + modelBuilder.Entity("GreenHome.Domain.ReportImage", b => + { + b.HasOne("GreenHome.Domain.UserDailyReport", "UserDailyReport") + .WithMany("Images") + .HasForeignKey("UserDailyReportId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("UserDailyReport"); + }); + + modelBuilder.Entity("GreenHome.Domain.UsageCounter", b => + { + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b => + { + b.HasOne("GreenHome.Domain.Device", "Device") + .WithMany() + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GreenHome.Domain.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Device"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GreenHome.Domain.AlertCondition", b => + { + b.Navigation("Rules"); + }); + + modelBuilder.Entity("GreenHome.Domain.Checklist", b => + { + b.Navigation("Completions"); + + b.Navigation("Items"); + }); + + modelBuilder.Entity("GreenHome.Domain.ChecklistCompletion", b => + { + b.Navigation("ItemCompletions"); + }); + + modelBuilder.Entity("GreenHome.Domain.Device", b => + { + b.Navigation("DeviceSettings"); + + b.Navigation("DeviceUsers"); + }); + + modelBuilder.Entity("GreenHome.Domain.DevicePost", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceRelay", b => + { + b.Navigation("RuleGroups"); + }); + + modelBuilder.Entity("GreenHome.Domain.DeviceSettings", b => + { + b.Navigation("Relays"); + }); + + modelBuilder.Entity("GreenHome.Domain.RelayRuleGroup", b => + { + b.Navigation("Conditions"); + }); + + modelBuilder.Entity("GreenHome.Domain.User", b => + { + b.Navigation("DeviceUsers"); + }); + + modelBuilder.Entity("GreenHome.Domain.UserDailyReport", b => + { + b.Navigation("Images"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.cs new file mode 100644 index 0000000..21f021e --- /dev/null +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/20260926160156_AddAIProfiles.cs @@ -0,0 +1,96 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GreenHome.Infrastructure.Data.Migrations.Migrations +{ + /// + public partial class AddAIProfiles : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "CostUsd", + table: "AIQueries", + type: "decimal(18,8)", + nullable: false, + defaultValue: 0m); + + migrationBuilder.AddColumn( + name: "ProfileId", + table: "AIQueries", + type: "int", + nullable: true); + + migrationBuilder.CreateTable( + name: "AIProfiles", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Provider = table.Column(type: "int", nullable: false), + BaseUrl = table.Column(type: "nvarchar(300)", maxLength: 300, nullable: false), + ApiKey = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false), + Model = table.Column(type: "nvarchar(100)", maxLength: 100, nullable: false), + Temperature = table.Column(type: "float", nullable: true), + MaxTokens = table.Column(type: "int", nullable: true), + IsActive = table.Column(type: "bit", nullable: false), + PromptPricePer1M = table.Column(type: "decimal(18,6)", nullable: true), + CompletionPricePer1M = table.Column(type: "decimal(18,6)", nullable: true), + BalanceUsd = table.Column(type: "decimal(18,6)", nullable: true), + BalanceCurrency = table.Column(type: "nvarchar(10)", maxLength: 10, nullable: true), + BalanceUpdatedAt = table.Column(type: "datetime2", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false), + UpdatedAt = table.Column(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); + } + + /// + 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"); + } + } +} diff --git a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs index a34e66c..52980ca 100644 --- a/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs +++ b/src/GreenHome.Infrastructure/Data/Migrations/Migrations/GreenHomeDbContextModelSnapshot.cs @@ -22,6 +22,75 @@ namespace GreenHome.Infrastructure.Data.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("GreenHome.Domain.AIProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ApiKey") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)"); + + b.Property("BalanceCurrency") + .HasMaxLength(10) + .HasColumnType("nvarchar(10)"); + + b.Property("BalanceUpdatedAt") + .HasColumnType("datetime2"); + + b.Property("BalanceUsd") + .HasColumnType("decimal(18,6)"); + + b.Property("BaseUrl") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("nvarchar(300)"); + + b.Property("CompletionPricePer1M") + .HasColumnType("decimal(18,6)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("IsActive") + .HasColumnType("bit"); + + b.Property("MaxTokens") + .HasColumnType("int"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("nvarchar(100)"); + + b.Property("PromptPricePer1M") + .HasColumnType("decimal(18,6)"); + + b.Property("Provider") + .HasColumnType("int"); + + b.Property("Temperature") + .HasColumnType("float"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.ToTable("AIProfiles", (string)null); + }); + modelBuilder.Entity("GreenHome.Domain.AIQuery", b => { b.Property("Id") @@ -37,6 +106,9 @@ namespace GreenHome.Infrastructure.Data.Migrations b.Property("CompletionTokens") .HasColumnType("int"); + b.Property("CostUsd") + .HasColumnType("decimal(18,8)"); + b.Property("CreatedAt") .HasColumnType("datetime2"); @@ -47,6 +119,9 @@ namespace GreenHome.Infrastructure.Data.Migrations .HasMaxLength(100) .HasColumnType("nvarchar(100)"); + b.Property("ProfileId") + .HasColumnType("int"); + b.Property("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"); }); diff --git a/src/GreenHome.Infrastructure/DependencyInjection.cs b/src/GreenHome.Infrastructure/DependencyInjection.cs index a0fef08..048b369 100644 --- a/src/GreenHome.Infrastructure/DependencyInjection.cs +++ b/src/GreenHome.Infrastructure/DependencyInjection.cs @@ -74,6 +74,8 @@ public static class DependencyInjection // AI services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); // Sun Calculator services.AddScoped(); diff --git a/src/GreenHome.Infrastructure/Services/AI/AIChatClient.cs b/src/GreenHome.Infrastructure/Services/AI/AIChatClient.cs new file mode 100644 index 0000000..ceba840 --- /dev/null +++ b/src/GreenHome.Infrastructure/Services/AI/AIChatClient.cs @@ -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; + +/// +/// 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). +/// +public sealed class AIChatClient +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ILogger _logger; + + public AIChatClient(IHttpClientFactory httpClientFactory, ILogger logger) + { + _httpClientFactory = httpClientFactory; + _logger = logger; + } + + public async Task AskAsync( + AIProfile profile, + List 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(cancellationToken: cancellationToken); + } + + /// + /// Lists model ids available on the provider's /models endpoint. + /// + public async Task> 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(); + 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; + } + + /// + /// 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. + /// + public async Task 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; + } +} diff --git a/src/GreenHome.Infrastructure/Services/AI/AIProfileService.cs b/src/GreenHome.Infrastructure/Services/AI/AIProfileService.cs new file mode 100644 index 0000000..613042f --- /dev/null +++ b/src/GreenHome.Infrastructure/Services/AI/AIProfileService.cs @@ -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; + +/// +/// 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. +/// +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 _logger; + + public AIProfileService( + GreenHomeDbContext dbContext, + AIChatClient chatClient, + IDeepSeekService deepSeekService, + ILogger logger) + { + _dbContext = dbContext; + _chatClient = chatClient; + _deepSeekService = deepSeekService; + _logger = logger; + } + + // ==================== CRUD ==================== + + public async Task> 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 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 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 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 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 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 GetActiveProfileAsync(CancellationToken cancellationToken = default) + { + return await _dbContext.AIProfiles + .FirstOrDefaultAsync(p => p.IsActive, cancellationToken); + } + + public async Task AskAsync( + List 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> 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 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 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> 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 ==================== + + /// + /// Per-profile usage totals (all time) keyed by profile id. + /// + private async Task> 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 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 + }; + } + + /// + /// Resolves the effective base URL: explicit user value wins; otherwise the + /// provider's default root. Compatible always requires an explicit URL. + /// + 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 + }); + } +} diff --git a/src/GreenHome.Infrastructure/Services/AI/IAIProfileService.cs b/src/GreenHome.Infrastructure/Services/AI/IAIProfileService.cs new file mode 100644 index 0000000..4f87fa8 --- /dev/null +++ b/src/GreenHome.Infrastructure/Services/AI/IAIProfileService.cs @@ -0,0 +1,95 @@ +using GreenHome.AI.DeepSeek; +using GreenHome.Application; +using GreenHome.Domain; + +namespace GreenHome.Infrastructure.Services.AI; + +/// +/// Result of an AI ask routed through the profile system. +/// Profile is null when the legacy DeepSeek fallback was used (no active profile). +/// +public sealed class AIAskResult +{ + public AIProfile? Profile { get; set; } + public ChatResponse? Response { get; set; } +} + +/// +/// 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. +/// +public interface IAIProfileService +{ + // ==================== CRUD ==================== + + Task> GetProfilesAsync(CancellationToken cancellationToken = default); + + Task GetProfileAsync(int id, CancellationToken cancellationToken = default); + + Task CreateProfileAsync(AIProfileCreateRequest request, int currentAdminId, CancellationToken cancellationToken = default); + + Task UpdateProfileAsync(int id, AIProfileUpdateRequest request, int currentAdminId, CancellationToken cancellationToken = default); + + Task DeleteProfileAsync(int id, int currentAdminId, CancellationToken cancellationToken = default); + + /// + /// Sets the given profile as the single active one (deactivates the rest). + /// + Task ActivateProfileAsync(int id, int currentAdminId, CancellationToken cancellationToken = default); + + // ==================== Runtime routing ==================== + + /// + /// The currently active profile, or null if none is set. + /// + Task GetActiveProfileAsync(CancellationToken cancellationToken = default); + + /// + /// Routes a chat request through the active profile; falls back to the + /// legacy configured DeepSeek service when no profile is active. + /// + Task AskAsync( + List messages, + double? temperature = null, + int? maxTokens = null, + CancellationToken cancellationToken = default); + + /// + /// Estimated cost in USD for a query given per-1M-token prices. 0 when prices are unknown. + /// + 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 ==================== + + /// + /// Fetches the model list from the provider's /models endpoint. + /// + Task> GetModelsAsync(int id, CancellationToken cancellationToken = default); + + /// + /// 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. + /// + Task RefreshBalanceAsync(int id, CancellationToken cancellationToken = default); + + // ==================== Usage (from the AIQueries log) ==================== + + /// + /// Aggregated usage for a profile within a date range (inclusive of `from`, exclusive of `to`). + /// + Task GetUsageAsync(int id, DateTime from, DateTime to, CancellationToken cancellationToken = default); + + /// + /// Per-day usage for a profile within a date range — for charting. + /// + Task> GetUsageByDayAsync(int id, DateTime from, DateTime to, CancellationToken cancellationToken = default); +} diff --git a/src/GreenHome.Infrastructure/Services/Report/DailyReportService.cs b/src/GreenHome.Infrastructure/Services/Report/DailyReportService.cs index 186fe37..1c17772 100644 --- a/src/GreenHome.Infrastructure/Services/Report/DailyReportService.cs +++ b/src/GreenHome.Infrastructure/Services/Report/DailyReportService.cs @@ -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 _logger; public DailyReportService( GreenHomeDbContext context, - IDeepSeekService deepSeekService, + IAIProfileService aiProfileService, + IAIQueryService aiQueryService, ILogger 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 + aiResult = await _aiProfileService.AskAsync( + new List { 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 + var aiResult = await _aiProfileService.AskAsync( + new List { 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 + var aiResult = await _aiProfileService.AskAsync( + new List { 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 ==================== + + /// + /// 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. + /// + 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 برای گزارش"); + } + } } diff --git a/src/GreenHome.Web/src/App.tsx b/src/GreenHome.Web/src/App.tsx index a74f7b1..44ff02d 100644 --- a/src/GreenHome.Web/src/App.tsx +++ b/src/GreenHome.Web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/src/GreenHome.Web/src/app/admin/AdminLayout.tsx b/src/GreenHome.Web/src/app/admin/AdminLayout.tsx index 64a6f0e..4452793 100644 --- a/src/GreenHome.Web/src/app/admin/AdminLayout.tsx +++ b/src/GreenHome.Web/src/app/admin/AdminLayout.tsx @@ -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 }, ] diff --git a/src/GreenHome.Web/src/app/admin/ai/page.tsx b/src/GreenHome.Web/src/app/admin/ai/page.tsx new file mode 100644 index 0000000..b5ee1fd --- /dev/null +++ b/src/GreenHome.Web/src/app/admin/ai/page.tsx @@ -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 = { + 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([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + const [formOpen, setFormOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [saving, setSaving] = useState(false) + const [busyId, setBusyId] = useState(null) + const [expandedId, setExpandedId] = useState(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 ( +
+
+

پروفایل‌های هوش مصنوعی

+ +
+ +
+ + + +
+ + {error &&

{error}

} + + {loading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+ ))} +
+ ) : profiles.length > 0 ? ( +
+ {profiles.map((p) => ( + setExpandedId(expandedId === p.id ? null : p.id)} + onActivate={() => handleActivate(p.id)} + onDelete={() => handleDelete(p.id)} + onRefreshBalance={() => handleRefreshBalance(p.id)} + onEdit={() => { setEditing(p); setFormOpen(true) }} + /> + ))} +
+ ) : ( +
+ +

هنوز پروفایلی ثبت نشده است.

+

اولین پروفایل AI را اضافه کنید تا تحلیل‌ها از طریق آن انجام شود.

+
+ )} + + { setFormOpen(false); setEditing(null) }} + onSave={handleSave} + /> +
+ ) +} + +function SummaryCard({ icon: Icon, label, value }: { icon: typeof BrainCircuit; label: string; value: string }) { + return ( +
+
+
+ +
+
+

{value}

+

{label}

+
+
+
+ ) +} + +// ==================== 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 ( +
+
+
+
+
+

{profile.name}

+ {profile.isActive && فعال} + {providerOf(profile.provider)} +
+

+ {profile.model} +

+
+
+ {balanceText && ( + {balanceText} + )} + + + + + {!profile.isActive && ( + + )} +
+
+ +
+ + + + +
+
+ + {expanded && } +
+ ) +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+

{value}

+

{label}

+
+ ) +} + +// ==================== Usage (chart + table) ==================== + +function UsageSection({ profileId, profileName }: { profileId: number; profileName: string }) { + const [days, setDays] = useState(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

{error}

+ if (!days) { + return ( +
+
+
+ ) + } + if (days.length === 0) { + return

در ۳۰ روز اخیر مصرفی برای «{profileName}» ثبت نشده است.

+ } + + const labels = days.map((d) => formatPersianDateFromIso(d.date)) + const sorted = [...days].sort((a, b) => a.date.localeCompare(b.date)) + + return ( +
+

مصرف روزانه ۳۰ روز اخیر

+ d.totalTokens), borderColor: '#16a34a', fill: true }, + { label: 'هزینه (دلار)', data: sorted.map((d) => d.costUsd), borderColor: '#f59e0b' }, + ]} + /> +
+ + + + + + + + + + + + + {[...days].reverse().map((d) => ( + + + + + + + + + ))} + +
تاریخدرخواستتوکن ورودیتوکن خروجیکل توکنهزینه (دلار)
{formatPersianDateFromIso(d.date)}{toPersianDigits(d.queryCount)}{toPersianDigits(d.promptTokens)}{toPersianDigits(d.completionTokens)}{toPersianDigits(d.totalTokens)}{d.costUsd.toLocaleString('en', { maximumFractionDigits: 6 })}
+
+
+ ) +} + +// ==================== 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([]) + 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 ( + +
+ {error &&

{error}

} + +
+ set('name')(e.target.value)} placeholder="مثلاً DeepSeek اصلی" /> + +
+ + set('baseUrl')(e.target.value)} + placeholder="https://api.deepseek.com/v1" + helperText="برای DeepSeek و OpenAI پیش‌فرض وجود دارد؛ برای سرویس سازگار با OpenAI الزامی است (با /v1)." + /> + + set('apiKey')(e.target.value)} + placeholder="sk-..." + helperText={editing ? 'خالی بگذارید تا کلید فعلی حفظ شود.' : undefined} + /> + +
+
+
+ {models.length > 0 ? ( + + ) : ( + set('model')(e.target.value)} placeholder="deepseek-chat" /> + )} +
+ {editing && ( + + )} +
+
+ +
+ set('temperature')(e.target.value)} placeholder="0.7" /> + set('maxTokens')(e.target.value)} /> + set('promptPrice')(e.target.value)} /> + set('completionPrice')(e.target.value)} /> +
+ +
+ + +
+
+
+ ) +} diff --git a/src/GreenHome.Web/src/lib/api/client.ts b/src/GreenHome.Web/src/lib/api/client.ts index f484c63..e040884 100644 --- a/src/GreenHome.Web/src/lib/api/client.ts +++ b/src/GreenHome.Web/src/lib/api/client.ts @@ -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(`${API_BASE}/api/admin/reports/devices-uptime${ReportParams(q)}`), adminReportUsageByUser: (q: { from?: string; to?: string }) => http(`${API_BASE}/api/admin/reports/usage-by-user${ReportParams(q)}`), + // AI Profiles + adminAiProfiles: () => http(`${API_BASE}/api/admin/ai-profiles`), + adminAiProfileCreate: (req: AiProfileRequest) => + http(`${API_BASE}/api/admin/ai-profiles`, { method: 'POST', body: JSON.stringify(req) }), + adminAiProfileUpdate: (id: number, req: AiProfileRequest) => + http(`${API_BASE}/api/admin/ai-profiles/${id}`, { method: 'PUT', body: JSON.stringify(req) }), + adminAiProfileDelete: (id: number) => + http(`${API_BASE}/api/admin/ai-profiles/${id}`, { method: 'DELETE' }), + adminAiProfileActivate: (id: number) => + http(`${API_BASE}/api/admin/ai-profiles/${id}/activate`, { method: 'POST' }), + adminAiProfileModels: (id: number) => + http(`${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(`${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(), diff --git a/src/GreenHome.Web/src/lib/api/types.ts b/src/GreenHome.Web/src/lib/api/types.ts index 95e2e85..5f05fd8 100644 --- a/src/GreenHome.Web/src/lib/api/types.ts +++ b/src/GreenHome.Web/src/lib/api/types.ts @@ -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 +} +