mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-06 20:02:49 +08:00
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:
@@ -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}`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user