Skip to content

Commit 25ba729

Browse files
Integrate AG-UI protocol into Web API layer
- Update StreamChatController to use AG-UI events and AguiEventStreamResult - Add profile-specific streaming endpoint {profileIdOrAlias}/stream - Fix AguiStreamOptions to use camelCase (via explicit JsonPropertyName) - Add Umbraco.Ai.Agui project reference to Web project - Update CompleteChatController for consistency - Update related unit tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f25fb35 commit 25ba729

7 files changed

Lines changed: 851 additions & 731 deletions

File tree

src/Umbraco.Ai.Agui/Streaming/AguiStreamOptions.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,10 @@ public sealed class AguiStreamOptions
1515
/// <summary>
1616
/// Gets or sets the JSON serializer options.
1717
/// Configured for AG-UI protocol compliance with polymorphic type handling.
18+
/// Uses camelCase via explicit JsonPropertyName attributes on all event types.
1819
/// </summary>
1920
public JsonSerializerOptions JsonSerializerOptions { get; set; } = new(JsonSerializerDefaults.Web)
2021
{
21-
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
2222
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
2323
AllowOutOfOrderMetadataProperties = true
2424
};

src/Umbraco.Ai.Web/Api/Management/Chat/Controllers/CompleteChatController.cs

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using Umbraco.Ai.Core.Profiles;
88
using Umbraco.Ai.Extensions;
99
using Umbraco.Ai.Web.Api.Common.Configuration;
10+
using Umbraco.Ai.Web.Api.Common.Models;
1011
using Umbraco.Ai.Web.Api.Management.Chat.Models;
1112
using Umbraco.Ai.Web.Api.Management.Configuration;
1213
using Umbraco.Cms.Core.Mapping;
@@ -51,17 +52,42 @@ public CompleteChatController(
5152
public async Task<IActionResult> CompleteChat(
5253
ChatRequestModel requestModel,
5354
CancellationToken cancellationToken = default)
55+
=> await CompleteChatAsync(
56+
requestModel,
57+
null,
58+
cancellationToken);
59+
60+
/// <summary>
61+
/// Complete a chat conversation (non-streaming).
62+
/// </summary>
63+
/// <param name="profileIdOrAlias"></param>
64+
/// <param name="requestModel">The chat request.</param>
65+
/// <param name="cancellationToken">Cancellation token.</param>
66+
/// <returns>The chat completion response.</returns>
67+
[HttpPost("{profileIdOrAlias}/complete")]
68+
[MapToApiVersion("1.0")]
69+
[ProducesResponseType(typeof(ChatResponseModel), StatusCodes.Status200OK)]
70+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
71+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
72+
public async Task<IActionResult> CompleteChatWithProfile(
73+
IdOrAlias profileIdOrAlias,
74+
ChatRequestModel requestModel,
75+
CancellationToken cancellationToken = default)
76+
=> await CompleteChatAsync(
77+
requestModel,
78+
await _profileService.TryGetProfileIdAsync(profileIdOrAlias, cancellationToken),
79+
cancellationToken);
80+
81+
private async Task<IActionResult> CompleteChatAsync(
82+
ChatRequestModel requestModel,
83+
Guid? profileId,
84+
CancellationToken cancellationToken)
5485
{
5586
try
5687
{
5788
// Convert request messages to ChatMessage list
5889
var messages = _umbracoMapper.MapEnumerable<ChatMessageModel, ChatMessage>(requestModel.Messages).ToList();
5990

60-
// Resolve profile ID from IdOrAlias
61-
var profileId = requestModel.ProfileIdOrAlias != null
62-
? await _profileService.TryGetProfileIdAsync(requestModel.ProfileIdOrAlias, cancellationToken)
63-
: null;
64-
6591
// Get chat response
6692
var response = profileId.HasValue
6793
? await _chatService.GetResponseAsync(
@@ -74,10 +100,6 @@ public async Task<IActionResult> CompleteChat(
74100

75101
return Ok(_umbracoMapper.Map<ChatResponseModel>(response));
76102
}
77-
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
78-
{
79-
return ProfileNotFound();
80-
}
81103
catch (InvalidOperationException ex)
82104
{
83105
return BadRequest(new ProblemDetails
Lines changed: 158 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,210 @@
1-
using System.Text.Json;
1+
using System.Runtime.CompilerServices;
2+
using System.Threading.Channels;
23
using Asp.Versioning;
3-
using Microsoft.AspNetCore.Authorization;
44
using Microsoft.AspNetCore.Http;
55
using Microsoft.AspNetCore.Mvc;
66
using Microsoft.Extensions.AI;
7+
using Umbraco.Ai.Agui.Events;
8+
using Umbraco.Ai.Agui.Events.Lifecycle;
9+
using Umbraco.Ai.Agui.Events.Messages;
10+
using Umbraco.Ai.Agui.Models;
11+
using Umbraco.Ai.Agui.Streaming;
712
using Umbraco.Ai.Core.Chat;
813
using Umbraco.Ai.Core.Profiles;
914
using Umbraco.Ai.Extensions;
10-
using Umbraco.Ai.Web.Api.Common.Configuration;
15+
using Umbraco.Ai.Web.Api.Common.Models;
1116
using Umbraco.Ai.Web.Api.Management.Chat.Models;
1217
using Umbraco.Ai.Web.Api.Management.Configuration;
13-
using Umbraco.Cms.Core.Mapping;
14-
using Umbraco.Cms.Web.Common.Authorization;
1518

1619
namespace Umbraco.Ai.Web.Api.Management.Chat.Controllers;
1720

1821
/// <summary>
19-
/// Controller for streaming chat completion using Server-Sent Events (SSE).
22+
/// Controller for streaming chat completion using AG-UI protocol over Server-Sent Events (SSE).
2023
/// </summary>
2124
[ApiVersion("1.0")]
2225
public class StreamChatController : ChatControllerBase
2326
{
2427
private readonly IAiChatService _chatService;
2528
private readonly IAiProfileService _profileService;
26-
private readonly IUmbracoMapper _umbracoMapper;
2729

2830
/// <summary>
2931
/// Initializes a new instance of the <see cref="StreamChatController"/> class.
3032
/// </summary>
3133
public StreamChatController(
3234
IAiChatService chatService,
33-
IAiProfileService profileService,
34-
IUmbracoMapper umbracoMapper)
35+
IAiProfileService profileService)
3536
{
3637
_chatService = chatService;
3738
_profileService = profileService;
38-
_umbracoMapper = umbracoMapper;
3939
}
40-
40+
4141
/// <summary>
42-
/// Complete a chat conversation with streaming response (SSE).
42+
/// Complete a chat conversation with AG-UI streaming response (SSE).
4343
/// </summary>
44-
/// <param name="requestModel">The chat request.</param>
44+
/// <param name="request">The AG-UI run request.</param>
4545
/// <param name="cancellationToken">Cancellation token.</param>
46-
/// <returns>A stream of chat completion chunks.</returns>
46+
/// <returns>A stream of AG-UI events.</returns>
4747
[HttpPost("stream")]
4848
[MapToApiVersion("1.0")]
4949
[Produces("text/event-stream")]
5050
[ProducesResponseType(StatusCodes.Status200OK)]
5151
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
52-
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
53-
public async Task StreamChat(
54-
ChatRequestModel requestModel,
52+
public async Task<IResult> StreamChat(
53+
AguiRunRequest request,
54+
CancellationToken cancellationToken = default)
55+
{
56+
var events = StreamAguiEventsAsync(request, null, cancellationToken);
57+
58+
return new AguiEventStreamResult(events);
59+
}
60+
61+
/// <summary>
62+
/// Complete a chat conversation with AG-UI streaming response (SSE).
63+
/// </summary>
64+
/// <param name="profileIdOrAlias"></param>
65+
/// <param name="request">The AG-UI run request.</param>
66+
/// <param name="cancellationToken">Cancellation token.</param>
67+
/// <returns>A stream of AG-UI events.</returns>
68+
[HttpPost("{profileIdOrAlias}/stream")]
69+
[MapToApiVersion("1.0")]
70+
[Produces("text/event-stream")]
71+
[ProducesResponseType(StatusCodes.Status200OK)]
72+
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
73+
public async Task<IResult> StreamChatWithProfile(
74+
IdOrAlias profileIdOrAlias,
75+
AguiRunRequest request,
5576
CancellationToken cancellationToken = default)
5677
{
57-
Response.ContentType = "text/event-stream";
58-
Response.Headers.CacheControl = "no-cache";
59-
Response.Headers.Connection = "keep-alive";
78+
var profileId = await _profileService.TryGetProfileIdAsync(profileIdOrAlias, cancellationToken);
79+
80+
var events = StreamAguiEventsAsync(request, profileId, cancellationToken);
81+
82+
return new AguiEventStreamResult(events);
83+
}
84+
85+
private async IAsyncEnumerable<IAguiEvent> StreamAguiEventsAsync(
86+
AguiRunRequest request,
87+
Guid? profileId,
88+
[EnumeratorCancellation] CancellationToken cancellationToken)
89+
{
90+
var threadId = string.IsNullOrEmpty(request.ThreadId) ? Guid.NewGuid().ToString() : request.ThreadId;
91+
var runId = string.IsNullOrEmpty(request.RunId) ? Guid.NewGuid().ToString() : request.RunId;
92+
var messageId = Guid.NewGuid().ToString();
93+
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
94+
95+
// Emit RunStarted
96+
yield return new RunStartedEvent
97+
{
98+
ThreadId = threadId,
99+
RunId = runId,
100+
Timestamp = timestamp
101+
};
102+
103+
// Emit TextMessageStart for the assistant response
104+
yield return new TextMessageStartEvent
105+
{
106+
MessageId = messageId,
107+
Role = AguiMessageRole.Assistant,
108+
Timestamp = timestamp
109+
};
110+
111+
// Use Channel for streaming with proper error handling
112+
var channel = Channel.CreateUnbounded<IAguiEvent>();
113+
var hasError = false;
114+
string? errorMessage = null;
60115

61-
try
116+
// Start background task to produce events
117+
_ = Task.Run(async () =>
62118
{
63-
// Convert request messages to ChatMessage list
64-
var messages = _umbracoMapper.MapEnumerable<ChatMessageModel, ChatMessage>(requestModel.Messages).ToList();
65-
66-
// Resolve profile ID from IdOrAlias
67-
var profileId = requestModel.ProfileIdOrAlias != null
68-
? await _profileService.TryGetProfileIdAsync(requestModel.ProfileIdOrAlias, cancellationToken)
69-
: null;
70-
71-
// Get streaming chat response
72-
var stream = profileId.HasValue
73-
? _chatService.GetStreamingResponseAsync(
74-
profileId.Value,
75-
messages,
76-
cancellationToken: cancellationToken)
77-
: _chatService.GetStreamingResponseAsync(
78-
messages,
79-
cancellationToken: cancellationToken);
80-
81-
await foreach (var update in stream.WithCancellation(cancellationToken))
119+
try
82120
{
83-
var chunk = _umbracoMapper.Map<ChatStreamChunkModel>(update);
121+
// Convert AG-UI messages to M.E.AI ChatMessages
122+
var chatMessages = ConvertToChatMessages(request.Messages);
84123

85-
var json = JsonSerializer.Serialize(chunk);
86-
await Response.WriteAsync($"data: {json}\n\n", cancellationToken);
87-
await Response.Body.FlushAsync(cancellationToken);
124+
// Get streaming response from chat service
125+
var stream = profileId.HasValue
126+
? _chatService.GetStreamingResponseAsync(profileId.Value, chatMessages, cancellationToken: cancellationToken)
127+
: _chatService.GetStreamingResponseAsync(chatMessages, cancellationToken: cancellationToken);
128+
129+
// Stream text content updates
130+
await foreach (var update in stream.WithCancellation(cancellationToken))
131+
{
132+
var text = update.Text;
133+
if (!string.IsNullOrEmpty(text))
134+
{
135+
await channel.Writer.WriteAsync(new TextMessageContentEvent
136+
{
137+
MessageId = messageId,
138+
Delta = text,
139+
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
140+
}, cancellationToken);
141+
}
142+
}
143+
}
144+
catch (Exception ex)
145+
{
146+
hasError = true;
147+
errorMessage = ex.Message;
148+
}
149+
finally
150+
{
151+
channel.Writer.Complete();
88152
}
153+
}, cancellationToken);
89154

90-
// Send final event
91-
await Response.WriteAsync("data: [DONE]\n\n", cancellationToken);
92-
await Response.Body.FlushAsync(cancellationToken);
155+
// Read from channel and yield events
156+
await foreach (var evt in channel.Reader.ReadAllAsync(cancellationToken))
157+
{
158+
yield return evt;
93159
}
94-
catch (InvalidOperationException ex) when (ex.Message.Contains("not found"))
160+
161+
// Emit TextMessageEnd
162+
yield return new TextMessageEndEvent
95163
{
96-
Response.StatusCode = StatusCodes.Status404NotFound;
97-
var error = JsonSerializer.Serialize(new { error = "Profile not found", detail = ex.Message });
98-
await Response.WriteAsync($"data: {error}\n\n", cancellationToken);
164+
MessageId = messageId,
165+
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
166+
};
167+
168+
if (hasError)
169+
{
170+
// Emit error event
171+
yield return new RunErrorEvent
172+
{
173+
Message = errorMessage ?? "Unknown error occurred",
174+
Code = "CHAT_ERROR",
175+
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
176+
};
99177
}
100-
catch (Exception ex)
178+
179+
// Emit RunFinished
180+
yield return new RunFinishedEvent
101181
{
102-
Response.StatusCode = StatusCodes.Status400BadRequest;
103-
var error = JsonSerializer.Serialize(new { error = "Chat streaming failed", detail = ex.Message });
104-
await Response.WriteAsync($"data: {error}\n\n", cancellationToken);
182+
ThreadId = threadId,
183+
RunId = runId,
184+
Outcome = hasError ? AguiRunOutcome.Interrupt : AguiRunOutcome.Success,
185+
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
186+
};
187+
}
188+
189+
private static List<ChatMessage> ConvertToChatMessages(IEnumerable<AguiMessage> messages)
190+
{
191+
var chatMessages = new List<ChatMessage>();
192+
193+
foreach (var msg in messages)
194+
{
195+
var role = msg.Role switch
196+
{
197+
AguiMessageRole.User => ChatRole.User,
198+
AguiMessageRole.Assistant => ChatRole.Assistant,
199+
AguiMessageRole.System => ChatRole.System,
200+
AguiMessageRole.Tool => ChatRole.Tool,
201+
AguiMessageRole.Developer => ChatRole.System, // Map developer to system
202+
_ => ChatRole.User
203+
};
204+
205+
chatMessages.Add(new ChatMessage(role, msg.Content ?? string.Empty));
105206
}
207+
208+
return chatMessages;
106209
}
107210
}

src/Umbraco.Ai.Web/Api/Management/Chat/Models/ChatRequestModel.cs

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,6 @@ namespace Umbraco.Ai.Web.Api.Management.Chat.Models;
88
/// </summary>
99
public class ChatRequestModel
1010
{
11-
/// <summary>
12-
/// The profile to use for chat completion, specified by ID or alias.
13-
/// If not specified, the default chat profile will be used.
14-
/// </summary>
15-
public IdOrAlias? ProfileIdOrAlias { get; set; }
16-
1711
/// <summary>
1812
/// The chat messages to send.
1913
/// </summary>

src/Umbraco.Ai.Web/Umbraco.Ai.Web.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
</ItemGroup>
1313

1414
<ItemGroup>
15+
<ProjectReference Include="..\Umbraco.Ai.Agui\Umbraco.Ai.Agui.csproj" />
1516
<ProjectReference Include="..\Umbraco.Ai.Core\Umbraco.Ai.Core.csproj" />
1617
</ItemGroup>
1718

0 commit comments

Comments
 (0)