Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions RustRconServerManager.Backend/Controllers/AiIntegrationController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using RustRconServerManager.Backend.Database;
using RustRconServerManager.Backend.Extensions;
using RustRconServerManager.Backend.Interfaces;
using RustRconServerManager.Backend.Models;
using RustRconServerManager.Shared.AiIntegration;

namespace RustRconServerManager.Backend.Controllers;

[ApiController]
[Route("api/[controller]")]
[Authorize]
public class AiIntegrationController : ControllerBase
{
private readonly AppDbContext _dbContext;
private readonly ILogger<AiIntegrationController> _logger;
private readonly IRconPasswordsCryptoService _cryptoService;
private readonly IAiService _aiService;

public AiIntegrationController(
AppDbContext dbContext,
ILogger<AiIntegrationController> logger,
IRconPasswordsCryptoService cryptoService,
IAiService aiService)
{
_dbContext = dbContext;
_logger = logger;
_cryptoService = cryptoService;
_aiService = aiService;
}

/// <summary>
/// Gets the current AI integration settings for the caller's SystemProfile. Admin-only,
/// same as the rest of Panel Settings' provider-credential endpoints.
/// </summary>
[HttpGet("settings")]
public async Task<IActionResult> GetSettings()
{
try
{
var currentUser = await User.GetUser(_dbContext);
if (!currentUser.isAdmin)
return Forbid();

var settings = await _dbContext.AiIntegrationSettings
.FirstOrDefaultAsync(s => s.SystemProfileId == currentUser.SystemProfileId);

return Ok(MapToDto(settings));
}
catch (Exception ex)
{
_logger.LogError(ex, "[AiIntegrationController] Error getting AI integration settings");
return StatusCode(500, new { error = "Error retrieving AI integration settings" });
}
}

[HttpPut("settings")]
public async Task<IActionResult> SetSettings([FromBody] SetAiIntegrationSettingsDto dto)
{
try
{
var currentUser = await User.GetUser(_dbContext);
if (!currentUser.isAdmin)
return Forbid();

if (!AiProviders.All.Contains(dto.Provider))
return BadRequest(new { error = $"Unknown provider '{dto.Provider}'." });

var requiresBaseUrl = dto.Provider is AiProviders.Ollama or AiProviders.LmStudio or AiProviders.Universal;
if (requiresBaseUrl && string.IsNullOrWhiteSpace(dto.BaseUrl))
return BadRequest(new { error = "Base URL is required for this provider." });

if (!string.IsNullOrWhiteSpace(dto.BaseUrl) && !Uri.TryCreate(dto.BaseUrl.Trim(), UriKind.Absolute, out _))
return BadRequest(new { error = "Base URL is not a valid URL." });

if (string.IsNullOrWhiteSpace(dto.Model))
return BadRequest(new { error = "A model name is required." });

var settings = await _dbContext.AiIntegrationSettings
.FirstOrDefaultAsync(s => s.SystemProfileId == currentUser.SystemProfileId);

if (settings == null)
{
settings = new AiIntegrationSettings
{
SystemProfileId = currentUser.SystemProfileId,
CreatedAt = DateTime.UtcNow
};
_dbContext.AiIntegrationSettings.Add(settings);
}

settings.Provider = dto.Provider;
settings.BaseUrl = string.IsNullOrWhiteSpace(dto.BaseUrl) ? null : dto.BaseUrl.Trim();
settings.Model = dto.Model.Trim();
settings.IsEnabled = dto.IsEnabled;
settings.UpdatedAt = DateTime.UtcNow;

if (dto.RemoveApiKey)
{
settings.EncryptedApiKey = null;
}
else if (!string.IsNullOrWhiteSpace(dto.ApiKey))
{
settings.EncryptedApiKey = _cryptoService.Encrypt(dto.ApiKey.Trim());
}

await _dbContext.SaveChangesAsync();

_logger.LogInformation("[AiIntegrationController] User {UserId} updated AI integration settings (provider {Provider})",
currentUser.Id, settings.Provider);

return Ok(MapToDto(settings));
}
catch (Exception ex)
{
_logger.LogError(ex, "[AiIntegrationController] Error saving AI integration settings");
return StatusCode(500, new { error = "Error saving AI integration settings" });
}
}

/// <summary>
/// Sends a minimal test prompt to the currently-saved provider config to confirm the
/// endpoint, model, and credentials actually work.
/// </summary>
[HttpPost("test")]
public async Task<IActionResult> TestConnection()
{
try
{
var currentUser = await User.GetUser(_dbContext);
if (!currentUser.isAdmin)
return Forbid();

var result = await _aiService.TestConnectionAsync(currentUser.SystemProfileId);
return Ok(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "[AiIntegrationController] Error testing AI connection");
return StatusCode(500, new { error = "Error testing AI connection" });
}
}

private static AiIntegrationSettingsDto MapToDto(AiIntegrationSettings? settings)
{
if (settings == null)
{
return new AiIntegrationSettingsDto();
}

return new AiIntegrationSettingsDto
{
Provider = settings.Provider,
BaseUrl = settings.BaseUrl,
Model = settings.Model,
IsEnabled = settings.IsEnabled,
ApiKeyConfigured = !string.IsNullOrWhiteSpace(settings.EncryptedApiKey)
};
}
}
3 changes: 3 additions & 0 deletions RustRconServerManager.Backend/Database/AppDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options):base(options)
// Database set for panel settings
public DbSet<PanelSettings> PanelSettings { get; set; }

