Merge pull request #1034 from iceljc/test/realtime-chat

Test/realtime chat
This commit is contained in:
Haiping 2025-05-06 20:25:05 -05:00 committed by GitHub
commit aa4440aa64
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
89 changed files with 1232 additions and 416 deletions

View file

@ -21,6 +21,7 @@
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="SharpHook" Version="5.3.9" />
<PackageVersion Include="SixLabors.ImageSharp" Version="3.1.7" />
<PackageVersion Include="System.ClientModel" Version="1.3.0" />
<PackageVersion Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.0.0" />
<PackageVersion Include="System.Memory.Data" Version="8.0.0" />

View file

@ -82,7 +82,7 @@ public class Agent
public PluginDef Plugin { get; set; }
[JsonIgnore]
public bool Installed => Plugin.Enabled;
public bool Installed => Plugin?.Enabled == true;
/// <summary>
/// Default is True, user will enable this by installing appropriate plugin.
@ -168,6 +168,7 @@ public class Agent
Functions = agent.Functions,
Responses = agent.Responses,
Samples = agent.Samples,
Templates = agent.Templates,
Utilities = agent.Utilities,
McpTools = agent.McpTools,
Knowledges = agent.Knowledges,

View file

@ -7,7 +7,6 @@ public class AgentTemplate
public AgentTemplate()
{
}
public AgentTemplate(string name, string content)

View file

@ -10,6 +10,7 @@ public interface IKnowledgeService
Task<bool> CreateVectorCollection(string collectionName, string collectionType, int dimension, string provider, string model);
Task<bool> DeleteVectorCollection(string collectionName);
Task<IEnumerable<VectorCollectionConfig>> GetVectorCollections(string? type = null);
Task<VectorCollectionDetails?> GetVectorCollectionDetails(string collectionName);
Task<IEnumerable<VectorSearchResult>> SearchVectorKnowledge(string query, string collectionName, VectorSearchOptions options);
Task<StringIdPagedItems<VectorSearchResult>> GetPagedVectorCollectionData(string collectionName, VectorFilter filter);
Task<bool> DeleteVectorCollectionData(string collectionName, string id);

View file

@ -47,5 +47,5 @@ public interface IContentGeneratingHook
/// <param name="instruction"></param>
/// <param name="functions"></param>
/// <returns></returns>
Task OnSessionUpdated(Agent agent, string instruction, FunctionDef[] functions) => Task.CompletedTask;
Task OnSessionUpdated(Agent agent, string instruction, FunctionDef[] functions, bool isInit = false) => Task.CompletedTask;
}

View file

@ -8,7 +8,8 @@ public interface IRealTimeCompletion
string Model { get; }
void SetModelName(string model);
Task Connect(RealtimeHubConnection conn,
Task Connect(
RealtimeHubConnection conn,
Action onModelReady,
Action<string, string> onModelAudioDeltaReceived,
Action onModelAudioResponseDone,
@ -23,7 +24,7 @@ public interface IRealTimeCompletion
Task SendEventToModel(object message);
Task Disconnect();
Task<string> UpdateSession(RealtimeHubConnection conn);
Task<string> UpdateSession(RealtimeHubConnection conn, bool isInit = false);
Task InsertConversationItem(RoleDialogModel message);
Task RemoveConversationItem(string itemId);
Task TriggerModelInference(string? instructions = null);

View file

@ -0,0 +1,6 @@
namespace BotSharp.Abstraction.Templating.Constants;
public static class TemplateRenderConstant
{
public const string RENDER_AGENT = "render_agent";
}

View file

@ -3,5 +3,5 @@ namespace BotSharp.Abstraction.Templating;
public interface ITemplateRender
{
string Render(string template, Dictionary<string, object> dict);
void Register(Type type);
void RegisterType(Type type);
}

View file

@ -10,6 +10,8 @@ public interface IVectorDb
=> throw new NotImplementedException();
Task<IEnumerable<string>> GetCollections()
=> throw new NotImplementedException();
Task<VectorCollectionDetails?> GetCollectionDetails(string collectionName)
=> throw new NotImplementedException();
Task<StringIdPagedItems<VectorCollectionData>> GetPagedCollectionData(string collectionName, VectorFilter filter)
=> throw new NotImplementedException();
Task<IEnumerable<VectorCollectionData>> GetCollectionData(string collectionName, IEnumerable<Guid> ids,

View file

@ -0,0 +1,51 @@
namespace BotSharp.Abstraction.VectorStorage.Models;
public class VectorCollectionDetails
{
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonPropertyName("optimizer_status")]
public string OptimizerStatus { get; set; }
[JsonPropertyName("segments_count")]
public ulong SegmentsCount { get; set; }
[JsonPropertyName("vectors_count")]
public ulong VectorsCount { get; set; }
[JsonPropertyName("indexed_vectors_count")]
public ulong IndexedVectorsCount { get; set; }
[JsonPropertyName("points_count")]
public ulong PointsCount { get; set; }
[JsonPropertyName("inner_config")]
public VectorCollectionDetailConfig? InnerConfig { get; set; }
[JsonPropertyName("basic_info")]
public VectorCollectionConfig? BasicInfo { get; set; }
}
public class VectorCollectionDetailConfig
{
public VectorCollectionDetailConfigParam? Param { get; set; }
}
public class VectorCollectionDetailConfigParam
{
[JsonPropertyName("shard_number")]
public uint? ShardNumber { get; set; }
[JsonPropertyName("sharding_method")]
public string? ShardingMethod { get; set; }
[JsonPropertyName("replication_factor")]
public uint? ReplicationFactor { get; set; }
[JsonPropertyName("write_consistency_factor")]
public uint? WriteConsistencyFactor { get; set; }
[JsonPropertyName("read_fan_out_factor")]
public uint? ReadFanOutFactor { get; set; }
}

View file

@ -23,7 +23,9 @@ public class McpClientManager : IDisposable
transport = new SseClientTransport(new SseClientTransportOptions
{
Name = config.Name,
Endpoint = new Uri(config.SseConfig.EndPoint)
Endpoint = new Uri(config.SseConfig.EndPoint),
AdditionalHeaders = config.SseConfig.AdditionalHeaders,
ConnectionTimeout = config.SseConfig.ConnectionTimeout
});
}
else if (config.StdioConfig != null)
@ -33,7 +35,8 @@ public class McpClientManager : IDisposable
Name = config.Name,
Command = config.StdioConfig.Command,
Arguments = config.StdioConfig.Arguments,
EnvironmentVariables = config.StdioConfig.EnvironmentVariables
EnvironmentVariables = config.StdioConfig.EnvironmentVariables,
ShutdownTimeout = config.StdioConfig.ShutdownTimeout
});
}
else

View file

@ -12,6 +12,7 @@
<ItemGroup>
<PackageReference Include="NAudio" />
<PackageReference Include="System.ClientModel" />
</ItemGroup>
<ItemGroup>

View file

@ -0,0 +1,11 @@
namespace BotSharp.Core.Realtime.Models.Chat;
public class ChatSessionUpdate
{
public string RawResponse { get; set; }
public ChatSessionUpdate()
{
}
}

View file

@ -0,0 +1,7 @@
namespace BotSharp.Core.Realtime.Models.Options;
public class ChatSessionOptions
{
public int? BufferSize { get; set; }
public JsonSerializerOptions? JsonOptions { get; set; }
}

View file

@ -42,14 +42,15 @@ public class RealtimeHub : IRealtimeHub
_completer = _services.GetServices<IRealTimeCompletion>().First(x => x.Provider == settings.Provider);
await _completer.Connect(_conn,
await _completer.Connect(
conn: _conn,
onModelReady: async () =>
{
// Not TriggerModelInference, waiting for user utter.
var instruction = await _completer.UpdateSession(_conn);
var instruction = await _completer.UpdateSession(_conn, isInit: true);
var data = _conn.OnModelReady();
await (init?.Invoke(data) ?? Task.CompletedTask);
await HookEmitter.Emit<IRealtimeHook>(_services, async hook => await hook.OnModelReady(agent, _completer));
await (init?.Invoke(data) ?? Task.CompletedTask);
},
onModelAudioDeltaReceived: async (audioDeltaData, itemId) =>
{

View file

@ -39,6 +39,7 @@ public class WaveStreamChannel : IStreamChannel
var waveFormat = new WaveFormat(24000, 16, 1); // 24000 Hz, 16-bit PCM, Mono
_bufferedWaveProvider = new BufferedWaveProvider(waveFormat);
_bufferedWaveProvider.BufferDuration = TimeSpan.FromMinutes(10);
//_bufferedWaveProvider.BufferLength = 1024;
_bufferedWaveProvider.DiscardOnBufferOverflow = true;
_waveOut = new WaveOutEvent()

View file

@ -15,3 +15,7 @@ global using BotSharp.Abstraction.Agents;
global using BotSharp.Abstraction.Routing;
global using BotSharp.Abstraction.Agents.Enums;
global using BotSharp.Abstraction.Conversations.Models;
global using BotSharp.Core.Realtime.Models.Chat;
global using BotSharp.Core.Realtime.Models.Options;
global using BotSharp.Core.Realtime.Websocket.Chat;

View file

@ -0,0 +1,79 @@
using BotSharp.Core.Realtime.Websocket.Common;
using System.ClientModel;
using System.Runtime.CompilerServices;
namespace BotSharp.Core.Realtime.Websocket.Chat;
public class BotSharpRealtimeSession : IDisposable
{
private readonly IServiceProvider _services;
private readonly WebSocket _websocket;
private readonly ChatSessionOptions? _sessionOptions;
private readonly object _singleReceiveLock = new();
private AsyncWebsocketDataCollectionResult _receivedCollectionResult;
public BotSharpRealtimeSession(
IServiceProvider services,
WebSocket websocket,
ChatSessionOptions? sessionOptions)
{
_services = services;
_websocket = websocket;
_sessionOptions = sessionOptions;
}
public async IAsyncEnumerable<ChatSessionUpdate> ReceiveUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (ClientResult result in ReceiveInnerUpdatesAsync(cancellationToken))
{
var update = HandleSessionResult(result);
yield return update;
}
}
private async IAsyncEnumerable<ClientResult> ReceiveInnerUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
lock (_singleReceiveLock)
{
_receivedCollectionResult ??= new(_websocket, _sessionOptions, cancellationToken);
}
await foreach (var result in _receivedCollectionResult)
{
yield return result;
}
}
private ChatSessionUpdate HandleSessionResult(ClientResult result)
{
using var response = result.GetRawResponse();
var bytes = response.Content.ToArray();
var text = Encoding.UTF8.GetString(bytes, 0, bytes.Length);
return new ChatSessionUpdate
{
RawResponse = text
};
}
public async Task SendEvent(string message)
{
if (_websocket.State == WebSocketState.Open)
{
var buffer = Encoding.UTF8.GetBytes(message);
await _websocket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, CancellationToken.None);
}
}
public async Task Disconnect()
{
if (_websocket.State == WebSocketState.Open)
{
await _websocket.CloseAsync(WebSocketCloseStatus.Empty, null, CancellationToken.None);
}
}
public void Dispose()
{
_websocket.Dispose();
}
}

