mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-10 17:42:49 +08:00
fix(assistant): 模型测试按钮改用流式累积 + 增强诊断信息
- 测试请求切换到 stream: true + SSE 累积,绕开部分兼容网关 non-streaming 分支对某些模型返回 200 + 空 body 的已知 bug, 行为与真实对话路径一致 - 后端 test_model_verbose 显式设置 Accept-Encoding: identity, 避免压缩协商带来的解码风险 - 用 resp.bytes() + 严格 UTF-8 decode,失败时 fallback 到 lossy 字符串 + 前 200 字节 hex dump,方便定位非 UTF-8 响应 - 展开 reqwest error source 链,响应头与字节数原样返回前端 - 前端结果面板突出显示完整模型回复、固定 prompt 标注、 响应头与 raw bytes hex,方便用户自查上游问题 - scripts/dev-api.js 同步 Rust 后端行为,保证 Web/桌面两侧诊断一致
This commit is contained in:
@@ -3025,11 +3025,51 @@ const ALWAYS_LOCAL = new Set([
|
||||
function _normalizeBaseUrl(raw) {
|
||||
let base = (raw || '').replace(/\/+$/, '')
|
||||
base = base.replace(/\/(api\/chat|api\/generate|api\/tags|api|chat\/completions|completions|responses|messages|models)\/?$/, '')
|
||||
base = base.replace(/\/(api\/chat|api\/generate|api\/tags|api|chat\/completions|completions|responses|messages|models)\/?$/, '')
|
||||
base = base.replace(/\/+$/, '')
|
||||
if (/:11434$/i.test(base)) return `${base}/v1`
|
||||
return base
|
||||
}
|
||||
|
||||
// 从 SSE 流文本中累积 OpenAI 风格的 delta.content / delta.reasoning_content
|
||||
// 同时兼容 Anthropic streaming (content_block_delta)
|
||||
// 格式示例:
|
||||
// data: {"choices":[{"delta":{"content":"你好"}}]}
|
||||
// data: {"choices":[{"delta":{"content":","}}]}
|
||||
// data: [DONE]
|
||||
function _extractSseReply(text) {
|
||||
if (!text) return ''
|
||||
let content = ''
|
||||
let reasoning = ''
|
||||
let sawDataLine = false
|
||||
for (const line of text.split('\n')) {
|
||||
let data
|
||||
if (line.startsWith('data: ')) data = line.slice(6)
|
||||
else if (line.startsWith('data:')) data = line.slice(5)
|
||||
else continue
|
||||
sawDataLine = true
|
||||
data = data.trim()
|
||||
if (!data || data === '[DONE]') continue
|
||||
try {
|
||||
const v = JSON.parse(data)
|
||||
// OpenAI / 兼容后端:choices[0].delta.content
|
||||
const delta = v?.choices?.[0]?.delta
|
||||
if (delta) {
|
||||
if (typeof delta.content === 'string') content += delta.content
|
||||
if (typeof delta.reasoning_content === 'string') reasoning += delta.reasoning_content
|
||||
}
|
||||
// Anthropic streaming: {"type":"content_block_delta","delta":{"type":"text_delta","text":"..."}}
|
||||
if (v?.type === 'content_block_delta' && typeof v?.delta?.text === 'string') {
|
||||
content += v.delta.text
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!sawDataLine) return ''
|
||||
if (content) return content
|
||||
if (reasoning) return `[reasoning] ${reasoning}`
|
||||
return ''
|
||||
}
|
||||
|
||||
// === 后端内存缓存(ARM 设备性能优化)===
|
||||
// 防止短时间内重复 spawn CLI 进程,显著降低 CPU 占用
|
||||
const _serverCache = new Map()
|
||||
@@ -4976,11 +5016,13 @@ const handlers = {
|
||||
reqBody = { contents: [{ role: 'user', parts: [{ text: '你好,请用一句话回复' }] }] }
|
||||
headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity' }
|
||||
} else {
|
||||
usedApi = 'Chat Completions'
|
||||
// OpenAI 兼容路径用 stream:true:部分兼容网关的 non-streaming 分支对某些模型
|
||||
// 会返回 200 + 空 body,而 streaming 分支所有 provider 都稳定支持,与真实对话一致
|
||||
usedApi = 'Chat Completions (SSE)'
|
||||
reqUrl = `${base}/chat/completions`
|
||||
realUrl = reqUrl
|
||||
reqBody = { model: modelId, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200, stream: false }
|
||||
headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity' }
|
||||
reqBody = { model: modelId, messages: [{ role: 'user', content: '你好,请用一句话回复' }], max_tokens: 200, stream: true }
|
||||
headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'identity', 'Accept': 'text/event-stream' }
|
||||
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`
|
||||
}
|
||||
|
||||
@@ -4991,32 +5033,58 @@ const handlers = {
|
||||
clearTimeout(timer)
|
||||
const elapsedMs = Date.now() - t0
|
||||
const error = e.name === 'AbortError' ? '请求超时 (30s)' : (e.message || String(e))
|
||||
return { success: false, status: 0, reqUrl, reqBody, respBody: '', reply: '', error, elapsedMs, usedApi }
|
||||
return { success: false, status: 0, reqUrl, reqBody, respHeaders: null, respBody: '', respRawHex: '', respByteCount: 0, reply: '', error, elapsedMs, usedApi }
|
||||
}
|
||||
clearTimeout(timer)
|
||||
const elapsedMs = Date.now() - t0
|
||||
const status = resp.status
|
||||
const respBody = await resp.text().catch(() => '')
|
||||
|
||||
let reply = ''
|
||||
// 抓取响应头
|
||||
const respHeaders = {}
|
||||
for (const [k, v] of resp.headers.entries()) respHeaders[k] = v
|
||||
// 先拿字节,再自己 UTF-8 decode,失败时给 hex dump
|
||||
let respBody = ''
|
||||
let respRawHex = ''
|
||||
let respByteCount = 0
|
||||
let decodeErr = null
|
||||
try {
|
||||
const v = JSON.parse(respBody)
|
||||
if (Array.isArray(v.content)) {
|
||||
reply = v.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
const buf = new Uint8Array(await resp.arrayBuffer())
|
||||
respByteCount = buf.length
|
||||
respRawHex = Array.from(buf.slice(0, 200)).map(b => b.toString(16).padStart(2, '0')).join(' ')
|
||||
try {
|
||||
respBody = new TextDecoder('utf-8', { fatal: true }).decode(buf)
|
||||
} catch (e) {
|
||||
// UTF-8 严格解码失败,给 lossy 版本
|
||||
respBody = new TextDecoder('utf-8').decode(buf)
|
||||
decodeErr = `响应体 UTF-8 解码失败: ${e.message} | 字节数=${respByteCount}`
|
||||
}
|
||||
if (!reply && v.candidates?.[0]?.content?.parts) {
|
||||
reply = v.candidates[0].content.parts.map(p => p.text).filter(Boolean).join('')
|
||||
}
|
||||
if (!reply && v.choices?.[0]?.message) {
|
||||
const msg = v.choices[0].message
|
||||
reply = msg.content || (msg.reasoning_content ? `[reasoning] ${msg.reasoning_content}` : '')
|
||||
}
|
||||
if (!reply && v.output?.text) reply = v.output.text
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
decodeErr = `读取响应字节失败: ${e.message}`
|
||||
}
|
||||
|
||||
const success = resp.ok && !!reply
|
||||
// 先尝试 SSE 累积(OpenAI stream:true / Anthropic streaming),再回退到单 JSON
|
||||
let reply = _extractSseReply(respBody)
|
||||
if (!reply) {
|
||||
try {
|
||||
const v = JSON.parse(respBody)
|
||||
if (Array.isArray(v.content)) {
|
||||
reply = v.content.filter(b => b.type === 'text').map(b => b.text).join('')
|
||||
}
|
||||
if (!reply && v.candidates?.[0]?.content?.parts) {
|
||||
reply = v.candidates[0].content.parts.map(p => p.text).filter(Boolean).join('')
|
||||
}
|
||||
if (!reply && v.choices?.[0]?.message) {
|
||||
const msg = v.choices[0].message
|
||||
reply = msg.content || (msg.reasoning_content ? `[reasoning] ${msg.reasoning_content}` : '')
|
||||
}
|
||||
if (!reply && v.output?.text) reply = v.output.text
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const success = resp.ok && !!reply && !decodeErr
|
||||
let error = null
|
||||
if (!resp.ok) {
|
||||
if (decodeErr) {
|
||||
error = decodeErr
|
||||
} else if (!resp.ok) {
|
||||
try {
|
||||
const v = JSON.parse(respBody)
|
||||
error = v.error?.message || v.message || `HTTP ${status}`
|
||||
@@ -5024,7 +5092,7 @@ const handlers = {
|
||||
} else if (!reply) {
|
||||
error = 'API 已响应但未解析出内容'
|
||||
}
|
||||
return { success, status, reqUrl, reqBody, respBody, reply, error, elapsedMs, usedApi }
|
||||
return { success, status, reqUrl, reqBody, respHeaders, respBody, respRawHex, respByteCount, reply, error, elapsedMs, usedApi }
|
||||
},
|
||||
|
||||
async list_remote_models({ baseUrl, apiKey, apiType = 'openai-completions' }) {
|
||||
|
||||
@@ -5246,11 +5246,132 @@ pub async fn test_model(
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
/// 从 SSE 流文本中累积 OpenAI 风格的 delta.content / delta.reasoning_content
|
||||
/// 格式示例:
|
||||
/// data: {"choices":[{"delta":{"content":"你好"}}]}
|
||||
/// data: {"choices":[{"delta":{"content":","}}]}
|
||||
/// data: [DONE]
|
||||
fn extract_sse_reply(text: &str) -> String {
|
||||
let mut content = String::new();
|
||||
let mut reasoning = String::new();
|
||||
let mut saw_data_line = false;
|
||||
for line in text.lines() {
|
||||
let data = if let Some(rest) = line.strip_prefix("data: ") {
|
||||
rest
|
||||
} else if let Some(rest) = line.strip_prefix("data:") {
|
||||
rest
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
saw_data_line = true;
|
||||
let data = data.trim();
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(data) {
|
||||
// OpenAI / 兼容后端:choices[0].delta.content
|
||||
let delta = v
|
||||
.get("choices")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("delta"));
|
||||
if let Some(d) = delta {
|
||||
if let Some(c) = d.get("content").and_then(|c| c.as_str()) {
|
||||
content.push_str(c);
|
||||
}
|
||||
if let Some(rc) = d.get("reasoning_content").and_then(|c| c.as_str()) {
|
||||
reasoning.push_str(rc);
|
||||
}
|
||||
}
|
||||
// Anthropic streaming: {"type":"content_block_delta","delta":{"type":"text_delta","text":"..."}}
|
||||
if v.get("type").and_then(|t| t.as_str()) == Some("content_block_delta") {
|
||||
if let Some(c) = v
|
||||
.get("delta")
|
||||
.and_then(|d| d.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
{
|
||||
content.push_str(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !saw_data_line {
|
||||
return String::new();
|
||||
}
|
||||
if !content.is_empty() {
|
||||
content
|
||||
} else if !reasoning.is_empty() {
|
||||
format!("[reasoning] {reasoning}")
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// 从单个 JSON 响应中提取 reply(兼容 OpenAI / Anthropic / Gemini / DashScope 非流式)
|
||||
fn extract_single_json_reply(text: &str) -> String {
|
||||
serde_json::from_str::<serde_json::Value>(text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
|
||||
let text = arr
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if !text.is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
if let Some(t) = v
|
||||
.get("candidates")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("content"))
|
||||
.and_then(|c| c.get("parts"))
|
||||
.and_then(|p| p.get(0))
|
||||
.and_then(|p| p.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return Some(t.to_string());
|
||||
}
|
||||
if let Some(msg) = v
|
||||
.get("choices")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("message"))
|
||||
{
|
||||
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
|
||||
if !content.is_empty() {
|
||||
return Some(content.to_string());
|
||||
}
|
||||
if let Some(rc) = msg
|
||||
.get("reasoning_content")
|
||||
.and_then(|c| c.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return Some(format!("[reasoning] {rc}"));
|
||||
}
|
||||
}
|
||||
if let Some(t) = v
|
||||
.get("output")
|
||||
.and_then(|o| o.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return Some(t.to_string());
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 测试模型(详细版 #Compat-1):返回完整 req/resp 信息,供前端 debug 面板展示
|
||||
///
|
||||
/// 相比 test_model:
|
||||
/// - 不会因 400/422/429 等吞掉错误返回"连接正常",一律如实回传 status + body
|
||||
/// - 返回结构化 JSON:success/status/req_url/req_body/resp_body/reply/error/elapsed_ms/used_api
|
||||
/// - 前端拿到后可以直接渲染 debug 面板,无需在 webview 里走外部 fetch(规避 status 0)
|
||||
/// - OpenAI 兼容路径使用 stream:true(绕开某些 new-api 后端的 non-streaming bug,
|
||||
/// 并与真实对话行为一致)
|
||||
#[tauri::command]
|
||||
pub async fn test_model_verbose(
|
||||
base_url: String,
|
||||
@@ -5307,20 +5428,25 @@ pub async fn test_model_verbose(
|
||||
}
|
||||
_ => {
|
||||
let url = format!("{}/chat/completions", base);
|
||||
// 关键:测试请求用 stream: true 而非 stream: false
|
||||
// 理由:部分兼容网关的 non-streaming 分支对某些模型会返回 200 + 空 body,
|
||||
// 而 streaming 分支是真实对话路径,所有 provider 都稳定支持。
|
||||
// 测试走 stream: true + SSE 累积,行为与真实对话一致。
|
||||
let body = json!({
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": "你好,请用一句话回复"}],
|
||||
"max_tokens": 200,
|
||||
"stream": false
|
||||
"stream": true
|
||||
});
|
||||
let mut req = client
|
||||
.post(&url)
|
||||
.header("Accept-Encoding", "identity")
|
||||
.header("Accept", "text/event-stream")
|
||||
.json(&body);
|
||||
if !api_key.is_empty() {
|
||||
req = req.header("Authorization", format!("Bearer {api_key}"));
|
||||
}
|
||||
("Chat Completions", url, body, req)
|
||||
("Chat Completions (SSE)", url, body, req)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5353,79 +5479,104 @@ pub async fn test_model_verbose(
|
||||
|
||||
let status = resp.status();
|
||||
let status_code = status.as_u16();
|
||||
// 读取响应体:若失败(如 gzip/brotli 解码异常、非法 UTF-8)直接返回错误,不静默吞成空串
|
||||
let text = match resp.text().await {
|
||||
Ok(t) => t,
|
||||
|
||||
// 先抓取响应头(text() 会消耗 resp)—— 这是关键诊断信息:
|
||||
// Content-Encoding 告诉我们是否压缩、是 br/gzip/zstd 还是啥
|
||||
// Content-Type 告诉我们是否是 JSON / text
|
||||
// Content-Length 告诉我们服务器声明的响应体大小
|
||||
let resp_headers = {
|
||||
let mut map = serde_json::Map::new();
|
||||
for (k, v) in resp.headers().iter() {
|
||||
map.insert(
|
||||
k.to_string(),
|
||||
serde_json::Value::String(v.to_str().unwrap_or("<non-utf8>").to_string()),
|
||||
);
|
||||
}
|
||||
serde_json::Value::Object(map)
|
||||
};
|
||||
|
||||
// 读取响应体:改用 bytes() 拿原始字节(reqwest 会按 Content-Encoding 自动解压),
|
||||
// 然后自己做 UTF-8 decode。这样:
|
||||
// 1. 失败时能给出更精确的错误分类(网络错误 vs 解压错误 vs UTF-8 错误)
|
||||
// 2. UTF-8 失败时能 fallback 到 hex dump + lossy string,方便诊断
|
||||
let bytes = match resp.bytes().await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
let mut err_chain = format!("{e}");
|
||||
let mut src: Option<&dyn std::error::Error> = std::error::Error::source(&e);
|
||||
while let Some(s) = src {
|
||||
err_chain.push_str(&format!(" → {s}"));
|
||||
src = std::error::Error::source(s);
|
||||
}
|
||||
return Ok(json!({
|
||||
"success": false,
|
||||
"status": status_code,
|
||||
"reqUrl": req_url,
|
||||
"reqBody": req_body_json,
|
||||
"respHeaders": resp_headers,
|
||||
"respBody": "",
|
||||
"respRawHex": "",
|
||||
"respByteCount": 0,
|
||||
"reply": "",
|
||||
"error": format!("读取响应体失败: {e} (可能是压缩编码未支持或非 UTF-8 响应)"),
|
||||
"error": format!("读取响应字节失败: {err_chain}"),
|
||||
"elapsedMs": elapsed_ms,
|
||||
"usedApi": used_api,
|
||||
}));
|
||||
}
|
||||
};
|
||||
let byte_count = bytes.len();
|
||||
|
||||
// 前 200 字节的 hex dump(无论成功失败都附上,方便调试)
|
||||
let hex_preview = bytes
|
||||
.iter()
|
||||
.take(200)
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
// 尝试严格 UTF-8 decode;失败时 fallback 到 lossy 并在 error 里带上诊断
|
||||
let text = match std::str::from_utf8(&bytes) {
|
||||
Ok(s) => s.to_string(),
|
||||
Err(e) => {
|
||||
let lossy = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let ascii_preview: String = bytes
|
||||
.iter()
|
||||
.take(80)
|
||||
.map(|&b| {
|
||||
if (0x20..=0x7e).contains(&b) {
|
||||
b as char
|
||||
} else {
|
||||
'.'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
return Ok(json!({
|
||||
"success": false,
|
||||
"status": status_code,
|
||||
"reqUrl": req_url,
|
||||
"reqBody": req_body_json,
|
||||
"respHeaders": resp_headers,
|
||||
"respBody": lossy,
|
||||
"respRawHex": hex_preview,
|
||||
"respByteCount": byte_count,
|
||||
"reply": "",
|
||||
"error": format!("响应体 UTF-8 解码失败: {e} | 字节数={byte_count} | 前 80 字节 ASCII='{ascii_preview}'"),
|
||||
"elapsedMs": elapsed_ms,
|
||||
"usedApi": used_api,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 提取 reply 文本(兼容 OpenAI / Anthropic / Gemini / DashScope)
|
||||
let reply = serde_json::from_str::<serde_json::Value>(&text)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
if let Some(arr) = v.get("content").and_then(|c| c.as_array()) {
|
||||
let text = arr
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(|t| t.as_str()) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(|t| t.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if !text.is_empty() {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
if let Some(t) = v
|
||||
.get("candidates")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("content"))
|
||||
.and_then(|c| c.get("parts"))
|
||||
.and_then(|p| p.get(0))
|
||||
.and_then(|p| p.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return Some(t.to_string());
|
||||
}
|
||||
if let Some(msg) = v
|
||||
.get("choices")
|
||||
.and_then(|c| c.get(0))
|
||||
.and_then(|c| c.get("message"))
|
||||
{
|
||||
let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
|
||||
if !content.is_empty() {
|
||||
return Some(content.to_string());
|
||||
}
|
||||
if let Some(rc) = msg
|
||||
.get("reasoning_content")
|
||||
.and_then(|c| c.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return Some(format!("[reasoning] {rc}"));
|
||||
}
|
||||
}
|
||||
if let Some(t) = v
|
||||
.get("output")
|
||||
.and_then(|o| o.get("text"))
|
||||
.and_then(|t| t.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
{
|
||||
return Some(t.to_string());
|
||||
}
|
||||
None
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// 提取 reply 文本:同时兼容 SSE 流(stream:true)和单次 JSON(stream:false)
|
||||
// 优先尝试 SSE 解析(OpenAI 兼容路径现在用 stream:true),失败再回退到单 JSON
|
||||
let reply = {
|
||||
let sse_reply = extract_sse_reply(&text);
|
||||
if !sse_reply.is_empty() {
|
||||
sse_reply
|
||||
} else {
|
||||
extract_single_json_reply(&text)
|
||||
}
|
||||
};
|
||||
|
||||
let success = status.is_success() && !reply.is_empty();
|
||||
let error = if !status.is_success() {
|
||||
@@ -5441,7 +5592,10 @@ pub async fn test_model_verbose(
|
||||
"status": status_code,
|
||||
"reqUrl": req_url,
|
||||
"reqBody": req_body_json,
|
||||
"respHeaders": resp_headers,
|
||||
"respBody": text,
|
||||
"respRawHex": hex_preview,
|
||||
"respByteCount": byte_count,
|
||||
"reply": reply,
|
||||
"error": error,
|
||||
"elapsedMs": elapsed_ms,
|
||||
|
||||
@@ -2936,7 +2936,7 @@ function renderMessages() {
|
||||
|
||||
function _linkify(str) { return str.replace(/(https?:\/\/[^\s,,。;))'"]+)/g, '<a href="$1" target="_blank" style="color:var(--primary)">$1</a>') }
|
||||
|
||||
function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatus, respBody, reply, error }) {
|
||||
function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatus, respHeaders, respBody, respRawHex, respByteCount, reply, error }) {
|
||||
let html = ''
|
||||
// 尝试解析 API 返回的错误信息
|
||||
let apiErrMsg = ''
|
||||
@@ -2958,6 +2958,17 @@ function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatu
|
||||
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>`
|
||||
}
|
||||
// 解码失败时,显眼展示关键诊断信息:Content-Encoding 和字节数
|
||||
if (error && respHeaders) {
|
||||
const contentEncoding = respHeaders['content-encoding'] || respHeaders['Content-Encoding'] || '(未声明)'
|
||||
const contentType = respHeaders['content-type'] || respHeaders['Content-Type'] || '(未知)'
|
||||
html += `<div style="margin-top:6px;padding:8px 10px;background:var(--bg-tertiary);border-left:3px solid var(--error);border-radius:4px;font-size:11px;color:var(--text-secondary);line-height:1.7;font-family:var(--font-mono)">` +
|
||||
`<div style="color:var(--text-primary);font-weight:600;margin-bottom:4px;font-family:var(--font-sans)">🔍 诊断信息</div>` +
|
||||
`Content-Encoding: <strong style="color:var(--warning)">${escHtml(contentEncoding)}</strong><br>` +
|
||||
`Content-Type: ${escHtml(contentType)}<br>` +
|
||||
(respByteCount ? `响应字节数: ${respByteCount}` : '') +
|
||||
`</div>`
|
||||
}
|
||||
// 模型回复(完整展示,不截断;长回复给最大高度 + scroll)
|
||||
if (reply) {
|
||||
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">` +
|
||||
@@ -2977,7 +2988,21 @@ function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatu
|
||||
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`
|
||||
// Response Headers(完整列出,每行一个)
|
||||
if (respHeaders && typeof respHeaders === 'object') {
|
||||
html += `<strong>Response Headers:</strong>\n`
|
||||
const entries = Object.entries(respHeaders)
|
||||
if (entries.length === 0) {
|
||||
html += `<span style="color:var(--text-tertiary);font-style:italic">(无)</span>\n\n`
|
||||
} else {
|
||||
html += entries.map(([k, v]) => ` ${escHtml(k)}: ${escHtml(String(v))}`).join('\n') + '\n\n'
|
||||
}
|
||||
}
|
||||
html += `<strong>Response Body:</strong>`
|
||||
if (respByteCount !== undefined && respByteCount !== null) {
|
||||
html += ` <span style="color:var(--text-tertiary);font-weight:normal">(${respByteCount} bytes)</span>`
|
||||
}
|
||||
html += `\n`
|
||||
// 美化 JSON(空串单独提示,避免误导为"empty"字面量)
|
||||
if (!respBody) {
|
||||
html += `<span style="color:var(--text-tertiary);font-style:italic">${escHtml(t('assistant.testRespBodyEmptyDetail'))}</span>`
|
||||
@@ -2988,6 +3013,10 @@ function buildTestResult({ success, elapsed, usedApi, reqUrl, reqBody, respStatu
|
||||
html += escHtml(respBody.slice(0, 4000))
|
||||
}
|
||||
}
|
||||
// Raw Bytes (hex):UTF-8 解码失败时最关键的诊断信息
|
||||
if (respRawHex) {
|
||||
html += `\n\n<strong>Raw Bytes (前 200 字节 hex):</strong>\n<span style="color:var(--text-tertiary);font-size:10px">${escHtml(respRawHex)}</span>`
|
||||
}
|
||||
html += `</div></details>`
|
||||
return html
|
||||
}
|
||||
@@ -3906,7 +3935,10 @@ function showSettings() {
|
||||
reqUrl: r.reqUrl || baseUrl,
|
||||
reqBody: r.reqBody || {},
|
||||
respStatus: r.status ?? 0,
|
||||
respHeaders: r.respHeaders || null,
|
||||
respBody: r.respBody || '',
|
||||
respRawHex: r.respRawHex || '',
|
||||
respByteCount: r.respByteCount || 0,
|
||||
reply: r.reply || '',
|
||||
error: r.error || null,
|
||||
})
|
||||
@@ -3919,7 +3951,10 @@ function showSettings() {
|
||||
reqUrl: baseUrl,
|
||||
reqBody: {},
|
||||
respStatus: 0,
|
||||
respHeaders: null,
|
||||
respBody: '',
|
||||
respRawHex: '',
|
||||
respByteCount: 0,
|
||||
error: err?.message || String(err),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user