Fix/nginx ai streaming buffering#1599
Conversation
Nginx was buffering the entire backend response before forwarding it to the client, preventing SSE streaming on the /ai/v4/request endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduce ApiService as a fetch-based HTTP layer with resource() factory, streaming via postStream(), and built-in error handling. Extract AiService from TablesService using streaming responses with AbortController for cancellation. AI panel now renders responses progressively as chunks arrive from the backend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR updates the frontend + nginx proxying to support true incremental AI response streaming (avoiding nginx buffering) and updates the AI panel to consume streamed chunks instead of waiting for a full response.
Changes:
- Added a new
ApiServicewrapper aroundfetch, includingpostStream()to consume streamed responses. - Added
AiServicethat exposescreateThread()/sendMessage()streaming APIs for the AI panel. - Updated the AI panel component + unit tests to use streaming +
AbortControllercancellation. - Updated nginx
/api/proxy settings to disable buffering/caching for streamed responses.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
frontend/src/app/services/api.service.ts |
Introduces fetch-based API wrapper with streaming support (postStream) and shared error handling. |
frontend/src/app/services/ai.service.ts |
New AI-specific service that wraps streaming calls and exposes thread/message APIs. |
frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.ts |
Switches AI panel to streamed rendering + abort-based cancellation. |
frontend/src/app/components/dashboard/db-table-view/db-table-ai-panel/db-table-ai-panel.component.spec.ts |
Updates unit tests to mock async streaming APIs. |
frontend/nginx/default.conf |
Disables proxy buffering/caching for /api/ to allow streaming to flush incrementally. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| this.angulartics2.eventTrack.next({ | ||
| action: 'AI panel: message sent successfully', | ||
| }); | ||
| posthog.capture('AI panel: message sent successfully'); |
There was a problem hiding this comment.
Same issue as createThread(): sendMessage() treats a null stream as a no-op, but ApiService.postStream() uses null for request failures, so the chat can silently stop without displaying an ai-error entry. Add explicit handling for stream === null (or have the service throw) so failures are visible in the panel.
| posthog.capture('AI panel: message sent successfully'); | |
| posthog.capture('AI panel: message sent successfully'); | |
| } else if (stream === null) { | |
| // Handle request failures where sendMessage returns null so they are visible in the UI. | |
| this.messagesChain.push({ | |
| type: 'ai-error', | |
| text: 'Failed to send message. Please try again.', | |
| }); | |
| this.angulartics2.eventTrack.next({ | |
| action: 'AI panel: message send failed', | |
| }); | |
| posthog.capture('AI panel: message send failed'); |
| it('should handle error when creating thread', async () => { | ||
| (mockAiService.createThread as ReturnType<typeof vi.fn>).mockRejectedValue('Error message'); | ||
|
|
||
| component.message = 'User question'; | ||
| component.createThread(); | ||
| await component.createThread(); | ||
|
|
||
| expect(component.messagesChain[1]).toEqual({ | ||
| type: 'ai-error', | ||
| text: 'Error message', | ||
| }); |
There was a problem hiding this comment.
These tests model AiService.createThread() failures as a rejected promise, but the concrete implementation (ApiService.postStream) resolves null on request failure after showing a notification. As a result, the component’s catch path may never run in production unless the service contract is changed to throw. Align the test doubles with the real contract (resolve null) or update AiService/ApiService to reject on errors so the tests reflect production behavior.
| return { | ||
| threadId: result.headers.get('X-OpenAI-Thread-ID'), |
There was a problem hiding this comment.
AiStreamResponse.threadId is typed as string, but Headers.get('X-OpenAI-Thread-ID') returns string | null. If the header is missing (or blocked by CORS), this will propagate a null thread ID and can break subsequent requests. Consider validating the header and throwing/returning an explicit error, or change the type to string | null and handle that case in the caller.
| return { | |
| threadId: result.headers.get('X-OpenAI-Thread-ID'), | |
| const threadId = result.headers.get('X-OpenAI-Thread-ID'); | |
| if (!threadId) { | |
| return null; | |
| } | |
| return { | |
| threadId, |
| const reader = response.body.getReader(); | ||
| const decoder = new TextDecoder(); | ||
|
|
There was a problem hiding this comment.
response.body can be null (e.g., certain error/empty responses or unsupported streaming scenarios). Calling response.body.getReader() without a guard can throw at runtime and will fail hard rather than surfacing a handled error. Add an explicit null check and handle it (e.g., throw a descriptive error or fall back to response.text()).
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| const decoder = new TextDecoder(); | |
| if (!response.body) { | |
| // Fallback for environments or responses where streaming is not available. | |
| const text = await response.text(); | |
| async function* fallbackTextStream(): AsyncGenerator<string> { | |
| if (text) { | |
| yield text; | |
| } | |
| } | |
| return { headers: response.headers, stream: fallbackTextStream() }; | |
| } | |
| const reader = response.body.getReader(); |
| const { done, value } = await reader.read(); | ||
| if (done) break; | ||
| yield decoder.decode(value, { stream: true }); | ||
| } |
There was a problem hiding this comment.
When streaming-decoding with TextDecoder and { stream: true }, the decoder may buffer an incomplete multi-byte sequence at chunk boundaries. The generator currently never flushes the decoder at end-of-stream, which can drop the final character(s). After the read loop completes, flush with a final decoder.decode() (and yield it if non-empty).
| } | |
| } | |
| const finalChunk = decoder.decode(); | |
| if (finalChunk) { | |
| yield finalChunk; | |
| } |
| this.angulartics2.eventTrack.next({ | ||
| action: 'AI panel: thread created successfully', | ||
| }); | ||
| posthog.capture('AI panel: thread created successfully'); |
There was a problem hiding this comment.
ApiService.postStream() returns null on non-abort errors after showing a global notification. In createThread(), if response is null (the likely error path), the UI currently does not add an ai-error message to the chat chain (the catch block won’t run because nothing is thrown), so the user sees their prompt with no response/error. Handle the null result explicitly (e.g., push an ai-error message / analytics) or change AiService/ApiService to throw on request failures so this catch is actually reachable.
| posthog.capture('AI panel: thread created successfully'); | |
| posthog.capture('AI panel: thread created successfully'); | |
| } else { | |
| // Handle non-throwing failure case from createThread (e.g., postStream returned null) | |
| this.messagesChain.push({ | |
| type: 'ai-error', | |
| text: 'AI request failed. Please try again.', | |
| }); | |
| this.angulartics2.eventTrack.next({ | |
| action: 'AI panel: thread creation returned an error', | |
| }); | |
| posthog.capture('AI panel: thread creation returned an error'); |
…n AI panel Previously _consumeStream called markdownService.parse() and then passed the resulting HTML into <markdown> content projection, causing double-rendering. Now raw markdown is stored directly and <markdown [data]> re-renders the whole content on each streaming chunk. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
No description provided.