2026-01-18 20:36:04 +01:00
using System.Globalization ;
2026-09-04 15:48:07 +02:00
using System.Text.Json.Serialization ;
2026-01-18 20:36:04 +01:00
2025-02-17 12:33:34 +01:00
using AIStudio.Components ;
2026-09-04 15:48:07 +02:00
using AIStudio.Provider ;
2025-01-02 14:50:54 +01:00
using AIStudio.Settings ;
2025-02-15 15:41:12 +01:00
using AIStudio.Settings.DataModel ;
2026-09-04 15:48:07 +02:00
using AIStudio.Tools.ToolCallingSystem ;
2025-02-17 14:12:46 +01:00
using AIStudio.Tools.ERIClient.DataModel ;
2025-01-02 14:50:54 +01:00
2024-05-04 11:11:09 +02:00
namespace AIStudio.Chat ;
/// <summary>
/// Data structure for a chat thread.
/// </summary>
2024-08-18 21:48:35 +02:00
public sealed record ChatThread
2024-05-04 11:11:09 +02:00
{
2025-09-03 21:25:17 +02:00
private static readonly ILogger < ChatThread > LOGGER = Program . LOGGER_FACTORY . CreateLogger < ChatThread >();
2024-07-13 10:37:57 +02:00
/// <summary>
/// The unique identifier of the chat thread.
/// </summary>
public Guid ChatId { get ; init ; }
/// <summary>
/// The unique identifier of the workspace.
/// </summary>
public Guid WorkspaceId { get ; set ; }
2026-07-15 12:53:30 +02:00
/// <summary>
/// The monotonically increasing number used for managed media transcript filenames.
/// </summary>
public ulong LastMediaTranscriptNumber { get ; set ; }
/// <summary>
/// Managed transcript attachments prepared for the composer but not sent yet.
/// Empty by default so older serialized threads require no migration.
/// </summary>
public List < ManagedTranscriptAttachment > PendingMediaTranscripts { get ; set ; } = [];
2024-11-23 13:04:02 +01:00
/// <summary>
/// Specifies the provider selected for the chat thread.
/// </summary>
public string SelectedProvider { get ; set ; } = string . Empty ;
2025-01-02 14:50:54 +01:00
/// <summary>
2026-09-07 16:09:00 +02:00
/// Specifies the profiles selected for the chat thread.
/// </summary>
public HashSet < string > SelectedProfileIds { get ; set ; } = [];
/// <summary>
/// Permanently supports reading the singular profile field written by older app versions.
/// New chats write only <see cref="SelectedProfileIds"/>.
2025-01-02 14:50:54 +01:00
/// </summary>
2026-09-07 16:09:00 +02:00
[JsonPropertyName("SelectedProfile")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? LegacySelectedProfile
{
get => null ;
set
{
if ( this . SelectedProfileIds . Count == 0 &&
! string . IsNullOrWhiteSpace ( value ) &&
! value . Equals ( Profile . NO_PROFILE . Id , StringComparison . OrdinalIgnoreCase ))
this . SelectedProfileIds . Add ( value );
}
}
2025-05-24 12:27:00 +02:00
/// <summary>
/// Specifies the profile selected for the chat thread.
/// </summary>
public string SelectedChatTemplate { get ; set ; } = string . Empty ;
2025-01-02 14:50:54 +01:00
2026-09-04 15:48:07 +02:00
/// <summary>
/// Specifies the tools selected for the chat thread, as the user chose them.
/// </summary>
/// <remarks>
/// Null means the thread never stored a selection, which is the case for every chat written
/// before tools existed: those open with the defaults of their component. An empty set is the
/// opposite statement — the user switched every tool off and wants it to stay that way.<br/><br/>
/// This is the unfiltered selection. What a provider may actually run is decided per request,
/// because a provider with too little confidence must not cost the user a tool permanently.
/// </remarks>
public HashSet < string >? SelectedToolIds { get ; set ; }
2026-01-18 20:36:04 +01:00
/// <summary>
/// Indicates whether to include the current date and time in the system prompt.
/// False by default for backward compatibility.
/// </summary>
public bool IncludeDateTime { get ; set ; } = false ;
2025-02-15 15:41:12 +01:00
/// <summary>
/// The data source options for this chat thread.
/// </summary>
public DataSourceOptions DataSourceOptions { get ; set ; } = new ();
2025-02-17 12:33:34 +01:00
/// <summary>
/// The AI-selected data sources for this chat thread.
/// </summary>
public IReadOnlyList < DataSourceAgentSelected > AISelectedDataSources { get ; set ; } = [];
2025-03-08 13:56:38 +01:00
/// <summary>
/// The augmented data for this chat thread. Will be inserted into the system prompt.
/// </summary>
public string AugmentedData { get ; set ; } = string . Empty ;
2025-03-08 20:13:08 +01:00
/// <summary>
/// The data security to use, derived from the data sources used so far.
/// </summary>
public DataSourceSecurity DataSecurity { get ; set ; } = DataSourceSecurity . NOT_SPECIFIED ;
2026-09-04 15:48:07 +02:00
/// <summary>
/// The minimum confidence required for providers that continue this chat after a tool returned sensitive data.
/// </summary>
[JsonInclude]
public ConfidenceLevel RequiredProviderConfidence { get ; private set ; } = ConfidenceLevel . NONE ;
public void RequireProviderConfidence ( ConfidenceLevel minimumProviderConfidence )
{
if ( minimumProviderConfidence > this . RequiredProviderConfidence )
this . RequiredProviderConfidence = minimumProviderConfidence ;
}
2024-05-04 11:11:09 +02:00
/// <summary>
/// The name of the chat thread. Usually generated by an AI model or manually edited by the user.
/// </summary>
2024-07-13 10:37:57 +02:00
public string Name { get ; set ; } = string . Empty ;
2024-05-04 11:11:09 +02:00
/// <summary>
/// The current system prompt for the chat thread.
/// </summary>
2026-01-18 20:36:04 +01:00
public string SystemPrompt { get ; set ; } = string . Empty ;
2024-07-13 10:37:57 +02:00
2024-05-04 11:11:09 +02:00
/// <summary>
/// The content blocks of the chat thread.
/// </summary>
2024-07-13 10:37:57 +02:00
public List < ContentBlock > Blocks { get ; init ; } = [];
2026-09-04 15:48:07 +02:00
[JsonIgnore]
public AIStudio . Tools . Components RuntimeComponent { get ; set ; } = AIStudio . Tools . Components . CHAT ;
[JsonIgnore]
public HashSet < string > RuntimeSelectedToolIds { get ; set ; } = [];
/// <summary>
/// Whether the tools of this run were named by the assistant's own rules instead of chosen by
/// the user.
/// </summary>
/// <remarks>
/// A user who cannot see a tool selection must not get tools they never picked, which is why
/// running tools normally requires a visible selection. That rule misses the case where nobody
/// asked the user in the first place: a document analysis policy or an assistant plugin names
/// its tools, and hiding the selection is the point rather than an obstacle. This flag tells
/// the providers which of the two they are looking at.
/// </remarks>
[JsonIgnore]
public bool RuntimeToolsAreAssistantManaged { get ; set ; }
/// <summary>
/// Whether this thread may run tools at all.
/// </summary>
public bool MayRunTools ( SettingsManager settingsManager ) => this . RuntimeToolsAreAssistantManaged || settingsManager . IsToolSelectionVisible ( this . RuntimeComponent );
2025-05-24 12:27:00 +02:00
private bool allowProfile = true ;
2025-01-02 14:50:54 +01:00
/// <summary>
/// Prepares the system prompt for the chat thread.
/// </summary>
/// <remarks>
/// The actual system prompt depends on the selected profile. If no profile is selected,
/// the system prompt is returned as is. When a profile is selected, the system prompt
/// is extended with the profile chosen.
/// </remarks>
/// <param name="settingsManager">The settings manager instance to use.</param>
2026-09-04 15:48:07 +02:00
/// <param name="runnableToolDefinitions">The tools which may run in this thread. Their instructions become part of the system prompt. Null when the thread runs without tools.</param>
2025-01-02 14:50:54 +01:00
/// <returns>The prepared system prompt.</returns>
2026-09-04 15:48:07 +02:00
public string PrepareSystemPrompt ( SettingsManager settingsManager , IEnumerable < ToolDefinition >? runnableToolDefinitions = null )
2025-01-02 14:50:54 +01:00
{
2026-06-10 21:01:27 +02:00
this . allowProfile = true ;
2025-05-24 12:27:00 +02:00
//
// Use the information from the chat template, if provided. Otherwise, use the default system prompt
//
string systemPromptTextWithChatTemplate ;
2026-01-18 20:36:04 +01:00
var logMessage = $"Using no chat template for chat thread '{this.Name}'." ;
if ( string . IsNullOrWhiteSpace ( this . SelectedChatTemplate ))
systemPromptTextWithChatTemplate = this . SystemPrompt ;
2025-05-24 12:27:00 +02:00
else
{
2026-01-18 20:36:04 +01:00
if (! Guid . TryParse ( this . SelectedChatTemplate , out var chatTemplateId ))
systemPromptTextWithChatTemplate = this . SystemPrompt ;
2025-05-24 12:27:00 +02:00
else
{
2026-01-18 20:36:04 +01:00
if ( this . SelectedChatTemplate == ChatTemplate . NO_CHAT_TEMPLATE . Id || chatTemplateId == Guid . Empty )
systemPromptTextWithChatTemplate = this . SystemPrompt ;
2025-05-24 12:27:00 +02:00
else
{
2026-06-10 21:01:27 +02:00
var chatTemplate = settingsManager . GetChatTemplateById ( this . SelectedChatTemplate );
if ( chatTemplate == ChatTemplate . NO_CHAT_TEMPLATE )
2026-01-18 20:36:04 +01:00
systemPromptTextWithChatTemplate = this . SystemPrompt ;
2025-05-24 12:27:00 +02:00
else
{
2026-01-18 20:36:04 +01:00
logMessage = $"Using chat template '{chatTemplate.Name}' for chat thread '{this.Name}'." ;
2025-05-24 12:27:00 +02:00
this . allowProfile = chatTemplate . AllowProfileUsage ;
2025-05-24 19:11:28 +02:00
systemPromptTextWithChatTemplate = chatTemplate . ToSystemPrompt ();
2025-05-24 12:27:00 +02:00
}
}
}
}
2025-05-24 19:11:28 +02:00
2025-07-11 09:57:46 +02:00
// We need a way to save the changed system prompt in our chat thread.
// Otherwise, the chat thread will always tell us that it is using the
// default system prompt:
2026-01-18 20:36:04 +01:00
this . SystemPrompt = systemPromptTextWithChatTemplate ;
2025-09-03 21:25:17 +02:00
LOGGER . LogInformation ( logMessage );
2026-01-18 20:36:04 +01:00
2025-05-24 12:27:00 +02:00
//
// Add augmented data, if available:
//
2026-01-18 20:36:04 +01:00
var isAugmentedDataAvailable = ! string . IsNullOrWhiteSpace ( this . AugmentedData );
2025-03-08 13:56:38 +01:00
var systemPromptWithAugmentedData = isAugmentedDataAvailable switch
{
true => $"""
2025-05-24 12:27:00 +02:00
{systemPromptTextWithChatTemplate}
2025-03-08 13:56:38 +01:00
2026-01-18 20:36:04 +01:00
{this.AugmentedData}
2025-03-08 13:56:38 +01:00
""" ,
2025-05-24 12:27:00 +02:00
false => systemPromptTextWithChatTemplate ,
2025-03-08 13:56:38 +01:00
};
if ( isAugmentedDataAvailable )
2025-09-03 21:25:17 +02:00
LOGGER . LogInformation ( "Augmented data is available for the chat thread." );
2025-03-08 13:56:38 +01:00
else
2025-09-03 21:25:17 +02:00
LOGGER . LogInformation ( "No augmented data is available for the chat thread." );
2025-03-08 13:56:38 +01:00
2025-05-24 12:27:00 +02:00
2025-01-02 14:50:54 +01:00
//
2026-09-07 16:09:00 +02:00
// Add information from the profiles if available and allowed:
2025-01-02 14:50:54 +01:00
//
string systemPromptText ;
2026-09-07 16:09:00 +02:00
logMessage = $"Using no profiles for chat thread '{this.Name}'." ;
var profiles = this . ResolveSelectedProfiles ( settingsManager );
if ( profiles . Count == 0 || ! this . allowProfile )
2025-03-08 13:56:38 +01:00
systemPromptText = systemPromptWithAugmentedData ;
2025-01-02 14:50:54 +01:00
else
{
2026-09-07 16:09:00 +02:00
logMessage = $"Using profiles '{string.Join(" ' , ' ", profiles.Select(profile => profile.Name))}' for chat thread '{this.Name}'." ;
systemPromptText = $"""
{systemPromptWithAugmentedData}
2025-01-02 14:50:54 +01:00
2026-09-07 16:09:00 +02:00
{Profile.ToSystemPrompt(profiles)}
""" ;
2025-01-02 14:50:54 +01:00
}
2025-09-03 21:25:17 +02:00
LOGGER . LogInformation ( logMessage );
2026-09-04 15:48:07 +02:00
var toolPolicy = ToolSelectionRules . BuildToolPolicyPrompt ( runnableToolDefinitions ?? []);
if (! string . IsNullOrWhiteSpace ( toolPolicy ))
{
systemPromptText = $"""
{systemPromptText}
{toolPolicy}
""" ;
}
2026-01-18 20:36:04 +01:00
if (! this . IncludeDateTime )
return systemPromptText ;
//
// Prepend the current date and time to the system prompt:
//
var nowUtc = DateTime . UtcNow ;
var nowLocal = DateTime . Now ;
var currentDateTime = string . Create (
new CultureInfo ( "en-US" ),
$"Today is {nowUtc:dddd, MMMM d, yyyy h:mm tt} (UTC) and {nowLocal:dddd, MMMM d, yyyy h:mm tt} (local time)."
);
return $"""
{currentDateTime}
{systemPromptText}
""" ;
2025-01-02 14:50:54 +01:00
}
2025-01-03 21:18:27 +01:00
2026-09-07 16:09:00 +02:00
private IReadOnlyList < Profile > ResolveSelectedProfiles ( SettingsManager settingsManager )
{
var profiles = settingsManager . ResolveProfiles ( this . SelectedProfileIds );
var resolvedIds = profiles . Select ( profile => profile . Id ). ToHashSet ( StringComparer . OrdinalIgnoreCase );
this . SelectedProfileIds . RemoveWhere ( profileId => ! resolvedIds . Contains ( profileId ));
return profiles ;
}
2025-01-03 18:01:22 +01:00
/// <summary>
/// Removes a content block from this chat thread.
/// </summary>
/// <param name="content">The content block to remove.</param>
2025-01-03 21:18:27 +01:00
/// <param name="removeForRegenerate">Indicates whether the content block is removed for
/// regeneration purposes. True, when the content block is removed for regeneration purposes,
/// which will not remove the previous user block if it is hidden from the user.</param>
public void Remove ( IContent content , bool removeForRegenerate = false )
2025-01-03 18:01:22 +01:00
{
var block = this . Blocks . FirstOrDefault ( x => x . Content == content );
if ( block is null )
return ;
2025-01-03 21:18:27 +01:00
//
// Remove the previous user block if it is hidden from the user. Otherwise,
// the experience might be confusing for the user.
//
// Explanation, using the ERI assistant as an example:
// - The ERI assistant generates for every file a hidden user prompt.
// - In the UI, the user can only see the AI's responses, not the hidden user prompts.
// - Now, the user removes one AI response
// - The hidden user prompt is still there, but the user can't see it.
// - Since the user prompt is hidden, neither is it possible to remove nor edit it.
// - This method solves this issue by removing the hidden user prompt when the AI response is removed.
//
if ( block . Role is ChatRole . AI && ! removeForRegenerate )
{
var sortedBlocks = this . Blocks . OrderBy ( x => x . Time ). ToList ();
var index = sortedBlocks . IndexOf ( block );
if ( index > 0 )
{
var previousBlock = sortedBlocks [ index - 1 ];
if ( previousBlock . Role is ChatRole . USER && previousBlock . HideFromUser )
2026-07-15 12:53:30 +02:00
{
DeleteManagedAttachments ( previousBlock );
2025-01-03 21:18:27 +01:00
this . Blocks . Remove ( previousBlock );
2026-07-15 12:53:30 +02:00
}
2025-01-03 21:18:27 +01:00
}
}
2026-07-15 12:53:30 +02:00
DeleteManagedAttachments ( block );
2025-01-03 21:18:27 +01:00
// Remove the block from the chat thread:
2025-01-03 18:01:22 +01:00
this . Blocks . Remove ( block );
}
2025-02-17 14:12:46 +01:00
2026-07-15 12:53:30 +02:00
private static void DeleteManagedAttachments ( ContentBlock block )
{
if ( block . Content is not ContentText textContent )
return ;
foreach ( var attachment in textContent . FileAttachments )
ManagedTranscriptAttachment . TryDeleteOwnedFile ( attachment );
}
2025-02-17 14:12:46 +01:00
/// <summary>
/// Transforms this chat thread to an ERI chat thread.
/// </summary>
/// <param name="token">The cancellation token.</param>
/// <returns>The ERI chat thread.</returns>
public async Task < Tools . ERIClient . DataModel . ChatThread > ToERIChatThread ( CancellationToken token = default )
{
//
// Transform the content blocks:
//
var contentBlocks = new List < Tools . ERIClient . DataModel . ContentBlock >( this . Blocks . Count );
foreach ( var block in this . Blocks )
{
var ( contentData , contentType ) = block . Content switch
{
2025-12-30 18:30:32 +01:00
ContentImage image => ( await image . TryAsBase64 ( token ) is ( success : true , { } base64Image ) ? base64Image : string . Empty , Tools . ERIClient . DataModel . ContentType . IMAGE ),
2025-02-17 14:12:46 +01:00
ContentText text => ( text . Text , Tools . ERIClient . DataModel . ContentType . TEXT ),
_ => ( string . Empty , Tools . ERIClient . DataModel . ContentType . UNKNOWN ),
};
contentBlocks . Add ( new Tools . ERIClient . DataModel . ContentBlock
{
Role = block . Role switch
{
ChatRole . AI => Role . AI ,
ChatRole . USER => Role . USER ,
ChatRole . AGENT => Role . AGENT ,
ChatRole . SYSTEM => Role . SYSTEM ,
ChatRole . NONE => Role . NONE ,
2025-03-17 17:10:03 +01:00
_ => Role . UNKNOWN ,
2025-02-17 14:12:46 +01:00
},
Content = contentData ,
Type = contentType ,
});
}
return new Tools . ERIClient . DataModel . ChatThread { ContentBlocks = contentBlocks };
}
2026-09-04 15:48:07 +02:00
}