Files
pos-system/apps/web-client-tpos-net/src/WebClientTpos.Server/Controllers/AiChatController.cs
Ho Ngoc Hai 04738248f2 rebrand: rename GoodGo → aPOS across all UI and display text
Replaced all user-facing "GoodGo" brand references with "aPOS"
across 35 files (53 occurrences):
- Layout headers: "aPOS Admin", "aPOS POS"
- Page titles: "— aPOS Admin", "— aPOS POS"
- Locale files: brand name keys
- Onboarding: welcome text, descriptions
- AI Chat: system prompt, provider headers
- Auth pages: login titles

Note: Internal namespace GoodGo.BlazorUi.Components.* preserved
(assembly reference, not user-facing brand text).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 21:25:19 +07:00

358 lines
16 KiB
C#

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;
/// <summary>
/// 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).
/// </summary>
[ApiController]
[Route("api/bff/ai")]
public class AiChatController : ControllerBase
{
/// <summary>
/// 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.
/// </summary>
private const int MaxToolLoops = 5;
private static readonly ConcurrentDictionary<string, AiChatConfig> Configs = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
PropertyNameCaseInsensitive = true
};
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<AiChatController> _logger;
public AiChatController(IHttpClientFactory httpClientFactory, ILogger<AiChatController> 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
// ═══════════════════════════════════════════════════════════════════════════════
/// <summary>
/// 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.
/// </summary>
[HttpPost("chat")]
public async Task<IActionResult> 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<string>();
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<ILoggerFactory>();
var executor = new McpToolExecutor(_httpClientFactory, loggerFactory.CreateLogger<McpToolExecutor>());
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
// ═══════════════════════════════════════════════════════════════════════════════
/// <summary>
/// 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.
/// </summary>
[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
}
});
}
/// <summary>
/// EN: Save or update the AI chat configuration for a shop.
/// VI: Luu hoac cap nhat cau hinh AI chat cho shop.
/// </summary>
[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
// ═══════════════════════════════════════════════════════════════════════════════
/// <summary>
/// EN: Build system prompt with shop context appended.
/// VI: Xay dung system prompt voi ngu canh shop duoc them vao.
/// </summary>
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.";
}
/// <summary>
/// EN: Create the appropriate AI chat provider based on provider name.
/// VI: Tao AI chat provider phu hop dua tren ten provider.
/// </summary>
private IAiChatProvider CreateProvider(string provider)
{
var httpClient = new HttpClient();
var loggerFactory = HttpContext.RequestServices.GetRequiredService<ILoggerFactory>();
return provider switch
{
"openai" or "openrouter" => new OpenAiChatProvider(httpClient, loggerFactory.CreateLogger<OpenAiChatProvider>()),
"claude" => new ClaudeChatProvider(httpClient, loggerFactory.CreateLogger<ClaudeChatProvider>()),
_ => 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
// ═══════════════════════════════════════════════════════════════════════════════
/// <summary>
/// EN: Internal config record stored per shop (in-memory v1).
/// VI: Record cau hinh noi bo luu theo shop (in-memory v1).
/// </summary>
public record AiChatConfig(
string Provider,
string ApiKey,
string Model,
string BaseUrl,
string? SystemPrompt,
bool Enabled);
/// <summary>
/// EN: DTO for the chat request from the frontend.
/// VI: DTO cho chat request tu frontend.
/// </summary>
public class AiChatRequestDto
{
public string ShopId { get; set; } = "";
public List<AiChatMessageDto> Messages { get; set; } = [];
}
/// <summary>
/// EN: DTO for a single chat message from the frontend.
/// VI: DTO cho mot tin nhan chat tu frontend.
/// </summary>
public class AiChatMessageDto
{
public string Role { get; set; } = "user";
public string Content { get; set; } = "";
}
/// <summary>
/// EN: DTO for saving/updating AI chat configuration.
/// VI: DTO de luu/cap nhat cau hinh AI chat.
/// </summary>
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; }
}