diff --git a/src/BingChat/BingChatConversation.cs b/src/BingChat/BingChatConversation.cs index b314ec8..c4db498 100644 --- a/src/BingChat/BingChatConversation.cs +++ b/src/BingChat/BingChatConversation.cs @@ -53,6 +53,7 @@ internal BingChatConversation( public async Task AskAsync(string message, CancellationToken ct = default) { var request = _request.ConstructInitialPayload(message); + var responses = new List(); await using var conn = await Connect(ct); @@ -60,7 +61,8 @@ public async Task AskAsync(string message, CancellationToken ct = defaul .StreamAsync("chat", request, ct) .FirstAsync(ct); - return BuildAnswer(response) ?? ""; + responses.Add(response); + return BingChatParser.ParseMessage(response) ?? ""; } /// @@ -70,24 +72,19 @@ public async IAsyncEnumerable StreamAsync( var request = _request.ConstructInitialPayload(message); var chan = Channel.CreateUnbounded(); var (rx, tx) = (chan.Reader, chan.Writer); + var responses = new List(); await using var conn = await Connect(ct); var completedLength = 0; - var messageId = (string?)null; using var updateCallback = conn.On("update", update => { - if (update.Messages is null or { Length: 0 }) - return; - - var message = update.Messages[0]; - if (message is { MessageType: not null } or { Text: null or { Length: 0 } }) - return; - if (messageId != (messageId ??= message.MessageId)) - return; - - tx.TryWrite(message.Text[completedLength..]); - completedLength = message.Text.Length; + responses.Add(update); + var answer = BingChatParser.ParseMessage(responses); + if (answer is null) return; + if (answer.Length > completedLength) + tx.TryWrite(answer[completedLength..]); + completedLength = answer.Length; }); var responseCallback = conn.StreamAsync("chat", request, ct) @@ -95,80 +92,16 @@ public async IAsyncEnumerable StreamAsync( .AsTask() .ContinueWith(response => { - // TODO: Properly format source attributions and adaptive cards, and append them at the end of the message. - // This is best done by extracting formatting logic from one-shot BuildAnswer used by AskAsync. - if (messageId != null) - { - var completedMessage = response.Result.Messages - .First(msg => msg.MessageId == messageId) - .Text; - if (completedMessage!.Length > completedLength) - tx.TryWrite(completedMessage[completedLength..]); - } + responses.Add(response.Result); + var answer = BingChatParser.ParseMessage(responses) ?? ""; + if (answer.Length > completedLength) + tx.TryWrite(answer[completedLength..]); tx.Complete(); }, ct); await foreach (var word in rx.ReadAllAsync(ct)) yield return word; } - private static string? BuildAnswer(ChatResponse response) - { - //Check status - if (!string.Equals(response.Result?.Value, "Success", StringComparison.OrdinalIgnoreCase)) - { - throw new BingChatException($"{response.Result?.Value}: {response.Result?.Message}"); - } - - //Collect messages, including of types: Chat, SearchQuery, LoaderMessage, Disengaged - var messages = new List(); - foreach (var itemMessage in response.Messages) - { - //Not needed - if (itemMessage.Author != "bot") continue; - if (itemMessage.MessageType is "InternalSearchResult" or "RenderCardRequest") - continue; - - //Not supported - if (itemMessage.MessageType is "GenerateContentQuery") - continue; - - //From Text - var text = itemMessage.Text; - - //From AdaptiveCards - var adaptiveCards = itemMessage.AdaptiveCards; - if (text is null && adaptiveCards?.Length > 0) - { - var bodies = new List(); - foreach (var body in adaptiveCards[0].Body) - { - if (body.Type != "TextBlock" || body.Text is null) continue; - bodies.Add(body.Text); - } - text = bodies.Count > 0 ? string.Join("\n", bodies) : null; - } - - //From MessageType - text ??= $"<{itemMessage.MessageType}>"; - - //From SourceAttributions - var sourceAttributions = itemMessage.SourceAttributions; - if (sourceAttributions?.Length > 0) - { - text += "\n"; - for (var nIndex = 0; nIndex < sourceAttributions.Length; nIndex++) - { - var sourceAttribution = sourceAttributions[nIndex]; - text += $"\n[{nIndex + 1}]: {sourceAttribution.SeeMoreUrl} \"{sourceAttribution.ProviderDisplayName}\""; - } - } - - messages.Add(text); - } - - return messages.Count > 0 ? string.Join("\n\n", messages) : null; - } - private static async Task Connect(CancellationToken ct) { var conn = new HubConnection( diff --git a/src/BingChat/BingChatParser.cs b/src/BingChat/BingChatParser.cs new file mode 100644 index 0000000..d60d547 --- /dev/null +++ b/src/BingChat/BingChatParser.cs @@ -0,0 +1,153 @@ +using BingChat.Model; + +namespace BingChat +{ + internal static class BingChatParser + { + /// + /// Build answer string on multiple responses, merging messages with duplicate Ids. + /// + public static string? ParseMessage(IList responses) + { + bool isStreamComplete = false; + var messageIds = new HashSet(); + var messages = new List(); + + //Collect response, from newer to older + for (int responseIndex = responses.Count - 1; responseIndex >= 0; --responseIndex) + { + var response = responses[responseIndex]; + if (response is null) continue; + + //Check status + if (response.Result is not null && response.Result.Value != "Success") + throw new BingChatException($"{response.Result?.Value}: {response.Result?.Message}"); + if (response.Result is not null) + isStreamComplete = true; + if (response.Messages is null) continue; + + //Collect messages, from newer to older + for (int messageIndex = response.Messages.Length - 1; messageIndex >= 0; --messageIndex) + { + var message = response.Messages[messageIndex]; + if (message is null) continue; + if (messageIds.Contains(message.MessageId)) + continue; + messageIds.Add(message.MessageId); + messages.Add(message); + } + } + + //Parse messages + var strMessages = new List(); + for (int messageIndex = messages.Count - 1; messageIndex >= 0; --messageIndex) + { + var message = messages[messageIndex]; + if (message is null) continue; + //the last message in stream doesn't need source attribution + var strMessage = ParseMessage(message, isStreamComplete || (messageIndex > 0)); + if (strMessage?.Length > 0) + strMessages.Add(strMessage); + } + + return strMessages.Count > 0 ? string.Join("\n\n", strMessages) : null; + } + + /// + /// Build answer string on a single response. + /// + public static string? ParseMessage(ChatResponse response) + { + //Check status + if (response.Result is not null && response.Result.Value != "Success") + throw new BingChatException($"{response.Result?.Value}: {response.Result?.Message}"); + if (response.Messages is null) return null; + + //Parse messages + var strMessages = new List(); + foreach (var message in response.Messages) + { + if (message is null) continue; + var strMessage = ParseMessage(message); + if (strMessage?.Length > 0) + strMessages.Add(strMessage); + } + + return strMessages.Count > 0 ? string.Join("\n\n", strMessages) : null; + } + + /// + /// Build answer string on a response message. + /// + private static string? ParseMessage(ResponseMessage message, bool needSourceAttribution = true) + { + //Not needed + if (message.Author != "bot") return null; + if (message.MessageType == "InternalSearchResult" || + message.MessageType == "RenderCardRequest") + return null; + + //Not supported + if (message.MessageType == "GenerateContentQuery") + return null; + + //From Text + var text = message.Text; + + //From AdaptiveCards + if (text is null && message.AdaptiveCards?.Length > 0) + text = ParseMessage(message.AdaptiveCards); + + //From MessageType + text ??= $"<{message.MessageType}>"; + + //From SourceAttributions + if (needSourceAttribution && message.SourceAttributions?.Length > 0) + text += "\n\n" + ParseMessage(message.SourceAttributions); + + return text; + } + + /// + /// Build answer string on adaptive cards. + /// + private static string? ParseMessage(AdaptiveCard[] cards) + { + if (cards.Length == 0) return null; + + var strMessages = new List(); + foreach (var card in cards) + { + if (card is null or {Bodies : null}) continue; + foreach (var body in card.Bodies) + { + //Plain text block + if (body.Type == "TextBlock" && body.Text is not null) + strMessages.Add(body.Text); + + //Not supported + //TODO: Rich text block or other type + } + } + + return strMessages.Count > 0 ? string.Join('\n', strMessages) : null; + } + + /// + /// Build answer string on source attributions. + /// + private static string? ParseMessage(SourceAttribution[] sources) + { + if (sources.Length == 0) return null; + + var strMessages = new List(); + for (var sourceIndex = 0; sourceIndex < sources.Length; ++sourceIndex) + { + var source = sources[sourceIndex]; + strMessages.Add($"[{sourceIndex + 1}]: {source.SeeMoreUrl} \"{source.ProviderDisplayName}\""); + } + + return strMessages.Count > 0 ? String.Join('\n', strMessages) : null; + } + } +} diff --git a/src/BingChat/Model/ChatResponse.cs b/src/BingChat/Model/ChatResponse.cs index ac1570d..1d50b7f 100644 --- a/src/BingChat/Model/ChatResponse.cs +++ b/src/BingChat/Model/ChatResponse.cs @@ -8,7 +8,7 @@ namespace BingChat.Model; internal sealed class ChatResponse { - public ResponseMessage[] Messages { get; set; } + public ResponseMessage[]? Messages { get; set; } public ResponseResult? Result { get; set; } } @@ -24,7 +24,7 @@ internal sealed class ResponseMessage internal sealed class AdaptiveCard { - public ResponseBody[] Body { get; set; } + public ResponseBody[]? Bodies { get; set; } } internal sealed class ResponseBody