View file

@ -1,18 +1,21 @@
using BotSharp.Core.Realtime.Models.Options;
using System.ClientModel;
using System.Net.WebSockets;
namespace BotSharp.Plugin.OpenAI.Providers.Realtime.Session;
namespace BotSharp.Core.Realtime.Websocket.Common;
public class AsyncWebsocketDataCollectionResult : AsyncCollectionResult<ClientResult>
internal class AsyncWebsocketDataCollectionResult : AsyncCollectionResult<ClientResult>
{
private readonly WebSocket _webSocket;
private readonly ChatSessionOptions? _sessionOptions;
private readonly CancellationToken _cancellationToken;
public AsyncWebsocketDataCollectionResult(
WebSocket webSocket,
ChatSessionOptions? sessionOptions,
CancellationToken cancellationToken)
{
_webSocket = webSocket;
_sessionOptions = sessionOptions;
_cancellationToken = cancellationToken;
}
@ -23,7 +26,7 @@ public class AsyncWebsocketDataCollectionResult : AsyncCollectionResult<ClientRe
public override async IAsyncEnumerable<ClientResult> GetRawPagesAsync()
{
await using var enumerator = new AsyncWebsocketDataResultEnumerator(_webSocket, _cancellationToken);
await using var enumerator = new AsyncWebsocketDataResultEnumerator(_webSocket, _sessionOptions, _cancellationToken);
while (await enumerator.MoveNextAsync().ConfigureAwait(false))
{
yield return enumerator.Current;

View file

@ -1,23 +1,28 @@
using System;
using BotSharp.Core.Realtime.Models.Options;
using System.Buffers;
using System.ClientModel;
using System.Net.WebSockets;
namespace BotSharp.Plugin.OpenAI.Providers.Realtime.Session;
namespace BotSharp.Core.Realtime.Websocket.Common;
public class AsyncWebsocketDataResultEnumerator : IAsyncEnumerator<ClientResult>
internal class AsyncWebsocketDataResultEnumerator : IAsyncEnumerator<ClientResult>
{
private readonly WebSocket _webSocket;
private readonly ChatSessionOptions? _sessionOptions;
private readonly CancellationToken _cancellationToken;
private readonly byte[] _buffer;
private const int DEFAULT_BUFFER_SIZE = 1024 * 32;
public AsyncWebsocketDataResultEnumerator(
WebSocket webSocket,
ChatSessionOptions? sessionOptions,
CancellationToken cancellationToken)
{
_webSocket = webSocket;
_sessionOptions = sessionOptions;
_cancellationToken = cancellationToken;
_buffer = ArrayPool<byte>.Shared.Rent(1024 * 32);
var bufferSize = sessionOptions?.BufferSize > 0 ? sessionOptions.BufferSize.Value : DEFAULT_BUFFER_SIZE;
_buffer = ArrayPool<byte>.Shared.Rent(bufferSize);
}
public ClientResult Current { get; private set; }
@ -31,7 +36,7 @@ public class AsyncWebsocketDataResultEnumerator : IAsyncEnumerator<ClientResult>
public async ValueTask<bool> MoveNextAsync()
{
var response = new AiWebsocketPipelineResponse();
var response = new WebsocketPipelineResponse();
while (!response.IsComplete)
{
var receivedResult = await _webSocket.ReceiveAsync(new(_buffer), _cancellationToken);

View file

@ -1,13 +1,11 @@
using System.ClientModel.Primitives;
using System.Net;
using System.Net.WebSockets;
namespace BotSharp.Plugin.OpenAI.Providers.Realtime.Session;
namespace BotSharp.Core.Realtime.Websocket.Common;
public class AiWebsocketPipelineResponse : PipelineResponse
internal class WebsocketPipelineResponse : PipelineResponse
{
public AiWebsocketPipelineResponse()
public WebsocketPipelineResponse()
{
}
@ -55,7 +53,7 @@ public class AiWebsocketPipelineResponse : PipelineResponse
}
else if (receivedResult.MessageType != WebSocketMessageType.Text)
{
throw new NotImplementedException($"{nameof(AiWebsocketPipelineResponse)} currently supports only text messages.");
throw new NotImplementedException($"{nameof(WebsocketPipelineResponse)} currently supports only text messages.");
}
var rawBytes = receivedBytes.ToArray();

View file

@ -1,42 +1,43 @@
using BotSharp.Plugin.OpenAI.Models.Realtime;
using System.ClientModel;
using System.Net.WebSockets;
using System.Runtime.CompilerServices;
using BotSharp.Core.Realtime.Models.Chat;
using BotSharp.Core.Realtime.Models.Options;
using BotSharp.Core.Realtime.Websocket.Common;
namespace BotSharp.Plugin.OpenAI.Providers.Realtime.Session;
namespace BotSharp.Core.Realtime.Websocket.Llm;
public class RealtimeChatSession : IDisposable
public class LlmRealtimeSession : IDisposable
{
private readonly IServiceProvider _services;
private readonly BotSharpOptions _options;
private readonly ChatSessionOptions? _sessionOptions;
private ClientWebSocket _webSocket;
private readonly object _singleReceiveLock = new();
private readonly SemaphoreSlim _clientEventSemaphore = new(initialCount: 1, maxCount: 1);
private AsyncWebsocketDataCollectionResult _receivedCollectionResult;
public RealtimeChatSession(
public LlmRealtimeSession(
IServiceProvider services,
BotSharpOptions options)
ChatSessionOptions? sessionOptions = null)
{
_services = services;
_options = options;
_sessionOptions = sessionOptions;
}
public async Task ConnectAsync(string provider, string model, CancellationToken cancellationToken = default)
public async Task ConnectAsync(Uri uri, Dictionary<string, string> headers, CancellationToken cancellationToken = default)
{
var settingsService = _services.GetRequiredService<ILlmProviderService>();
var settings = settingsService.GetSetting(provider, model);
_webSocket?.Dispose();
_webSocket = new ClientWebSocket();
_webSocket.Options.SetRequestHeader("Authorization", $"Bearer {settings.ApiKey}");
_webSocket.Options.SetRequestHeader("OpenAI-Beta", "realtime=v1");
await _webSocket.ConnectAsync(new Uri($"wss://api.openai.com/v1/realtime?model={model}"), cancellationToken);
foreach (var header in headers)
{
_webSocket.Options.SetRequestHeader(header.Key, header.Value);
}
await _webSocket.ConnectAsync(uri, cancellationToken);
}
public async IAsyncEnumerable<SessionConversationUpdate> ReceiveUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
public async IAsyncEnumerable<ChatSessionUpdate> ReceiveUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
await foreach (ClientResult result in ReceiveInnerUpdatesAsync(cancellationToken))
{
@ -45,11 +46,11 @@ public class RealtimeChatSession : IDisposable
}
}
public async IAsyncEnumerable<ClientResult> ReceiveInnerUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
private async IAsyncEnumerable<ClientResult> ReceiveInnerUpdatesAsync([EnumeratorCancellation] CancellationToken cancellationToken = default)
{
lock (_singleReceiveLock)
{
_receivedCollectionResult ??= new(_webSocket, cancellationToken);
_receivedCollectionResult ??= new(_webSocket, _sessionOptions, cancellationToken);
}
await foreach (var result in _receivedCollectionResult)
@ -58,12 +59,12 @@ public class RealtimeChatSession : IDisposable
}
}
private SessionConversationUpdate HandleSessionResult(ClientResult result)
private ChatSessionUpdate HandleSessionResult(ClientResult result)
{
using var response = result.GetRawResponse();
var bytes = response.Content.ToArray();
var text = Encoding.UTF8.GetString(bytes, 0, bytes.Length);
return new SessionConversationUpdate
return new ChatSessionUpdate
{
RawResponse = text
};
@ -82,7 +83,7 @@ public class RealtimeChatSession : IDisposable
{
if (message is not string data)
{
data = JsonSerializer.Serialize(message, _options.JsonSerializerOptions);
data = JsonSerializer.Serialize(message, _sessionOptions?.JsonOptions);
}
var buffer = Encoding.UTF8.GetBytes(data);

View file

@ -45,7 +45,7 @@ public class AgentPlugin : IBotSharpPlugin
{
var settingService = provider.GetRequiredService<ISettingService>();
var render = provider.GetRequiredService<ITemplateRender>();
render.Register(typeof(AgentSettings));
render.RegisterType(typeof(AgentSettings));
return settingService.Bind<AgentSettings>("Agent");
});
}

View file

@ -22,6 +22,7 @@ public partial class AgentService
agent.TemplateDict[t.Key] = t.Value;
}
agent.TemplateDict[TemplateRenderConstant.RENDER_AGENT] = agent;
var res = render.Render(string.Join("\r\n", instructions), agent.TemplateDict);
return res;
}
@ -128,6 +129,7 @@ public partial class AgentService
}
// render liquid template
agent.TemplateDict[TemplateRenderConstant.RENDER_AGENT] = agent;
var content = render.Render(template, agent.TemplateDict);
HookEmitter.Emit<IContentGeneratingHook>(_services, async hook =>

View file

@ -199,7 +199,7 @@ public partial class AgentService
var samples = GetSamplesFromFile(dir);
return agent.SetInstruction(defaultInstruction)
.SetChannelInstructions(channelInstructions)
.SetTemplates(templates)
.SetTemplates(templates)
.SetFunctions(functions)
.SetResponses(responses)
.SetSamples(samples);

View file

@ -31,7 +31,7 @@ public class ConversationPlugin : IBotSharpPlugin
{
var settingService = provider.GetRequiredService<ISettingService>();
var render = provider.GetRequiredService<ITemplateRender>();
render.Register(typeof(ConversationSetting));
render.RegisterType(typeof(ConversationSetting));
return settingService.Bind<ConversationSetting>("Conversation");
});

View file

@ -53,7 +53,7 @@ public partial class ConversationService
#if DEBUG
Console.WriteLine($"\r\n{error}\r\n");
#else
_logger.LogError($"{error}");
_logger.LogError(ex, $"{error}");
#endif
}
}

View file

@ -66,7 +66,7 @@ public partial class FileInstructService
}
catch (Exception ex)
{
_logger.LogError($"Error when analyzing pdf in file service: {ex.Message}\r\n{ex.InnerException}");
_logger.LogError(ex, $"Error when analyzing pdf in file service.");
return content;
}
finally
@ -113,7 +113,7 @@ public partial class FileInstructService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when saving pdf file: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when saving pdf file.");
continue;
}
}
@ -141,7 +141,7 @@ public partial class FileInstructService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when converting pdf file to images ({file}): {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when converting pdf file to images ({file}).");
continue;
}
}

View file

@ -108,7 +108,7 @@ public partial class FileInstructService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when selecting files. {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when selecting files.");
return new List<MessageFileModel>();
}
}

View file

@ -24,7 +24,7 @@ public partial class LocalFileStorageService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when saving speech file. {fileName} ({conversationId})\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when saving speech file. {fileName} (conv id: {conversationId})");
return false;
}
}

View file

