From 11e772496f22765b1393d1b55da875718ce3057b Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 28 Jul 2026 01:02:12 +0800 Subject: [PATCH] =?UTF-8?q?fix(t75=20=E2=91=A0):=20portal=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=8A=8A=20JSON=20=E8=A7=A3=E6=9E=90=E9=8C=AF?= =?UTF-8?q?=E8=AA=A4=E5=99=B4=E7=B5=A6=E4=BD=BF=E7=94=A8=E8=80=85=EF=BC=8B?= =?UTF-8?q?404=20=E8=AA=AA=E4=BA=BA=E8=A9=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 同事實測:存 Gemini 金鑰時畫面出現 「Unexpected non-whitespace character after JSON at position 4」。 根因兩層: ① 前端 15 處無條件 r.json(),但伺服器不一定回 JSON——404 頁/CF 錯誤頁都是 HTML。 JSON.parse 一爆,錯誤沿 .catch 走到 friendlyErr,而 friendlyErr 最後一行是 =把任何例外訊息原樣顯示 ⇒ 技術英文直接噴到畫面。 ② 真正的原因是 /portal/admin/chat-key 回 404(實例的 cypher 是舊版沒這端點), 但使用者完全看不出來,只看到一句看不懂的英文。 修: - 新增 safeJson(r):用 r.text() 再 try/catch parse,解析不了回 {} 不拋錯;15 處改用它 - friendlyErr 收斂:JSON 類錯誤→「伺服器回應異常,請稍後再試」; 純英文技術訊息→「操作失敗」;我們自己寫的中文訊息才原樣顯示 - 兩處金鑰儲存加 404 專屬提示:「你的知識庫版本還沒有這個功能,請先更新知識庫」 ——講清楚為什麼與怎麼辦,否則他只看到「儲存失敗」會反覆重試同一件事 - 順手:安裝卡片那處原本 x.d.error 在 x.d 為 undefined 時會再爆一次,補 x.d && 防護 測試 safejson.test.mjs 8/8:404 HTML 不拋錯/空回應/正常 JSON 仍解析得出/ JSON 錯誤不外洩原文且說人話/網路錯誤訊息保留/中文訊息原樣/英文技術訊息收斂。 ⚠️ 這只解「不噴技術訊息」;金鑰要真的存得進去仍需實例更新 cypher(②層待辦)。 --- console-ui/public/portal/index.html | 62 ++++++++++++++++------ console-ui/public/portal/safejson.test.mjs | 46 ++++++++++++++++ 2 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 console-ui/public/portal/safejson.test.mjs diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index c47c0e1..adf04ad 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -431,10 +431,32 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { clearTimeout(toast._t); toast._t = setTimeout(function () { el.style.display = 'none'; }, 2000); } + // safeJson:把回應當 JSON 解析,**解析不了就回空物件而不是拋例外**(t75 ①,2026-07-28)。 + // + // 為什麼需要:原本各處都直接 r.json(),但伺服器不一定回 JSON——404 頁、Cloudflare 錯誤頁、 + // 反向代理的 HTML 都是純文字。JSON.parse 一爆,錯誤就沿著 .catch 走到 friendlyErr, + // **技術訊息原文被噴到畫面上**。leo 同事實測看到的就是: + // 「Unexpected non-whitespace character after JSON at position 4」 + // 真正的原因其實是 `/portal/admin/chat-key` 回 404(實例的 cypher 是舊版沒這端點), + // 但使用者只看得到一句看不懂的英文 ⇒ 無從判斷是自己打錯還是系統壞了。 + function safeJson(r) { + return r.text().then(function (t) { + if (!t) return {}; + try { return JSON.parse(t); } catch (e) { return {}; } + }); + } + + // friendlyErr:給使用者看的訊息,**不外洩技術細節**(t75 ①)。 + // 原本最後一行是 `return m` =把任何例外訊息原樣顯示,包含 JSON parse 錯誤、 + // stack 片段這種對使用者毫無意義、只會嚇到人的東西。 function friendlyErr(e) { var m = e && e.message ? String(e.message) : String(e); if (/failed to fetch|load failed|networkerror|network request failed/i.test(m)) return '連線中斷——請檢查網路後重試'; - return m; + // JSON 解析類=伺服器回了非預期內容(多半是 404/代理錯誤頁),對使用者說人話。 + if (/json|unexpected token|unexpected non-whitespace/i.test(m)) return '伺服器回應異常,請稍後再試一次(若持續發生請回報)'; + // 其餘:只在含中文(=我們自己寫的訊息)時原樣顯示;純英文技術訊息一律收斂。 + if (/[\u4e00-\u9fff]/.test(m)) return m; + return '操作失敗,請稍後再試一次'; } function authHeaders() { return S.token ? { 'Authorization': 'Bearer ' + S.token } : {}; } @@ -587,7 +609,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { // t49:全新實例(還沒有任何帳號)→ 換顯示「首次設定」,不讓用戶對著登入殼發呆。 // 探測走 console/auth-status(configured 布林,console 首設同一顆;失敗就保持登入殼=誠實降級)。 fetch(API_BASE + '/console/auth-status') - .then(function (r) { return r.json(); }) + .then(function (r) { return safeJson(r); }) .then(function (d) { if (d && d.configured === false) { $('v-login').classList.remove('on'); @@ -631,7 +653,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { function boot() { if (!S.token) { showAuth(); return; } fetch(API_BASE + '/portal/session', { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (!x.ok) { dropSession(); return; } S.profile = x.d; @@ -656,7 +678,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: password }) }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { $('login-submit').disabled = false; if (!x.ok) { $('login-status').textContent = x.d.error || '登入失敗'; return; } @@ -742,10 +764,16 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ key: k }) - }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + }).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { kb.disabled = false; if (guard401(x.status)) return; + // t75 ①:404=這個實例的伺服器版本還沒有這個功能(不是使用者做錯)。 + // 講清楚「為什麼」與「怎麼辦」,否則他只看到「儲存失敗」會反覆重試同一件事。 + if (x.status === 404) { + m.textContent = '你的知識庫版本還沒有這個功能,請先更新知識庫再回來設定金鑰'; + m.style.color = '#b4462f'; return; + } if (!x.ok) { m.textContent = (x.d && x.d.error) || '儲存失敗'; m.style.color = '#b4462f'; return; } $('st-key').value = ''; m.textContent = '已儲存,AI 問答可以用了'; m.style.color = '#3f7a4f'; @@ -827,10 +855,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ key: k }) - }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + }).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { kb.disabled = false; - if (!x.ok || !x.d.success) { m.textContent = x.d.error || '啟用失敗,請再試一次'; return; } + // t75 ①:同設定頁——404=實例版本太舊,不是使用者的錯。 + if (x.status === 404) { m.textContent = '你的知識庫版本還沒有這個功能,請先更新知識庫'; return; } + if (!x.ok || !x.d.success) { m.textContent = (x.d && x.d.error) || '啟用失敗,請再試一次'; return; } markStep('key'); }) .catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; }); @@ -933,7 +963,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { $('se-results').innerHTML = ''; var url = API_BASE + '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword'); fetch(url, { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok) { $('se-count').innerHTML = '' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + ''; return; } @@ -977,7 +1007,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { $('ai-go').disabled = true; $('ai-out').innerHTML = '
AI 查閱知識庫中…
'; fetch(API_BASE + '/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { $('ai-go').disabled = false; if (guard401(x.status)) return; @@ -1026,7 +1056,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { $('se-count').textContent = '查詢關聯中…'; renderGraphEmpty('查詢關聯中…'); fetch(API_BASE + '/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok) { $('se-count').innerHTML = '' + esc(x.d.error || ('關聯查詢失敗(HTTP ' + x.status + ')')) + ''; renderGraphEmpty(x.d.error || '關聯查詢失敗'); return; } @@ -1089,7 +1119,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { if (!S.cardId) { $('cd-main').innerHTML = '
沒有指定卡片——從「搜尋」點一張進來。
'; return; } $('cd-main').innerHTML = '
載入中…
'; fetch(API_BASE + '/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok || !x.d.entry) { $('cd-main').innerHTML = '
' + esc(x.d.error || '讀不到這張卡片') + '
'; return; } @@ -1131,7 +1161,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { ? ':00-MAP.md ↗' : '存在知識庫的 00-MAP.md'; fetch(API_BASE + '/portal/data/graph/overview', { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok) { $('map-box').innerHTML = '
' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')')) + '
'; return; } @@ -1237,7 +1267,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { function loadWorkflows() { $('wf-list').innerHTML = '
載入中…
'; fetch(API_BASE + '/portal/data/workflows', { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok) { $('wf-list').innerHTML = '
' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + ')')) + '
'; return; } @@ -1301,7 +1331,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) }) }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok) { setRowStatus(row, false, '失敗', x.d.error || ('HTTP ' + x.status)); return; } @@ -1336,7 +1366,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { var opt = { method: method, headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()) }; if (body !== undefined) opt.body = JSON.stringify(body); return fetch(path, opt).then(function (r) { - return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); + return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }); } @@ -1627,7 +1657,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ current: oldPw, 'new': newPw }) }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { $('st-pw-save').disabled = false; if (guard401(x.status)) return; diff --git a/console-ui/public/portal/safejson.test.mjs b/console-ui/public/portal/safejson.test.mjs new file mode 100644 index 0000000..4a0908d --- /dev/null +++ b/console-ui/public/portal/safejson.test.mjs @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8'); + +// 抽出 safeJson 與 friendlyErr 求值 +const grab = (name) => { + const i = html.indexOf(`function ${name}(`); + if (i < 0) throw new Error(`找不到 ${name}`); + let d=0, j=html.indexOf('{', i); + for (let k=j;k{c?(console.log('PASS:',l),pass++):(console.log('FAIL:',l,e),fail++)}; + +// ① safeJson:非 JSON 不可拋例外(同事撞到的 404 HTML 頁) +const html404 = '404 Not Found'; +await fn.safeJson({ text: () => Promise.resolve(html404) }) + .then(d => t('404 HTML → 回空物件不拋錯', typeof d === 'object' && d !== null)) + .catch(e => t('404 HTML → 不該拋錯', false, e.message)); + +await fn.safeJson({ text: () => Promise.resolve('') }) + .then(d => t('空回應 → 回空物件', JSON.stringify(d)==='{}')) + .catch(() => t('空回應 → 不該拋錯', false)); + +await fn.safeJson({ text: () => Promise.resolve('{"error":"帳號或密碼不對"}') }) + .then(d => t('正常 JSON 仍要解析得出來', d.error === '帳號或密碼不對'), ) + .catch(() => t('正常 JSON 不該拋錯', false)); + +// ② friendlyErr:不可把技術訊息噴給使用者 +const leak = fn.friendlyErr(new Error('Unexpected non-whitespace character after JSON at position 4')); +t('JSON 錯誤 → 不外洩原文', !/JSON|position/i.test(leak), `實得: ${leak}`); +t('JSON 錯誤 → 說人話', /伺服器回應異常/.test(leak), `實得: ${leak}`); + +const net = fn.friendlyErr(new Error('Failed to fetch')); +t('網路錯誤 → 既有訊息保留', /連線中斷/.test(net), `實得: ${net}`); + +const ours = fn.friendlyErr(new Error('帳號或密碼不對——用你在知識庫網站設定的那組')); +t('我們自己的中文訊息 → 原樣顯示', /帳號或密碼不對/.test(ours), `實得: ${ours}`); + +const stack = fn.friendlyErr(new Error('TypeError: Cannot read properties of undefined')); +t('英文技術訊息 → 收斂不外洩', !/TypeError|undefined/.test(stack), `實得: ${stack}`); + +console.log(`\n=== ${pass} passed, ${fail} failed ===`); +process.exit(fail?1:0);