Skip to content

Commit b955601

Browse files
authored
Make the Chat UI Extensible with Notifications (#380)
1 parent 5cc6cf1 commit b955601

39 files changed

+2914
-50
lines changed

Directory.Packages.props

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
</PropertyGroup>
88
<ItemGroup>
99
<!-- Miscellaneous Packages -->
10-
<PackageVersion Include="CrestApps.AgentSkills.OrchardCore" Version="1.0.0-preview-0001" />
11-
<PackageVersion Include="CrestApps.AgentSkills.Mcp.OrchardCore" Version="1.0.0-preview-0001" />
10+
<PackageVersion Include="CrestApps.AgentSkills.OrchardCore" Version="1.0.0-preview-0002" />
11+
<PackageVersion Include="CrestApps.AgentSkills.Mcp.OrchardCore" Version="1.0.0-preview-0002" />
1212
<PackageVersion Include="DocumentFormat.OpenXml" Version="3.4.1" />
1313
<PackageVersion Include="Fluid.Core" Version="2.31.0" />
1414
<PackageVersion Include="FluentFTP" Version="53.0.2" />
@@ -102,10 +102,11 @@
102102
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="10.4.0" />
103103
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.4.0" />
104104
<PackageVersion Include="Microsoft.Extensions.AI.AzureAIInference" Version="10.0.0-preview.1.25559.3" />
105-
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.4" />
106-
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.4" />
107-
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.4" />
108-
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.4" />
105+
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.5" />
106+
<PackageVersion Include="Microsoft.Extensions.FileProviders.Physical" Version="10.0.5" />
107+
<PackageVersion Include="Microsoft.Extensions.Localization.Abstractions" Version="10.0.5" />
108+
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
109+
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.5" />
109110
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.4.0" />
110111
</ItemGroup>
111112
<ItemGroup>
Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
using CrestApps.OrchardCore.AI.Models;
2+
using Microsoft.Extensions.Localization;
3+
4+
namespace CrestApps.OrchardCore.AI;
5+
6+
/// <summary>
7+
/// Provides convenient extension methods for <see cref="IChatNotificationSender"/>
8+
/// to send common notification types without manually constructing <see cref="ChatNotification"/> objects.
9+
/// </summary>
10+
public static class ChatNotificationSenderExtensions
11+
{
12+
/// <summary>
13+
/// Well-known notification IDs used by built-in notification types.
14+
/// </summary>
15+
public static class NotificationIds
16+
{
17+
public const string Typing = "typing";
18+
public const string Transfer = "transfer";
19+
public const string AgentConnected = "agent-connected";
20+
public const string ConversationEnded = "conversation-ended";
21+
public const string SessionEnded = "session-ended";
22+
}
23+
24+
/// <summary>
25+
/// Well-known notification action names.
26+
/// </summary>
27+
public static class ActionNames
28+
{
29+
public const string CancelTransfer = "cancel-transfer";
30+
public const string EndSession = "end-session";
31+
}
32+
33+
/// <summary>
34+
/// Shows a typing indicator bubble (e.g., "Mike is typing…").
35+
/// </summary>
36+
/// <param name="sender">The notification sender.</param>
37+
/// <param name="sessionId">The session or interaction identifier.</param>
38+
/// <param name="chatType">The type of chat context.</param>
39+
/// <param name="T">The string localizer for translating user-facing messages.</param>
40+
/// <param name="agentName">Optional name of the agent who is typing.</param>
41+
public static Task ShowTypingAsync(
42+
this IChatNotificationSender sender,
43+
string sessionId,
44+
ChatContextType chatType,
45+
IStringLocalizer T,
46+
string agentName = null)
47+
{
48+
var content = string.IsNullOrEmpty(agentName)
49+
? T["Agent is typing"].Value
50+
: T["{0} is typing", agentName].Value;
51+
52+
return sender.SendAsync(sessionId, chatType, new ChatNotification
53+
{
54+
Id = NotificationIds.Typing,
55+
Type = "typing",
56+
Content = content,
57+
Icon = "fa-solid fa-ellipsis",
58+
});
59+
}
60+
61+
/// <summary>
62+
/// Hides a previously shown typing indicator.
63+
/// </summary>
64+
/// <param name="sender">The notification sender.</param>
65+
/// <param name="sessionId">The session or interaction identifier.</param>
66+
/// <param name="chatType">The type of chat context.</param>
67+
public static Task HideTypingAsync(
68+
this IChatNotificationSender sender,
69+
string sessionId,
70+
ChatContextType chatType)
71+
{
72+
return sender.RemoveAsync(sessionId, chatType, NotificationIds.Typing);
73+
}
74+
75+
/// <summary>
76+
/// Shows a transfer indicator bubble with optional wait time and cancel button.
77+
/// </summary>
78+
/// <param name="sender">The notification sender.</param>
79+
/// <param name="sessionId">The session or interaction identifier.</param>
80+
/// <param name="chatType">The type of chat context.</param>
81+
/// <param name="T">The string localizer for translating user-facing messages.</param>
82+
/// <param name="message">The transfer message. When <see langword="null"/>, a localized default is used.</param>
83+
/// <param name="estimatedWaitTime">Optional estimated wait time string (e.g., "2 minutes").</param>
84+
/// <param name="cancellable">Whether to show a cancel button. Defaults to <see langword="true"/>.</param>
85+
public static Task ShowTransferAsync(
86+
this IChatNotificationSender sender,
87+
string sessionId,
88+
ChatContextType chatType,
89+
IStringLocalizer T,
90+
string message = null,
91+
string estimatedWaitTime = null,
92+
bool cancellable = true)
93+
{
94+
var content = message ?? T["Transferring you to a live agent..."].Value;
95+
96+
if (!string.IsNullOrEmpty(estimatedWaitTime))
97+
{
98+
content += " " + T["Estimated wait: {0}.", estimatedWaitTime].Value;
99+
}
100+
101+
var notification = new ChatNotification
102+
{
103+
Id = NotificationIds.Transfer,
104+
Type = "transfer",
105+
Content = content,
106+
Icon = "fa-solid fa-headset",
107+
};
108+
109+
if (cancellable)
110+
{
111+
notification.Actions =
112+
[
113+
new ChatNotificationAction
114+
{
115+
Name = ActionNames.CancelTransfer,
116+
Label = T["Cancel Transfer"].Value,
117+
CssClass = "btn-outline-danger",
118+
Icon = "fa-solid fa-xmark",
119+
},
120+
];
121+
}
122+
123+
if (!string.IsNullOrEmpty(estimatedWaitTime))
124+
{
125+
notification.Metadata = new Dictionary<string, string>
126+
{
127+
["estimatedWaitTime"] = estimatedWaitTime,
128+
};
129+
}
130+
131+
return sender.SendAsync(sessionId, chatType, notification);
132+
}
133+
134+
/// <summary>
135+
/// Updates the transfer indicator with new information (e.g., updated wait time).
136+
/// </summary>
137+
/// <param name="sender">The notification sender.</param>
138+
/// <param name="sessionId">The session or interaction identifier.</param>
139+
/// <param name="chatType">The type of chat context.</param>
140+
/// <param name="localizer">The string localizer for translating user-facing messages.</param>
141+
/// <param name="message">The updated transfer message.</param>
142+
/// <param name="estimatedWaitTime">Optional updated estimated wait time.</param>
143+
/// <param name="cancellable">Whether to show a cancel button.</param>
144+
public static Task UpdateTransferAsync(
145+
this IChatNotificationSender sender,
146+
string sessionId,
147+
ChatContextType chatType,
148+
IStringLocalizer localizer,
149+
string message = null,
150+
string estimatedWaitTime = null,
151+
bool cancellable = true)
152+
{
153+
return ShowTransferAsync(sender, sessionId, chatType, localizer, message, estimatedWaitTime, cancellable);
154+
}
155+
156+
/// <summary>
157+
/// Hides a previously shown transfer indicator.
158+
/// </summary>
159+
/// <param name="sender">The notification sender.</param>
160+
/// <param name="sessionId">The session or interaction identifier.</param>
161+
/// <param name="chatType">The type of chat context.</param>
162+
public static Task HideTransferAsync(
163+
this IChatNotificationSender sender,
164+
string sessionId,
165+
ChatContextType chatType)
166+
{
167+
return sender.RemoveAsync(sessionId, chatType, NotificationIds.Transfer);
168+
}
169+
170+
/// <summary>
171+
/// Shows an "agent connected" bubble indicating a live agent has joined the session.
172+
/// </summary>
173+
/// <param name="sender">The notification sender.</param>
174+
/// <param name="sessionId">The session or interaction identifier.</param>
175+
/// <param name="chatType">The type of chat context.</param>
176+
/// <param name="T">The string localizer for translating user-facing messages.</param>
177+
/// <param name="agentName">Optional name of the connected agent.</param>
178+
/// <param name="message">Optional custom message. When <see langword="null"/>, a localized default is used.</param>
179+
public static Task ShowAgentConnectedAsync(
180+
this IChatNotificationSender sender,
181+
string sessionId,
182+
ChatContextType chatType,
183+
IStringLocalizer T,
184+
string agentName = null,
185+
string message = null)
186+
{
187+
var content = message
188+
?? (string.IsNullOrEmpty(agentName)
189+
? T["You are now connected to a live agent."].Value
190+
: T["You are now connected to {0}.", agentName].Value);
191+
192+
return sender.SendAsync(sessionId, chatType, new ChatNotification
193+
{
194+
Id = NotificationIds.AgentConnected,
195+
Type = "info",
196+
Content = content,
197+
Icon = "fa-solid fa-user-check",
198+
Dismissible = true,
199+
});
200+
}
201+
202+
/// <summary>
203+
/// Hides a previously shown agent-connected notification.
204+
/// </summary>
205+
/// <param name="sender">The notification sender.</param>
206+
/// <param name="sessionId">The session or interaction identifier.</param>
207+
/// <param name="chatType">The type of chat context.</param>
208+
public static Task HideAgentConnectedAsync(
209+
this IChatNotificationSender sender,
210+
string sessionId,
211+
ChatContextType chatType)
212+
{
213+
return sender.RemoveAsync(sessionId, chatType, NotificationIds.AgentConnected);
214+
}
215+
216+
/// <summary>
217+
/// Shows a "conversation ended" bubble. The user can still send messages
218+
/// via text or audio input, but this indicates the conversation mode has ended.
219+
/// </summary>
220+
/// <param name="sender">The notification sender.</param>
221+
/// <param name="sessionId">The session or interaction identifier.</param>
222+
/// <param name="chatType">The type of chat context.</param>
223+
/// <param name="T">The string localizer for translating user-facing messages.</param>
224+
/// <param name="message">The message to display. When <see langword="null"/>, a localized default is used.</param>
225+
public static Task ShowConversationEndedAsync(
226+
this IChatNotificationSender sender,
227+
string sessionId,
228+
ChatContextType chatType,
229+
IStringLocalizer T,
230+
string message = null)
231+
{
232+
return sender.SendAsync(sessionId, chatType, new ChatNotification
233+
{
234+
Id = NotificationIds.ConversationEnded,
235+
Type = "ended",
236+
Content = message ?? T["Conversation ended."].Value,
237+
Icon = "fa-solid fa-circle-check",
238+
Dismissible = true,
239+
});
240+
}
241+
242+
/// <summary>
243+
/// Shows a "session ended" bubble and optionally allows ending the session programmatically.
244+
/// </summary>
245+
/// <param name="sender">The notification sender.</param>
246+
/// <param name="sessionId">The session or interaction identifier.</param>
247+
/// <param name="chatType">The type of chat context.</param>
248+
/// <param name="T">The string localizer for translating user-facing messages.</param>
249+
/// <param name="message">The message to display. When <see langword="null"/>, a localized default is used.</param>
250+
public static Task ShowSessionEndedAsync(
251+
this IChatNotificationSender sender,
252+
string sessionId,
253+
ChatContextType chatType,
254+
IStringLocalizer T,
255+
string message = null)
256+
{
257+
return sender.SendAsync(sessionId, chatType, new ChatNotification
258+
{
259+
Id = NotificationIds.SessionEnded,
260+
Type = "ended",
261+
Content = message ?? T["This chat session has ended."].Value,
262+
Icon = "fa-solid fa-circle-check",
263+
Dismissible = true,
264+
});
265+
}
266+
}

src/Abstractions/CrestApps.OrchardCore.AI.Abstractions/CrestApps.OrchardCore.AI.Abstractions.csproj

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

1414
<ItemGroup>
15+
<PackageReference Include="Microsoft.Extensions.Localization.Abstractions" />
1516
<PackageReference Include="OrchardCore.Infrastructure.Abstractions" />
1617
<PackageReference Include="Microsoft.Extensions.AI.Abstractions" />
1718
<PackageReference Include="OrchardCore.Indexing.Abstractions" />
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using CrestApps.OrchardCore.AI.Models;
2+
3+
namespace CrestApps.OrchardCore.AI;
4+
5+
/// <summary>
6+
/// Handles user-initiated actions on chat notification bubbles.
7+
/// When a user clicks an action button on a notification (e.g., "Cancel Transfer"),
8+
/// the hub resolves a keyed service whose key matches the action name.
9+
/// Register implementations using <c>services.AddKeyedScoped&lt;IChatNotificationActionHandler, YourHandler&gt;("your-action-name")</c>.
10+
/// </summary>
11+
public interface IChatNotificationActionHandler
12+
{
13+
/// <summary>
14+
/// Handles the notification action triggered by the user.
15+
/// </summary>
16+
/// <param name="context">The context containing session details, notification ID, and service provider.</param>
17+
/// <param name="cancellationToken">A token to cancel the operation.</param>
18+
Task HandleAsync(ChatNotificationActionContext context, CancellationToken cancellationToken = default);
19+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using CrestApps.OrchardCore.AI.Models;
2+
3+
namespace CrestApps.OrchardCore.AI;
4+
5+
/// <summary>
6+
/// Sends transient UI notifications to chat clients via SignalR.
7+
/// Notifications appear as visual bubbles in the chat interface and are separate
8+
/// from chat history. Use this service from webhooks, background tasks, or response
9+
/// handlers to provide real-time feedback to users.
10+
/// </summary>
11+
/// <remarks>
12+
/// <para>Notifications are sent to SignalR groups, so all clients connected to the
13+
/// same session receive the notification. The group name is determined by the
14+
/// <paramref name="chatType"/> parameter.</para>
15+
/// </remarks>
16+
public interface IChatNotificationSender
17+
{
18+
/// <summary>
19+
/// Sends a notification to all clients connected to the specified session.
20+
/// If a notification with the same <see cref="ChatNotification.Id"/> already exists
21+
/// on the client, it will be replaced.
22+
/// </summary>
23+
/// <param name="sessionId">The session or interaction identifier.</param>
24+
/// <param name="chatType">The type of chat context.</param>
25+
/// <param name="notification">The notification to display.</param>
26+
Task SendAsync(string sessionId, ChatContextType chatType, ChatNotification notification);
27+
28+
/// <summary>
29+
/// Updates an existing notification on all connected clients.
30+
/// Only replaces the notification if one with a matching <see cref="ChatNotification.Id"/> exists.
31+
/// </summary>
32+
/// <param name="sessionId">The session or interaction identifier.</param>
33+
/// <param name="chatType">The type of chat context.</param>
34+
/// <param name="notification">The updated notification.</param>
35+
Task UpdateAsync(string sessionId, ChatContextType chatType, ChatNotification notification);
36+
37+
/// <summary>
38+
/// Removes a notification from all connected clients.
39+
/// </summary>
40+
/// <param name="sessionId">The session or interaction identifier.</param>
41+
/// <param name="chatType">The type of chat context.</param>
42+
/// <param name="notificationId">The identifier of the notification to remove.</param>
43+
Task RemoveAsync(string sessionId, ChatContextType chatType, string notificationId);
44+
}

0 commit comments

Comments
 (0)