@ -153,7 +153,7 @@ public partial class LocalFileStorageService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when saving message file {file.FileName}: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when saving message file {file.FileName} (conv id: {conversationId}).");
continue;
}
}
@ -333,7 +333,7 @@ public partial class LocalFileStorageService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting message file screenshots {file} (messageId: {messageId}), Error: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting message file screenshots {file} (messageId: {messageId})");
return files;
}
}

View file

@ -31,9 +31,8 @@ public partial class LocalFileStorageService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when saving knowledge file " +
$"(Vector store provider: {vectorStoreProvider}, Collection: {collectionName}, File name: {fileName})." +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when saving knowledge file " +
$"(Vector store provider: {vectorStoreProvider}, Collection: {collectionName}, File name: {fileName}).");
return false;
}
}

View file

@ -40,7 +40,7 @@ public partial class LocalFileStorageService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when saving user avatar: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when saving user avatar (user id: {_user.Id})");
return false;
}
}

View file

@ -84,7 +84,7 @@ public class ExecuteTemplateFn : IFunctionCallback
catch (Exception ex)
{
var error = $"Error when getting agent {agent.Name} instruction response.";
_logger.LogWarning($"{error} {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"{error} (template name: {templateName})");
return error;
}

View file

@ -45,7 +45,7 @@ public partial class InstructService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting ai response, {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting ai response");
}
return result;

View file

@ -26,7 +26,7 @@ public partial class FileRepository
}
catch (Exception ex)
{
_logger.LogError($"Error when saving crontab item: {ex.Message}\r\n{ex.InnerException}");
_logger.LogError(ex, $"Error when saving crontab item (agent id: {cron.AgentId}, conv id: {cron.ConversationId}).");
return false;
}
}
@ -57,7 +57,7 @@ public partial class FileRepository
}
catch (Exception ex)
{
_logger.LogError($"Error when deleting crontab item (${conversationId}): {ex.Message}\r\n{ex.InnerException}");
_logger.LogError(ex, $"Error when deleting crontab item (conv id: {conversationId}).");
return false;
}
}

View file

@ -386,7 +386,7 @@ public partial class FileRepository : IBotSharpRepository
foreach (var file in Directory.GetFiles(templateDir))
{
var fileName = file.Split(Path.DirectorySeparatorChar).Last();
var fileName = Path.GetFileName(file);
var splitIdx = fileName.LastIndexOf(".");
var name = fileName.Substring(0, splitIdx);
var extension = fileName.Substring(splitIdx + 1);

View file

@ -89,7 +89,7 @@ public class BotSharpStatsService : IBotSharpStatsService
}
catch (Exception ex)
{
_logger.LogError($"Error when updating global stats {input.Metric}-{input.Dimension}-{input.DimRefVal}. {ex.Message}\r\n{ex.InnerException}");
_logger.LogError(ex, $"Error when updating global stats {input.Metric}-{input.Dimension}-{input.DimRefVal}.");
return false;
}
}

View file

@ -3,8 +3,11 @@ using BotSharp.Abstraction.Routing.Models;
using BotSharp.Abstraction.Templating;
using BotSharp.Abstraction.Translation.Models;
using Fluid;
using Fluid.Ast;
using System.Collections;
using System.IO;
using System.Reflection;
using System.Text.Encodings.Web;
namespace BotSharp.Core.Templating;
@ -32,6 +35,8 @@ public class TemplateRender : ITemplateRender
_options.MemberAccessStrategy.Register<FunctionParametersDef>();
_options.MemberAccessStrategy.Register<UserIdentity>();
_options.MemberAccessStrategy.Register<TranslationInput>();
_parser.RegisterIdentifierTag("link", RenderIdentifierTag);
}
public string Render(string template, Dictionary<string, object> dict)
@ -40,17 +45,16 @@ public class TemplateRender : ITemplateRender
{
var context = new TemplateContext(dict, _options);
template = t.Render(context);
return template;
}
else
{
_logger.LogWarning(error);
return template;
}
return template;
}
public void Register(Type type)
public void RegisterType(Type type)
{
if (type == null || IsStringType(type)) return;
@ -59,7 +63,7 @@ public class TemplateRender : ITemplateRender
if (type.IsGenericType)
{
var genericType = type.GetGenericArguments()[0];
Register(genericType);
RegisterType(genericType);
}
}
else if (IsTrackToNextLevel(type))
@ -68,13 +72,46 @@ public class TemplateRender : ITemplateRender
var props = type.GetProperties();
foreach (var prop in props)
{
Register(prop.PropertyType);
RegisterType(prop.PropertyType);
}
}
}
#region Private methods
private static async ValueTask<Completion> RenderIdentifierTag(string identifier, TextWriter writer, TextEncoder encoder, TemplateContext context)
{
try
{
var value = await context.Model.GetValueAsync(TemplateRenderConstant.RENDER_AGENT, context);
var agent = value?.ToObjectValue() as Agent;
var found = agent?.Templates?.FirstOrDefault(x => x.Name.IsEqualTo(identifier));
var key = $"{agent?.Id} | {identifier}";
if (found == null || (context.AmbientValues.TryGetValue(key, out var visited) && (bool)visited))
{
writer.Write(string.Empty);
}
else if (_parser.TryParse(found.Content, out var t, out _))
{
context.AmbientValues[key] = true;
var rendered = t.Render(context);
writer.Write(rendered);
context.AmbientValues.Remove(key);
}
else
{
writer.Write(string.Empty);
}
}
catch
{
writer.Write(string.Empty);
}
return Completion.Normal;
}
private static bool IsStringType(Type type)
{
return type == typeof(string);

View file

@ -41,6 +41,7 @@ global using BotSharp.Abstraction.Statistics.Enums;
global using BotSharp.Abstraction.Statistics.Services;
global using BotSharp.Abstraction.Loggers.Services;
global using BotSharp.Abstraction.Infrastructures.Events;
global using BotSharp.Abstraction.Templating.Constants;
global using BotSharp.Core.Repository;
global using BotSharp.Core.Routing;
global using BotSharp.Core.Agents.Services;

View file

@ -1 +1 @@
You are a AI Assistant. You can answer user's question.
You are a AI Assistant. You can answer user's question.

View file

@ -654,4 +654,4 @@ public class ConversationController : ControllerBase
return jsonOption;
}
#endregion
}
}

View file

@ -34,6 +34,13 @@ public class KnowledgeBaseController : ControllerBase
return collections.Select(x => VectorCollectionConfigViewModel.From(x));
}
[HttpGet("knowledge/vector/{collection}/details")]
public async Task<VectorCollectionDetailsViewModel?> GetVectorCollectionDetails([FromRoute] string collection)
{
var details = await _knowledgeService.GetVectorCollectionDetails(collection);
return VectorCollectionDetailsViewModel.From(details);
}
[HttpPost("knowledge/vector/create-collection")]
public async Task<bool> CreateVectorCollection([FromBody] CreateVectorCollectionRequest request)
{

View file

@ -0,0 +1,23 @@
using BotSharp.Abstraction.VectorStorage.Models;
namespace BotSharp.OpenAPI.ViewModels.Knowledges;
public class VectorCollectionDetailsViewModel : VectorCollectionDetails
{
public static VectorCollectionDetailsViewModel? From(VectorCollectionDetails? model)
{
if (model == null) return null;
return new VectorCollectionDetailsViewModel
{
Status = model.Status,
OptimizerStatus = model.OptimizerStatus,
SegmentsCount = model.SegmentsCount,
VectorsCount = model.VectorsCount,
IndexedVectorsCount = model.IndexedVectorsCount,
PointsCount = model.PointsCount,
InnerConfig = model.InnerConfig,
BasicInfo = model.BasicInfo
};
}
}

View file

@ -94,7 +94,7 @@ public class NativeWhisperProvider : IAudioTranscription
catch (Exception ex)
{
var error = "Failed to load whisper model";
_logger.LogWarning($"${error}: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"{error}");
throw new Exception($"{error}: {ex.Message}");
}
}

View file

@ -20,6 +20,7 @@
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\..\Infrastructure\BotSharp.Core.Crontab\BotSharp.Core.Crontab.csproj" />
<ProjectReference Include="..\..\Infrastructure\BotSharp.Core.Realtime\BotSharp.Core.Realtime.csproj" />
<ProjectReference Include="..\..\Infrastructure\BotSharp.OpenAPI\BotSharp.OpenAPI.csproj" />
</ItemGroup>
</Project>

View file

@ -3,11 +3,11 @@ using System.Text.RegularExpressions;
namespace BotSharp.Plugin.ChatHub;
public class WebSocketsMiddleware
public class ChatHubMiddleware
{
private readonly RequestDelegate _next;
public WebSocketsMiddleware(RequestDelegate next)
public ChatHubMiddleware(RequestDelegate next)
{
_next = next;
}

View file

@ -0,0 +1,151 @@
using Microsoft.AspNetCore.Http;
using System.Net.WebSockets;
namespace BotSharp.Plugin.ChatHub;
public class ChatStreamMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ChatStreamMiddleware> _logger;
private BotSharpRealtimeSession _session;
public ChatStreamMiddleware(
RequestDelegate next,
ILogger<ChatStreamMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task Invoke(HttpContext httpContext)
{
var request = httpContext.Request;
if (request.Path.StartsWithSegments("/chat/stream"))
{
if (httpContext.WebSockets.IsWebSocketRequest)
{
try
{
var services = httpContext.RequestServices;
var segments = request.Path.Value.Split("/");
var agentId = segments[segments.Length - 2];
var conversationId = segments[segments.Length - 1];
using var webSocket = await httpContext.WebSockets.AcceptWebSocketAsync();
await HandleWebSocket(services, agentId, conversationId, webSocket);
}
catch (Exception ex)
{
_session?.Dispose();
_logger.LogError(ex, $"Error when connecting Chat stream. ({ex.Message})");
}
return;
}
}
await _next(httpContext);
}
private async Task HandleWebSocket(IServiceProvider services, string agentId, string conversationId, WebSocket webSocket)
{
_session?.Dispose();
_session = new BotSharpRealtimeSession(services, webSocket, new ChatSessionOptions
{
BufferSize = 1024 * 16,
JsonOptions = BotSharpOptions.defaultJsonOptions
});
var hub = services.GetRequiredService<IRealtimeHub>();
var conn = hub.SetHubConnection(conversationId);
conn.CurrentAgentId = agentId;
// load conversation and state
var convService = services.GetRequiredService<IConversationService>();
convService.SetConversationId(conversationId, []);
await convService.GetConversationRecordOrCreateNew(agentId);
await foreach (ChatSessionUpdate update in _session.ReceiveUpdatesAsync(CancellationToken.None))
{
var receivedText = update?.RawResponse;
if (string.IsNullOrEmpty(receivedText))
{
continue;
}
var (eventType, data) = MapEvents(conn, receivedText);
if (eventType == "start")
{
await ConnectToModel(hub, webSocket);
}
else if (eventType == "media")
{
if (!string.IsNullOrEmpty(data))
{
await hub.Completer.AppenAudioBuffer(data);
}
}
else if (eventType == "disconnect")
{
await hub.Completer.Disconnect();
break;
}
}
await _session.Disconnect();
_session.Dispose();
}
private async Task ConnectToModel(IRealtimeHub hub, WebSocket webSocket)
{
await hub.ConnectToModel(async data =>
{
if (_session != null)
{
await _session.SendEvent(data);
}
});
}
private (string, string) MapEvents(RealtimeHubConnection conn, string receivedText)
{
var response = JsonSerializer.Deserialize<ChatStreamEventResponse>(receivedText);
string data = string.Empty;
switch (response.Event)
{
case "start":
conn.ResetStreamState();
break;
case "media":
var mediaResponse = JsonSerializer.Deserialize<ChatStreamMediaEventResponse>(receivedText);
data = mediaResponse?.Body?.Payload ?? string.Empty;
break;
case "disconnect":
break;
}
conn.OnModelMessageReceived = message =>
JsonSerializer.Serialize(new
{
@event = "media",
media = new { payload = message }
});
conn.OnModelAudioResponseDone = () =>
JsonSerializer.Serialize(new
{
@event = "mark",
mark = new { name = "responsePart" }
});
conn.OnModelUserInterrupted = () =>
JsonSerializer.Serialize(new
{
@event = "clear"
});
return (response.Event, data);
}
}

View file

@ -195,8 +195,7 @@ public class ChatHubConversationHook : ConversationHookBase
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to init client conversation in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})" +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to init client conversation in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})");
}
}
@ -215,8 +214,7 @@ public class ChatHubConversationHook : ConversationHookBase
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to receive assistant message in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})" +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to receive assistant message in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})");
}
}
@ -235,8 +233,7 @@ public class ChatHubConversationHook : ConversationHookBase
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to receive assistant message in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})" +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to receive assistant message in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})");
}
}
@ -256,8 +253,7 @@ public class ChatHubConversationHook : ConversationHookBase
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to generate sender action in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})" +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to generate sender action in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})");
}
}
@ -276,8 +272,7 @@ public class ChatHubConversationHook : ConversationHookBase
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to delete message in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})" +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to delete message in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})");
}
}
@ -296,8 +291,7 @@ public class ChatHubConversationHook : ConversationHookBase
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to generate notification in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})" +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to generate notification in {nameof(ChatHubConversationHook)} (conversation id: {conversationId})");
}
}
#endregion

