Merge branch 'SciSharp:master' into master

This commit is contained in:
visagang 2025-03-13 11:44:34 -05:00 committed by GitHub
commit b61a3e53a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 670 additions and 153 deletions

View file

@ -4,4 +4,9 @@ public class WebBrowsingSettings
{
public string Driver { get; set; } = "Playwright";
public bool Headless { get; set; }
// Default timeout in milliseconds
public float DefaultTimeout { get; set; } = 30000;
public bool IsEnableScreenshot { get; set; }
// Default wait time in seconds after page is opened
public int DefaultWaitTime { get; set; } = 5;
}

View file

@ -78,4 +78,7 @@ public abstract class ConversationHookBase : IConversationHook
public virtual Task OnNotificationGenerated(RoleDialogModel message)
=> Task.CompletedTask;
public virtual Task OnUserDisconnected(Conversation conversation)
=> Task.CompletedTask;
}

View file

@ -25,6 +25,13 @@ public interface IConversationHook
/// <returns></returns>
Task OnUserAgentConnectedInitially(Conversation conversation);
/// <summary>
/// Triggered when user disconnects with agent.
/// </summary>
/// <param name="conversation"></param>
/// <returns></returns>
Task OnUserDisconnected(Conversation conversation);
/// <summary>
/// Triggered once for every new conversation.
/// </summary>

View file

