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 @@
-
-
-
語意搜尋(vectorize)
-
狀態偵測中…
-
-
-
-
- 誠實提示:開關真相=部署端 ~/.arcrun/config.yamlkbdb_embed: trueacr update 重部署(#32 教訓:wrangler 直推改的形態 config 要同步)。Console 只如實顯示狀態,不假裝能遠端開啟。目前狀態:偵測中… -
+ +
語意搜尋
+
狀態偵測中…
+
MCP token 有效期(TTL)
@@ -1451,16 +1449,26 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { fetch(API_BASE + '/kbdb/search?q=mira&mode=semantic', { headers: apiHeaders() }) .then(function (r) { return r.json(); }) .then(function (d) { + // t36:狀態照實顯示(live 探測 mode,不是讀設定值)。啟用時不再顯示任何操作指示—— + // 沒有東西要用戶操作;未啟用才給一句人話與下一步。 var on = d.mode === 'semantic'; - $('st-vec-switch').classList.toggle('on', on); $('st-vec').textContent = on - ? '已啟用・語意搜尋可用(搜尋頁切「語意」)' - : '未啟用・' + (d.capability_hint || '部署端尚未開啟 Vectorize(kbdb_embed)'); - $('st-vec-state').textContent = on ? '已啟用(live 探測 mode=semantic)' : '未啟用(live 探測降級 keyword)'; + ? '● 已啟用——搜尋頁切到「語意」就能用意思找資料。' + : '○ 尚未啟用——目前用關鍵字搜尋,不會假裝有語意結果。'; + var hint = $('st-vec-hint'); + if (on) { + hint.style.display = 'none'; + } else { + hint.style.display = ''; + hint.innerHTML = '一鍵安裝的實例會在安裝時自動開通語意索引。' + + '如果你這個實例是較早裝的、或安裝當下開通沒成功,重新跑一次安裝流程即可補上(已建好的資料不會重來)。'; + } }) .catch(function () { - $('st-vec').textContent = '狀態偵測失敗(KBDB 不可達)'; - $('st-vec-state').textContent = '偵測失敗'; + $('st-vec').textContent = '狀態偵測失敗(知識庫服務目前連不上)'; + var hint = $('st-vec-hint'); + hint.style.display = ''; + hint.textContent = '這通常是暫時的,稍後重新整理這一頁再看。'; }); // MCP token TTL(誠實佔位:只顯示目前生效值,不假裝能改) fetch(API_BASE + '/console/settings-data') @@ -1510,9 +1518,9 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { }) .catch(function (e) { st.innerHTML = '請求失敗:' + esc(friendlyErr(e)) + ''; }); }); - $('st-vec-switch').addEventListener('click', function () { - toast('此開關不能遠端改——部署端 config.yaml 開 kbdb_embed 後 acr update 重部署'); - }); + // t36:原本這裡綁在那顆假開關上(點了只會 toast 一段 CLI 指示)。開關已移除, + // 這個 handler 也必須一起拿掉——留著會讓 $('st-vec-switch') 回 null、addEventListener + // 當場拋錯,把後面所有綁定(含登出)一起打斷。 $('st-logout').addEventListener('click', function () { var t = S.token; if (t) fetch(API_BASE + '/console/logout', { method: 'POST', headers: { Authorization: 'Bearer ' + t } }).catch(function () {}); diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index c207785..6fefeac 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -186,6 +186,27 @@
+ +
+
+
+
Arcrun
+
歡迎!先建立你的帳號
+
+
+ + + + +
+ + +
+
這組帳密就是之後登入知識庫用的,只需要設定這一次。
+
+
+
@@ -310,6 +331,50 @@
+ +
+ +
+ 你的知識庫網址(小幫手連線用) + + +
+
同步小幫手
+
把你電腦上的資料夾變成知識庫。換電腦或重裝時可以再下載一次。
+
+ 下載 Mac 版 +
封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。
+
+
+ +
+
AI 設定
+
+ +
+
Gemini API Key
+
聊天問答與文件萃取都用這一把。免費申請,金鑰只存在你自己的知識庫裡。
+ +
+ +
+
讓知識卡整理得更好(選填)
+ +
+ 讀你文件、整理成知識卡的那個 AI,換成更強的模型。卡片會更抓得到重點、關聯也連得更準,之後搜尋和問答的品質跟著提升。需要你的電腦已安裝 Claude Code。不填就用上面那把 Gemini,一樣能用。 +
+ +
+
+ +
+
+
+
@@ -334,18 +399,15 @@
載入中…
庫目錄管理
-
- 這裡是「庫」的登記簿——條目歸哪個庫由資料導入(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')) + '' + + '
' + + (dpick.sure + ? '不是這個系統?' + esc(dpick.other.label) + '' + : '或 ' + esc(dpick.win.label) + '') + + '
' + // 誠實降級(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) + '
' : '') + - '
' + - '' + - (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 = '
載入中…
'; return; } + if (window._loadAiConfig) window._loadAiConfig(); var libs = p.libraries || []; var libHtml = libs.indexOf('*') >= 0 ? '全部知識庫' @@ -1337,7 +1744,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/os-split.test.mjs b/console-ui/public/portal/os-split.test.mjs new file mode 100644 index 0000000..3075676 --- /dev/null +++ b/console-ui/public/portal/os-split.test.mjs @@ -0,0 +1,49 @@ +import fs from 'node:fs'; +const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8'); +// 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾) +const start = html.indexOf('var DAEMON_BASE_DEFAULT'); +const endMark = "return '(封測版未簽章,第一次請右鍵→打開)';\n }"; +const end = html.indexOf(endMark) + endMark.length; +if (start < 0 || end < start) throw new Error('抽不到函式區塊'); +const src = html.slice(start, end); + +const cases = [ + ['Windows', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120 Safari/537.36'], + ['Mac', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Safari/605.1.15'], + ['iPhone', 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Safari/604.1'], + ['Linux', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120 Safari/537.36'], +]; +let pass=0, fail=0; +const chk=(l,c,extra='')=>{ if(c){console.log('PASS:',l);pass++;} else {console.log('FAIL:',l,extra);fail++;} }; + +for (const [name, ua] of cases) { + const fn = new Function('navigator','window', src + '; return {daemonPick:daemonPick, daemonHint:daemonHint, daemonBase:daemonBase};'); + const api = fn({userAgent: ua}, {}); + const d = api.daemonPick(); + const label = d.sure ? d.pick.label : '(兩個都給)'; + const url = d.sure ? d.pick.url : d.mac.url + ' + ' + d.win.url; + console.log(`\n[${name}] sure=${d.sure} → ${label}`); + console.log(` url: ${url}`); + if (name==='Windows') { + chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url); + chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('mac-unsigned.zip')); + chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行')); + } + if (name==='Mac') { + chk('Mac 給 mac zip', d.sure && d.pick.url.endsWith('ArcrunRAG-mac-unsigned.zip')); + chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip')); + chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵')); + } + if (name==='iPhone' || name==='Linux') { + // iPhone 含 "Mac OS X" 但不是桌機 Mac;Linux 兩者皆非 → 都該落在「不確定=兩個都給」 + if (name==='Linux') chk('Linux 判不出來→兩個都給', d.sure===false); + if (name==='iPhone') chk('iPhone 不該被判成 Mac(手機→兩個都給)', d.sure===false, 'sure='+d.sure); + } +} +// 舊 key 相容 +const fn2 = new Function('navigator','window', src + '; return daemonBase();'); +console.log('\n[相容] daemonDownload 舊 key →', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}})); +chk('舊 key 推得出目錄', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonDownload:'https://x.dev/d/ArcrunRAG-mac-unsigned.zip'}})==='https://x.dev/d/'); +chk('daemonBase 新 key 優先', fn2({userAgent:''},{ARCRUN_CONFIG:{daemonBase:'https://y.dev/z'}})==='https://y.dev/z/'); +console.log(`\n=== ${pass} passed, ${fail} failed ===`); +process.exit(fail?1:0); 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); diff --git a/console-ui/scripts/build.mjs b/console-ui/scripts/build.mjs deleted file mode 100644 index 0587a61..0000000 --- a/console-ui/scripts/build.mjs +++ /dev/null @@ -1,236 +0,0 @@ -/** - * console-ui build — 把 cypher-executor 的三支 UI renderer 在「建置時」跑一次, - * 產出純靜態 HTML 到 public/,交給 Cloudflare Pages 託管。 - * - * 為什麼這樣做(cypher-ui-split 第一刀): - * 原本 console/portal/dashboard 的 HTML 由 cypher-executor Worker 在「每次請求時」 - * 用 template literal 組出來 → 5,240 行 UI 字串永遠躺在 Worker bundle 裡(748KB), - * 連 /health 這種什麼都不做的請求都要付 5-7ms CPU(免費層上限 10ms)。 - * UI 是靜態的(單檔 HTML+原生 JS、零外部資源),本來就該待在 Pages。 - * - * 保持原特性(leo 反覆強調簡化): - * - 零打包工具、零 npm 依賴:本檔只用 node 內建 fs/path,正則抽出 renderer 的 - * template literal 後求值。不引入 esbuild/vite/rollup。 - * - 產出仍是「單檔 HTML+原生 JS hash routing、零外部資源」。 - * - * 唯一的行為差異=API base: - * 原本 UI 與 API 同源,fetch 全用相對路徑('/kbdb/search')。搬上 Pages 後跨網域, - * 故注入 window.ARCRUN_API_BASE,並把 fetch 的相對路徑改成 API_BASE + path。 - * 見下方 rewriteFetchPaths()。 - */ -import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const ROOT = join(HERE, '..'); -const SRC = join(ROOT, '..', 'cypher-executor', 'src'); -const OUT = join(ROOT, 'public'); - -// ── 建置期組態(原本是 Worker 的 env var,現在是建置參數)──────────────── -// Pages 是靜態站,沒有 per-request env;品牌/profile 這類「一個部署一個值」的 -// 設定改在建置時決定(要換值=重跑 build 再部署,符合靜態站模型)。 -// 具名部署目標(deploy.targets.json):一個目標=帳號+profile+apiBase 綁在一起。 -// 帶 DEPLOY_TARGET=personal|enterprise 就套用該組值;個別環境變數仍可覆蓋(除錯用)。 -// 立此檔的原因見 deploy.targets.json 的 _readme——散在部署指令裡的參數帶漏過三次。 -const TARGET_NAME = process.env.DEPLOY_TARGET || ''; -let TARGET = {}; -if (TARGET_NAME) { - const targets = JSON.parse(readFileSync(join(ROOT, 'deploy.targets.json'), 'utf8')); - TARGET = targets[TARGET_NAME]; - if (!TARGET) { - const names = Object.keys(targets).filter((k) => !k.startsWith('_')); - throw new Error(`未知的 DEPLOY_TARGET:"${TARGET_NAME}"。可用:${names.join(' / ')}`); - } - console.log(`部署目標:${TARGET_NAME} — ${TARGET.description}`); -} - -const CFG = { - brand: process.env.CONSOLE_BRAND || TARGET.brand || 'Arcrun', - profile: process.env.CONSOLE_PROFILE || TARGET.profile || 'full', - registryBase: process.env.REGISTRY_BASE || 'https://registry.arcrun.dev', - sourceWebBase: process.env.PORTAL_SOURCE_WEB_BASE || '', - // API base 走 runtime 注入(見 public/config.js),這裡只放預設值 - apiBase: process.env.ARCRUN_API_BASE || TARGET.apiBase || '', -}; - -/** - * 讀 TS 原始碼並取出整個 renderer 函式的**函式主體**(不只 template literal)。 - * - * 取整個 body 而非只取反引號區塊,是因為 renderer 在 return 之前會先算區域變數 - * (如 console.ts 的 rag/views/home 由 profile 推導)。只搬模板=把那段推導邏輯 - * 複製一份到本檔=雙份真相會漂移。連 body 一起求值 → 推導邏輯永遠只有一份。 - */ -/** - * renderer 原始檔的位置:本專案 `console-ui/src/` 優先,找不到才回退 cypher-executor。 - * - * 為什麼要這層(2026-07-22 修):`5a16484` 把 UI 搬出 cypher-executor 時, - * **刪了 console.ts / portal-ui.ts 卻只搬走 build 產物(HTML),原始檔沒跟著搬** - * → build.mjs 讀不到來源,`npm run build` 從那天起就 ENOENT 死掉, - * 線上 HTML 是刪檔前烤好的、之後再也無法重建(profile 改了也不會生效)。 - * 現已從 git 撈回放進 console-ui/src/——UI 原始碼跟著 UI 專案走,才是那一刀的原意。 - * console-dashboard.ts 仍在 cypher-executor(它同時含 API),故保留回退路徑。 - */ -function resolveSource(file) { - const local = join(ROOT, 'src', file.replace(/^routes\//, '')); - if (existsSync(local)) return local; - return join(SRC, file); -} - -function extractRendererBody(file, fnName) { - const code = readFileSync(resolveSource(file), 'utf8'); - const start = code.indexOf(`function ${fnName}(`); - if (start < 0) throw new Error(`找不到 ${fnName} in ${file}`); - const braceStart = code.indexOf('{', code.indexOf(')', start)); - if (braceStart < 0) throw new Error(`${fnName} 找不到函式主體`); - // 掃到配對的收尾大括號;需略過字串/template literal/註解裡的括號 - let i = braceStart + 1; - let depth = 1; - let mode = null; // null | "'" | '"' | '`' | 'line' | 'block' - let tplDepth = 0; - while (i < code.length && depth > 0) { - const ch = code[i]; - const nx = code[i + 1]; - if (mode === null) { - if (ch === '\\') { i += 2; continue; } - if (ch === '/' && nx === '/') { mode = 'line'; i += 2; continue; } - if (ch === '/' && nx === '*') { mode = 'block'; i += 2; continue; } - if (ch === "'" || ch === '"') { mode = ch; i++; continue; } - if (ch === '`') { mode = '`'; tplDepth = 0; i++; continue; } - if (ch === '{') depth++; - else if (ch === '}') depth--; - i++; - continue; - } - if (mode === 'line') { if (ch === '\n') mode = null; i++; continue; } - if (mode === 'block') { if (ch === '*' && nx === '/') { mode = null; i += 2; continue; } i++; continue; } - if (ch === '\\') { i += 2; continue; } - if (mode === '`') { - // template literal 內的 ${ … } 是真程式碼,其中的引號/括號要照常計數才不會誤判收尾 - if (ch === '$' && nx === '{') { tplDepth++; i += 2; continue; } - if (ch === '}' && tplDepth > 0) { tplDepth--; i++; continue; } - if (ch === '`' && tplDepth === 0) { mode = null; i++; continue; } - i++; - continue; - } - if (ch === mode) mode = null; - i++; - } - // 去掉 TS 的型別註記(本 body 只有 `const x: T =` 這種簡單形態) - return code.slice(braceStart + 1, i - 1).replace(/\bconst\s+(\w+):\s*[\w<>[\]|]+\s*=/g, 'const $1 ='); -} - -/** 取出 lib/taipei-time.ts 匯出的 TAIPEI_CLIENT_JS 字串常數(UI 內嵌的客戶端時間工具)。 */ -function extractTaipeiClientJs() { - const code = readFileSync(join(SRC, 'lib', 'taipei-time.ts'), 'utf8'); - // 形態=字串陣列 .join('\n')(見 lib/taipei-time.ts),直接求值該陣列表達式 - const m = code.match(/export const TAIPEI_CLIENT_JS\s*=\s*(\[[\s\S]*?\]\.join\('\\n'\));/); - if (!m) throw new Error('找不到 TAIPEI_CLIENT_JS'); - return new Function(`return ${m[1]};`)(); -} - -/** - * 求值 renderer 函式主體。用 new Function 而非 eval——只餵建置期組態, - * 輸入是本 repo 自己的原始碼(非使用者輸入),無注入面。 - */ -function render(body, vars) { - const names = Object.keys(vars); - const fn = new Function(...names, body); - return fn(...names.map((n) => vars[n])); -} - -/** - * 把 UI 內原生 JS 的相對路徑 fetch 改成打 API base。 - * - * 只改 `fetch('/...` 與 `fetch("/...`(開頭是單斜線=同源絕對路徑)這一種形態, - * 其餘(fetch(url, …) 這類變數形式)另由各檔的 url 組法在下面單獨處理。 - */ -function rewriteFetchPaths(html, file) { - // ① fetch('/xxx → fetch(API_BASE + '/xxx - let out = html.replace(/fetch\((['"])\/(?!\/)/g, 'fetch(API_BASE + $1/'); - // ② 變數式 fetch(url, ...):url 由上方 var url = '/kbdb/search?...' 組成 → - // 把這類「以單斜線開頭的路徑字面值指派」也補上 API_BASE - out = out.replace(/(\bvar\s+url\s*=\s*)(['"])\/(?!\/)/g, '$1API_BASE + $2/'); - // ③ portal 的 adminApi(method, path, body):path 由呼叫端傳字面值進來,①② - // 都掃不到(8 個呼叫點)。在 helper 內部補前綴=一處修好全部,不必改 8 個呼叫點。 - out = out.replace( - /(function adminApi\(method, path, body\) \{)/, - '$1\n path = API_BASE + path;' - ); - - // 防呆:搬完後不該再有「直接 fetch 同源相對路徑」的殘留。掃到就讓建置失敗, - // 免得漏網的呼叫點在 Pages 上打到 Pages 自己(404)才被發現。 - // 註:adminApi 的呼叫端仍是相對路徑字面值——那是對的,前綴由 helper 內部(③)加。 - const unprefixed = [...out.matchAll(/fetch\((['"])\/(?!\/)[^'"]*/g)].map((m) => m[0]); - if (unprefixed.length) { - throw new Error( - `${file}:有 ${unprefixed.length} 個相對路徑 fetch 沒被改寫成 API_BASE:\n ` + - [...new Set(unprefixed)].join('\n ') - ); - } - // adminApi 形態存在時,必須確認 helper 已被加上前綴(否則 8 個呼叫點全會打錯家) - if (/function adminApi\(method, path, body\)/.test(out) && !/path = API_BASE \+ path;/.test(out)) { - throw new Error(`${file}:偵測到 adminApi helper 但前綴注入失敗`); - } - return out; -} - -/** 在頁面 注入 config.js(runtime 決定 API base),並定義 API_BASE 供內嵌 JS 用。 */ -function injectApiBase(html) { - const snippet = ` -`; - const withCfg = html.replace('', `${snippet}\n`); - // 內嵌的 IIFE 裡宣告 API_BASE(各頁的主 - - - -
-
-
${brand} 駕駛艙
-
-
- -
-
-
-
-
-
載入中
-
-
-
-
-
-
今日完成
-
/
-
-
-
-
收件匣未處理
-
-
來自 Telegram
-
-
-
-
等你的事
-
載入中…
-
-
-
今日路線
- - - -
系統狀況live 健康信號
-
載入中…
-
每 60 秒自動刷新
- 進入完整控制台 › -
- - - -`; -} - -// GET /console/dashboard — 駕駛艙頁(無需登入;純渲染 dashboard-data,無互動、無說明文字) -// 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG") -// CONSOLE_PROFILE=rag(console-profile-trim):駕駛艙不屬企業版頁面 → 302 回 /console。 -// 選 302 不選 404:舊書籤/外鏈直接落回產品頁,不給死路(只裁 UI 頁面,資料端點行為不動)。 -consoleDashboardRouter.get('/console/dashboard', (c) => { - if ((c.env.CONSOLE_PROFILE || 'full') === 'rag') return c.redirect('/console', 302); - return c.html(renderDashboardHtml(c.env.CONSOLE_BRAND || 'Arcrun')); -}); diff --git a/console-ui/src/console.ts b/console-ui/src/console.ts deleted file mode 100644 index bf12c5c..0000000 --- a/console-ui/src/console.ts +++ /dev/null @@ -1,1623 +0,0 @@ -/** - * Mira Console 完整版(Arcrun#3 console 系,2026-07-04 總管派工) - * - * 視覺:claude design 定稿「紙感暖黑 2a」(system-dev/docs/6-user/Mira Console 設計規劃/ - * Mira Console.dc.html + Mira Style Guide.dc.html)——紙紋底 repeating-linear-gradient、 - * 明體標題(Songti TC 級聯)、琥珀 var(--amber) 強調、墨綠呼吸球嵌「安」字、正文 ≥16px、 - * 手機優先 390px + ≥1024px 側欄、頁面本體不出水平捲軸。 - * - * 資訊架構:brief 8 頁(docs/1-vision/mira-console-design-brief.md)= - * 1 登入/首次設定 2 駕駛艙 3 總庫搜尋 4 卡片詳頁+關聯 5 工作流 6 憑證管理 - * 7 分流台(原收件匣,Arcrun#9 改裝) 8 設定。 - * 單檔 HTML + 原生 JS hash routing(#/cockpit …),零外部資源、零 build step。 - * - * 薄殼(rule 07):零業務邏輯,只 fetch 既有 API 渲染。各頁資料源(誠實原則,無源的顯示 - * 誠實空狀態,禁假資料): - * - 駕駛艙:GET /console/dashboard-data(免登入聚合端點,console-dashboard.ts) - * - 總庫搜尋:GET /kbdb/search(mode=semantic 未開 Vectorize 會誠實降級 + capability_hint; - * chips 用 entry_type——KBDB 真能篩的欄位,不編造「電腦筆記/手機筆記」等不存在的分類); - * 頭部統計來自 GET /console/kb-scale-data(精耕層 wiki 卡/三元組/已嵌入——leo 2026-07-07 - * 裁:45.8 萬 14-E 搬遷遺產 deprecated 不再當招牌數字;搜尋本身仍搜全庫); - * 搜尋框上方=藏書地圖(library-map SDD M5/R4,Arcrun#39):GET /kbdb/map 全館庫卡片 - * (narrative+top entities+triplet 數+更新時間感),逐庫 GET /kbdb/map/:library 補 - * degree/跨庫 bridges/commit_hash(詳圖拉不到就維持名字版,不擋渲染);點庫卡片= - * 設 library filter(/kbdb/search 既有 library 參數)進該庫搜尋;/map 空陣列或錯誤= - * 誠實空狀態(指引 POST /map/recompute backfill),不擋搜尋等其他功能 - * - 卡片詳頁:GET /kbdb/entries/:id;關聯視圖走 GET /kbdb/graph/neighbors/:name - * (cypher 代轉 kbdb-graph-plugin,kbdb-proxy.ts;查無 triplet → 「尚無關聯資料」) - * - 工作流:GET /webhooks/named(list)+ POST /webhooks/named/:name/trigger(手動觸發) - * + GET /workflows/:name/executions(最近執行);釘選存 localStorage - * - 憑證:GET /credentials/catalog(D1 目錄唯讀,絕不含密文)+ POST /credentials(新增) - * + PUT /credentials/:name(整筆替換);刪除後端未接 D1(T9 範圍)→ 標「即將開通」 - * - 分流台:GET /console/triage-data(需 session——待辦/訊息原文屬機敏,計數才免登入)。 - * 資料源二合一(kb-ingest SDD R7 / Arcrun#9):entry_type=todo(Logseq 萃取)+ - * entry_type=inbox(Telegram);三欄 80/15/5(ai/collab/leo)+ per-project chips - * +已完成預設隱藏可切換。分欄判定在 lib/console-triage-model.ts(純函式),頁面只渲染。 - * 勾掉/還原走 POST /console/triage-check(同 session 保護;leo 2026-07-08 拍板)—— - * 雙向銷案語意:console 勾掉=終局(checked_via:console,萃取端絕不復活)、Logseq 改 - * DONE 由萃取端 PATCH(checked_via:logseq);console 只忠實顯示非 done 項。 - * - 設定:vectorize 狀態探測(讀 /kbdb/search 回應的 mode/capability_hint,無新端點; - * 開關本體需部署端 config kbdb_embed + acr update → 誠實文案講清楚、不假裝能遠端開) - * + MCP token TTL 誠實佔位(/console/settings-data;可調功能=Arcrun#19) - * + /console/setup/reset 換帳密 + 系統資訊(精耕層規模) - * - * 2026-07-07 fix/console-truth-audit(總管稽核派工 + leo 裁決): - * 1. 全站日期/時間顯示統一 Asia/Taipei(lib/taipei-time.ts,與駕駛艙 #36 台北日判定同套; - * 原本用瀏覽器本地 getter,換裝置就換日期;憑證頁加顯時分——7/6 22:57 建立不再被誤讀)。 - * 2. 總庫搜尋/設定頁頭部統計改精耕層(458,732 是真數字——cypher 與 base 一手同值實證—— - * 但 leo 裁 14-E 遺產 deprecated 不再顯示)。 - * 3. 駕駛艙「今日完成/今日路線」接 sprint 任務板真資料(dashboard-data 端,同 #36 模式)。 - * - * 認證模型沿 v1(console-auth.ts):單一管理員 email+password,session token 在 localStorage, - * 實際打 /kbdb/* 用後端回的固定租戶字串(CONSOLE_TENANT)。駕駛艙免登入版在 /console/dashboard。 - * - * v0 的 components/recipes 查詢區(Arcrun#3 原搜尋台)收進「工作流」頁下方折疊區,不砍功能。 - * - * 2026-07-04 leo 實測三回饋(總管派工二輪): - * 1. 深/淺主題:CSS custom properties 兩份色板(:root=淺色紙感宣紙米白+墨字, - * :root[data-theme=dark]=原定稿暖黑不動),預設淺色,選擇存 localStorage - * (key arcrun_console_theme),head 預載腳本防閃色。切換入口=登入/首次設定頁小鈕、 - * 側欄項、設定頁正式開關。金琥珀例外(按鈕漸層/狀態球/toast/gcenter)兩版不變。 - * 2. 登入/首次設定置中:.authwrap.view.on 補 display:flex(原被 .view.on 的 block 蓋掉 → 靠左)。 - * 3. fetch 韌性:friendlyErr() 把裸 Failed to fetch / Load failed 轉「連線中斷」誠實文案; - * 駕駛艙 60 秒定時器常駐,網路恢復自動刷回。 - */ -import { Hono } from 'hono'; -import type { Bindings } from '../types'; -import { TAIPEI_CLIENT_JS } from '../lib/taipei-time'; - -export const consoleRouter = new Hono<{ Bindings: Bindings }>(); - -function renderConsoleHtml(registryBase: string, brand: string, profile: string): string { - // Console profile(config 裁剪,非 auth——#24/#25 多人 Portal 是另一回事): - // full(未設即此)=全套,Mira 實例現行為一字不變。 - // rag=企業產品樣:只留 總庫搜尋/工作流/設定(card 是搜尋的詳頁,跟著留),落地=搜尋頁。 - // 隱藏頁的 view div 仍在 DOM(display:none、路由 guard 導回,永遠點不進)—— - // 因 creds/inbox 有 top-level addEventListener 綁定,抽掉 div 會讓整包 JS 啟動即炸, - // 純渲染層裁剪以「導不進去」為準,不為了抽 DOM 把 null-guard 灑滿全檔。 - const rag = profile === 'rag'; - const views = rag - ? ['search', 'card', 'workflows', 'settings'] - : ['cockpit', 'search', 'card', 'workflows', 'creds', 'inbox', 'settings']; - const home = rag ? 'search' : 'cockpit'; - return ` - - - - -${brand} Console - - - - - - -
-
-
-
${brand}
-
私人系統・僅供擁有者進入
-
-
- - - -
-
-
這是一個人的智慧總部。
若你不是擁有者,這裡沒有你要找的東西。
- ${rag ? '' : '免登入看駕駛艙 ›'} - -
-
- - -
-
-
-
${brand}
-
首次設定
-
系統偵測到尚未設定擁有者帳號。
設定一組 Email 與密碼,之後只有你能進入。
-
-
- - - - -
-
- -
-
- - -
-
- - ${rag ? '' : ''} - - - ${rag ? '' : ` - `} - - -
-
-
- - -
-
${brand} 控制台
-
-
-
-
-
-
載入中
-
-
-
-
-
-
今日完成
-
/
-
-
-
-
收件匣未處理
-
-
來自 Telegram
-
-
-
-
等你的事
-
載入中…
-
-
-
-
-
- 今日路線狀態即時同步 -
-
  • 載入中…
- -
    -
    每 60 秒自動刷新
    -
    -
    -
    - - - - - -
    -
    - - 知識卡片 -
    -
    -
    載入中…
    -
    -
    關聯視圖
    -
    -
    -
    -
    -
    - - -
    -
    工作流
    - -
    載入中…
    -
    -
    零件與 Recipes(框架資源)
    -
    - - -
    -
    -
    -
    - - -
    -
    憑證管理
    -
    - 🛡 保險箱原則:系統只保存目錄與加密後的值,任何頁面都看不到密文內容。只能整筆替換或刪除。 -
    - -
    載入中…
    -
    - - -
    -
    分流台全部待辦・80/15/5
    -
    -
    載入中…
    -
    - - -
    -
    設定
    -
    -
    -
    -
    -
    深色模式
    -
    預設淺色(紙感)。切換立即生效,選擇記在這台裝置(localStorage)。
    -
    -
    -
    -
    -
    -
    -
    -
    語意搜尋(vectorize)
    -
    狀態偵測中…
    -
    -
    -
    -
    - 誠實提示:開關真相=部署端 ~/.arcrun/config.yamlkbdb_embed: trueacr update 重部署(#32 教訓:wrangler 直推改的形態 config 要同步)。Console 只如實顯示狀態,不假裝能遠端開啟。目前狀態:偵測中… -
    -
    -
    -
    MCP token 有效期(TTL)
    -
    讀取中…
    -
    - 誠實佔位:此頁還不能改這個值——可調功能=Arcrun#19(設定頁改→存 KBDB→發 token 時讀)。實作前要調整請在部署端 env MCP_TOKEN_TTL(mcp worker)設定。 -
    -
    -
    -
    更換帳號密碼
    -
    需輸入舊密碼驗證身分
    -
    - - - - -
    -
    -
    - ${rag ? '' : `
    -
    -
    憑證管理
    -
    只保存目錄與加密值,不可回看
    -
    - -
    `} -
    -
    系統資訊
    -
    載入中…
    -
    - -
    -
    - -
    -
    - - -
    - ${rag ? '' : '
    駕駛艙
    '} -
    搜尋
    -
    工作流
    - ${rag ? '' : '
    分流台
    '} -
    設定
    -
    - -
    - - - - -`; -} - -// GET /console — Mira Console 完整版(Arcrun#3 console 系)。registry base 現算:同帳號 -// arcrun-registry worker(WORKER_SUBDOMAIN 沿用既有 KBDB_BASE_URL 那套組法),沒設就退回官方公開 registry。 -consoleRouter.get('/console', (c) => { - const subdomain = c.env.WORKER_SUBDOMAIN; - const registryBase = subdomain - ? `https://arcrun-registry.${subdomain}.workers.dev` - : 'https://registry.arcrun.dev'; - // 品牌字樣(Arcrun#21):引擎預設 Arcrun,實例可用 CONSOLE_BRAND 覆蓋(如 "Arcrun RAG") - // 頁面組成(console-profile-trim):CONSOLE_PROFILE 未設=full 全套;"rag"=企業產品樣(搜尋落地) - return c.html(renderConsoleHtml(registryBase, c.env.CONSOLE_BRAND || 'Arcrun', c.env.CONSOLE_PROFILE || 'full')); -}); diff --git a/console-ui/src/portal-ui.ts b/console-ui/src/portal-ui.ts deleted file mode 100644 index 74f7970..0000000 --- a/console-ui/src/portal-ui.ts +++ /dev/null @@ -1,1390 +0,0 @@ -/** - * RAG Portal 前端殼 — P3:GET /portal 單檔 HTML(portal-auth design §1 D-1/§6,Gitea #24/#25) - * - * 形態(D-1):cypher-executor 的新路由、**獨立 HTML 殼**——不在 console 上加 if(「Admin - * Console 現狀不動」鐵律)。樣式重用 console 的設計語言(紙感/明體標題/琥珀強調/深淺主題), - * 取捨=**複製樣式子集而非抽共用模組**:抽共用要動 console.ts(違「console 不動」),且兩頁 - * 受眾不同(owner vs 同仁)預期各自演化;代價=樣式雙份、改版要兩邊同步(PR 說明誠實記)。 - * - * 頁面(design §6,leo 頁面級拍板): - * - 登入頁(brand=CONSOLE_BRAND;未登入只見這個殼) - * - 搜尋頁(落地頁):keyword/semantic/graph 三模式+結果含 source 溯源+卡片詳頁 - * - 設定頁:改自己密碼、看自己角色與可查庫、主題切換 - * - 工作流頁:D-8 定案 admin 可見(PORTAL_SHOW_WORKFLOWS 可調 all/off);唯讀、不開 trigger - * - graph 模式按 D-4 粗閘顯示(/portal/session 的 graph_allowed) - * - 管理頁(P4,admin-only nav):帳號管理(列表/新增/停用/重設密碼+每帳號勾選可查庫 - * 含 ["*"] 全庫)+庫目錄管理(登記/停用/graph_source 標記)。一次性密碼只在畫面顯示 - * 一次(server 不留明碼);一般用戶 nav 不顯示、直打 /portal/admin/* 也是 403(role 閘)。 - * - Mira 專屬頁(駕駛艙/分流台/憑證/專案)一律不進 - * - * 安全(design §3.3 關鍵差異 vs console): - * - 本頁 JS **沒有任何租戶字串、沒有 X-Arcrun-API-Key**——只持 portal session token - * (localStorage `arcrun_portal_session`),一切資料走 /portal/data/*(server-side enforce)。 - * - 前端的「藏」(graph 模式不顯示、工作流頁不顯示)只是 UX;真閘在 /portal/data/* 路由層 - * (403/404),curl 直打也繞不過(#48 guard 精神)。 - * - 所有動態內容經 esc() 跳脫(防 XSS,同 console 慣例);密碼欄位值不進 log、不進 URL。 - * - * 薄殼(rule 07):零業務邏輯,只 fetch /portal/* 端點渲染。 - */ -import { Hono } from 'hono'; -import type { Bindings } from '../types'; -import { TAIPEI_CLIENT_JS } from '../lib/taipei-time'; - -export const portalUiRouter = new Hono<{ Bindings: Bindings }>(); - -function renderPortalHtml(brand: string, sourceWebBase = ''): string { - return ` - - - - -${brand} Portal - - - - - - -
    -
    -
    -
    ${brand}
    -
    知識入口・Portal
    -
    -
    - - - -
    -
    -
    帳號由管理員發放。忘記密碼請聯絡管理員重設。
    - -
    -
    - - -
    -
    - - - - - - - - -
    -
    -
    - - - - - -
    -
    總圖
    -
    -
    -
    - 點節點可跳到該實體的圖譜搜尋。這張圖由知識庫的三元組即時算出——同一份地圖的文字版 - (給 AI 注入用的總庫目錄)。 -
    -
    -
    - - -
    -
    - - 知識卡片 -
    -
    載入中…
    -
    - - -
    -
    上傳Markdown / 純文字
    -
    - 上傳的文件會進入知識庫收件管線:約 1 分鐘後可在搜尋頁找到;AI 整理後會以 wiki 卡形式出現。支援 .md / .txt(一律以 .md 收件)。 -
    -
    -
    把 .md / .txt 檔案拖放到這裡
    -
    - - -
    -
    -
    - - -
    -
    工作流唯讀・系統狀態
    -
    - 此頁只顯示系統裡的工作流與最近執行狀態,不能觸發執行(觸發屬系統擁有者權限)。 -
    -
    載入中…
    -
    - - -
    -
    設定
    -
    -
    -
    我的帳號
    -
    載入中…
    -
    -
    -
    -
    -
    深色模式
    -
    預設淺色(紙感)。切換立即生效,選擇記在這台裝置。
    -
    -
    -
    -
    -
    -
    更改密碼
    -
    需輸入舊密碼驗證身分;新密碼至少 8 碼
    -
    - - - - -
    -
    -
    - -
    -
    - - -
    -
    管理帳號與知識庫授權
    - -
    帳號管理
    -
    -
    新增同仁帳號
    -
    建立後會產生一組一次性密碼——只顯示這一次,請當場轉交同仁(同仁可在「設定」自行改密)。
    -
    - - - - -
    -
    -
    -
    -
    載入中…
    - -
    庫目錄管理
    -
    - 這裡是「庫」的登記簿——條目歸哪個庫由資料導入(ingest)時蓋章決定;未蓋章的舊資料一律視同 general。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。 -
    -
    -
    登記新庫
    -
    - - - - -
    -
    -
    -
    載入中…
    -
    - -
    -
    - - -
    -
    搜尋
    -
    總圖
    -
    上傳
    -
    工作流
    -
    管理
    -
    設定
    -
    - -
    - - - -`; -} - -// GET /portal — Portal HTML 殼(無 auth:回的是純殼,資料全在 /portal/data/* session 閘後)。 -// 未 bootstrap 任何 portal_user 時登入必失敗(「尚未啟用」的誠實形態),不影響 console。 -portalUiRouter.get('/portal', (c) => { - const brand = c.env.CONSOLE_BRAND || 'Arcrun'; - return c.html(renderPortalHtml(brand, c.env.PORTAL_SOURCE_WEB_BASE || '')); -}); diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index d839da5..364b86c 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -708,43 +708,11 @@ portalRouter.get('/portal/admin/libraries', (c) => }), ); -// POST /portal/admin/libraries — 登記一個庫。body {name, display_name?, description?}。 -portalRouter.post('/portal/admin/libraries', (c) => - run(c, async () => { - const auth = await requirePortalAdmin(c); - if (!auth.ok) return auth.res; - const body = await c.req.json().catch(() => null); - const name = String(body?.name ?? '').trim(); - if (!isValidLibraryName(name) || name === '*') { - return c.json({ error: '庫名限 A-Za-z0-9_-(1-64 字元;"*" 是保留值不可登記)' }, 400); - } - const seeded = await ensurePortalTemplates(c.env); - if (seeded.errors.length > 0) { - return c.json({ error: `portal templates seed 失敗:${seeded.errors.join('; ')}` }, 502); - } - const existing = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); - if (existing.some((l) => (l.values.name ?? '') === name)) { - return c.json({ error: `庫 ${name} 已登記` }, 409); - } - const ns = portalNamespace(c.env); - const res = await kbdbFetch(c.env, '/records', { - method: 'POST', - body: JSON.stringify({ - template: LIBRARY_TEMPLATE, - owner_id: ns, - values: { - name, - display_name: String(body?.display_name ?? '').trim() || name, - description: String(body?.description ?? '').trim(), - status: 'active', - }, - }), - }); - if (!res.ok) throw new KbdbError(`POST /records(portal_library)→ ${res.status}`); - const created = (await res.json()) as { record?: PortalRecord }; - return c.json({ success: true, library: created.record ? toPublicLibrary(created.record) : { name } }); - }), -); +// t160(leo 07-31:「要直通 daemon,同步,**沒有登記這回事**」): +// 人工建庫端點 POST /portal/admin/libraries 已刪——庫只從 daemon 同步自動出現 +// (/portal/daemon/libraries,t159)。現行 UI(e28e190 起)本就零呼叫此端點(死端點); +// 人工登記只會製造對不上的空庫(07-27 leo 拿掉表單時已定調)。 +// GET 列表與 PATCH(管理已存在的庫:改名/停用/graph_source)照舊。 // POST /portal/daemon/libraries — 小幫手(daemon)連線精靈時把看守資料夾的庫報上來自動登記。 // t159(2026-07-31 leo prod 實走揪出):daemon registerLibraries(arcrun-tray main.go:505,t52)