2025-01-13 18:51:26 +00:00
// ReSharper disable InconsistentNaming
2025-02-09 11:36:37 +00:00
2025-02-17 11:33:34 +00:00
using AIStudio.Assistants.ERI ;
2025-02-17 13:12:46 +00:00
using AIStudio.Chat ;
using AIStudio.Tools.ERIClient ;
2025-02-09 11:36:37 +00:00
using AIStudio.Tools.ERIClient.DataModel ;
2026-05-18 14:26:51 +00:00
using AIStudio.Tools.PluginSystem ;
2025-02-17 13:12:46 +00:00
using AIStudio.Tools.RAG ;
using AIStudio.Tools.Services ;
2026-05-18 14:26:51 +00:00
using Lua ;
2025-02-17 13:12:46 +00:00
using ChatThread = AIStudio . Chat . ChatThread ;
using ContentType = AIStudio . Tools . ERIClient . DataModel . ContentType ;
2025-02-09 11:36:37 +00:00
2025-01-13 18:51:26 +00:00
namespace AIStudio.Settings.DataModel ;
/// <summary>
/// An external data source, accessed via an ERI server, cf. https://github.com/MindWorkAI/ERI.
/// </summary>
public readonly record struct DataSourceERI_V1 : IERIDataSource
{
2026-05-18 14:26:51 +00:00
private static readonly ILogger < DataSourceERI_V1 > LOGGER = Program . LOGGER_FACTORY . CreateLogger < DataSourceERI_V1 > ( ) ;
2025-01-13 18:51:26 +00:00
public DataSourceERI_V1 ( )
{
}
/// <inheritdoc />
public uint Num { get ; init ; }
/// <inheritdoc />
public string Id { get ; init ; } = Guid . Empty . ToString ( ) ;
/// <inheritdoc />
public string Name { get ; init ; } = string . Empty ;
/// <inheritdoc />
public DataSourceType Type { get ; init ; } = DataSourceType . NONE ;
2025-02-09 11:36:37 +00:00
/// <inheritdoc />
2025-01-13 18:51:26 +00:00
public string Hostname { get ; init ; } = string . Empty ;
2025-02-09 11:36:37 +00:00
/// <inheritdoc />
2025-01-13 18:51:26 +00:00
public int Port { get ; init ; }
2025-02-09 11:36:37 +00:00
/// <inheritdoc />
2025-01-13 18:51:26 +00:00
public AuthMethod AuthMethod { get ; init ; } = AuthMethod . NONE ;
2025-02-09 11:36:37 +00:00
/// <inheritdoc />
2025-01-13 18:51:26 +00:00
public string Username { get ; init ; } = string . Empty ;
2025-02-15 14:41:12 +00:00
2026-05-18 14:26:51 +00:00
/// <inheritdoc />
public DataSourceERIUsernamePasswordMode UsernamePasswordMode { get ; init ; } = DataSourceERIUsernamePasswordMode . USER_MANAGED ;
2025-02-15 14:41:12 +00:00
/// <inheritdoc />
public DataSourceSecurity SecurityPolicy { get ; init ; } = DataSourceSecurity . NOT_SPECIFIED ;
2026-05-18 14:26:51 +00:00
/// <inheritdoc />
public bool IsEnterpriseConfiguration { get ; init ; }
/// <inheritdoc />
public Guid EnterpriseConfigurationPluginId { get ; init ; } = Guid . Empty ;
2025-02-17 11:33:34 +00:00
/// <inheritdoc />
public ERIVersion Version { get ; init ; } = ERIVersion . V1 ;
2025-02-17 13:12:46 +00:00
2025-03-08 20:04:17 +00:00
/// <inheritdoc />
2025-03-08 20:24:39 +00:00
public string SelectedRetrievalId { get ; init ; } = string . Empty ;
2025-05-26 17:53:31 +00:00
/// <inheritdoc />
public ushort MaxMatches { get ; init ; } = 10 ;
2025-03-08 20:04:17 +00:00
2025-02-17 13:12:46 +00:00
/// <inheritdoc />
2025-09-25 17:47:18 +00:00
public async Task < IReadOnlyList < IRetrievalContext > > RetrieveDataAsync ( IContent lastUserPrompt , ChatThread thread , CancellationToken token = default )
2025-02-17 13:12:46 +00:00
{
// Important: Do not dispose the RustService here, as it is a singleton.
var rustService = Program . SERVICE_PROVIDER . GetRequiredService < RustService > ( ) ;
var logger = Program . SERVICE_PROVIDER . GetRequiredService < ILogger < DataSourceERI_V1 > > ( ) ;
using var eriClient = ERIClientFactory . Get ( this . Version , this ) ! ;
2025-03-11 12:57:47 +00:00
var authResponse = await eriClient . AuthenticateAsync ( rustService , cancellationToken : token ) ;
2025-02-17 13:12:46 +00:00
if ( authResponse . Successful )
{
var retrievalRequest = new RetrievalRequest
{
2025-09-25 17:47:18 +00:00
LatestUserPromptType = lastUserPrompt . ToERIContentType ,
LatestUserPrompt = lastUserPrompt switch
2025-02-17 13:12:46 +00:00
{
ContentText text = > text . Text ,
2025-12-30 17:30:32 +00:00
ContentImage image = > await image . TryAsBase64 ( token ) is ( success : true , { } base64Image )
? base64Image
: string . Empty ,
2025-02-17 13:12:46 +00:00
_ = > string . Empty
} ,
Thread = await thread . ToERIChatThread ( token ) ,
2025-05-26 17:53:31 +00:00
MaxMatches = this . MaxMatches ,
2026-05-18 14:26:51 +00:00
RetrievalProcessId = this . SelectedRetrievalId ,
2025-02-17 13:12:46 +00:00
Parameters = null , // The ERI server selects useful default parameters
} ;
var retrievalResponse = await eriClient . ExecuteRetrievalAsync ( retrievalRequest , token ) ;
if ( retrievalResponse is { Successful : true , Data : not null } )
{
//
// Next, we have to transform the ERI context back to our generic retrieval context:
//
var genericRetrievalContexts = new List < IRetrievalContext > ( retrievalResponse . Data . Count ) ;
foreach ( var eriContext in retrievalResponse . Data )
{
switch ( eriContext . Type )
{
case ContentType . TEXT :
genericRetrievalContexts . Add ( new RetrievalTextContext
{
Path = eriContext . Path ? ? string . Empty ,
Type = eriContext . ToRetrievalContentType ( ) ,
Links = eriContext . Links ,
2025-02-22 19:51:06 +00:00
Category = eriContext . Type . ToRetrievalContentCategory ( ) ,
2025-02-17 13:12:46 +00:00
MatchedText = eriContext . MatchedContent ,
2025-09-25 17:47:18 +00:00
DataSourceName = eriContext . Name ,
2025-02-17 13:12:46 +00:00
SurroundingContent = eriContext . SurroundingContent ,
} ) ;
break ;
case ContentType . IMAGE :
genericRetrievalContexts . Add ( new RetrievalImageContext
{
Path = eriContext . Path ? ? string . Empty ,
Type = eriContext . ToRetrievalContentType ( ) ,
Links = eriContext . Links ,
Source = eriContext . MatchedContent ,
2025-02-22 19:51:06 +00:00
Category = eriContext . Type . ToRetrievalContentCategory ( ) ,
2025-02-17 13:12:46 +00:00
SourceType = ContentImageSource . BASE64 ,
2025-09-25 17:47:18 +00:00
DataSourceName = eriContext . Name ,
2025-02-17 13:12:46 +00:00
} ) ;
break ;
default :
logger . LogWarning ( $"The ERI context type '{eriContext.Type}' is not supported yet." ) ;
break ;
}
}
return genericRetrievalContexts ;
}
logger . LogWarning ( $"Was not able to retrieve data from the ERI data source '{this.Name}'. Message: {retrievalResponse.Message}" ) ;
return [ ] ;
}
logger . LogWarning ( $"Was not able to authenticate with the ERI data source '{this.Name}'. Message: {authResponse.Message}" ) ;
return [ ] ;
}
2026-05-18 14:26:51 +00:00
public static bool TryParseConfiguration ( int idx , LuaTable table , Guid configPluginId , out DataSourceERI_V1 dataSource )
{
dataSource = default ;
if ( ! table . TryGetValue ( "Id" , out var idValue ) | | ! idValue . TryRead < string > ( out var idText ) | | ! Guid . TryParse ( idText , out var id ) )
{
LOGGER . LogWarning ( $"The configured data source {idx} does not contain a valid ID. The ID must be a valid GUID. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( ! table . TryGetValue ( "Name" , out var nameValue ) | | ! nameValue . TryRead < string > ( out var name ) | | string . IsNullOrWhiteSpace ( name ) )
{
LOGGER . LogWarning ( $"The configured data source {idx} does not contain a valid name. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( ! table . TryGetValue ( "Type" , out var typeValue ) | | ! typeValue . TryRead < string > ( out var typeText ) | | ! Enum . TryParse < DataSourceType > ( typeText , true , out var type ) | | type is not DataSourceType . ERI_V1 )
{
LOGGER . LogWarning ( $"The configured data source {idx} does not contain a supported data source type. Only ERI_V1 is supported. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( ! table . TryGetValue ( "Hostname" , out var hostnameValue ) | | ! hostnameValue . TryRead < string > ( out var hostname ) | | string . IsNullOrWhiteSpace ( hostname ) )
{
LOGGER . LogWarning ( $"The configured data source {idx} does not contain a valid hostname. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( ! table . TryGetValue ( "Port" , out var portValue ) | | ! portValue . TryRead < int > ( out var port ) | | port is < 1 or > 65535 )
{
LOGGER . LogWarning ( $"The configured data source {idx} does not contain a valid port. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( ! table . TryGetValue ( "AuthMethod" , out var authMethodValue ) | | ! authMethodValue . TryRead < string > ( out var authMethodText ) | | ! Enum . TryParse < AuthMethod > ( authMethodText , true , out var authMethod ) )
{
LOGGER . LogWarning ( $"The configured data source {idx} does not contain a valid auth method. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( ! table . TryGetValue ( "SecurityPolicy" , out var securityPolicyValue ) | | ! securityPolicyValue . TryRead < string > ( out var securityPolicyText ) | | ! Enum . TryParse < DataSourceSecurity > ( securityPolicyText , true , out var securityPolicy ) )
{
LOGGER . LogWarning ( $"The configured data source {idx} does not contain a valid security policy. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( securityPolicy is DataSourceSecurity . NOT_SPECIFIED )
{
LOGGER . LogWarning ( $"The configured data source {idx} must specify a security policy. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( ! table . TryGetValue ( "SelectedRetrievalId" , out var selectedRetrievalIdValue ) | | ! selectedRetrievalIdValue . TryRead < string > ( out var selectedRetrievalId ) | | string . IsNullOrWhiteSpace ( selectedRetrievalId ) )
{
LOGGER . LogWarning ( $"The configured data source {idx} must specify a selected retrieval ID. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( ! table . TryGetValue ( "MaxMatches" , out var maxMatchesValue ) | | ! maxMatchesValue . TryRead < int > ( out var maxMatches ) | | maxMatches is < 1 or > ushort . MaxValue )
{
LOGGER . LogWarning ( $"The configured data source {idx} does not contain a valid maximum number of matches. (Plugin ID: {configPluginId})" ) ;
return false ;
}
var username = string . Empty ;
var usernamePasswordMode = DataSourceERIUsernamePasswordMode . USER_MANAGED ;
if ( table . TryGetValue ( "UsernamePasswordMode" , out var usernamePasswordModeValue ) & & usernamePasswordModeValue . TryRead < string > ( out var usernamePasswordModeText ) )
{
if ( ! Enum . TryParse ( usernamePasswordModeText , true , out usernamePasswordMode ) )
{
LOGGER . LogWarning ( $"The configured data source {idx} does not contain a valid username/password mode. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( usernamePasswordMode is DataSourceERIUsernamePasswordMode . USER_MANAGED )
{
LOGGER . LogWarning ( $"The configured data source {idx} uses the user-managed username/password mode. This mode is not allowed in configuration plugins. (Plugin ID: {configPluginId})" ) ;
return false ;
}
}
if ( authMethod is AuthMethod . USERNAME_PASSWORD )
{
if ( ! table . TryGetValue ( "UsernamePasswordMode" , out _ ) | | usernamePasswordMode is DataSourceERIUsernamePasswordMode . USER_MANAGED )
{
LOGGER . LogWarning ( $"The configured data source {idx} must specify an organization-managed username/password mode. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( usernamePasswordMode is DataSourceERIUsernamePasswordMode . SHARED_USERNAME_AND_PASSWORD & &
( ! table . TryGetValue ( "Username" , out var usernameValue ) | | ! usernameValue . TryRead < string > ( out username ) | | string . IsNullOrWhiteSpace ( username ) ) )
{
LOGGER . LogWarning ( $"The configured data source {idx} must specify a username. (Plugin ID: {configPluginId})" ) ;
return false ;
}
}
dataSource = new DataSourceERI_V1
{
Num = 0 ,
Id = id . ToString ( ) ,
Name = name ,
Type = DataSourceType . ERI_V1 ,
Hostname = CleanHostname ( hostname ) ,
Port = port ,
AuthMethod = authMethod ,
Username = username ,
UsernamePasswordMode = usernamePasswordMode ,
SecurityPolicy = securityPolicy ,
Version = ERIVersion . V1 ,
SelectedRetrievalId = selectedRetrievalId ,
MaxMatches = ( ushort ) maxMatches ,
IsEnterpriseConfiguration = true ,
EnterpriseConfigurationPluginId = configPluginId ,
} ;
return TryQueueEnterpriseSecret ( idx , table , configPluginId , dataSource ) ;
}
/// <summary>
/// Exports the ERI v1 data source configuration as a Lua configuration section.
/// </summary>
/// <param name="encryptedSecret">Optional encrypted token or password to include in the export.</param>
/// <param name="usernamePasswordMode">The organization-managed username/password mode to export.</param>
/// <returns>A Lua configuration section string.</returns>
public string ExportAsConfigurationSection ( string? encryptedSecret = null , DataSourceERIUsernamePasswordMode usernamePasswordMode = DataSourceERIUsernamePasswordMode . USER_MANAGED )
{
var secretLine = string . Empty ;
var usernamePasswordModeLine = string . Empty ;
var usernameLine = string . Empty ;
switch ( this . AuthMethod )
{
case AuthMethod . TOKEN :
secretLine = CreateSecretLine ( "Token" , encryptedSecret ) ;
break ;
case AuthMethod . USERNAME_PASSWORD :
if ( usernamePasswordMode is DataSourceERIUsernamePasswordMode . USER_MANAGED )
usernamePasswordMode = DataSourceERIUsernamePasswordMode . OS_USERNAME_SHARED_PASSWORD ;
usernamePasswordModeLine = $"" "
["UsernamePasswordMode"] = "{usernamePasswordMode}" ,
"" ";
if ( usernamePasswordMode is DataSourceERIUsernamePasswordMode . SHARED_USERNAME_AND_PASSWORD )
{
var username = string . IsNullOrWhiteSpace ( this . Username ) ? "<shared username>" : this . Username ;
usernameLine = $"" "
["Username"] = "{LuaTools.EscapeLuaString(username)}" ,
"" ";
}
secretLine = CreateSecretLine ( "Password" , encryptedSecret ) ;
break ;
}
return $ $"" "
CONFIG [ "DATA_SOURCES" ] [ # CONFIG [ "DATA_SOURCES" ] + 1 ] = {
["Id"] = "{{Guid.NewGuid().ToString()}}" ,
["Name"] = "{{LuaTools.EscapeLuaString(this.Name)}}" ,
["Type"] = "ERI_V1" ,
["Hostname"] = "{{LuaTools.EscapeLuaString(this.Hostname)}}" ,
["Port"] = { { this . Port } } ,
["AuthMethod"] = "{{this.AuthMethod}}" ,
{ { usernamePasswordModeLine } }
{ { usernameLine } }
{ { secretLine } }
["SecurityPolicy"] = "{{this.SecurityPolicy}}" ,
["SelectedRetrievalId"] = "{{LuaTools.EscapeLuaString(this.SelectedRetrievalId)}}" ,
["MaxMatches"] = { { this . MaxMatches } } ,
}
"" ";
}
private static bool TryQueueEnterpriseSecret ( int idx , LuaTable table , Guid configPluginId , DataSourceERI_V1 dataSource )
{
var secretFieldName = dataSource . AuthMethod switch
{
AuthMethod . TOKEN = > "Token" ,
AuthMethod . USERNAME_PASSWORD = > "Password" ,
_ = > string . Empty ,
} ;
if ( string . IsNullOrWhiteSpace ( secretFieldName ) )
return true ;
if ( ! table . TryGetValue ( secretFieldName , out var secretValue ) | | ! secretValue . TryRead < string > ( out var encryptedSecret ) | | string . IsNullOrWhiteSpace ( encryptedSecret ) )
{
LOGGER . LogWarning ( $"The configured data source {idx} does not contain a valid encrypted {secretFieldName}. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( ! EnterpriseEncryption . IsEncrypted ( encryptedSecret ) )
{
LOGGER . LogWarning ( $"The configured data source {idx} contains a plaintext {secretFieldName}. Only encrypted secrets (starting with 'ENC:v1:') are supported. (Plugin ID: {configPluginId})" ) ;
return false ;
}
var encryption = PluginFactory . EnterpriseEncryption ;
if ( encryption ? . IsAvailable ! = true )
{
LOGGER . LogWarning ( $"The configured data source {idx} contains an encrypted {secretFieldName}, but no encryption secret is configured. (Plugin ID: {configPluginId})" ) ;
return false ;
}
if ( ! encryption . TryDecrypt ( encryptedSecret , out var decryptedSecret ) )
{
LOGGER . LogWarning ( $"Failed to decrypt the {secretFieldName} for data source {idx}. The encryption secret may be incorrect. (Plugin ID: {configPluginId})" ) ;
return false ;
}
PendingEnterpriseSecrets . Add ( new (
$"{ISecretId.ENTERPRISE_KEY_PREFIX}::{dataSource.Id}" ,
dataSource . Name ,
decryptedSecret ,
SecretStoreType . DATA_SOURCE ) ) ;
LOGGER . LogDebug ( $"Successfully decrypted the {secretFieldName} for data source {idx}. It will be stored in the OS keyring. (Plugin ID: {configPluginId})" ) ;
return true ;
}
private static string CreateSecretLine ( string fieldName , string? encryptedSecret )
{
if ( string . IsNullOrWhiteSpace ( encryptedSecret ) )
return string . Empty ;
return $"" "
["{fieldName}"] = "{LuaTools.EscapeLuaString(encryptedSecret)}" ,
"" ";
}
private static string CleanHostname ( string hostname )
{
var cleanedHostname = hostname . Trim ( ) ;
return cleanedHostname . EndsWith ( '/' ) ? cleanedHostname [ . . ^ 1 ] : cleanedHostname ;
}
2025-01-13 18:51:26 +00:00
}