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

@@ -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}`
}

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)

View File

@@ -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'),

View File

@@ -2946,35 +2946,47 @@ function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatu
apiErrMsg = errJson.error?.message || errJson.message || ''
} catch {}
}
// 状态行
// 状态行(加粗显示,区分成功/警告/失败)
if (error) {
html += `<span style="color:var(--error)">✗ ${t('assistant.testFailed')}: ${escHtml(error)}</span>`
html += `<div style="color:var(--error);font-weight:500">✗ ${t('assistant.testFailed')}: ${escHtml(error)}</div>`
} else if (success) {
html += `<span style="color:var(--success)">✓ ${t('assistant.testSuccess', { elapsed, api: usedApi })}</span>`
html += `<div style="color:var(--success);font-weight:500">✓ ${t('assistant.testSuccess', { elapsed, api: usedApi })}</div>`
} else {
html += `<span style="color:var(--warning)">${statusIcon('warn', 14)} HTTP ${respStatus}${t('assistant.testNoReply')}</span>`
html += `<div style="color:var(--warning);font-weight:500">${statusIcon('warn', 14)} HTTP ${respStatus}${t('assistant.testNoReply')}</div>`
}
// API 错误信息完整展示URL 可点击)
if (apiErrMsg) {
html += `<div style="margin-top:6px;padding:8px 10px;background:var(--bg-tertiary);border-left:3px solid var(--warning);border-radius:4px;font-size:12px;color:var(--text-secondary);line-height:1.6;word-break:break-all">${_linkify(escHtml(apiErrMsg))}</div>`
}
// 回复预览
// 模型回复(完整展示,不截断;长回复给最大高度 + scroll
if (reply) {
const short = reply.length > 80 ? reply.slice(0, 80) + '...' : reply
html += `<div style="margin-top:4px;padding:6px 8px;background:var(--bg-tertiary);border-radius:4px;font-size:12px;color:var(--text-secondary)">${escHtml(short)}</div>`
html += `<div style="margin-top:6px;padding:8px 10px;background:var(--bg-tertiary);border-left:3px solid var(--success);border-radius:4px;font-size:13px;color:var(--text-primary);line-height:1.6;white-space:pre-wrap;word-break:break-word;max-height:180px;overflow:auto">` +
`<div style="font-size:10px;color:var(--text-tertiary);margin-bottom:4px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase">${t('assistant.testModelReply')}</div>` +
escHtml(reply) +
`</div>`
}
// respBody 空但 reply 非空:明确诊断
if (!respBody && reply) {
html += `<div style="margin-top:6px;font-size:11px;color:var(--text-tertiary);font-style:italic;line-height:1.5">${escHtml(t('assistant.testRespBodyEmpty'))}</div>`
}
// 固定 prompt 脚注(用户知情权:测试的是预设请求,不是真实对话)
html += `<div style="margin-top:8px;font-size:10px;color:var(--text-tertiary);opacity:0.7;line-height:1.4">📌 ${escHtml(t('assistant.testFixedPrompt'))}</div>`
// 折叠的详细信息
html += `<details style="margin-top:6px;font-size:11px"><summary style="cursor:pointer;color:var(--text-tertiary);user-select:none">查看完整请求/响应参数</summary>`
html += `<div style="margin-top:4px;max-height:200px;overflow:auto;background:var(--bg-tertiary);border-radius:4px;padding:8px;font-family:var(--font-mono);font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-all">`
html += `<details style="margin-top:6px;font-size:11px"><summary style="cursor:pointer;color:var(--text-tertiary);user-select:none">${t('assistant.testShowDetails')}</summary>`
html += `<div style="margin-top:4px;max-height:240px;overflow:auto;background:var(--bg-tertiary);border-radius:4px;padding:8px;font-family:var(--font-mono);font-size:11px;line-height:1.5;white-space:pre-wrap;word-break:break-all">`
html += `<strong>POST</strong> ${escHtml(reqUrl)}\n\n`
html += `<strong>Request Body:</strong>\n${escHtml(JSON.stringify(reqBody, null, 2))}\n\n`
html += `<strong>Response Status:</strong> ${respStatus}\n\n`
html += `<strong>Response Body:</strong>\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 += `<span style="color:var(--text-tertiary);font-style:italic">${escHtml(t('assistant.testRespBodyEmptyDetail'))}</span>`
} else {
try {
html += escHtml(JSON.stringify(JSON.parse(respBody), null, 2))
} catch {
html += escHtml(respBody.slice(0, 4000))
}
}
html += `</div></details>`
return html