2026-01-18 19:36:04 +00:00
using System.Globalization ;
2026-09-04 13:48:07 +00:00
using System.Text.Json.Serialization ;
2026-01-18 19:36:04 +00:00
2025-02-17 11:33:34 +00:00
using AIStudio.Components ;
2026-09-04 13:48:07 +00:00
using AIStudio.Provider ;
2025-01-02 13:50:54 +00:00
using AIStudio.Settings ;
2025-02-15 14:41:12 +00:00
using AIStudio.Settings.DataModel ;
2026-09-04 13:48:07 +00:00
using AIStudio.Tools.ToolCallingSystem ;
2025-02-17 13:12:46 +00:00
using AIStudio.Tools.ERIClient.DataModel ;
2025-01-02 13:50:54 +00:00
2024-05-04 09:11:09 +00:00
namespace AIStudio.Chat ;
/// <summary>
/// Data structure for a chat thread.
/// </summary>
2024-08-18 19:48:35 +00:00
public sealed record ChatThread
2024-05-04 09:11:09 +00:00
{
2025-09-03 19:25:17 +00:00
private static readonly ILogger < ChatThread > LOGGER = Program . LOGGER_FACTORY . CreateLogger < ChatThread > ( ) ;
2024-07-13 08:37:57 +00: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 10:53:30 +00: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 12:04:02 +00:00
/// <summary>
/// Specifies the provider selected for the chat thread.
/// </summary>
public string SelectedProvider { get ; set ; } = string . Empty ;
2025-01-02 13:50:54 +00:00
/// <summary>
/// Specifies the profile selected for the chat thread.
/// </summary>
public string SelectedProfile { get ; set ; } = string . Empty ;
2025-05-24 10:27:00 +00:00
/// <summary>
/// Specifies the profile selected for the chat thread.
/// </summary>
public string SelectedChatTemplate { get ; set ; } = string . Empty ;
2025-01-02 13:50:54 +00:00
2026-09-04 13:48:07 +00: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 19:36:04 +00: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 14:41:12 +00:00
/// <summary>
/// The data source options for this chat thread.
/// </summary>
public DataSourceOptions DataSourceOptions { get ; set ; } = new ( ) ;
2025-02-17 11:33:34 +00:00
/// <summary>
/// The AI-selected data sources for this chat thread.
/// </summary>
public IReadOnlyList < DataSourceAgentSelected > AISelectedDataSources { get ; set ; } = [ ] ;
2025-03-08 12:56:38 +00: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 19:13:08 +00: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 13:48:07 +00: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 09:11:09 +00:00
/// <summary>
/// The name of the chat thread. Usually generated by an AI model or manually edited by the user.
/// </summary>
2024-07-13 08:37:57 +00:00
public string Name { get ; set ; } = string . Empty ;
2024-05-04 09:11:09 +00:00
/// <summary>
/// The current system prompt for the chat thread.
/// </summary>
2026-01-18 19:36:04 +00:00
public string SystemPrompt { get ; set ; } = string . Empty ;
2024-07-13 08:37:57 +00:00
2024-05-04 09:11:09 +00:00
/// <summary>
/// The content blocks of the chat thread.
/// </summary>
2024-07-13 08:37:57 +00:00
public List < ContentBlock > Blocks { get ; init ; } = [ ] ;
2026-09-04 13:48:07 +00: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 10:27:00 +00:00
private bool allowProfile = true ;
2025-01-02 13:50:54 +00: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 13:48:07 +00: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 13:50:54 +00:00
/// <returns>The prepared system prompt.</returns>
2026-09-04 13:48:07 +00:00
public string PrepareSystemPrompt ( SettingsManager settingsManager , IEnumerable < ToolDefinition > ? runnableToolDefinitions = null )
2025-01-02 13:50:54 +00:00
{
2026-06-10 19:01:27 +00:00
this . allowProfile = true ;
2025-05-24 10:27:00 +00:00
//
// Use the information from the chat template, if provided. Otherwise, use the default system prompt
//
string systemPromptTextWithChatTemplate ;
2026-01-18 19:36:04 +00:00
var logMessage = $"Using no chat template for chat thread '{this.Name}'." ;
if ( string . IsNullOrWhiteSpace ( this . SelectedChatTemplate ) )
systemPromptTextWithChatTemplate = this . SystemPrompt ;
2025-05-24 10:27:00 +00:00
else
{
2026-01-18 19:36:04 +00:00
if ( ! Guid . TryParse ( this . SelectedChatTemplate , out var chatTemplateId ) )
systemPromptTextWithChatTemplate = this . SystemPrompt ;
2025-05-24 10:27:00 +00:00
else
{
2026-01-18 19:36:04 +00:00
if ( this . SelectedChatTemplate = = ChatTemplate . NO_CHAT_TEMPLATE . Id | | chatTemplateId = = Guid . Empty )
systemPromptTextWithChatTemplate = this . SystemPrompt ;
2025-05-24 10:27:00 +00:00
else
{
2026-06-10 19:01:27 +00:00
var chatTemplate = settingsManager . GetChatTemplateById ( this . SelectedChatTemplate ) ;
if ( chatTemplate = = ChatTemplate . NO_CHAT_TEMPLATE )
2026-01-18 19:36:04 +00:00
systemPromptTextWithChatTemplate = this . SystemPrompt ;
2025-05-24 10:27:00 +00:00
else
{
2026-01-18 19:36:04 +00:00
logMessage = $"Using chat template '{chatTemplate.Name}' for chat thread '{this.Name}'." ;
2025-05-24 10:27:00 +00:00
this . allowProfile = chatTemplate . AllowProfileUsage ;
2025-05-24 17:11:28 +00:00
systemPromptTextWithChatTemplate = chatTemplate . ToSystemPrompt ( ) ;
2025-05-24 10:27:00 +00:00
}
}
}
}
2025-05-24 17:11:28 +00:00
2025-07-11 07:57:46 +00: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 19:36:04 +00:00
this . SystemPrompt = systemPromptTextWithChatTemplate ;
2025-09-03 19:25:17 +00:00
LOGGER . LogInformation ( logMessage ) ;
2026-01-18 19:36:04 +00:00
2025-05-24 10:27:00 +00:00
//
// Add augmented data, if available:
//
2026-01-18 19:36:04 +00:00
var isAugmentedDataAvailable = ! string . IsNullOrWhiteSpace ( this . AugmentedData ) ;
2025-03-08 12:56:38 +00:00
var systemPromptWithAugmentedData = isAugmentedDataAvailable switch
{
true = > $"" "
2025-05-24 10:27:00 +00:00
{ systemPromptTextWithChatTemplate }
2025-03-08 12:56:38 +00:00
2026-01-18 19:36:04 +00:00
{ this . AugmentedData }
2025-03-08 12:56:38 +00:00
"" ",
2025-05-24 10:27:00 +00:00
false = > systemPromptTextWithChatTemplate ,
2025-03-08 12:56:38 +00:00
} ;
if ( isAugmentedDataAvailable )
2025-09-03 19:25:17 +00:00
LOGGER . LogInformation ( "Augmented data is available for the chat thread." ) ;
2025-03-08 12:56:38 +00:00
else
2025-09-03 19:25:17 +00:00
LOGGER . LogInformation ( "No augmented data is available for the chat thread." ) ;
2025-03-08 12:56:38 +00:00
2025-05-24 10:27:00 +00:00
2025-01-02 13:50:54 +00:00
//
2025-05-24 17:11:28 +00:00
// Add information from the profile if available and allowed:
2025-01-02 13:50:54 +00:00
//
string systemPromptText ;
2026-01-18 19:36:04 +00:00
logMessage = $"Using no profile for chat thread '{this.Name}'." ;
if ( string . IsNullOrWhiteSpace ( this . SelectedProfile ) | | ! this . allowProfile )
2025-03-08 12:56:38 +00:00
systemPromptText = systemPromptWithAugmentedData ;
2025-01-02 13:50:54 +00:00
else
{
2026-01-18 19:36:04 +00:00
if ( ! Guid . TryParse ( this . SelectedProfile , out var profileId ) )
2025-03-08 12:56:38 +00:00
systemPromptText = systemPromptWithAugmentedData ;
2025-01-02 13:50:54 +00:00
else
{
2026-01-18 19:36:04 +00:00
if ( this . SelectedProfile = = Profile . NO_PROFILE . Id | | profileId = = Guid . Empty )
2025-03-08 12:56:38 +00:00
systemPromptText = systemPromptWithAugmentedData ;
2025-01-02 13:50:54 +00:00
else
{
2026-06-10 19:01:27 +00:00
var profile = settingsManager . GetProfileById ( this . SelectedProfile ) ;
if ( profile = = Profile . NO_PROFILE )
2025-03-08 12:56:38 +00:00
systemPromptText = systemPromptWithAugmentedData ;
2025-01-02 13:50:54 +00:00
else
{
2026-01-18 19:36:04 +00:00
logMessage = $"Using profile '{profile.Name}' for chat thread '{this.Name}'." ;
2025-01-02 13:50:54 +00:00
systemPromptText = $"" "
2025-03-08 12:56:38 +00:00
{ systemPromptWithAugmentedData }
2025-01-02 13:50:54 +00:00
{ profile . ToSystemPrompt ( ) }
"" ";
}
}
}
}
2025-09-03 19:25:17 +00:00
LOGGER . LogInformation ( logMessage ) ;
2026-09-04 13:48:07 +00:00
var toolPolicy = ToolSelectionRules . BuildToolPolicyPrompt ( runnableToolDefinitions ? ? [ ] ) ;
if ( ! string . IsNullOrWhiteSpace ( toolPolicy ) )
{
systemPromptText = $"" "
{ systemPromptText }
{ toolPolicy }
"" ";
}
2026-01-18 19:36:04 +00: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 13:50:54 +00:00
}
2025-01-03 20:18:27 +00:00
2025-01-03 17:01:22 +00:00
/// <summary>
/// Removes a content block from this chat thread.
/// </summary>
/// <param name="content">The content block to remove.</param>
2025-01-03 20:18:27 +00: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 17:01:22 +00:00
{
var block = this . Blocks . FirstOrDefault ( x = > x . Content = = content ) ;
if ( block is null )
return ;
2025-01-03 20:18:27 +00: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 10:53:30 +00:00
{
DeleteManagedAttachments ( previousBlock ) ;
2025-01-03 20:18:27 +00:00
this . Blocks . Remove ( previousBlock ) ;
2026-07-15 10:53:30 +00:00
}
2025-01-03 20:18:27 +00:00
}
}
2026-07-15 10:53:30 +00:00
DeleteManagedAttachments ( block ) ;
2025-01-03 20:18:27 +00:00
// Remove the block from the chat thread:
2025-01-03 17:01:22 +00:00
this . Blocks . Remove ( block ) ;
}
2025-02-17 13:12:46 +00:00
2026-07-15 10:53:30 +00: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 13:12:46 +00: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 17:30:32 +00:00
ContentImage image = > ( await image . TryAsBase64 ( token ) is ( success : true , { } base64Image ) ? base64Image : string . Empty , Tools . ERIClient . DataModel . ContentType . IMAGE ) ,
2025-02-17 13:12:46 +00: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 16:10:03 +00:00
_ = > Role . UNKNOWN ,
2025-02-17 13:12:46 +00:00
} ,
Content = contentData ,
Type = contentType ,
} ) ;
}
return new Tools . ERIClient . DataModel . ChatThread { ContentBlocks = contentBlocks } ;
}
2026-09-04 13:48:07 +00:00
}