From 20d9a889ac035866cd666e3fff536a3adf7d3706 Mon Sep 17 00:00:00 2001
From: wanghanlin <1533525126@qq.com>
Date: Thu, 24 Sep 2026 17:42:10 +0800
Subject: [PATCH] =?UTF-8?q?fix(sdk):=20=E6=B5=81=E5=BC=8F=E9=9B=B6?=
=?UTF-8?q?=E5=86=85=E5=AE=B9=E6=97=B6=E9=99=8D=E7=BA=A7=E8=B7=AF=E5=BE=84?=
=?UTF-8?q?=E6=9C=AA=E6=B8=B2=E6=9F=93=E7=AD=94=E6=A1=88=EF=BC=8C=E7=95=8C?=
=?UTF-8?q?=E9=9D=A2=E6=B0=B8=E4=B9=85=E5=81=9C=E5=9C=A8=E3=80=8C=E6=AD=A3?=
=?UTF-8?q?=E5=9C=A8=E6=80=9D=E8=80=83...=E3=80=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
问题:第三方页面反馈「对话框一直显示正在思考...,但控制台里请求与响应数据都正常」。
定位:服务端这次的流式响应只回了「开场 role 帧 + finish_reason=stop 帧 + [DONE]」三个控制帧、
零内容(服务端成因见后一个提交)。SDK 的 sendStreamMessage 在 onDone 里发现零内容时确实会
降级再发一次同步 /ai/chat,但该分支只写 `chatRequest(...).then(resolve)` —— 既不渲染气泡也不
收掉 loading;而 produceAIReply 的 streaming 分支把「渲染气泡 + 收 loading」全权委托给了
onChunk 回调,这条路径拿不到 onChunk。结果同步拿到的答案被静默丢弃、loading 永不消失。
- 降级路径补齐 hideLoading + renderAIBubble(复用 dom.ts 已有实现,终态与非流式分支一致)
- 同一函数另一处同类出口:首个 token 之前点「停止生成」时 loading 同样不会被收,且会把一条
空 AI 消息写进历史。补 hideLoading;并在 produceAIReply 加守卫:aiContent 为空串时不 push、
不落库、不派发 csk:newMessage(避免历史里多出空气泡)
- 流式零内容时上报 onError code=stream_empty,不再静默;README 补该错误码与触发方式说明
验证:用逐字复刻线上那 3 帧的 mock 流驱动真实 SDK(puppeteer)——降级拿到的答案被正常渲染成
气泡、「正在思考...」消失、onError 收到 stream_empty,请求序列与线上 HAR 一致;正常带内容的流
不受影响(errors 为空、syncFallbackCalls=0);首 token 前中止时 loading 消失且历史无空消息。
client 单测 92 个全绿。
(static/sdk/test.html 仅两条 link 顺序变化,hash 未变,属构建产物顺序抖动)
---
client/README.md | 3 ++-
client/src/chat.ts | 32 +++++++++++++++++++++++--
src/main/resources/static/sdk/test.html | 2 +-
3 files changed, 33 insertions(+), 4 deletions(-)
diff --git a/client/README.md b/client/README.md
index 58df04e..20b4308 100644
--- a/client/README.md
+++ b/client/README.md
@@ -202,7 +202,7 @@ SDK 产物位于 `client/dist/` 目录:
默认开启(`streaming: true`),AI 回复逐字输出,支持:
- 流式追加到气泡,实时滚动到底部
- 流中断兜底:保留已接收内容 + 灰色提示
-- 无流内容时自动降级为同步请求
+- 无流内容时自动降级为同步请求,并**把拿到的答案正常渲染出来**(同时上报 `onError` 的 `stream_empty`);用户点「停止生成」时不会再多发一次请求
### 4.2 Markdown 渲染
@@ -449,6 +449,7 @@ ChatbotSDK.init({
| `auth_expired` | 刷新后仍返回 401,Token 彻底失效 | 检查 API Key 是否被吊销/过期、是否绑定了启用中的客服角色;修好后调 `setToken()` |
| `auth_refresh_failed` | 换 Token 的 `getToken` 回调失败(宿主后端异常或网络问题) | 检查宿主换 Token 接口;同一 code 30 秒内只上报一次,不会刷屏 |
| `config_invalid` | `init()` 传入的 `token` / `expiresIn` 等参数非法,该参数被忽略 | 按提示修正配置 |
+| `stream_empty` | 服务端 SSE 流返回了**零内容**(只收到开场帧与收尾帧),SDK 已自动降级为同步请求并渲染答案 | 答案仍会出来(会慢一次往返)。若频繁出现,查后端:该模型是否流式输出为空(典型是 provider 的流式分片文本未被正确提取)。见 `SDK-INTEGRATION.md` |
---
diff --git a/client/src/chat.ts b/client/src/chat.ts
index e0ed7b2..1bb63c5 100644
--- a/client/src/chat.ts
+++ b/client/src/chat.ts
@@ -541,6 +541,15 @@ async function produceAIReply(userText: string, images?: ImageAttachment[]): Pro
renderAIBubble(messagesContainer, aiContent, aiTimestamp, aiMsgId);
}
}
+
+ // 零内容(服务端返回空串、或用户在首个 token 前点了「停止生成」):
+ // 不落库、不派发新消息事件,避免历史里多出一条空气泡(刷新后会渲染成空白)。
+ // 只需确保 loading 收掉;返回 false 让调用方恢复待发送图片便于重试。
+ if (!aiContent) {
+ if (hideLoadingFn) hideLoadingFn();
+ return false;
+ }
+
const aiMsg: ChatMessage = { id: aiMsgId, role: 'ai', content: aiContent, timestamp: aiTimestamp };
messages.push(aiMsg);
@@ -619,14 +628,33 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
// 否则下面的「无流内容降级」分支会因 streamStarted=false 再发一次同步 /ai/chat 请求 ——
// 用户明明点了停止,却反而多发出一个请求。
if (signal.aborted) {
- if (wrapperEl && bubbleEl) finalizeAIBubble(wrapperEl, bubbleEl);
+ if (wrapperEl && bubbleEl) {
+ finalizeAIBubble(wrapperEl, bubbleEl);
+ } else if (hideLoadingFn) {
+ // 首个 token 之前就被中止:气泡尚未创建,必须自己收掉 loading,
+ // 否则「正在思考...」会一直留在界面上
+ hideLoadingFn();
+ }
resolve(accumulated);
return;
}
// 无流内容降级为同步请求(须在 wrapperEl/bubbleEl 判断之外:
// 二者仅在 onChunk 收到首个 token 时才赋值,否则此分支不可达)
if (!streamStarted && accumulated === '') {
- chatRequest(text, imageUrls).then(resolve).catch(reject);
+ // 服务端流式返回了零内容(典型:模型流式输出为空,只发了开场帧 + 收尾帧 + [DONE])。
+ // 上报专属错误码便于宿主/监控感知,不再静默。
+ logger.error('流式响应未返回任何内容,已降级为同步请求', undefined, 'stream_empty');
+ chatRequest(text, imageUrls)
+ .then((syncText) => {
+ // 降级路径必须自己完成「渲染气泡 + 收掉 loading」:
+ // produceAIReply 的 streaming 分支把这两件事全权委托给了 onChunk 回调
+ //(hideLoading 与 createEmptyAIBubble 都写在 onChunk 里),而本路径拿不到 onChunk。
+ // 漏了这一步,流式零内容时同步拿到的答案会被静默丢弃、界面永久停在「正在思考...」。
+ if (hideLoadingFn) hideLoadingFn();
+ if (messagesContainer) renderAIBubble(messagesContainer, syncText, aiTimestamp, aiMsgId);
+ resolve(syncText);
+ })
+ .catch(reject);
return;
}
if (wrapperEl && bubbleEl) {
diff --git a/src/main/resources/static/sdk/test.html b/src/main/resources/static/sdk/test.html
index 78fabfb..0e929e3 100644
--- a/src/main/resources/static/sdk/test.html
+++ b/src/main/resources/static/sdk/test.html
@@ -11,8 +11,8 @@
-
+