// Database set for AI provider configuration (Ollama/LM Studio/OpenAI/Anthropic/Universal)
public DbSet<AiIntegrationSettings> AiIntegrationSettings { get; set; }

// Database set for user sessions (multi-session support)
public DbSet<UserSession> UserSessions { get; set; }

Expand Down
51 changes: 51 additions & 0 deletions RustRconServerManager.Backend/Interfaces/IAiService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using RustRconServerManager.Backend.Models;
using RustRconServerManager.Shared.AiIntegration;

namespace RustRconServerManager.Backend.Interfaces;

/// <summary>
/// A single message in an AI chat request. Provider clients translate this into whatever
/// shape that provider's API actually expects (OpenAI/LM Studio/Ollama/Universal all use an
/// OpenAI-style {role, content} messages array already; Anthropic separates the system
/// prompt out, which AiService handles internally).
/// </summary>
public class AiChatMessage
{
public string Role { get; set; } = "user"; // "system" | "user" | "assistant"
public string Content { get; set; } = string.Empty;
}

public class AiChatResult
{
public bool Success { get; set; }
public string? Content { get; set; }
public string? ErrorMessage { get; set; }
}

/// <summary>
/// Talks to whichever AI provider a SystemProfile has configured. This is the framework
/// other AI-powered features (server monitoring, script/mod repair suggestions, rule-breaker
/// detection, admin notifications, etc.) are meant to build on - none of those exist yet,
/// this just makes "send this profile's configured AI provider a chat request" possible.
/// </summary>
public interface IAiService
{
/// <summary>
/// Loads the raw settings row for a profile, or null if none has been saved yet.
/// </summary>
Task<AiIntegrationSettings?> GetSettingsAsync(int systemProfileId);

/// <summary>
/// Sends a minimal request to the configured provider to confirm the endpoint, model,
/// and credentials actually work - used by the "Test Connection" button.
/// </summary>
Task<TestAiConnectionResultDto> TestConnectionAsync(int systemProfileId);

/// <summary>
/// Sends a chat request to the profile's configured provider. Returns
/// Success = false (with ErrorMessage set) if AI isn't configured/enabled for this
/// profile, or if the provider call fails - callers should treat that as "AI isn't
/// available right now" rather than throwing.
/// </summary>
Task<AiChatResult> SendChatAsync(int systemProfileId, List<AiChatMessage> messages, CancellationToken cancellationToken = default);
}
Loading
Loading