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,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-04-04
|
||||
@@ -0,0 +1,66 @@
|
||||
## Context
|
||||
|
||||
The chat UI has a hardcoded response stub. A local OpenAI-compatible proxy at `localhost:8317` serves the Responses API (`POST /v1/responses`) with Claude models. The existing architecture has a WASM client calling the API backend — we add a new endpoint that proxies to the Responses API and streams tokens back.
|
||||
|
||||
The Responses API streaming format uses SSE with events like `response.output_text.delta` carrying a `delta` field with text fragments.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Wire real AI responses through the existing client → backend → proxy chain
|
||||
- Stream tokens to the UI for responsive feel
|
||||
- Keep the proxy URL and model configurable (server-side only)
|
||||
- Show a thinking indicator while waiting for first token
|
||||
|
||||
**Non-Goals:**
|
||||
- Conversation history / multi-turn context (future phase)
|
||||
- Model selection UI (future phase)
|
||||
- Retry logic or rate limiting
|
||||
- Markdown rendering of responses (future phase — Markdig)
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision 1: Backend proxies the Responses API
|
||||
|
||||
The WASM client cannot call `localhost:8317` directly (different origin, and we keep external service URLs server-side). The API backend gets a new `ChatController` that:
|
||||
1. Receives messages from the client
|
||||
2. Forwards them to `POST /v1/responses` with `"stream": true`
|
||||
3. Reads the SSE stream, extracts `response.output_text.delta` events
|
||||
4. Re-emits the text deltas as simple SSE events to the client
|
||||
|
||||
**Format for client SSE**: `data: {"text": "<delta>"}\n\n` for each token, and `data: [DONE]\n\n` at the end. This is simpler than forwarding the full Responses API event structure.
|
||||
|
||||
**Alternative considered**: Having the client call `localhost:8317` directly via CORS. Rejected — breaks the architecture constraint of keeping external URLs server-side.
|
||||
|
||||
### Decision 2: Client-side streaming with SetBrowserResponseStreamingEnabled
|
||||
|
||||
Per the stack spec, the client uses:
|
||||
- `SetBrowserResponseStreamingEnabled(true)` on the `HttpRequestMessage`
|
||||
- `HttpCompletionOption.ResponseHeadersRead` to start reading before the full response arrives
|
||||
- Line-by-line iteration of the response stream
|
||||
|
||||
This avoids any JavaScript interop for streaming.
|
||||
|
||||
### Decision 3: Simple DTOs in Shared project
|
||||
|
||||
Add `ChatRequest` (list of messages) and keep the existing `ChatMessage` model. The SSE parsing happens in `ChatApiClient` — no DTO needed for individual stream events since they're parsed inline.
|
||||
|
||||
### Decision 4: Configuration via appsettings.json
|
||||
|
||||
The API's `appsettings.json` gets:
|
||||
```json
|
||||
{
|
||||
"ResponsesApi": {
|
||||
"BaseUrl": "http://localhost:8317",
|
||||
"Model": "claude-sonnet-4-6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is the API project's appsettings (server-side, not exposed to the browser).
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Proxy adds latency] → Minimal for localhost; acceptable tradeoff for keeping URLs server-side
|
||||
- [No conversation history] → Intentional; each request is single-turn for now. Multi-turn comes in a future phase.
|
||||
- [No retry on stream failure] → If the stream breaks mid-response, the partial text stays visible and an error is shown. Good enough for phase 1.
|
||||
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
The chat UI currently returns hardcoded responses. A local OpenAI-compatible proxy is running at `localhost:8317` that exposes the Responses API (`POST /v1/responses`) backed by Anthropic Claude models. This change wires the chat to produce real AI responses via streaming, replacing the hardcoded stub.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a chat endpoint to the API backend that proxies requests to the local Responses API
|
||||
- Stream tokens from the Responses API back to the WASM client as SSE
|
||||
- Update ChatApiClient with a streaming chat method
|
||||
- Replace the hardcoded response in Chat.razor with live streaming from the API
|
||||
- Add a "thinking" indicator while the assistant is responding
|
||||
- Disable input during streaming to prevent overlapping requests
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `chat-streaming`: Streaming AI responses from the Responses API proxy through the backend to the WASM client
|
||||
|
||||
### Modified Capabilities
|
||||
- `chat-ui`: Replace hardcoded response with streaming AI response, add typing indicator, disable input during streaming
|
||||
|
||||
## Impact
|
||||
|
||||
- `src/ChatAgent.Api/ChatAgent.Api.csproj`: Add no new packages (uses built-in HttpClient)
|
||||
- `src/ChatAgent.Api/Controllers/ChatController.cs`: New controller proxying to Responses API
|
||||
- `src/ChatAgent.Api/Program.cs`: Register HttpClient for the proxy, add configuration
|
||||
- `src/ChatAgent.Api/appsettings.json`: New — configure Responses API base URL and model
|
||||
- `src/ChatAgent.Client/Services/ChatApiClient.cs`: Add streaming chat method
|
||||
- `src/ChatAgent.Client/Pages/Chat.razor`: Replace hardcoded response with streaming call
|
||||
- `src/ChatAgent.Shared/Models/`: New request/response DTOs for the chat endpoint
|
||||
@@ -0,0 +1,51 @@
|
||||
## ADDED 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
|
||||
@@ -0,0 +1,50 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Hardcoded response
|
||||
|
||||
The assistant SHALL reply with a real AI response streamed from the backend API, replacing the previous hardcoded stub. Tokens appear incrementally as they arrive.
|
||||
|
||||
#### Scenario: Bot replies with streamed AI response
|
||||
|
||||
- **WHEN** the user sends any message
|
||||
- **THEN** the assistant message appears and grows token by token as the stream delivers text
|
||||
|
||||
### Requirement: Message input
|
||||
|
||||
The chat page SHALL provide a text input area at the bottom of the page where the user can type and submit messages.
|
||||
|
||||
#### Scenario: Submit via button
|
||||
|
||||
- **WHEN** the user types text and clicks the send button
|
||||
- **THEN** the message is added to the conversation and the input is cleared
|
||||
|
||||
#### Scenario: Submit via Enter key
|
||||
|
||||
- **WHEN** the user types text and presses Enter
|
||||
- **THEN** the message is submitted (same as clicking send)
|
||||
|
||||
#### Scenario: Empty input blocked
|
||||
|
||||
- **WHEN** the user attempts to send an empty or whitespace-only message
|
||||
- **THEN** nothing is sent and no message is added
|
||||
|
||||
#### Scenario: Input disabled during streaming
|
||||
|
||||
- **WHEN** the assistant is currently streaming a response
|
||||
- **THEN** the input field and send button are disabled until streaming completes
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user