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 @@ - +