Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ba9f0cf5e | |||
| ba709d15d5 | |||
| 9023509b01 | |||
| 4dc48bd9fd | |||
| 78373b0912 | |||
| 8f53f9daa9 | |||
| a45b25038a | |||
| 7dada5fdb9 | |||
| b078f9aa78 | |||
| 24b8acdf60 | |||
| a202725c39 | |||
| 8f197d1203 | |||
| 35a6e8febb | |||
| d3e101eb29 | |||
| 212ce06f07 | |||
| ebbe23c639 | |||
| 9325d21f9e | |||
| 13d8773704 | |||
| 99cfe60a2c |
@@ -1,7 +1,11 @@
|
|||||||
|
using backend.Realtime;
|
||||||
using Microsoft.AspNetCore.Hosting;
|
using Microsoft.AspNetCore.Hosting;
|
||||||
using Microsoft.AspNetCore.Mvc.Testing;
|
using Microsoft.AspNetCore.Mvc.Testing;
|
||||||
|
using Microsoft.AspNetCore.TestHost;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace backend.tests;
|
namespace backend.tests;
|
||||||
|
|
||||||
@@ -17,8 +21,14 @@ public class ApiFactory : WebApplicationFactory<Program>
|
|||||||
cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
cfg.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["ConnectionStrings:Default"] = $"Data Source={_dbPath}",
|
["ConnectionStrings:Default"] = $"Data Source={_dbPath}",
|
||||||
|
["OPENAI_API_KEY"] = "test-key",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.ConfigureTestServices(services =>
|
||||||
|
{
|
||||||
|
services.AddScoped<RealtimeSessionFactory, FakeRealtimeSessionFactory>();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void Dispose(bool disposing)
|
protected override void Dispose(bool disposing)
|
||||||
@@ -32,3 +42,24 @@ public class ApiFactory : WebApplicationFactory<Program>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class FakeRealtimeSessionFactory(
|
||||||
|
OpenAIKeyProvider keys,
|
||||||
|
backend.Tools.ToolRegistry registry,
|
||||||
|
backend.Conversations.ConversationLog log,
|
||||||
|
ILoggerFactory loggerFactory)
|
||||||
|
: RealtimeSessionFactory(keys, registry, log, loggerFactory)
|
||||||
|
{
|
||||||
|
public static ScriptedRealtimeUpstream Upstream { get; set; } = new();
|
||||||
|
|
||||||
|
public override Task<RealtimeSession> CreateAsync(
|
||||||
|
Guid deviceId, RealtimeSettings cfg, ISessionOutput output,
|
||||||
|
backend.Tools.IDeviceChannel channel, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Upstream.Push(new System.Text.Json.Nodes.JsonObject { ["type"] = "session.updated" });
|
||||||
|
var s = new RealtimeSession(
|
||||||
|
deviceId, Upstream, output, log, registry, cfg, channel,
|
||||||
|
loggerFactory.CreateLogger<RealtimeSession>());
|
||||||
|
return Task.FromResult(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using backend.Conversations;
|
||||||
|
using backend.Data;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class ConversationLogTests(ApiFactory factory) : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory = factory;
|
||||||
|
|
||||||
|
private async Task<(AppDbContext db, ConversationLog log, Guid deviceId)> ArrangeAsync()
|
||||||
|
{
|
||||||
|
var scope = _factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
|
||||||
|
var deviceId = Guid.NewGuid();
|
||||||
|
return (db, log, deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Start_creates_conversation_row()
|
||||||
|
{
|
||||||
|
var (db, log, deviceId) = await ArrangeAsync();
|
||||||
|
|
||||||
|
var convo = await log.StartAsync(deviceId, default);
|
||||||
|
|
||||||
|
var row = await db.Conversations.FirstAsync(c => c.Id == convo.Id);
|
||||||
|
row.DeviceId.Should().Be(deviceId);
|
||||||
|
row.EndedAt.Should().BeNull();
|
||||||
|
row.EndReason.Should().Be(EndReason.Unknown);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Turns_are_persisted_with_monotonic_index_per_conversation()
|
||||||
|
{
|
||||||
|
var (db, log, deviceId) = await ArrangeAsync();
|
||||||
|
var convo = await log.StartAsync(deviceId, default);
|
||||||
|
|
||||||
|
await log.AppendUserAsync(convo.Id, "hi", default);
|
||||||
|
await log.AppendAssistantAsync(convo.Id, "hello there", default);
|
||||||
|
await log.AppendToolAsync(convo.Id, "get_current_time", "{}", "{\"now\":\"2026-06-11T00:00:00Z\"}", default);
|
||||||
|
|
||||||
|
var turns = await db.Turns
|
||||||
|
.Where(t => t.ConversationId == convo.Id)
|
||||||
|
.OrderBy(t => t.Index)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
turns.Should().HaveCount(3);
|
||||||
|
turns.Select(t => t.Index).Should().Equal(0, 1, 2);
|
||||||
|
turns.Select(t => t.Role).Should().Equal(TurnRole.User, TurnRole.Assistant, TurnRole.Tool);
|
||||||
|
turns[0].Text.Should().Be("hi");
|
||||||
|
turns[1].Text.Should().Be("hello there");
|
||||||
|
turns[2].ToolName.Should().Be("get_current_time");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task End_sets_ended_at_and_reason()
|
||||||
|
{
|
||||||
|
var (db, log, deviceId) = await ArrangeAsync();
|
||||||
|
var convo = await log.StartAsync(deviceId, default);
|
||||||
|
|
||||||
|
await log.EndAsync(convo.Id, EndReason.Idle, default);
|
||||||
|
|
||||||
|
var row = await db.Conversations.FirstAsync(c => c.Id == convo.Id);
|
||||||
|
row.EndedAt.Should().NotBeNull();
|
||||||
|
row.EndReason.Should().Be(EndReason.Idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class DeviceHubHandshakeTests(ApiFactory factory) : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory = factory;
|
||||||
|
|
||||||
|
private async Task<string> PairAndGetTokenAsync(string email)
|
||||||
|
{
|
||||||
|
var http = _factory.CreateClient();
|
||||||
|
await http.PostAsJsonAsync("/api/auth/register",
|
||||||
|
new { email, password = "Passw0rd!" });
|
||||||
|
await http.PostAsJsonAsync("/api/auth/login",
|
||||||
|
new { email, password = "Passw0rd!" });
|
||||||
|
var codeRes = await http.PostAsJsonAsync("/api/pair-code", new { name = "ws-test" });
|
||||||
|
var code = (await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>())!["code"].ToString()!;
|
||||||
|
|
||||||
|
var pairRes = await _factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||||
|
new { code, hostname = "smoke", client_version = "0.1" });
|
||||||
|
var pairBody = await pairRes.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||||
|
return pairBody!["device_token"].ToString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Connect_without_token_fails_upgrade()
|
||||||
|
{
|
||||||
|
var client = _factory.Server.CreateWebSocketClient();
|
||||||
|
var uri = new Uri(_factory.Server.BaseAddress, "device");
|
||||||
|
var act = async () => await client.ConnectAsync(uri, CancellationToken.None);
|
||||||
|
await act.Should().ThrowAsync<Exception>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Hello_ack_includes_config()
|
||||||
|
{
|
||||||
|
var token = await PairAndGetTokenAsync("ws1@example.com");
|
||||||
|
var client = _factory.Server.CreateWebSocketClient();
|
||||||
|
client.ConfigureRequest = req => req.Headers["Authorization"] = $"Bearer {token}";
|
||||||
|
|
||||||
|
var uri = new Uri(_factory.Server.BaseAddress, "device");
|
||||||
|
var ws = await client.ConnectAsync(uri, CancellationToken.None);
|
||||||
|
|
||||||
|
await SendJson(ws, new { type = "hello", device_id = Guid.NewGuid(), client_version = "0.1" });
|
||||||
|
var ack = await ReceiveJson(ws);
|
||||||
|
ack.GetProperty("type").GetString().Should().Be("hello_ack");
|
||||||
|
ack.GetProperty("config").GetProperty("voice").GetString().Should().NotBeNullOrEmpty();
|
||||||
|
ack.GetProperty("config").GetProperty("model").GetString().Should().NotBeNullOrEmpty();
|
||||||
|
ack.GetProperty("config").GetProperty("enabled_tools").GetArrayLength().Should().BeGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Ping_replies_with_pong()
|
||||||
|
{
|
||||||
|
var token = await PairAndGetTokenAsync("ws2@example.com");
|
||||||
|
var client = _factory.Server.CreateWebSocketClient();
|
||||||
|
client.ConfigureRequest = req => req.Headers["Authorization"] = $"Bearer {token}";
|
||||||
|
var uri = new Uri(_factory.Server.BaseAddress, "device");
|
||||||
|
var ws = await client.ConnectAsync(uri, CancellationToken.None);
|
||||||
|
|
||||||
|
await SendJson(ws, new { type = "hello", device_id = Guid.NewGuid(), client_version = "0.1" });
|
||||||
|
await ReceiveJson(ws);
|
||||||
|
await SendJson(ws, new { type = "ping" });
|
||||||
|
var pong = await ReceiveJson(ws);
|
||||||
|
pong.GetProperty("type").GetString().Should().Be("pong");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendJson(WebSocket ws, object payload)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
|
||||||
|
await ws.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> ReceiveJson(WebSocket ws)
|
||||||
|
{
|
||||||
|
var buf = new byte[8192];
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var r = await ws.ReceiveAsync(buf, CancellationToken.None);
|
||||||
|
stream.Write(buf, 0, r.Count);
|
||||||
|
if (r.EndOfMessage) break;
|
||||||
|
}
|
||||||
|
return JsonDocument.Parse(stream.ToArray()).RootElement.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
[Collection("WakeFlow")] // tests touch FakeRealtimeSessionFactory.Upstream static — serialize
|
||||||
|
public class DeviceHubWakeFlowTests(ApiFactory factory) : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory = factory;
|
||||||
|
|
||||||
|
private async Task<string> PairAndGetTokenAsync(string email)
|
||||||
|
{
|
||||||
|
var http = _factory.CreateClient();
|
||||||
|
await http.PostAsJsonAsync("/api/auth/register",
|
||||||
|
new { email, password = "Passw0rd!" });
|
||||||
|
await http.PostAsJsonAsync("/api/auth/login",
|
||||||
|
new { email, password = "Passw0rd!" });
|
||||||
|
var codeRes = await http.PostAsJsonAsync("/api/pair-code", new { name = "wake-test" });
|
||||||
|
var code = (await codeRes.Content.ReadFromJsonAsync<Dictionary<string, object>>())!["code"].ToString()!;
|
||||||
|
var pairRes = await _factory.CreateClient().PostAsJsonAsync("/api/pair",
|
||||||
|
new { code, hostname = "smoke", client_version = "0.1" });
|
||||||
|
var pairBody = await pairRes.Content.ReadFromJsonAsync<Dictionary<string, object>>();
|
||||||
|
return pairBody!["device_token"].ToString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Wake_opens_session_started_then_assistant_done_then_session_ended_on_cancel()
|
||||||
|
{
|
||||||
|
FakeRealtimeSessionFactory.Upstream = new ScriptedRealtimeUpstream();
|
||||||
|
var token = await PairAndGetTokenAsync("wake1@example.com");
|
||||||
|
var client = _factory.Server.CreateWebSocketClient();
|
||||||
|
client.ConfigureRequest = req => req.Headers["Authorization"] = $"Bearer {token}";
|
||||||
|
var uri = new Uri(_factory.Server.BaseAddress, "device");
|
||||||
|
var ws = await client.ConnectAsync(uri, CancellationToken.None);
|
||||||
|
|
||||||
|
await SendJson(ws, new { type = "hello", device_id = Guid.NewGuid(), client_version = "0.1" });
|
||||||
|
await ReceiveJson(ws); // ack
|
||||||
|
|
||||||
|
await SendJson(ws, new { type = "wake", at = 0L });
|
||||||
|
var started = await ReceiveJson(ws);
|
||||||
|
started.GetProperty("type").GetString().Should().Be("session_started");
|
||||||
|
|
||||||
|
FakeRealtimeSessionFactory.Upstream.Push(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "response.audio_transcript.done",
|
||||||
|
["transcript"] = "hello!",
|
||||||
|
});
|
||||||
|
FakeRealtimeSessionFactory.Upstream.Push(new JsonObject { ["type"] = "response.done" });
|
||||||
|
|
||||||
|
var done = await ReceiveJson(ws);
|
||||||
|
done.GetProperty("type").GetString().Should().Be("assistant_done");
|
||||||
|
|
||||||
|
await SendJson(ws, new { type = "cancel" });
|
||||||
|
var ended = await ReceiveJson(ws);
|
||||||
|
ended.GetProperty("type").GetString().Should().Be("session_ended");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Binary_uplink_after_wake_reaches_upstream_as_input_audio_buffer_append()
|
||||||
|
{
|
||||||
|
FakeRealtimeSessionFactory.Upstream = new ScriptedRealtimeUpstream();
|
||||||
|
var token = await PairAndGetTokenAsync("wake2@example.com");
|
||||||
|
var client = _factory.Server.CreateWebSocketClient();
|
||||||
|
client.ConfigureRequest = req => req.Headers["Authorization"] = $"Bearer {token}";
|
||||||
|
var uri = new Uri(_factory.Server.BaseAddress, "device");
|
||||||
|
var ws = await client.ConnectAsync(uri, CancellationToken.None);
|
||||||
|
|
||||||
|
await SendJson(ws, new { type = "hello", device_id = Guid.NewGuid(), client_version = "0.1" });
|
||||||
|
await ReceiveJson(ws);
|
||||||
|
await SendJson(ws, new { type = "wake", at = 0L });
|
||||||
|
await ReceiveJson(ws); // session_started
|
||||||
|
|
||||||
|
var frame = new byte[3840];
|
||||||
|
for (int i = 0; i < frame.Length; i++) frame[i] = 0x55;
|
||||||
|
await ws.SendAsync(frame, WebSocketMessageType.Binary, true, CancellationToken.None);
|
||||||
|
|
||||||
|
// Allow time for the pump's 250 ms poll + send.
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
if (FakeRealtimeSessionFactory.Upstream.Sent.Any(e =>
|
||||||
|
(string?)e["type"] == "input_audio_buffer.append")) break;
|
||||||
|
await Task.Delay(50);
|
||||||
|
}
|
||||||
|
FakeRealtimeSessionFactory.Upstream.Sent
|
||||||
|
.Any(e => (string?)e["type"] == "input_audio_buffer.append")
|
||||||
|
.Should().BeTrue();
|
||||||
|
|
||||||
|
await SendJson(ws, new { type = "cancel" });
|
||||||
|
await ReceiveJson(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendJson(WebSocket ws, object payload)
|
||||||
|
{
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
|
||||||
|
await ws.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> ReceiveJson(WebSocket ws)
|
||||||
|
{
|
||||||
|
var buf = new byte[16384];
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var r = await ws.ReceiveAsync(buf, CancellationToken.None);
|
||||||
|
if (r.MessageType == WebSocketMessageType.Binary) { stream.Write(buf, 0, r.Count); continue; }
|
||||||
|
stream.Write(buf, 0, r.Count);
|
||||||
|
if (r.EndOfMessage) break;
|
||||||
|
}
|
||||||
|
return JsonDocument.Parse(stream.ToArray()).RootElement.Clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
|
using backend.DeviceHub;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class DeviceRegistryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Register_unregister_round_trip()
|
||||||
|
{
|
||||||
|
var reg = new DeviceRegistry();
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
var dev = new ActiveDevice(id, new ClientWebSocket());
|
||||||
|
reg.TryRegister(dev).Should().BeTrue();
|
||||||
|
reg.TryRegister(dev).Should().BeFalse();
|
||||||
|
reg.IsOnline(id).Should().BeTrue();
|
||||||
|
reg.OnlineCount.Should().Be(1);
|
||||||
|
|
||||||
|
reg.Unregister(id).Should().BeTrue();
|
||||||
|
reg.IsOnline(id).Should().BeFalse();
|
||||||
|
reg.OnlineCount.Should().Be(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using backend.Tools;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class EndSessionToolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Returns_ok_true()
|
||||||
|
{
|
||||||
|
var tool = new EndSessionTool();
|
||||||
|
var ctx = new DeviceContext(Guid.NewGuid(), Guid.NewGuid(), new NoopChannel());
|
||||||
|
|
||||||
|
var res = await tool.ExecuteAsync(
|
||||||
|
new ToolInvocation("call_1", JsonDocument.Parse("{}").RootElement), ctx, default);
|
||||||
|
|
||||||
|
res.Output.GetProperty("ok").GetBoolean().Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Name_and_runs_during_response_match_spec()
|
||||||
|
{
|
||||||
|
var tool = new EndSessionTool();
|
||||||
|
tool.Name.Should().Be("end_session");
|
||||||
|
tool.RunsDuringResponse.Should().BeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NoopChannel : IDeviceChannel
|
||||||
|
{
|
||||||
|
public Task<JsonElement> CallPiToolAsync(string n, JsonElement a, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
using backend.DeviceHub;
|
||||||
|
using backend.Tools;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class FakeDeviceChannel : IDeviceChannel, IDeviceClient
|
||||||
|
{
|
||||||
|
public List<HubEnvelope> SentEnvelopes { get; } = new();
|
||||||
|
public List<byte[]> SentBinary { get; } = new();
|
||||||
|
public ConcurrentQueue<JsonElement> PiToolReplies { get; } = new();
|
||||||
|
|
||||||
|
public Task SendEnvelopeAsync<T>(T envelope, CancellationToken ct) where T : HubEnvelope
|
||||||
|
{
|
||||||
|
SentEnvelopes.Add(envelope);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SendBinaryAsync(ReadOnlyMemory<byte> bytes, CancellationToken ct)
|
||||||
|
{
|
||||||
|
SentBinary.Add(bytes.ToArray());
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<JsonElement> CallPiToolAsync(string name, JsonElement args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (PiToolReplies.TryDequeue(out var reply))
|
||||||
|
return Task.FromResult(reply);
|
||||||
|
return Task.FromResult(JsonDocument.Parse("""{"ok":true}""").RootElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using backend.Tools;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class GetCurrentTimeToolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Returns_now_field_with_iso_utc_timestamp()
|
||||||
|
{
|
||||||
|
var tool = new GetCurrentTimeTool();
|
||||||
|
var args = JsonDocument.Parse("{}").RootElement;
|
||||||
|
var ctx = new DeviceContext(Guid.NewGuid(), Guid.NewGuid(), new NoopChannel());
|
||||||
|
|
||||||
|
var res = await tool.ExecuteAsync(new ToolInvocation("call_1", args), ctx, default);
|
||||||
|
|
||||||
|
res.Output.TryGetProperty("now", out var now).Should().BeTrue();
|
||||||
|
now.GetString().Should().MatchRegex(@"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}");
|
||||||
|
now.GetString()!.Should().EndWith("Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
private class NoopChannel : IDeviceChannel
|
||||||
|
{
|
||||||
|
public Task<JsonElement> CallPiToolAsync(string name, JsonElement args, CancellationToken ct)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using backend.Conversations;
|
||||||
|
using backend.Realtime;
|
||||||
|
using backend.Tools;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class RealtimeSessionAudioTests(ApiFactory factory) : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Uplink_bytes_become_input_audio_buffer_append()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
|
||||||
|
var reg = new ToolRegistry(Array.Empty<ITool>());
|
||||||
|
var upstream = new ScriptedRealtimeUpstream();
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var cfg = new RealtimeSettings("m", "v", "p", 30, new HashSet<string>());
|
||||||
|
var session = new RealtimeSession(Guid.NewGuid(), upstream, sink, log, reg, cfg,
|
||||||
|
new FakeDeviceChannel(), NullLogger.Instance);
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "session.updated" });
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var run = session.RunAsync(cts.Token);
|
||||||
|
await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
var frame = new byte[3840];
|
||||||
|
for (int i = 0; i < frame.Length; i++) frame[i] = (byte)(i % 256);
|
||||||
|
await session.WriteUplinkAsync(frame, default);
|
||||||
|
|
||||||
|
await Task.Delay(100);
|
||||||
|
|
||||||
|
var appended = upstream.Sent
|
||||||
|
.FirstOrDefault(e => (string?)e["type"] == "input_audio_buffer.append");
|
||||||
|
appended.Should().NotBeNull();
|
||||||
|
var b64 = (string?)appended!["audio"];
|
||||||
|
Convert.FromBase64String(b64!).Length.Should().Be(3840);
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
await run;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Audio_delta_from_upstream_becomes_binary_to_device()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
|
||||||
|
var reg = new ToolRegistry(Array.Empty<ITool>());
|
||||||
|
var upstream = new ScriptedRealtimeUpstream();
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var cfg = new RealtimeSettings("m", "v", "p", 30, new HashSet<string>());
|
||||||
|
var session = new RealtimeSession(Guid.NewGuid(), upstream, sink, log, reg, cfg,
|
||||||
|
new FakeDeviceChannel(), NullLogger.Instance);
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "session.updated" });
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var run = session.RunAsync(cts.Token);
|
||||||
|
await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
var payload = new byte[3840];
|
||||||
|
for (int i = 0; i < payload.Length; i++) payload[i] = (byte)((i + 1) % 256);
|
||||||
|
upstream.Push(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "response.audio.delta",
|
||||||
|
["delta"] = Convert.ToBase64String(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
await Task.Delay(100);
|
||||||
|
sink.Binary.Should().NotBeEmpty();
|
||||||
|
sink.Binary[0].Length.Should().Be(3840);
|
||||||
|
sink.Binary[0][0].Should().Be(1);
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
await run;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using backend.Conversations;
|
||||||
|
using backend.Realtime;
|
||||||
|
using backend.Tools;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class RealtimeSessionEndSessionTests(ApiFactory factory) : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task End_session_call_closes_session_with_reason_tool_and_skips_response_create()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
|
||||||
|
var reg = new ToolRegistry(new ITool[] { new EndSessionTool() });
|
||||||
|
var upstream = new ScriptedRealtimeUpstream();
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var cfg = new RealtimeSettings("m", "v", "p", 30, new HashSet<string> { "end_session" });
|
||||||
|
var session = new RealtimeSession(Guid.NewGuid(), upstream, sink, log, reg, cfg,
|
||||||
|
new FakeDeviceChannel(), NullLogger.Instance);
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "session.updated" });
|
||||||
|
var run = session.RunAsync(default);
|
||||||
|
await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "response.audio.delta",
|
||||||
|
["delta"] = Convert.ToBase64String(new byte[3840]),
|
||||||
|
});
|
||||||
|
upstream.Push(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "response.function_call_arguments.done",
|
||||||
|
["call_id"] = "c1",
|
||||||
|
["name"] = "end_session",
|
||||||
|
["arguments"] = "{}",
|
||||||
|
});
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "response.done" });
|
||||||
|
|
||||||
|
await run;
|
||||||
|
|
||||||
|
sink.Binary.Should().NotBeEmpty();
|
||||||
|
|
||||||
|
var ended = sink.Envelopes.Last(e => (string?)e["type"] == "session_ended");
|
||||||
|
((string?)ended["reason"]).Should().Be("tool");
|
||||||
|
|
||||||
|
upstream.Sent.Should().NotContain(e => (string?)e["type"] == "response.create");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using backend.Conversations;
|
||||||
|
using backend.Realtime;
|
||||||
|
using backend.Tools;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class RealtimeSessionIdleTimeoutTests(ApiFactory factory) : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Session_ends_with_reason_idle_when_no_speech_for_timeout()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
|
||||||
|
var reg = new ToolRegistry(Array.Empty<ITool>());
|
||||||
|
var upstream = new ScriptedRealtimeUpstream();
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var cfg = new RealtimeSettings("m", "v", "p", IdleTimeoutSeconds: 1, new HashSet<string>());
|
||||||
|
var session = new RealtimeSession(Guid.NewGuid(), upstream, sink, log, reg, cfg,
|
||||||
|
new FakeDeviceChannel(), NullLogger.Instance);
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "session.updated" });
|
||||||
|
var run = session.RunAsync(default);
|
||||||
|
await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
await run.WaitAsync(TimeSpan.FromSeconds(4));
|
||||||
|
|
||||||
|
var ended = sink.Envelopes.Last(e => (string?)e["type"] == "session_ended");
|
||||||
|
((string?)ended["reason"]).Should().Be("idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Speech_started_resets_the_idle_timer()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
|
||||||
|
var reg = new ToolRegistry(Array.Empty<ITool>());
|
||||||
|
var upstream = new ScriptedRealtimeUpstream();
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var cfg = new RealtimeSettings("m", "v", "p", IdleTimeoutSeconds: 2, new HashSet<string>());
|
||||||
|
var session = new RealtimeSession(Guid.NewGuid(), upstream, sink, log, reg, cfg,
|
||||||
|
new FakeDeviceChannel(), NullLogger.Instance);
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "session.updated" });
|
||||||
|
var run = session.RunAsync(default);
|
||||||
|
await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
await Task.Delay(1000);
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "input_audio_buffer.speech_started" });
|
||||||
|
await Task.Delay(1500);
|
||||||
|
|
||||||
|
sink.Envelopes.Any(e => (string?)e["type"] == "session_ended").Should().BeFalse();
|
||||||
|
|
||||||
|
await run.WaitAsync(TimeSpan.FromSeconds(4));
|
||||||
|
var ended = sink.Envelopes.Last(e => (string?)e["type"] == "session_ended");
|
||||||
|
((string?)ended["reason"]).Should().Be("idle");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using backend.Conversations;
|
||||||
|
using backend.Data;
|
||||||
|
using backend.Realtime;
|
||||||
|
using backend.Tools;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class RealtimeSessionLifecycleTests(ApiFactory factory) : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Open_emits_session_update_then_session_started_then_session_ended_on_cancel()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
|
||||||
|
var registry = new ToolRegistry(new ITool[] { new GetCurrentTimeTool() });
|
||||||
|
|
||||||
|
var upstream = new ScriptedRealtimeUpstream();
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var cfg = new RealtimeSettings(
|
||||||
|
Model: "gpt-4o-realtime-preview",
|
||||||
|
Voice: "alloy",
|
||||||
|
SystemPrompt: "test",
|
||||||
|
IdleTimeoutSeconds: 30,
|
||||||
|
EnabledTools: new HashSet<string> { "get_current_time" });
|
||||||
|
|
||||||
|
var session = new RealtimeSession(
|
||||||
|
deviceId: Guid.NewGuid(),
|
||||||
|
upstream, sink, log, registry, cfg,
|
||||||
|
channel: new FakeDeviceChannel(),
|
||||||
|
logger: NullLogger.Instance);
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "session.updated" });
|
||||||
|
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var runTask = session.RunAsync(cts.Token);
|
||||||
|
|
||||||
|
await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
await runTask;
|
||||||
|
|
||||||
|
upstream.Sent.Select(e => (string?)e["type"]).Should().StartWith("session.update");
|
||||||
|
sink.Envelopes.Select(e => (string?)e["type"]).Should().Contain("session_started");
|
||||||
|
sink.Envelopes.Select(e => (string?)e["type"]).Should().Contain("session_ended");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class NullLogger : Microsoft.Extensions.Logging.ILogger
|
||||||
|
{
|
||||||
|
public static readonly NullLogger Instance = new();
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
|
||||||
|
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => false;
|
||||||
|
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel l, Microsoft.Extensions.Logging.EventId e,
|
||||||
|
TState s, Exception? ex, Func<TState, Exception?, string> f) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class RecordingSink : ISessionOutput
|
||||||
|
{
|
||||||
|
public List<JsonObject> Envelopes { get; } = new();
|
||||||
|
public List<byte[]> Binary { get; } = new();
|
||||||
|
public Task WriteEnvelopeAsync(JsonObject e, CancellationToken ct)
|
||||||
|
{
|
||||||
|
lock (Envelopes) Envelopes.Add(e);
|
||||||
|
_gate.Release();
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
public Task WriteBinaryAsync(ReadOnlyMemory<byte> b, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Binary.Add(b.ToArray());
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
private readonly SemaphoreSlim _gate = new(0, int.MaxValue);
|
||||||
|
public async Task WaitForAsync(string type, TimeSpan timeout)
|
||||||
|
{
|
||||||
|
var deadline = DateTime.UtcNow + timeout;
|
||||||
|
while (DateTime.UtcNow < deadline)
|
||||||
|
{
|
||||||
|
lock (Envelopes)
|
||||||
|
{
|
||||||
|
if (Envelopes.Any(e => (string?)e["type"] == type)) return;
|
||||||
|
}
|
||||||
|
await _gate.WaitAsync(timeout);
|
||||||
|
}
|
||||||
|
throw new TimeoutException($"never saw {type}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using backend.Conversations;
|
||||||
|
using backend.Data;
|
||||||
|
using backend.Realtime;
|
||||||
|
using backend.Tools;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class RealtimeSessionToolsTests(ApiFactory factory) : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_current_time_call_returns_function_call_output_and_persists_tool_turn()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
|
||||||
|
var reg = new ToolRegistry(new ITool[] { new GetCurrentTimeTool() });
|
||||||
|
var upstream = new ScriptedRealtimeUpstream();
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var cfg = new RealtimeSettings("m", "v", "p", 30, new HashSet<string> { "get_current_time" });
|
||||||
|
var session = new RealtimeSession(Guid.NewGuid(), upstream, sink, log, reg, cfg,
|
||||||
|
new FakeDeviceChannel(), NullLogger.Instance);
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "session.updated" });
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var run = session.RunAsync(cts.Token);
|
||||||
|
await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "response.function_call_arguments.done",
|
||||||
|
["call_id"] = "call_abc",
|
||||||
|
["name"] = "get_current_time",
|
||||||
|
["arguments"] = "{}",
|
||||||
|
});
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "response.done" });
|
||||||
|
|
||||||
|
var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(2);
|
||||||
|
while (DateTime.UtcNow < deadline && !upstream.Sent.Any(e =>
|
||||||
|
(string?)e["type"] == "conversation.item.create"))
|
||||||
|
{
|
||||||
|
await Task.Delay(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = upstream.Sent.First(e => (string?)e["type"] == "conversation.item.create");
|
||||||
|
((string?)item["item"]!["type"]).Should().Be("function_call_output");
|
||||||
|
((string?)item["item"]!["call_id"]).Should().Be("call_abc");
|
||||||
|
((string?)item["item"]!["output"]).Should().Contain("\"now\"");
|
||||||
|
|
||||||
|
upstream.Sent.Should().Contain(e => (string?)e["type"] == "response.create");
|
||||||
|
|
||||||
|
var convoId = session.ConversationId!.Value;
|
||||||
|
var toolTurn = await db.Turns.FirstAsync(t =>
|
||||||
|
t.ConversationId == convoId && t.Role == TurnRole.Tool);
|
||||||
|
toolTurn.ToolName.Should().Be("get_current_time");
|
||||||
|
toolTurn.ToolResultJson.Should().Contain("\"now\"");
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
await run;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using backend.Conversations;
|
||||||
|
using backend.Data;
|
||||||
|
using backend.Realtime;
|
||||||
|
using backend.Tools;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class RealtimeSessionTranscriptTests(ApiFactory factory) : IClassFixture<ApiFactory>
|
||||||
|
{
|
||||||
|
private readonly ApiFactory _factory = factory;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task User_and_assistant_transcripts_are_persisted_and_response_done_emits_assistant_done()
|
||||||
|
{
|
||||||
|
using var scope = _factory.Services.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var log = scope.ServiceProvider.GetRequiredService<ConversationLog>();
|
||||||
|
var reg = new ToolRegistry(Array.Empty<ITool>());
|
||||||
|
var upstream = new ScriptedRealtimeUpstream();
|
||||||
|
var sink = new RecordingSink();
|
||||||
|
var cfg = new RealtimeSettings("m", "v", "p", 30, new HashSet<string>());
|
||||||
|
var session = new RealtimeSession(Guid.NewGuid(), upstream, sink, log, reg, cfg,
|
||||||
|
new FakeDeviceChannel(), NullLogger.Instance);
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "session.updated" });
|
||||||
|
var cts = new CancellationTokenSource();
|
||||||
|
var run = session.RunAsync(cts.Token);
|
||||||
|
await sink.WaitForAsync("session_started", TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
upstream.Push(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "conversation.item.input_audio_transcription.completed",
|
||||||
|
["transcript"] = "hello",
|
||||||
|
});
|
||||||
|
upstream.Push(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "response.audio_transcript.done",
|
||||||
|
["transcript"] = "hi there",
|
||||||
|
});
|
||||||
|
upstream.Push(new JsonObject { ["type"] = "response.done" });
|
||||||
|
|
||||||
|
await sink.WaitForAsync("assistant_done", TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
var convoId = session.ConversationId!.Value;
|
||||||
|
var turns = await db.Turns
|
||||||
|
.Where(t => t.ConversationId == convoId)
|
||||||
|
.OrderBy(t => t.Index).ToListAsync();
|
||||||
|
turns.Select(t => t.Role).Should().Equal(TurnRole.User, TurnRole.Assistant);
|
||||||
|
turns[0].Text.Should().Be("hello");
|
||||||
|
turns[1].Text.Should().Be("hi there");
|
||||||
|
|
||||||
|
cts.Cancel();
|
||||||
|
await run;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using System.Threading.Channels;
|
||||||
|
using backend.Realtime;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class ScriptedRealtimeUpstream : IRealtimeUpstream
|
||||||
|
{
|
||||||
|
private readonly Channel<JsonObject> _toRelay =
|
||||||
|
Channel.CreateUnbounded<JsonObject>(new UnboundedChannelOptions { SingleReader = true });
|
||||||
|
|
||||||
|
private readonly Channel<JsonObject> _fromRelay =
|
||||||
|
Channel.CreateUnbounded<JsonObject>(new UnboundedChannelOptions { SingleWriter = true });
|
||||||
|
|
||||||
|
public IList<JsonObject> Sent { get; } = new List<JsonObject>();
|
||||||
|
|
||||||
|
public Task SendJsonAsync(JsonObject envelope, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Sent.Add(envelope);
|
||||||
|
return _fromRelay.Writer.WriteAsync(envelope, ct).AsTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JsonObject?> ReceiveJsonAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await _toRelay.Reader.ReadAsync(ct);
|
||||||
|
}
|
||||||
|
catch (ChannelClosedException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Push(JsonObject evt) => _toRelay.Writer.TryWrite(evt);
|
||||||
|
public void CloseUpstream() => _toRelay.Writer.TryComplete();
|
||||||
|
|
||||||
|
public async Task<JsonObject> WaitForSentAsync(string type, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await foreach (var item in _fromRelay.Reader.ReadAllAsync(ct))
|
||||||
|
{
|
||||||
|
if ((string?)item["type"] == type) return item;
|
||||||
|
}
|
||||||
|
throw new TimeoutException($"never saw outbound {type}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
_toRelay.Writer.TryComplete();
|
||||||
|
_fromRelay.Writer.TryComplete();
|
||||||
|
return ValueTask.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using backend.Tools;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class SetVolumeToolTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Forwards_call_to_pi_and_returns_its_result()
|
||||||
|
{
|
||||||
|
var fake = new RecordingChannel(
|
||||||
|
JsonDocument.Parse("""{"ok":true}""").RootElement);
|
||||||
|
var tool = new SetVolumeTool();
|
||||||
|
var ctx = new DeviceContext(Guid.NewGuid(), Guid.NewGuid(), fake);
|
||||||
|
|
||||||
|
var args = JsonDocument.Parse("""{"level":42}""").RootElement;
|
||||||
|
var res = await tool.ExecuteAsync(new ToolInvocation("c1", args), ctx, default);
|
||||||
|
|
||||||
|
fake.LastName.Should().Be("set_volume");
|
||||||
|
fake.LastArgs.GetProperty("level").GetInt32().Should().Be(42);
|
||||||
|
res.Output.GetProperty("ok").GetBoolean().Should().BeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Rejects_out_of_range_level_without_calling_pi()
|
||||||
|
{
|
||||||
|
var fake = new RecordingChannel(JsonDocument.Parse("""{"ok":true}""").RootElement);
|
||||||
|
var tool = new SetVolumeTool();
|
||||||
|
var ctx = new DeviceContext(Guid.NewGuid(), Guid.NewGuid(), fake);
|
||||||
|
|
||||||
|
var args = JsonDocument.Parse("""{"level":150}""").RootElement;
|
||||||
|
var res = await tool.ExecuteAsync(new ToolInvocation("c1", args), ctx, default);
|
||||||
|
|
||||||
|
fake.LastName.Should().BeNull();
|
||||||
|
res.Output.GetProperty("ok").GetBoolean().Should().BeFalse();
|
||||||
|
res.Output.GetProperty("error").GetString().Should().Contain("0..100");
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingChannel(JsonElement reply) : IDeviceChannel
|
||||||
|
{
|
||||||
|
public string? LastName { get; private set; }
|
||||||
|
public JsonElement LastArgs { get; private set; }
|
||||||
|
public Task<JsonElement> CallPiToolAsync(string name, JsonElement args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
LastName = name;
|
||||||
|
LastArgs = args;
|
||||||
|
return Task.FromResult(reply);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using backend.Tools;
|
||||||
|
using FluentAssertions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace backend.tests;
|
||||||
|
|
||||||
|
public class ToolRegistryTests
|
||||||
|
{
|
||||||
|
private readonly ITool[] _all = new ITool[]
|
||||||
|
{
|
||||||
|
new GetCurrentTimeTool(),
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Get_returns_tool_by_exact_name()
|
||||||
|
{
|
||||||
|
var reg = new ToolRegistry(_all);
|
||||||
|
reg.Get("get_current_time").Should().NotBeNull();
|
||||||
|
reg.Get("does_not_exist").Should().BeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Filter_returns_only_enabled_tools_preserving_order()
|
||||||
|
{
|
||||||
|
var reg = new ToolRegistry(_all);
|
||||||
|
var enabled = new HashSet<string> { "get_current_time", "ghost" };
|
||||||
|
reg.EnabledFor(enabled).Select(t => t.Name).Should().Equal("get_current_time");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace backend.Conversations;
|
||||||
|
|
||||||
|
public enum EndReason
|
||||||
|
{
|
||||||
|
Unknown = 0,
|
||||||
|
Tool = 1,
|
||||||
|
Idle = 2,
|
||||||
|
Error = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Conversation
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public Guid DeviceId { get; set; }
|
||||||
|
public DateTimeOffset StartedAt { get; set; }
|
||||||
|
public DateTimeOffset? EndedAt { get; set; }
|
||||||
|
public EndReason EndReason { get; set; } = EndReason.Unknown;
|
||||||
|
public List<Turn> Turns { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using backend.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace backend.Conversations;
|
||||||
|
|
||||||
|
public class ConversationLog(AppDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<Conversation> StartAsync(Guid deviceId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var c = new Conversation
|
||||||
|
{
|
||||||
|
DeviceId = deviceId,
|
||||||
|
StartedAt = DateTimeOffset.UtcNow,
|
||||||
|
};
|
||||||
|
db.Conversations.Add(c);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AppendUserAsync(Guid conversationId, string text, CancellationToken ct)
|
||||||
|
=> AppendAsync(conversationId, TurnRole.User, text: text, ct: ct);
|
||||||
|
|
||||||
|
public Task AppendAssistantAsync(Guid conversationId, string text, CancellationToken ct)
|
||||||
|
=> AppendAsync(conversationId, TurnRole.Assistant, text: text, ct: ct);
|
||||||
|
|
||||||
|
public Task AppendToolAsync(
|
||||||
|
Guid conversationId, string toolName, string argsJson, string resultJson, CancellationToken ct)
|
||||||
|
=> AppendAsync(conversationId, TurnRole.Tool,
|
||||||
|
toolName: toolName, argsJson: argsJson, resultJson: resultJson, ct: ct);
|
||||||
|
|
||||||
|
public async Task EndAsync(Guid conversationId, EndReason reason, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var row = await db.Conversations.FirstAsync(c => c.Id == conversationId, ct);
|
||||||
|
row.EndedAt = DateTimeOffset.UtcNow;
|
||||||
|
row.EndReason = reason;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AppendAsync(
|
||||||
|
Guid conversationId, TurnRole role,
|
||||||
|
string? text = null, string? toolName = null,
|
||||||
|
string? argsJson = null, string? resultJson = null,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var next = await db.Turns
|
||||||
|
.Where(t => t.ConversationId == conversationId)
|
||||||
|
.Select(t => (int?)t.Index)
|
||||||
|
.MaxAsync(ct) ?? -1;
|
||||||
|
|
||||||
|
db.Turns.Add(new Turn
|
||||||
|
{
|
||||||
|
ConversationId = conversationId,
|
||||||
|
Index = next + 1,
|
||||||
|
Role = role,
|
||||||
|
Text = text,
|
||||||
|
ToolName = toolName,
|
||||||
|
ToolArgsJson = argsJson,
|
||||||
|
ToolResultJson = resultJson,
|
||||||
|
CreatedAt = DateTimeOffset.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace backend.Conversations;
|
||||||
|
|
||||||
|
public enum TurnRole
|
||||||
|
{
|
||||||
|
User = 0,
|
||||||
|
Assistant = 1,
|
||||||
|
Tool = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Turn
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
public Guid ConversationId { get; set; }
|
||||||
|
public int Index { get; set; }
|
||||||
|
public TurnRole Role { get; set; }
|
||||||
|
public string? Text { get; set; }
|
||||||
|
public string? ToolName { get; set; }
|
||||||
|
public string? ToolArgsJson { get; set; }
|
||||||
|
public string? ToolResultJson { get; set; }
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using backend.Auth;
|
using backend.Auth;
|
||||||
|
using backend.Conversations;
|
||||||
using backend.Devices;
|
using backend.Devices;
|
||||||
using backend.Pairing;
|
using backend.Pairing;
|
||||||
using backend.Settings;
|
using backend.Settings;
|
||||||
@@ -15,6 +16,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
public DbSet<DeviceConfig> DeviceConfigs => Set<DeviceConfig>();
|
public DbSet<DeviceConfig> DeviceConfigs => Set<DeviceConfig>();
|
||||||
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
public DbSet<SystemSettings> SystemSettings => Set<SystemSettings>();
|
||||||
public DbSet<PairingCode> PairingCodes => Set<PairingCode>();
|
public DbSet<PairingCode> PairingCodes => Set<PairingCode>();
|
||||||
|
public DbSet<Conversation> Conversations => Set<Conversation>();
|
||||||
|
public DbSet<Turn> Turns => Set<Turn>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder b)
|
protected override void OnModelCreating(ModelBuilder b)
|
||||||
{
|
{
|
||||||
@@ -36,5 +39,13 @@ public class AppDbContext(DbContextOptions<AppDbContext> options)
|
|||||||
b.Entity<SystemSettings>().Property(s => s.Id).ValueGeneratedNever();
|
b.Entity<SystemSettings>().Property(s => s.Id).ValueGeneratedNever();
|
||||||
b.Entity<PairingCode>().HasKey(p => p.Code);
|
b.Entity<PairingCode>().HasKey(p => p.Code);
|
||||||
b.Entity<PairingCode>().HasIndex(p => p.ExpiresAt);
|
b.Entity<PairingCode>().HasIndex(p => p.ExpiresAt);
|
||||||
|
|
||||||
|
b.Entity<Conversation>().HasIndex(c => c.DeviceId);
|
||||||
|
b.Entity<Conversation>()
|
||||||
|
.HasMany(c => c.Turns)
|
||||||
|
.WithOne()
|
||||||
|
.HasForeignKey(t => t.ConversationId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.Entity<Turn>().HasIndex(t => new { t.ConversationId, t.Index }).IsUnique();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using backend.Realtime;
|
||||||
|
using backend.Tools;
|
||||||
|
|
||||||
|
namespace backend.DeviceHub;
|
||||||
|
|
||||||
|
public interface IDeviceClient
|
||||||
|
{
|
||||||
|
Task SendEnvelopeAsync<T>(T envelope, CancellationToken ct) where T : HubEnvelope;
|
||||||
|
Task SendBinaryAsync(ReadOnlyMemory<byte> bytes, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ActiveDevice : IDeviceChannel, IDeviceClient, ISessionOutput, IAsyncDisposable
|
||||||
|
{
|
||||||
|
public Guid DeviceId { get; }
|
||||||
|
public WebSocket Ws => _ws;
|
||||||
|
public RealtimeSession? CurrentSession { get; set; }
|
||||||
|
public CancellationTokenSource? SessionCts { get; set; }
|
||||||
|
|
||||||
|
private readonly WebSocket _ws;
|
||||||
|
private readonly SemaphoreSlim _sendLock = new(1, 1);
|
||||||
|
private readonly ConcurrentDictionary<string, TaskCompletionSource<JsonElement>> _pendingTools = new();
|
||||||
|
|
||||||
|
public ActiveDevice(Guid deviceId, WebSocket ws)
|
||||||
|
{
|
||||||
|
DeviceId = deviceId;
|
||||||
|
_ws = ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendEnvelopeAsync<T>(T envelope, CancellationToken ct) where T : HubEnvelope
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(envelope, envelope.GetType());
|
||||||
|
await _sendLock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _ws.SendAsync(Encoding.UTF8.GetBytes(json),
|
||||||
|
WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||||
|
}
|
||||||
|
finally { _sendLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendBinaryAsync(ReadOnlyMemory<byte> bytes, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _sendLock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _ws.SendAsync(bytes, WebSocketMessageType.Binary, endOfMessage: true, ct);
|
||||||
|
}
|
||||||
|
finally { _sendLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task WriteEnvelopeAsync(JsonObject envelope, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var json = envelope.ToJsonString();
|
||||||
|
return WriteRawTextAsync(json, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WriteRawTextAsync(string json, CancellationToken ct)
|
||||||
|
{
|
||||||
|
await _sendLock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _ws.SendAsync(Encoding.UTF8.GetBytes(json),
|
||||||
|
WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||||
|
}
|
||||||
|
finally { _sendLock.Release(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task WriteBinaryAsync(ReadOnlyMemory<byte> bytes, CancellationToken ct)
|
||||||
|
=> SendBinaryAsync(bytes, ct);
|
||||||
|
|
||||||
|
public Task<JsonElement> CallPiToolAsync(string name, JsonElement args, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var callId = "srv_" + Guid.NewGuid().ToString("N")[..12];
|
||||||
|
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
_pendingTools[callId] = tcs;
|
||||||
|
|
||||||
|
var envelope = new ToolCallEnvelope("tool_call", callId, name, args);
|
||||||
|
_ = SendEnvelopeAsync(envelope, ct);
|
||||||
|
|
||||||
|
ct.Register(() => tcs.TrySetCanceled());
|
||||||
|
return tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void CompleteToolResult(ToolResultEnvelope env)
|
||||||
|
{
|
||||||
|
if (!_pendingTools.TryRemove(env.CallId, out var tcs)) return;
|
||||||
|
if (env.Ok && env.Result is JsonElement r) tcs.TrySetResult(r);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var msg = (env.Error ?? "unknown").Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||||
|
var doc = JsonDocument.Parse($$"""{"ok":false,"error":"{{msg}}"}""");
|
||||||
|
tcs.TrySetResult(doc.RootElement.Clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (_ws.State == WebSocketState.Open)
|
||||||
|
await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
_ws.Dispose();
|
||||||
|
_sendLock.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using backend.Data;
|
||||||
|
using backend.Devices;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace backend.DeviceHub;
|
||||||
|
|
||||||
|
public class DeviceAuth(AppDbContext db, DeviceTokenService tokens)
|
||||||
|
{
|
||||||
|
public async Task<Device?> AuthenticateAsync(string? authHeader, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(authHeader)) return null;
|
||||||
|
const string prefix = "Bearer ";
|
||||||
|
if (!authHeader.StartsWith(prefix, StringComparison.Ordinal)) return null;
|
||||||
|
var token = authHeader[prefix.Length..].Trim();
|
||||||
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
|
var hash = tokens.Hash(token);
|
||||||
|
var device = await db.Devices.Include(d => d.Config)
|
||||||
|
.FirstOrDefaultAsync(d => d.TokenHash == hash && !d.IsRevoked, ct);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using backend.Conversations;
|
||||||
|
using backend.Data;
|
||||||
|
using backend.Devices;
|
||||||
|
using backend.Realtime;
|
||||||
|
using backend.Tools;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace backend.DeviceHub;
|
||||||
|
|
||||||
|
public static class DeviceHubEndpoint
|
||||||
|
{
|
||||||
|
public static IEndpointRouteBuilder MapDeviceHub(this IEndpointRouteBuilder app)
|
||||||
|
{
|
||||||
|
app.Map("/device", async (
|
||||||
|
HttpContext ctx,
|
||||||
|
DeviceAuth auth,
|
||||||
|
DeviceRegistry reg,
|
||||||
|
AppDbContext db,
|
||||||
|
RealtimeSessionFactory sessions,
|
||||||
|
IServiceScopeFactory scopes) =>
|
||||||
|
{
|
||||||
|
if (!ctx.WebSockets.IsWebSocketRequest)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var device = await auth.AuthenticateAsync(
|
||||||
|
ctx.Request.Headers.Authorization.ToString(), ctx.RequestAborted);
|
||||||
|
if (device is null)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
|
||||||
|
await using var active = new ActiveDevice(device.Id, ws);
|
||||||
|
if (!reg.TryRegister(active))
|
||||||
|
{
|
||||||
|
await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation,
|
||||||
|
"device already connected", ctx.RequestAborted);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await HandleAsync(active, device, db, sessions, scopes, ctx.RequestAborted);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
reg.Unregister(device.Id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task HandleAsync(
|
||||||
|
ActiveDevice active, Device device, AppDbContext db,
|
||||||
|
RealtimeSessionFactory sessions, IServiceScopeFactory scopes,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var buffer = new byte[8192];
|
||||||
|
var ws = active.Ws;
|
||||||
|
|
||||||
|
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
using var msg = new MemoryStream();
|
||||||
|
WebSocketReceiveResult res;
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
res = await ws.ReceiveAsync(buffer, ct);
|
||||||
|
if (res.MessageType == WebSocketMessageType.Close) return;
|
||||||
|
msg.Write(buffer, 0, res.Count);
|
||||||
|
if (res.EndOfMessage) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.MessageType == WebSocketMessageType.Binary)
|
||||||
|
{
|
||||||
|
if (msg.Length == 3840 && active.CurrentSession is not null)
|
||||||
|
{
|
||||||
|
await active.CurrentSession.WriteUplinkAsync(msg.ToArray(), ct);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var text = Encoding.UTF8.GetString(msg.ToArray());
|
||||||
|
using var doc = JsonDocument.Parse(text);
|
||||||
|
var type = doc.RootElement.GetProperty("type").GetString();
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case "hello":
|
||||||
|
await SendHelloAckAsync(active, device, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "ping":
|
||||||
|
device.LastSeenAt = DateTimeOffset.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await active.SendEnvelopeAsync(new PongEnvelope("pong"), ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "wake":
|
||||||
|
if (active.CurrentSession is null)
|
||||||
|
await StartSessionAsync(active, device, sessions, scopes, ct);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "cancel":
|
||||||
|
active.SessionCts?.Cancel();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "playback_done":
|
||||||
|
break; // v1: client coordination only
|
||||||
|
|
||||||
|
case "tool_result":
|
||||||
|
var env = JsonSerializer.Deserialize<ToolResultEnvelope>(text)!;
|
||||||
|
active.CompleteToolResult(env);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task StartSessionAsync(
|
||||||
|
ActiveDevice active, Device device,
|
||||||
|
RealtimeSessionFactory sessions, IServiceScopeFactory scopes,
|
||||||
|
CancellationToken parentCt)
|
||||||
|
{
|
||||||
|
var enabledTools = JsonSerializer.Deserialize<string[]>(device.Config?.EnabledToolsJson ?? "[]")
|
||||||
|
?? Array.Empty<string>();
|
||||||
|
var cfg = new RealtimeSettings(
|
||||||
|
Model: device.Config?.Model ?? "gpt-4o-realtime-preview",
|
||||||
|
Voice: device.Config?.Voice ?? "alloy",
|
||||||
|
SystemPrompt: device.Config?.SystemPrompt ?? "",
|
||||||
|
IdleTimeoutSeconds: device.Config?.IdleTimeoutSeconds ?? 30,
|
||||||
|
EnabledTools: new HashSet<string>(enabledTools));
|
||||||
|
|
||||||
|
active.SessionCts = CancellationTokenSource.CreateLinkedTokenSource(parentCt);
|
||||||
|
var ct = active.SessionCts.Token;
|
||||||
|
|
||||||
|
var sessionScope = scopes.CreateAsyncScope();
|
||||||
|
var scopedSessions = sessionScope.ServiceProvider.GetRequiredService<RealtimeSessionFactory>();
|
||||||
|
|
||||||
|
RealtimeSession session;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
session = await scopedSessions.CreateAsync(
|
||||||
|
device.Id, cfg, active, active, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
await active.SendEnvelopeAsync(
|
||||||
|
new ErrorEnvelope("error", "upstream", ex.Message, true), parentCt);
|
||||||
|
await sessionScope.DisposeAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
active.CurrentSession = session;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try { await session.RunAsync(ct); }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
active.CurrentSession = null;
|
||||||
|
active.SessionCts?.Dispose();
|
||||||
|
active.SessionCts = null;
|
||||||
|
await sessionScope.DisposeAsync();
|
||||||
|
}
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SendHelloAckAsync(ActiveDevice active, Device device, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var enabled = device.Config?.EnabledToolsJson ?? "[]";
|
||||||
|
var config = JsonNode.Parse($$"""
|
||||||
|
{
|
||||||
|
"voice": "{{Escape(device.Config?.Voice)}}",
|
||||||
|
"model": "{{Escape(device.Config?.Model)}}",
|
||||||
|
"system_prompt": "{{Escape(device.Config?.SystemPrompt)}}",
|
||||||
|
"idle_timeout_seconds": {{device.Config?.IdleTimeoutSeconds ?? 30}},
|
||||||
|
"enabled_tools": {{enabled}}
|
||||||
|
}
|
||||||
|
""")!.AsObject();
|
||||||
|
|
||||||
|
await active.WriteEnvelopeAsync(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "hello_ack",
|
||||||
|
["config"] = config,
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Escape(string? s) =>
|
||||||
|
(s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
|
||||||
|
namespace backend.DeviceHub;
|
||||||
|
|
||||||
|
public class DeviceRegistry
|
||||||
|
{
|
||||||
|
private readonly ConcurrentDictionary<Guid, ActiveDevice> _online = new();
|
||||||
|
|
||||||
|
public bool TryRegister(ActiveDevice dev)
|
||||||
|
=> _online.TryAdd(dev.DeviceId, dev);
|
||||||
|
|
||||||
|
public bool Unregister(Guid deviceId)
|
||||||
|
=> _online.TryRemove(deviceId, out _);
|
||||||
|
|
||||||
|
public ActiveDevice? Get(Guid deviceId)
|
||||||
|
=> _online.TryGetValue(deviceId, out var d) ? d : null;
|
||||||
|
|
||||||
|
public bool IsOnline(Guid deviceId) => _online.ContainsKey(deviceId);
|
||||||
|
|
||||||
|
public int OnlineCount => _online.Count;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace backend.DeviceHub;
|
||||||
|
|
||||||
|
public record HubEnvelope([property: JsonPropertyName("type")] string Type);
|
||||||
|
|
||||||
|
public record HelloEnvelope(string Type,
|
||||||
|
[property: JsonPropertyName("device_id")] Guid DeviceId,
|
||||||
|
[property: JsonPropertyName("client_version")] string ClientVersion) : HubEnvelope(Type);
|
||||||
|
|
||||||
|
public record HelloAckEnvelope(string Type,
|
||||||
|
[property: JsonPropertyName("config")] JsonElement Config) : HubEnvelope(Type);
|
||||||
|
|
||||||
|
public record PongEnvelope(string Type) : HubEnvelope(Type);
|
||||||
|
|
||||||
|
public record WakeEnvelope(string Type,
|
||||||
|
[property: JsonPropertyName("at")] long? At) : HubEnvelope(Type);
|
||||||
|
|
||||||
|
public record SessionStartedEnvelope(string Type,
|
||||||
|
[property: JsonPropertyName("conversation_id")] Guid ConversationId) : HubEnvelope(Type);
|
||||||
|
|
||||||
|
public record SessionEndedEnvelope(string Type,
|
||||||
|
[property: JsonPropertyName("reason")] string Reason) : HubEnvelope(Type);
|
||||||
|
|
||||||
|
public record AssistantDoneEnvelope(string Type) : HubEnvelope(Type);
|
||||||
|
|
||||||
|
public record ToolCallEnvelope(string Type,
|
||||||
|
[property: JsonPropertyName("call_id")] string CallId,
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("arguments")] JsonElement Arguments) : HubEnvelope(Type);
|
||||||
|
|
||||||
|
public record ToolResultEnvelope(string Type,
|
||||||
|
[property: JsonPropertyName("call_id")] string CallId,
|
||||||
|
[property: JsonPropertyName("ok")] bool Ok,
|
||||||
|
[property: JsonPropertyName("result")] JsonElement? Result,
|
||||||
|
[property: JsonPropertyName("error")] string? Error) : HubEnvelope(Type);
|
||||||
|
|
||||||
|
public record ErrorEnvelope(string Type,
|
||||||
|
[property: JsonPropertyName("code")] string Code,
|
||||||
|
[property: JsonPropertyName("message")] string Message,
|
||||||
|
[property: JsonPropertyName("fatal")] bool? Fatal) : HubEnvelope(Type);
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using backend.Data;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace backend.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260611210852_AddConversationsAndTurns")]
|
||||||
|
partial class AddConversationsAndTurns
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "9.0.17");
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("RoleNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetRoleClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimType")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ClaimValue")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserClaims", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderKey")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ProviderDisplayName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("LoginProvider", "ProviderKey");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserLogins", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("RoleId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "RoleId");
|
||||||
|
|
||||||
|
b.HasIndex("RoleId");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserRoles", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("LoginProvider")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("UserId", "LoginProvider", "Name");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUserTokens", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Auth.ApplicationUser", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("AccessFailedCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("ConcurrencyStamp")
|
||||||
|
.IsConcurrencyToken()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("EmailConfirmed")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("LockoutEnabled")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedEmail")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("NormalizedUserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("PhoneNumberConfirmed")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("SecurityStamp")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("TwoFactorEnabled")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("UserName")
|
||||||
|
.HasMaxLength(256)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedEmail")
|
||||||
|
.HasDatabaseName("EmailIndex");
|
||||||
|
|
||||||
|
b.HasIndex("NormalizedUserName")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("UserNameIndex");
|
||||||
|
|
||||||
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Conversations.Conversation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("DeviceId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("EndReason")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("EndedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("StartedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DeviceId");
|
||||||
|
|
||||||
|
b.ToTable("Conversations");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Conversations.Turn", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConversationId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Index")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Text")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ToolArgsJson")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ToolName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ToolResultJson")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ConversationId", "Index")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Turns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRevoked")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastSeenAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("PairedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("TokenHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OwnerUserId");
|
||||||
|
|
||||||
|
b.HasIndex("TokenHash")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Devices");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("DeviceId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EnabledToolsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("IdleTimeoutSeconds")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("SystemPrompt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Voice")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("DeviceId");
|
||||||
|
|
||||||
|
b.ToTable("DeviceConfigs");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Pairing.PairingCode", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Code")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("ConsumedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("ExpiresAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("OwnerUserId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Code");
|
||||||
|
|
||||||
|
b.HasIndex("ExpiresAt");
|
||||||
|
|
||||||
|
b.ToTable("PairingCodes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Settings.SystemSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("DefaultIdleTimeoutSeconds")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultModel")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultSystemPrompt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultVoice")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("SystemSettings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("RoleId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Conversations.Turn", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Conversations.Conversation", null)
|
||||||
|
.WithMany("Turns")
|
||||||
|
.HasForeignKey("ConversationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("OwnerUserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Devices.DeviceConfig", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Devices.Device", null)
|
||||||
|
.WithOne("Config")
|
||||||
|
.HasForeignKey("backend.Devices.DeviceConfig", "DeviceId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Conversations.Conversation", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Turns");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Config");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace backend.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddConversationsAndTurns : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Conversations",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
DeviceId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
StartedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||||
|
EndedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
|
||||||
|
EndReason = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Conversations", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Turns",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
ConversationId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
Index = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Role = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Text = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ToolName = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ToolArgsJson = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
ToolResultJson = table.Column<string>(type: "TEXT", nullable: true),
|
||||||
|
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Turns", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_Turns_Conversations_ConversationId",
|
||||||
|
column: x => x.ConversationId,
|
||||||
|
principalTable: "Conversations",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Conversations_DeviceId",
|
||||||
|
table: "Conversations",
|
||||||
|
column: "DeviceId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_Turns_ConversationId_Index",
|
||||||
|
table: "Turns",
|
||||||
|
columns: new[] { "ConversationId", "Index" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Turns");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Conversations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,6 +208,69 @@ namespace backend.Migrations
|
|||||||
b.ToTable("AspNetUsers", (string)null);
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Conversations.Conversation", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("DeviceId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("EndReason")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("EndedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("StartedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("DeviceId");
|
||||||
|
|
||||||
|
b.ToTable("Conversations");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Conversations.Turn", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid>("ConversationId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("Index")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Role")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Text")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ToolArgsJson")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ToolName")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("ToolResultJson")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("ConversationId", "Index")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Turns");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -374,6 +437,15 @@ namespace backend.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Conversations.Turn", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("backend.Conversations.Conversation", null)
|
||||||
|
.WithMany("Turns")
|
||||||
|
.HasForeignKey("ConversationId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("backend.Auth.ApplicationUser", null)
|
b.HasOne("backend.Auth.ApplicationUser", null)
|
||||||
@@ -392,6 +464,11 @@ namespace backend.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("backend.Conversations.Conversation", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Turns");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("backend.Devices.Device", b =>
|
modelBuilder.Entity("backend.Devices.Device", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Config");
|
b.Navigation("Config");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using backend.Auth;
|
using backend.Auth;
|
||||||
using backend.Data;
|
using backend.Data;
|
||||||
|
using backend.DeviceHub;
|
||||||
using backend.Devices;
|
using backend.Devices;
|
||||||
using backend.Install;
|
using backend.Install;
|
||||||
using backend.Pairing;
|
using backend.Pairing;
|
||||||
@@ -33,6 +34,15 @@ builder.Services.AddAuthorization();
|
|||||||
builder.Services.AddScoped<backend.Devices.DeviceTokenService>();
|
builder.Services.AddScoped<backend.Devices.DeviceTokenService>();
|
||||||
builder.Services.AddScoped<backend.Pairing.PairingService>();
|
builder.Services.AddScoped<backend.Pairing.PairingService>();
|
||||||
builder.Services.AddSingleton<backend.Install.InstallScriptBuilder>();
|
builder.Services.AddSingleton<backend.Install.InstallScriptBuilder>();
|
||||||
|
builder.Services.AddScoped<backend.Conversations.ConversationLog>();
|
||||||
|
builder.Services.AddSingleton<backend.Realtime.OpenAIKeyProvider>();
|
||||||
|
builder.Services.AddSingleton<backend.Tools.ITool, backend.Tools.GetCurrentTimeTool>();
|
||||||
|
builder.Services.AddSingleton<backend.Tools.ITool, backend.Tools.EndSessionTool>();
|
||||||
|
builder.Services.AddSingleton<backend.Tools.ITool, backend.Tools.SetVolumeTool>();
|
||||||
|
builder.Services.AddSingleton<backend.Tools.ToolRegistry>();
|
||||||
|
builder.Services.AddScoped<backend.Realtime.RealtimeSessionFactory>();
|
||||||
|
builder.Services.AddSingleton<backend.DeviceHub.DeviceRegistry>();
|
||||||
|
builder.Services.AddScoped<backend.DeviceHub.DeviceAuth>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
@@ -61,6 +71,9 @@ app.MapInstallEndpoints();
|
|||||||
|
|
||||||
app.MapGet("/health", () => Results.Ok(new { ok = true }));
|
app.MapGet("/health", () => Results.Ok(new { ok = true }));
|
||||||
|
|
||||||
|
app.UseWebSockets();
|
||||||
|
app.MapDeviceHub();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
public partial class Program { }
|
public partial class Program { }
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace backend.Realtime;
|
||||||
|
|
||||||
|
public interface IRealtimeUpstream : IAsyncDisposable
|
||||||
|
{
|
||||||
|
Task SendJsonAsync(JsonObject envelope, CancellationToken ct);
|
||||||
|
Task<JsonObject?> ReceiveJsonAsync(CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace backend.Realtime;
|
||||||
|
|
||||||
|
public class OpenAIKeyProvider
|
||||||
|
{
|
||||||
|
private readonly string? _key;
|
||||||
|
|
||||||
|
public OpenAIKeyProvider(IConfiguration cfg)
|
||||||
|
{
|
||||||
|
_key = cfg["OPENAI_API_KEY"] ?? cfg["OpenAI:ApiKey"];
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasKey => !string.IsNullOrWhiteSpace(_key);
|
||||||
|
|
||||||
|
public string GetRequired()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(_key))
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"OPENAI_API_KEY is not configured. Set the env var on the Coolify app.");
|
||||||
|
return _key!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
|
||||||
|
namespace backend.Realtime;
|
||||||
|
|
||||||
|
public class OpenAIRealtimeUpstream : IRealtimeUpstream
|
||||||
|
{
|
||||||
|
private readonly ClientWebSocket _ws = new();
|
||||||
|
|
||||||
|
public async Task ConnectAsync(string apiKey, string model, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_ws.Options.SetRequestHeader("Authorization", $"Bearer {apiKey}");
|
||||||
|
_ws.Options.SetRequestHeader("OpenAI-Beta", "realtime=v1");
|
||||||
|
var uri = new Uri($"wss://api.openai.com/v1/realtime?model={Uri.EscapeDataString(model)}");
|
||||||
|
await _ws.ConnectAsync(uri, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendJsonAsync(JsonObject envelope, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var json = envelope.ToJsonString();
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
await _ws.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<JsonObject?> ReceiveJsonAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var buffer = new byte[8192];
|
||||||
|
using var stream = new MemoryStream();
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
WebSocketReceiveResult result;
|
||||||
|
try { result = await _ws.ReceiveAsync(buffer, ct); }
|
||||||
|
catch (WebSocketException) { return null; }
|
||||||
|
catch (OperationCanceledException) { return null; }
|
||||||
|
|
||||||
|
if (result.MessageType == WebSocketMessageType.Close) return null;
|
||||||
|
stream.Write(buffer, 0, result.Count);
|
||||||
|
if (result.EndOfMessage)
|
||||||
|
{
|
||||||
|
var json = Encoding.UTF8.GetString(stream.ToArray());
|
||||||
|
return JsonNode.Parse(json)?.AsObject();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
if (_ws.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
try { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
_ws.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using backend.Tools;
|
||||||
|
|
||||||
|
namespace backend.Realtime;
|
||||||
|
|
||||||
|
public static class RealtimeEvents
|
||||||
|
{
|
||||||
|
public static JsonObject SessionUpdate(RealtimeSettings cfg, IEnumerable<ITool> enabledTools)
|
||||||
|
{
|
||||||
|
var tools = new JsonArray();
|
||||||
|
foreach (var t in enabledTools)
|
||||||
|
{
|
||||||
|
tools.Add(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "function",
|
||||||
|
["name"] = t.Name,
|
||||||
|
["description"] = t.Description,
|
||||||
|
["parameters"] = JsonNode.Parse(t.ParameterSchema.GetRawText())!,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "session.update",
|
||||||
|
["session"] = new JsonObject
|
||||||
|
{
|
||||||
|
["voice"] = cfg.Voice,
|
||||||
|
["instructions"] = cfg.SystemPrompt,
|
||||||
|
["modalities"] = new JsonArray("text", "audio"),
|
||||||
|
["input_audio_format"] = "pcm16",
|
||||||
|
["output_audio_format"] = "pcm16",
|
||||||
|
["input_audio_transcription"] = new JsonObject { ["model"] = "whisper-1" },
|
||||||
|
["turn_detection"] = new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "server_vad",
|
||||||
|
["threshold"] = 0.5,
|
||||||
|
["prefix_padding_ms"] = 300,
|
||||||
|
["silence_duration_ms"] = 500,
|
||||||
|
},
|
||||||
|
["tools"] = tools,
|
||||||
|
["tool_choice"] = "auto",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonObject InputAudioBufferAppend(ReadOnlySpan<byte> pcm16)
|
||||||
|
{
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "input_audio_buffer.append",
|
||||||
|
["audio"] = Convert.ToBase64String(pcm16),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonObject FunctionCallOutput(string callId, string outputJson)
|
||||||
|
{
|
||||||
|
return new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "conversation.item.create",
|
||||||
|
["item"] = new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "function_call_output",
|
||||||
|
["call_id"] = callId,
|
||||||
|
["output"] = outputJson,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonObject ResponseCreate() => new() { ["type"] = "response.create" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using backend.Conversations;
|
||||||
|
using backend.Tools;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace backend.Realtime;
|
||||||
|
|
||||||
|
public interface ISessionOutput
|
||||||
|
{
|
||||||
|
Task WriteEnvelopeAsync(JsonObject envelope, CancellationToken ct);
|
||||||
|
Task WriteBinaryAsync(ReadOnlyMemory<byte> bytes, CancellationToken ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RealtimeSession
|
||||||
|
{
|
||||||
|
private readonly Guid _deviceId;
|
||||||
|
private readonly IRealtimeUpstream _upstream;
|
||||||
|
private readonly ISessionOutput _output;
|
||||||
|
private readonly ConversationLog _log;
|
||||||
|
private readonly ToolRegistry _registry;
|
||||||
|
private readonly RealtimeSettings _cfg;
|
||||||
|
private readonly IDeviceChannel _channel;
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
|
||||||
|
private Conversation? _conversation;
|
||||||
|
private EndReason _endReason = EndReason.Unknown;
|
||||||
|
private bool _endRequested;
|
||||||
|
private DateTime _lastSpeechAt = DateTime.UtcNow;
|
||||||
|
private readonly List<(string CallId, string Name, string ArgsJson)> _pendingCalls = new();
|
||||||
|
|
||||||
|
private readonly System.Threading.Channels.Channel<byte[]> _uplink =
|
||||||
|
System.Threading.Channels.Channel.CreateBounded<byte[]>(
|
||||||
|
new System.Threading.Channels.BoundedChannelOptions(64)
|
||||||
|
{
|
||||||
|
FullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest,
|
||||||
|
});
|
||||||
|
|
||||||
|
public ValueTask WriteUplinkAsync(byte[] frame, CancellationToken ct)
|
||||||
|
=> _uplink.Writer.WriteAsync(frame, ct);
|
||||||
|
|
||||||
|
public RealtimeSession(
|
||||||
|
Guid deviceId,
|
||||||
|
IRealtimeUpstream upstream,
|
||||||
|
ISessionOutput output,
|
||||||
|
ConversationLog log,
|
||||||
|
ToolRegistry registry,
|
||||||
|
RealtimeSettings cfg,
|
||||||
|
IDeviceChannel channel,
|
||||||
|
ILogger logger)
|
||||||
|
{
|
||||||
|
_deviceId = deviceId;
|
||||||
|
_upstream = upstream;
|
||||||
|
_output = output;
|
||||||
|
_log = log;
|
||||||
|
_registry = registry;
|
||||||
|
_cfg = cfg;
|
||||||
|
_channel = channel;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Guid? ConversationId => _conversation?.Id;
|
||||||
|
|
||||||
|
public async Task RunAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var enabled = _registry.EnabledFor(_cfg.EnabledTools).ToList();
|
||||||
|
await _upstream.SendJsonAsync(RealtimeEvents.SessionUpdate(_cfg, enabled), ct);
|
||||||
|
|
||||||
|
var ack = await WaitForUpstreamTypeAsync("session.updated", ct);
|
||||||
|
if (ack is null)
|
||||||
|
{
|
||||||
|
_endReason = EndReason.Error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_conversation = await _log.StartAsync(_deviceId, ct);
|
||||||
|
await _output.WriteEnvelopeAsync(new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "session_started",
|
||||||
|
["conversation_id"] = _conversation.Id.ToString(),
|
||||||
|
}, ct);
|
||||||
|
_lastSpeechAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var uplinkTask = Task.Run(() => PumpUplinkAsync(ct), ct);
|
||||||
|
var idleTask = Task.Run(() => WatchdogAsync(ct), ct);
|
||||||
|
await PumpAsync(ct);
|
||||||
|
_uplink.Writer.TryComplete();
|
||||||
|
await uplinkTask;
|
||||||
|
await idleTask;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
if (_endReason == EndReason.Unknown) _endReason = EndReason.Error;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "RealtimeSession crashed");
|
||||||
|
_endReason = EndReason.Error;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
await _upstream.DisposeAsync();
|
||||||
|
if (_conversation is not null)
|
||||||
|
{
|
||||||
|
await _log.EndAsync(_conversation.Id, _endReason, CancellationToken.None);
|
||||||
|
}
|
||||||
|
var endEnv = new JsonObject
|
||||||
|
{
|
||||||
|
["type"] = "session_ended",
|
||||||
|
["reason"] = _endReason.ToString().ToLowerInvariant(),
|
||||||
|
};
|
||||||
|
await _output.WriteEnvelopeAsync(endEnv, CancellationToken.None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PumpAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (!ct.IsCancellationRequested && !_endRequested)
|
||||||
|
{
|
||||||
|
var evt = await ReceiveOrIdleAsync(ct);
|
||||||
|
if (evt is null && _endRequested) return;
|
||||||
|
if (evt is null && ct.IsCancellationRequested) return;
|
||||||
|
if (evt is null) continue;
|
||||||
|
var type = (string?)evt["type"];
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case "input_audio_buffer.speech_started":
|
||||||
|
_lastSpeechAt = DateTime.UtcNow;
|
||||||
|
break;
|
||||||
|
case "response.audio.delta":
|
||||||
|
await ForwardAudioDeltaAsync(evt, ct);
|
||||||
|
break;
|
||||||
|
case "conversation.item.input_audio_transcription.completed":
|
||||||
|
{
|
||||||
|
var text = (string?)evt["transcript"];
|
||||||
|
if (_conversation is not null && !string.IsNullOrEmpty(text))
|
||||||
|
await _log.AppendUserAsync(_conversation.Id, text!, ct);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "response.audio_transcript.done":
|
||||||
|
{
|
||||||
|
var text = (string?)evt["transcript"];
|
||||||
|
if (_conversation is not null && !string.IsNullOrEmpty(text))
|
||||||
|
await _log.AppendAssistantAsync(_conversation.Id, text!, ct);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "response.function_call_arguments.done":
|
||||||
|
{
|
||||||
|
var callId = (string?)evt["call_id"] ?? "";
|
||||||
|
var name = (string?)evt["name"] ?? "";
|
||||||
|
var args = (string?)evt["arguments"] ?? "{}";
|
||||||
|
_pendingCalls.Add((callId, name, args));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "response.done":
|
||||||
|
{
|
||||||
|
await _output.WriteEnvelopeAsync(
|
||||||
|
new JsonObject { ["type"] = "assistant_done" }, ct);
|
||||||
|
|
||||||
|
if (_pendingCalls.Count == 0) break;
|
||||||
|
var calls = _pendingCalls.ToList();
|
||||||
|
_pendingCalls.Clear();
|
||||||
|
|
||||||
|
if (calls.Any(c => c.Name == "end_session"))
|
||||||
|
{
|
||||||
|
_endReason = EndReason.Tool;
|
||||||
|
_endRequested = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var c in calls)
|
||||||
|
{
|
||||||
|
await ExecuteToolAsync(c.CallId, c.Name, c.ArgsJson, ct);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ExecuteToolAsync(string callId, string name, string argsJson, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var tool = _registry.Get(name);
|
||||||
|
string outputJson;
|
||||||
|
if (tool is null)
|
||||||
|
{
|
||||||
|
outputJson = $$"""{"ok":false,"error":"unknown tool: {{name}}"}""";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var argsDoc = System.Text.Json.JsonDocument.Parse(argsJson);
|
||||||
|
var ctx = new DeviceContext(_deviceId, _conversation!.Id, _channel);
|
||||||
|
var res = await tool.ExecuteAsync(
|
||||||
|
new ToolInvocation(callId, argsDoc.RootElement.Clone()), ctx, ct);
|
||||||
|
outputJson = res.Output.GetRawText();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
outputJson = System.Text.Json.JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
ok = false,
|
||||||
|
error = ex.Message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_conversation is not null)
|
||||||
|
{
|
||||||
|
await _log.AppendToolAsync(_conversation.Id, name, argsJson, outputJson, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _upstream.SendJsonAsync(RealtimeEvents.FunctionCallOutput(callId, outputJson), ct);
|
||||||
|
await _upstream.SendJsonAsync(RealtimeEvents.ResponseCreate(), ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ForwardAudioDeltaAsync(JsonObject evt, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var b64 = (string?)evt["delta"];
|
||||||
|
if (string.IsNullOrEmpty(b64)) return;
|
||||||
|
var bytes = Convert.FromBase64String(b64);
|
||||||
|
await _output.WriteBinaryAsync(bytes, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task WatchdogAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var timeout = TimeSpan.FromSeconds(_cfg.IdleTimeoutSeconds);
|
||||||
|
while (!ct.IsCancellationRequested && !_endRequested)
|
||||||
|
{
|
||||||
|
await Task.Delay(250, ct);
|
||||||
|
if (DateTime.UtcNow - _lastSpeechAt > timeout)
|
||||||
|
{
|
||||||
|
_endReason = EndReason.Idle;
|
||||||
|
_endRequested = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonObject?> ReceiveOrIdleAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||||
|
var receive = _upstream.ReceiveJsonAsync(timeout.Token);
|
||||||
|
var delay = Task.Delay(250, timeout.Token);
|
||||||
|
var done = await Task.WhenAny(receive, delay);
|
||||||
|
if (done == receive) return await receive;
|
||||||
|
timeout.Cancel();
|
||||||
|
try { await receive; } catch { /* ignore */ }
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PumpUplinkAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (var frame in _uplink.Reader.ReadAllAsync(ct))
|
||||||
|
{
|
||||||
|
await _upstream.SendJsonAsync(RealtimeEvents.InputAudioBufferAppend(frame), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch (Exception ex) { _logger.LogWarning(ex, "uplink pump errored"); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<JsonObject?> WaitForUpstreamTypeAsync(string type, CancellationToken ct)
|
||||||
|
{
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
var evt = await _upstream.ReceiveJsonAsync(ct);
|
||||||
|
if (evt is null) return null;
|
||||||
|
if ((string?)evt["type"] == type) return evt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using backend.Conversations;
|
||||||
|
using backend.Tools;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace backend.Realtime;
|
||||||
|
|
||||||
|
public class RealtimeSessionFactory
|
||||||
|
{
|
||||||
|
protected readonly OpenAIKeyProvider keys;
|
||||||
|
protected readonly ToolRegistry registry;
|
||||||
|
protected readonly ConversationLog log;
|
||||||
|
protected readonly ILoggerFactory loggerFactory;
|
||||||
|
|
||||||
|
public RealtimeSessionFactory(
|
||||||
|
OpenAIKeyProvider keys, ToolRegistry registry,
|
||||||
|
ConversationLog log, ILoggerFactory loggerFactory)
|
||||||
|
{
|
||||||
|
this.keys = keys;
|
||||||
|
this.registry = registry;
|
||||||
|
this.log = log;
|
||||||
|
this.loggerFactory = loggerFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public virtual async Task<RealtimeSession> CreateAsync(
|
||||||
|
Guid deviceId,
|
||||||
|
RealtimeSettings cfg,
|
||||||
|
ISessionOutput output,
|
||||||
|
IDeviceChannel channel,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var upstream = new OpenAIRealtimeUpstream();
|
||||||
|
await upstream.ConnectAsync(keys.GetRequired(), cfg.Model, ct);
|
||||||
|
return new RealtimeSession(
|
||||||
|
deviceId, upstream, output, log, registry, cfg, channel,
|
||||||
|
loggerFactory.CreateLogger<RealtimeSession>());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace backend.Realtime;
|
||||||
|
|
||||||
|
public record RealtimeSettings(
|
||||||
|
string Model,
|
||||||
|
string Voice,
|
||||||
|
string SystemPrompt,
|
||||||
|
int IdleTimeoutSeconds,
|
||||||
|
IReadOnlySet<string> EnabledTools);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace backend.Tools;
|
||||||
|
|
||||||
|
public class EndSessionTool : ITool
|
||||||
|
{
|
||||||
|
public string Name => "end_session";
|
||||||
|
public string Description =>
|
||||||
|
"Call this when the user has said goodbye or otherwise indicates the conversation is over.";
|
||||||
|
public bool RunsDuringResponse => false;
|
||||||
|
|
||||||
|
public JsonElement ParameterSchema { get; } =
|
||||||
|
JsonDocument.Parse("""{"type":"object","properties":{},"required":[]}""").RootElement;
|
||||||
|
|
||||||
|
public Task<ToolResult> ExecuteAsync(ToolInvocation invocation, DeviceContext device, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var output = JsonDocument.Parse("""{"ok":true}""").RootElement;
|
||||||
|
return Task.FromResult(new ToolResult(output));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace backend.Tools;
|
||||||
|
|
||||||
|
public class GetCurrentTimeTool : ITool
|
||||||
|
{
|
||||||
|
public string Name => "get_current_time";
|
||||||
|
public string Description => "Returns the current UTC time as an ISO-8601 string.";
|
||||||
|
public bool RunsDuringResponse => false;
|
||||||
|
|
||||||
|
public JsonElement ParameterSchema { get; } =
|
||||||
|
JsonDocument.Parse("""{"type":"object","properties":{},"required":[]}""").RootElement;
|
||||||
|
|
||||||
|
public Task<ToolResult> ExecuteAsync(ToolInvocation invocation, DeviceContext device, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ");
|
||||||
|
var output = JsonDocument.Parse($$"""{"now":"{{now}}"}""").RootElement;
|
||||||
|
return Task.FromResult(new ToolResult(output));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace backend.Tools;
|
||||||
|
|
||||||
|
public interface IDeviceChannel
|
||||||
|
{
|
||||||
|
Task<JsonElement> CallPiToolAsync(string name, JsonElement args, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace backend.Tools;
|
||||||
|
|
||||||
|
public record DeviceContext(Guid DeviceId, Guid ConversationId, IDeviceChannel Channel);
|
||||||
|
public record ToolInvocation(string CallId, JsonElement Arguments);
|
||||||
|
public record ToolResult(JsonElement Output);
|
||||||
|
|
||||||
|
public interface ITool
|
||||||
|
{
|
||||||
|
string Name { get; }
|
||||||
|
string Description { get; }
|
||||||
|
JsonElement ParameterSchema { get; }
|
||||||
|
bool RunsDuringResponse { get; }
|
||||||
|
Task<ToolResult> ExecuteAsync(
|
||||||
|
ToolInvocation invocation,
|
||||||
|
DeviceContext device,
|
||||||
|
CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace backend.Tools;
|
||||||
|
|
||||||
|
public class SetVolumeTool : ITool
|
||||||
|
{
|
||||||
|
public string Name => "set_volume";
|
||||||
|
public string Description =>
|
||||||
|
"Sets the assistant's audio output volume on the device. Level is 0..100.";
|
||||||
|
public bool RunsDuringResponse => true;
|
||||||
|
|
||||||
|
public JsonElement ParameterSchema { get; } = JsonDocument.Parse(
|
||||||
|
"""
|
||||||
|
{
|
||||||
|
"type":"object",
|
||||||
|
"properties":{"level":{"type":"integer","minimum":0,"maximum":100}},
|
||||||
|
"required":["level"]
|
||||||
|
}
|
||||||
|
""").RootElement;
|
||||||
|
|
||||||
|
public async Task<ToolResult> ExecuteAsync(ToolInvocation invocation, DeviceContext device, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!invocation.Arguments.TryGetProperty("level", out var lvlEl)
|
||||||
|
|| !lvlEl.TryGetInt32(out var level)
|
||||||
|
|| level < 0 || level > 100)
|
||||||
|
{
|
||||||
|
var err = JsonDocument.Parse("""{"ok":false,"error":"level must be an integer in 0..100"}""").RootElement;
|
||||||
|
return new ToolResult(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
var piReply = await device.Channel.CallPiToolAsync(Name, invocation.Arguments, ct);
|
||||||
|
return new ToolResult(piReply);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace backend.Tools;
|
||||||
|
|
||||||
|
public class ToolRegistry
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, ITool> _byName;
|
||||||
|
private readonly List<ITool> _all;
|
||||||
|
|
||||||
|
public ToolRegistry(IEnumerable<ITool> tools)
|
||||||
|
{
|
||||||
|
_all = tools.ToList();
|
||||||
|
_byName = _all.ToDictionary(t => t.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyList<ITool> All => _all;
|
||||||
|
|
||||||
|
public ITool? Get(string name) => _byName.GetValueOrDefault(name);
|
||||||
|
|
||||||
|
public IEnumerable<ITool> EnabledFor(IReadOnlySet<string> enabled)
|
||||||
|
=> _all.Where(t => enabled.Contains(t.Name));
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user