feat: wire chat UI to Responses API with streaming
Add ChatController that proxies POST /api/chat to the local Responses API (localhost:8317/v1/responses) with SSE streaming. Client reads tokens via SetBrowserResponseStreamingEnabled and renders them incrementally. Includes thinking indicator, input disabled during streaming, and error handling. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
|||||||
|
## 1. Shared Models
|
||||||
|
|
||||||
|
- [x] 1.1 Create ChatRequest.cs in ChatAgent.Shared/Models with a Messages list property
|
||||||
|
|
||||||
|
## 2. API Backend
|
||||||
|
|
||||||
|
- [x] 2.1 Add appsettings.json to ChatAgent.Api with ResponsesApi:BaseUrl and ResponsesApi:Model
|
||||||
|
- [x] 2.2 Register an HttpClient for the Responses API proxy in Api Program.cs
|
||||||
|
- [x] 2.3 Create ChatController with POST /api/chat that proxies to the Responses API with streaming
|
||||||
|
- [x] 2.4 Parse Responses API SSE stream, extract response.output_text.delta events, re-emit as simplified SSE to client
|
||||||
|
|
||||||
|
## 3. Client Streaming
|
||||||
|
|
||||||
|
- [x] 3.1 Add a streaming SendChatAsync method to ChatApiClient that uses SetBrowserResponseStreamingEnabled and HttpCompletionOption.ResponseHeadersRead
|
||||||
|
- [x] 3.2 Parse the simplified SSE stream line-by-line, yielding text deltas
|
||||||
|
|
||||||
|
## 4. Chat Page Updates
|
||||||
|
|
||||||
|
- [x] 4.1 Replace hardcoded response in Chat.razor with a call to ChatApiClient.SendChatAsync
|
||||||
|
- [x] 4.2 Append tokens to the assistant message incrementally with StateHasChanged after each delta
|
||||||
|
- [x] 4.3 Add a thinking indicator shown until the first token arrives
|
||||||
|
- [x] 4.4 Disable input field and send button while streaming is in progress
|
||||||
|
- [x] 4.5 Handle errors — display error message if API call fails
|
||||||
|
- [x] 4.6 Auto-scroll during streaming (not just at the end)
|
||||||
|
|
||||||
|
## 5. Verify
|
||||||
|
|
||||||
|
- [x] 5.1 Run dotnet build to confirm no errors
|
||||||
|
- [ ] 5.2 Manually verify: send a message, see streaming response from Claude
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
## 1. Shared Models
|
|
||||||
|
|
||||||
- [ ] 1.1 Create ChatRequest.cs in ChatAgent.Shared/Models with a Messages list property
|
|
||||||
|
|
||||||
## 2. API Backend
|
|
||||||
|
|
||||||
- [ ] 2.1 Add appsettings.json to ChatAgent.Api with ResponsesApi:BaseUrl and ResponsesApi:Model
|
|
||||||
- [ ] 2.2 Register an HttpClient for the Responses API proxy in Api Program.cs
|
|
||||||
- [ ] 2.3 Create ChatController with POST /api/chat that proxies to the Responses API with streaming
|
|
||||||
- [ ] 2.4 Parse Responses API SSE stream, extract response.output_text.delta events, re-emit as simplified SSE to client
|
|
||||||
|
|
||||||
## 3. Client Streaming
|
|
||||||
|
|
||||||
- [ ] 3.1 Add a streaming SendChatAsync method to ChatApiClient that uses SetBrowserResponseStreamingEnabled and HttpCompletionOption.ResponseHeadersRead
|
|
||||||
- [ ] 3.2 Parse the simplified SSE stream line-by-line, yielding text deltas
|
|
||||||
|
|
||||||
## 4. Chat Page Updates
|
|
||||||
|
|
||||||
- [ ] 4.1 Replace hardcoded response in Chat.razor with a call to ChatApiClient.SendChatAsync
|
|
||||||
- [ ] 4.2 Append tokens to the assistant message incrementally with StateHasChanged after each delta
|
|
||||||
- [ ] 4.3 Add a thinking indicator shown until the first token arrives
|
|
||||||
- [ ] 4.4 Disable input field and send button while streaming is in progress
|
|
||||||
- [ ] 4.5 Handle errors — display error message if API call fails
|
|
||||||
- [ ] 4.6 Auto-scroll during streaming (not just at the end)
|
|
||||||
|
|
||||||
## 5. Verify
|
|
||||||
|
|
||||||
- [ ] 5.1 Run dotnet build to confirm no errors
|
|
||||||
- [ ] 5.2 Manually verify: send a message, see streaming response from Claude
|
|
||||||
55
openspec/specs/chat-streaming/spec.md
Normal file
55
openspec/specs/chat-streaming/spec.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define the streaming AI response pipeline — backend proxy to the Responses API, SSE delivery to the WASM client, configuration, and error handling.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
### Requirement: Chat endpoint proxies to Responses API
|
||||||
|
|
||||||
|
The API backend SHALL expose `POST /api/chat` that accepts a list of messages and proxies the request to the local Responses API at a configurable base URL using the `POST /v1/responses` endpoint.
|
||||||
|
|
||||||
|
#### Scenario: Successful proxy request
|
||||||
|
|
||||||
|
- **WHEN** the client sends a POST to `/api/chat` with a message list
|
||||||
|
- **THEN** the API forwards the messages to the Responses API with the configured model and returns the response
|
||||||
|
|
||||||
|
### Requirement: Streaming response delivery
|
||||||
|
|
||||||
|
The API backend SHALL stream the Responses API's SSE events back to the WASM client as `text/event-stream`, forwarding `response.output_text.delta` events so the client can render tokens incrementally.
|
||||||
|
|
||||||
|
#### Scenario: Tokens stream to client
|
||||||
|
|
||||||
|
- **WHEN** the Responses API emits `response.output_text.delta` events
|
||||||
|
- **THEN** the backend forwards each delta as an SSE event to the client containing the text fragment
|
||||||
|
|
||||||
|
#### Scenario: Stream completes
|
||||||
|
|
||||||
|
- **WHEN** the Responses API emits `response.completed`
|
||||||
|
- **THEN** the backend signals stream completion to the client
|
||||||
|
|
||||||
|
### Requirement: Configurable proxy target
|
||||||
|
|
||||||
|
The Responses API base URL and model name SHALL be configurable via `appsettings.json` in the API project, not hardcoded.
|
||||||
|
|
||||||
|
#### Scenario: Configuration read at startup
|
||||||
|
|
||||||
|
- **WHEN** the API starts
|
||||||
|
- **THEN** it reads `ResponsesApi:BaseUrl` and `ResponsesApi:Model` from configuration
|
||||||
|
|
||||||
|
### Requirement: Client streams from backend
|
||||||
|
|
||||||
|
The WASM client SHALL call `POST /api/chat` with `SetBrowserResponseStreamingEnabled(true)` and `HttpCompletionOption.ResponseHeadersRead`, then iterate the SSE stream to update the UI token by token.
|
||||||
|
|
||||||
|
#### Scenario: Client reads streaming response
|
||||||
|
|
||||||
|
- **WHEN** the client sends a chat request
|
||||||
|
- **THEN** it reads the response stream incrementally and appends each text delta to the assistant message in real time
|
||||||
|
|
||||||
|
### Requirement: Error propagation
|
||||||
|
|
||||||
|
If the Responses API returns an error or is unreachable, the API backend SHALL return an appropriate HTTP error status and the client SHALL display the error to the user.
|
||||||
|
|
||||||
|
#### Scenario: Proxy unreachable
|
||||||
|
|
||||||
|
- **WHEN** the Responses API is not running
|
||||||
|
- **THEN** the client displays an error message instead of an assistant response
|
||||||
@@ -42,14 +42,33 @@ The chat page SHALL provide a text input area at the bottom of the page where th
|
|||||||
- **WHEN** the user attempts to send an empty or whitespace-only message
|
- **WHEN** the user attempts to send an empty or whitespace-only message
|
||||||
- **THEN** nothing is sent and no message is added
|
- **THEN** nothing is sent and no message is added
|
||||||
|
|
||||||
### Requirement: Hardcoded response
|
#### Scenario: Input disabled during streaming
|
||||||
|
|
||||||
In this phase, the assistant SHALL reply with a hardcoded message to every user input. This stubs the AI integration point for future phases.
|
- **WHEN** the assistant is currently streaming a response
|
||||||
|
- **THEN** the input field and send button are disabled until streaming completes
|
||||||
|
|
||||||
#### Scenario: Bot replies to any input
|
### Requirement: Thinking indicator
|
||||||
|
|
||||||
|
The chat page SHALL show a visual indicator while waiting for the first token from the assistant.
|
||||||
|
|
||||||
|
#### Scenario: Indicator shown during wait
|
||||||
|
|
||||||
|
- **WHEN** the user sends a message and the assistant has not yet started streaming
|
||||||
|
- **THEN** a thinking indicator (e.g., animated dots) is shown in the assistant message area
|
||||||
|
|
||||||
|
#### Scenario: Indicator replaced by content
|
||||||
|
|
||||||
|
- **WHEN** the first token arrives from the stream
|
||||||
|
- **THEN** the thinking indicator is replaced by the streamed text
|
||||||
|
|
||||||
|
### Requirement: Streaming AI response
|
||||||
|
|
||||||
|
The assistant SHALL reply with a real AI response streamed from the backend API. Tokens appear incrementally as they arrive.
|
||||||
|
|
||||||
|
#### Scenario: Bot replies with streamed AI response
|
||||||
|
|
||||||
- **WHEN** the user sends any message
|
- **WHEN** the user sends any message
|
||||||
- **THEN** the assistant replies with a hardcoded response (e.g., "This is a placeholder response. AI integration coming soon!")
|
- **THEN** the assistant message appears and grows token by token as the stream delivers text
|
||||||
|
|
||||||
### Requirement: Auto-scroll
|
### Requirement: Auto-scroll
|
||||||
|
|
||||||
|
|||||||
183
src/ChatAgent.Api/Controllers/ChatController.cs
Normal file
183
src/ChatAgent.Api/Controllers/ChatController.cs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
// ChatController.cs -- Proxies chat requests to the Responses API with streaming.
|
||||||
|
//
|
||||||
|
// This controller receives messages from the WASM client, forwards them to the
|
||||||
|
// local Responses API (OpenAI-compatible) at a configurable URL, and streams
|
||||||
|
// the response tokens back as Server-Sent Events (SSE).
|
||||||
|
//
|
||||||
|
// Key concepts demonstrated:
|
||||||
|
// - IHttpClientFactory named client injection for external API calls
|
||||||
|
// - IConfiguration for reading appsettings.json values
|
||||||
|
// - SSE streaming response from ASP.NET Core (text/event-stream)
|
||||||
|
// - Parsing upstream SSE events and re-emitting simplified events to the client
|
||||||
|
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ChatAgent.Shared.Models;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace ChatAgent.Api.Controllers
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Proxies chat requests to the Responses API and streams tokens back to the client.
|
||||||
|
/// The Responses API URL and model are configured in appsettings.json under "ResponsesApi".
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class ChatController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
|
public ChatController(IHttpClientFactory httpClientFactory, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POST /api/chat -- Accepts a ChatRequest with messages, forwards to the Responses API
|
||||||
|
/// with streaming enabled, and re-emits text deltas as simplified SSE events.
|
||||||
|
///
|
||||||
|
/// Client SSE format:
|
||||||
|
/// data: {"text":"token here"}\n\n -- for each text delta
|
||||||
|
/// data: [DONE]\n\n -- when streaming completes
|
||||||
|
/// data: {"error":"message"}\n\n -- if an error occurs
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task Post([FromBody] ChatRequest request)
|
||||||
|
{
|
||||||
|
// Set the response content type to SSE so the client knows to read it as a stream.
|
||||||
|
// "text/event-stream" is the standard MIME type for Server-Sent Events.
|
||||||
|
Response.ContentType = "text/event-stream";
|
||||||
|
Response.Headers["Cache-Control"] = "no-cache";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = _httpClientFactory.CreateClient("ResponsesApi");
|
||||||
|
var model = _configuration["ResponsesApi:Model"] ?? "claude-sonnet-4-6";
|
||||||
|
|
||||||
|
// Build the Responses API request payload.
|
||||||
|
// The Responses API expects "input" (array of role/content objects) and "model".
|
||||||
|
// "stream": true enables SSE streaming of token deltas.
|
||||||
|
var inputMessages = request.Messages.Select(m => new
|
||||||
|
{
|
||||||
|
role = m.Role,
|
||||||
|
content = m.Content
|
||||||
|
}).ToArray();
|
||||||
|
|
||||||
|
var payload = new
|
||||||
|
{
|
||||||
|
model,
|
||||||
|
input = inputMessages,
|
||||||
|
stream = true
|
||||||
|
};
|
||||||
|
|
||||||
|
var jsonPayload = JsonSerializer.Serialize(payload);
|
||||||
|
var content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
// Use HttpCompletionOption.ResponseHeadersRead so we start reading the stream
|
||||||
|
// as soon as headers arrive, rather than waiting for the full response body.
|
||||||
|
using var upstreamRequest = new HttpRequestMessage(HttpMethod.Post, "/v1/responses")
|
||||||
|
{
|
||||||
|
Content = content
|
||||||
|
};
|
||||||
|
|
||||||
|
using var upstreamResponse = await client.SendAsync(
|
||||||
|
upstreamRequest,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
HttpContext.RequestAborted);
|
||||||
|
|
||||||
|
if (!upstreamResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var errorBody = await upstreamResponse.Content.ReadAsStringAsync();
|
||||||
|
await WriteSSEAsync($"{{\"error\":\"Responses API returned {upstreamResponse.StatusCode}: {EscapeJson(errorBody)}\"}}");
|
||||||
|
await WriteSSEAsync("[DONE]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the upstream SSE stream line by line, extract text deltas,
|
||||||
|
// and re-emit them as simplified SSE events to the client.
|
||||||
|
using var stream = await upstreamResponse.Content.ReadAsStreamAsync();
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
|
// Use ReadLineAsync and check for null instead of reader.EndOfStream,
|
||||||
|
// because EndOfStream performs a synchronous read which is not supported
|
||||||
|
// in ASP.NET Core's async pipeline.
|
||||||
|
string? line;
|
||||||
|
while ((line = await reader.ReadLineAsync()) != null)
|
||||||
|
{
|
||||||
|
// SSE format: "data: {json}" lines, separated by blank lines.
|
||||||
|
// We only care about lines starting with "data: ".
|
||||||
|
if (!line.StartsWith("data: "))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var data = line.Substring(6); // strip "data: " prefix
|
||||||
|
|
||||||
|
// Parse the JSON to find response.output_text.delta events.
|
||||||
|
// These carry the actual text tokens in the "delta" field.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(data);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("type", out var typeElement))
|
||||||
|
{
|
||||||
|
var eventType = typeElement.GetString();
|
||||||
|
|
||||||
|
if (eventType == "response.output_text.delta")
|
||||||
|
{
|
||||||
|
// Extract the text delta and send it to the client
|
||||||
|
if (root.TryGetProperty("delta", out var deltaElement))
|
||||||
|
{
|
||||||
|
var delta = deltaElement.GetString() ?? "";
|
||||||
|
await WriteSSEAsync($"{{\"text\":{JsonSerializer.Serialize(delta)}}}");
|
||||||
|
await Response.Body.FlushAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (eventType == "response.completed")
|
||||||
|
{
|
||||||
|
// Stream is done
|
||||||
|
await WriteSSEAsync("[DONE]");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Skip malformed JSON lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we exit the loop without seeing response.completed, still signal done
|
||||||
|
await WriteSSEAsync("[DONE]");
|
||||||
|
}
|
||||||
|
catch (HttpRequestException ex)
|
||||||
|
{
|
||||||
|
await WriteSSEAsync($"{{\"error\":{JsonSerializer.Serialize($"Failed to reach Responses API: {ex.Message}")}}}");
|
||||||
|
await WriteSSEAsync("[DONE]");
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
// Client disconnected — nothing to do
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes a single SSE event to the response stream.
|
||||||
|
/// SSE format: "data: {payload}\n\n"
|
||||||
|
/// </summary>
|
||||||
|
private async Task WriteSSEAsync(string data)
|
||||||
|
{
|
||||||
|
await Response.WriteAsync($"data: {data}\n\n");
|
||||||
|
await Response.Body.FlushAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escapes a string for embedding in JSON (handles quotes and backslashes).
|
||||||
|
/// </summary>
|
||||||
|
private static string EscapeJson(string s)
|
||||||
|
{
|
||||||
|
return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,15 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
// for explicit structure -- each controller is a separate file with clear routing (D-05).
|
// for explicit structure -- each controller is a separate file with clear routing (D-05).
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
|
// Register a named HttpClient for proxying requests to the Responses API.
|
||||||
|
// The base URL comes from appsettings.json (server-side config, not exposed to the browser).
|
||||||
|
// IHttpClientFactory manages the underlying HttpMessageHandler lifetime.
|
||||||
|
builder.Services.AddHttpClient("ResponsesApi", client =>
|
||||||
|
{
|
||||||
|
var baseUrl = builder.Configuration["ResponsesApi:BaseUrl"] ?? "http://localhost:8317";
|
||||||
|
client.BaseAddress = new Uri(baseUrl);
|
||||||
|
});
|
||||||
|
|
||||||
// AddCors() registers Cross-Origin Resource Sharing services.
|
// AddCors() registers Cross-Origin Resource Sharing services.
|
||||||
// CORS is REQUIRED because the Blazor WASM client runs on a different origin
|
// CORS is REQUIRED because the Blazor WASM client runs on a different origin
|
||||||
// (https://localhost:5200) than this API (https://localhost:7100).
|
// (https://localhost:5200) than this API (https://localhost:7100).
|
||||||
|
|||||||
@@ -5,5 +5,9 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"ResponsesApi": {
|
||||||
|
"BaseUrl": "http://localhost:8317",
|
||||||
|
"Model": "claude-sonnet-4-6"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@* Chat.razor -- The main chat interface.
|
@* Chat.razor -- The main chat interface with streaming AI responses.
|
||||||
|
|
||||||
This is the primary page of the application, mapped to the root route "/".
|
This is the primary page of the application, mapped to the root route "/".
|
||||||
It displays a vertically scrolling message list and a text input at the bottom,
|
It displays a vertically scrolling message list and a text input at the bottom,
|
||||||
@@ -9,9 +9,10 @@
|
|||||||
- Two-way binding with @bind-Value on MudTextField
|
- Two-way binding with @bind-Value on MudTextField
|
||||||
- Event handling with @onclick and OnKeyDown
|
- Event handling with @onclick and OnKeyDown
|
||||||
- List rendering with @foreach over a List<T>
|
- List rendering with @foreach over a List<T>
|
||||||
- StateHasChanged() for manual re-render triggers
|
- StateHasChanged() for manual re-render triggers during streaming
|
||||||
- IJSRuntime for calling JavaScript (auto-scroll)
|
- IJSRuntime for calling JavaScript (auto-scroll)
|
||||||
- Conditional CSS classes based on data (user vs assistant styling)
|
- IAsyncEnumerable consumption for streaming API responses
|
||||||
|
- Conditional rendering for thinking indicator and error states
|
||||||
*@
|
*@
|
||||||
|
|
||||||
@page "/"
|
@page "/"
|
||||||
@@ -21,15 +22,17 @@
|
|||||||
built-in scroll API. *@
|
built-in scroll API. *@
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
|
|
||||||
|
@* ChatApiClient is our typed HttpClient wrapper that handles API communication.
|
||||||
|
It was registered in Program.cs via AddHttpClient<ChatApiClient>. *@
|
||||||
|
@inject ChatApiClient ApiClient
|
||||||
|
|
||||||
<PageTitle>Chat Agent</PageTitle>
|
<PageTitle>Chat Agent</PageTitle>
|
||||||
|
|
||||||
@* Chat container: uses flexbox to fill available height.
|
@* Chat container: uses flexbox to fill available height.
|
||||||
The message area grows to fill space; the input stays pinned at the bottom. *@
|
The message area grows to fill space; the input stays pinned at the bottom. *@
|
||||||
<div class="chat-container">
|
<div class="chat-container">
|
||||||
|
|
||||||
@* Message list: scrollable area that grows to fill available space.
|
@* Message list: scrollable area that grows to fill available space. *@
|
||||||
The @ref directive captures a reference to this DOM element so we can
|
|
||||||
scroll it programmatically via JavaScript interop. *@
|
|
||||||
<div class="message-list" @ref="_messageListRef">
|
<div class="message-list" @ref="_messageListRef">
|
||||||
@if (_messages.Count == 0)
|
@if (_messages.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -48,14 +51,23 @@
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
@* Render each message as a MudPaper card.
|
@* Render each message as a MudPaper card.
|
||||||
@foreach iterates the list; Blazor re-renders this block when _messages changes.
|
|
||||||
The CSS class changes based on Role to align user messages right, assistant left. *@
|
The CSS class changes based on Role to align user messages right, assistant left. *@
|
||||||
@foreach (var message in _messages)
|
@foreach (var message in _messages)
|
||||||
{
|
{
|
||||||
<div class="message-row @(message.Role == "user" ? "message-user" : "message-assistant")">
|
<div class="message-row @(message.Role == "user" ? "message-user" : "message-assistant")">
|
||||||
<MudPaper Class="@($"message-bubble {(message.Role == "user" ? "bubble-user" : "bubble-assistant")}")"
|
<MudPaper Class="@($"message-bubble {(message.Role == "user" ? "bubble-user" : "bubble-assistant")}")"
|
||||||
Elevation="0">
|
Elevation="0">
|
||||||
|
@if (message.Role == "assistant" && string.IsNullOrEmpty(message.Content) && _isStreaming)
|
||||||
|
{
|
||||||
|
@* Thinking indicator: shown while waiting for the first token.
|
||||||
|
MudProgressCircular gives an animated spinner that disappears
|
||||||
|
once the first text delta arrives. *@
|
||||||
|
<MudProgressCircular Size="Size.Small" Indeterminate="true" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
<MudText Typo="Typo.body1">@message.Content</MudText>
|
<MudText Typo="Typo.body1">@message.Content</MudText>
|
||||||
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -63,26 +75,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Input area: pinned at the bottom of the chat container.
|
@* Input area: pinned at the bottom of the chat container.
|
||||||
MudTextField with an Adornment provides the send button inside the text field,
|
Disabled attribute prevents interaction while the assistant is streaming. *@
|
||||||
similar to ChatGPT's input design. *@
|
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
<MudTextField @bind-Value="_userInput"
|
<MudTextField @bind-Value="_userInput"
|
||||||
Placeholder="Type a message..."
|
Placeholder="@(_isStreaming ? "Waiting for response..." : "Type a message...")"
|
||||||
Variant="Variant.Outlined"
|
Variant="Variant.Outlined"
|
||||||
Adornment="Adornment.End"
|
Adornment="Adornment.End"
|
||||||
AdornmentIcon="@Icons.Material.Filled.Send"
|
AdornmentIcon="@Icons.Material.Filled.Send"
|
||||||
AdornmentColor="Color.Primary"
|
AdornmentColor="@(_isStreaming ? Color.Default : Color.Primary)"
|
||||||
OnAdornmentClick="SendMessage"
|
OnAdornmentClick="SendMessage"
|
||||||
OnKeyDown="HandleKeyDown"
|
OnKeyDown="HandleKeyDown"
|
||||||
Immediate="true"
|
Immediate="true"
|
||||||
FullWidth="true"
|
FullWidth="true"
|
||||||
AutoFocus="true" />
|
AutoFocus="true"
|
||||||
|
Disabled="_isStreaming" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
// The conversation messages, displayed in the message list.
|
// The conversation messages, displayed in the message list.
|
||||||
// Using a simple List<T> since we only add to the end — no complex state management needed.
|
|
||||||
private List<ChatMessage> _messages = new();
|
private List<ChatMessage> _messages = new();
|
||||||
|
|
||||||
// The current text in the input field. Bound two-way via @bind-Value.
|
// The current text in the input field. Bound two-way via @bind-Value.
|
||||||
@@ -91,26 +102,29 @@
|
|||||||
// DOM reference to the message list div, used for auto-scrolling via JS interop.
|
// DOM reference to the message list div, used for auto-scrolling via JS interop.
|
||||||
private ElementReference _messageListRef;
|
private ElementReference _messageListRef;
|
||||||
|
|
||||||
|
// Tracks whether we are currently streaming a response from the API.
|
||||||
|
// Used to disable input and show the thinking indicator.
|
||||||
|
private bool _isStreaming = false;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles the Enter key press to submit the message.
|
/// Handles the Enter key press to submit the message.
|
||||||
/// KeyboardEventArgs gives us the key that was pressed.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task HandleKeyDown(KeyboardEventArgs e)
|
private async Task HandleKeyDown(KeyboardEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Key == "Enter" && !e.ShiftKey)
|
if (e.Key == "Enter" && !e.ShiftKey && !_isStreaming)
|
||||||
{
|
{
|
||||||
await SendMessage();
|
await SendMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends the user's message and appends a hardcoded assistant response.
|
/// Sends the user's message and streams the AI response token by token.
|
||||||
/// In future phases, this will call the API instead of using a hardcoded reply.
|
/// Each token delta updates the assistant message and triggers a re-render.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task SendMessage()
|
private async Task SendMessage()
|
||||||
{
|
{
|
||||||
// Block empty or whitespace-only submissions
|
// Block empty or whitespace-only submissions, and prevent double-send during streaming
|
||||||
if (string.IsNullOrWhiteSpace(_userInput))
|
if (string.IsNullOrWhiteSpace(_userInput) || _isStreaming)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// Add the user's message
|
// Add the user's message
|
||||||
@@ -121,33 +135,67 @@
|
|||||||
Timestamp = DateTime.UtcNow
|
Timestamp = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
|
|
||||||
// Clear the input field
|
var userText = _userInput.Trim();
|
||||||
_userInput = string.Empty;
|
_userInput = string.Empty;
|
||||||
|
_isStreaming = true;
|
||||||
|
|
||||||
// Add a hardcoded assistant response.
|
// Add an empty assistant message that will be filled token by token.
|
||||||
// This is the stub that will be replaced with an API call in the next phase.
|
// The thinking indicator shows while Content is empty.
|
||||||
_messages.Add(new ChatMessage
|
var assistantMessage = new ChatMessage
|
||||||
{
|
{
|
||||||
Role = "assistant",
|
Role = "assistant",
|
||||||
Content = "This is a placeholder response. AI integration coming soon!",
|
Content = string.Empty,
|
||||||
Timestamp = DateTime.UtcNow
|
Timestamp = DateTime.UtcNow
|
||||||
});
|
};
|
||||||
|
_messages.Add(assistantMessage);
|
||||||
|
|
||||||
// StateHasChanged() tells Blazor to re-render this component.
|
StateHasChanged();
|
||||||
// It's needed here because we modified _messages after the initial render cycle.
|
await ScrollToBottom();
|
||||||
// Without this call, the new messages wouldn't appear until the next UI event.
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Build the request with the current user message.
|
||||||
|
// Future phases will include full conversation history for multi-turn.
|
||||||
|
var request = new ChatRequest
|
||||||
|
{
|
||||||
|
Messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new ChatMessage { Role = "user", Content = userText }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Stream tokens from the API. IAsyncEnumerable yields each text delta
|
||||||
|
// as it arrives, allowing us to update the UI incrementally.
|
||||||
|
await foreach (var delta in ApiClient.SendChatStreamingAsync(request))
|
||||||
|
{
|
||||||
|
// Append each token to the assistant message content.
|
||||||
|
assistantMessage.Content += delta;
|
||||||
|
|
||||||
|
// StateHasChanged() triggers a re-render so the user sees each token appear.
|
||||||
|
// This is the core of the streaming UX — without it, the full response
|
||||||
|
// would only appear after the stream completes.
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
// Auto-scroll to the bottom after rendering the new messages.
|
// Auto-scroll during streaming so new content stays visible
|
||||||
// We use a small delay to ensure the DOM has updated before scrolling.
|
|
||||||
await Task.Delay(50);
|
|
||||||
await ScrollToBottom();
|
await ScrollToBottom();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// If the API call fails, show the error in the assistant message.
|
||||||
|
assistantMessage.Content = $"Error: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_isStreaming = false;
|
||||||
|
assistantMessage.Timestamp = DateTime.UtcNow;
|
||||||
|
StateHasChanged();
|
||||||
|
await ScrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Scrolls the message list to the bottom using JavaScript interop.
|
/// Scrolls the message list to the bottom using JavaScript interop.
|
||||||
/// Blazor has no built-in scroll API, so we call a tiny JS snippet directly.
|
|
||||||
/// InvokeVoidAsync calls a JS function that returns nothing (void).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ScrollToBottom()
|
private async Task ScrollToBottom()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
// The base URL is configured in Program.cs via AddHttpClient<ChatApiClient>.
|
// The base URL is configured in Program.cs via AddHttpClient<ChatApiClient>.
|
||||||
|
|
||||||
using System.Net.Http.Json;
|
using System.Net.Http.Json;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
using ChatAgent.Shared.Models;
|
using ChatAgent.Shared.Models;
|
||||||
|
using Microsoft.AspNetCore.Components.WebAssembly.Http;
|
||||||
|
|
||||||
namespace ChatAgent.Client.Services
|
namespace ChatAgent.Client.Services
|
||||||
{
|
{
|
||||||
@@ -49,5 +52,94 @@ namespace ChatAgent.Client.Services
|
|||||||
// configured in Program.cs (e.g., https://localhost:7100/api/health).
|
// configured in Program.cs (e.g., https://localhost:7100/api/health).
|
||||||
return await _httpClient.GetFromJsonAsync<HealthResponse>("api/health");
|
return await _httpClient.GetFromJsonAsync<HealthResponse>("api/health");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a chat request to POST /api/chat and streams the response as an
|
||||||
|
/// async enumerable of text deltas. Each yielded string is a token fragment.
|
||||||
|
///
|
||||||
|
/// Key Blazor WASM streaming concepts:
|
||||||
|
/// - SetBrowserResponseStreamingEnabled(true) tells the browser's Fetch API
|
||||||
|
/// to make the response body readable as a stream (not buffered).
|
||||||
|
/// - HttpCompletionOption.ResponseHeadersRead means we start reading the
|
||||||
|
/// stream as soon as HTTP headers arrive, not after the full body downloads.
|
||||||
|
/// - We parse the SSE format line by line, extracting "text" from each data event.
|
||||||
|
/// </summary>
|
||||||
|
public async IAsyncEnumerable<string> SendChatStreamingAsync(ChatRequest request)
|
||||||
|
{
|
||||||
|
// Build the HTTP request manually so we can set streaming options.
|
||||||
|
var jsonContent = JsonSerializer.Serialize(request);
|
||||||
|
var httpRequest = new HttpRequestMessage(HttpMethod.Post, "api/chat")
|
||||||
|
{
|
||||||
|
Content = new StringContent(jsonContent, Encoding.UTF8, "application/json")
|
||||||
|
};
|
||||||
|
|
||||||
|
// SetBrowserResponseStreamingEnabled is a Blazor WASM extension that tells
|
||||||
|
// the browser's Fetch API to expose the response as a ReadableStream.
|
||||||
|
// Without this, the browser buffers the entire response before .NET can read it.
|
||||||
|
httpRequest.SetBrowserResponseStreamingEnabled(true);
|
||||||
|
|
||||||
|
// ResponseHeadersRead: start processing as soon as headers arrive.
|
||||||
|
using var response = await _httpClient.SendAsync(
|
||||||
|
httpRequest,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead);
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
// Read the SSE stream line by line.
|
||||||
|
using var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
|
||||||
|
// Use ReadLineAsync and check for null instead of reader.EndOfStream,
|
||||||
|
// because EndOfStream performs a synchronous read which is not supported
|
||||||
|
// in Blazor WASM's async streaming pipeline.
|
||||||
|
string? line;
|
||||||
|
while ((line = await reader.ReadLineAsync()) != null)
|
||||||
|
{
|
||||||
|
// SSE lines starting with "data: " contain our payload.
|
||||||
|
if (!line.StartsWith("data: "))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var data = line.Substring(6);
|
||||||
|
|
||||||
|
// "[DONE]" signals the end of the stream.
|
||||||
|
if (data == "[DONE]")
|
||||||
|
yield break;
|
||||||
|
|
||||||
|
// Parse the simplified JSON event: {"text":"token"} or {"error":"message"}
|
||||||
|
// Note: C# does not allow yield inside try-catch, so we parse first
|
||||||
|
// and yield outside the try block.
|
||||||
|
string? parsedText = null;
|
||||||
|
string? parsedError = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(data);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("error", out var errorElement))
|
||||||
|
{
|
||||||
|
parsedError = errorElement.GetString();
|
||||||
|
}
|
||||||
|
else if (root.TryGetProperty("text", out var textElement))
|
||||||
|
{
|
||||||
|
parsedText = textElement.GetString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
// Skip malformed SSE data
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedError != null)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException($"Chat API error: {parsedError}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(parsedText))
|
||||||
|
{
|
||||||
|
yield return parsedText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
src/ChatAgent.Shared/Models/ChatRequest.cs
Normal file
21
src/ChatAgent.Shared/Models/ChatRequest.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
// ChatRequest.cs -- DTO sent from the WASM client to the API backend to request a chat response.
|
||||||
|
//
|
||||||
|
// This lives in ChatAgent.Shared so both client and API agree on the request shape.
|
||||||
|
// The API backend uses this to build the Responses API request.
|
||||||
|
|
||||||
|
namespace ChatAgent.Shared.Models
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Request payload for POST /api/chat. Contains the conversation messages
|
||||||
|
/// to send to the AI model. Currently single-turn (one user message),
|
||||||
|
/// but the list structure supports multi-turn in future phases.
|
||||||
|
/// </summary>
|
||||||
|
public class ChatRequest
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The conversation messages to send. Each message has a Role ("user" or "assistant")
|
||||||
|
/// and Content (the text). The API forwards these to the Responses API.
|
||||||
|
/// </summary>
|
||||||
|
public List<ChatMessage> Messages { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user