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);