View file

@ -85,7 +85,7 @@ public class StreamingLogHook : ConversationHookBase, IContentGeneratingHook, IR
await SendContentLog(conversationId, input);
}
public async Task OnSessionUpdated(Agent agent, string instruction, FunctionDef[] functions)
public async Task OnSessionUpdated(Agent agent, string instruction, FunctionDef[] functions, bool isInit = false)
{
var conversationId = _state.GetConversationId();
if (string.IsNullOrEmpty(conversationId)) return;
@ -98,6 +98,8 @@ public class StreamingLogHook : ConversationHookBase, IContentGeneratingHook, IR
}
_logger.LogInformation(log);
if (isInit) return;
var message = new RoleDialogModel(AgentRole.Assistant, log)
{
MessageId = _routingCtx.MessageId
@ -482,8 +484,7 @@ public class StreamingLogHook : ConversationHookBase, IContentGeneratingHook, IR
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to send content log in {nameof(StreamingLogHook)} (conversation id: {conversationId})." +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to send content log in {nameof(StreamingLogHook)} (conversation id: {conversationId}).");
}
}
@ -502,8 +503,7 @@ public class StreamingLogHook : ConversationHookBase, IContentGeneratingHook, IR
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to send state log in {nameof(StreamingLogHook)} (conversation id: {conversationId})." +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to send state log in {nameof(StreamingLogHook)} (conversation id: {conversationId}).");
}
}
@ -522,8 +522,7 @@ public class StreamingLogHook : ConversationHookBase, IContentGeneratingHook, IR
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to send agent queue log in {nameof(StreamingLogHook)} (conversation id: {conversationId})." +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to send agent queue log in {nameof(StreamingLogHook)} (conversation id: {conversationId}).");
}
}
@ -542,8 +541,7 @@ public class StreamingLogHook : ConversationHookBase, IContentGeneratingHook, IR
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to send state change in {nameof(StreamingLogHook)} (conversation id: {conversationId})." +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to send state change in {nameof(StreamingLogHook)} (conversation id: {conversationId}).");
}
}

View file

@ -103,8 +103,7 @@ public class WelcomeHook : ConversationHookBase
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to send event in {nameof(WelcomeHook)} (conversation id: {conversationId})." +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to send event in {nameof(WelcomeHook)} (conversation id: {conversationId}).");
}
}
}

View file

@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace BotSharp.Plugin.ChatHub.Models.Stream;
internal class ChatStreamEventResponse
{
[JsonPropertyName("event")]
public string Event { get; set; }
}
internal class ChatStreamMediaEventResponse : ChatStreamEventResponse
{
[JsonPropertyName("body")]
public MediaEventResponseBody Body { get; set; }
}
internal class MediaEventResponseBody
{
[JsonPropertyName("payload")]
public string Payload { get; set; }
}

View file

@ -64,8 +64,7 @@ public class SignalRHub : Hub
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to add chat group in {nameof(SignalRHub)} (conversation id: {conversationId})." +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Failed to add chat group in {nameof(SignalRHub)} (conversation id: {conversationId}).");
}
}
}

View file

@ -18,8 +18,6 @@ global using BotSharp.Abstraction.Agents.Settings;
global using BotSharp.Abstraction.Conversations.Settings;
global using BotSharp.Abstraction.Agents.Enums;
global using BotSharp.Abstraction.Conversations.Models;
global using BotSharp.OpenAPI.ViewModels.Conversations;
global using BotSharp.OpenAPI.ViewModels.Users;
global using BotSharp.Abstraction.Agents.Models;
global using BotSharp.Abstraction.Functions.Models;
global using BotSharp.Abstraction.Loggers;
@ -32,5 +30,14 @@ global using BotSharp.Abstraction.Messaging;
global using BotSharp.Abstraction.Messaging.Enums;
global using BotSharp.Abstraction.Messaging.Models.RichContent;
global using BotSharp.Abstraction.Templating;
global using BotSharp.Abstraction.Realtime;
global using BotSharp.Abstraction.Realtime.Models;
global using BotSharp.OpenAPI.ViewModels.Conversations;
global using BotSharp.OpenAPI.ViewModels.Users;
global using BotSharp.Plugin.ChatHub.Settings;
global using BotSharp.Plugin.ChatHub.Enums;
global using BotSharp.Plugin.ChatHub.Enums;
global using BotSharp.Plugin.ChatHub.Models.Stream;
global using BotSharp.Core.Realtime.Models.Chat;
global using BotSharp.Core.Realtime.Models.Options;
global using BotSharp.Core.Realtime.Websocket.Chat;

View file

@ -64,7 +64,7 @@ public class HandleEmailSenderFn : IFunctionCallback
catch (Exception ex)
{
var msg = $"Failed to send the email. {ex.Message}";
_logger.LogError($"{msg}\n(Error: {ex.Message}\r\n{ex.InnerException})");
_logger.LogError(ex, $"{msg}");
message.Content = msg;
return false;
}

View file

@ -90,7 +90,7 @@ public class EditImageFn : IFunctionCallback
catch (Exception ex)
{
var error = $"Error when getting image edit response. {ex.Message}";
_logger.LogWarning($"{error}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"{error}");
return error;
}
}

View file

@ -68,7 +68,7 @@ public class GenerateImageFn : IFunctionCallback
catch (Exception ex)
{
var error = $"Error when generating image.";
_logger.LogWarning($"{error} {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"{error}");
return error;
}
}

View file

@ -108,7 +108,7 @@ public class ReadImageFn : IFunctionCallback
catch (Exception ex)
{
var error = $"Error when analyzing images.";
_logger.LogWarning($"{error} {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"{error}");
return error;
}
}

View file

@ -86,7 +86,7 @@ public class ReadPdfFn : IFunctionCallback
catch (Exception ex)
{
var error = $"Error when analyzing pdf file(s).";
_logger.LogWarning($"{error} {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"{error}");
return error;
}
}

View file