@ -47,6 +47,17 @@ public static class FileUtility
return $"data:{contentType};base64,{base64}";
}
public static BinaryData BuildBinaryDataFromFile(IFormFile file)
{
using var stream = new MemoryStream();
file.CopyTo(stream);
stream.Position = 0;
var binary = BinaryData.FromStream(stream);
stream.Close();
return binary;
}
public static string GetFileContentType(string fileName)
{
string contentType;

View file

@ -60,6 +60,14 @@ public interface IKnowledgeService
Task<FileBinaryDataModel> GetKnowledgeDocumentBinaryData(string collectionName, Guid fileId);
#endregion
#region Snapshot
Task<IEnumerable<VectorCollectionSnapshot>> GetVectorCollectionSnapshots(string collectionName);
Task<VectorCollectionSnapshot?> CreateVectorCollectionSnapshot(string collectionName);
Task<BinaryData> DownloadVectorCollectionSnapshot(string collectionName, string snapshotFileName);
Task<bool> RecoverVectorCollectionFromSnapshot(string collectionName, string snapshotFileName, BinaryData snapshotData);
Task<bool> DeleteVectorCollectionSnapshot(string collectionName, string snapshotName);
#endregion
#region Common
Task<bool> RefreshVectorKnowledgeConfigs(VectorCollectionConfigsModel configs);
#endregion

View file

@ -6,8 +6,8 @@ namespace BotSharp.Abstraction.Loggers.Services;
public interface ILoggerService
{
#region Conversation
Task<List<ContentLogOutputModel>> GetConversationContentLogs(string conversationId);
Task<List<ConversationStateLogModel>> GetConversationStateLogs(string conversationId);
Task<DateTimePagination<ContentLogOutputModel>> GetConversationContentLogs(string conversationId, ConversationLogFilter filter);
Task<DateTimePagination<ConversationStateLogModel>> GetConversationStateLogs(string conversationId, ConversationLogFilter filter);
#endregion
#region Instruction

View file

@ -0,0 +1,6 @@
namespace BotSharp.Abstraction.Realtime;
public interface IRealtimeHook
{
string[] OnModelTranscriptPrompt(Agent agent);
}

View file

@ -0,0 +1,17 @@
namespace BotSharp.Abstraction.Repositories.Filters;
public class ConversationLogFilter
{
public int Size { get; set; } = 20;
public DateTime StartTime { get; set; } = DateTime.UtcNow;
public ConversationLogFilter()
{
}
public static ConversationLogFilter Empty()
{
return new();
}
}

View file

@ -164,14 +164,14 @@ public interface IBotSharpRepository : IHaveServiceProvider
#region Conversation Content Log
void SaveConversationContentLog(ContentLogOutputModel log)
=> throw new NotImplementedException();
List<ContentLogOutputModel> GetConversationContentLogs(string conversationId)
DateTimePagination<ContentLogOutputModel> GetConversationContentLogs(string conversationId, ConversationLogFilter filter)
=> throw new NotImplementedException();
#endregion
#region Conversation State Log
void SaveConversationStateLog(ConversationStateLogModel log)
=> throw new NotImplementedException();
List<ConversationStateLogModel> GetConversationStateLogs(string conversationId)
DateTimePagination<ConversationStateLogModel> GetConversationStateLogs(string conversationId, ConversationLogFilter filter)
=> throw new NotImplementedException();
#endregion

View file

@ -0,0 +1,6 @@
namespace BotSharp.Abstraction.Utilities;
public class DateTimePagination<T> : PagedItems<T>
{
public DateTime? NextTime { get; set; }
}

View file

@ -6,14 +6,36 @@ public interface IVectorDb
{
string Provider { get; }
Task<bool> DoesCollectionExist(string collectionName);
Task<IEnumerable<string>> GetCollections();
Task<StringIdPagedItems<VectorCollectionData>> GetPagedCollectionData(string collectionName, VectorFilter filter);
Task<IEnumerable<VectorCollectionData>> GetCollectionData(string collectionName, IEnumerable<Guid> ids, bool withPayload = false, bool withVector = false);
Task<bool> CreateCollection(string collectionName, int dimension);
Task<bool> DeleteCollection(string collectionName);
Task<bool> Upsert(string collectionName, Guid id, float[] vector, string text, Dictionary<string, object>? payload = null);
Task<IEnumerable<VectorCollectionData>> Search(string collectionName, float[] vector, IEnumerable<string>? fields, int limit = 5, float confidence = 0.5f, bool withVector = false);
Task<bool> DeleteCollectionData(string collectionName, List<Guid> ids);
Task<bool> DeleteCollectionAllData(string collectionName);
Task<bool> DoesCollectionExist(string collectionName)
=> throw new NotImplementedException();
Task<IEnumerable<string>> GetCollections()
=> throw new NotImplementedException();
Task<StringIdPagedItems<VectorCollectionData>> GetPagedCollectionData(string collectionName, VectorFilter filter)
=> throw new NotImplementedException();
Task<IEnumerable<VectorCollectionData>> GetCollectionData(string collectionName, IEnumerable<Guid> ids,
bool withPayload = false, bool withVector = false)
=> throw new NotImplementedException();
Task<bool> CreateCollection(string collectionName, int dimension)
=> throw new NotImplementedException();
Task<bool> DeleteCollection(string collectionName)
=> throw new NotImplementedException();
Task<bool> Upsert(string collectionName, Guid id, float[] vector, string text, Dictionary<string, object>? payload = null)
=> throw new NotImplementedException();
Task<IEnumerable<VectorCollectionData>> Search(string collectionName, float[] vector, IEnumerable<string>? fields,
int limit = 5, float confidence = 0.5f, bool withVector = false)
=> throw new NotImplementedException();
Task<bool> DeleteCollectionData(string collectionName, List<Guid> ids)
=> throw new NotImplementedException();
Task<bool> DeleteCollectionAllData(string collectionName)
=> throw new NotImplementedException();
Task<IEnumerable<VectorCollectionSnapshot>> GetCollectionSnapshots(string collectionName)
=> throw new NotImplementedException();
Task<VectorCollectionSnapshot?> CreateCollectionShapshot(string collectionName)
=> throw new NotImplementedException();
Task<BinaryData> DownloadCollectionSnapshot(string collectionName, string snapshotFileName)
=> throw new NotImplementedException();
Task<bool> RecoverCollectionFromShapshot(string collectionName, string snapshotFileName, BinaryData snapshotData)
=> throw new NotImplementedException();
Task<bool> DeleteCollectionShapshot(string collectionName, string snapshotName)
=> throw new NotImplementedException();
}

View file

@ -0,0 +1,9 @@
namespace BotSharp.Abstraction.VectorStorage.Models;
public class VectorCollectionSnapshot
{
public string Name { get; set; } = default!;
public long Size { get; set; }
public DateTime CreatedTime { get; set; }
public string? CheckSum { get; set; }
}

View file

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

View file

@ -1,4 +1,6 @@
using BotSharp.Abstraction.Utilities;
using BotSharp.Core.Infrastructures;
using Microsoft.AspNetCore.Cors.Infrastructure;
namespace BotSharp.Core.Realtime.Services;
@ -86,11 +88,14 @@ public class RealtimeHub : IRealtimeHub
var routing = _services.GetRequiredService<IRoutingService>();
routing.Context.Push(agent.Id);
var storage = _services.GetRequiredService<IConversationStorage>();
var dialogs = convService.GetDialogHistory();
if (dialogs.Count == 0)
{
dialogs.Add(new RoleDialogModel(AgentRole.User, "Hi"));
storage.Append(_conn.ConversationId, dialogs.First());
}
routing.Context.SetDialogs(dialogs);
await _completer.Connect(_conn,
@ -155,6 +160,7 @@ public class RealtimeHub : IRealtimeHub
{
// append output audio transcript to conversation
dialogs.Add(message);
storage.Append(_conn.ConversationId, message);
foreach (var hook in hookProvider.HooksOrderByPriority)
{
@ -174,6 +180,7 @@ public class RealtimeHub : IRealtimeHub
{
// append input audio transcript to conversation
dialogs.Add(message);
storage.Append(_conn.ConversationId, message);
foreach (var hook in hookProvider.HooksOrderByPriority)
{
@ -224,6 +231,9 @@ public class RealtimeHub : IRealtimeHub
};
dialogs.Add(message);
var storage = _services.GetRequiredService<IConversationStorage>();
storage.Append(_conn.ConversationId, message);
foreach (var hook in hookProvider.HooksOrderByPriority)
{
hook.SetAgent(agent)
@ -239,14 +249,9 @@ public class RealtimeHub : IRealtimeHub
private async Task HandleUserDisconnected()
{
// Save dialog history
var routing = _services.GetRequiredService<IRoutingService>();
var storage = _services.GetRequiredService<IConversationStorage>();
var dialogs = routing.Context.GetDialogs();
foreach (var item in dialogs)
{
storage.Append(_conn.ConversationId, item);
}
var convService = _services.GetRequiredService<IConversationService>();
var conversation = await convService.GetConversation(_conn.ConversationId);
await HookEmitter.Emit<IConversationHook>(_services, x => x.OnUserDisconnected(conversation));
}
private async Task SendEventToUser(WebSocket webSocket, object message)

View file

@ -4,18 +4,28 @@ namespace BotSharp.Core.Loggers.Services;
public partial class LoggerService
{
public async Task<List<ContentLogOutputModel>> GetConversationContentLogs(string conversationId)
public async Task<DateTimePagination<ContentLogOutputModel>> GetConversationContentLogs(string conversationId, ConversationLogFilter filter)
{
if (filter == null)
{
filter = ConversationLogFilter.Empty();
}
var db = _services.GetRequiredService<IBotSharpRepository>();
var logs = db.GetConversationContentLogs(conversationId);
var logs = db.GetConversationContentLogs(conversationId, filter);
return await Task.FromResult(logs);
}
public async Task<List<ConversationStateLogModel>> GetConversationStateLogs(string conversationId)
public async Task<DateTimePagination<ConversationStateLogModel>> GetConversationStateLogs(string conversationId, ConversationLogFilter filter)
{
if (filter == null)
{
filter = ConversationLogFilter.Empty();
}
var db = _services.GetRequiredService<IBotSharpRepository>();
var logs = db.GetConversationStateLogs(conversationId);
var logs = db.GetConversationStateLogs(conversationId, filter);
return await Task.FromResult(logs);
}
}

View file

@ -1,4 +1,5 @@
using BotSharp.Abstraction.Loggers.Models;
using Microsoft.IdentityModel.Logging;
using System.IO;
namespace BotSharp.Core.Repository
@ -54,26 +55,34 @@ namespace BotSharp.Core.Repository
File.WriteAllText(file, JsonSerializer.Serialize(log, _options));
}
public List<ContentLogOutputModel> GetConversationContentLogs(string conversationId)
public DateTimePagination<ContentLogOutputModel> GetConversationContentLogs(string conversationId, ConversationLogFilter filter)
{
var logs = new List<ContentLogOutputModel>();
if (string.IsNullOrEmpty(conversationId)) return logs;
if (string.IsNullOrEmpty(conversationId)) return new();
var convDir = FindConversationDirectory(conversationId);
if (string.IsNullOrEmpty(convDir)) return logs;
if (string.IsNullOrEmpty(convDir)) return new();
var logDir = Path.Combine(convDir, "content_log");
if (!Directory.Exists(logDir)) return logs;
if (!Directory.Exists(logDir)) return new();
var logs = new List<ContentLogOutputModel>();
foreach (var file in Directory.GetFiles(logDir))
{
var text = File.ReadAllText(file);
var log = JsonSerializer.Deserialize<ContentLogOutputModel>(text);
if (log == null) continue;
if (log == null || log.CreatedTime >= filter.StartTime) continue;
logs.Add(log);
}
return logs.OrderBy(x => x.CreatedTime).ToList();
logs = logs.OrderByDescending(x => x.CreatedTime).Take(filter.Size).ToList();
logs.Reverse();
return new DateTimePagination<ContentLogOutputModel>
{
Items = logs,
Count = logs.Count,
NextTime = logs.FirstOrDefault()?.CreatedTime
};
}
#endregion
@ -99,26 +108,34 @@ namespace BotSharp.Core.Repository
File.WriteAllText(file, JsonSerializer.Serialize(log, _options));
}
public List<ConversationStateLogModel> GetConversationStateLogs(string conversationId)
public DateTimePagination<ConversationStateLogModel> GetConversationStateLogs(string conversationId, ConversationLogFilter filter)
{
var logs = new List<ConversationStateLogModel>();
if (string.IsNullOrEmpty(conversationId)) return logs;
if (string.IsNullOrEmpty(conversationId)) return new();
var convDir = FindConversationDirectory(conversationId);
if (string.IsNullOrEmpty(convDir)) return logs;
if (string.IsNullOrEmpty(convDir)) return new();
var logDir = Path.Combine(convDir, "state_log");
if (!Directory.Exists(logDir)) return logs;
if (!Directory.Exists(logDir)) return new();
var logs = new List<ConversationStateLogModel>();
foreach (var file in Directory.GetFiles(logDir))
{
var text = File.ReadAllText(file);
var log = JsonSerializer.Deserialize<ConversationStateLogModel>(text);
if (log == null) continue;
if (log == null || log.CreatedTime >= filter.StartTime) continue;
logs.Add(log);
}
return logs.OrderBy(x => x.CreatedTime).ToList();
logs = logs.OrderByDescending(x => x.CreatedTime).Take(filter.Size).ToList();
logs.Reverse();
return new DateTimePagination<ConversationStateLogModel>
{
Items = logs,
Count = logs.Count,
NextTime = logs.FirstOrDefault()?.CreatedTime
};
}
#endregion

View file

@ -81,11 +81,11 @@ public class ConversationController : ControllerBase
}
[HttpGet("/conversation/{conversationId}/dialogs")]
public async Task<IEnumerable<ChatResponseModel>> GetDialogs([FromRoute] string conversationId)
public async Task<IEnumerable<ChatResponseModel>> GetDialogs([FromRoute] string conversationId, [FromQuery] int count = 100)
{
var conv = _services.GetRequiredService<IConversationService>();
conv.SetConversationId(conversationId, [], isReadOnly: true);
var history = conv.GetDialogHistory(fromBreakpoint: false);
var history = conv.GetDialogHistory(lastCount: count, fromBreakpoint: false);
var userService = _services.GetRequiredService<IUserService>();
var agentService = _services.GetRequiredService<IAgentService>();

View file

@ -19,16 +19,16 @@ public class DashboardController : ControllerBase
}
#region User Components
[HttpGet("/dashboard/components")]
public async Task<UserDashboardModel> GetComponents()
public async Task<UserDashboardViewModel> GetComponents()
{
var userService = _services.GetRequiredService<IUserService>();
var dashboardProfile = await userService.GetDashboard();
if (dashboardProfile == null) return new UserDashboardModel();
if (dashboardProfile == null) return new();
var result = new UserDashboardModel
var result = new UserDashboardViewModel
{
ConversationList = dashboardProfile.ConversationList.Select(
x => new UserDashboardConversationModel
x => new UserDashboardConversationViewModel
{
Name = x.Name,
ConversationId = x.ConversationId,
@ -40,7 +40,7 @@ public class DashboardController : ControllerBase
}
[HttpPost("/dashboard/component/conversation")]
public async Task UpdateDashboardConversationInstruction(UserDashboardConversationModel dashConv)
public async Task UpdateDashboardConversationInstruction(UserDashboardConversationViewModel dashConv)
{
if (string.IsNullOrEmpty(dashConv.Name) && string.IsNullOrEmpty(dashConv.Instruction))
{

View file

@ -12,7 +12,9 @@ public class KnowledgeBaseController : ControllerBase
private readonly IKnowledgeService _knowledgeService;
private readonly IServiceProvider _services;
public KnowledgeBaseController(IKnowledgeService knowledgeService, IServiceProvider services)
public KnowledgeBaseController(
IKnowledgeService knowledgeService,
IServiceProvider services)
{
_knowledgeService = knowledgeService;
_services = services;
@ -117,6 +119,46 @@ public class KnowledgeBaseController : ControllerBase
#endregion
#region Snapshot
[HttpGet("/knowledge/vector/{collection}/snapshots")]
public async Task<IEnumerable<VectorCollectionSnapshotViewModel>> GetVectorCollectionSnapshots([FromRoute] string collection)
{
var snapshots = await _knowledgeService.GetVectorCollectionSnapshots(collection);
return snapshots.Select(x => VectorCollectionSnapshotViewModel.From(x));
}
[HttpPost("/knowledge/vector/{collection}/snapshot")]
public async Task<VectorCollectionSnapshotViewModel?> CreateVectorCollectionSnapshot([FromRoute] string collection)
{
var snapshot = await _knowledgeService.CreateVectorCollectionSnapshot(collection);
return VectorCollectionSnapshotViewModel.From(snapshot);
}
[HttpGet("/knowledge/vector/{collection}/snapshot")]
public async Task<IActionResult> GetVectorCollectionSnapshot([FromRoute] string collection, [FromQuery] string snapshotFileName)
{
var snapshot = await _knowledgeService.DownloadVectorCollectionSnapshot(collection, snapshotFileName);
return BuildFileResult(snapshotFileName, snapshot);
}
[HttpPost("/knowledge/vector/{collection}/snapshot/recover")]
public async Task<bool> RecoverVectorCollectionFromSnapshot([FromRoute] string collection, IFormFile snapshotFile)
{
var fileName = snapshotFile.FileName;
var binary = FileUtility.BuildBinaryDataFromFile(snapshotFile);
var done = await _knowledgeService.RecoverVectorCollectionFromSnapshot(collection, fileName, binary);
return done;
}
[HttpDelete("/knowledge/vector/{collection}/snapshot")]
public async Task<bool> DeleteVectorCollectionSnapshots([FromRoute] string collection, [FromBody] DeleteVectorCollectionSnapshotRequest request)
{
var done = await _knowledgeService.DeleteVectorCollectionSnapshot(collection, request.SnapshotName);
return done;
}
#endregion
#region Document
[HttpPost("/knowledge/document/{collection}/upload")]
public async Task<UploadKnowledgeResponse> UploadKnowledgeDocuments([FromRoute] string collection, [FromBody] VectorKnowledgeUploadRequest request)
@ -187,7 +229,6 @@ public class KnowledgeBaseController : ControllerBase
#endregion
#region Graph
[HttpPost("/knowledge/graph/search")]
public async Task<GraphKnowledgeViewModel> SearchGraphKnowledge([FromBody] SearchGraphKnowledgeRequest request)
@ -214,4 +255,13 @@ public class KnowledgeBaseController : ControllerBase
return saved ? "Success" : "Fail";
}
#endregion
#region Private methods
private FileStreamResult BuildFileResult(string fileName, BinaryData fileData)
{
var stream = fileData.ToStream();
stream.Position = 0;
return File(stream, "application/octet-stream", Path.GetFileName(fileName));
}
#endregion
}

View file

@ -10,14 +10,11 @@ namespace BotSharp.OpenAPI.Controllers;
public class LoggerController : ControllerBase
{
private readonly IServiceProvider _services;
private readonly IUserIdentity _user;
public LoggerController(
IServiceProvider services,
IUserIdentity user)
IServiceProvider services)
{
_services = services;
_user = user;
}
[HttpGet("/logger/full-log")]
@ -40,17 +37,21 @@ public class LoggerController : ControllerBase
#region Conversation log
[HttpGet("/logger/conversation/{conversationId}/content-log")]
public async Task<List<ContentLogOutputModel>> GetConversationContentLogs([FromRoute] string conversationId)
public async Task<DateTimePagination<ContentLogOutputModel>> GetConversationContentLogs(
[FromRoute] string conversationId,
[FromQuery] ConversationLogFilter request)
{
var logging = _services.GetRequiredService<ILoggerService>();
return await logging.GetConversationContentLogs(conversationId);
return await logging.GetConversationContentLogs(conversationId, request);
}
[HttpGet("/logger/conversation/{conversationId}/state-log")]
public async Task<List<ConversationStateLogModel>> GetConversationStateLogs([FromRoute] string conversationId)
public async Task<DateTimePagination<ConversationStateLogModel>> GetConversationStateLogs(
[FromRoute] string conversationId,
[FromQuery] ConversationLogFilter request)
{
var logging = _services.GetRequiredService<ILoggerService>();
return await logging.GetConversationStateLogs(conversationId);
return await logging.GetConversationStateLogs(conversationId, request);
}
#endregion

View file

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace BotSharp.OpenAPI.ViewModels.Knowledges;
public class DeleteVectorCollectionSnapshotRequest
{
[JsonPropertyName("snapshot_name")]
public string SnapshotName { get; set; } = default!;
}

View file

@ -0,0 +1,35 @@
using BotSharp.Abstraction.VectorStorage.Models;
using System.Text.Json.Serialization;
namespace BotSharp.OpenAPI.ViewModels.Knowledges;
public class VectorCollectionSnapshotViewModel
{
[JsonPropertyName("name")]
public string Name { get; set; } = default!;
[JsonPropertyName("size")]
public long Size { get; set; }
[JsonPropertyName("created_time")]
public DateTime CreatedTime { get; set; }
[JsonPropertyName("check_sum")]
public string? CheckSum { get; set; }
public static VectorCollectionSnapshotViewModel? From(VectorCollectionSnapshot? model)
{
if (model == null)
{
return null;
}
return new VectorCollectionSnapshotViewModel
{
Name = model.Name,
Size = model.Size,
CreatedTime = model.CreatedTime,
CheckSum = model.CheckSum
};
}
}

View file

@ -1,19 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
namespace BotSharp.OpenAPI.ViewModels.Users;
public class UserDashboardModel
{
public class UserDashboardViewModel
{
[JsonPropertyName("conversation_list")]
public IList<UserDashboardConversationModel> ConversationList { get; set; } = [];
public IList<UserDashboardConversationViewModel> ConversationList { get; set; } = [];
}
public class UserDashboardConversationModel
public class UserDashboardConversationViewModel
{
[JsonPropertyName("name")]
public string? Name { get; set; }

View file

@ -10,7 +10,6 @@ public class ChatHubCrontabHook : ICrontabHook
private readonly IHubContext<SignalRHub> _chatHub;
private readonly ILogger<ChatHubCrontabHook> _logger;
private readonly IUserIdentity _user;
private readonly IConversationStorage _storage;
private readonly BotSharpOptions _options;
private readonly ChatHubSettings _settings;
@ -22,7 +21,6 @@ public class ChatHubCrontabHook : ICrontabHook
IHubContext<SignalRHub> chatHub,
ILogger<ChatHubCrontabHook> logger,
IUserIdentity user,
IConversationStorage storage,
BotSharpOptions options,
ChatHubSettings settings)
{
@ -30,7 +28,6 @@ public class ChatHubCrontabHook : ICrontabHook
_chatHub = chatHub;
_logger = logger;
_user = user;
_storage = storage;
_options = options;
_settings = settings;
}
@ -58,19 +55,8 @@ public class ChatHubCrontabHook : ICrontabHook
{
try
{
if (_settings.EventDispatchBy == EventDispatchType.Group)
{
await _chatHub.Clients.Group(item.ConversationId).SendAsync(GENERATE_NOTIFICATION, json);
}
else
{
await _chatHub.Clients.User(item.UserId).SendAsync(GENERATE_NOTIFICATION, json);
}
}
catch (Exception ex)
{
_logger.LogWarning($"Failed to send event in {nameof(ChatHubCrontabHook)} (conversation id: {item.ConversationId})." +
$"\r\n{ex.Message}\r\n{ex.InnerException}");
await _chatHub.Clients.User(item.UserId).SendAsync(GENERATE_NOTIFICATION, json);
}
catch { }
}
}

View file

@ -60,9 +60,9 @@ public class GraphDb : IGraphDb
using (var client = http.CreateClient())
{
var uri = new Uri(url);
try
{
var uri = new Uri(url);
var data = JsonSerializer.Serialize(request, _jsonOptions);
var message = new HttpRequestMessage
{

View file

@ -0,0 +1,64 @@
namespace BotSharp.Plugin.KnowledgeBase.Services;
public partial class KnowledgeService
{
public async Task<IEnumerable<VectorCollectionSnapshot>> GetVectorCollectionSnapshots(string collectionName)
{
if (string.IsNullOrWhiteSpace(collectionName))
{
return Enumerable.Empty<VectorCollectionSnapshot>();
}
var db = GetVectorDb();
var snapshots = await db.GetCollectionSnapshots(collectionName);
return snapshots;
}
public async Task<VectorCollectionSnapshot?> CreateVectorCollectionSnapshot(string collectionName)
{
if (string.IsNullOrWhiteSpace(collectionName))
{
return null;
}
var db = GetVectorDb();
var snapshot = await db.CreateCollectionShapshot(collectionName);
return snapshot;
}
public async Task<BinaryData> DownloadVectorCollectionSnapshot(string collectionName, string snapshotFileName)
{
if (string.IsNullOrWhiteSpace(collectionName) || string.IsNullOrWhiteSpace(snapshotFileName))
{
return BinaryData.Empty;
}
var db = GetVectorDb();
var snapshot = await db.DownloadCollectionSnapshot(collectionName, snapshotFileName);
return snapshot;
}
public async Task<bool> RecoverVectorCollectionFromSnapshot(string collectionName, string snapshotFileName, BinaryData snapshotData)
{
if (string.IsNullOrWhiteSpace(collectionName))
{
return false;
}
var db = GetVectorDb();
var done = await db.RecoverCollectionFromShapshot(collectionName, snapshotFileName, snapshotData);
return done;
}
public async Task<bool> DeleteVectorCollectionSnapshot(string collectionName, string snapshotName)
{
if (string.IsNullOrWhiteSpace(collectionName) || string.IsNullOrWhiteSpace(snapshotName))
{
return false;
}
var db = GetVectorDb();
var done = await db.DeleteCollectionShapshot(collectionName, snapshotName);
return done;
}
}

View file

@ -1,5 +1,6 @@
using BotSharp.Abstraction.Loggers.Models;
using BotSharp.Abstraction.Repositories.Filters;
using MongoDB.Driver;
using System.Text.Json;
namespace BotSharp.Plugin.MongoStorage.Repository;
@ -34,7 +35,8 @@ public partial class MongoRepository
{
if (log == null) return;
var found = _dc.Conversations.AsQueryable().FirstOrDefault(x => x.Id == log.ConversationId);
var filter = Builders<ConversationDocument>.Filter.Eq(x => x.Id, log.ConversationId);
var found = _dc.Conversations.Find(filter).FirstOrDefault();
if (found == null) return;
var logDoc = new ConversationContentLogDocument
@ -52,25 +54,36 @@ public partial class MongoRepository
_dc.ContentLogs.InsertOne(logDoc);
}
public List<ContentLogOutputModel> GetConversationContentLogs(string conversationId)
public DateTimePagination<ContentLogOutputModel> GetConversationContentLogs(string conversationId, ConversationLogFilter filter)
{
var logs = _dc.ContentLogs
.AsQueryable()
.Where(x => x.ConversationId == conversationId)
.Select(x => new ContentLogOutputModel
{
ConversationId = x.ConversationId,
MessageId = x.MessageId,
Name = x.Name,
AgentId = x.AgentId,
Role = x.Role,
Source = x.Source,
Content = x.Content,
CreatedTime = x.CreatedTime
})
.OrderBy(x => x.CreatedTime)
.ToList();
return logs;
var builder = Builders<ConversationContentLogDocument>.Filter;
var logFilters = new List<FilterDefinition<ConversationContentLogDocument>>
{
builder.Eq(x => x.ConversationId, conversationId),
builder.Lt(x => x.CreatedTime, filter.StartTime)
};
var logSortDef = Builders<ConversationContentLogDocument>.Sort.Descending(x => x.CreatedTime);
var docs = _dc.ContentLogs.Find(builder.And(logFilters)).Sort(logSortDef).Limit(filter.Size).ToList();
var logs = docs.Select(x => new ContentLogOutputModel
{
ConversationId = x.ConversationId,
MessageId = x.MessageId,
Name = x.Name,
AgentId = x.AgentId,
Role = x.Role,
Source = x.Source,
Content = x.Content,
CreatedTime = x.CreatedTime
}).ToList();
logs.Reverse();
return new DateTimePagination<ContentLogOutputModel>
{
Items = logs,
Count = logs.Count,
NextTime = logs.FirstOrDefault()?.CreatedTime
};
}
#endregion
@ -79,7 +92,8 @@ public partial class MongoRepository
{
if (log == null) return;
var found = _dc.Conversations.AsQueryable().FirstOrDefault(x => x.Id == log.ConversationId);
var filter = Builders<ConversationDocument>.Filter.Eq(x => x.Id, log.ConversationId);
var found = _dc.Conversations.Find(filter).FirstOrDefault();
if (found == null) return;
var logDoc = new ConversationStateLogDocument
@ -94,22 +108,33 @@ public partial class MongoRepository
_dc.StateLogs.InsertOne(logDoc);
}
public List<ConversationStateLogModel> GetConversationStateLogs(string conversationId)
public DateTimePagination<ConversationStateLogModel> GetConversationStateLogs(string conversationId, ConversationLogFilter filter)
{
var logs = _dc.StateLogs
.AsQueryable()
.Where(x => x.ConversationId == conversationId)
.Select(x => new ConversationStateLogModel
{
ConversationId = x.ConversationId,
AgentId = x.AgentId,
MessageId = x.MessageId,
States = x.States,
CreatedTime = x.CreatedTime
})
.OrderBy(x => x.CreatedTime)
.ToList();
return logs;
var builder = Builders<ConversationStateLogDocument>.Filter;
var logFilters = new List<FilterDefinition<ConversationStateLogDocument>>
{
builder.Eq(x => x.ConversationId, conversationId),
builder.Lt(x => x.CreatedTime, filter.StartTime)
};
var logSortDef = Builders<ConversationStateLogDocument>.Sort.Descending(x => x.CreatedTime);
var docs = _dc.StateLogs.Find(builder.And(logFilters)).Sort(logSortDef).Limit(filter.Size).ToList();
var logs = docs.Select(x => new ConversationStateLogModel
{
ConversationId = x.ConversationId,
AgentId = x.AgentId,
MessageId = x.MessageId,
States = x.States,
CreatedTime = x.CreatedTime
}).ToList();
logs.Reverse();
return new DateTimePagination<ConversationStateLogModel>
{
Items = logs,
Count = logs.Count,
NextTime = logs.FirstOrDefault()?.CreatedTime
};
}
#endregion

View file

@ -72,4 +72,11 @@ public class InputAudioTranscription
{
[JsonPropertyName("model")]
public string Model { get; set; } = null!;
[JsonPropertyName("language")]
public string Language { get; set; } = "en";
[JsonPropertyName("prompt")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? Prompt { get; set; }
}

View file

@ -2,7 +2,9 @@ using BotSharp.Abstraction.Conversations.Enums;
using BotSharp.Abstraction.Files.Utilities;
using BotSharp.Abstraction.Functions.Models;
using BotSharp.Abstraction.Options;
using BotSharp.Abstraction.Realtime;
using BotSharp.Abstraction.Realtime.Models;
using BotSharp.Abstraction.Routing;
using BotSharp.Core.Infrastructures;
using BotSharp.Plugin.OpenAI.Models.Realtime;
using OpenAI.Chat;
@ -42,7 +44,7 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
Action onModelReady,
Action<string,string> onModelAudioDeltaReceived,
Action onModelAudioResponseDone,
Action<string> onAudioTranscriptDone,
Action<string> onModelAudioTranscriptDone,
Action<List<RoleDialogModel>> onModelResponseDone,
Action<string> onConversationItemCreated,
Action<RoleDialogModel> onInputAudioTranscriptionCompleted,
@ -64,7 +66,7 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
onModelReady,
onModelAudioDeltaReceived,
onModelAudioResponseDone,
onAudioTranscriptDone,
onModelAudioTranscriptDone,
onModelResponseDone,
onConversationItemCreated,
onInputAudioTranscriptionCompleted,
@ -125,10 +127,10 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
Action onModelReady,
Action<string,string> onModelAudioDeltaReceived,
Action onModelAudioResponseDone,
Action<string> onAudioTranscriptDone,
Action<string> onModelAudioTranscriptDone,
Action<List<RoleDialogModel>> onModelResponseDone,
Action<string> onConversationItemCreated,
Action<RoleDialogModel> onInputAudioTranscriptionCompleted,
Action<RoleDialogModel> onUserAudioTranscriptionCompleted,
Action onUserInterrupted)
{
var buffer = new byte[1024 * 32];
@ -138,7 +140,7 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
{
result = await _webSocket.ReceiveAsync(
new ArraySegment<byte>(buffer), CancellationToken.None);
// Convert received data to text/audio (Twilio sends Base64-encoded audio)
string receivedText = Encoding.UTF8.GetString(buffer, 0, result.Count);
if (string.IsNullOrEmpty(receivedText))
@ -171,7 +173,7 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
_logger.LogInformation($"{response.Type}: {receivedText}");
var data = JsonSerializer.Deserialize<ResponseAudioTranscript>(receivedText);
await Task.Delay(1000);
onAudioTranscriptDone(data.Transcript);
onModelAudioTranscriptDone(data.Transcript);
}
else if (response.Type == "response.audio.delta")
{
@ -201,8 +203,11 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
else if (response.Type == "conversation.item.input_audio_transcription.completed")
{
_logger.LogInformation($"{response.Type}: {receivedText}");
var message = await OnInputAudioTranscriptionCompleted(conn, receivedText);
onInputAudioTranscriptionCompleted(message);
var message = await OnUserAudioTranscriptionCompleted(conn, receivedText);
if (!string.IsNullOrEmpty(message.Content))
{
onUserAudioTranscriptionCompleted(message);
}
}
else if (response.Type == "input_audio_buffer.speech_started")
{
@ -309,6 +314,9 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
return fn;
}).ToArray();
var words = new List<string>();
HookEmitter.Emit<IRealtimeHook>(_services, hook => words.AddRange(hook.OnModelTranscriptPrompt(agent)));
var sessionUpdate = new
{
type = "session.update",
@ -319,6 +327,8 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
InputAudioTranscription = new InputAudioTranscription
{
Model = "whisper-1",
Language = "en",
Prompt = string.Join(", ", words.Select(x => x.ToLower().Trim()).Distinct()).SubstringMax(1024)
},
Voice = "alloy",
Instructions = instruction,
@ -329,7 +339,7 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
MaxResponseOutputTokens = 512,
TurnDetection = new RealtimeSessionTurnDetection
{
Threshold = 0.8f,
Threshold = 0.9f,
PrefixPadding = 300,
SilenceDuration = 800
}
@ -662,7 +672,7 @@ public class RealTimeCompletionProvider : IRealTimeCompletion
return outputs;
}
public async Task<RoleDialogModel> OnInputAudioTranscriptionCompleted(RealtimeHubConnection conn, string response)
public async Task<RoleDialogModel> OnUserAudioTranscriptionCompleted(RealtimeHubConnection conn, string response)
{
var data = JsonSerializer.Deserialize<ResponseAudioTranscript>(response);
return new RoleDialogModel(AgentRole.User, data.Transcript)

View file

@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace BotSharp.Plugin.Qdrant.Models;
public class RecoverFromSnapshotResponse
{
[JsonPropertyName("time")]
public decimal Time { get; set; }
[JsonPropertyName("status")]
public string Status { get; set; }
[JsonPropertyName("result")]
public bool Result { get; set; }
}

View file

@ -1,8 +1,14 @@
using BotSharp.Abstraction.Options;
using BotSharp.Abstraction.Utilities;
using BotSharp.Abstraction.VectorStorage.Models;
using BotSharp.Plugin.Qdrant.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Qdrant.Client;
using Qdrant.Client.Grpc;
using System.Net.Http;
using System.Net.Mime;
using System.Text.Json;
namespace BotSharp.Plugin.Qdrant;
@ -10,15 +16,18 @@ public class QdrantDb : IVectorDb
{
private QdrantClient _client;
private readonly QdrantSetting _setting;
private readonly BotSharpOptions _options;
private readonly IServiceProvider _services;
private readonly ILogger<QdrantDb> _logger;
public QdrantDb(
QdrantSetting setting,
BotSharpOptions options,
ILogger<QdrantDb> logger,
IServiceProvider services)
{
_setting = setting;
_options = options;
_logger = logger;
_services = services;
}
@ -39,6 +48,7 @@ public class QdrantDb : IVectorDb
return _client;
}
#region Collection
public async Task<bool> DoesCollectionExist(string collectionName)
{
var client = GetClient();
@ -86,7 +96,9 @@ public class QdrantDb : IVectorDb
var collections = await GetClient().ListCollectionsAsync();
return collections.ToList();
}
#endregion
#region Collection data
public async Task<StringIdPagedItems<VectorCollectionData>> GetPagedCollectionData(string collectionName, VectorFilter filter)
{
var exist = await DoesCollectionExist(collectionName);
@ -332,4 +344,150 @@ public class QdrantDb : IVectorDb
var result = await client.DeleteAsync(collectionName, new Filter());
return result.Status == UpdateStatus.Completed;
}
#endregion
#region Snapshots
public async Task<IEnumerable<VectorCollectionSnapshot>> GetCollectionSnapshots(string collectionName)
{
var exist = await DoesCollectionExist(collectionName);
if (!exist)
{
return Enumerable.Empty<VectorCollectionSnapshot>();
}
var client = GetClient();
var data = await client.ListSnapshotsAsync(collectionName);
var snapshots = data.Select(x => new VectorCollectionSnapshot
{
Name = x.Name,
Size = x.Size,
CreatedTime = x.CreationTime.ToDateTime(),
CheckSum = x.Checksum
});
return snapshots;
}
public async Task<VectorCollectionSnapshot?> CreateCollectionShapshot(string collectionName)
{
var exist = await DoesCollectionExist(collectionName);
if (!exist)
{
return null;
}
var client = GetClient();
var desc = await client.CreateSnapshotAsync(collectionName);
if (desc == null)
{
return null;
}
return new VectorCollectionSnapshot
{
Name = desc.Name,
Size = desc.Size,
CreatedTime = desc.CreationTime.ToDateTime(),
CheckSum = desc.Checksum
};
}
public async Task<BinaryData> DownloadCollectionSnapshot(string collectionName, string snapshotFileName)
{
var exist = await DoesCollectionExist(collectionName);
if (!exist)
{
return BinaryData.Empty;
}
var domain = $"https://{_setting.Url}:6333";
var url = $"{domain}/collections/{collectionName}/snapshots/{snapshotFileName}";
var http = _services.GetRequiredService<IHttpClientFactory>();
using (var client = http.CreateClient())
{
try
{
var uri = new Uri(url);
var message = new HttpRequestMessage
{
Method = HttpMethod.Get,
RequestUri = uri
};
client.DefaultRequestHeaders.Add("api-key", _setting.ApiKey);
var rawResponse = await client.SendAsync(message);
rawResponse.EnsureSuccessStatusCode();
using var contentStream = await rawResponse.Content.ReadAsStreamAsync();
return BinaryData.FromStream(contentStream);
}
catch (Exception ex)
{
_logger.LogError($"Error when downloading Qdrant snapshot (Endpoint: {url}, Snapshot: {snapshotFileName}). {ex.Message}\r\n{ex.InnerException}");
return BinaryData.Empty;
}
}
}
public async Task<bool> RecoverCollectionFromShapshot(string collectionName, string snapshotFileName, BinaryData snapshotData)
{
var domain = $"https://{_setting.Url}:6333";
var url = $"{domain}/collections/{collectionName}/snapshots/upload";
var http = _services.GetRequiredService<IHttpClientFactory>();
using (var client = http.CreateClient())
{
try
{
var uri = new Uri(url);
var data = new MultipartFormDataContent
{
{ new StringContent(snapshotFileName), "name" },
{ new StringContent(MediaTypeNames.Application.Octet), "type" },
{ new StreamContent(snapshotData.ToStream()), "snapshot", snapshotFileName }
};
var message = new HttpRequestMessage
{
Method = HttpMethod.Post,
RequestUri = uri,
Content = data
};
client.DefaultRequestHeaders.Add("api-key", _setting.ApiKey);
var rawResponse = await client.SendAsync(message);
rawResponse.EnsureSuccessStatusCode();
var responseStr = await rawResponse.Content.ReadAsStringAsync();
var response = JsonSerializer.Deserialize<RecoverFromSnapshotResponse>(responseStr, _options.JsonSerializerOptions);
return response?.Result == true;
}
catch (Exception ex)
{
_logger.LogError($"Error when uploading Qdrant snapshot (Endpoint: {url}, Snapshot: {snapshotFileName}). {ex.Message}\r\n{ex.InnerException}");
return false;
}
}
}
public async Task<bool> DeleteCollectionShapshot(string collectionName, string snapshotName)
{
var exist = await DoesCollectionExist(collectionName);
if (!exist)
{
return false;
}
try
{
var client = GetClient();
await client.DeleteSnapshotAsync(collectionName, snapshotName);
return true;
}
catch
{
return false;
}
}
#endregion
}

View file

@ -1,4 +1,7 @@
using BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts;
using Microsoft.VisualBasic;
using Twilio.Rest.Api.V2010.Account;
using Task = System.Threading.Tasks.Task;
namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.Functions;
@ -20,6 +23,7 @@ public class HangupPhoneCallFn : IFunctionCallback
public async Task<bool> Execute(RoleDialogModel message)
{
var args = JsonSerializer.Deserialize<HangupPhoneCallArgs>(message.FunctionArgs);
var states = _services.GetRequiredService<IConversationStateService>();
var callSid = states.GetState("twilio_call_sid");
@ -30,14 +34,20 @@ public class HangupPhoneCallFn : IFunctionCallback
return false;
}
// Have to find the SID by the phone number
var call = CallResource.Update(
status: CallResource.UpdateStatusEnum.Completed,
pathSid: callSid
);
message.Content = args.GoodbyeMessage;
message.Content = "The call has ended.";
message.StopCompletion = true;
_ = Task.Run(async () =>
{
await Task.Delay(args.GoodbyeMessage.Split(' ').Length * 400);
// Have to find the SID by the phone number
var call = CallResource.Update(
status: CallResource.UpdateStatusEnum.Completed,
pathSid: callSid
);
message.Content = "The call has been ended.";
message.StopCompletion = true;
});
return true;
}

View file

@ -0,0 +1,9 @@
using System.Text.Json.Serialization;
namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts;
public class HangupPhoneCallArgs
{
[JsonPropertyName("goodbye_message")]
public string? GoodbyeMessage { get; set; }
}

View file

@ -5,8 +5,8 @@ namespace BotSharp.Plugin.Twilio.OutboundPhoneCallHandler.LlmContexts;
public class LlmContextIn
{
[JsonPropertyName("phone_number")]
public string PhoneNumber { get; set; }
public string PhoneNumber { get; set; } = null!;
[JsonPropertyName("initial_message")]
public string InitialMessage { get; set; }
public string? InitialMessage { get; set; }
}

View file

@ -5,7 +5,11 @@
"parameters": {
"type": "object",
"properties": {
"goodbye_message": {
"type": "string",
"description": "A polite closing statement for ending a conversation."
}
},
"required": []
"required": [ "goodbye_message" ]
}
}

View file

@ -1,3 +1,5 @@
using BotSharp.Abstraction.Browsing.Settings;
namespace BotSharp.Plugin.WebDriver.Drivers.PlaywrightDriver;
public partial class PlaywrightWebDriver
@ -5,19 +7,21 @@ public partial class PlaywrightWebDriver
public async Task<BrowserActionResult> ScreenshotAsync(MessageInfo message, string path)
{
var result = new BrowserActionResult();
await _instance.Wait(message.ContextId, waitNetworkIdle: false);
var page = _instance.GetPage(message.ContextId);
await Task.Delay(300);
var bytes = await page.ScreenshotAsync(new PageScreenshotOptions
var _webDriver = _services.GetRequiredService<WebBrowsingSettings>();
if (_webDriver.IsEnableScreenshot)
{
Path = path,
FullPage = true
});
await _instance.Wait(message.ContextId, waitNetworkIdle: false);
var page = _instance.GetPage(message.ContextId);
await Task.Delay(300);
var bytes = await page.ScreenshotAsync(new PageScreenshotOptions
{
Path = path,
FullPage = true
});
result.Body = "data:image/png;base64," + Convert.ToBase64String(bytes);
}
result.IsSuccess = true;
result.Body = "data:image/png;base64," + Convert.ToBase64String(bytes);
return result;
}
}

View file

@ -1,3 +1,5 @@
using BotSharp.Abstraction.Browsing.Settings;
namespace BotSharp.Plugin.WebDriver.Functions;
public class GoToPageFn : IFunctionCallback
@ -25,7 +27,7 @@ public class GoToPageFn : IFunctionCallback
var url = webDriverService.ReplaceToken(args.Url);
url = url.Replace("https://https://", "https://");
var _webDriver = _services.GetRequiredService<WebBrowsingSettings>();
var result = await _browser.GoToPage(new MessageInfo
{
AgentId = message.CurrentAgentId,
@ -33,7 +35,8 @@ public class GoToPageFn : IFunctionCallback
MessageId = message.MessageId
}, new PageActionArgs
{
Url = url
Url = url,
Timeout = _webDriver.DefaultTimeout
});
message.Content = result.IsSuccess ? $"Page {url} is open." : $"Page {url} open failed. {result.Message}";

View file

@ -26,7 +26,7 @@ public class OpenBrowserFn : IFunctionCallback
var webDriverService = _services.GetRequiredService<WebDriverService>();
var url = webDriverService.ReplaceToken(args.Url);
var _webDriver = _services.GetRequiredService<WebBrowsingSettings>();
url = url.Replace("https://https://", "https://");
var msgInfo = new MessageInfo
{
@ -40,7 +40,8 @@ public class OpenBrowserFn : IFunctionCallback
});
result = await _browser.GoToPage(msgInfo, new PageActionArgs
{
Url = url
Url = url,
Timeout = _webDriver.DefaultTimeout
});
if (result.IsSuccess)

View file

@ -1,3 +1,5 @@
using BotSharp.Abstraction.Browsing.Settings;
namespace BotSharp.Plugin.WebDriver.UtilFunctions;
public class UtilWebGoToPageFn : IFunctionCallback
@ -21,8 +23,10 @@ public class UtilWebGoToPageFn : IFunctionCallback
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
var _webDriver = _services.GetRequiredService<WebBrowsingSettings>();
args.Timeout = _webDriver.DefaultTimeout;
args.WaitForNetworkIdle = false;
args.WaitTime = 5;
args.WaitTime = _webDriver.DefaultWaitTime;
args.OpenNewTab = true;
var conv = _services.GetRequiredService<IConversationService>();