fix(assistant): 修复测试 Response Body 为 (empty) + 优化结果展示

## 用户反馈

截图显示测试结果详情里 "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 滑块)
This commit is contained in:
晴天
2026-04-20 14:12:40 +08:00
parent d6cc0e04d3
commit f69360744f
4 changed files with 66 additions and 20 deletions

View File

@@ -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::<serde_json::Value>(&text)