@ -9,8 +9,9 @@ namespace BotSharp.Plugin.GoogleAi.Providers.Realtime;
public class GoogleRealTimeProvider : IRealTimeCompletion
{
public string Provider => "google-ai";
private string _model = GoogleAIModels.Gemini2FlashExp;
public string Model => _model;
private string _model = GoogleAIModels.Gemini2FlashExp;
private MultiModalLiveClient _client;
private GenerativeModel _chatClient;
private readonly IServiceProvider _services;
@ -34,15 +35,16 @@ public class GoogleRealTimeProvider : IRealTimeCompletion
_model = model;
}
private Action onModelReady;
Action<string, string> onModelAudioDeltaReceived;
private Action onModelAudioResponseDone;
Action<string> onModelAudioTranscriptDone;
private Action<List<RoleDialogModel>> onModelResponseDone;
Action<string> onConversationItemCreated;
private Action<RoleDialogModel> onInputAudioTranscriptionCompleted;
Action onUserInterrupted;
RealtimeHubConnection conn;
private RealtimeHubConnection _conn;
private Action _onModelReady;
private Action<string, string> _onModelAudioDeltaReceived;
private Action _onModelAudioResponseDone;
private Action<string> _onModelAudioTranscriptDone;
private Action<List<RoleDialogModel>> _onModelResponseDone;
private Action<string> _onConversationItemCreated;
private Action<RoleDialogModel> _onInputAudioTranscriptionCompleted;
private Action _onUserInterrupted;
public async Task Connect(RealtimeHubConnection conn,
Action onModelReady,
@ -54,15 +56,15 @@ public class GoogleRealTimeProvider : IRealTimeCompletion
Action<RoleDialogModel> onInputAudioTranscriptionCompleted,
Action onUserInterrupted)
{
this.conn = conn;
this.onModelReady = onModelReady;
this.onModelAudioDeltaReceived = onModelAudioDeltaReceived;
this.onModelAudioResponseDone = onModelAudioResponseDone;
this.onModelAudioTranscriptDone = onModelAudioTranscriptDone;
this.onModelResponseDone = onModelResponseDone;
this.onConversationItemCreated = onConversationItemCreated;
this.onInputAudioTranscriptionCompleted = onInputAudioTranscriptionCompleted;
this.onUserInterrupted = onUserInterrupted;
_conn = conn;
_onModelReady = onModelReady;
_onModelAudioDeltaReceived = onModelAudioDeltaReceived;
_onModelAudioResponseDone = onModelAudioResponseDone;
_onModelAudioTranscriptDone = onModelAudioTranscriptDone;
_onModelResponseDone = onModelResponseDone;
_onConversationItemCreated = onConversationItemCreated;
_onInputAudioTranscriptionCompleted = onInputAudioTranscriptionCompleted;
_onUserInterrupted = onUserInterrupted;
var realtimeModelSettings = _services.GetRequiredService<RealtimeModelSettings>();
_model = realtimeModelSettings.Model;
@ -120,7 +122,7 @@ public class GoogleRealTimeProvider : IRealTimeCompletion
client.Connected += (sender, e) =>
{
_logger.LogInformation("Google Realtime Client connected.");
onModelReady();
_onModelReady();
};
client.Disconnected += (sender, e) =>
@ -133,39 +135,39 @@ public class GoogleRealTimeProvider : IRealTimeCompletion
_logger.LogInformation("User message received.");
if (e.Payload.SetupComplete != null)
{
onConversationItemCreated(_client.ConnectionId.ToString());
_onConversationItemCreated(_client.ConnectionId.ToString());
}
if (e.Payload.ServerContent != null)
{
if (e.Payload.ServerContent.TurnComplete == true)
{
var responseDone = await ResponseDone(conn, e.Payload.ServerContent);
onModelResponseDone(responseDone);
var responseDone = await ResponseDone(_conn, e.Payload.ServerContent);
_onModelResponseDone(responseDone);
}
}
};
client.AudioChunkReceived += (sender, e) =>
{
onModelAudioDeltaReceived(Convert.ToBase64String(e.Buffer), Guid.NewGuid().ToString());
_onModelAudioDeltaReceived(Convert.ToBase64String(e.Buffer), Guid.NewGuid().ToString());
};
client.TextChunkReceived += (sender, e) =>
{
onInputAudioTranscriptionCompleted(new RoleDialogModel(AgentRole.Assistant, e.Text));
_onInputAudioTranscriptionCompleted(new RoleDialogModel(AgentRole.Assistant, e.Text));
};
client.GenerationInterrupted += (sender, e) =>
{
_logger.LogInformation("Audio generation interrupted.");
onUserInterrupted();
_onUserInterrupted();
};
client.AudioReceiveCompleted += (sender, e) =>
{
_logger.LogInformation("Audio receive completed.");
onModelAudioResponseDone();
_onModelAudioResponseDone();
};
client.ErrorOccurred += (sender, e) =>
@ -236,7 +238,7 @@ public class GoogleRealTimeProvider : IRealTimeCompletion
//todo Send Audio Chunks to Model, Botsharp RealTime Implementation seems to be incomplete
}
public async Task<string> UpdateSession(RealtimeHubConnection conn)
public async Task<string> UpdateSession(RealtimeHubConnection conn, bool isInit = false)
{
var convService = _services.GetRequiredService<IConversationService>();
var conv = await convService.GetConversation(conn.ConversationId);
@ -276,7 +278,7 @@ public class GoogleRealTimeProvider : IRealTimeCompletion
}).ToArray();
await HookEmitter.Emit<IContentGeneratingHook>(_services,
async hook => { await hook.OnSessionUpdated(agent, prompt, functions); });
async hook => { await hook.OnSessionUpdated(agent, prompt, functions, isInit); });
if (_settings.Gemini.UseGoogleSearch)
{

View file

@ -81,7 +81,7 @@ public class GraphDb : IGraphDb
}
catch (Exception ex)
{
_logger.LogError($"Error when fetching Lessen GLM response (Endpoint: {url}). {ex.Message}\r\n{ex.InnerException}");
_logger.LogError(ex, $"Error when fetching Lessen GLM response (Endpoint: {url}).");
return result;
}
}

View file

@ -45,7 +45,7 @@ public class HandleHttpRequestFn : IFunctionCallback
catch (Exception ex)
{
var msg = $"Fail when sending http request. Url: {url}, method: {method}, content: {content}";
_logger.LogError($"{msg}\n(Error: {ex.Message}\r\n{ex.InnerException})");
_logger.LogError(ex, $"{msg}");
message.Content = msg;
return false;
}

View file

@ -98,7 +98,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogError($"Error when processing knowledge file ({file.FileName}). {ex.Message}\r\n{ex.InnerException}");
_logger.LogError(ex, $"Error when processing knowledge file ({file.FileName}).");
failedFiles.Add(file.FileName);
continue;
}
@ -170,9 +170,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when importing doc content to knowledgebase ({collectionName}-{fileName})" +
$"\r\n{ex.Message}" +
$"\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when importing doc content to knowledgebase ({collectionName}-{fileName})");
return false;
}
}
@ -214,9 +212,8 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when deleting knowledge document " +
$"(Collection: {collectionName}, File id: {fileId})" +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when deleting knowledge document " +
$"(Collection: {collectionName}, File id: {fileId})");
return false;
}
}

View file

@ -15,7 +15,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when searching graph knowledge (Query: {query}). {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when searching graph knowledge (Query: {query}).");
return new GraphSearchResult();
}
}

View file

@ -64,7 +64,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when creating a vector collection ({collectionName}). {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when creating a vector collection ({collectionName}).");
return false;
}
}
@ -86,11 +86,38 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting vector db collections. {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting vector db collections.");
return Enumerable.Empty<VectorCollectionConfig>();
}
}
public async Task<VectorCollectionDetails?> GetVectorCollectionDetails(string collectionName)
{
try
{
if (string.IsNullOrWhiteSpace(collectionName)) return null;
var db = _services.GetRequiredService<IBotSharpRepository>();
var configs = db.GetKnowledgeCollectionConfigs(new VectorCollectionConfigFilter
{
CollectionNames = [collectionName]
}).ToList();
var vectorDb = GetVectorDb();
var details = await vectorDb.GetCollectionDetails(collectionName);
if (details != null)
{
details.BasicInfo = configs.FirstOrDefault();
}
return details;
}
catch (Exception ex)
{
_logger.LogWarning(ex, $"Error when getting vector db collection details.");
return null;
}
}
public async Task<bool> DeleteVectorCollection(string collectionName)
{
try
@ -118,7 +145,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when deleting collection ({collectionName}). {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when deleting collection ({collectionName}).");
return false;
}
}
@ -146,7 +173,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when creating vector collection data. {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when creating vector collection data.");
return false;
}
}
@ -155,13 +182,15 @@ public partial class KnowledgeService
{
try
{
if (string.IsNullOrWhiteSpace(collectionName) || string.IsNullOrWhiteSpace(update.Text) || !Guid.TryParse(update.Id, out var guid))
if (string.IsNullOrWhiteSpace(collectionName)
|| string.IsNullOrWhiteSpace(update.Text)
|| !Guid.TryParse(update.Id, out var guid))
{
return false;
}
var db = GetVectorDb();
var found = await db.GetCollectionData(collectionName, new List<Guid> { guid });
var found = await db.GetCollectionData(collectionName, [guid]);
if (found.IsNullOrEmpty())
{
return false;
@ -176,7 +205,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when updating vector collection data. {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when updating vector collection data.");
return false;
}
}
@ -185,18 +214,18 @@ public partial class KnowledgeService
{
try
{
if (string.IsNullOrWhiteSpace(collectionName) || string.IsNullOrWhiteSpace(update.Text) || !Guid.TryParse(update.Id, out var guid))
if (string.IsNullOrWhiteSpace(collectionName)
|| string.IsNullOrWhiteSpace(update.Text)
|| !Guid.TryParse(update.Id, out var guid))
{
return false;
}
var db = GetVectorDb();
var found = await db.GetCollectionData(collectionName, new List<Guid> { guid },
withVector: true,
withPayload: true);
var found = await db.GetCollectionData(collectionName, [guid], withVector: true, withPayload: true);
if (!found.IsNullOrEmpty())
{
if (found.First().Data["text"].ToString() == update.Text)
if (found.First().Data[KnowledgePayloadName.Text].ToString() == update.Text)
{
// Only update payload
return await db.Upsert(collectionName, guid, found.First().Vector, update.Text, update.Payload);
@ -212,7 +241,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when updating vector collection data. {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when updating vector collection data.");
return false;
}
}
@ -231,7 +260,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when deleting vector collection data ({collectionName}-{id}). {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when deleting vector collection data ({collectionName}-{id}).");
return false;
}
}
@ -246,7 +275,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when deleting vector collection data ({collectionName}). {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when deleting vector collection data ({collectionName}).");
return false;
}
}
@ -266,7 +295,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting vector knowledge collection data ({collectionName}). {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting vector knowledge collection data ({collectionName}).");
return new StringIdPagedItems<VectorSearchResult>();
}
}
@ -287,7 +316,7 @@ public partial class KnowledgeService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when searching vector knowledge ({collectionName}). {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when searching vector knowledge ({collectionName}).");
return Enumerable.Empty<VectorSearchResult>();
}
}

View file

@ -26,7 +26,7 @@ public partial class MongoRepository
}
catch (Exception ex)
{
_logger.LogError($"Error when saving crontab item: {ex.Message}\r\n{ex.InnerException}");
_logger.LogError(ex, $"Error when saving crontab item (agent id: {item.AgentId}, conv id: {item.ConversationId}).");
return false;
}
}

View file

@ -15,8 +15,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Infrastructure\BotSharp.Abstraction\BotSharp.Abstraction.csproj" />
<ProjectReference Include="..\..\Infrastructure\BotSharp.Core\BotSharp.Core.csproj" />
<ProjectReference Include="..\..\Infrastructure\BotSharp.Core.Realtime\BotSharp.Core.Realtime.csproj" />
</ItemGroup>
</Project>

View file

