From f69360744f3b1c4f7ddce459b4980c3c5f21c94a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Mon, 20 Apr 2026 14:12:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(assistant):=20=E4=BF=AE=E5=A4=8D=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=20Response=20Body=20=E4=B8=BA=20(empty)=20+=20?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BB=93=E6=9E=9C=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 用户反馈 截图显示测试结果详情里 "Response Body: (empty)",但对话实际可用。 用户:"有一个比较严重的bug...具体返回参数看不到...我感觉我们的功能不完整" ## 根因分析 1. **Accept-Encoding 未限制**:reqwest 只启用了 gzip feature(未启用 brotli), 但 provider 经 CDN/反代可能返回 Content-Encoding: br,导致 resp.text() 解码失败。 2. **错误被静默吞**:`resp.text().await.unwrap_or_default()` 在解码失败时返回 "", 前端展示为 (empty) 但没有任何错误提示,用户无法诊断。 3. **展示设计:reply 被截断 + 藏在折叠面板**:成功时模型回复只显示前 80 字符的 预览,完整内容要展开 "查看完整请求/响应参数" 才能看到(还被 JSON 混在一起)。 ## 修复 ### Rust test_model_verbose 三个分支(OpenAI / Anthropic / Gemini)都显式加 `Accept-Encoding: identity` 头,禁止响应压缩。测试请求的响应体很小(几百字节),不压缩的性能损失可忽略。 `resp.text().await` 失败时不再 unwrap_or_default 静默吞,而是返回带 error 的 JSON:`"读取响应体失败: {e} (可能是压缩编码未支持或非 UTF-8 响应)"` ### dev-api.js test_model_verbose(Web 模式) 三个分支的 headers 都加 `'Accept-Encoding': 'identity'`,和 Rust 行为一致。 ### 前端 buildTestResult 重写 - **顶部显眼展示模型回复**(边框高亮 + 完整内容 + max-height:180px 加 scroll, 不再截断为 80 字预览): ``` ✓ 连接成功 (300ms, Chat Completions) ╔═════════════════════════════╗ ║ MODEL REPLY ║ ← 完整回复全文 ║ 你好!我是 QC-A04... ║ ╚═════════════════════════════╝ ``` - **空 respBody + 非空 reply 时给明确诊断**:"响应主体未能读取(可能是压缩 编码异常),但已从响应流中提取到回复内容" - **固定 prompt 脚注**:`📌 本次测试使用预设 prompt "你好,请用一句话回复" + max_tokens=200` —— 让用户明白这是固定诊断请求,不是真实对话。 - **详情面板的空 body 展示优化**:不再显示 "(empty)" 字面量(可能被误解为 服务器真的返回了空字符串),改为带颜色和 italic 的 "(响应体为空)" 提示。 ### i18n 新增 5 个翻译键 - testModelReply:Model Reply - testFixedPrompt:本次测试使用预设 prompt... - testRespBodyEmpty:响应主体未能读取...但已从流中提取回复 - testShowDetails:查看完整请求/响应参数(原来硬编码中文) - testRespBodyEmptyDetail:(响应体为空) [原来是硬编码 "(empty)"] 均覆盖 zh-CN / en / zh-TW / ja / ko / vi。 ## 验证 - npm run build 通过(assistant chunk gzip 49.43 KB) - cargo check 通过 - 下一步:用户实测后确认 Response Body 正常显示再发 v0.13.4 ## 相关 - Roadmap v0.14.0:把测试功能升级成迷你 Playground(自定义 prompt / 多轮 对话 / 流式显示 / max_tokens 滑块) --- scripts/dev-api.js | 8 ++++--- src-tauri/src/commands/config.rs | 33 +++++++++++++++++++++++--- src/locales/modules/assistant.js | 5 ++++ src/pages/assistant.js | 40 +++++++++++++++++++++----------- 4 files changed, 66 insertions(+), 20 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index f333845..4aa407a 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -4959,26 +4959,28 @@ const handlers = { const controller = new AbortController() const timer = setTimeout(() => controller.abort(), 30000) + // Accept-Encoding: identity 禁止响应压缩,规避 Node fetch 对某些压缩格式的解码异常 + // (和 Rust test_model_verbose 保持行为一致) let usedApi, reqUrl, reqBody, headers, realUrl if (type === 'anthropic-messages') { usedApi = 'Anthropic Messages' reqUrl = `${base}/messages` realUrl = reqUrl reqBody = { model: modelId, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200 } - headers = { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01' } + headers = { 'Content-Type': 'application/json', 'anthropic-version': '2023-06-01', 'Accept-Encoding': 'identity' } if (apiKey) headers['x-api-key'] = apiKey } else if (type === 'google-gemini') { usedApi = 'Gemini' reqUrl = `${base}/models/${encodeURIComponent(modelId)}:generateContent?key=***` realUrl = `${base}/models/${encodeURIComponent(modelId)}:generateContent?key=${encodeURIComponent(apiKey || '')}` reqBody = { contents: [{ role: 'user', parts: [{ text: '你好,请用一句话回复' }] }] } - headers = { 'Content-Type': 'application/json' } + headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity' } } else { usedApi = 'Chat Completions' reqUrl = `${base}/chat/completions` realUrl = reqUrl reqBody = { model: modelId, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200, stream: false } - headers = { 'Content-Type': 'application/json' } + headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity' } if (apiKey) headers['Authorization'] = `Bearer ${apiKey}` } diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 398c84d..49658a9 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -5268,6 +5268,10 @@ pub async fn test_model_verbose( crate::commands::build_http_client_no_proxy(std::time::Duration::from_secs(30), None) .map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; + // 关键:显式 Accept-Encoding: identity 禁止响应压缩,避免: + // - reqwest 未启用 brotli feature 时,provider 返回 Content-Encoding: br 导致 text() 失败 + // - 某些 CDN 会根据默认 UA 自动压缩响应 + // 测试请求的响应体很小(几百字节),不压缩的性能损失可忽略 let (used_api, req_url, req_body_json, req_builder) = match api_type_norm { "anthropic-messages" => { let url = format!("{}/messages", base); @@ -5279,6 +5283,7 @@ pub async fn test_model_verbose( let mut req = client .post(&url) .header("anthropic-version", "2023-06-01") + .header("Accept-Encoding", "identity") .json(&body); if !api_key.is_empty() { req = req.header("x-api-key", api_key.clone()); @@ -5294,7 +5299,10 @@ pub async fn test_model_verbose( let body = json!({ "contents": [{"role": "user", "parts": [{"text": "你好,请用一句话回复"}]}] }); - let req = client.post(&url_real).json(&body); + let req = client + .post(&url_real) + .header("Accept-Encoding", "identity") + .json(&body); ("Gemini", url_display, body, req) } _ => { @@ -5305,7 +5313,10 @@ pub async fn test_model_verbose( "max_tokens": 200, "stream": false }); - let mut req = client.post(&url).json(&body); + let mut req = client + .post(&url) + .header("Accept-Encoding", "identity") + .json(&body); if !api_key.is_empty() { req = req.header("Authorization", format!("Bearer {api_key}")); } @@ -5342,7 +5353,23 @@ pub async fn test_model_verbose( let status = resp.status(); let status_code = status.as_u16(); - let text = resp.text().await.unwrap_or_default(); + // 读取响应体:若失败(如 gzip/brotli 解码异常、非法 UTF-8)直接返回错误,不静默吞成空串 + let text = match resp.text().await { + Ok(t) => t, + Err(e) => { + return Ok(json!({ + "success": false, + "status": status_code, + "reqUrl": req_url, + "reqBody": req_body_json, + "respBody": "", + "reply": "", + "error": format!("读取响应体失败: {e} (可能是压缩编码未支持或非 UTF-8 响应)"), + "elapsedMs": elapsed_ms, + "usedApi": used_api, + })); + } + }; // 提取 reply 文本(兼容 OpenAI / Anthropic / Gemini / DashScope) let reply = serde_json::from_str::(&text) diff --git a/src/locales/modules/assistant.js b/src/locales/modules/assistant.js index 7527a18..a758c41 100644 --- a/src/locales/modules/assistant.js +++ b/src/locales/modules/assistant.js @@ -261,6 +261,11 @@ export default { testSuccess: _('连接成功', 'Connection successful', '連線成功', '接続成功', '연결 성공', 'Kết nối thành công', 'Conexión exitosa', 'Conexão bem-sucedida', 'Подключение успешно', 'Connexion réussie', 'Verbindung erfolgreich'), testFailed: _('连接失败', 'Connection failed', '連線失敗', '接続失敗', '연결 실패', 'Kết nối thất bại', 'Conexión fallida', 'Conexão falhou', 'Ошибка подключения', 'Connexion échouée', 'Verbindung fehlgeschlagen'), testNoReply: _('(无回复内容)', '(No reply content)', '(無回覆內容)', '(応答なし)'), + testModelReply: _('模型回复', 'Model Reply', '模型回覆', 'モデル応答', '모델 응답', 'Phản hồi mô hình'), + testFixedPrompt: _('本次测试使用预设 prompt "你好,请用一句话回复" + max_tokens=200', 'Test uses fixed prompt "Hi, reply in one sentence" + max_tokens=200', '本次測試使用預設 prompt「你好,請用一句話回覆」+ max_tokens=200', 'テストは固定 prompt "こんにちは、一言で返答してください" + max_tokens=200 を使用', '이 테스트는 고정 prompt "안녕하세요, 한 문장으로 답변" + max_tokens=200 사용', 'Sử dụng prompt cố định "Xin chào, trả lời trong một câu" + max_tokens=200'), + testRespBodyEmpty: _('注:响应主体未能读取(可能是压缩编码异常),但已从响应流中提取到回复内容', 'Note: response body unreadable (possibly compression encoding issue), but reply was extracted from the stream', '注:回應主體未能讀取(可能是壓縮編碼異常),但已從回應串流中提取到回覆內容', '注:レスポンスボディを読み取れませんでした(圧縮エンコーディング異常の可能性)が、ストリームから回答を抽出しました', '참고: 응답 본문을 읽을 수 없음 (압축 인코딩 이슈 가능), 하지만 스트림에서 응답을 추출함', 'Ghi chú: không đọc được body phản hồi (có thể do nén), nhưng đã trích xuất nội dung phản hồi từ luồng'), + testShowDetails: _('查看完整请求/响应参数', 'View full request / response', '查看完整請求/回應參數', '完全なリクエスト/レスポンスを表示', '전체 요청/응답 보기', 'Xem toàn bộ request / response'), + testRespBodyEmptyDetail: _('(响应体为空)', '(response body is empty)', '(回應體為空)', '(レスポンスボディが空)', '(응답 본문이 비어 있음)', '(body phản hồi trống)'), settingsTitle: _('助手设置', 'Assistant Settings', '助手設定', 'アシスタント設定', '어시스턴트 설정', 'Cài đặt trợ lý', 'Configuración del asistente', 'Configurações do assistente', 'Настройки ассистента', 'Paramètres de l\'assistant', 'Assistenten-Einstellungen'), settings: _('设置', 'Settings', '設定', '設定', '설정', 'Cài đặt', 'Configuración', 'Configurações', 'Настройки', 'Paramètres', 'Einstellungen'), settingsSaved: _('设置已保存', 'Settings saved', '設定已儲存', '設定を保存しました', '설정 저장됨', 'Đã lưu cài đặt', 'Configuración guardada', 'Configurações salvas', 'Настройки сохранены', 'Paramètres enregistrés', 'Einstellungen gespeichert'), diff --git a/src/pages/assistant.js b/src/pages/assistant.js index ef1c6ae..5fe906e 100644 --- a/src/pages/assistant.js +++ b/src/pages/assistant.js @@ -2946,35 +2946,47 @@ function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatu apiErrMsg = errJson.error?.message || errJson.message || '' } catch {} } - // 状态行 + // 状态行(加粗显示,区分成功/警告/失败) if (error) { - html += `✗ ${t('assistant.testFailed')}: ${escHtml(error)}` + html += `
✗ ${t('assistant.testFailed')}: ${escHtml(error)}
` } else if (success) { - html += `✓ ${t('assistant.testSuccess', { elapsed, api: usedApi })}` + html += `
✓ ${t('assistant.testSuccess', { elapsed, api: usedApi })}
` } else { - html += `${statusIcon('warn', 14)} HTTP ${respStatus} — ${t('assistant.testNoReply')}` + html += `
${statusIcon('warn', 14)} HTTP ${respStatus} — ${t('assistant.testNoReply')}
` } // API 错误信息(完整展示,URL 可点击) if (apiErrMsg) { html += `
${_linkify(escHtml(apiErrMsg))}
` } - // 回复预览 + // 模型回复(完整展示,不截断;长回复给最大高度 + scroll) if (reply) { - const short = reply.length > 80 ? reply.slice(0, 80) + '...' : reply - html += `
「${escHtml(short)}」
` + html += `
` + + `
${t('assistant.testModelReply')}
` + + escHtml(reply) + + `
` } + // respBody 空但 reply 非空:明确诊断 + if (!respBody && reply) { + html += `
${escHtml(t('assistant.testRespBodyEmpty'))}
` + } + // 固定 prompt 脚注(用户知情权:测试的是预设请求,不是真实对话) + html += `
📌 ${escHtml(t('assistant.testFixedPrompt'))}
` // 折叠的详细信息 - html += `
查看完整请求/响应参数` - html += `
` + html += `
${t('assistant.testShowDetails')}` + html += `
` html += `POST ${escHtml(reqUrl)}\n\n` html += `Request Body:\n${escHtml(JSON.stringify(reqBody, null, 2))}\n\n` html += `Response Status: ${respStatus}\n\n` html += `Response Body:\n` - // 美化 JSON - try { - html += escHtml(JSON.stringify(JSON.parse(respBody), null, 2)) - } catch { - html += escHtml(respBody?.slice(0, 2000) || '(empty)') + // 美化 JSON(空串单独提示,避免误导为"empty"字面量) + if (!respBody) { + html += `${escHtml(t('assistant.testRespBodyEmptyDetail'))}` + } else { + try { + html += escHtml(JSON.stringify(JSON.parse(respBody), null, 2)) + } catch { + html += escHtml(respBody.slice(0, 4000)) + } } html += `
` return html