From 85d1f71b0f29563abd7657869a7be5a421e03630 Mon Sep 17 00:00:00 2001 From: octo-patch Date: Sun, 26 Apr 2026 10:44:19 +0800 Subject: [PATCH] fix: close stream after upstream ends to prevent client hang (fixes #176) When an LLM provider (e.g. Ollama native API) does not send a [DONE] sentinel at the end of the stream, the ReadableStream in the API handler was never explicitly closed. This left the client's reader.read() loop waiting indefinitely, causing the UI to appear stuck. Two changes: - chat.ts: call controller.close() after exhausting the upstream body, wrapped in try/catch to silently ignore the case where [DONE] already closed it. Also guard the text encoding so undefined delta content (the final finish_reason chunk) is not encoded. - ConversationView: wrap the stream-reading loop in try/catch so that any stream error marks the message as FAILED instead of leaving it stuck in LOADING state. Co-Authored-By: Octopus --- src/components/ConversationView/index.tsx | 28 +++++++++++++++-------- src/pages/api/chat.ts | 14 ++++++++++-- 2 files changed, 30 insertions(+), 12 deletions(-) diff --git a/src/components/ConversationView/index.tsx b/src/components/ConversationView/index.tsx index 585fbb6..b1698d0 100644 --- a/src/components/ConversationView/index.tsx +++ b/src/components/ConversationView/index.tsx @@ -259,18 +259,26 @@ const ConversationView = () => { const reader = data.getReader(); const decoder = new TextDecoder("utf-8"); let done = false; - while (!done) { - const { value, done: readerDone } = await reader.read(); - if (value) { - const char = decoder.decode(value); - if (char) { - assistantMessage.content = assistantMessage.content + char; - messageStore.updateMessage(assistantMessage.id, { - content: assistantMessage.content, - }); + try { + while (!done) { + const { value, done: readerDone } = await reader.read(); + if (value) { + const char = decoder.decode(value); + if (char) { + assistantMessage.content = assistantMessage.content + char; + messageStore.updateMessage(assistantMessage.id, { + content: assistantMessage.content, + }); + } } + done = readerDone; } - done = readerDone; + } catch (error) { + messageStore.updateMessage(assistantMessage.id, { + content: assistantMessage.content || "Failed to receive response. Please check your API endpoint configuration.", + status: "FAILED", + }); + return; } messageStore.updateMessage(assistantMessage.id, { status: "DONE", diff --git a/src/pages/api/chat.ts b/src/pages/api/chat.ts index 4919e95..9e53d19 100644 --- a/src/pages/api/chat.ts +++ b/src/pages/api/chat.ts @@ -140,8 +140,10 @@ const handler = async (req: NextRequest) => { try { const json = JSON.parse(data); const text = json.choices[0].delta?.content; - const queue = encoder.encode(text); - controller.enqueue(queue); + if (text) { + const queue = encoder.encode(text); + controller.enqueue(queue); + } } catch (e) { controller.error(e); } @@ -151,6 +153,14 @@ const handler = async (req: NextRequest) => { for await (const chunk of remoteRes.body as any) { parser.feed(decoder.decode(chunk)); } + // Ensure the stream is closed after all upstream chunks are consumed. + // Some providers (e.g. Ollama native API) may not send a [DONE] sentinel, + // which would leave the ReadableStream open and the client hanging indefinitely. + try { + controller.close(); + } catch { + // Already closed by the [DONE] handler — ignore. + } }, });