@ -10,6 +10,7 @@ public class ConversationItemBody
{
[JsonPropertyName("id")]
public string Id { get; set; } = null!;
[JsonPropertyName("type")]
public string Type { get; set; } = null!;

View file

@ -1,11 +0,0 @@
namespace BotSharp.Plugin.OpenAI.Models.Realtime;
public class SessionConversationUpdate
{
public string RawResponse { get; set; }
public SessionConversationUpdate()
{
}
}

View file

@ -1,5 +1,4 @@
using BotSharp.Plugin.OpenAI.Models.Realtime;
using BotSharp.Plugin.OpenAI.Providers.Realtime.Session;
using OpenAI.Chat;
namespace BotSharp.Plugin.OpenAI.Providers.Realtime;
@ -12,27 +11,28 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
public string Provider => "openai";
public string Model => _model;
protected readonly OpenAiSettings _settings;
protected readonly IServiceProvider _services;
protected readonly ILogger<RealTimeCompletionProvider> _logger;
private readonly BotSharpOptions _options;
private readonly RealtimeModelSettings _settings;
private readonly IServiceProvider _services;
private readonly ILogger<RealTimeCompletionProvider> _logger;
private readonly BotSharpOptions _botsharpOptions;
protected string _model = "gpt-4o-mini-realtime-preview";
private RealtimeChatSession _session;
private LlmRealtimeSession _session;
public RealTimeCompletionProvider(
OpenAiSettings settings,
RealtimeModelSettings settings,
ILogger<RealTimeCompletionProvider> logger,
IServiceProvider services,
BotSharpOptions options)
BotSharpOptions botsharpOptions)
{
_settings = settings;
_logger = logger;
_services = services;
_options = options;
_botsharpOptions = botsharpOptions;
}
public async Task Connect(RealtimeHubConnection conn,
public async Task Connect(
RealtimeHubConnection conn,
Action onModelReady,
Action<string,string> onModelAudioDeltaReceived,
Action onModelAudioResponseDone,
@ -42,14 +42,33 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
Action<RoleDialogModel> onInputAudioTranscriptionCompleted,
Action onInterruptionDetected)
{
var settingsService = _services.GetRequiredService<ILlmProviderService>();
var realtimeModelSettings = _services.GetRequiredService<RealtimeModelSettings>();
_model = realtimeModelSettings.Model;
var settings = settingsService.GetSetting(Provider, _model);
_session?.Dispose();
_session = new RealtimeChatSession(_services, _options);
await _session.ConnectAsync(Provider, _model, CancellationToken.None);
if (_session != null)
{
_session.Dispose();
}
_ = ReceiveMessage(conn,
_session = new LlmRealtimeSession(_services, new ChatSessionOptions
{
JsonOptions = _botsharpOptions.JsonSerializerOptions
});
await _session.ConnectAsync(
uri: new Uri($"wss://api.openai.com/v1/realtime?model={_model}"),
headers: new Dictionary<string, string>
{
{"Authorization", $"Bearer {settings.ApiKey}"},
{"OpenAI-Beta", "realtime=v1"}
},
cancellationToken: CancellationToken.None);
_ = ReceiveMessage(
conn,
onModelReady,
onModelAudioDeltaReceived,
onModelAudioResponseDone,
@ -62,7 +81,11 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
public async Task Disconnect()
{
_session?.Disconnect();
if (_session != null)
{
await _session.Disconnect();
_session.Dispose();
}
}
public async Task AppenAudioBuffer(string message)
@ -122,7 +145,8 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
});
}
private async Task ReceiveMessage(RealtimeHubConnection conn,
private async Task ReceiveMessage(
RealtimeHubConnection conn,
Action onModelReady,
Action<string, string> onModelAudioDeltaReceived,
Action onModelAudioResponseDone,
@ -132,7 +156,7 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
Action<RoleDialogModel> onUserAudioTranscriptionCompleted,
Action onInterruptionDetected)
{
await foreach (SessionConversationUpdate update in _session.ReceiveUpdatesAsync(CancellationToken.None))
await foreach (ChatSessionUpdate update in _session.ReceiveUpdatesAsync(CancellationToken.None))
{
var receivedText = update?.RawResponse;
if (string.IsNullOrEmpty(receivedText))
@ -205,11 +229,15 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
else if (response.Type == "conversation.item.created")
{
_logger.LogInformation($"{response.Type}: {receivedText}");
var data = JsonSerializer.Deserialize<ConversationItemCreated>(receivedText);
await Task.Delay(500);
onConversationItemCreated(receivedText);
}
else if (response.Type == "conversation.item.input_audio_transcription.completed")
{
_logger.LogInformation($"{response.Type}: {receivedText}");
var message = await OnUserAudioTranscriptionCompleted(conn, receivedText);
if (!string.IsNullOrEmpty(message.Content))
{
@ -223,10 +251,17 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
onInterruptionDetected();
}
else if (response.Type == "input_audio_buffer.speech_stopped")
{
_logger.LogInformation($"{response.Type}: {receivedText}");
await Task.Delay(500);
}
else if (response.Type == "input_audio_buffer.committed")
{
_logger.LogInformation($"{response.Type}: {receivedText}");
}
}
_session.Dispose();
}
public async Task SendEventToModel(object message)
@ -236,7 +271,7 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
await _session.SendEventToModel(message);
}
public async Task<string> UpdateSession(RealtimeHubConnection conn)
public async Task<string> UpdateSession(RealtimeHubConnection conn, bool isInit = false)
{
var convService = _services.GetRequiredService<IConversationService>();
var conv = await convService.GetConversation(conn.ConversationId);
@ -257,28 +292,26 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
return fn;
}).ToArray();
var realtimeModelSettings = _services.GetRequiredService<RealtimeModelSettings>();
var sessionUpdate = new
{
type = "session.update",
session = new RealtimeSessionUpdateRequest
{
InputAudioFormat = realtimeModelSettings.InputAudioFormat,
OutputAudioFormat = realtimeModelSettings.OutputAudioFormat,
Voice = realtimeModelSettings.Voice,
InputAudioFormat = _settings.InputAudioFormat,
OutputAudioFormat = _settings.OutputAudioFormat,
Voice = _settings.Voice,
Instructions = instruction,
ToolChoice = "auto",
Tools = functions,
Modalities = realtimeModelSettings.Modalities,
Temperature = Math.Max(options.Temperature ?? realtimeModelSettings.Temperature, 0.6f),
MaxResponseOutputTokens = realtimeModelSettings.MaxResponseOutputTokens,
Modalities = _settings.Modalities,
Temperature = Math.Max(options.Temperature ?? _settings.Temperature, 0.6f),
MaxResponseOutputTokens = _settings.MaxResponseOutputTokens,
TurnDetection = new RealtimeSessionTurnDetection
{
InterruptResponse = realtimeModelSettings.InterruptResponse/*,
Threshold = realtimeModelSettings.TurnDetection.Threshold,
PrefixPadding = realtimeModelSettings.TurnDetection.PrefixPadding,
SilenceDuration = realtimeModelSettings.TurnDetection.SilenceDuration*/
InterruptResponse = _settings.InterruptResponse/*,
Threshold = _settings.TurnDetection.Threshold,
PrefixPadding = _settings.TurnDetection.PrefixPadding,
SilenceDuration = _settings.TurnDetection.SilenceDuration*/
},
InputAudioNoiseReduction = new InputAudioNoiseReduction
{
@ -287,28 +320,26 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
}
};
if (realtimeModelSettings.InputAudioTranscribe)
if (_settings.InputAudioTranscribe)
{
var words = new List<string>();
HookEmitter.Emit<IRealtimeHook>(_services, hook => words.AddRange(hook.OnModelTranscriptPrompt(agent)));
sessionUpdate.session.InputAudioTranscription = new InputAudioTranscription
{
Model = realtimeModelSettings.InputAudioTranscription.Model,
Language = realtimeModelSettings.InputAudioTranscription.Language,
Model = _settings.InputAudioTranscription.Model,
Language = _settings.InputAudioTranscription.Language,
Prompt = string.Join(", ", words.Select(x => x.ToLower().Trim()).Distinct()).SubstringMax(1024)
};
}
await HookEmitter.Emit<IContentGeneratingHook>(_services, async hook =>
{
await hook.OnSessionUpdated(agent, instruction, functions);
await hook.OnSessionUpdated(agent, instruction, functions, isInit);
});
await SendEventToModel(sessionUpdate);
await Task.Delay(300);
return instruction;
}
@ -584,6 +615,7 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
var contentHooks = _services.GetServices<IContentGeneratingHook>().ToList();
var prompts = new List<string>();
var inputTokenDetails = data.Usage?.InputTokenDetails;
var outputTokenDetails = data.Usage?.OutputTokenDetails;
@ -601,61 +633,45 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
MessageType = MessageTypeName.FunctionCall
});
// After chat completion hook
foreach (var hook in contentHooks)
{
await hook.AfterGenerated(new RoleDialogModel(AgentRole.Assistant, $"{output.Name}\r\n{output.Arguments}")
{
CurrentAgentId = conn.CurrentAgentId
},
new TokenStatsModel
{
Provider = Provider,
Model = _model,
Prompt = $"{output.Name}\r\n{output.Arguments}",
TextInputTokens = inputTokenDetails?.TextTokens ?? 0 - inputTokenDetails?.CachedTokenDetails?.TextTokens ?? 0,
CachedTextInputTokens = data.Usage?.InputTokenDetails?.CachedTokenDetails?.TextTokens ?? 0,
AudioInputTokens = inputTokenDetails?.AudioTokens ?? 0 - inputTokenDetails?.CachedTokenDetails?.AudioTokens ?? 0,
CachedAudioInputTokens = inputTokenDetails?.CachedTokenDetails?.AudioTokens ?? 0,
TextOutputTokens = outputTokenDetails?.TextTokens ?? 0,
AudioOutputTokens = outputTokenDetails?.AudioTokens ?? 0
});
}
prompts.Add($"{output.Name}({output.Arguments})");
}
else if (output.Type == "message")
{
var content = output.Content.FirstOrDefault();
var content = output.Content.FirstOrDefault()?.Transcript ?? string.Empty;
outputs.Add(new RoleDialogModel(output.Role, content.Transcript)
outputs.Add(new RoleDialogModel(output.Role, content)
{
CurrentAgentId = conn.CurrentAgentId,
MessageId = output.Id,
MessageType = MessageTypeName.Plain
});
// After chat completion hook
foreach (var hook in contentHooks)
{
await hook.AfterGenerated(new RoleDialogModel(AgentRole.Assistant, content.Transcript)
{
CurrentAgentId = conn.CurrentAgentId
},
new TokenStatsModel
{
Provider = Provider,
Model = _model,
Prompt = content.Transcript,
TextInputTokens = inputTokenDetails?.TextTokens ?? 0 - inputTokenDetails?.CachedTokenDetails?.TextTokens ?? 0,
CachedTextInputTokens = data.Usage?.InputTokenDetails?.CachedTokenDetails?.TextTokens ?? 0,
AudioInputTokens = inputTokenDetails?.AudioTokens ?? 0 - inputTokenDetails?.CachedTokenDetails?.AudioTokens ?? 0,
CachedAudioInputTokens = inputTokenDetails?.CachedTokenDetails?.AudioTokens ?? 0,
TextOutputTokens = outputTokenDetails?.TextTokens ?? 0,
AudioOutputTokens = outputTokenDetails?.AudioTokens ?? 0
});
}
prompts.Add(content);
}
}
var text = string.Join("\r\n", prompts);
// After chat completion hook
foreach (var hook in contentHooks)
{
await hook.AfterGenerated(new RoleDialogModel(AgentRole.Assistant, text)
{
CurrentAgentId = conn.CurrentAgentId
},
new TokenStatsModel
{
Provider = Provider,
Model = _model,
Prompt = text,
TextInputTokens = inputTokenDetails?.TextTokens ?? 0 - inputTokenDetails?.CachedTokenDetails?.TextTokens ?? 0,
CachedTextInputTokens = data.Usage?.InputTokenDetails?.CachedTokenDetails?.TextTokens ?? 0,
AudioInputTokens = inputTokenDetails?.AudioTokens ?? 0 - inputTokenDetails?.CachedTokenDetails?.AudioTokens ?? 0,
CachedAudioInputTokens = inputTokenDetails?.CachedTokenDetails?.AudioTokens ?? 0,
TextOutputTokens = outputTokenDetails?.TextTokens ?? 0,
AudioOutputTokens = outputTokenDetails?.AudioTokens ?? 0
});
}
return outputs;
}
@ -675,4 +691,4 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
return message;
}
}
}

View file

@ -32,3 +32,7 @@ global using BotSharp.Abstraction.Realtime.Models;
global using BotSharp.Core.Infrastructures;
global using BotSharp.Plugin.OpenAI.Models;
global using BotSharp.Plugin.OpenAI.Settings;
global using BotSharp.Core.Realtime.Models.Chat;
global using BotSharp.Core.Realtime.Models.Options;
global using BotSharp.Core.Realtime.Websocket.Llm;

View file

