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>
358 lines
16 KiB
C#
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; }
|
|
}
|