Skip to content

Commit 4ad20bd

Browse files
wehosclaude
andauthored
fix(voice_clone): 解析非 JSON 响应不再抛 "Unexpected token '<'" (Project-N-E-K-O#771)
* fix(voice_clone): 解析非 JSON 响应不再抛 "Unexpected token '<'" 注册音色页在后端/反向代理返回 HTML(404、502、504 等)时,前端直接调 res.json() 会抛 "Unexpected token '<', '<html>...' is not valid JSON", 用户完全无法判断真正发生了什么。新增 safeReadResponse / buildNonJsonError 两个小工具,先看 Content-Type 再决定按 JSON 还是文本读响应,并把状态码 和正文片段拼成可读错误(404 单独提示让用户重启服务端),同步替换 registerVoice、voice_id 自动保存、playPreview、loadVoices、deleteVoice 五处旧的 res.json() 直读路径。新增 voice.serverNonJsonError / voice.serverRouteNotFound 文案到 6 种语言。 * fix(voice_clone): safeReadResponse 识别 +json 结构化后缀媒体类型 原实现只认 application/json,会把 application/problem+json (RFC 7807)、 application/vnd.api+json 等合法 JSON 响应当成非 JSON,从而丢掉 data.error / data.detail / data.code 的结构化报错路径。补充一条 /\+json(\s*;|\s*$)/ 正则,让上游/网关返回标准结构化 JSON 错误时依然能被 registerVoice、playPreform、loadVoices、deleteVoice 正确提取诊断信息。 * fix(voice_clone): 未知 error code 不再泄漏 i18n key;voice_id 保存失败带上诊断信息 CodeRabbit 在 Project-N-E-K-O#771 上提了两条相关反馈: 1. registerVoice / playPreview 里原本 `(data.code && window.t) ? t('errors.'+code) : ...` 的写法,在 code 不存在于 locale 时会触发 i18next 的「缺失 key 回退成 key 本身」 行为,用户会看到 "errors.MINIMAX_API_KEY_MISSING" 这样的字面 key。实测 voice_clone 路径上 9 个可能 code 里只有 1 个有翻译——问题非常真实。 新增 resolveBackendErrorMsg 工具:只有翻译确实存在(返回值 !== key 本身)才使用 i18n,否则回退到 data.detail / message / error / "API returned {status}"。 不需要维护易腐的白名单。 2. voice_id 自动保存流程的 catch 原本只显示静态的 voiceIdSaveRequestError,把 safeReadResponse/buildNonJsonError 在 PUT 里构造的 HTTP 状态/正文摘要全吞掉。 现在把 e.message 拼进最终提示,让诊断信息能一路透传到 UI。 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6624fb5 commit 4ad20bd

7 files changed

Lines changed: 118 additions & 15 deletions

File tree

static/js/voice_clone.js

Lines changed: 106 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,59 @@ function openApiSettings() {
1212
}
1313
}
1414