@ -96,6 +96,39 @@ public class QdrantDb : IVectorDb
var collections = await GetClient().ListCollectionsAsync();
return collections.ToList();
}
public async Task<VectorCollectionDetails?> GetCollectionDetails(string collectionName)
{
var exist = await DoesCollectionExist(collectionName);
if (!exist) return null;
var client = GetClient();
var details = await client.GetCollectionInfoAsync(collectionName);
if (details == null) return null;
return new VectorCollectionDetails
{
Status = details.Status.ToString(),
OptimizerStatus = details.OptimizerStatus.ToString(),
SegmentsCount = details.SegmentsCount,
InnerConfig = new VectorCollectionDetailConfig
{
Param = new VectorCollectionDetailConfigParam
{
ShardNumber = details.Config?.Params?.ShardNumber,
ShardingMethod = details.Config?.Params?.ShardingMethod.ToString(),
ReplicationFactor = details.Config?.Params?.ReplicationFactor,
WriteConsistencyFactor = details.Config?.Params?.WriteConsistencyFactor,
ReadFanOutFactor = details.Config?.Params?.ReadFanOutFactor
}
},
VectorsCount = details.VectorsCount,
IndexedVectorsCount = details.IndexedVectorsCount,
PointsCount = details.PointsCount
};
}
#endregion
#region Collection data
@ -423,7 +456,7 @@ public class QdrantDb : IVectorDb
}
catch (Exception ex)
{
_logger.LogError($"Error when downloading Qdrant snapshot (Endpoint: {url}, Snapshot: {snapshotFileName}). {ex.Message}\r\n{ex.InnerException}");
_logger.LogError(ex, $"Error when downloading Qdrant snapshot (Endpoint: {url}, Snapshot: {snapshotFileName}).");
return BinaryData.Empty;
}
}
@ -464,7 +497,7 @@ public class QdrantDb : IVectorDb
}
catch (Exception ex)
{
_logger.LogError($"Error when uploading Qdrant snapshot (Endpoint: {url}, Snapshot: {snapshotFileName}). {ex.Message}\r\n{ex.InnerException}");
_logger.LogError(ex, $"Error when uploading Qdrant snapshot (Endpoint: {url}, Snapshot: {snapshotFileName}).");
return false;
}
}

View file

@ -73,7 +73,7 @@ public class GetTableDefinitionFn : IFunctionCallback
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting ddl statement of table {table}. {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting ddl statement of table {table}.");
}
}
@ -125,7 +125,7 @@ public class GetTableDefinitionFn : IFunctionCallback
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting ddl statement of table {table}. {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting ddl statement of table {table}.");
}
}
@ -165,7 +165,7 @@ public class GetTableDefinitionFn : IFunctionCallback
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting ddl statement of table {table}. {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting ddl statement of table {table}.");
}
}
connection.Close();

View file

@ -82,8 +82,8 @@ public class DbKnowledgeService
}
catch (Exception ex)
{
var note = $"Error processing table {table}: {ex.Message}\r\n{ex.InnerException}";
_logger.LogWarning(note);
var note = $"Error processing table {table}.";
_logger.LogWarning(ex, note);
}
}

View file

@ -67,7 +67,7 @@ public class GetTableDefinitionFn : IFunctionCallback
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting ddl statement of table {table}. {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting ddl statement of table {table}.");
}
}
@ -119,7 +119,7 @@ public class GetTableDefinitionFn : IFunctionCallback
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting ddl statement of table {table}. {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting ddl statement of table {table}.");
}
}
@ -159,7 +159,7 @@ public class GetTableDefinitionFn : IFunctionCallback
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting ddl statement of table {table}. {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting ddl statement of table {table}.");
}
}
connection.Close();

View file

@ -17,7 +17,7 @@ public partial class TencentCosService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when saving speech file. {fileName} ({conversationId})\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when saving speech file. {fileName} (conv id: {conversationId})");
return false;
}
}

View file

@ -20,7 +20,7 @@ public partial class TencentCosService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting files: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting files (path: {relativePath}).");
return Enumerable.Empty<string>();
}
}
@ -33,7 +33,7 @@ public partial class TencentCosService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting file bytes: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting file bytes (url: {fileStorageUrl}).");
return Array.Empty<byte>();
}
}
@ -48,7 +48,7 @@ public partial class TencentCosService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when saving file stream to path: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when saving file stream to path ({filePath}).");
return false;
}
}
@ -63,7 +63,7 @@ public partial class TencentCosService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when saving file bytes to path: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when saving file bytes to path ({filePath}).");
return false;
}
}

View file

@ -138,7 +138,7 @@ public partial class TencentCosService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when saving message file {file.FileName}: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex,$"Error when saving message file {file.FileName} (conv id: {conversationId}).");
continue;
}
}
@ -318,7 +318,7 @@ public partial class TencentCosService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when getting message file screenshots {file} (messageId: {messageId}), Error: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when getting message file screenshots {file} (messageId: {messageId}).");
return files;
}
}

View file

@ -25,9 +25,8 @@ public partial class TencentCosService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when saving knowledge file " +
$"(Vector store provider: {vectorStoreProvider}, Collection: {collectionName}, File name: {fileName})." +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when saving knowledge file " +
$"(Vector store provider: {vectorStoreProvider}, Collection: {collectionName}, File name: {fileName}).");
return false;
}
}
@ -107,8 +106,7 @@ public partial class TencentCosService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when downloading collection file ({collectionName}-{vectorStoreProvider}-{fileId}-{fileName})" +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when downloading collection file ({collectionName}-{vectorStoreProvider}-{fileId}-{fileName})");
return BinaryData.Empty;
}
}

View file

@ -36,7 +36,7 @@ public partial class TencentCosService
}
catch (Exception ex)
{
_logger.LogWarning($"Error when saving user avatar: {ex.Message}\r\n{ex.InnerException}");
_logger.LogWarning(ex, $"Error when saving user avatar (user id: {_user.Id}).");
return false;
}
}

View file

@ -42,9 +42,12 @@ builder.Services.AddSignalR()
var app = builder.Build();
app.UseWebSockets();
// Enable SignalR
app.MapHub<SignalRHub>("/chatHub");
app.UseMiddleware<WebSocketsMiddleware>();
app.UseMiddleware<ChatHubMiddleware>();
app.UseMiddleware<ChatStreamMiddleware>();
// Use BotSharp
app.UseBotSharp()

View file

@ -0,0 +1,122 @@
using NAudio.Wave;
namespace BotSharp.Test.RealtimeVoice.Audio;
internal class AudioInStream : Stream
{
private const int SAMPLE_RATE = 16000;
private const int BYTES_PER_SAMPLE = 2;
private const int CHANNELS = 1;
private const int SAMPLING_SECONDS = 10;
private const int TIMEOUT_SECONDS = 100;
private readonly byte[] _buffer = new byte[SAMPLE_RATE * BYTES_PER_SAMPLE * CHANNELS * SAMPLING_SECONDS];
private readonly object _lock = new();
private int _bufferReadPtr = 0;
private int _bufferWritePtr = 0;
private readonly WaveInEvent _waveInEvent;
private AudioInStream()
{
_waveInEvent = new WaveInEvent
{
WaveFormat = new WaveFormat(SAMPLE_RATE, BYTES_PER_SAMPLE * 8, CHANNELS),
DeviceNumber = 0
};
_waveInEvent.DataAvailable += (_, e) =>
{
lock (_lock)
{
var bytesToCopy = e.BytesRecorded;
if (_bufferWritePtr + bytesToCopy >= _buffer.Length)
{
var chunkLength = _buffer.Length - _bufferWritePtr;
Array.Copy(e.Buffer, 0, _buffer, _bufferWritePtr, chunkLength);
bytesToCopy -= chunkLength;
_bufferWritePtr = 0;
}
Array.Copy(e.Buffer, e.BytesRecorded - bytesToCopy, _buffer, _bufferWritePtr, bytesToCopy);
_bufferWritePtr += bytesToCopy;
}
};
_waveInEvent.StartRecording();
}
public static AudioInStream Init() => new();
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotImplementedException();
public override long Position { get => throw new NotImplementedException(); set => throw new NotImplementedException(); }
public override int Read(byte[] buffer, int offset, int count)
{
var total = count;
while (GetAvailableBytes() < count)
{
Thread.Sleep(TIMEOUT_SECONDS);
}
lock (_lock)
{
if (_bufferReadPtr + count >= _buffer.Length)
{
var chunkLength = _buffer.Length - _bufferReadPtr;
Array.Copy(_buffer, _bufferReadPtr, buffer, offset, chunkLength);
_bufferReadPtr = 0;
count -= chunkLength;
offset += chunkLength;
}
Array.Copy(_buffer, _bufferReadPtr, buffer, offset, count);
_bufferReadPtr += count;
}
return total;
}
private int GetAvailableBytes()
{
if (_bufferWritePtr >= _bufferReadPtr)
{
return _bufferWritePtr - _bufferReadPtr;
}
else
{
return _buffer.Length - _bufferReadPtr + _bufferWritePtr;
}
}
public override void Flush()
{
throw new NotImplementedException();
}
public override long Seek(long offset, SeekOrigin origin)
{
throw new NotImplementedException();
}
public override void SetLength(long value)
{
throw new NotImplementedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotImplementedException();
}
protected override void Dispose(bool disposing = true)
{
_waveInEvent?.Dispose();
base.Dispose(disposing);
}
}

View file

@ -0,0 +1,53 @@
using NAudio.Wave;
namespace BotSharp.Test.RealtimeVoice.Audio;
internal class AudioOut : IDisposable
{
private const int SAMPLE_RATE = 24000;
private const int BYTES_PER_SAMPLE = 2;
private const int CHANNELS = 1;
private const int BUFFER_MINS = 10;
private readonly BufferedWaveProvider _waveProvider;
private readonly WaveOutEvent _waveOutEvent;
private AudioOut()
{
var audioFormat = new WaveFormat(
rate: SAMPLE_RATE,
bits: BYTES_PER_SAMPLE * 8,
channels: CHANNELS);
_waveProvider = new BufferedWaveProvider(audioFormat)
{
BufferDuration = TimeSpan.FromMinutes(BUFFER_MINS),
DiscardOnBufferOverflow = true
};
_waveOutEvent = new WaveOutEvent()
{
DeviceNumber = 0
};
_waveOutEvent.Init(_waveProvider);
_waveOutEvent.Play();
}
public static AudioOut Init() => new();
public void Enqueue(BinaryData data)
{
var buffer = data?.ToArray() ?? [];
_waveProvider.AddSamples(buffer, 0, buffer.Length);
}
public void ClearBuffer()
{
_waveProvider.ClearBuffer();
}
public void Dispose()
{
_waveOutEvent?.Dispose();
}
}

View file

@ -0,0 +1,7 @@
namespace BotSharp.Test.RealtimeVoice.Enums;
internal enum SessionMode
{
StreamChannel = 1,
CustomStream = 2
}

View file

