2025-03-06 18:01:43 +00:00
|
|
|
namespace BotSharp.Core.Realtime.Services;
|
2025-02-07 22:40:57 +00:00
|
|
|
|
|
|
|
|
public class RealtimeHub : IRealtimeHub
|
|
|
|
|
{
|
|
|
|
|
private readonly IServiceProvider _services;
|
|
|
|
|
private readonly ILogger _logger;
|
2025-02-27 04:40:50 +00:00
|
|
|
|
2025-03-06 21:10:17 +00:00
|
|
|
private RealtimeHubConnection _conn;
|
|
|
|
|
public RealtimeHubConnection HubConn => _conn;
|
|
|
|
|
|
|
|
|
|
private IRealTimeCompletion _completer;
|
|
|
|
|
public IRealTimeCompletion Completer => _completer;
|
|
|
|
|
|
2025-02-07 22:40:57 +00:00
|
|
|
public RealtimeHub(IServiceProvider services, ILogger<RealtimeHub> logger)
|
|
|
|
|
{
|
|
|
|
|
_services = services;
|
|
|
|
|
_logger = logger;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public async Task Listen(WebSocket userWebSocket,
|
2025-03-06 21:10:17 +00:00
|
|
|
Action<string> onUserMessageReceived)
|
2025-02-07 22:40:57 +00:00
|
|
|
{
|
2025-03-03 21:30:06 +00:00
|
|
|
var buffer = new byte[1024 * 16];
|
2025-02-07 22:40:57 +00:00
|
|
|
WebSocketReceiveResult result;
|
2025-03-06 21:10:17 +00:00
|
|
|
|
2025-02-07 22:40:57 +00:00
|
|
|
|
|
|
|
|
do
|
|
|
|
|
{
|
|
|
|
|
result = await userWebSocket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
|
|
|
|
|
string receivedText = Encoding.UTF8.GetString(buffer, 0, result.Count);
|
2025-03-03 21:30:06 +00:00
|
|
|
|
2025-02-07 22:40:57 +00:00
|
|
|
if (string.IsNullOrEmpty(receivedText))
|
|
|
|
|
{
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-06 21:10:17 +00:00
|
|
|
onUserMessageReceived(receivedText);
|
2025-02-09 23:31:46 +00:00
|
|
|
|
2025-03-06 21:10:17 +00:00
|
|
|
if (_conn.Event == "user_connected")
|
2025-02-07 22:40:57 +00:00
|
|
|
{
|
2025-03-06 21:10:17 +00:00
|
|
|
await ConnectToModel(userWebSocket);
|
2025-02-07 22:40:57 +00:00
|
|
|
}
|
2025-03-06 21:10:17 +00:00
|
|
|
else if (_conn.Event == "user_data_received")
|
2025-02-07 22:40:57 +00:00
|
|
|
{
|
2025-03-06 21:10:17 +00:00
|
|
|
await _completer.AppenAudioBuffer(_conn.Data);
|
2025-02-07 22:40:57 +00:00
|
|
|
}
|
2025-03-06 21:10:17 +00:00
|
|
|
else if (_conn.Event == "user_dtmf_received")
|
2025-03-05 22:57:34 +00:00
|
|
|
{
|
2025-03-06 21:10:17 +00:00
|
|
|
await HandleUserDtmfReceived();
|
2025-03-05 22:57:34 +00:00
|
|
|
}
|
2025-03-06 21:10:17 +00:00
|
|
|
else if (_conn.Event == "user_disconnected")
|
2025-02-07 22:40:57 +00:00
|
|
|
{
|
2025-03-06 21:10:17 +00:00
|
|
|
await _completer.Disconnect();
|
|
|
|
|
await HandleUserDisconnected();
|
2025-02-07 22:40:57 +00:00
|
|
|
}
|
|
|
|
|
} while (!result.CloseStatus.HasValue);
|
|
|
|
|
|
|
|
|
|
await userWebSocket.CloseAsync(result.CloseStatus.Value, result.CloseStatusDescription, CancellationToken.None);
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-06 21:10:17 +00:00
|
|
|
private async Task ConnectToModel(WebSocket userWebSocket)
|
2025-02-07 22:40:57 +00:00
|
|
|
{
|
2025-02-09 23:31:46 +00:00
|
|
|
var hookProvider = _services.GetRequiredService<ConversationHookProvider>();
|
|
|
|
|
var convService = _services.GetRequiredService<IConversationService>();
|
2025-03-06 21:10:17 +00:00
|
|
|
convService.SetConversationId(_conn.ConversationId, []);
|
|
|
|
|
var conversation = await convService.GetConversation(_conn.ConversationId);
|
2025-02-11 23:27:07 +00:00
|
|
|
|
2025-02-09 23:31:46 +00:00
|
|
|
var agentService = _services.GetRequiredService<IAgentService>();
|
|
|
|
|
var agent = await agentService.LoadAgent(conversation.AgentId);
|
2025-03-06 21:10:17 +00:00
|
|
|
_conn.CurrentAgentId = agent.Id;
|
2025-02-11 23:27:07 +00:00
|
|
|
|
2025-03-03 21:30:06 +00:00
|
|
|
// Set model
|
|
|
|
|
var model = agent.LlmConfig.Model;
|
|
|
|
|
if (!model.Contains("-realtime-"))
|
|
|
|
|
{
|
|
|
|
|
var llmProviderService = _services.GetRequiredService<ILlmProviderService>();
|
|
|
|
|
model = llmProviderService.GetProviderModel("openai", "gpt-4", realTime: true).Name;
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-06 21:10:17 +00:00
|
|
|
_completer.SetModelName(model);
|
|
|
|
|
_conn.Model = model;
|
2025-03-03 21:30:06 +00:00
|
|
|
|
2025-02-09 23:31:46 +00:00
|
|
|
var routing = _services.GetRequiredService<IRoutingService>();
|
2025-02-28 18:44:59 +00:00
|
|
|
routing.Context.Push(agent.Id);
|
|
|
|
|
|
2025-03-12 19:03:38 +00:00
|
|
|
var storage = _services.GetRequiredService<IConversationStorage>();
|
2025-02-09 23:31:46 +00:00
|
|
|
var dialogs = convService.GetDialogHistory();
|
2025-02-28 03:19:20 +00:00
|
|
|
if (dialogs.Count == 0)
|
|
|
|
|
{
|
|
|
|
|
dialogs.Add(new RoleDialogModel(AgentRole.User, "Hi"));
|
2025-03-12 19:03:38 +00:00
|
|
|
storage.Append(_conn.ConversationId, dialogs.First());
|
2025-02-28 03:19:20 +00:00
|
|
|
}
|
2025-03-12 19:03:38 +00:00
|
|
|
|
2025-02-09 23:31:46 +00:00
|
|
|
routing.Context.SetDialogs(dialogs);
|
2025-03-17 22:09:02 +00:00
|
|
|
routing.Context.SetMessageId(_conn.ConversationId, dialogs.Last().MessageId);
|
2025-02-09 23:31:46 +00:00
|
|
|
|
2025-03-13 00:20:30 +00:00
|
|
|
var states = _services.GetRequiredService<IConversationStateService>();
|
|
|
|
|
|
2025-03-06 21:10:17 +00:00
|
|
|
await _completer.Connect(_conn,
|
2025-02-09 23:31:46 +00:00
|
|
|
onModelReady: async () =>
|
|
|
|
|
{
|
2025-03-15 02:06:40 +00:00
|
|
|
// Not TriggerModelInference, waiting for user utter.
|
2025-03-15 14:29:46 +00:00
|
|
|
var instruction = await _completer.UpdateSession(_conn);
|
2025-03-13 00:20:30 +00:00
|
|
|
|
2025-03-15 02:06:40 +00:00
|
|
|
// Trigger model inference if there is no audio file in the conversation
|
|
|
|
|
if (!states.ContainsState("init_audio_file"))
|
|
|
|
|
{
|
2025-03-13 00:20:30 +00:00
|
|
|
if (dialogs.LastOrDefault()?.Role == AgentRole.Assistant)
|
|
|
|
|
{
|
|
|
|
|
await _completer.TriggerModelInference($"Rephase your last response:\r\n{dialogs.LastOrDefault()?.Content}");
|
|
|
|
|
}
|
|
|
|
|
else
|
|
|
|
|
{
|
|
|
|
|
await _completer.TriggerModelInference("Reply based on the conversation context.");
|
|
|
|
|
}
|
2025-02-10 23:28:03 +00:00
|
|
|
}
|
2025-03-15 14:29:46 +00:00
|
|
|
else
|
|
|
|
|
{
|
2025-03-18 20:14:36 +00:00
|
|
|
// Append dialogs into model context
|
|
|
|
|
var history = "[CONVERSATION HISTORY]\r\n";
|
2025-03-15 14:29:46 +00:00
|
|
|
foreach (var message in dialogs)
|
|
|
|
|
{
|
2025-03-18 20:14:36 +00:00
|
|
|
history += $"{message.Role}: {message.Content}\r\n";
|
2025-03-15 14:29:46 +00:00
|
|
|
}
|
|
|
|
|
|
2025-03-18 20:14:36 +00:00
|
|
|
await _completer.TriggerModelInference($"{instruction}\r\n\r\n{history}\r\n\r\nAssist user without repeating your previous statement.");
|
2025-03-15 14:29:46 +00:00
|
|
|
}
|
2025-02-09 23:31:46 +00:00
|
|
|
},
|
2025-03-06 09:03:30 +00:00
|
|
|
onModelAudioDeltaReceived: async (audioDeltaData, itemId) =>
|
2025-02-09 23:31:46 +00:00
|
|
|
{
|
2025-03-06 21:10:17 +00:00
|
|
|
var data = _conn.OnModelMessageReceived(audioDeltaData);
|
2025-02-09 23:31:46 +00:00
|
|
|
await SendEventToUser(userWebSocket, data);
|
2025-03-05 18:29:28 +00:00
|
|
|
|
2025-03-06 09:03:30 +00:00
|
|
|
// If this is the first delta of a new response, set the start timestamp
|
2025-03-06 21:10:17 +00:00
|
|
|
if (!_conn.ResponseStartTimestamp.HasValue)
|
2025-03-05 18:29:28 +00:00
|
|
|
{
|
2025-03-06 21:10:17 +00:00
|
|
|
_conn.ResponseStartTimestamp = _conn.LatestMediaTimestamp;
|
|
|
|
|
_logger.LogDebug($"Setting start timestamp for new response: {_conn.ResponseStartTimestamp}ms");
|
2025-03-06 09:03:30 +00:00
|
|
|
}
|
|
|
|
|
// Record last assistant item ID for interruption handling
|
|
|
|
|
if (!string.IsNullOrEmpty(itemId))
|
|
|
|
|
{
|
2025-03-06 21:10:17 +00:00
|
|
|
_conn.LastAssistantItemId = itemId;
|
2025-03-05 18:29:28 +00:00
|
|
|
}
|
2025-03-06 09:03:30 +00:00
|
|
|
|
|
|
|
|
// Send mark messages to Media Streams so we know if and when AI response playback is finished
|
2025-03-06 21:10:17 +00:00
|
|
|
await SendMark(userWebSocket, _conn);
|
2025-02-09 23:31:46 +00:00
|
|
|
},
|
|
|
|
|
onModelAudioResponseDone: async () =>
|
|
|
|
|
{
|
2025-03-06 21:10:17 +00:00
|
|
|
var data = _conn.OnModelAudioResponseDone();
|
2025-02-09 23:31:46 +00:00
|
|
|
await SendEventToUser(userWebSocket, data);
|
|
|
|
|
},
|
|
|
|
|
onAudioTranscriptDone: async transcript =>
|
|
|
|
|
{
|
|
|
|
|
|
|
|
|
|
},
|
2025-02-11 23:27:07 +00:00
|
|
|
onModelResponseDone: async messages =>
|
2025-02-09 23:31:46 +00:00
|
|
|
{
|
|
|
|
|
foreach (var message in messages)
|
|
|
|
|
{
|
|
|
|
|
// Invoke function
|
2025-03-06 21:10:17 +00:00
|
|
|
if (message.MessageType == MessageTypeName.FunctionCall &&
|
|
|
|
|
!string.IsNullOrEmpty(message.FunctionName))
|
2025-02-09 23:31:46 +00:00
|
|
|
{
|
|
|
|
|
await routing.InvokeFunction(message.FunctionName, message);
|
|
|
|
|
}
|
2025-03-05 18:29:28 +00:00
|
|
|
else
|
2025-03-05 13:48:06 +00:00
|
|
|
{
|
2025-03-05 18:29:28 +00:00
|
|
|
// append output audio transcript to conversation
|
|
|
|
|
dialogs.Add(message);
|
2025-03-12 19:03:38 +00:00
|
|
|
storage.Append(_conn.ConversationId, message);
|
2025-03-05 18:29:28 +00:00
|
|
|
|
|
|
|
|
foreach (var hook in hookProvider.HooksOrderByPriority)
|
|
|
|
|
{
|
|
|
|
|
hook.SetAgent(agent)
|
|
|
|
|
.SetConversation(conversation);
|
2025-02-11 23:27:07 +00:00
|
|
|
|
2025-03-05 18:29:28 +00:00
|
|
|
await hook.OnResponseGenerated(message);
|
|
|
|
|
}
|
2025-02-11 23:27:07 +00:00
|
|
|
}
|
2025-02-09 23:31:46 +00:00
|
|
|
}
|
|
|
|
|
},
|
2025-02-11 23:27:07 +00:00
|
|
|
onConversationItemCreated: async response =>
|
|
|
|
|
{
|
|
|
|
|
|
|
|
|
|
},
|
|
|
|
|
onInputAudioTranscriptionCompleted: async message =>
|
|
|
|
|
{
|
2025-03-01 02:42:03 +00:00
|
|
|
// append input audio transcript to conversation
|
2025-02-11 23:27:07 +00:00
|
|
|
dialogs.Add(message);
|
2025-03-12 19:03:38 +00:00
|
|
|
storage.Append(_conn.ConversationId, message);
|
2025-03-17 22:09:02 +00:00
|
|
|
routing.Context.SetMessageId(_conn.ConversationId, message.MessageId);
|
2025-03-01 02:42:03 +00:00
|
|
|
|
|
|
|
|
foreach (var hook in hookProvider.HooksOrderByPriority)
|
|
|
|
|
{
|
|
|
|
|
hook.SetAgent(agent)
|
|
|
|
|
.SetConversation(conversation);
|
|
|
|
|
|
|
|
|
|
await hook.OnMessageReceived(message);
|
|
|
|
|
}
|
2025-02-11 23:27:07 +00:00
|
|
|
},
|
2025-02-09 23:31:46 +00:00
|
|
|
onUserInterrupted: async () =>
|
|
|
|
|
{
|
2025-03-05 18:29:28 +00:00
|
|
|
// Reset states
|
2025-03-06 21:10:17 +00:00
|
|
|
_conn.ResetResponseState();
|
2025-03-05 18:29:28 +00:00
|
|
|
|
2025-03-06 21:10:17 +00:00
|
|
|
var data = _conn.OnModelUserInterrupted();
|
2025-02-09 23:31:46 +00:00
|
|
|
await SendEventToUser(userWebSocket, data);
|
|
|
|
|
});
|
2025-02-07 22:40:57 +00:00
|
|
|
}
|
|
|
|
|
|
2025-03-06 09:03:30 +00:00
|
|
|
private async Task SendMark(WebSocket userWebSocket, RealtimeHubConnection conn)
|
|
|
|
|
{
|
|
|
|
|
if (!string.IsNullOrEmpty(conn.StreamId))
|
|
|
|
|
{
|
|
|
|
|
var markEvent = new
|
|
|
|
|
{
|
|
|
|
|
@event = "mark",
|
|
|
|
|
streamSid = conn.StreamId,
|
|
|
|
|
mark = new { name = "responsePart" }
|
|
|
|
|
};
|
|
|
|
|
await SendEventToUser(userWebSocket, markEvent);
|
|
|
|
|
conn.MarkQueue.Enqueue("responsePart");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-06 21:10:17 +00:00
|
|
|
private async Task HandleUserDtmfReceived()
|
2025-03-05 22:57:34 +00:00
|
|
|
{
|
|
|
|
|
var routing = _services.GetRequiredService<IRoutingService>();
|
|
|
|
|
var hookProvider = _services.GetRequiredService<ConversationHookProvider>();
|
|
|
|
|
var agentService = _services.GetRequiredService<IAgentService>();
|
2025-03-06 21:10:17 +00:00
|
|
|
var agent = await agentService.LoadAgent(_conn.CurrentAgentId);
|
2025-03-05 22:57:34 +00:00
|
|
|
var dialogs = routing.Context.GetDialogs();
|
|
|
|
|
var convService = _services.GetRequiredService<IConversationService>();
|
2025-03-06 21:10:17 +00:00
|
|
|
var conversation = await convService.GetConversation(_conn.ConversationId);
|
2025-03-05 22:57:34 +00:00
|
|
|
|
2025-03-06 21:10:17 +00:00
|
|
|
var message = new RoleDialogModel(AgentRole.User, _conn.Data)
|
2025-03-05 22:57:34 +00:00
|
|
|
{
|
|
|
|
|
CurrentAgentId = routing.Context.GetCurrentAgentId()
|
|
|
|
|
};
|
|
|
|
|
dialogs.Add(message);
|
|
|
|
|
|
2025-03-12 19:03:38 +00:00
|
|
|
var storage = _services.GetRequiredService<IConversationStorage>();
|
|
|
|
|
storage.Append(_conn.ConversationId, message);
|
|
|
|
|
|
2025-03-05 22:57:34 +00:00
|
|
|
foreach (var hook in hookProvider.HooksOrderByPriority)
|
|
|
|
|
{
|
|
|
|
|
hook.SetAgent(agent)
|
|
|
|
|
.SetConversation(conversation);
|
|
|
|
|
|
|
|
|
|
await hook.OnMessageReceived(message);
|
|
|
|
|
}
|
|
|
|
|
|
2025-03-06 21:10:17 +00:00
|
|
|
await _completer.InsertConversationItem(message);
|
2025-03-12 01:57:49 +00:00
|
|
|
var instruction = await _completer.UpdateSession(_conn);
|
|
|
|
|
await _completer.TriggerModelInference($"{instruction}\r\n\r\nReply based on the user input: {message.Content}");
|
2025-03-05 22:57:34 +00:00
|
|
|
}
|
|
|
|
|
|
2025-03-06 21:10:17 +00:00
|
|
|
private async Task HandleUserDisconnected()
|
2025-03-05 22:57:34 +00:00
|
|
|
{
|
2025-03-14 18:59:13 +00:00
|
|
|
|
2025-03-05 22:57:34 +00:00
|
|
|
}
|
|
|
|
|
|
2025-02-09 23:31:46 +00:00
|
|
|
private async Task SendEventToUser(WebSocket webSocket, object message)
|
2025-02-07 22:40:57 +00:00
|
|
|
{
|
|
|
|
|
var data = JsonSerializer.Serialize(message);
|
|
|
|
|
var buffer = Encoding.UTF8.GetBytes(data);
|
|
|
|
|
await webSocket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, CancellationToken.None);
|
|
|
|
|
}
|
2025-03-06 21:10:17 +00:00
|
|
|
|
|
|
|
|
public RealtimeHubConnection SetHubConnection(string conversationId)
|
|
|
|
|
{
|
|
|
|
|
_conn = new RealtimeHubConnection
|
|
|
|
|
{
|
|
|
|
|
ConversationId = conversationId
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return _conn;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public IRealTimeCompletion SetCompleter(string provider)
|
|
|
|
|
{
|
|
|
|
|
_completer = _services.GetServices<IRealTimeCompletion>().First(x => x.Provider == provider);
|
|
|
|
|
return _completer;
|
|
|
|
|
}
|
2025-02-07 22:40:57 +00:00
|
|
|
}
|