Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: signalr transport #16

Merged
merged 2 commits into from
May 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/BingChat/BingChat.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Websocket.Client" Version="4.4.43" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="7.0.5" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
</ItemGroup>

</Project>
192 changes: 89 additions & 103 deletions src/BingChat/BingChatConversation.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
using System.Net.WebSockets;
using System.Reactive.Linq;
using System.Text.Json;
using Websocket.Client;
using Microsoft.AspNetCore.Connections;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.AspNetCore.Http.Connections.Client;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.AspNetCore.SignalR.Protocol;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;

namespace BingChat;

Expand All @@ -10,7 +15,29 @@ namespace BingChat;
/// </summary>
internal sealed class BingChatConversation : IBingChattable
{
private const char TerminalChar = '\u001e';
private static readonly HttpConnectionFactory ConnectionFactory = new(Options.Create(
new HttpConnectionOptions
{
DefaultTransferFormat = TransferFormat.Text,
SkipNegotiation = true,
Transports = HttpTransportType.WebSockets,
Headers =
{
["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/113.0.0.0 Safari/537.36 Edg/113.0.1774.57"
}
}),
NullLoggerFactory.Instance);

private static readonly JsonHubProtocol HubProtocol = new(
Options.Create(new JsonHubProtocolOptions()
{
PayloadSerializerOptions = SerializerContext.Default.Options
}));

private static readonly UriEndPoint HubEndpoint = new(new Uri("wss://sydney.bing.com/sydney/ChatHub"));

private readonly BingChatRequest _request;

internal BingChatConversation(
Expand All @@ -20,122 +47,81 @@ internal BingChatConversation(
}

/// <inheritdoc/>
public Task<string> AskAsync(string message)
public async Task<string> AskAsync(string message)
{
var wsClient = new WebsocketClient(new Uri("wss://sydney.bing.com/sydney/ChatHub"));
var tcs = new TaskCompletionSource<string>();

void Cleanup()
{
wsClient.Stop(WebSocketCloseStatus.Empty, string.Empty).ContinueWith(t =>
{
if (t.IsFaulted) tcs.SetException(t.Exception!);
wsClient.Dispose();
});
}
var request = _request.ConstructInitialPayload(message);

string? GetAnswer(BingChatConversationResponse response)
{
//Check status
if (!response.Item.Result.Value.Equals("Success", StringComparison.OrdinalIgnoreCase))
{
throw new BingChatException($"{response.Item.Result.Value}: {response.Item.Result.Message}");
}
await using var conn = new HubConnection(
ConnectionFactory,
HubProtocol,
HubEndpoint,
new ServiceCollection().BuildServiceProvider(),
NullLoggerFactory.Instance);

//Collect messages, including of types: Chat, SearchQuery, LoaderMessage, Disengaged
var messages = new List<string>();
foreach (var itemMessage in response.Item.Messages)
{
//Not needed
if (itemMessage.Author != "bot") continue;
if (itemMessage.MessageType == "InternalSearchResult" ||
itemMessage.MessageType == "RenderCardRequest")
continue;

//Not supported
if (itemMessage.MessageType == "GenerateContentQuery")
continue;

//From Text
var text = itemMessage.Text;

//From AdaptiveCards
var adaptiveCards = itemMessage.AdaptiveCards;
if (text is null && adaptiveCards is not null && adaptiveCards.Length > 0)
{
var bodies = new List<string>();
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;
}
await conn.StartAsync();

//From MessageType
text ??= $"<{itemMessage.MessageType}>";
var response = await conn
.StreamAsync<BingChatConversationResponse>("chat", request)
.FirstAsync();

//From SourceAttributions
var sourceAttributions = itemMessage.SourceAttributions;
if (sourceAttributions is not null && 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 BuildAnswer(response) ?? "<empty answer>";
}

return messages.Count > 0 ? string.Join("\n\n", messages) : null;
private static string? BuildAnswer(BingChatConversationResponse response)
{
//Check status
if (!response.Result.Value.Equals("Success", StringComparison.OrdinalIgnoreCase))
{
throw new BingChatException($"{response.Result.Value}: {response.Result.Message}");
}

void OnMessageReceived(string text)
//Collect messages, including of types: Chat, SearchQuery, LoaderMessage, Disengaged
var messages = new List<string>();
foreach (var itemMessage in response.Messages)
{
try
{
foreach (var part in text.Split(TerminalChar, StringSplitOptions.RemoveEmptyEntries))
{
var json = JsonSerializer.Deserialize(part, SerializerContext.Default.BingChatConversationResponse);
//Not needed
if (itemMessage.Author != "bot") continue;
if (itemMessage.MessageType is "InternalSearchResult" or "RenderCardRequest")
continue;

if (json is not { Type: 2 }) continue;
//Not supported
if (itemMessage.MessageType is "GenerateContentQuery")
continue;

Cleanup();
//From Text
var text = itemMessage.Text;

tcs.SetResult(GetAnswer(json) ?? "<empty answer>");
return;
}
}
catch (Exception e)
//From AdaptiveCards
var adaptiveCards = itemMessage.AdaptiveCards;
if (text is null && adaptiveCards?.Length > 0)
{
Cleanup();
tcs.SetException(e);
var bodies = new List<string>();
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;
}
}

wsClient.MessageReceived
.Where(msg => msg.MessageType == WebSocketMessageType.Text)
.Select(msg => msg.Text)
.Subscribe(OnMessageReceived);
//From MessageType
text ??= $"<{itemMessage.MessageType}>";

// Start the WebSocket client
wsClient.Start().ContinueWith(t =>
{
if (t.IsFaulted)
//From SourceAttributions
var sourceAttributions = itemMessage.SourceAttributions;
if (sourceAttributions?.Length > 0)
{
Cleanup();
tcs.SetException(t.Exception!);
return;
text += "\n";
for (var nIndex = 0; nIndex < sourceAttributions.Length; nIndex++)
{
var sourceAttribution = sourceAttributions[nIndex];
text += $"\n[{nIndex + 1}]: {sourceAttribution.SeeMoreUrl} \"{sourceAttribution.ProviderDisplayName}\"";
}
}

// Send initial messages
wsClient.Send("{\"protocol\":\"json\",\"version\":1}" + TerminalChar);
wsClient.Send(_request.ConstructInitialPayload(message) + TerminalChar);
});
messages.Add(text);
}

return tcs.Task;
return messages.Count > 0 ? string.Join("\n\n", messages) : null;
}
}
}
51 changes: 1 addition & 50 deletions src/BingChat/BingChatConversationRequest.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Text.Json.Serialization;

// ReSharper disable MemberCanBeInternal
// ReSharper disable ClassNeverInstantiated.Global
// ReSharper disable UnusedAutoPropertyAccessor.Global
Expand All @@ -10,52 +8,16 @@ namespace BingChat;

internal sealed class BingChatConversationRequest
{
[JsonPropertyName("type")]
public int Type { get; set; }

[JsonPropertyName("invocationId")]
public string InvocationId { get; set; }

[JsonPropertyName("target")]
public string Target { get; set; }

[JsonPropertyName("arguments")]
public RequestArgument[] Arguments { get; set; }
}

internal sealed class RequestArgument
{
[JsonPropertyName("source")]
public string Source { get; set; }

[JsonPropertyName("optionsSets")]
public string[] OptionsSets { get; set; }

[JsonPropertyName("allowedMessageTypes")]
public string[] AllowedMessageTypes { get; set; }

[JsonPropertyName("sliceIds")]
public string[] SliceIds { get; set; }

[JsonPropertyName("traceId")]
public string TraceId { get; set; }

[JsonPropertyName("isStartOfSession")]
public bool IsStartOfSession { get; set; }

[JsonPropertyName("message")]
public RequestMessage Message { get; set; }

[JsonPropertyName("tone")]
public string Tone { get; set; }

[JsonPropertyName("conversationSignature")]
public string ConversationSignature { get; set; }

[JsonPropertyName("participant")]
public Participant Participant { get; set; }

[JsonPropertyName("conversationId")]
public string ConversationId { get; set; }
}

Expand All @@ -67,25 +29,14 @@ internal sealed class RequestMessage
// region = ,
// location = ,
// locationHints: [],

[JsonPropertyName("timestamp")]
public DateTime Timestamp { get; set; }

[JsonPropertyName("author")]
public string Author { get; set; }

[JsonPropertyName("inputMethod")]
public string InputMethod { get; set; }

[JsonPropertyName("messageType")]
public string MessageType { get; set; }

[JsonPropertyName("text")]
public string Text { get; set; }
}

internal struct Participant
{
[JsonPropertyName("id")]
public string Id { get; set; }
}
}
Loading