using System.Collections.Concurrent; using System.Text.Json; using System.Text.Json.Serialization; using Microsoft.AspNetCore.Mvc; using WebClientTpos.Server.AiChat; namespace WebClientTpos.Server.Controllers; /// /// EN: AI Chat BFF controller — orchestrates LLM chat with MCP tool execution loop. /// Supports OpenAI, OpenRouter, and Claude providers. Stores config in-memory (v1). /// VI: AI Chat BFF controller — dieu phoi LLM chat voi vong lap thuc thi MCP tool. /// Ho tro provider OpenAI, OpenRouter, va Claude. Luu config trong memory (v1). /// [ApiController] [Route("api/bff/ai")] public class AiChatController : ControllerBase { /// /// EN: Maximum number of tool execution loops to prevent infinite recursion. /// VI: So vong lap thuc thi tool toi da de ngan de quy vo han. /// private const int MaxToolLoops = 5; private static readonly ConcurrentDictionary Configs = new(); private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, PropertyNameCaseInsensitive = true }; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; public AiChatController(IHttpClientFactory httpClientFactory, ILogger logger) { _httpClientFactory = httpClientFactory; _logger = logger; } // ═══════════════════════════════════════════════════════════════════════════════ // EN: Chat endpoint — main orchestration with tool execution loop // VI: Endpoint chat — dieu phoi chinh voi vong lap thuc thi tool // ═══════════════════════════════════════════════════════════════════════════════ /// /// EN: Send a chat message to the configured AI provider, with automatic MCP tool execution. /// VI: Gui tin nhan chat den AI provider da cau hinh, voi thuc thi MCP tool tu dong. /// [HttpPost("chat")] public async Task Chat([FromBody] AiChatRequestDto request, CancellationToken ct) { // EN: Validate request // VI: Xac thuc request if (string.IsNullOrWhiteSpace(request.ShopId)) return BadRequest(new { success = false, error = new { code = "MISSING_SHOP_ID", message = "shopId is required" } }); if (request.Messages is null or { Count: 0 }) return BadRequest(new { success = false, error = new { code = "MISSING_MESSAGES", message = "messages are required" } }); // EN: Get AI config for this shop // VI: Lay AI config cho shop nay if (!Configs.TryGetValue(request.ShopId, out var config) || !config.Enabled) { return BadRequest(new { success = false, error = new { code = "AI_NOT_CONFIGURED", message = "AI chat is not configured or disabled for this shop" } }); } try { // EN: Build system prompt with shop context // VI: Xay dung system prompt voi nguu canh shop var systemPrompt = BuildSystemPrompt(config.SystemPrompt, request.ShopId); // EN: Get MCP tool definitions for this shop // VI: Lay dinh nghia MCP tool cho shop nay var shopGuid = Guid.Parse(request.ShopId); var tools = McpToolRegistry.GetToolDefinitions(shopGuid); // EN: Convert incoming messages to internal format // VI: Chuyen doi tin nhan den sang dinh dang noi bo var messages = request.Messages.Select(m => new AiChatMessage(m.Role, m.Content)).ToList(); // EN: Select the appropriate AI provider // VI: Chon AI provider phu hop var provider = CreateProvider(config.Provider); // EN: Tool execution loop — re-send to LLM after each tool execution round // VI: Vong lap thuc thi tool — gui lai den LLM sau moi luot thuc thi tool var toolsUsed = new List(); var loopCount = 0; while (loopCount < MaxToolLoops) { var aiRequest = new AiChatRequest( config.Model, config.ApiKey, config.BaseUrl, messages, systemPrompt, tools); var aiResponse = await provider.SendAsync(aiRequest, ct); if (!aiResponse.RequiresToolExecution || aiResponse.ToolCalls is null) { // EN: No more tool calls — return the final response // VI: Khong con tool call — tra ve response cuoi cung return Ok(new { success = true, data = new { content = aiResponse.Content, toolsUsed = toolsUsed.Count > 0 ? toolsUsed : null } }); } // EN: Append assistant message with tool calls to conversation // VI: Them tin nhan assistant voi tool calls vao cuoc hoi thoai messages.Add(new AiChatMessage( "assistant", aiResponse.Content, aiResponse.ToolCalls)); // EN: Execute each tool call and append results // VI: Thuc thi tung tool call va them ket qua var loggerFactory = HttpContext.RequestServices.GetRequiredService(); var executor = new McpToolExecutor(_httpClientFactory, loggerFactory.CreateLogger()); foreach (var toolCall in aiResponse.ToolCalls) { _logger.LogInformation( "EN: Executing tool '{ToolName}' (loop {Loop}). " + "VI: Thuc thi tool (vong lap).", toolCall.Name, loopCount + 1); var result = await executor.ExecuteToolAsync(toolCall.Name, toolCall.ArgumentsJson, ct); toolsUsed.Add(toolCall.Name); // EN: Append tool result message to conversation // VI: Them tin nhan ket qua tool vao cuoc hoi thoai messages.Add(new AiChatMessage( "tool", result, ToolCallId: toolCall.Id, Name: toolCall.Name)); } loopCount++; } // EN: Max loops reached — return whatever we have // VI: Da dat so vong toi da — tra ve nhung gi co _logger.LogWarning("EN: Max tool loops ({MaxLoops}) reached for shop {Shop}.", MaxToolLoops, request.ShopId); return Ok(new { success = true, data = new { content = "I've completed the available tool operations. Please let me know if you need anything else.", toolsUsed = toolsUsed.Count > 0 ? toolsUsed : null } }); } catch (HttpRequestException ex) { _logger.LogError(ex, "EN: AI provider request failed for shop {Shop}.", request.ShopId); return StatusCode(502, new { success = false, error = new { code = "AI_PROVIDER_ERROR", message = ex.Message } }); } catch (JsonException ex) { _logger.LogError(ex, "EN: Failed to parse AI response for shop {Shop}.", request.ShopId); return StatusCode(502, new { success = false, error = new { code = "AI_PARSE_ERROR", message = "Failed to parse AI provider response" } }); } } // ═══════════════════════════════════════════════════════════════════════════════ // EN: Config endpoints — read/write AI configuration per shop // VI: Endpoint cau hinh — doc/ghi AI configuration theo shop // ═══════════════════════════════════════════════════════════════════════════════ /// /// EN: Get the AI chat configuration for a shop. API key is masked for security. /// VI: Lay cau hinh AI chat cho shop. API key duoc an di de bao mat. /// [HttpGet("config")] public IActionResult GetConfig([FromQuery] string shopId) { if (string.IsNullOrWhiteSpace(shopId)) return BadRequest(new { success = false, error = new { code = "MISSING_SHOP_ID", message = "shopId is required" } }); if (!Configs.TryGetValue(shopId, out var config)) { return Ok(new { success = true, data = (object?)null }); } // EN: Mask the API key — show only last 4 characters // VI: An API key — chi hien 4 ky tu cuoi var maskedKey = config.ApiKey.Length > 4 ? new string('*', config.ApiKey.Length - 4) + config.ApiKey[^4..] : "****"; return Ok(new { success = true, data = new { provider = config.Provider, apiKey = maskedKey, model = config.Model, baseUrl = config.BaseUrl, systemPrompt = config.SystemPrompt, enabled = config.Enabled } }); } /// /// EN: Save or update the AI chat configuration for a shop. /// VI: Luu hoac cap nhat cau hinh AI chat cho shop. /// [HttpPut("config")] public IActionResult SaveConfig([FromBody] AiChatConfigDto dto) { if (string.IsNullOrWhiteSpace(dto.ShopId)) return BadRequest(new { success = false, error = new { code = "MISSING_SHOP_ID", message = "shopId is required" } }); if (string.IsNullOrWhiteSpace(dto.Provider)) return BadRequest(new { success = false, error = new { code = "MISSING_PROVIDER", message = "provider is required (openai, openrouter, claude)" } }); if (string.IsNullOrWhiteSpace(dto.ApiKey)) return BadRequest(new { success = false, error = new { code = "MISSING_API_KEY", message = "apiKey is required" } }); if (string.IsNullOrWhiteSpace(dto.Model)) return BadRequest(new { success = false, error = new { code = "MISSING_MODEL", message = "model is required" } }); // EN: Resolve base URL from provider if not explicitly provided // VI: Xac dinh base URL tu provider neu khong duoc cung cap var baseUrl = dto.BaseUrl ?? dto.Provider.ToLowerInvariant() switch { "openai" => "https://api.openai.com/v1", "openrouter" => "https://openrouter.ai/api/v1", "claude" => "https://api.anthropic.com", _ => "" }; var config = new AiChatConfig( dto.Provider.ToLowerInvariant(), dto.ApiKey, dto.Model, baseUrl, dto.SystemPrompt, dto.Enabled ?? true); Configs[dto.ShopId] = config; _logger.LogInformation("EN: AI config saved for shop {Shop} — provider: {Provider}, model: {Model}.", dto.ShopId, config.Provider, config.Model); return Ok(new { success = true, data = new { message = "AI chat configuration saved" } }); } // ═══════════════════════════════════════════════════════════════════════════════ // EN: Private helpers // VI: Cac phuong thuc tro giup noi bo // ═══════════════════════════════════════════════════════════════════════════════ /// /// EN: Build system prompt with shop context appended. /// VI: Xay dung system prompt voi ngu canh shop duoc them vao. /// private static string BuildSystemPrompt(string? customPrompt, string shopId) { var basePrompt = customPrompt ?? "You are a helpful AI assistant for a aPOS POS shop. " + "You can help manage products, inventory, recipes, and view analytics. " + "Answer in the same language the user writes in (Vietnamese or English)."; return $"{basePrompt}\n\n" + $"[Context] Current shop ID: {shopId}. " + "When calling tools, use this shop ID if the user doesn't specify one. " + "Always confirm destructive actions (delete, large stock changes) before executing."; } /// /// EN: Create the appropriate AI chat provider based on provider name. /// VI: Tao AI chat provider phu hop dua tren ten provider. /// private IAiChatProvider CreateProvider(string provider) { var httpClient = new HttpClient(); var loggerFactory = HttpContext.RequestServices.GetRequiredService(); return provider switch { "openai" or "openrouter" => new OpenAiChatProvider(httpClient, loggerFactory.CreateLogger()), "claude" => new ClaudeChatProvider(httpClient, loggerFactory.CreateLogger()), _ => throw new ArgumentException($"Unsupported AI provider: {provider}. Use 'openai', 'openrouter', or 'claude'.") }; } } // ═══════════════════════════════════════════════════════════════════════════════ // EN: Request/Response DTOs for the AI Chat controller // VI: Cac DTO Request/Response cho AI Chat controller // ═══════════════════════════════════════════════════════════════════════════════ /// /// EN: Internal config record stored per shop (in-memory v1). /// VI: Record cau hinh noi bo luu theo shop (in-memory v1). /// public record AiChatConfig( string Provider, string ApiKey, string Model, string BaseUrl, string? SystemPrompt, bool Enabled); /// /// EN: DTO for the chat request from the frontend. /// VI: DTO cho chat request tu frontend. /// public class AiChatRequestDto { public string ShopId { get; set; } = ""; public List Messages { get; set; } = []; } /// /// EN: DTO for a single chat message from the frontend. /// VI: DTO cho mot tin nhan chat tu frontend. /// public class AiChatMessageDto { public string Role { get; set; } = "user"; public string Content { get; set; } = ""; } /// /// EN: DTO for saving/updating AI chat configuration. /// VI: DTO de luu/cap nhat cau hinh AI chat. /// public class AiChatConfigDto { public string ShopId { get; set; } = ""; public string Provider { get; set; } = ""; public string ApiKey { get; set; } = ""; public string Model { get; set; } = ""; public string? BaseUrl { get; set; } public string? SystemPrompt { get; set; } public bool? Enabled { get; set; } }