@ -1,135 +1,8 @@
using BotSharp.Abstraction.Conversations.Enums;
using BotSharp.Abstraction.Conversations.Models;
using BotSharp.Abstraction.Conversations;
using BotSharp.OpenAPI;
using System.Text.Json;
using System.Reflection;
var services = ServiceBuilder.CreateHostBuilder(Assembly.GetExecutingAssembly());
var channel = services.GetRequiredService<IStreamChannel>();
Console.WriteLine("PCM-16 Microphone Capture (24kHz Sample Rate)");
Console.WriteLine("-----------------------------------------------");
var convService = services.GetRequiredService<IConversationService>();
var conv = new Conversation
{
AgentId = "01e2fc5c-2c89-4ec7-8470-7688608b496c",
Channel = ConversationChannel.Phone,
Title = $"Test",
Tags = [],
};
conv = await convService.NewConversation(conv);
await channel.ConnectAsync(conv.Id);
var hub = services.GetRequiredService<IRealtimeHub>();
var conn = hub.SetHubConnection(conv.Id);
conn.OnModelReady = () =>
JsonSerializer.Serialize(new
{
@event = "init"
});
conn.OnModelMessageReceived = message =>
JsonSerializer.Serialize(new
{
@event = "media",
media = message
});
conn.OnModelAudioResponseDone = () =>
JsonSerializer.Serialize(new
{
@event = "mark",
mark = new { name = "responsePart" }
});
conn.OnModelUserInterrupted = () =>
JsonSerializer.Serialize(new
{
@event = "interrupted"
});
conn.OnUserSpeechDetected = () =>
JsonSerializer.Serialize(new
{
@event = "speech_detected"
});
await hub.ConnectToModel(async data =>
{
var response = JsonSerializer.Deserialize<ModelResponseEvent>(data);
if (response.Event == "speech_detected")
{
channel.ClearBuffer();
}
else if (response.Event == "media")
{
var message = JsonSerializer.Deserialize<ModelResponseMediaEvent>(data);
await channel.SendAsync(Convert.FromBase64String(message.Media), CancellationToken.None);
}
});
StreamReceiveResult result;
var buffer = new byte[1024 * 8];
do
{
var seg = new ArraySegment<byte>(buffer);
result = await channel.ReceiveAsync(seg, CancellationToken.None);
await hub.Completer.AppenAudioBuffer(seg, result.Count);
// Display the audio level
int audioLevel = CalculateAudioLevel(buffer, result.Count);
DisplayAudioLevel(audioLevel);
} while (result.Status == StreamChannelStatus.Open);
int CalculateAudioLevel(byte[] buffer, int bytesRecorded)
{
// Simple audio level calculation (RMS)
int bytesPerSample = 2; // 16-bit PCM = 2 bytes per sample
int sampleCount = bytesRecorded / bytesPerSample;
if (sampleCount == 0) return 0;
double sum = 0;
for (int i = 0; i < bytesRecorded; i += 2)
{
if (i + 1 < bytesRecorded)
{
short sample = (short)((buffer[i + 1] << 8) | buffer[i]);
double normalized = sample / (short.MaxValue * 1.0 + 1);
sum += normalized * normalized;
}
}
double rms = Math.Sqrt(sum / sampleCount);
double db = 20 * Math.Log10(rms);
if (double.IsInfinity(db) || double.IsNaN(db))
{
return 0;
}
db = Math.Clamp(db, -100, 0);
return (int)((db + 100) * 1);
}
void DisplayAudioLevel(int level)
{
const int sep = 50;
// Normalize level to 0-50 range for display
int displayLevel = (level * sep) / 100;
// Clear the current line
Console.Write("\r" + new string(' ', 60));
// Display audio level as a bar
Console.Write("\rMicrophone: [");
Console.Write(new string('#', displayLevel).PadRight(sep, ' '));
Console.Write("]\r");
}
var agentId = BuiltInAgentId.Chatbot;
var session = ConsoleChatSession.Init(services);
await session.StartAsync(agentId, SessionMode.StreamChannel);

View file

@ -0,0 +1,46 @@
using System.Text.Json;
namespace BotSharp.Test.RealtimeVoice.Session;
internal partial class ConsoleChatSession
{
/// <summary>
/// Start a new chat session via custom stream
/// </summary>
/// <param name="agentId"></param>
/// <returns></returns>
private async Task StartCustomStreamAsync(string agentId)
{
DisplayRemarks();
var (hub, conversationId) = await Setup(agentId);
var audioOut = AudioOut.Init();
await hub.ConnectToModel(
responseToUser: async data =>
{
var response = JsonSerializer.Deserialize<ModelResponseEvent>(data);
if (response.Event == "speech_detected")
{
audioOut.ClearBuffer();
}
else if (response.Event == "media")
{
var message = JsonSerializer.Deserialize<ModelResponseMediaEvent>(data);
var binaryData = BinaryData.FromBytes(Convert.FromBase64String(message.Media));
audioOut.Enqueue(binaryData);
}
},
init: async data =>
{
_ = Task.Run(async () =>
{
using var audioIn = AudioInStream.Init();
Console.WriteLine("\r\nListening microphone...\r\n");
await SendAudio(hub, audioIn);
});
});
while (true) { }
}
}

View file

@ -0,0 +1,50 @@
using System.Text.Json;
namespace BotSharp.Test.RealtimeVoice.Session;
internal partial class ConsoleChatSession
{
/// <summary>
/// Start a new chat session via stream channel
/// </summary>
/// <param name="agentId"></param>
/// <returns></returns>
private async Task StartStreamChannelAsync(string agentId)
{
DisplayRemarks();
var (hub, conversationId) = await Setup(agentId);
var channel = _services.GetRequiredService<IStreamChannel>();
await channel.ConnectAsync(conversationId);
await hub.ConnectToModel(async data =>
{
var response = JsonSerializer.Deserialize<ModelResponseEvent>(data);
if (response.Event == "speech_detected")
{
channel.ClearBuffer();
}
else if (response.Event == "media")
{
var message = JsonSerializer.Deserialize<ModelResponseMediaEvent>(data);
await channel.SendAsync(Convert.FromBase64String(message.Media), CancellationToken.None);
}
});
StreamReceiveResult result;
var buffer = new byte[1024 * 32];
do
{
var seg = new ArraySegment<byte>(buffer);
result = await channel.ReceiveAsync(seg, CancellationToken.None);
await hub.Completer.AppenAudioBuffer(seg, result.Count);
// Display the audio level
int audioLevel = CalculateAudioLevel(buffer, result.Count);
DisplayAudioLevel(audioLevel);
} while (result.Status == StreamChannelStatus.Open);
}
}

View file

@ -0,0 +1,171 @@
using System.Buffers;
using System.Text.Json;
namespace BotSharp.Test.RealtimeVoice.Session;
internal partial class ConsoleChatSession
{
private readonly IServiceProvider _services;
private ConsoleChatSession(
IServiceProvider services)
{
_services = services;
}
public static ConsoleChatSession Init(IServiceProvider services)
{
return new(services);
}
/// <summary>
/// Start a new session
/// </summary>
/// <param name="agentId"></param>
/// <param name="mode"></param>
/// <returns></returns>
public async Task StartAsync(string agentId, SessionMode mode)
{
switch (mode)
{
case SessionMode.StreamChannel:
await StartStreamChannelAsync(agentId);
break;
case SessionMode.CustomStream:
await StartCustomStreamAsync(agentId);
break;
}
}
private void DisplayRemarks()
{
Console.WriteLine("PCM-16 Microphone Capture (24kHz Sample Rate)");
Console.WriteLine("-----------------------------------------------");
}
private async Task SendAudio(IRealtimeHub hub, Stream stream)
{
var buffer = ArrayPool<byte>.Shared.Rent(1024 * 16);
try
{
while (true)
{
var bytesNum = await stream.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None);
if (bytesNum == 0) break;
var audioBytes = buffer.AsMemory(0, bytesNum);
var data = BinaryData.FromBytes(audioBytes);
await hub.Completer.AppenAudioBuffer(data.ToArray(), data.Length);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>
/// Create a new conversation and set up the events
/// </summary>
/// <param name="agentId"></param>
/// <returns></returns>
private async Task<(IRealtimeHub, string)> Setup(string agentId)
{
var convService = _services.GetRequiredService<IConversationService>();
var hub = _services.GetRequiredService<IRealtimeHub>();
var conv = new Conversation
{
AgentId = agentId,
Channel = ConversationChannel.Phone,
Title = $"Test",
Tags = [],
};
conv = await convService.NewConversation(conv);
var conn = hub.SetHubConnection(conv.Id);
conn.CurrentAgentId = conv.AgentId;
conn.OnModelReady = () =>
JsonSerializer.Serialize(new
{
@event = "init"
});
conn.OnModelMessageReceived = message =>
JsonSerializer.Serialize(new
{
@event = "media",
media = message
});
conn.OnModelAudioResponseDone = () =>
JsonSerializer.Serialize(new
{
@event = "mark",
mark = new { name = "responsePart" }
});
conn.OnModelUserInterrupted = () =>
JsonSerializer.Serialize(new
{
@event = "interrupted"
});
conn.OnUserSpeechDetected = () =>
JsonSerializer.Serialize(new
{
@event = "speech_detected"
});
return (hub, conv.Id);
}
private int CalculateAudioLevel(byte[] buffer, int bytesRecorded)
{
// Simple audio level calculation (RMS)
int bytesPerSample = 2; // 16-bit PCM = 2 bytes per sample
int sampleCount = bytesRecorded / bytesPerSample;
if (sampleCount == 0) return 0;
double sum = 0;
for (int i = 0; i < bytesRecorded; i += 2)
{
if (i + 1 < bytesRecorded)
{
short sample = (short)((buffer[i + 1] << 8) | buffer[i]);
double normalized = sample / (short.MaxValue * 1.0 + 1);
sum += normalized * normalized;
}
}
double rms = Math.Sqrt(sum / sampleCount);
double db = 20 * Math.Log10(rms);
if (double.IsInfinity(db) || double.IsNaN(db))
{
return 0;
}
db = Math.Clamp(db, -100, 0);
return (int)((db + 100) * 1);
}
private void DisplayAudioLevel(int level)
{
const int sep = 50;
// Normalize level to 0-50 range for display
int displayLevel = (level * sep) / 100;
// Clear the current line
Console.Write("\r" + new string(' ', 60));
// Display audio level as a bar
Console.Write("\rMicrophone: [");
Console.Write(new string('#', displayLevel).PadRight(sep, ' '));
Console.Write("]\r");
}
}

View file

@ -6,6 +6,14 @@ global using BotSharp.Core;
global using BotSharp.Core.Infrastructures;
global using BotSharp.Core.Plugins;
global using BotSharp.Logger;
global using BotSharp.Abstraction.Agents.Enums;
global using BotSharp.Abstraction.Realtime.Models;
global using BotSharp.Abstraction.Realtime;
global using BotSharp.Abstraction.Realtime.Enums;
global using BotSharp.Abstraction.Conversations.Enums;
global using BotSharp.Abstraction.Conversations.Models;
global using BotSharp.Abstraction.Conversations;
global using BotSharp.Test.RealtimeVoice.Session;
global using BotSharp.Test.RealtimeVoice.Audio;
global using BotSharp.Test.RealtimeVoice.Enums;