using System.ComponentModel; using TinyChat.Messages.Formatting; namespace TinyChat; /// /// A user control that provides a chat interface with message display and text input functionality. /// public partial class ChatControl : UserControl { private const string ROBOT_WELCOME = "●\n┌─┴─┐\n◉‿◉\n└───┘\n\nGreetings human.\nHow can I help you today?"; private List _messages = []; /// /// Occurs when a message is sent from the text box and allows the cancellation of sending. /// public event EventHandler? MessageSending; /// /// Occurs when a message has been sent from the user interface. /// public event EventHandler? MessageSent; /// /// Gets the control that manages and displays the chat message history. /// /// /// The control responsible for displaying chat messages, or if not initialized. /// [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public Control? MessageHistoryControl { get; private set; } /// /// Gets the control that displays the welcome message when no chat messages are present. /// /// /// The welcome message control, or if not initialized. /// [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public Control? WelcomeControl { get; private set; } /// /// Gets the control that provides the chat input interface for sending messages. /// /// /// The input control for entering and sending chat messages, or if not initialized. /// [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public Control? InputControl { get; private set; } /// /// Gets the split container control that divides the chat history panel from the input panel. /// /// /// The split container control managing the layout of history and input areas, or if not initialized. /// [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public Control? SplitContainerControl { get; private set; } /// /// Initializes a new instance of the class. /// public ChatControl() => InitializeComponent(); /// /// Gets or sets the message history displayed in the chat control. /// [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public IEnumerable Messages { get => _messages.AsReadOnly(); set { _messages = value is null ? [] : [.. value]; PopulateMessages(); } } /// /// Gets or sets the welcome message displayed when no messages are present in the chat history. /// [Category("Chat")] [Description("Gets or sets the welcome message displayed when no messages are present in the chat history.")] [DefaultValue(ROBOT_WELCOME)] [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] public string WelcomeMessage { get; set; } = ROBOT_WELCOME; /// /// Gets or sets the splitter position dividing the chat message history from the chat input box below. /// [Category("Chat")] [DefaultValue(60)] [Description("Gets or sets the splitter position dividing the chat message history from the chat input box below.")] [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] public int SplitterPosition { get => (SplitContainerControl as ISplitContainerControl)?.SplitterPosition ?? 0; set { if (SplitContainerControl is ISplitContainerControl splitContainer) splitContainer.SplitterPosition = value; } } /// /// Gets or sets the sender for messages sent from this chat control. /// [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public ISender Sender { get; set; } = new NamedSender(Environment.UserName); /// /// Gets or sets the formatter that converts message content into displayable strings. /// [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public IMessageFormatter MessageFormatter { get; set; } = new PlainTextMessageFormatter(); /// /// Updates the visibility of the welcome control based on the current message history. /// protected virtual void UpdateWelcomeControlVisibility() { if (WelcomeControl is not null) WelcomeControl.Visible = ShouldShowWelcomeControl(); } /// /// Determines whether the welcome control should be displayed based on the current message history. /// protected virtual bool ShouldShowWelcomeControl() => _messages.Count == 0; /// protected override void OnHandleCreated(EventArgs e) { base.OnHandleCreated(e); MessageFormatter = CreateDefaultMessageFormatter() ?? MessageFormatter; } /// /// Creates the message formatter that is used to display chat messages contents in the chat user interface /// /// protected virtual IMessageFormatter? CreateDefaultMessageFormatter() => null; /// /// Adds a chat message to the message history control. /// /// The sender of the message. /// The content of the message. /// public virtual IChatMessageControl AddMessage(ISender sender, IChatMessageContent content) { var message = AddChatMessage(sender, content); UpdateWelcomeControlVisibility(); return AppendMessageControl(message); } /// /// Adds a chat message with with support of streaming input, like when an AI assistant is streaming tokens /// /// The sender of the streaming message. /// The stream of the tokens. /// An optional callback that can be used to process the streamed messages after it was received completely. /// An optional callback that can be used to process exceptions that occured during the processing of the stream. /// An optional synchronization context. Only required if the applications does not provide a default synchronization context. /// The token to cancel the operation with. /// public virtual IChatMessageControl AddStreamingMessage( ISender sender, IAsyncEnumerable stream, SynchronizationContext? synchronizationContext = default, Action? completionCallback = default, Action? exceptionCallback = default, CancellationToken cancellationToken = default) { var cancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); void InputControl_CancellationRequested(object? sender, EventArgs e) => cancellationSource.Cancel(); var stringBuilder = new NotifyingStringBuilder(); var content = new ChangingMessageContent(stringBuilder); var message = AddChatMessage(sender, content); var context = (synchronizationContext ?? SynchronizationContext.Current) ?? throw new InvalidOperationException("No synchronization context available. Please make sure a the default SynchronizationContext is available or pass in an SynchronizationContext as argument!"); UpdateWelcomeControlVisibility(); var messageControl = AppendMessageControl(message); var inputControl = InputControl as IChatInputControl; // loop through the stream in a background thread and append the chunks to the string builder context.Post(async (_) => { try { if (inputControl != null) inputControl.CancellationRequested += InputControl_CancellationRequested; messageControl.SetIsReceivingStream(true); inputControl?.SetIsReceivingStream(true, allowCancellation: cancellationToken.CanBeCanceled); await foreach (var chunk in stream.ConfigureAwait(true).WithCancellation(cancellationSource.Token)) { stringBuilder.Append(chunk); // leave the chat if cancellation was requested, the stream might or might not support cancellation. if (cancellationToken.IsCancellationRequested) break; } } catch (Exception ex) { exceptionCallback?.Invoke(ex); if (!cancellationSource.Token.IsCancellationRequested) throw; } finally { inputControl?.SetIsReceivingStream(false, allowCancellation: false); messageControl.SetIsReceivingStream(false); if (inputControl != null) inputControl.CancellationRequested -= InputControl_CancellationRequested; } completionCallback?.Invoke(stringBuilder.ToString()); }, state: null); return messageControl; } /// /// Removes a given message from the chat /// /// public virtual void RemoveMessage(IChatMessage message) { _messages.Remove(message); if (MessageHistoryControl is IChatMessageHistoryControl casted) casted.RemoveMessageControl(message); UpdateWelcomeControlVisibility(); } /// /// Raises the event and initializes the chat control layout. /// protected override void OnCreateControl() { base.OnCreateControl(); var splitContainer = CreateSplitContainerControl(); SplitContainerControl = (Control)splitContainer; Controls.Add(SplitContainerControl); LayoutSplitContainerControl(SplitContainerControl); MessageHistoryControl = (Control)CreateMessageHistoryControl(); splitContainer?.HistoryPanel?.Controls.Add(MessageHistoryControl); LayoutMessageHistoryControl(MessageHistoryControl); WelcomeControl = CreateWelcomeControl(); splitContainer?.HistoryPanel?.Controls.Add(WelcomeControl); LayoutWelcomeControl(WelcomeControl); var inputControl = CreateChatInputControl(); inputControl.MessageSending += (_, e) => SendMessage(e); InputControl = (Control)inputControl; splitContainer?.ChatInputPanel?.Controls.Add(InputControl); LayoutChatInputControl(InputControl); PopulateMessages(); } /// /// Adds the messages to the controls /// private void PopulateMessages() { if (MessageHistoryControl is IChatMessageHistoryControl casted) casted.ClearMessageControls(); foreach (var message in _messages) AppendMessageControl(message); UpdateWelcomeControlVisibility(); } /// /// Appends a chat message to the message container. /// /// The chat message to append. protected virtual IChatMessageControl AppendMessageControl(IChatMessage message) { var messageControl = CreateMessageControl(message); messageControl.Message = message; var control = (Control)messageControl; if (MessageHistoryControl is IChatMessageHistoryControl casted) { LayoutMessageControl(MessageHistoryControl, control); casted.AppendMessageControl(messageControl); } return messageControl; } /// /// Creates the container control that will hold all chat messages. /// /// A that serves as the messages container. protected virtual IChatMessageHistoryControl CreateMessageHistoryControl() => new FlowLayoutMessageHistoryControl(); /// /// Applies layout settings to the messages container control. /// /// The control to layout. protected virtual void LayoutMessageHistoryControl(Control control) { control.Dock = DockStyle.Fill; } /// /// Creates the container control that will hold all chat messages. /// /// A that serves as the messages container. protected virtual Control CreateWelcomeControl() { var label = new Label { Text = WelcomeMessage, TextAlign = ContentAlignment.MiddleCenter, Dock = DockStyle.Fill, Font = new Font("Tahoma", 14f), UseMnemonic = false }; var panel = new Panel(); panel.Controls.Add(label); return panel; } /// /// Applies layout settings to the messages container control. /// /// The control to layout. protected virtual void LayoutWelcomeControl(Control control) { control.Dock = DockStyle.Fill; control.BringToFront(); } /// /// Creates a message control for displaying a specific chat message. /// /// The chat message to create a control for. /// An instance for the message. protected virtual IChatMessageControl CreateMessageControl(IChatMessage message) => new ChatMessageControl() { Message = message, MessageFormatter = MessageFormatter }; /// /// Applies layout settings to a chat message control and adds it to the container. /// /// The container to add the message control to. /// The chat message control to layout and add. protected virtual void LayoutMessageControl(Control container, Control chatMessageControl) { chatMessageControl.Dock = DockStyle.Fill; } /// /// Creates the split container control that holds the message history and input controls. /// /// protected virtual ISplitContainerControl CreateSplitContainerControl() => new ChatSplitContainerControl(); /// /// Applies layout settings to the split container control. /// /// protected virtual void LayoutSplitContainerControl(Control splitter) { splitter.Dock = DockStyle.Fill; ((ISplitContainerControl)splitter).SplitterPosition = 60; } /// /// Creates the text input control for sending new messages. /// /// An instance for message input. protected virtual IChatInputControl CreateChatInputControl() => new ChatInputControl(); /// /// Applies layout settings to the text input control. /// /// The text box control to layout. protected virtual void LayoutChatInputControl(Control textBox) => textBox.Dock = DockStyle.Fill; /// /// Sends a message from the current sender with the specified text content. /// /// The text content of the message to send. /// /// if the message was sent successfully; /// if the message sending was cancelled. /// /// /// This method creates a wrapper around the provided text /// and uses the control's default property for the message sender. /// The message sending can be cancelled by handling the event /// and setting the MessageSendingEventArgs.Cancel property to . /// public bool SendMessage(string message) { var args = new MessageSendingEventArgs(Sender, new StringMessageContent(message)); SendMessage(args); return !args.Cancel; } /// /// Sends a message from the specified sender with the given content. /// /// The sender of the message. /// The content of the message to send. /// /// if the message was sent successfully; /// if the message sending was cancelled. /// /// /// This method allows specifying both the sender and content of the message explicitly. /// The message sending can be cancelled by handling the event /// and setting the MessageSendingEventArgs.Cancel property to . /// public bool SendMessage(ISender sender, IChatMessageContent content) { var args = new MessageSendingEventArgs(sender, content); SendMessage(args); return !args.Cancel; } /// /// Sends a message using the provided event arguments, handling the complete message sending workflow. /// /// The event arguments containing the sender, content, and cancellation state. /// /// This method orchestrates the complete message sending process: /// /// Determines the effective sender (uses .Sender if provided, otherwise falls back to the control's ) /// Raises the event to allow subscribers to inspect or cancel the operation /// If not cancelled, adds the message to the chat history and displays it /// Raises the event to notify subscribers that the message was successfully sent /// /// The message sending can be cancelled by setting the MessageSendingEventArgs.Cancel property to /// in the event handler. /// public virtual void SendMessage(MessageSendingEventArgs e) { var sender = e.Sender ?? Sender; MessageSending?.Invoke(this, e); if (!e.Cancel) { AppendMessageControl(AddChatMessage(sender, e.Content)); MessageSent?.Invoke(this, new MessageSentEventArgs(sender, e.Content)); } } /// /// Creates a new chat message instance. /// /// The sender of the message. /// The content of the message. /// A new instance. protected virtual IChatMessage CreateChatMessage(ISender sender, IChatMessageContent content) => new ChatMessage(sender, content); /// /// Creates a new chat message and adds it to the message history. /// /// The sender of the message. /// The content of the message. /// A new instance. protected virtual IChatMessage AddChatMessage(ISender sender, IChatMessageContent content) { var message = CreateChatMessage(sender, content); _messages.Add(message); return message; } }