diff --git a/console-ui/package.json b/console-ui/package.json
index 720adf3..68dce54 100644
--- a/console-ui/package.json
+++ b/console-ui/package.json
@@ -2,12 +2,11 @@
"name": "arcrun-console-ui",
"version": "0.1.0",
"private": true,
- "description": "Arcrun Console / Portal 靜態前端(Cloudflare Pages)——從 cypher-executor 搬出的 UI 層",
+ "description": "Arcrun Console / Portal 靜態前端——public/ 是唯一世代真身(t160:舊 src/+build 已 git rm,直接託管)",
"scripts": {
- "build": "node scripts/build.mjs",
"deploy": "node scripts/deploy.mjs",
"deploy:personal": "node scripts/deploy.mjs personal",
"deploy:enterprise": "node scripts/deploy.mjs enterprise",
- "preview": "npm run build && npx serve public"
+ "preview": "npx serve public"
}
-}
+}
\ No newline at end of file
diff --git a/console-ui/public/console/index.html b/console-ui/public/console/index.html
index ef360fc..4b97e4a 100644
--- a/console-ui/public/console/index.html
+++ b/console-ui/public/console/index.html
@@ -412,16 +412,14 @@
- 這裡是「庫」的登記簿——條目歸哪個庫由資料導入(ingest)時蓋章決定;未蓋章的舊資料一律視同
general 。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。
+
+ 你的知識庫網址(小幫手連線用)
+
+ 複製
-
@@ -399,10 +461,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 } : {}; }
@@ -548,14 +632,31 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
// ── 認證流 ──
function showAuth() {
+ $('v-firstsetup').classList.remove('on');
$('v-login').classList.add('on');
$('shell').classList.remove('on');
$('tabbar').classList.remove('on');
+ // t49:全新實例(還沒有任何帳號)→ 換顯示「首次設定」,不讓用戶對著登入殼發呆。
+ // 探測走 console/auth-status(configured 布林,console 首設同一顆;失敗就保持登入殼=誠實降級)。
+ fetch(API_BASE + '/console/auth-status')
+ .then(function (r) { return safeJson(r); })
+ .then(function (d) {
+ if (d && d.configured === false) {
+ $('v-login').classList.remove('on');
+ $('v-firstsetup').classList.add('on');
+ }
+ })
+ .catch(function () { /* 探測不到就維持登入殼 */ });
}
function showApp() {
$('v-login').classList.remove('on');
+ $('v-firstsetup').classList.remove('on');
$('shell').classList.add('on');
$('tabbar').classList.add('on');
+ // t53(leo 07-25:「進站要做的事——給金鑰、下載 daemon——不然這個安裝沒完成」):
+ // 常駐「完成安裝」清單卡,三件做完才消失(localStorage 記進度;admin 才看得到——
+ // 金鑰與 daemon 設定是裝機者的事,同事帳號不顯示)。
+ try { renderSetupChecklist(); } catch (e) { /* 清單卡失敗不擋主流程 */ }
var p = S.profile || {};
$('side-foot').innerHTML = esc(p.display_name || '') + '
' + esc(location.host);
$('nav-workflows').classList.toggle('hide', !p.workflows_visible);
@@ -582,7 +683,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;
@@ -607,7 +708,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; }
@@ -618,11 +719,308 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
})
.catch(function (e) { $('login-submit').disabled = false; $('login-status').textContent = friendlyErr(e); });
}
+ // t72 OS 分流(2026-07-27,leo:「客戶是用 windows 的」)——在此之前四處寫死「下載 Mac 版」,
+ // Windows 客戶按下去會拿到 Mac 的 .app=按了連到錯的東西(leo 判準:那不算友善)。
+ // 判不出 OS 時**兩個都給**,不替用戶猜;且無論判成哪個,頁面都留「不是這個系統?」的另一版連結
+ // ——UA 會判錯,判錯時用戶要有路可走(施工圖 §3 注意事項 1、3)。
+ var DAEMON_BASE_DEFAULT = 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/';
+ var DAEMON_MAC = 'ArcrunRAG-mac-unsigned.zip';
+ var DAEMON_WIN = 'ArcrunRAG-win-unsigned.zip';
+ // Mac 那顆 21MB > jsDelivr 單檔 20MB 上限(實測回 "File size exceeded...")→ 一律走 raw;
+ // Windows 13MB 雖在限內,同走 raw 保持單一來源、少一個會壞的地方。
+ function daemonBase() {
+ var cfg = (window.ARCRUN_CONFIG || {});
+ if (cfg.daemonBase) return String(cfg.daemonBase).replace(/\/?$/, '/');
+ // 舊 key 相容(施工圖 §3 注意事項 2):daemonDownload 是「單一檔案網址」,
+ // 有設就尊重它當 Mac 版網址,並由它推出目錄給 Windows 版用。
+ if (cfg.daemonDownload) {
+ var s = String(cfg.daemonDownload);
+ return s.slice(0, s.lastIndexOf('/') + 1) || DAEMON_BASE_DEFAULT;
+ }
+ return DAEMON_BASE_DEFAULT;
+ }
+ function daemonPick() {
+ var base = daemonBase();
+ var ua = navigator.userAgent || '';
+ // 手機/平板先排除:iOS 的 UA 含 "Mac OS X",不排除會把 iPhone 判成 Mac,
+ // 讓手機用戶下載一個裝不起來的桌面 app(測到才發現,2026-07-27)。
+ // 手機=判不出桌面 OS → 落到「兩個都給」,用戶回桌機再選。
+ var isMobile = /iPhone|iPad|iPod|Android|Mobile/i.test(ua);
+ var isWin = !isMobile && /Windows NT/i.test(ua);
+ var isMac = !isMobile && /Macintosh|Mac OS X/i.test(ua) && !/Windows/i.test(ua);
+ var mac = { os: 'mac', label: '下載 Mac 版', url: base + DAEMON_MAC };
+ var win = { os: 'win', label: '下載 Windows 版', url: base + DAEMON_WIN };
+ if (isWin) return { pick: win, other: mac, sure: true };
+ if (isMac) return { pick: mac, other: win, sure: true };
+ return { pick: null, other: null, sure: false, mac: mac, win: win };
+ }
+ // 封測期第一次開啟的擋關提示(未簽章)——Mac/Windows 攔法不同,話術也不同。
+ function daemonHint(os) {
+ if (os === 'win') return '(封測版未簽章,Windows 第一次會跳藍色視窗擋下來——點「更多資訊」→「仍要執行」就好)';
+ return '(封測版未簽章,第一次請右鍵→打開)';
+ }
+ // 07-27:設定頁的常駐入口(下載小幫手/換 AI 金鑰)——與一次性卡片同一組 API
+ (function () {
+ var dl = $('st-daemon-dl');
+ if (dl) {
+ var d = daemonPick();
+ if (d.sure) {
+ dl.setAttribute('href', d.pick.url);
+ dl.textContent = d.pick.label;
+ // 判對了也要留另一版的路(UA 會判錯)
+ var alt = document.createElement('span');
+ alt.className = 'muted';
+ alt.style.cssText = 'font-size:12.5px;margin-left:8px';
+ alt.innerHTML = '不是這個系統?
' + d.other.label + ' ';
+ if (dl.parentNode) dl.parentNode.insertBefore(alt, dl.nextSibling);
+ } else {
+ // 判不出來=兩個都給,不預設 Mac
+ dl.setAttribute('href', d.mac.url);
+ dl.textContent = d.mac.label;
+ var both = document.createElement('span');
+ both.className = 'muted';
+ both.style.cssText = 'font-size:12.5px;margin-left:8px';
+ both.innerHTML = '或
' + d.win.label + ' ';
+ if (dl.parentNode) dl.parentNode.insertBefore(both, dl.nextSibling);
+ }
+ }
+ })();
+
+ // t131 AI 設定(合併 Gemini API Key+Claude 加強版)
+ (function () {
+ // 進入設定頁時讀取現有設定(GET /portal/admin/ai)
+ function loadAiConfig() {
+ if (!(S.profile && S.profile.role === 'admin')) return;
+ fetch(API_BASE + '/portal/admin/ai', { headers: authHeaders() })
+ .then(function (r) { return r.ok ? safeJson(r) : null; })
+ .then(function (d) {
+ if (!d) return;
+ var ki = $('st-ai-key');
+ if (ki && d.has_key) ki.placeholder = '已設定(留空=不變更)';
+ var cb = $('st-ai-use-claude');
+ if (cb) {
+ // 有 claude 才能勾;沒有則停用並顯示提示
+ var hint = $('st-ai-claude-hint');
+ if (d.claude_available) {
+ cb.disabled = false;
+ cb.checked = !!d.use_claude_for_extract;
+ if (hint) hint.style.display = 'none';
+ } else {
+ cb.disabled = true;
+ cb.checked = false;
+ if (hint) {
+ hint.style.display = '';
+ // 區分:從未連上小幫手 vs 有連上但沒裝 Claude
+ hint.textContent = d.has_key
+ ? '你的電腦沒有偵測到 Claude Code;裝好並讓小幫手重連一次後,這個選項就會開啟。'
+ : '連上小幫手後才知道你的電腦有沒有 Claude Code。';
+ }
+ }
+ }
+ })
+ .catch(function () { /* 讀不到不擋頁面 */ });
+ }
+ window._loadAiConfig = loadAiConfig;
+
+ // 儲存
+ var sb = $('st-ai-save');
+ if (sb) sb.addEventListener('click', function () {
+ var m = $('st-ai-status');
+ var k = (($('st-ai-key') && $('st-ai-key').value) || '').trim();
+ var cb = $('st-ai-use-claude');
+ var useClaud = cb && !cb.disabled ? cb.checked : undefined;
+ var body = {};
+ if (k) body.gemini_api_key = k;
+ if (useClaud !== undefined) body.use_claude_for_extract = useClaud;
+ sb.disabled = true; m.textContent = '儲存中…'; m.style.color = '';
+ fetch(API_BASE + '/portal/admin/ai', {
+ method: 'POST',
+ headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
+ body: JSON.stringify(body)
+ }).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); })
+ .then(function (x) {
+ sb.disabled = false;
+ if (guard401(x.status)) return;
+ if (!x.ok) { m.textContent = (x.d && x.d.error) || '儲存失敗'; m.style.color = '#b4462f'; return; }
+ if ($('st-ai-key')) { $('st-ai-key').value = ''; $('st-ai-key').placeholder = '已設定(留空=不變更)'; }
+ var claudeOn = x.d && x.d.use_claude_for_extract;
+ m.textContent = claudeOn
+ ? '已儲存,萃取改用 Claude Code。小幫手請重連一次生效。'
+ : '已儲存,AI 問答與萃取皆可使用。';
+ m.style.color = '#3f7a4f';
+ })
+ .catch(function (e) { sb.disabled = false; m.textContent = friendlyErr(e); m.style.color = '#b4462f'; });
+ });
+ })();
+
$('st-logout').addEventListener('click', function () {
fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ });
dropSession();
});
+ // t87 07-28 leo:知識庫網址 helper(只有 origin,不含 /portal/# 後綴),兩處 UI 共用
+ function copyOriginUrl(btn) {
+ var url = location.origin;
+ var orig = btn.textContent;
+ if (!navigator.clipboard || !navigator.clipboard.writeText) {
+ alert('請手動選取並複製:' + url);
+ return;
+ }
+ navigator.clipboard.writeText(url).then(function () {
+ btn.textContent = '已複製';
+ setTimeout(function () { btn.textContent = orig; }, 1500);
+ }).catch(function () {
+ alert('請手動選取並複製:' + url);
+ });
+ }
+ (function () {
+ ['st-origin-url', 'ad-origin-url'].forEach(function (id) {
+ var el = $(id); if (el) el.textContent = location.origin;
+ });
+ ['st-copy-url', 'ad-copy-url'].forEach(function (id) {
+ var el = $(id); if (el) el.addEventListener('click', function () { copyOriginUrl(this); });
+ });
+ })();
+
+ // ── t53 完成安裝清單(進站必見,三件做完才消失)─────────────────────────────
+ function setupSteps() {
+ try { return JSON.parse(localStorage.getItem('arcrun_setup_steps') || '{}'); } catch (e) { return {}; }
+ }
+ function markStep(k) {
+ var s = setupSteps(); s[k] = 1;
+ try { localStorage.setItem('arcrun_setup_steps', JSON.stringify(s)); } catch (e) { /* noop */ }
+ renderSetupChecklist();
+ }
+ function renderSetupChecklist() {
+ var old = document.getElementById('setup-checklist');
+ if (old) old.remove();
+ var p = S.profile || {};
+ if (p.role !== 'admin') return;
+ var s = setupSteps();
+ if (s.daemon && s.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案
+ var cfg = window.ARCRUN_CONFIG || {};
+ var dpick = daemonPick(); // t72 OS 分流(同一組判定,見上面 daemonPick)
+ var daemonUrl = dpick.sure ? dpick.pick.url : dpick.mac.url;
+ var row = function (done, id, html) {
+ return '
'
+ + '
' + (done ? '✅' : '⬜') + ' ' + html + '
';
+ };
+ var el = document.createElement('div');
+ el.id = 'setup-checklist';
+ el.style.cssText = 'position:fixed;right:20px;bottom:20px;z-index:60;max-width:400px;width:calc(100% - 40px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(8px);font-size:14px;line-height:1.65';
+ el.innerHTML = '
還差 ' + (2 - (s.daemon?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了 '
+ + row(s.daemon, 'daemon',
+ '
下載同步小幫手 (把資料夾變成知識庫)
'
+ + (daemonUrl
+ // t72:按鈕字樣+擋關話術都跟著 OS 走;並且一律附「另一個系統」的連結
+ //(UA 判錯時用戶要有路走;判不出來時=兩個平等並列,不預設 Mac)
+ ? '
'
+ + esc(dpick.sure ? dpick.pick.label : dpick.mac.label) + ' '
+ + '
'
+ + esc(daemonHint(dpick.sure ? dpick.pick.os : 'mac')) + ' '
+ + '
'
+ // 誠實降級(leo 判準:按了連到錯的機制=不友善):沒有可用下載點時
+ // 不放死連結,說明現況並讓這步可手動打勾,不卡住清單。
+ : '
封測期由我們直接寄給你安裝檔;'
+ + '收到後回來按 我已經裝好了 。 ')
+ // t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案——
+ // 小幫手第一次開啟會問網址+帳密,自己去換設定。
+ + '
裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了,不用下載設定檔。
')
+ + row(s.key, 'key',
+ '
啟用 AI 問答 (
aistudio.google.com 免費申請)
'
+ + '
'
+ + '
啟用 '
+ + '
')
+ + '
稍後再說
';
+ document.body.appendChild(el);
+ var dl = document.getElementById('sc-daemon');
+ if (dl) dl.addEventListener('click', function () { markStep('daemon'); });
+ // t54:config.json 下載鈕已移除(設定由小幫手憑帳密自取)
+ var kb = document.getElementById('sc-key-save');
+ if (kb) kb.addEventListener('click', function () {
+ var k = (document.getElementById('sc-key').value || '').trim();
+ var m = document.getElementById('sc-key-msg');
+ if (!k) { m.textContent = '請先貼上金鑰'; return; }
+ kb.disabled = true; m.textContent = '啟用中…';
+ fetch(API_BASE + '/portal/admin/ai', {
+ method: 'POST',
+ headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()),
+ body: JSON.stringify({ gemini_api_key: k })
+ }).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 && x.d.error) || '啟用失敗,請再試一次'; return; }
+ markStep('key');
+ })
+ .catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; });
+ });
+ var later = document.getElementById('sc-later');
+ if (later) later.addEventListener('click', function () { el.remove(); }); // 只藏本次,下次進站再提醒
+ }
+
+ // ── t49 首次設定(leo 07-25):console/setup → portal/admin/bootstrap → 預設庫登記 → portal/login
+ // 四發全是既有端點,一顆按鈕做完;用戶感受=「建一組帳密就進來了」。
+ $('fs-submit').addEventListener('click', doFirstSetup);
+ $('fs-password2').addEventListener('keydown', function (ev) { if (ev.key === 'Enter') doFirstSetup(); });
+ $('fs-tologin').addEventListener('click', function () {
+ $('v-firstsetup').classList.remove('on');
+ $('v-login').classList.add('on');
+ });
+ function doFirstSetup() {
+ var email = $('fs-email').value.trim();
+ var pw = $('fs-password').value;
+ var st = $('fs-status');
+ if (!email || pw.length < 8) { st.textContent = '請填 Email,密碼至少 8 碼'; return; }
+ if (pw !== $('fs-password2').value) { st.textContent = '兩次密碼不一樣'; return; }
+ $('fs-submit').disabled = true; st.textContent = '';
+ var post = function (path, body, hdrs) {
+ return fetch(API_BASE + path, {
+ method: 'POST',
+ headers: Object.assign({ 'Content-Type': 'application/json' }, hdrs || {}),
+ body: JSON.stringify(body)
+ }).then(function (r) { return r.json().catch(function () { return {}; }).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); });
+ };
+ post('/console/setup', { email: email, password: pw })
+ .then(function (x) {
+ // 已設定過(409)=這實例其實有人建過了——自動帶去登入,別把用戶困在死路(leo 實撞)
+ if (x.status === 409) {
+ $('fs-submit').disabled = false;
+ $('v-firstsetup').classList.remove('on');
+ $('v-login').classList.add('on');
+ $('login-email').value = email;
+ $('login-status').textContent = '這個知識庫已經設定過帳號了,直接登入即可。';
+ throw { handled: true };
+ }
+ if (!x.ok || !x.d.session_token) throw new Error(x.d.error || '設定沒有成功,請再試一次');
+ var ownerTok = x.d.session_token;
+ return post('/portal/admin/bootstrap', { email: email, password: pw, display_name: email.split('@')[0] }, { Authorization: 'Bearer ' + ownerTok })
+ .then(function (b) {
+ // 409=已 bootstrap 過(冪等視為就緒);其餘失敗誠實丟出
+ if (!b.ok && b.status !== 409) throw new Error(b.d.error || '初始化沒有成功,請再試一次');
+ return post('/portal/login', { email: email, password: pw });
+ });
+ })
+ .then(function (l) {
+ if (!l.ok || !l.d.session_token) throw new Error(l.d.error || '帳號建好了,但自動登入沒成功——請用剛設定的帳密登入');
+ S.token = l.d.session_token;
+ try { localStorage.setItem('arcrun_portal_session', S.token); } catch (e) { /* noop */ }
+ })
+ .then(function () {
+ $('fs-submit').disabled = false;
+ try { localStorage.setItem('arcrun_onboarding', '1'); } catch (e) { /* noop */ }
+ boot();
+ })
+ .catch(function (e) {
+ if (e && e.handled) return; // 409 已自動切登入,不再顯示錯誤
+ $('fs-submit').disabled = false;
+ st.textContent = (e && e.message) || friendlyErr(e);
+ });
+ }
+
// 任何 data 請求收到 401 → session 失效 → 回登入殼
function guard401(status) {
if (status === 401) { dropSession(); return true; }
@@ -650,7 +1048,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; }
@@ -694,7 +1092,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;
@@ -743,7 +1141,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; }
@@ -806,7 +1204,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; }
@@ -848,7 +1246,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; }
@@ -954,7 +1352,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; }
@@ -1018,7 +1416,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; }
@@ -1053,7 +1451,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 }; });
});
}
@@ -1128,22 +1526,37 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
}
function renderAdminLibs() {
- if (!adminLibs.length) { $('ad-libs').innerHTML = '
還沒有登記任何庫。未登記時整個系統視同單一 general 庫。
'; return; }
+ if (!adminLibs.length) { $('ad-libs').innerHTML = '
同步小幫手還沒送來任何庫。裝好小幫手並選好資料夾後,庫會自動出現在這裡。
'; return; }
$('ad-libs').innerHTML = adminLibs.map(function (l) {
var disabled = l.status === 'disabled';
- return '
' +
+ var notWatching = l.daemon_watching === false; // daemon 有報告但此庫不在其中
+ var removeBtn = l.auto
+ ? '
移除 '
+ : '
移除 ';
+ // t142:卡數+三元組數顯示(政府驗收用)。兩者皆 0 顯示「還沒有內容」,不顯示「0 張」。
+ var cardCount = typeof l.card_count === 'number' ? l.card_count : undefined;
+ var tripletCount = typeof l.triplet_count === 'number' ? l.triplet_count : undefined;
+ var statsHtml = '';
+ if (cardCount !== undefined || tripletCount !== undefined) {
+ var parts = [];
+ if (cardCount > 0) parts.push(cardCount + ' 張知識卡');
+ if (tripletCount > 0) parts.push(tripletCount + ' 條關聯');
+ statsHtml = '
' +
+ (parts.length ? parts.join('・') : '還沒有內容') + '
';
+ }
+ return '
' +
'
' +
'' + esc(l.display_name || l.name) + ' ' +
'' + esc(l.name) + ' ' +
- '' + (disabled ? '已停用' : '啟用中') + ' ' +
- (l.graph_source ? '圖譜來源 ' : '') + '
' +
- (l.description ? '
' + esc(l.description) + '
' : '') +
- '
' +
- '' + (l.graph_source ? '取消圖譜來源' : '標為圖譜來源') + ' ' +
- (disabled
- ? '啟用 '
- : '停用 ') +
- '
';
+ (l.auto
+ ? '
啟用中 同步自動出現 '
+ : '
' + (disabled ? '已停用' : '啟用中') + ' ') +
+ removeBtn +
+ '
' +
+ statsHtml +
+ (!l.auto && l.description ? '
' + esc(l.description) + '
' : '') +
+ (notWatching ? '
小幫手目前沒有在同步這個資料夾
' : '') +
+ '
';
}).join('');
}
@@ -1181,28 +1594,6 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.catch(function (e) { $('ad-nu-create').disabled = false; st.textContent = friendlyErr(e); });
});
- // 登記新庫
- $('ad-nl-create').addEventListener('click', function () {
- var name = $('ad-nl-name').value.trim();
- var st = $('ad-nl-status');
- if (!name) { st.textContent = '請填庫名(英數 _ -)'; return; }
- st.textContent = '';
- $('ad-nl-create').disabled = true;
- adminApi('POST', '/portal/admin/libraries', {
- name: name,
- display_name: $('ad-nl-display').value.trim(),
- description: $('ad-nl-desc').value.trim()
- })
- .then(function (x) {
- $('ad-nl-create').disabled = false;
- if (guard401(x.status)) return;
- if (!x.ok) { st.textContent = x.d.error || ('登記失敗(HTTP ' + x.status + ')'); return; }
- $('ad-nl-name').value = ''; $('ad-nl-display').value = ''; $('ad-nl-desc').value = '';
- toast('庫已登記');
- loadAdmin();
- })
- .catch(function (e) { $('ad-nl-create').disabled = false; st.textContent = friendlyErr(e); });
- });
// 「全部知識庫」勾選 → 其餘庫勾選框連動停用(存的就是 ["*"],個別勾選無意義)
document.addEventListener('change', function (ev) {
@@ -1280,24 +1671,39 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
}
- if (act === 'lib-status' || act === 'lib-graph') {
- var lid = t.getAttribute('data-lid');
- var body2 = {};
- if (act === 'lib-status') {
- var nextS = t.getAttribute('data-next');
- if (nextS === 'disabled' && !confirm('確定停用庫「' + (t.getAttribute('data-name') || '') + '」?停用後不再出現在庫勾選清單(既有授權不會被自動改)。')) return;
- body2.status = nextS;
- } else {
- body2.graph_source = t.getAttribute('data-next') === 'true';
- }
+ // 移除已登記庫(有 record_id)
+ if (act === 'lib-remove') {
+ var libId = t.getAttribute('data-id') || '';
+ var libName = t.getAttribute('data-name') || libId;
+ if (!confirm('把「' + libName + '」從目錄移除?\n\n資料不會刪除,重新同步時會再出現。')) return;
t.disabled = true;
- adminApi('PATCH', '/portal/admin/libraries/' + encodeURIComponent(lid), body2)
+ adminApi('DELETE', '/portal/admin/libraries/' + encodeURIComponent(libId))
.then(function (x) {
t.disabled = false;
if (guard401(x.status)) return;
- if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + ')')); return; }
- toast('庫已更新');
- loadAdmin();
+ if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ')')); return; }
+ adminLibs = adminLibs.filter(function (l) { return l.record_id !== libId; });
+ renderAdminLibs();
+ toast('已從目錄移除');
+ })
+ .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
+ return;
+ }
+ // 移除 auto 庫(無 record_id,entries 標 deprecated)
+ if (act === 'lib-remove-auto') {
+ var autoName = t.getAttribute('data-name') || '';
+ var input = window.prompt('移除「' + autoName + '」會讓它的內容不再被搜尋到(資料保留可還原)。\n\n請輸入庫名確認:');
+ if (input === null) return; // 取消
+ if (input.trim() !== autoName) { toast('庫名輸入不符,取消移除'); return; }
+ t.disabled = true;
+ adminApi('DELETE', '/portal/admin/libraries/by-name/' + encodeURIComponent(autoName), { confirm: autoName })
+ .then(function (x) {
+ t.disabled = false;
+ if (guard401(x.status)) return;
+ if (!x.ok) { toast(x.d.error || ('移除失敗(HTTP ' + x.status + ')')); return; }
+ adminLibs = adminLibs.filter(function (l) { return l.name !== autoName; });
+ renderAdminLibs();
+ toast('已移除(' + (x.d.deprecated_count || 0) + ' 筆標為不可搜)');
})
.catch(function (e) { t.disabled = false; toast(friendlyErr(e)); });
return;
@@ -1312,6 +1718,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return {
function loadSettings() {
var p = S.profile;
if (!p) { $('st-me').innerHTML = '