Files
agent-framework/dotnet/samples/AgentWebChat/AgentWebChat.Web/Components/Pages/Home.razor
T
Reuben Bond e7441ee29e .NET: Add agent hosting package and update sample (#296)
* Add agent hosting package and update sample

* Review feedback and cleanup

* Include the narrator

* wip

* wip

* Remove workaround for empty state writes.

* Handle changes to AgentThread.

* One more.

* Fix.

---------

Co-authored-by: Aditya Mandaleeka <adityam@microsoft.com>
2025-08-06 21:26:36 +00:00

741 lines
21 KiB
Plaintext

@page "/"
@attribute [StreamRendering(true)]
@inject AgentDiscoveryClient AgentClient
@inject IJSRuntime JSRuntime
@inject ILogger<Home> Logger
@inject IActorClient ActorClient
@rendermode InteractiveServer
@using System.Text
@using System.Text.Json
@using Microsoft.Extensions.AI
@using Microsoft.Extensions.AI.Agents
@using Microsoft.Extensions.AI.Agents.Hosting
@using Microsoft.Extensions.AI.Agents.Runtime
<PageTitle>Agent Web Chat</PageTitle>
<div class="chat-app-container">
<div class="chat-header">
<h1 class="chat-title">
<svg class="chat-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path>
</svg>
Agent Web Chat
</h1>
<p class="chat-subtitle">The best hypertext-based chat on the Web!</p>
</div>
<div class="agent-selection-card">
<label for="agent-select" class="agent-select-label">Choose your AI agent:</label>
<div class="agent-select-wrapper">
<select id="agent-select" class="agent-select" @bind="selectedAgentName" disabled="@(isLoadingAgents || isStreaming)">
<option value="">-- Select an agent --</option>
@foreach (var agent in availableAgents)
{
<option value="@agent.Name">@GetAgentDisplayName(agent.Name!) - @agent.Description</option>
}
</select>
@if (!string.IsNullOrEmpty(selectedAgentName) && currentConversation == null)
{
<button class="start-chat-btn" @onclick="StartNewConversation">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
Start Chat
</button>
}
</div>
</div>
@if (conversations.Any())
{
<div class="conversations-section">
<div class="conversation-tabs">
@foreach (var conv in conversations)
{
<div class="conversation-tab @(conv.SessionId == currentConversation?.SessionId ? "active" : "")"
@onclick="() => SelectConversation(conv.SessionId)">
<span class="tab-icon">@GetAgentIcon(conv.AgentName)</span>
<span class="tab-name">@GetAgentDisplayName(conv.AgentName)</span>
<button type="button" class="tab-close"
aria-label="Close"
@onclick:stopPropagation="true"
@onclick="() => CloseConversation(conv.SessionId)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
}
</div>
</div>
}
@if (currentConversation != null)
{
<div class="chat-container">
<div class="chat-messages" id="chat-messages">
@foreach (var message in currentConversation.Messages)
{
<div class="message-wrapper @(message.Role == ChatRole.User ? "user" : "agent")">
@if (message.Role != ChatRole.User)
{
<div class="message-avatar">@GetAgentIcon(currentConversation.AgentName)</div>
}
<div class="message-bubble">
<div class="message-content">@message.Text</div>
<div class="message-meta">
@(message.Role == ChatRole.User ? "You" : GetAgentDisplayName(currentConversation.AgentName))
</div>
</div>
</div>
}
@if (isStreaming && currentStreamedMessage.Length > 0)
{
<div class="message-wrapper agent">
<div class="message-avatar">@GetAgentIcon(currentConversation.AgentName)</div>
<div class="message-bubble streaming">
<div class="message-content">
@currentStreamedMessage
<span class="typing-indicator"></span>
</div>
</div>
</div>
}
</div>
<div class="chat-input-container">
<div class="chat-input-wrapper">
<input @bind="currentMessage"
@bind:event="oninput"
@onkeydown="HandleKeyPress"
@onkeydown:preventDefault="ShouldPreventDefault"
class="chat-input"
placeholder="Type your message..."
disabled="@isStreaming" />
<button @onclick="SendMessage"
class="send-button"
disabled="@(isStreaming || string.IsNullOrWhiteSpace(currentMessage))">
@if (isStreaming)
{
<div class="spinner"></div>
}
else
{
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="22" y1="2" x2="11" y2="13"></line>
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
</svg>
}
</button>
</div>
</div>
</div>
}
</div>
<style>
* {
box-sizing: border-box;
}
.chat-app-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
.chat-header {
text-align: center;
margin-bottom: 2rem;
}
.chat-title {
font-size: 2.5rem;
font-weight: 700;
color: #1a1a1a;
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.chat-icon {
color: #6366f1;
}
.chat-subtitle {
color: #6b7280;
font-size: 1.125rem;
margin: 0;
}
.agent-selection-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 2rem;
}
.agent-select-label {
display: block;
font-weight: 600;
color: #374151;
margin-bottom: 0.75rem;
}
.agent-select-wrapper {
display: flex;
gap: 1rem;
align-items: center;
}
.agent-select {
flex: 1;
padding: 0.75rem 1rem;
font-size: 1rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
background: white;
color: #374151;
cursor: pointer;
transition: all 0.2s;
}
.agent-select:hover:not(:disabled) {
border-color: #6366f1;
}
.agent-select:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.agent-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.start-chat-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.start-chat-btn:hover {
background: #4f46e5;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.conversations-section {
margin-bottom: 1.5rem;
}
.conversation-tabs {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.conversation-tab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: white;
border: 2px solid #e5e7eb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
position: relative;
white-space: nowrap;
}
.conversation-tab:hover {
border-color: #6366f1;
background: #f9fafb;
}
.conversation-tab.active {
background: #6366f1;
color: white;
border-color: #6366f1;
}
.tab-icon {
font-size: 1.25rem;
}
.tab-name {
font-weight: 500;
}
.tab-close {
margin-left: 0.5rem;
background: none;
border: none;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.tab-close:hover {
opacity: 1;
}
.conversation-tab.active .tab-close {
color: white;
}
.chat-container {
background: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
display: flex;
flex-direction: column;
height: 600px;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
background: #f9fafb;
}
.message-wrapper {
display: flex;
gap: 0.75rem;
margin-bottom: 1.5rem;
animation: fadeIn 0.3s ease-in-out;
}
.message-wrapper.user {
flex-direction: row-reverse;
}
.message-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #6366f1;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
flex-shrink: 0;
}
.message-bubble {
max-width: 70%;
background: white;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.message-wrapper.user .message-bubble {
background: #6366f1;
color: white;
}
.message-content {
line-height: 1.5;
word-wrap: break-word;
}
.message-meta {
font-size: 0.75rem;
opacity: 0.6;
margin-top: 0.5rem;
}
.message-bubble.streaming {
background: #e0e7ff;
}
.typing-indicator {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #6366f1;
margin-left: 4px;
animation: pulse 1.4s infinite;
}
@@keyframes pulse {
0%, 60%, 100% {
opacity: 0.2;
}
30% {
opacity: 1;
}
}
@@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chat-input-container {
border-top: 1px solid #e5e7eb;
padding: 1rem;
background: white;
}
.chat-input-wrapper {
display: flex;
gap: 0.75rem;
}
.chat-input {
flex: 1;
padding: 0.75rem 1rem;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 1rem;
transition: all 0.2s;
}
.chat-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.chat-input:disabled {
opacity: 0.5;
background: #f9fafb;
}
.send-button {
padding: 0.75rem 1rem;
background: #6366f1;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
min-width: 50px;
}
.send-button:hover:not(:disabled) {
background: #4f46e5;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@@keyframes spin {
to {
transform: rotate(360deg);
}
}
@@media (max-width: 768px) {
.chat-app-container {
padding: 1rem;
}
.chat-title {
font-size: 2rem;
}
.message-bubble {
max-width: 85%;
}
.chat-container {
height: 500px;
}
}
</style>
@code {
private string currentMessage = "";
private bool isStreaming = false;
private bool isLoadingAgents = true;
private string currentStreamedMessage = "";
private string selectedAgentName = "";
private List<AgentDiscoveryClient.AgentDiscoveryCard> availableAgents = new();
private List<Conversation> conversations = new();
private Conversation? currentConversation;
private class Conversation
{
public string SessionId { get; set; } = Guid.NewGuid().ToString();
public string AgentName { get; set; } = "";
public List<ChatMessage> Messages { get; set; } = new();
}
protected override async Task OnInitializedAsync()
{
Logger.LogDebug("Initializing Agent Chat component");
// Load agents
try
{
availableAgents = await AgentClient.GetAgentsAsync();
Logger.LogInformation("Loaded {AgentCount} agents", availableAgents.Count);
// Default to first agent and start a conversation
if (availableAgents.Any())
{
selectedAgentName = availableAgents.First().Name!;
StartNewConversation();
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Failed to load agents");
}
finally
{
isLoadingAgents = false;
}
// Conversations start fresh on page load
}
private string GetAgentIcon(string agentName)
{
return agentName?.ToLower() switch
{
"pirate" => "🏴‍☠️",
"knights-and-knaves" => "⚔️",
_ => "🤖"
};
}
private string GetAgentDisplayName(string agentName)
{
return agentName?.ToLower() switch
{
"pirate" => "Pirate",
"knights-and-knaves" => "Knights & Knaves",
_ => agentName ?? "Agent"
};
}
private void StartNewConversation()
{
if (string.IsNullOrEmpty(selectedAgentName))
return;
var newConversation = new Conversation
{
AgentName = selectedAgentName
};
conversations.Add(newConversation);
currentConversation = newConversation;
Logger.LogInformation("Started new conversation with agent: {AgentName}, session: {SessionId}",
newConversation.AgentName, newConversation.SessionId);
StateHasChanged();
}
private void SelectConversation(string sessionId)
{
currentConversation = conversations.FirstOrDefault(c => c.SessionId == sessionId);
if (currentConversation != null)
{
selectedAgentName = currentConversation.AgentName;
Logger.LogDebug("Selected conversation with session: {SessionId}", sessionId);
}
StateHasChanged();
}
private void CloseConversation(string sessionId)
{
var conversationToRemove = conversations.FirstOrDefault(c => c.SessionId == sessionId);
if (conversationToRemove != null)
{
conversations.Remove(conversationToRemove);
if (currentConversation?.SessionId == sessionId)
{
currentConversation = conversations.FirstOrDefault();
if (currentConversation != null)
{
selectedAgentName = currentConversation.AgentName;
}
}
Logger.LogInformation("Closed conversation with session: {SessionId}", sessionId);
}
StateHasChanged();
}
private async Task SendMessage()
{
if (string.IsNullOrWhiteSpace(currentMessage) || isStreaming || currentConversation == null)
return;
var userMessage = currentMessage.Trim();
currentMessage = "";
Logger.LogInformation("User sending message: '{UserMessage}' to agent {AgentName} in session {SessionId}",
userMessage, currentConversation.AgentName, currentConversation.SessionId);
// Add user message to chat
currentConversation.Messages.Add(new ChatMessage(ChatRole.User, userMessage));
StateHasChanged();
await ScrollToBottom();
// Start streaming response
isStreaming = true;
currentStreamedMessage = "";
StateHasChanged();
try
{
var responseContent = new StringBuilder();
var agent = new AgentProxy(currentConversation.AgentName, ActorClient);
var thread = agent.GetNewThread();
thread.ConversationId = currentConversation.SessionId;
await foreach (var update in agent.RunStreamingAsync(
[new ChatMessage(ChatRole.User, userMessage)],
thread,
cancellationToken: CancellationToken.None))
{
var content = update.Text ?? "";
if (!string.IsNullOrEmpty(content))
{
responseContent.Append(content);
currentStreamedMessage = responseContent.ToString();
StateHasChanged();
await ScrollToBottom();
}
}
// Add the complete agent response to chat messages
if (responseContent.Length > 0)
{
currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, responseContent.ToString()));
}
else
{
currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, "Sorry, I couldn't generate a response."));
}
}
catch (Exception ex)
{
Logger.LogError(ex, "Error occurred while processing message in session {SessionId}: {ErrorMessage}",
currentConversation.SessionId, ex.Message);
currentConversation.Messages.Add(new ChatMessage(ChatRole.Assistant, $"Error: {ex.Message}"));
}
finally
{
isStreaming = false;
currentStreamedMessage = "";
StateHasChanged();
await ScrollToBottom();
}
}
private bool ShouldPreventDefault = false;
private async Task HandleKeyPress(KeyboardEventArgs e)
{
if (e.Key == "Enter" && !e.ShiftKey)
{
ShouldPreventDefault = true;
await SendMessage();
ShouldPreventDefault = false;
}
else if (e.Key == "Escape")
{
currentMessage = ""; // Clear input on Escape
ShouldPreventDefault = true;
StateHasChanged();
ShouldPreventDefault = false; // Reset after clearing
}
else
{
ShouldPreventDefault = false;
}
}
private async Task ScrollToBottom()
{
try
{
await JSRuntime.InvokeVoidAsync("scrollToBottom", "chat-messages");
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to scroll to bottom");
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JSRuntime.InvokeVoidAsync("eval", @"
window.scrollToBottom = function(elementId) {
const element = document.getElementById(elementId);
if (element) {
requestAnimationFrame(() => {
element.scrollTop = element.scrollHeight;
});
}
};
");
}
}
}