15+
// 安全地解析 fetch 响应:当后端/反向代理返回 HTML(404/502/504/网关错误等)时
16+
// 不应抛出 "Unexpected token '<', '<html>...' is not valid JSON",而应返回带状态码的可读错误。
17+
async function safeReadResponse(res) {
18+
const contentType = (res.headers.get('content-type') || '').toLowerCase();
19+
// 识别 application/json 以及 RFC 6839 的结构化后缀(如 application/problem+json,
20+
// application/vnd.api+json 等),它们都是合法 JSON。
21+
const isJsonContentType = contentType.includes('application/json') || /\+json(\s*;|\s*$)/.test(contentType);
22+
if (isJsonContentType) {
23+
try {
24+
return { data: await res.json(), nonJson: false, text: '' };
25+
} catch (_) {
26+
// Content-Type 声明 JSON 但解析失败,落到文本分支
27+
}
28+
}
29+
let text = '';
30+
try { text = await res.text(); } catch (_) { text = ''; }
31+
return { data: null, nonJson: true, text };
32+
}
33+
34+
function buildNonJsonError(res, text) {
35+
// 去除 HTML 标签并截断,避免把整段 HTML 报告给用户
36+
const snippet = text
37+
? text.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120)
38+
: '';
39+
if (window.t) {
40+
if (res.status === 404) {
41+
return window.t('voice.serverRouteNotFound', { status: res.status });
42+
}
43+
return window.t('voice.serverNonJsonError', {
44+
status: res.status,
45+
snippet: snippet || res.statusText || ''
46+
});
47+
}
48+
if (res.status === 404) {
49+
return `接口未找到 (HTTP 404),请确认服务端已正确部署并重启`;
50+
}
51+
return `服务端返回了非JSON响应 (HTTP ${res.status})${snippet ? ': ' + snippet : ''}`;
52+
}
53+
54+
// 把后端错误响应体转成可读消息:
55+
// 只有在 errors.<code> 翻译确实存在时才使用 i18n,否则回退到响应自带文案,
56+
// 避免 i18next 的「缺失 key 回退成 key 本身」行为把 "errors.XXX_UNKNOWN" 直接丢给用户。
57+
function resolveBackendErrorMsg(data, status) {
58+
if (data && data.code && window.t) {
59+
const i18nKey = 'errors.' + data.code;
60+
const translated = window.t(i18nKey, data.details || {});
61+
if (translated && translated !== i18nKey) {
62+
return translated;
63+
}
64+
}
65+
return (data && (data.detail || data.message || data.error)) || `API returned ${status}`;
66+
}
67+
1568
function parseVoiceRegisterError(errorObj) {
1669
const errorCode = errorObj?.code;
1770
const errorMsg = errorObj?.message || errorObj?.error || errorObj || '';
@@ -436,11 +489,18 @@ function registerVoice() {
436489

437490
fetch(apiUrl, requestOptions)
438491
.then(async res => {
439-
const data = await res.json();
492+
const { data, nonJson, text } = await safeReadResponse(res);
440493
if (!res.ok) {
441-
// 从响应体中提取详细错误信息
442-
const errorMsg = (data.code && window.t) ? window.t('errors.' + data.code, data.details || {}) : (data.error || data.detail || `API returned ${res.status}`);
443-
throw new Error(errorMsg);
494+
if (data) {
495+
// 从响应体中提取详细错误信息(优先已翻译的 errors.<code>,缺失则回退到 message/detail/error)
496+
throw new Error(resolveBackendErrorMsg(data, res.status));
497+
}
498+
// 后端/网关返回了 HTML(如 404/502/504),构造可读错误而不是 "Unexpected token '<'"
499+
throw new Error(buildNonJsonError(res, text));
500+
}
501+
if (nonJson) {
502+
// 状态码 2xx 但响应体不是 JSON——不应发生,但仍优雅处理
503+
throw new Error(buildNonJsonError(res, text));
444504
}
445505
return data;
446506
})
@@ -494,11 +554,18 @@ function registerVoice() {
494554
method: 'PUT',
495555
headers: { 'Content-Type': 'application/json' },
496556
body: JSON.stringify({ voice_id: data.voice_id })
497-
}).then(resp => {
557+
}).then(async resp => {
558+
const { data: respData, nonJson, text } = await safeReadResponse(resp);
498559
if (!resp.ok) {
499-
throw new Error(`API returned ${resp.status}`);
560+
if (respData && (respData.error || respData.detail)) {
561+
throw new Error(respData.error || respData.detail);
562+
}
563+
throw new Error(buildNonJsonError(resp, text));
500564
}
501-
return resp.json();
565+
if (nonJson) {
566+
throw new Error(buildNonJsonError(resp, text));
567+
}
568+
return respData;
502569
}).then(res => {
503570
if (!res.success) {
504571
const errorMsg = res.error || (window.t ? window.t('common.unknownError') : '未知错误');
@@ -534,9 +601,13 @@ function registerVoice() {
534601
}
535602
}
536603
}).catch(e => {
604+
// e 可能携带 safeReadResponse/buildNonJsonError 构造的可读错误
605+
// (含 HTTP 状态和正文摘要),必须拼进最终提示,否则诊断信息被吞。
606+
const saveErrorMsg = e?.message || e?.toString() || (window.t ? window.t('common.unknownError') : '未知错误');
607+
const base = window.t ? window.t('voice.voiceIdSaveRequestError') : 'voice_id自动保存请求出错';
537608
const errorSpan = document.createElement('span');
538609
errorSpan.className = 'error';
539-
errorSpan.textContent = (window.t ? window.t('voice.voiceIdSaveRequestError') : 'voice_id自动保存请求出错');
610+
errorSpan.textContent = saveErrorMsg ? `${base}: ${saveErrorMsg}` : base;
540611
resultDiv.appendChild(document.createElement('br'));
541612
resultDiv.appendChild(errorSpan);
542613
});
@@ -590,10 +661,16 @@ async function playPreview(voiceId, btn) {
590661
if (!audioSrc) {
591662
// 如果本地没有缓存,则从服务器获取
592663
const response = await fetch(`/api/characters/voice_preview?voice_id=${encodeURIComponent(voiceId)}`);
593-
if (response.status === 404) {
594-
throw new Error('API route not found (404). Please ensure the server has been restarted.');
664+
const { data, nonJson, text } = await safeReadResponse(response);
665+
if (!response.ok) {
666+
if (data && (data.error || data.detail)) {
667+
throw new Error(data.error || data.detail);
668+
}
669+
throw new Error(buildNonJsonError(response, text));
670+
}
671+
if (nonJson) {
672+
throw new Error(buildNonJsonError(response, text));
595673
}
596-
const data = await response.json();
597674

598675
if (data.success && data.audio) {
599676
audioSrc = `data:${data.mime_type || 'audio/mpeg'};base64,${data.audio}`;
@@ -605,7 +682,7 @@ async function playPreview(voiceId, btn) {
605682
// localStorage 可能满了,但我们仍然可以播放这一次生成的音频
606683
}
607684
} else {
608-
const _errMsg = (data.code && window.t) ? window.t('errors.' + data.code, data.details || {}) : (data.error || 'Failed to get preview');
685+
const _errMsg = resolveBackendErrorMsg(data, response.status) || 'Failed to get preview';
609686
throw new Error(_errMsg);
610687
}
611688
}
@@ -652,10 +729,16 @@ async function loadVoices() {
652729

653730
try {
654731
const response = await fetch('/api/characters/voices');
732+
const { data, nonJson, text } = await safeReadResponse(response);
655733
if (!response.ok) {
656-
throw new Error(`API returned ${response.status}`);
734+
if (data && (data.error || data.detail)) {
735+
throw new Error(data.error || data.detail);
736+
}
737+
throw new Error(buildNonJsonError(response, text));
738+
}
739+
if (nonJson) {
740+
throw new Error(buildNonJsonError(response, text));
657741
}
658-
const data = await response.json();
659742

660743
if ((!data.voices || Object.keys(data.voices).length === 0) &&
661744
(!data.free_voices || Object.keys(data.free_voices).length === 0)) {
@@ -860,7 +943,15 @@ async function deleteVoice(voiceId, voiceName) {
860943
headers: { 'Content-Type': 'application/json' }
861944
});
862945

863-
const data = await response.json();
946+
const { data: parsed, nonJson, text } = await safeReadResponse(response);
947+
if (!response.ok && !parsed) {
948+
// 后端/网关返回了 HTML(如 404/502),抛出可读错误
949+
throw new Error(buildNonJsonError(response, text));
950+
}
951+
if (nonJson) {
952+
throw new Error(buildNonJsonError(response, text));
953+
}
954+
const data = parsed || {};
864955

865956
if (response.ok && data.success) {
866957
// 删除本地缓存的预览音频

static/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,8 @@
12741274
"prefixShouldBeEnglishLetterAndNumber": "Prefix should be English letters and numbers",
12751275
"invalidApiKeyProvided": "Invalid API-key provided",
12761276
"requestError": "Request error: {{error}}",
1277+
"serverNonJsonError": "Server returned a non-JSON response (HTTP {{status}}): {{snippet}}",
1278+
"serverRouteNotFound": "API route not found (HTTP {{status}}). Please ensure the server is deployed and restarted.",
12771279
"voiceIdSaveFailed": "Failed to automatically save voice_id: {{error}}",
12781280
"voiceIdSaved": "voice_id has been automatically saved to character",
12791281
"pageWillRefresh": "Current page will automatically refresh to apply new voice",

static/locales/ja.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,8 @@
12741274
"prefixShouldBeEnglishLetterAndNumber": "接頭辞は英文字と数字である必要があります",
12751275
"invalidApiKeyProvided": "無効なAPIキーが提供されました",
12761276
"requestError": "リクエストエラー:{{error}}",
1277+
"serverNonJsonError": "サーバーが非JSONレスポンスを返しました (HTTP {{status}}):{{snippet}}",
1278+
"serverRouteNotFound": "APIエンドポイントが見つかりません (HTTP {{status}})。サーバーが正しくデプロイされ再起動されていることを確認してください。",
12771279
"voiceIdSaveFailed": "voice_idの自動保存に失敗しました: {{error}}",
12781280
"voiceIdSaved": "voice_idをキャラクターに自動保存しました",
12791281
"pageWillRefresh": "新しいボイスを適用するためにページが自動更新されます",

static/locales/ko.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,8 @@
12741274
"prefixShouldBeEnglishLetterAndNumber": "접두사는 영어 문자와 숫자여야 합니다",
12751275
"invalidApiKeyProvided": "제공된 API 키가 유효하지 않습니다",
12761276
"requestError": "요청 오류: {{error}}",
1277+
"serverNonJsonError": "서버가 JSON이 아닌 응답을 반환했습니다 (HTTP {{status}}): {{snippet}}",
1278+
"serverRouteNotFound": "API 경로를 찾을 수 없습니다 (HTTP {{status}}). 서버가 올바르게 배포되고 재시작되었는지 확인하세요.",
12771279
"voiceIdSaveFailed": "voice_id를 자동으로 저장하지 못했습니다: {{error}}",
12781280
"voiceIdSaved": "voice_id가 자동으로 캐릭터에 저장되었습니다.",
12791281
"pageWillRefresh": "새 음성을 적용하기 위해 현재 페이지가 자동으로 새로 고쳐집니다.",

static/locales/ru.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,8 @@
12741274
"prefixShouldBeEnglishLetterAndNumber": "Префикс должен состоять из английских букв и цифр",
12751275
"invalidApiKeyProvided": "Указан неверный API-ключ",
12761276
"requestError": "Ошибка запроса: {{error}}",
1277+
"serverNonJsonError": "Сервер вернул не-JSON ответ (HTTP {{status}}): {{snippet}}",
1278+
"serverRouteNotFound": "Маршрут API не найден (HTTP {{status}}). Убедитесь, что сервер развёрнут и перезапущен.",
12771279
"voiceIdSaveFailed": "Не удалось автоматически сохранить voice_id: {{error}}",
12781280
"voiceIdSaved": "voice_id автоматически сохранён для персонажа",
12791281
"pageWillRefresh": "Текущая страница обновится для применения нового голоса",

static/locales/zh-CN.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,8 @@
12741274
"prefixShouldBeEnglishLetterAndNumber": "前缀应为英文字母和数字",
12751275
"invalidApiKeyProvided": "提供的API密钥无效",
12761276
"requestError": "请求出错:{{error}}",
1277+
"serverNonJsonError": "服务端返回了非JSON响应 (HTTP {{status}}):{{snippet}}",
1278+
"serverRouteNotFound": "接口未找到 (HTTP {{status}}),请确认服务端已正确部署并重启",
12771279
"voiceIdSaveFailed": "voice_id自动保存失败: {{error}}",
12781280
"voiceIdSaved": "voice_id已自动保存到角色",
12791281
"pageWillRefresh": "当前页面即将自动刷新以应用新语音",

static/locales/zh-TW.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1274,6 +1274,8 @@
12741274
"prefixShouldBeEnglishLetterAndNumber": "前綴應為英文字母和數字",
12751275
"invalidApiKeyProvided": "提供的API密鑰無效",
12761276
"requestError": "請求齣錯:{{error}}",
1277+
"serverNonJsonError": "服務端返迴瞭非JSON響應 (HTTP {{status}}):{{snippet}}",
1278+
"serverRouteNotFound": "接口未找到 (HTTP {{status}}),請確認服務端已正確部署並重啓",
12771279
"voiceIdSaveFailed": "voice_id自動保存失敗: {{error}}",
12781280
"voiceIdSaved": "voice_id已自動保存到角色",
12791281
"pageWillRefresh": "當前頁麵即將自動刷新以應用新語音",

0 commit comments

Comments
 (0)