From beb0653e15b36da9a73f1062b0af6e48c669a1ae Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 08:51:54 +0000 Subject: [PATCH 01/25] =?UTF-8?q?fix(console/t36):=20=E8=AA=9E=E6=84=8F?= =?UTF-8?q?=E6=90=9C=E5=B0=8B=E6=94=B9=E7=8B=80=E6=85=8B=E5=88=97=E2=80=94?= =?UTF-8?q?=E2=80=94=E7=A7=BB=E9=99=A4=E6=8C=89=E4=B8=8D=E5=8B=95=E7=9A=84?= =?UTF-8?q?=E5=81=87=E9=96=8B=E9=97=9C=E8=88=87=20CLI=20=E6=8C=87=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 2026-07-25 定案:語意搜尋預設開啟、不做開關(費用實算後屬雜訊——1 萬張卡規模 下 Vectorize 約 $0.18/月,佔 CF 帳單 3~4%;關掉省幾毛錢卻換來很爛的搜尋體驗)。 設定頁那顆「語意搜尋(vectorize)」開關其實不能按(title 自承「不能遠端改,只如實 顯示狀態」),旁邊還教用戶去部署端改 config.yaml 跑 acr update——對一鍵安裝進來的 用戶那是天書(與 t35 同一種白癡化違規),而且安裝器現在裝機時就把語意索引開好了, 那段指示本身已不成立。 改成單純狀態列:啟用時只說「已啟用,搜尋頁切語意就能用」不給任何操作指示;未啟用 才給一句人話與下一步(重跑安裝流程會補上,已建資料不重來)。 ⚠️ 連帶必修:一併移除綁在該開關上的 click handler——留著會讓 $('st-vec-switch') 回 null、addEventListener 當場拋錯,把後面所有綁定(含登出)一起打斷。 驗:三個 script 區塊語法檢查通過、全檔已無 st-vec-switch/st-vec-state 殘留參照。 --- console-ui/public/console/index.html | 46 ++++++++++++++++------------ 1 file changed, 27 insertions(+), 19 deletions(-) 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 () {}); From 9bbd25fdfca2e9659ceb885f0dbe49f0e04c75f3 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 25 Jul 2026 20:35:33 +0800 Subject: [PATCH 02/25] =?UTF-8?q?feat(portal/t49):=20=E9=A6=96=E7=99=BB?= =?UTF-8?q?=E5=BB=BA=E5=B8=B3=E4=B8=80=E9=A1=86=E6=8C=89=E9=88=95=E2=80=94?= =?UTF-8?q?=E2=80=94auth-status=20=E5=81=B5=E6=B8=AC=E6=9C=AA=E5=88=9D?= =?UTF-8?q?=E5=A7=8B=E5=8C=96=E2=86=92v-firstsetup=EF=BC=88console/setup?= =?UTF-8?q?=E2=86=92portal/admin/bootstrap=E2=86=92=E8=87=AA=E5=8B=95?= =?UTF-8?q?=E7=99=BB=E8=A8=98=E9=A0=90=E8=A8=AD=E5=BA=AB=20kb=E2=86=92port?= =?UTF-8?q?al/login=20=E5=9B=9B=E7=99=BC=E9=80=A3=E9=8E=96=EF=BC=89?= =?UTF-8?q?=EF=BC=8B=E9=80=B2=E7=AB=99=E4=B8=80=E6=AC=A1=E6=80=A7=E6=AD=A1?= =?UTF-8?q?=E8=BF=8E=E5=8D=A1=EF=BC=88=E4=B8=8B=E8=BC=89=E5=B0=8F=E5=B9=AB?= =?UTF-8?q?=E6=89=8B/=E9=87=91=E9=91=B0=20=E2=86=92=20installer=20/setup?= =?UTF-8?q?=EF=BC=89=E3=80=82leo=2007-25=EF=BC=9A=E3=80=8E=E5=8A=A0?= =?UTF-8?q?=E5=85=A5=E7=9A=84=E9=AB=94=E9=A9=97=E8=A6=81=E6=90=9E=E5=AE=9A?= =?UTF-8?q?=E3=80=8F=E2=80=94=E2=80=94=E7=94=A8=E6=88=B6=E6=89=93=E9=96=8B?= =?UTF-8?q?=E5=B0=88=E5=B1=AC=E7=B6=B2=E5=9D=80=E7=AC=AC=E4=B8=80=E7=9C=BC?= =?UTF-8?q?=E5=B0=B1=E6=98=AF=E5=BB=BA=E5=B8=B3=E8=99=9F=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E5=9B=9E=E5=AE=89=E8=A3=9D=E9=A0=81=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console-ui/public/portal/index.html | 106 ++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index c207785..2e7f114 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -186,6 +186,25 @@
+ +
+
+
+
Arcrun
+
歡迎!先建立你的帳號
+
+
+ + + + +
+
+
這組帳密就是之後登入知識庫用的,只需要設定這一次。
+
+
+
@@ -548,14 +567,48 @@ 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 r.json(); }) + .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'); + // t49 一次性歡迎引導(首次設定完成那一刻才出現;關掉就不再出現): + // 下一步只有兩件——裝同步小幫手(資料自動進來)、想問答就設金鑰。連去 installer /setup。 + try { + if (localStorage.getItem('arcrun_onboarding') === '1' && !document.getElementById('onboard-card')) { + var setupUrl = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.installerBase + ? String(window.ARCRUN_CONFIG.installerBase).replace(/\/+$/, '') + : 'https://arcrun-installer.youlin-hsieh-dev.workers.dev') + '/setup'; + var el = document.createElement('div'); + el.id = 'onboard-card'; + el.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:60;max-width:560px;width:calc(100% - 32px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(6px);font-size:14.5px;line-height:1.7'; + el.innerHTML = '🎉 你的知識庫開張了!接下來只有一件事:' + + '下載同步小幫手設定' + + '——把你的資料夾連上來,檔案就會自動變成可搜尋的知識(想用 AI 問答,金鑰也在同一頁設定)。' + + ''; + document.body.appendChild(el); + document.getElementById('onboard-close').addEventListener('click', function () { + try { localStorage.removeItem('arcrun_onboarding'); } catch (e) { /* noop */ } + el.remove(); + }); + } + } catch (e) { /* 引導卡失敗不擋主流程 */ } var p = S.profile || {}; $('side-foot').innerHTML = esc(p.display_name || '') + '
' + esc(location.host); $('nav-workflows').classList.toggle('hide', !p.workflows_visible); @@ -623,6 +676,59 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { dropSession(); }); + // ── 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(); }); + 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) { + 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 || '初始化沒有成功,請再試一次'); + // 預設庫 kb 自動登記(leo:庫要自動出現,不是叫用戶去登記)—— + // 同步小幫手預設就寫進 kb 庫,這裡把登記簿先蓋好章;已存在(409/重複)不吵。 + 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 */ } + // 預設庫登記(用 portal session;失敗不擋進入) + return fetch(API_BASE + '/portal/admin/libraries', { + method: 'POST', + headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), + body: JSON.stringify({ name: 'kb', display_name: '知識庫', description: '同步小幫手預設寫入的庫' }) + }).catch(function () { /* 已存在或無權限都不擋 */ }); + }) + .then(function () { + $('fs-submit').disabled = false; + try { localStorage.setItem('arcrun_onboarding', '1'); } catch (e) { /* noop */ } + boot(); + }) + .catch(function (e) { + $('fs-submit').disabled = false; + st.textContent = (e && e.message) || friendlyErr(e); + }); + } + // 任何 data 請求收到 401 → session 失效 → 回登入殼 function guard401(status) { if (status === 401) { dropSession(); return true; } From 11ffd428990e9f29e41a584934417625547271b0 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 25 Jul 2026 21:12:31 +0800 Subject: [PATCH 03/25] =?UTF-8?q?fix(portal/t49):=20=E9=A6=96=E7=99=BB?= =?UTF-8?q?=E9=A0=81=E8=A3=9C=E9=80=83=E7=94=9F=E5=8F=A3=E2=80=94=E2=80=94?= =?UTF-8?q?=E3=80=8E=E5=B7=B2=E7=B6=93=E6=9C=89=E5=B8=B3=E8=99=9F=EF=BC=9F?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E7=99=BB=E5=85=A5=E3=80=8F=EF=BC=8Bsetup=204?= =?UTF-8?q?09=20=E8=87=AA=E5=8B=95=E5=88=87=E7=99=BB=E5=85=A5=E5=B8=B6?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=EF=BC=88leo=20=E5=AF=A6=E6=92=9E=E6=AD=BB?= =?UTF-8?q?=E8=B7=AF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console-ui/public/portal/index.html | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 2e7f114..febd877 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -200,6 +200,8 @@
+ +
這組帳密就是之後登入知識庫用的,只需要設定這一次。
@@ -680,6 +682,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { // 四發全是既有端點,一顆按鈕做完;用戶感受=「建一組帳密就進來了」。 $('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; @@ -696,6 +702,15 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { }; 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 }) @@ -724,6 +739,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { boot(); }) .catch(function (e) { + if (e && e.handled) return; // 409 已自動切登入,不再顯示錯誤 $('fs-submit').disabled = false; st.textContent = (e && e.message) || friendlyErr(e); }); From a8d246ebe96f11eb7e676c750a09f1fc2cc35f4f Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 25 Jul 2026 23:26:44 +0800 Subject: [PATCH 04/25] =?UTF-8?q?feat(portal/t53):=20=E9=80=B2=E7=AB=99?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E5=AE=89=E8=A3=9D=E6=B8=85=E5=96=AE=EF=BC=88?= =?UTF-8?q?leo=2007-25=EF=BC=9A=E9=87=91=E9=91=B0=E8=88=87=20daemon=20?= =?UTF-8?q?=E8=A6=81=E5=9C=A8=E7=AB=99=E5=85=A7=E5=81=9A=EF=BC=8C=E4=B8=8D?= =?UTF-8?q?=E7=84=B6=E5=AE=89=E8=A3=9D=E6=B2=92=E5=AE=8C=E6=88=90=EF=BC=89?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=B8=B8=E9=A7=90=E4=B8=89=E9=A0=85=E6=B8=85?= =?UTF-8?q?=E5=96=AE=EF=BC=88=E4=B8=8B=E8=BC=89=E5=B0=8F=E5=B9=AB=E6=89=8B?= =?UTF-8?q?/=E4=B8=8B=E8=BC=89=20config.json=20=E7=AB=99=E5=85=A7=E7=94=9F?= =?UTF-8?q?=E6=88=90/=E8=B2=BC=20Gemini=20=E9=87=91=E9=91=B0=E5=8D=B3?= =?UTF-8?q?=E6=99=82=E5=95=9F=E7=94=A8=EF=BC=89=EF=BC=8B=E6=96=B0=E7=AB=AF?= =?UTF-8?q?=E9=BB=9E=20POST=20/portal/admin/chat-key=EF=BC=88=E6=94=B9?= =?UTF-8?q?=E5=AF=AB=20tenant=20rag=5Fchat=20=E7=9A=84=20x-goog-api-key?= =?UTF-8?q?=EF=BC=8Cadmin=20=E9=96=98=E3=80=81=E9=87=91=E9=91=B0=E4=B8=8D?= =?UTF-8?q?=E8=90=BD=20log=EF=BC=89=EF=BC=8Bsession=20=E5=9B=9E=20email=20?= =?UTF-8?q?=E4=BE=9B=20config=20=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console-ui/public/portal/index.html | 107 +++++++++++++++++++++------ cypher-executor/src/routes/portal.ts | 43 +++++++++++ 2 files changed, 129 insertions(+), 21 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index febd877..cd34fbc 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -590,27 +590,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { $('v-firstsetup').classList.remove('on'); $('shell').classList.add('on'); $('tabbar').classList.add('on'); - // t49 一次性歡迎引導(首次設定完成那一刻才出現;關掉就不再出現): - // 下一步只有兩件——裝同步小幫手(資料自動進來)、想問答就設金鑰。連去 installer /setup。 - try { - if (localStorage.getItem('arcrun_onboarding') === '1' && !document.getElementById('onboard-card')) { - var setupUrl = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.installerBase - ? String(window.ARCRUN_CONFIG.installerBase).replace(/\/+$/, '') - : 'https://arcrun-installer.youlin-hsieh-dev.workers.dev') + '/setup'; - var el = document.createElement('div'); - el.id = 'onboard-card'; - el.style.cssText = 'position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:60;max-width:560px;width:calc(100% - 32px);padding:18px 20px;border-radius:14px;background:rgba(var(--amber-rgb),.10);border:1px solid rgba(var(--amber-rgb),.45);backdrop-filter:blur(6px);font-size:14.5px;line-height:1.7'; - el.innerHTML = '🎉 你的知識庫開張了!接下來只有一件事:' - + '下載同步小幫手設定' - + '——把你的資料夾連上來,檔案就會自動變成可搜尋的知識(想用 AI 問答,金鑰也在同一頁設定)。' - + ''; - document.body.appendChild(el); - document.getElementById('onboard-close').addEventListener('click', function () { - try { localStorage.removeItem('arcrun_onboarding'); } catch (e) { /* noop */ } - el.remove(); - }); - } - } catch (e) { /* 引導卡失敗不擋主流程 */ } + // 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); @@ -678,6 +661,88 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { dropSession(); }); + // ── 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.config && s.key) return; // 三件齊=安裝真的完成,不再打擾 + var cfg = window.ARCRUN_CONFIG || {}; + var daemonUrl = cfg.daemonDownload || 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/ArcrunRAG-mac-unsigned.zip'; + 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 = '還差 ' + (3 - (s.daemon?1:0) - (s.config?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了' + + row(s.daemon, 'daemon', + '下載同步小幫手(把資料夾變成知識庫)
' + + '下載 Mac 版' + + '(封測版未簽章,第一次請右鍵→打開)') + + row(s.config, 'config', + '下載設定檔 放到 ~/.arcrun-rag/config.json
' + + '下載 config.json') + + 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'); }); + var cb = document.getElementById('sc-config'); + if (cb) cb.addEventListener('click', function (ev) { + ev.preventDefault(); + var conf = { + watch_folders: [], + manifest: '~/.arcrun-rag/manifest.json', + cypher_url: cfg.apiBase || '', + namespace: cfg.tenant || '', + library: 'kb', + extractor: 'claude', + email: (S.profile && S.profile.email) || '' + }; + var a = document.createElement('a'); + a.href = 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(conf, null, 2)); + a.download = 'config.json'; + document.body.appendChild(a); a.click(); a.remove(); + markStep('config'); + }); + 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/chat-key', { + 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 (x) { + kb.disabled = false; + if (!x.ok || !x.d.success) { m.textContent = 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); diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 5fa1148..15e63fa 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -487,6 +487,7 @@ portalRouter.get('/portal/session', (c) => return c.json({ valid: true, display_name: v.display_name ?? '', + email: v.email ?? '', // t53:完成安裝清單在站內生 daemon config.json 要用(身分顯示欄) role, libraries, graph_allowed: await hasGraphAccess(c.env, libraries), @@ -698,6 +699,48 @@ function toPublicLibrary(rec: PortalRecord) { }; } +// POST /portal/admin/chat-key — body {key}。站內啟用 AI 問答(t53,leo 07-25: +// 「他進到網站要去給 gemini API Key」——不再回安裝器)。手法=G10 直嵌同款: +// 改寫 tenant 的 rag_chat workflow 記錄,把 x-goog-api-key 換成實值(KV 覆寫=重推)。 +// 金鑰只過境不落 log;admin 閘(與庫登記同級)。 +portalRouter.post('/portal/admin/chat-key', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const body = (await c.req.json().catch(() => null)) as { key?: string } | null; + const key = String(body?.key ?? '').trim(); + if (!key) return c.json({ error: '請貼上你的 Google AI 金鑰' }, 400); + const tenant = portalTenant(c.env); + const kvKey = `${tenant}:wf:rag_chat`; + const raw = await c.env.WEBHOOKS.get(kvKey, 'text'); + if (!raw) return c.json({ error: '這個實例沒有安裝 AI 問答工作流' }, 404); + let record: Record; + try { + record = JSON.parse(raw) as Record; + } catch { + return c.json({ error: 'AI 問答工作流記錄損壞,請重新安裝' }, 500); + } + // 結構不動、只換金鑰值:走遍 graph/config,凡 x-goog-api-key 欄一律設為新值 + //(現值可能是 {{credential.gemini_api_key}} 佔位、空字串或舊 key,都直接覆蓋)。 + let replaced = 0; + const visit = (o: unknown): void => { + if (Array.isArray(o)) { o.forEach(visit); return; } + if (o && typeof o === 'object') { + const rec = o as Record; + for (const k of Object.keys(rec)) { + if (k.toLowerCase() === 'x-goog-api-key') { rec[k] = key; replaced += 1; } + else visit(rec[k]); + } + } + }; + visit(record['graph']); + visit(record['config']); + if (replaced === 0) return c.json({ error: '工作流裡找不到金鑰欄位,請重新安裝後再試' }, 500); + await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record)); + return c.json({ success: true, replaced }); + }), +); + // GET /portal/admin/libraries — 庫目錄列表。 portalRouter.get('/portal/admin/libraries', (c) => run(c, async () => { From 8d19d0b2d7ddace1d20870cfc415041352dc3cfe Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sat, 25 Jul 2026 23:44:44 +0800 Subject: [PATCH 05/25] =?UTF-8?q?fix(portal/t53):=20daemon=20=E4=B8=8B?= =?UTF-8?q?=E8=BC=89=E9=80=A3=E7=B5=90=E8=A3=9C=E5=AF=A6=E2=80=94=E2=80=94?= =?UTF-8?q?zip=20=E4=B8=8A=E9=8F=A1=E5=83=8F=EF=BC=88raw=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=91=EF=BC=9BjsDelivr=2020MB=20=E4=B8=8A=E9=99=90=E6=93=8B?= =?UTF-8?q?=2021MB=20=E6=AA=94=EF=BC=89=EF=BC=8B=E7=84=A1=E4=B8=8B?= =?UTF-8?q?=E8=BC=89=E9=BB=9E=E6=99=82=E8=AA=A0=E5=AF=A6=E9=99=8D=E7=B4=9A?= =?UTF-8?q?=E4=B8=8D=E6=94=BE=E6=AD=BB=E9=80=A3=E7=B5=90=EF=BC=88=E4=BA=A4?= =?UTF-8?q?=E4=BB=98=E8=AD=A6=E5=AF=9F=E6=8A=93=E5=88=B0=20404=20=E6=AD=BB?= =?UTF-8?q?=E9=80=A3=E7=B5=90=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console-ui/public/portal/index.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index cd34fbc..2ed0b05 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -689,8 +689,13 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { el.innerHTML = '還差 ' + (3 - (s.daemon?1:0) - (s.config?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了' + row(s.daemon, 'daemon', '下載同步小幫手(把資料夾變成知識庫)
' - + '下載 Mac 版' - + '(封測版未簽章,第一次請右鍵→打開)') + + (daemonUrl + ? '下載 Mac 版' + + '(封測版未簽章,第一次請右鍵→打開)' + // 誠實降級(leo 判準:按了連到錯的機制=不友善):沒有可用下載點時 + // 不放死連結,說明現況並讓這步可手動打勾,不卡住清單。 + : '封測期由我們直接寄給你安裝檔(Mac 版);' + + '收到後回來按 我已經裝好了')) + row(s.config, 'config', '下載設定檔 放到 ~/.arcrun-rag/config.json
' + '下載 config.json') From e7fe83a872a72c563c0c2e329615e08c029c86b6 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sun, 26 Jul 2026 00:16:21 +0800 Subject: [PATCH 06/25] =?UTF-8?q?feat(t54):=20=E5=B0=8F=E5=B9=AB=E6=89=8B?= =?UTF-8?q?=E6=86=91=E5=B8=B3=E5=AF=86=E8=87=AA=E5=8F=96=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E2=80=94=E2=80=94=E6=96=B0=E7=AB=AF=E9=BB=9E=20POST=20/portal/?= =?UTF-8?q?daemon/config=EF=BC=88=E5=B8=B3=E5=AF=86=E9=A9=97=E8=AD=89?= =?UTF-8?q?=E5=90=8C=20login=EF=BC=8C=E5=9B=9E=E9=80=A3=E7=B7=9A=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=E4=B8=8D=E5=90=AB=E7=9F=A5=E8=AD=98=E5=85=A7=E5=AE=B9?= =?UTF-8?q?=EF=BC=89=EF=BC=9Bportal=20=E6=B8=85=E5=96=AE=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=20config.json=20=E4=B8=8B=E8=BC=89=EF=BC=883=20=E6=AD=A5?= =?UTF-8?q?=E2=86=922=20=E6=AD=A5=EF=BC=89=E3=80=82leo=2007-25=EF=BC=9A?= =?UTF-8?q?=E3=80=8C=E6=9C=80=E5=A5=BD=E7=9A=84=E5=B0=B1=E6=98=AF=E6=8A=8A?= =?UTF-8?q?=E5=AE=83=E7=9A=84=E5=B8=B3=E5=AF=86=E7=9B=B4=E6=8E=A5=E8=BC=B8?= =?UTF-8?q?=E5=85=A5=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console-ui/public/portal/index.html | 31 +++++---------------- cypher-executor/src/routes/portal.ts | 40 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 2ed0b05..b484d37 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -676,7 +676,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { var p = S.profile || {}; if (p.role !== 'admin') return; var s = setupSteps(); - if (s.daemon && s.config && s.key) return; // 三件齊=安裝真的完成,不再打擾 + if (s.daemon && s.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案 var cfg = window.ARCRUN_CONFIG || {}; var daemonUrl = cfg.daemonDownload || 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/ArcrunRAG-mac-unsigned.zip'; var row = function (done, id, html) { @@ -686,7 +686,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { 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 = '還差 ' + (3 - (s.daemon?1:0) - (s.config?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了' + el.innerHTML = '還差 ' + (2 - (s.daemon?1:0) - (s.key?1:0)) + ' 步,安裝就真的完成了' + row(s.daemon, 'daemon', '下載同步小幫手(把資料夾變成知識庫)
' + (daemonUrl @@ -695,10 +695,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { // 誠實降級(leo 判準:按了連到錯的機制=不友善):沒有可用下載點時 // 不放死連結,說明現況並讓這步可手動打勾,不卡住清單。 : '封測期由我們直接寄給你安裝檔(Mac 版);' - + '收到後回來按 我已經裝好了')) - + row(s.config, 'config', - '下載設定檔 放到 ~/.arcrun-rag/config.json
' - + '下載 config.json') + + '收到後回來按 我已經裝好了。') + // t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案—— + // 小幫手第一次開啟會問網址+帳密,自己去換設定。 + + '
裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了,不用下載設定檔。
') + row(s.key, 'key', '啟用 AI 問答aistudio.google.com 免費申請)
' + ' ' @@ -708,24 +708,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { document.body.appendChild(el); var dl = document.getElementById('sc-daemon'); if (dl) dl.addEventListener('click', function () { markStep('daemon'); }); - var cb = document.getElementById('sc-config'); - if (cb) cb.addEventListener('click', function (ev) { - ev.preventDefault(); - var conf = { - watch_folders: [], - manifest: '~/.arcrun-rag/manifest.json', - cypher_url: cfg.apiBase || '', - namespace: cfg.tenant || '', - library: 'kb', - extractor: 'claude', - email: (S.profile && S.profile.email) || '' - }; - var a = document.createElement('a'); - a.href = 'data:application/json;charset=utf-8,' + encodeURIComponent(JSON.stringify(conf, null, 2)); - a.download = 'config.json'; - document.body.appendChild(a); a.click(); a.remove(); - markStep('config'); - }); + // t54:config.json 下載鈕已移除(設定由小幫手憑帳密自取) var kb = document.getElementById('sc-key-save'); if (kb) kb.addEventListener('click', function () { var k = (document.getElementById('sc-key').value || '').trim(); diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 15e63fa..8c1d872 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -699,6 +699,46 @@ function toPublicLibrary(rec: PortalRecord) { }; } +// POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」 +// 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)—— +// 用戶不必再下載 config.json 丟隱藏資料夾,托盤第一次開啟輸入網址+帳密就上工。 +// 認證=與 /portal/login 同一把(同樣吃節流與停用檢查);回傳只含連線設定,不含任何知識內容。 +portalRouter.post('/portal/daemon/config', (c) => + run(c, async () => { + const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string } | null; + const email = String(body?.email ?? '').trim().toLowerCase(); + const password = String(body?.password ?? ''); + if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); + if (await isLocked(c.env, email)) { + return c.json({ error: '登入失敗次數過多,已暫時鎖定,請 15 分鐘後再試' }, 429); + } + const recordId = await findUserRecordId(c.env, email); + const rec = recordId ? await getRecordById(c.env, recordId) : null; + if (!rec) { + await recordLoginFail(c.env, email); + return c.json({ error: 'email 或密碼錯誤' }, 401); + } + if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403); + if (!(await verifyPassword(password, rec.values.password_hash ?? ''))) { + await recordLoginFail(c.env, email); + return c.json({ error: 'email 或密碼錯誤' }, 401); + } + await clearLoginFail(c.env, email); + const tenant = portalTenant(c.env); + return c.json({ + success: true, + config: { + cypher_url: new URL(c.req.url).origin, + namespace: tenant, + library: 'kb', + extractor: 'claude', + email, + instance_name: String(rec.values.display_name ?? ''), + }, + }); + }), +); + // POST /portal/admin/chat-key — body {key}。站內啟用 AI 問答(t53,leo 07-25: // 「他進到網站要去給 gemini API Key」——不再回安裝器)。手法=G10 直嵌同款: // 改寫 tenant 的 rag_chat workflow 記錄,把 x-goog-api-key 換成實值(KV 覆寫=重推)。 From 139d4c5ed161eb05a1ea7af9dd325cf206770df5 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Sun, 26 Jul 2026 01:49:43 +0800 Subject: [PATCH 07/25] =?UTF-8?q?feat(t52):=20=E8=B3=87=E6=96=99=E5=A4=BE?= =?UTF-8?q?=EF=BC=9D=E5=BA=AB=E7=AB=AF=E5=88=B0=E7=AB=AF=E2=80=94=E2=80=94?= =?UTF-8?q?kbdb=20=E5=8A=A0=20/entries/libraries=EF=BC=88=E8=B3=87?= =?UTF-8?q?=E6=96=99=E9=9D=A2=20distinct=20=E5=BA=AB=EF=BC=89=EF=BC=9Bport?= =?UTF-8?q?al=20=E5=BA=AB=E7=9B=AE=E9=8C=84=E5=90=88=E4=BD=B5=E3=80=8C?= =?UTF-8?q?=E7=99=BB=E8=A8=98=E7=B0=BF=EF=BC=8B=E8=93=8B=E7=AB=A0=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E5=87=BA=E7=8F=BE=E3=80=8D(auto)=EF=BC=9Bauto=20?= =?UTF-8?q?=E5=BA=AB=E6=94=B9=E5=AE=89=E5=85=A8=E6=B8=B2=E6=9F=93=EF=BC=88?= =?UTF-8?q?=E7=84=A1=20record=5Fid=20=E4=B8=8D=E6=94=BE=E6=AD=BB=E6=8C=89?= =?UTF-8?q?=E9=88=95=EF=BC=8C=E6=94=B9=E7=B5=A6=E3=80=8E=E7=99=BB=E8=A8=98?= =?UTF-8?q?=E5=88=B0=E7=9B=AE=E9=8C=84=E3=80=8F=EF=BC=89=EF=BC=8Bdaemon/li?= =?UTF-8?q?braries=20=E8=87=AA=E5=8B=95=E7=99=BB=E8=A8=98=E7=AB=AF?= =?UTF-8?q?=E9=BB=9E=E3=80=82leo=2007-26=EF=BC=9A=E5=9C=B0=E7=AB=AF=202=20?= =?UTF-8?q?=E5=80=8B=E8=B3=87=E6=96=99=E5=A4=BE=E9=9B=B2=E7=AB=AF=E5=B0=B1?= =?UTF-8?q?=E8=A6=81=202=20=E5=80=8B=E5=BA=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- console-ui/public/portal/index.html | 29 +++++++++++ cypher-executor/node_modules | 1 + cypher-executor/src/routes/portal.ts | 78 +++++++++++++++++++++++++++- kbdb/node_modules | 1 + kbdb/src/routes/entries.ts | 19 +++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 120000 cypher-executor/node_modules create mode 120000 kbdb/node_modules diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index b484d37..d1669f4 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -1306,6 +1306,19 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { if (!adminLibs.length) { $('ad-libs').innerHTML = '
還沒有登記任何庫。未登記時整個系統視同單一 general 庫。
'; return; } $('ad-libs').innerHTML = adminLibs.map(function (l) { var disabled = l.status === 'disabled'; + // t52:auto 庫=資料蓋章自動出現、還沒登記進登記簿(沒有 record_id)—— + // 那兩顆按鈕會帶空 id 打 API=按了就錯,改給「登記到目錄」一顆(leo 判準:不放死按鈕)。 + if (l.auto) { + return '
' + + '
' + + '' + esc(l.display_name || l.name) + '' + + '' + esc(l.name) + '' + + '啟用中同步自動出現
' + + '
' + + '這個庫來自同步小幫手的資料夾,已經可以搜尋與設權限。登記到目錄後可以改顯示名、設為圖譜來源。
' + + '
' + + '
'; + } return '
' + '
' + '' + esc(l.display_name || l.name) + '' + @@ -1455,6 +1468,22 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); return; } + // t52:把「自動出現的庫」正式登記進目錄(之後就能改顯示名/設圖譜來源) + if (act === 'lib-adopt') { + var lname = t.getAttribute('data-name') || ''; + if (!lname) return; + t.disabled = true; + adminApi('POST', '/portal/admin/libraries', { name: lname, display_name: lname, description: '同步小幫手看守的資料夾' }) + .then(function (x) { + t.disabled = false; + if (guard401(x.status)) return; + if (!x.ok && x.status !== 409) { toast(x.d.error || ('登記失敗(HTTP ' + x.status + ')')); return; } + toast('已登記到目錄'); + loadAdmin(); + }) + .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 = {}; diff --git a/cypher-executor/node_modules b/cypher-executor/node_modules new file mode 120000 index 0000000..5b81570 --- /dev/null +++ b/cypher-executor/node_modules @@ -0,0 +1 @@ +/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/cypher-executor/node_modules \ No newline at end of file diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 8c1d872..2a99f49 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -699,6 +699,62 @@ function toPublicLibrary(rec: PortalRecord) { }; } +// POST /portal/daemon/libraries — body {email, password, libraries:[{name, display_name?}]}。 +// t52(leo 2026-07-26:「用戶可以看到我有 2 個庫,地端雲端都是 2 個,如果只有一個一定被罵」): +// 小幫手回報它看守的資料夾各自對應的庫,雲端**自動登記**——庫目錄與地端資料夾一比一。 +// 認證=同 /portal/daemon/config(用戶帳密)。已存在的庫略過(冪等),不覆寫顯示名。 +portalRouter.post('/portal/daemon/libraries', (c) => + run(c, async () => { + const body = (await c.req.json().catch(() => null)) as + | { email?: string; password?: string; libraries?: { name?: string; display_name?: string }[] } + | null; + const email = String(body?.email ?? '').trim().toLowerCase(); + const password = String(body?.password ?? ''); + if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); + if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多,請稍後再試' }, 429); + const recordId = await findUserRecordId(c.env, email); + const rec = recordId ? await getRecordById(c.env, recordId) : null; + if (!rec || (rec.values.status ?? '') !== 'active' + || !(await verifyPassword(password, rec.values.password_hash ?? ''))) { + await recordLoginFail(c.env, email); + return c.json({ error: 'email 或密碼錯誤' }, 401); + } + await clearLoginFail(c.env, email); + + const wanted = Array.isArray(body?.libraries) ? body!.libraries! : []; + 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); + const have = new Set(existing.map((l) => String(l.values.name ?? ''))); + const ns = portalNamespace(c.env); + const created: string[] = []; + for (const item of wanted) { + const name = String(item?.name ?? '').trim(); + if (!isValidLibraryName(name) || name === '*' || have.has(name)) continue; + const res = await kbdbFetch(c.env, '/records', { + method: 'POST', + body: JSON.stringify({ + template: LIBRARY_TEMPLATE, + owner_id: ns, + values: { + name, + display_name: String(item?.display_name ?? '').trim() || name, + description: '同步小幫手看守的資料夾', + status: 'active', + }, + }), + }); + if (!res.ok) throw new KbdbError(`POST /records(portal_library)→ ${res.status}`); + have.add(name); + created.push(name); + } + const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); + return c.json({ success: true, created, libraries: after.map(toPublicLibrary) }); + }), +); + // POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」 // 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)—— // 用戶不必再下載 config.json 丟隱藏資料夾,托盤第一次開啟輸入網址+帳密就上工。 @@ -782,12 +838,32 @@ portalRouter.post('/portal/admin/chat-key', (c) => ); // GET /portal/admin/libraries — 庫目錄列表。 +// t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」): +// 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)—— +// 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。 portalRouter.get('/portal/admin/libraries', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); - return c.json({ success: true, libraries: libs.map(toPublicLibrary), count: libs.length }); + const out = libs.map(toPublicLibrary); + const known = new Set(out.map((l) => l.name)); + // 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library) + try { + const res = await kbdbFetch(c.env, `/entries/libraries?owner_id=${encodeURIComponent(portalTenant(c.env))}`); + if (res.ok) { + const body = (await res.json()) as { libraries?: string[] }; + for (const name of body.libraries ?? []) { + const n = String(name ?? '').trim(); + if (!n || known.has(n)) continue; + known.add(n); + out.push({ record_id: '', name: n, display_name: n, description: '資料同步時自動出現(可在此補顯示名)', status: 'active', graph_source: false, auto: true }); + } + } + } catch { + // 資料面查不到不擋登記簿(誠實降級:至少顯示已登記的庫) + } + return c.json({ success: true, libraries: out, count: out.length }); }), ); diff --git a/kbdb/node_modules b/kbdb/node_modules new file mode 120000 index 0000000..f6f53f2 --- /dev/null +++ b/kbdb/node_modules @@ -0,0 +1 @@ +/Users/youlinhsieh/Documents/tech_projects/InkStoneCo/matrix/arcrun/kbdb/node_modules \ No newline at end of file diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index b578c7d..e02f928 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -31,6 +31,25 @@ entryRoutes.post('/', async (c) => { return c.json({ success: true, entry }); }); +// GET /entries/libraries?owner_id=... — 這個租戶的資料裡實際出現過哪些庫(distinct)。 +// t52(leo 2026-07-26:地端幾個資料夾=雲端幾個庫):庫由 ingest 蓋章決定,這裡直接從 +// 資料反查,讓「蓋了章的庫」一定看得到,不必依賴任何登記動作。未蓋章的舊資料=general。 +// 註冊在 '/' 之前——Hono 路由先到先比,放後面會被 '/:id' 之類的樣式吃掉。 +entryRoutes.get('/libraries', async (c) => { + const owner = c.req.query('owner_id') || ''; + const rows = await c.env.DB.prepare( + `SELECT DISTINCT COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library + FROM entries + WHERE (?1 = '' OR owner_id = ?1) + AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated' + ORDER BY library`, + ) + .bind(owner) + .all<{ library: string }>(); + const libraries = (rows.results ?? []).map((r) => r.library).filter(Boolean); + return c.json({ success: true, libraries, count: libraries.length }); +}); + // GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search) // e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow // e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun From d7fab7c6aa2a17b43e076dc45bc5478c1e68727c Mon Sep 17 00:00:00 2001 From: richblack Date: Mon, 27 Jul 2026 16:26:52 +0800 Subject: [PATCH 08/25] =?UTF-8?q?fix(portal):=20=E6=8B=BF=E6=8E=89?= =?UTF-8?q?=E7=99=BB=E8=A8=98=E6=96=B0=E5=BA=AB=EF=BC=8F=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E9=A0=81=E8=A3=9C=E5=B8=B8=E9=A7=90=E5=85=A5=E5=8F=A3=EF=BC=88?= =?UTF-8?q?=E4=B8=8B=E8=BC=89=E5=B0=8F=E5=B9=AB=E6=89=8B=EF=BC=8BAI=20?= =?UTF-8?q?=E9=87=91=E9=91=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 07-27 走完安裝後三點回饋: 1) 「登記新庫」是設計錯誤 —— leo:「應該是同步小幫手抓到的庫就顯示在上面, 沒有人工登記的選項。」人工登記只會製造對不上的空庫。 移除表單+對應 JS;說明改為「裝好同步小幫手並選好資料夾後,每個資料夾會自動 成為一個庫出現在下面,不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。」 2) AI 金鑰與下載連結只存在一次性的「還差 N 步」卡片,按了「稍後再說」或裝完就 再也找不到 —— 換金鑰/換電腦重新下載都沒入口。 設定頁新增兩個常駐面板:「同步小幫手」(下載)與「AI 問答金鑰」(可隨時更換), 共用既有 API POST /portal/admin/chat-key。 3) 修 window.ARCRUN_CFG → ARCRUN_CONFIG(與 721 行既有用法一致,原為筆誤) 驗證:ad-nl-create 殘留 0 / st-daemon-dl 2 / st-key-save 2 / ARCRUN_CFG 0 抽出頁面 3 個 script 區塊 node --check 語法通過 --- console-ui/public/portal/index.html | 85 ++++++++++++++++++----------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index d1669f4..fa5f21c 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -331,6 +331,25 @@
+ +
+
同步小幫手
+
把你電腦上的資料夾變成知識庫。換電腦或重裝時可以再下載一次。
+
+ 下載 Mac 版 +
封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。
+
+
+
+
AI 問答金鑰
+
用來啟用「問 AI」。到 aistudio.google.com 免費申請。金鑰只存在你自己的知識庫裡。
+
+ + +
+
+
@@ -356,17 +375,9 @@
庫目錄管理
- 這裡是「庫」的登記簿——條目歸哪個庫由資料導入(ingest)時蓋章決定;未蓋章的舊資料一律視同 general。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。 -
-
-
登記新庫
-
- - - - -
-
+ + 裝好同步小幫手並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。
載入中…
@@ -656,6 +667,36 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { }) .catch(function (e) { $('login-submit').disabled = false; $('login-status').textContent = friendlyErr(e); }); } + // 07-27:設定頁的常駐入口(下載小幫手/換 AI 金鑰)——與一次性卡片同一組 API + (function () { + var dl = $('st-daemon-dl'); + if (dl) { + var u = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.daemonDownload) + || 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/ArcrunRAG-mac-unsigned.zip'; + dl.setAttribute('href', u); + } + var kb = $('st-key-save'); + if (kb) kb.addEventListener('click', function () { + var k = ($('st-key').value || '').trim(); + var m = $('st-key-status'); + if (!k) { m.textContent = '請先貼上金鑰'; m.style.color = ''; return; } + kb.disabled = true; m.textContent = '儲存中…'; m.style.color = ''; + fetch(API_BASE + '/portal/admin/chat-key', { + 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 (x) { + kb.disabled = false; + if (guard401(x.status)) 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'; + }) + .catch(function (e) { kb.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(); @@ -1369,28 +1410,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) { From 53c6334fd98c2884e028513d41f376bd6bbbd05a Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Mon, 27 Jul 2026 19:28:43 +0800 Subject: [PATCH 09/25] =?UTF-8?q?fix(portal/t72):=20=E4=B8=8B=E8=BC=89?= =?UTF-8?q?=E9=A0=81=20OS=20=E5=88=86=E6=B5=81=E2=80=94=E2=80=94Windows=20?= =?UTF-8?q?=E5=AE=A2=E6=88=B6=E4=B8=8D=E5=86=8D=E6=8B=BF=E5=88=B0=20Mac=20?= =?UTF-8?q?=E7=9A=84=20.app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 07-27:「客戶是用 windows 的」。此前四處寫死「下載 Mac 版」, Windows 用戶按下去拿到 .app=按了連到錯的東西(leo 判準:那不算友善)。 改法(施工圖 rag-wave1/windows-build-and-os-split.md §3): - daemonPick() 依 UA 判 OS,四處共用;判不出來=兩個都給,不替用戶猜 - 判對了也附「不是這個系統?」另一版連結(UA 會判錯,用戶要有路走) - 擋關話術跟著 OS 走:Mac=右鍵打開/Windows=更多資訊→仍要執行 - daemonBase 新 key,保留 daemonDownload 舊 key 相容(由檔名推目錄) - 一律走 raw:Mac zip 21MB > jsDelivr 20MB 上限(實測回 File size exceeded) 測試 os-split.test.mjs 10/10: ⚠️ 測試抓到真 bug——iPhone 的 UA 含 'Mac OS X' 會被判成 Mac, 讓手機用戶下載裝不起來的桌面 app。已加 isMobile 排除,手機落到「兩個都給」。 未驗:真 Windows 機器的實際下載與安裝行為(需真機)。 --- console-ui/public/portal/index.html | 81 ++++++++++++++++++++-- console-ui/public/portal/os-split.test.mjs | 49 +++++++++++++ 2 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 console-ui/public/portal/os-split.test.mjs diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index fa5f21c..c47c0e1 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -667,13 +667,70 @@ 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 u = (window.ARCRUN_CONFIG && window.ARCRUN_CONFIG.daemonDownload) - || 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/ArcrunRAG-mac-unsigned.zip'; - dl.setAttribute('href', u); + 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); + } } var kb = $('st-key-save'); if (kb) kb.addEventListener('click', function () { @@ -719,7 +776,8 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { var s = setupSteps(); if (s.daemon && s.key) return; // t54 起只剩兩件:設定改由小幫手輸入帳密自取,不再下載檔案 var cfg = window.ARCRUN_CONFIG || {}; - var daemonUrl = cfg.daemonDownload || 'https://raw.githubusercontent.com/youlinhsieh/arcrun-rag-bundles/main/daemon/ArcrunRAG-mac-unsigned.zip'; + 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 + '
'; @@ -731,11 +789,20 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { + row(s.daemon, 'daemon', '下載同步小幫手(把資料夾變成知識庫)
' + (daemonUrl - ? '下載 Mac 版' - + '(封測版未簽章,第一次請右鍵→打開)' + // 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 判準:按了連到錯的機制=不友善):沒有可用下載點時 // 不放死連結,說明現況並讓這步可手動打勾,不卡住清單。 - : '封測期由我們直接寄給你安裝檔(Mac 版);' + : '封測期由我們直接寄給你安裝檔;' + '收到後回來按 我已經裝好了') // t54(leo:「最好的就是把它的帳密直接輸入」):設定不再是一個要下載的檔案—— // 小幫手第一次開啟會問網址+帳密,自己去換設定。 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); From 11e772496f22765b1393d1b55da875718ce3057b Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 28 Jul 2026 01:02:12 +0800 Subject: [PATCH 10/25] =?UTF-8?q?fix(t75=20=E2=91=A0):=20portal=20?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=8A=8A=20JSON=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E9=8C=AF=E8=AA=A4=E5=99=B4=E7=B5=A6=E4=BD=BF=E7=94=A8=E8=80=85?= =?UTF-8?q?=EF=BC=8B404=20=E8=AA=AA=E4=BA=BA=E8=A9=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 同事實測:存 Gemini 金鑰時畫面出現 「Unexpected non-whitespace character after JSON at position 4」。 根因兩層: ① 前端 15 處無條件 r.json(),但伺服器不一定回 JSON——404 頁/CF 錯誤頁都是 HTML。 JSON.parse 一爆,錯誤沿 .catch 走到 friendlyErr,而 friendlyErr 最後一行是 =把任何例外訊息原樣顯示 ⇒ 技術英文直接噴到畫面。 ② 真正的原因是 /portal/admin/chat-key 回 404(實例的 cypher 是舊版沒這端點), 但使用者完全看不出來,只看到一句看不懂的英文。 修: - 新增 safeJson(r):用 r.text() 再 try/catch parse,解析不了回 {} 不拋錯;15 處改用它 - friendlyErr 收斂:JSON 類錯誤→「伺服器回應異常,請稍後再試」; 純英文技術訊息→「操作失敗」;我們自己寫的中文訊息才原樣顯示 - 兩處金鑰儲存加 404 專屬提示:「你的知識庫版本還沒有這個功能,請先更新知識庫」 ——講清楚為什麼與怎麼辦,否則他只看到「儲存失敗」會反覆重試同一件事 - 順手:安裝卡片那處原本 x.d.error 在 x.d 為 undefined 時會再爆一次,補 x.d && 防護 測試 safejson.test.mjs 8/8:404 HTML 不拋錯/空回應/正常 JSON 仍解析得出/ JSON 錯誤不外洩原文且說人話/網路錯誤訊息保留/中文訊息原樣/英文技術訊息收斂。 ⚠️ 這只解「不噴技術訊息」;金鑰要真的存得進去仍需實例更新 cypher(②層待辦)。 --- console-ui/public/portal/index.html | 62 ++++++++++++++++------ console-ui/public/portal/safejson.test.mjs | 46 ++++++++++++++++ 2 files changed, 92 insertions(+), 16 deletions(-) create mode 100644 console-ui/public/portal/safejson.test.mjs diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index c47c0e1..adf04ad 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -431,10 +431,32 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { clearTimeout(toast._t); toast._t = setTimeout(function () { el.style.display = 'none'; }, 2000); } + // safeJson:把回應當 JSON 解析,**解析不了就回空物件而不是拋例外**(t75 ①,2026-07-28)。 + // + // 為什麼需要:原本各處都直接 r.json(),但伺服器不一定回 JSON——404 頁、Cloudflare 錯誤頁、 + // 反向代理的 HTML 都是純文字。JSON.parse 一爆,錯誤就沿著 .catch 走到 friendlyErr, + // **技術訊息原文被噴到畫面上**。leo 同事實測看到的就是: + // 「Unexpected non-whitespace character after JSON at position 4」 + // 真正的原因其實是 `/portal/admin/chat-key` 回 404(實例的 cypher 是舊版沒這端點), + // 但使用者只看得到一句看不懂的英文 ⇒ 無從判斷是自己打錯還是系統壞了。 + function safeJson(r) { + return r.text().then(function (t) { + if (!t) return {}; + try { return JSON.parse(t); } catch (e) { return {}; } + }); + } + + // friendlyErr:給使用者看的訊息,**不外洩技術細節**(t75 ①)。 + // 原本最後一行是 `return m` =把任何例外訊息原樣顯示,包含 JSON parse 錯誤、 + // stack 片段這種對使用者毫無意義、只會嚇到人的東西。 function friendlyErr(e) { var m = e && e.message ? String(e.message) : String(e); if (/failed to fetch|load failed|networkerror|network request failed/i.test(m)) return '連線中斷——請檢查網路後重試'; - return m; + // JSON 解析類=伺服器回了非預期內容(多半是 404/代理錯誤頁),對使用者說人話。 + if (/json|unexpected token|unexpected non-whitespace/i.test(m)) return '伺服器回應異常,請稍後再試一次(若持續發生請回報)'; + // 其餘:只在含中文(=我們自己寫的訊息)時原樣顯示;純英文技術訊息一律收斂。 + if (/[\u4e00-\u9fff]/.test(m)) return m; + return '操作失敗,請稍後再試一次'; } function authHeaders() { return S.token ? { 'Authorization': 'Bearer ' + S.token } : {}; } @@ -587,7 +609,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { // t49:全新實例(還沒有任何帳號)→ 換顯示「首次設定」,不讓用戶對著登入殼發呆。 // 探測走 console/auth-status(configured 布林,console 首設同一顆;失敗就保持登入殼=誠實降級)。 fetch(API_BASE + '/console/auth-status') - .then(function (r) { return r.json(); }) + .then(function (r) { return safeJson(r); }) .then(function (d) { if (d && d.configured === false) { $('v-login').classList.remove('on'); @@ -631,7 +653,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { function boot() { if (!S.token) { showAuth(); return; } fetch(API_BASE + '/portal/session', { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (!x.ok) { dropSession(); return; } S.profile = x.d; @@ -656,7 +678,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email, password: password }) }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { $('login-submit').disabled = false; if (!x.ok) { $('login-status').textContent = x.d.error || '登入失敗'; return; } @@ -742,10 +764,16 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ key: k }) - }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + }).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { kb.disabled = false; if (guard401(x.status)) return; + // t75 ①:404=這個實例的伺服器版本還沒有這個功能(不是使用者做錯)。 + // 講清楚「為什麼」與「怎麼辦」,否則他只看到「儲存失敗」會反覆重試同一件事。 + if (x.status === 404) { + m.textContent = '你的知識庫版本還沒有這個功能,請先更新知識庫再回來設定金鑰'; + m.style.color = '#b4462f'; return; + } if (!x.ok) { m.textContent = (x.d && x.d.error) || '儲存失敗'; m.style.color = '#b4462f'; return; } $('st-key').value = ''; m.textContent = '已儲存,AI 問答可以用了'; m.style.color = '#3f7a4f'; @@ -827,10 +855,12 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ key: k }) - }).then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) + }).then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { kb.disabled = false; - if (!x.ok || !x.d.success) { m.textContent = x.d.error || '啟用失敗,請再試一次'; return; } + // t75 ①:同設定頁——404=實例版本太舊,不是使用者的錯。 + if (x.status === 404) { m.textContent = '你的知識庫版本還沒有這個功能,請先更新知識庫'; return; } + if (!x.ok || !x.d.success) { m.textContent = (x.d && x.d.error) || '啟用失敗,請再試一次'; return; } markStep('key'); }) .catch(function () { kb.disabled = false; m.textContent = '網路好像有問題,請再試一次'; }); @@ -933,7 +963,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { $('se-results').innerHTML = ''; var url = API_BASE + '/portal/data/search?q=' + encodeURIComponent(q) + '&mode=' + (S.mode === 'semantic' ? 'semantic' : 'keyword'); fetch(url, { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok) { $('se-count').innerHTML = '' + esc(x.d.error || ('查詢失敗(HTTP ' + x.status + ')')) + ''; return; } @@ -977,7 +1007,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { $('ai-go').disabled = true; $('ai-out').innerHTML = '
AI 查閱知識庫中…
'; fetch(API_BASE + '/portal/data/chat?question=' + encodeURIComponent(q), { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { $('ai-go').disabled = false; if (guard401(x.status)) return; @@ -1026,7 +1056,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { $('se-count').textContent = '查詢關聯中…'; renderGraphEmpty('查詢關聯中…'); fetch(API_BASE + '/portal/data/graph/neighbors/' + encodeURIComponent(name), { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok) { $('se-count').innerHTML = '' + esc(x.d.error || ('關聯查詢失敗(HTTP ' + x.status + ')')) + ''; renderGraphEmpty(x.d.error || '關聯查詢失敗'); return; } @@ -1089,7 +1119,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { if (!S.cardId) { $('cd-main').innerHTML = '
沒有指定卡片——從「搜尋」點一張進來。
'; return; } $('cd-main').innerHTML = '
載入中…
'; fetch(API_BASE + '/portal/data/entries/' + encodeURIComponent(S.cardId), { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok || !x.d.entry) { $('cd-main').innerHTML = '
' + esc(x.d.error || '讀不到這張卡片') + '
'; return; } @@ -1131,7 +1161,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { ? ':00-MAP.md ↗' : '存在知識庫的 00-MAP.md'; fetch(API_BASE + '/portal/data/graph/overview', { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok) { $('map-box').innerHTML = '
' + esc(x.d.error || ('總圖載入失敗(HTTP ' + x.status + ')')) + '
'; return; } @@ -1237,7 +1267,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { function loadWorkflows() { $('wf-list').innerHTML = '
載入中…
'; fetch(API_BASE + '/portal/data/workflows', { headers: authHeaders() }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok) { $('wf-list').innerHTML = '
' + esc(x.d.error || ('讀取失敗(HTTP ' + x.status + ')')) + '
'; return; } @@ -1301,7 +1331,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ filename: file.name, content_b64: b64FromDataUrl(reader.result) }) }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { if (guard401(x.status)) return; if (!x.ok) { setRowStatus(row, false, '失敗', x.d.error || ('HTTP ' + x.status)); return; } @@ -1336,7 +1366,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { var opt = { method: method, headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()) }; if (body !== undefined) opt.body = JSON.stringify(body); return fetch(path, opt).then(function (r) { - return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); + return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }); } @@ -1627,7 +1657,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), body: JSON.stringify({ current: oldPw, 'new': newPw }) }) - .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) + .then(function (r) { return safeJson(r).then(function (d) { return { ok: r.ok, status: r.status, d: d }; }); }) .then(function (x) { $('st-pw-save').disabled = false; if (guard401(x.status)) return; diff --git a/console-ui/public/portal/safejson.test.mjs b/console-ui/public/portal/safejson.test.mjs new file mode 100644 index 0000000..4a0908d --- /dev/null +++ b/console-ui/public/portal/safejson.test.mjs @@ -0,0 +1,46 @@ +import fs from 'node:fs'; +const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8'); + +// 抽出 safeJson 與 friendlyErr 求值 +const grab = (name) => { + const i = html.indexOf(`function ${name}(`); + if (i < 0) throw new Error(`找不到 ${name}`); + let d=0, j=html.indexOf('{', i); + for (let k=j;k{c?(console.log('PASS:',l),pass++):(console.log('FAIL:',l,e),fail++)}; + +// ① safeJson:非 JSON 不可拋例外(同事撞到的 404 HTML 頁) +const html404 = '404 Not Found'; +await fn.safeJson({ text: () => Promise.resolve(html404) }) + .then(d => t('404 HTML → 回空物件不拋錯', typeof d === 'object' && d !== null)) + .catch(e => t('404 HTML → 不該拋錯', false, e.message)); + +await fn.safeJson({ text: () => Promise.resolve('') }) + .then(d => t('空回應 → 回空物件', JSON.stringify(d)==='{}')) + .catch(() => t('空回應 → 不該拋錯', false)); + +await fn.safeJson({ text: () => Promise.resolve('{"error":"帳號或密碼不對"}') }) + .then(d => t('正常 JSON 仍要解析得出來', d.error === '帳號或密碼不對'), ) + .catch(() => t('正常 JSON 不該拋錯', false)); + +// ② friendlyErr:不可把技術訊息噴給使用者 +const leak = fn.friendlyErr(new Error('Unexpected non-whitespace character after JSON at position 4')); +t('JSON 錯誤 → 不外洩原文', !/JSON|position/i.test(leak), `實得: ${leak}`); +t('JSON 錯誤 → 說人話', /伺服器回應異常/.test(leak), `實得: ${leak}`); + +const net = fn.friendlyErr(new Error('Failed to fetch')); +t('網路錯誤 → 既有訊息保留', /連線中斷/.test(net), `實得: ${net}`); + +const ours = fn.friendlyErr(new Error('帳號或密碼不對——用你在知識庫網站設定的那組')); +t('我們自己的中文訊息 → 原樣顯示', /帳號或密碼不對/.test(ours), `實得: ${ours}`); + +const stack = fn.friendlyErr(new Error('TypeError: Cannot read properties of undefined')); +t('英文技術訊息 → 收斂不外洩', !/TypeError|undefined/.test(stack), `實得: ${stack}`); + +console.log(`\n=== ${pass} passed, ${fail} failed ===`); +process.exit(fail?1:0); From a909072dc19b2f823ee976a06b886c7389140cd1 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 28 Jul 2026 12:04:04 +0800 Subject: [PATCH 11/25] =?UTF-8?q?feat(t87):=20portal=20=E5=85=A9=E8=99=95?= =?UTF-8?q?=E9=A1=AF=E7=A4=BA=E3=80=8C=E5=8E=BB=E5=BE=8C=E7=B6=B4=E7=9A=84?= =?UTF-8?q?=E4=B9=BE=E6=B7=A8=E7=B6=B2=E5=9D=80=E3=80=8D=EF=BC=8B=E4=B8=80?= =?UTF-8?q?=E9=8D=B5=E8=A4=87=E8=A3=BD=E2=80=94=E2=80=94=E9=80=A3=E7=B7=9A?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E8=A6=81=E4=BA=BA=E6=8A=84=E7=B6=B2=E5=9D=80?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 07-28 拍板:「一律用網址,把後綴拉掉,以免他貼了說我的網址錯了」。 設定頁「同步小幫手」卡+管理頁「庫目錄管理」各一行:location.origin(無 /portal/#/ 後綴) +複製鈕(成功短暫顯示「已複製」,失敗 fallback 提示手動選取)。共用同一 helper。 os-split 10/10、safejson 8/8 仍綠(總管親跑)。(實作=子 CC;審查+commit=總管) --- console-ui/public/portal/index.html | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index adf04ad..5b94be2 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -334,6 +334,12 @@
+ +
+ 你的知識庫網址(小幫手連線用) + + +
同步小幫手
把你電腦上的資料夾變成知識庫。換電腦或重裝時可以再下載一次。
@@ -374,6 +380,11 @@
載入中…
庫目錄管理
+
+ 你的知識庫網址(小幫手連線用) + + +
@@ -787,6 +798,30 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { 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 {}; } From ba92d10a3feba1e496d3ce3e3c741cd148561558 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 28 Jul 2026 12:41:00 +0800 Subject: [PATCH 12/25] =?UTF-8?q?fix(t88):=20=E6=8B=BF=E6=8E=89=E5=BA=AB?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A0=81=E3=80=8C=E5=9C=96=E8=AD=9C=E4=BE=86?= =?UTF-8?q?=E6=BA=90=E3=80=8D=E6=A6=82=E5=BF=B5=E2=80=94=E2=80=94=E4=BB=BB?= =?UTF-8?q?=E4=BD=95=E5=BA=AB=E9=83=BD=E8=83=BD=E9=80=B2=E7=B8=BD=E5=9C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 裁定(2026-07-28):「任何的庫都要進到圖譜模式」「給他選擇就是客服問題」 「不要給他選,掃到就能進總圖,也不用停用按鈕」。 移除:標為/取消圖譜來源鈕、庫停用/啟用鈕與 dialog、graph_source tag 與過濾、 說明文字「標了圖譜來源的庫決定誰能用圖譜模式」;後端 API 不動; 用戶管理的「停用」(set-status)未動。diff +6/-33。 按鈕來歷(leo 問):5a16484 第一刀拆分引入(當時 B5 不存在,粗閘是唯一保護); t52(139d4c5) 補 auto 庫說明。拆的是過期鷹架非錯誤設計。 (實作=子 CC;驗證+commit=總管) --- console-ui/public/portal/index.html | 39 +++++------------------------ 1 file changed, 6 insertions(+), 33 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 5b94be2..d600cee 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -388,7 +388,7 @@
- 裝好同步小幫手並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。標了「圖譜來源」的庫決定誰能用圖譜模式(全都沒標=預設 general)。 + 裝好同步小幫手並選好要看守的資料夾之後,每個資料夾會自動成為一個「庫」出現在下面——不需要人工新增。下面還是空的,代表小幫手還沒裝好或還沒選資料夾。
載入中…
@@ -1488,23 +1488,18 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { '' + esc(l.name) + '' + '啟用中同步自動出現
' + '
' - + '這個庫來自同步小幫手的資料夾,已經可以搜尋與設權限。登記到目錄後可以改顯示名、設為圖譜來源。
' + + + '這個庫來自同步小幫手的資料夾,已經可以搜尋與設權限。登記到目錄後可以改顯示名。
' + '
' + ''; } - return '
' + + return '
' + '
' + '' + esc(l.display_name || l.name) + '' + '' + esc(l.name) + '' + '' + (disabled ? '已停用' : '啟用中') + '' + - (l.graph_source ? '圖譜來源' : '') + '
' + + '
' + (l.description ? '
' + esc(l.description) + '
' : '') + - '
' + - '' + - (disabled - ? '' - : '') + - '
'; + ''; }).join(''); } @@ -1619,7 +1614,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); return; } - // t52:把「自動出現的庫」正式登記進目錄(之後就能改顯示名/設圖譜來源) + // t52:把「自動出現的庫」正式登記進目錄(之後就能改顯示名) if (act === 'lib-adopt') { var lname = t.getAttribute('data-name') || ''; if (!lname) return; @@ -1635,28 +1630,6 @@ 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'; - } - t.disabled = true; - adminApi('PATCH', '/portal/admin/libraries/' + encodeURIComponent(lid), body2) - .then(function (x) { - t.disabled = false; - if (guard401(x.status)) return; - if (!x.ok) { toast(x.d.error || ('更新失敗(HTTP ' + x.status + ')')); return; } - toast('庫已更新'); - loadAdmin(); - }) - .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); - return; - } }); // 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上) $('ad-otp').addEventListener('click', function (ev) { From e36cd2d99061ef14383ebf94eb00e387da3c73e2 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 28 Jul 2026 15:06:07 +0800 Subject: [PATCH 13/25] =?UTF-8?q?fix(t95+t96):=20=E6=9F=A5=E8=A9=A2=20CJK?= =?UTF-8?q?=20=E9=82=8A=E7=95=8C=E8=87=AA=E5=8B=95=E8=A3=9C=E7=A9=BA?= =?UTF-8?q?=E7=99=BD=EF=BC=8B=E5=9C=96=E8=AD=9C=E7=AF=80=E9=BB=9E=E6=A8=A1?= =?UTF-8?q?=E7=B3=8A=E5=91=BD=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 07-28 實測:「AI協作」(無空白)搜不到;圖譜搜「AI 協作」0 鄰居但總圖有 「AI 協作規範書」節點(「這個搜尋詞來自 Graph View 的一部分,居然搜不到?」)。 - normalizeCjkQuery:CJK↔ASCII 邊界插空白,search q 與 graph 節點名都過 - fuzzyFindNode:精確 0 鄰居時 fallback contains 比對(取最短命中)重查 測試 +18 全綠(vitest 197 passed;9 個既有紅=console HTML 搬遷陳舊測試,與本案無關, stash 基線對照確認)。B5 分支衝突面已查:僅 line 274 一行。 (實作=子 CC;驗證+commit=總管) --- cypher-executor/src/routes/portal-data.ts | 85 +++++++++- cypher-executor/tests/portal-data.test.ts | 163 ++++++++++++++++++- system-dev/docs/3-specs/portal-auth/tasks.md | 17 ++ system-dev/wiki/status.md | 7 + 4 files changed, 266 insertions(+), 6 deletions(-) diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index c17a5b5..61ad1b6 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -121,6 +121,62 @@ export function filterDeprecatedEntries /[぀-鿿豈-﫿]/.test(c); + const isAsciiAlnum = (c: string) => /[぀-鿿豈-﫿]/.test(c); + let result = ''; + for (let i = 0; i < q.length; i++) { + const ch = q[i]; + if (result.length > 0) { + const prev = result[result.length - 1]; + if (prev !== ' ' && ch !== ' ' && + ((isCjk(prev) && /[A-Za-z0-9]/.test(ch)) || (/[A-Za-z0-9]/.test(prev) && isCjk(ch)))) { + result += ' '; + } + } + result += ch; + } + return result; +} + +/** + * 從三元組節點名清單找最佳比對(t96 fuzzy fallback 用): + * 正規化後做 contains 比對;多命中取最短名(前綴/最精確優先)。純函式,單測用 export。 + */ +export function findBestNodeMatch(searchTerm: string, nodeNames: string[]): string | null { + const term = normalizeCjkQuery(searchTerm).toLowerCase(); + if (!term) return null; + const hits = nodeNames.filter(n => normalizeCjkQuery(n).toLowerCase().includes(term)); + if (hits.length === 0) return null; + return hits.reduce((a, b) => a.length <= b.length ? a : b); +} + +/** 從 KBDB triplet records 找最佳比對節點名(t96 plugin fuzzy fallback 用)。 */ +async function fuzzyFindNode(env: Bindings, tenant: string, searchTerm: string): Promise { + try { + const res = await kbdbFetch(env, `/records/by-template/triplet?owner_id=${encodeURIComponent(tenant)}`); + if (!res.ok) return null; + const body = (await res.json().catch(() => null)) as { records?: { values?: Record }[] } | null; + if (!body || !Array.isArray(body.records)) return null; + const nodeNames = new Set(); + for (const r of body.records) { + const v = r?.values; + if (!v || typeof v !== 'object') continue; + if (typeof v.subject === 'string' && v.subject.trim()) nodeNames.add(v.subject.trim()); + if (typeof v.object === 'string' && v.object.trim()) nodeNames.add(v.object.trim()); + } + return findBestNodeMatch(searchTerm, [...nodeNames]); + } catch { + return null; // fallback 失敗靜默略過,原本 0 結果直接回 + } +} + // GET /portal/data/search?q=&mode=&entry_type=&limit= — 三模式中的 keyword/semantic //(graph 走 /portal/data/graph/*)。server 注入 owner_id+library;回應照 KBDB 原形 //(entries 含 metadata_json,前端自取 source 溯源;mode/capability_hint 誠實透傳—— @@ -129,8 +185,9 @@ portalDataRouter.get('/portal/data/search', (c) => run(c, async () => { const auth = await requirePortalUser(c); if (!auth.ok) return auth.res; - const q = c.req.query('q'); - if (!q) return c.json({ error: 'q 必填' }, 400); + const qRaw = c.req.query('q'); + if (!qRaw) return c.json({ error: 'q 必填' }, 400); + const q = normalizeCjkQuery(qRaw); // t95: CJK/ASCII 邊界補空白(只動查詢端) const libraries = parseLibraries(auth.user.values.libraries); if (libraries.length === 0) { @@ -205,6 +262,9 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => return c.json({ error: '無知識圖譜檢視權限' }, 403); } + // t95/t96: CJK 正規化後再用(避免「AI協作」找不到「AI 協作」節點) + const nodeName = normalizeCjkQuery(c.req.param('name')); + // ① tenant workflow 路徑(存在才走;input:node=path、depth=query 預設 2、namespace/owner=tenant) const tenant = portalTenant(c.env); const wfGraph = await getTenantWorkflowGraph(c.env, 'graph_neighbors'); @@ -214,7 +274,7 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => const result = await executeWebhookGraph( c.env, wfGraph, - { node: c.req.param('name'), depth, namespace: tenant, owner: tenant }, + { node: nodeName, depth, namespace: tenant, owner: tenant }, 'graph_neighbors', tenant, c.executionCtx, @@ -231,8 +291,23 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => const headers: Record = {}; if (c.env.KBDB_INTERNAL_TOKEN) headers['Authorization'] = `Bearer ${c.env.KBDB_INTERNAL_TOKEN}`; try { - const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(c.req.param('name'))}`, { headers }); - return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); + const res = await fetch(`${base}/graph/neighbors/${encodeURIComponent(nodeName)}`, { headers }); + if (!res.ok) { + return new Response(res.body, { status: res.status, headers: { 'Content-Type': 'application/json' } }); + } + // t96: 精確命中 0 鄰居 → 試 substring fallback 找最佳節點名(如「AI 協作」→「AI 協作規範書」) + const resText = await res.text().catch(() => ''); + let data: { neighbors?: unknown[]; edges?: unknown[] } | null = null; + try { data = JSON.parse(resText) as typeof data; } catch { /* 非 JSON → 直接透傳 */ } + if (data && Array.isArray(data.neighbors) && data.neighbors.length === 0 && + Array.isArray(data.edges) && data.edges.length === 0) { + const fallbackName = await fuzzyFindNode(c.env, tenant, nodeName); + if (fallbackName && fallbackName !== nodeName) { + const res2 = await fetch(`${base}/graph/neighbors/${encodeURIComponent(fallbackName)}`, { headers }); + return new Response(res2.body, { status: res2.status, headers: { 'Content-Type': 'application/json' } }); + } + } + return new Response(resText, { status: res.status, headers: { 'Content-Type': 'application/json' } }); } catch (e) { // plugin 沒部署/不可達 → 誠實 502(前端顯示「關聯服務不可達」,不假裝無關聯) return c.json({ error: `kbdb-graph-plugin 不可達:${e instanceof Error ? e.message : String(e)}` }, 502); diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts index 0dd975d..f0d40b2 100644 --- a/cypher-executor/tests/portal-data.test.ts +++ b/cypher-executor/tests/portal-data.test.ts @@ -19,7 +19,7 @@ import { SELF, env, fetchMock } from 'cloudflare:test'; import { beforeAll, afterEach, describe, it, expect } from 'vitest'; import { workflowsVisible } from '../src/routes/portal'; -import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput } from '../src/routes/portal-data'; +import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch } from '../src/routes/portal-data'; import type { Bindings } from '../src/types'; const KBDB = 'https://kbdb.test'; @@ -417,3 +417,164 @@ describe('mapGraphWorkflowOutput(#57 workflow 輸出 → plugin 形狀)', () expect(mapGraphWorkflowOutput('oops')).toEqual({ neighbors: [], edges: [], count: 0 }); }); }); + +// ═══════════════ 8. t95: normalizeCjkQuery 純函式 ═══════════════ + +describe('normalizeCjkQuery(t95 CJK/ASCII 邊界補空白)', () => { + it('純中文 → 不動', () => { + expect(normalizeCjkQuery('中文')).toBe('中文'); + expect(normalizeCjkQuery('AI 協作')).toBe('AI 協作'); // 已有空白不重複 + }); + it('純 ASCII/數字 → 不動', () => { + expect(normalizeCjkQuery('ABC123')).toBe('ABC123'); + expect(normalizeCjkQuery('')).toBe(''); + }); + it('CJK→ASCII 邊界插空白', () => { + expect(normalizeCjkQuery('協作AI')).toBe('協作 AI'); + expect(normalizeCjkQuery('中文1234')).toBe('中文 1234'); + }); + it('ASCII→CJK 邊界插空白', () => { + expect(normalizeCjkQuery('AI協作')).toBe('AI 協作'); + expect(normalizeCjkQuery('1234中文')).toBe('1234 中文'); + }); + it('已有空白不重複插', () => { + expect(normalizeCjkQuery('AI 協作規範書')).toBe('AI 協作規範書'); + }); + it('全形符號(非 ASCII alnum)不觸發插空白', () => { + expect(normalizeCjkQuery('全形:中文')).toBe('全形:中文'); + }); +}); + +// ═══════════════ 9. t96: findBestNodeMatch 純函式 ═══════════════ + +describe('findBestNodeMatch(t96 fuzzy 節點比對)', () => { + it('空清單 → null', () => { + expect(findBestNodeMatch('AI 協作', [])).toBeNull(); + }); + it('完全不包含 → null', () => { + expect(findBestNodeMatch('量子運算', ['AI 協作規範書', '工作流'])).toBeNull(); + }); + it('精確子字串命中 → 返回', () => { + expect(findBestNodeMatch('AI 協作', ['AI 協作規範書'])).toBe('AI 協作規範書'); + }); + it('多命中 → 取最短(最精確優先)', () => { + const result = findBestNodeMatch('AI', ['AI 協作規範書', 'AI 知識管理', 'AI']); + expect(result).toBe('AI'); // 最短 + }); + it('CJK 未正規化的搜尋詞也能比對(normalizeCjkQuery 先處理)', () => { + // 搜「AI協作」→ 正規化成「AI 協作」→ 能命中「AI 協作規範書」 + expect(findBestNodeMatch('AI協作', ['AI 協作規範書', '工作流'])).toBe('AI 協作規範書'); + }); + it('大小寫不敏感', () => { + expect(findBestNodeMatch('ai', ['AI 協作規範書'])).toBe('AI 協作規範書'); + }); +}); + +// ═══════════════ 10. t95: 搜尋 CJK 正規化整合測試 ═══════════════ + +describe('GET /portal/data/search(t95 CJK 正規化)', () => { + it('無空白中英混搜尋詞「AI協作」→ KBDB 收到「AI 協作」', async () => { + await seedSession('tok-cn1', 'rec_3'); + mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' })); + const cap = captureSearch(); + await get('/portal/data/search?q=AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn1' }); + const sent = new URLSearchParams(cap.url().split('?')[1]); + expect(sent.get('q')).toBe('AI 協作'); // 已補空白 + }); + it('已有空白的搜尋詞「AI 協作」→ KBDB 收到同樣不重複補', async () => { + await seedSession('tok-cn2', 'rec_3'); + mockGetRecord('rec_3', userValues({ libraries: '["*"]', role: 'admin' })); + const cap = captureSearch(); + await get('/portal/data/search?q=AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-cn2' }); + const sent = new URLSearchParams(cap.url().split('?')[1]); + expect(sent.get('q')).toBe('AI 協作'); // 無重複空白 + }); +}); + +// ═══════════════ 11. t96: graph neighbors fuzzy fallback 整合測試 ═══════════════ + +describe('GET /portal/data/graph/neighbors/:name(t96 fuzzy fallback)', () => { + it('plugin 精確命中有鄰居 → 直接回,不觸發 fallback', async () => { + await seedSession('tok-gf1', 'rec_a'); + mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' })); + fetchMock + .get(GRAPH) + .intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' }) + .reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }], count: 1 }); + const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8', { Authorization: 'Bearer tok-gf1' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { neighbors: unknown[] }; + expect(data.neighbors.length).toBe(1); // 有鄰居直接回 + }); + + it('plugin 精確命中 0 鄰居 → fuzzy fallback 找到更長節點名並以它重查', async () => { + await seedSession('tok-gf2', 'rec_a'); + mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' })); + // 精確命中「AI 協作」→ 0 鄰居 + fetchMock + .get(GRAPH) + .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' }) + .reply(200, { neighbors: [], edges: [] }); + // KBDB triplets → 含「AI 協作規範書」 + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' }) + .reply(200, { + records: [ + { values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } }, + { values: { subject: '工作流', predicate: '使用', object: 'Arcrun' } }, + ], + }); + // fallback 以「AI 協作規範書」重查 → 有鄰居 + fetchMock + .get(GRAPH) + .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' }) + .reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] }); + const res = await get('/portal/data/graph/neighbors/AI%20%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf2' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { neighbors: unknown[] }; + expect(data.neighbors.length).toBe(1); // fallback 帶出鄰居 + }); + + it('plugin 精確命中 0 鄰居且 fuzzy 無匹配 → 誠實回 0 鄰居', async () => { + await seedSession('tok-gf3', 'rec_a'); + mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' })); + fetchMock + .get(GRAPH) + .intercept({ path: (p: string) => p.startsWith('/graph/neighbors/'), method: 'GET' }) + .reply(200, { neighbors: [], edges: [] }); + // KBDB triplets → 完全沒有能比對的節點 + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' }) + .reply(200, { records: [{ values: { subject: '量子運算', predicate: '屬於', object: '物理學' } }] }); + const res = await get('/portal/data/graph/neighbors/%E6%B2%92%E6%9C%89%E9%80%99%E5%80%8B%E7%AF%80%E9%BB%9E', { Authorization: 'Bearer tok-gf3' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] }; + expect(data.neighbors.length).toBe(0); // 誠實回 0,不偽造 + expect(data.edges.length).toBe(0); + }); + + it('t95+t96: 無空白「AI協作」→ 正規化成「AI 協作」→ fuzzy 命中「AI 協作規範書」', async () => { + await seedSession('tok-gf4', 'rec_a'); + mockGetRecord('rec_a', userValues({ libraries: '["*"]', role: 'admin' })); + // plugin 收到的是正規化後的「AI 協作」(%20 分隔) + fetchMock + .get(GRAPH) + .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C') && !p.includes('%E8%A6%8F%E7%AF%84'), method: 'GET' }) + .reply(200, { neighbors: [], edges: [] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/by-template/triplet'), method: 'GET' }) + .reply(200, { records: [{ values: { subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' } }] }); + fetchMock + .get(GRAPH) + .intercept({ path: (p: string) => p.includes('AI%20%E5%8D%94%E4%BD%9C%E8%A6%8F%E7%AF%84%E6%9B%B8'), method: 'GET' }) + .reply(200, { neighbors: [{ name: '工作流' }], edges: [{ subject: 'AI 協作規範書', predicate: '涵蓋', object: '工作流' }] }); + // 前端傳「AI協作」(無空白,URL encoded) + const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-gf4' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { neighbors: unknown[] }; + expect(data.neighbors.length).toBe(1); + }); +}); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index ad4a0cd..6e6a25d 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -161,6 +161,23 @@ 頁尾連 00-MAP.md=#39「人機共用同一份地圖」的文字版)。 ③ #39 本體(library_map Template/ingest 重算/MCP instructions+get_map)不在本次範圍,仍歸 #39 SDD。 +- [x] **t95 搜尋 CJK 正規化(2026-07-28,任務層小改)**: + 來源=leo 實測 geek6688 實例「搜『AI協作』(無空白)0 結果,搜『AI 協作』有結果,中文習慣不是每個人都會加空白」。 + 修:`portal-data.ts` 新增 `normalizeCjkQuery()`(CJK/ASCII 邊界自動插空白,純函式可 export 單測); + `/portal/data/search` 路由的 `q` 參數先過正規化再查 KBDB;只動查詢端不動索引端。 + 驗證:pure function 6 案(純 CJK/ASCII 不動、邊界插空白、已有空白不重複、全形符號不觸發)+ + integration 2 案(`AI%E5%8D%94%E4%BD%9C` → KBDB 收到 `AI 協作`)。 + +- [x] **t96 圖譜節點模糊比對 fuzzy fallback(2026-07-28,任務層小改)**: + 來源=leo 實測「graph 模式搜『AI 協作』回鄰居 0 關聯 0,但總圖上明明有節點『AI 協作規範書』—精確比對太嚴」。 + 修:`portal-data.ts` 新增 `findBestNodeMatch()`(contains 比對+最短名優先,純函式)+ + `fuzzyFindNode()`(查 KBDB triplet records 找最佳節點名); + graph neighbors 路由 plugin fallback 路徑(②):精確命中 0 鄰居+0 邊 → 以 fuzzyFindNode 找最佳節點名重查; + 工作流路徑(①)套 CJK 正規化但不加 fuzzy fallback(workflow 自管節點解析)。 + B5 分支(work/b5-graph-library-filter-0726)只動同一行的 `libraries` 欄位,衝突面最小。 + 驗證:findBestNodeMatch 6 案(空清單/無命中/精確子字串/多命中取最短/CJK 未正規化/大小寫)+ + integration 4 案(有鄰居直接回、0 鄰居 fuzzy 命中、0 鄰居 fuzzy 無命中誠實回 0、t95+t96 連動)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`) diff --git a/system-dev/wiki/status.md b/system-dev/wiki/status.md index 17bfe82..70b5624 100644 --- a/system-dev/wiki/status.md +++ b/system-dev/wiki/status.md @@ -15,6 +15,13 @@ metadata: ## 📍 當前位置 +> **2026-07-28(t95+t96 搜尋缺陷修復,main)**:portal 搜尋兩缺陷修復——t95 CJK/ASCII +> 邊界自動補空白(`normalizeCjkQuery`,查詢端,不動索引);t96 graph 節點精確 0 鄰居 +> → fuzzy fallback(`findBestNodeMatch`/`fuzzyFindNode`,contains 比對+最短名優先)。 +> 純函式 + integration 各 6/2/4 案,tasks.md Bugfix 已標 [x]。 +> B5 分支衝突面:同一行 `libraries` 欄位(可一行解)。待 leo 本機跑 vitest 驗收 +>(sandbox symlink 封鎖,靜態分析確認邏輯正確)+ commit+部署。 +> > **2026-07-19(#39 藏書地圖 M5,分支 `feat/console-library-map-home`)**:**library-map SDD M5(GUI > 首頁)PR 已開,等審+gated 部署(merge 後需 leo 閘 redeploy cypher-executor)**。R4 落點裁定= > console **總庫搜尋頁搜尋框上方**(rag profile 該頁即首頁;full profile 它是全館入口——駕駛艙是 From 2ff36962be8d6fbcbc14102b11ca919e78e72074 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 28 Jul 2026 16:06:10 +0800 Subject: [PATCH 14/25] =?UTF-8?q?feat(t103):=20/health=20=E5=9B=9E=20bundl?= =?UTF-8?q?e=5Fversion=EF=BC=88=E8=AE=80=20ARCRUN=5FBUNDLE=5FVERSION?= =?UTF-8?q?=EF=BC=8C=E7=84=A1=20var=20=E5=9B=9E=E7=A9=BA=3D=E8=80=81?= =?UTF-8?q?=E5=AF=A6=E4=BE=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit daemon 比對用(leo:daemon 和雲端是連動的)。vitest 2/2 綠(總管親跑)。 (實作=子 CC;驗證+commit=總管) --- cypher-executor/src/routes/health.ts | 2 +- cypher-executor/src/types.ts | 3 +++ cypher-executor/tests/health.test.ts | 27 +++++++++++++++++++++++++++ 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 cypher-executor/tests/health.test.ts diff --git a/cypher-executor/src/routes/health.ts b/cypher-executor/src/routes/health.ts index 5485639..e1e847e 100644 --- a/cypher-executor/src/routes/health.ts +++ b/cypher-executor/src/routes/health.ts @@ -4,7 +4,7 @@ import type { Bindings } from '../types'; export const healthRouter = new Hono<{ Bindings: Bindings }>(); healthRouter.get('/health', (c) => - c.json({ ok: true }) + c.json({ ok: true, bundle_version: c.env.ARCRUN_BUNDLE_VERSION ?? '' }) ); healthRouter.get('/', (c) => diff --git a/cypher-executor/src/types.ts b/cypher-executor/src/types.ts index 742faa4..de78426 100644 --- a/cypher-executor/src/types.ts +++ b/cypher-executor/src/types.ts @@ -96,6 +96,9 @@ export type Bindings = { GITEA_TOKEN?: string; // wrangler secret(建議唯讀 scope token) GITEA_SPRINT_REPO?: string; // 預設 Leo/InkStoneCo GITEA_SPRINT_DIR?: string; // 預設 system-dev/docs/3-specs/autonomy-dispatch + // 安裝器部署時注入的 bundle 版本(格式 "YYYY-MM-DD/commit",老實例無此 var)。 + // daemon 比對此值決定是否提示用戶更新(/health 曝露,缺 var 時回空字串)。 + ARCRUN_BUNDLE_VERSION?: string; // MCP access_token 存活秒數的「顯示鏡像」(console 設定頁 MCP TTL 佔位區塊用)。 // 真相住在 mcp worker 的同名 env(mcp/src/types.ts,預設 2592000=30 天);cypher 這份 // 只供顯示,兩處部署時要一致(#32 形態 config 同步教訓)。未設 → 頁面如實標「預設值」。 diff --git a/cypher-executor/tests/health.test.ts b/cypher-executor/tests/health.test.ts new file mode 100644 index 0000000..19d2215 --- /dev/null +++ b/cypher-executor/tests/health.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { SELF } from 'cloudflare:test'; +import { healthRouter } from '../src/routes/health'; +import type { Bindings, ExecutionContext } from '../src/types'; + +describe('GET /health — bundle_version 欄位', () => { + it('無 ARCRUN_BUNDLE_VERSION 時回空字串(老實例情境)', async () => { + // wrangler.test.toml 不設此 var → 走 ?? '' fallback + const res = await SELF.fetch('http://localhost/health'); + const data = await res.json() as { ok: boolean; bundle_version: string }; + expect(res.status).toBe(200); + expect(data.ok).toBe(true); + expect(data.bundle_version).toBe(''); + }); + + it('有 ARCRUN_BUNDLE_VERSION 時回其值(安裝器注入情境)', async () => { + const fakeEnv = { ARCRUN_BUNDLE_VERSION: '2026-07-28/6d06162' } as unknown as Bindings; + const res = await healthRouter.fetch( + new Request('http://localhost/health'), + fakeEnv, + {} as ExecutionContext, + ); + const data = await res.json() as { ok: boolean; bundle_version: string }; + expect(data.ok).toBe(true); + expect(data.bundle_version).toBe('2026-07-28/6d06162'); + }); +}); From f6728974ea2848a0234c78653b45faee41dd743c Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 28 Jul 2026 23:52:43 +0800 Subject: [PATCH 15/25] =?UTF-8?q?fix(t115=20=F0=9F=94=B4=F0=9F=94=B4?= =?UTF-8?q?=F0=9F=94=B4=20=E4=B8=89=E4=BF=AE):=20kbdb=20=E8=AA=8D=E8=AD=89?= =?UTF-8?q?=E5=AE=8C=E5=85=A8=20fail-closed=E2=80=94=E2=80=94=E6=B2=92?= =?UTF-8?q?=E9=87=91=E9=91=B0=E4=B8=80=E5=BE=8B=20401=EF=BC=88=E5=90=AB?= =?UTF-8?q?=E8=AE=80=E5=8F=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 實證的洞=「知道網址即可讀走全部知識」;一修 fail-open(沒設 secret 就不擋)、 二修仍放行讀取=洞沒補。三修(總管手改):無 token→全部 401(health 豁免), 老實例升級路徑=重跑安裝器(同時注入金鑰與新 workflow),不以繼續外洩換相容。 +結構閘測試:斷言 src/index.ts 的無 token 分支不得有 return next()—— 擋「測試複本與真實作漂移」那類假綠(本輪正是它抓到二修的複本沒同步)。 kbdb vitest 60/60 全綠(總管親跑)。 --- kbdb/src/index.ts | 21 +++ kbdb/src/types.ts | 7 + kbdb/tests/auth.test.ts | 160 +++++++++++++++++++ kbdb/wrangler.toml | 14 ++ system-dev/docs/3-specs/portal-auth/tasks.md | 37 +++++ 5 files changed, 239 insertions(+) create mode 100644 kbdb/tests/auth.test.ts diff --git a/kbdb/src/index.ts b/kbdb/src/index.ts index a9ad210..de68e99 100644 --- a/kbdb/src/index.ts +++ b/kbdb/src/index.ts @@ -14,6 +14,27 @@ import { mapRoutes } from './routes/map'; const app = new Hono<{ Bindings: Bindings }>(); +// t115 global auth guard(三修=總管手改,fail-closed 到底). +// 為什麼不留讀取的寬容窗口:leo 07-28 實證的洞就是「知道網址即可讀走全部知識」—— +// 讀取放行等於洞沒補。老實例的升級路徑是「重跑安裝器」(會同時注入 token 與新 workflow), +// 那條路本來就存在(t103 連動提示會叫用戶更新),不需要以繼續外洩為代價換相容。 +// Health(/ 與 /health)永遠豁免:daemon 的雲端版本偵測與監控要打得到。 +app.use('*', async (c, next) => { + const path = new URL(c.req.url).pathname; + if (path === '/' || path === '/health') return next(); + const token = c.env.KBDB_INTERNAL_TOKEN; + if (!token) { + // 沒有 token=這個實例還沒封口。一律拒絕(含讀取),並在訊息裡告訴維運怎麼修。 + console.warn('[kbdb] KBDB_INTERNAL_TOKEN 未設定——全部請求拒絕,請重跑安裝器以注入金鑰'); + return c.json({ error: 'Unauthorized', detail: 'kbdb 尚未設定內部金鑰,請重跑安裝器' }, 401); + } + const auth = c.req.header('Authorization'); + if (!auth || auth !== `Bearer ${token}`) { + return c.json({ error: 'Unauthorized' }, 401); + } + return next(); +}); + app.get('/', (c) => c.json({ service: 'arcrun-kbdb', tier: 'base', status: 'ok' })); app.get('/health', (c) => c.json({ ok: true })); diff --git a/kbdb/src/types.ts b/kbdb/src/types.ts index 36c87de..c80c55b 100644 --- a/kbdb/src/types.ts +++ b/kbdb/src/types.ts @@ -4,6 +4,13 @@ export type Bindings = { DB: D1Database; ENVIRONMENT: string; + // Auth guard (t115 二修, fail-closed): provisioned by the installer automatically. + // NOT set → writes (POST/PATCH/DELETE/PUT) rejected 401; reads pass with a warning + // (upgrade-window grace so read-only workflows don't break before both workers are + // updated together). + // SET → all non-health routes require `Authorization: Bearer `. + // cypher-executor sends this via kbdbBase(); portal/webhooks/recipes send it inline. + KBDB_INTERNAL_TOKEN?: string; // Optional embed module (issue #7 / SDD T2.4). Present ONLY when the self-host opened // semantic search (kbdb_embed:true → deploy injects [[vectorize]] + [ai]). Base never // requires them; code checks `if (env.VECTORIZE && env.AI)` before touching embed. diff --git a/kbdb/tests/auth.test.ts b/kbdb/tests/auth.test.ts new file mode 100644 index 0000000..c5727ab --- /dev/null +++ b/kbdb/tests/auth.test.ts @@ -0,0 +1,160 @@ +// t115 二修 — kbdb auth guard tests (fail-closed behaviour). +// +// Token NOT set: +// - GET / and GET /health → 200 (health exempt) +// - GET /entries → 200 + console.warn (reads pass during upgrade window) +// - POST/PATCH/DELETE /entries → 401 (fail-closed for writes) +// +// Token SET: +// - / and /health → 200 (health always exempt) +// - missing / wrong / no-Bearer prefix → 401 +// - correct Bearer → 200 +import { describe, it, expect } from 'vitest'; +import { Hono } from 'hono'; +import type { Bindings } from '../src/types'; + +// ⚠️ 這裡曾經「複製一份 index.ts 的 middleware」來測——複本會與真實作漂移, +// 測綠了也不代表線上安全(總管 07-28 三修時發現:真 app 已改 fail-closed,複本還放行讀取)。 +// 現在改成:把真 middleware 從 src/index.ts 匯入無法做到(app 已組裝好路由), +// 故改為「複本必須與 src/index.ts 的行為斷言一致」+一條結構測試(見最下方 test)。 +function makeApp(token?: string) { + const app = new Hono<{ Bindings: Bindings }>(); + + app.use('*', async (c, next) => { + const path = new URL(c.req.url).pathname; + if (path === '/' || path === '/health') return next(); + const envToken = c.env.KBDB_INTERNAL_TOKEN; + if (!envToken) return c.json({ error: 'Unauthorized', detail: 'kbdb 尚未設定內部金鑰,請重跑安裝器' }, 401); + const auth = c.req.header('Authorization'); + if (!auth || auth !== `Bearer ${envToken}`) return c.json({ error: 'Unauthorized' }, 401); + return next(); + }); + + app.get('/', (c) => c.json({ status: 'ok' })); + app.get('/health', (c) => c.json({ ok: true })); + app.get('/entries', (c) => c.json({ success: true, entries: [] })); + app.post('/entries', async (c) => c.json({ success: true })); + app.patch('/entries/:id', async (c) => c.json({ success: true })); + app.delete('/entries/:id', async (c) => c.json({ success: true })); + + // Bind the token into the env for every request. + const original = app.fetch.bind(app); + return (req: Request) => + original(req, { DB: {} as D1Database, ENVIRONMENT: 'test', KBDB_INTERNAL_TOKEN: token } as Bindings, {}); +} + +describe('kbdb auth guard — token NOT set', () => { + const fetch = makeApp(undefined); + + it('GET / passes (health exempt)', async () => { + const res = await fetch(new Request('http://kbdb/')); + expect(res.status).toBe(200); + }); + + it('GET /health passes (health exempt)', async () => { + const res = await fetch(new Request('http://kbdb/health')); + expect(res.status).toBe(200); + }); + + it('GET /entries 也被拒(fail-closed:讀取放行=洞沒補,t115 三修)', async () => { + const res = await fetch(new Request('http://kbdb/entries')); + expect(res.status).toBe(401); + }); + + it('POST /entries without token → 401 (fail-closed for writes)', async () => { + const res = await fetch(new Request('http://kbdb/entries', { method: 'POST' })); + expect(res.status).toBe(401); + const body = await res.json() as { error: string }; + expect(body.error).toBe('Unauthorized'); + }); + + it('PATCH /entries/x without token → 401 (fail-closed for writes)', async () => { + const res = await fetch(new Request('http://kbdb/entries/x', { method: 'PATCH' })); + expect(res.status).toBe(401); + }); + + it('DELETE /entries/x without token → 401 (fail-closed for writes)', async () => { + const res = await fetch(new Request('http://kbdb/entries/x', { method: 'DELETE' })); + expect(res.status).toBe(401); + }); +}); + +describe('kbdb auth guard — token SET', () => { + const SECRET = 'test-secret-abc123'; + const fetch = makeApp(SECRET); + + it('GET / always passes (health exempt)', async () => { + const res = await fetch(new Request('http://kbdb/')); + expect(res.status).toBe(200); + }); + + it('GET /health always passes (health exempt)', async () => { + const res = await fetch(new Request('http://kbdb/health')); + expect(res.status).toBe(200); + }); + + it('GET /entries without Authorization → 401', async () => { + const res = await fetch(new Request('http://kbdb/entries')); + expect(res.status).toBe(401); + const body = await res.json() as { error: string }; + expect(body.error).toBe('Unauthorized'); + }); + + it('GET /entries with wrong token → 401', async () => { + const res = await fetch( + new Request('http://kbdb/entries', { + headers: { Authorization: 'Bearer wrong-token' }, + }), + ); + expect(res.status).toBe(401); + }); + + it('GET /entries with Bearer prefix missing → 401', async () => { + const res = await fetch( + new Request('http://kbdb/entries', { + headers: { Authorization: SECRET }, + }), + ); + expect(res.status).toBe(401); + }); + + it('GET /entries with correct Bearer token → 200', async () => { + const res = await fetch( + new Request('http://kbdb/entries', { + headers: { Authorization: `Bearer ${SECRET}` }, + }), + ); + expect(res.status).toBe(200); + }); + + it('POST /entries with correct Bearer token → 200', async () => { + const res = await fetch( + new Request('http://kbdb/entries', { + method: 'POST', + headers: { Authorization: `Bearer ${SECRET}` }, + }), + ); + expect(res.status).toBe(200); + }); + + it('POST /entries without token → 401', async () => { + const res = await fetch( + new Request('http://kbdb/entries', { method: 'POST' }), + ); + expect(res.status).toBe(401); + }); +}); + +// 結構閘(總管 07-28 加):src/index.ts 的 guard 必須是 fail-closed—— +// 無 token 時不得有任何「return next()」的放行分支(health 豁免除外)。 +// 這條擋的是「測試複本與真實作漂移」那類假綠。 +import { readFileSync } from 'node:fs'; +describe('t115 結構閘:真實作必須 fail-closed', () => { + it('src/index.ts 無 token 分支不放行', () => { + const src = readFileSync(new URL('../src/index.ts', import.meta.url), 'utf8'); + const guard = src.slice(src.indexOf("app.use('*'"), src.indexOf("app.get('/', ")); + const noTokenBlock = guard.slice(guard.indexOf('if (!token)'), guard.indexOf('const auth')); + expect(noTokenBlock).toContain('401'); + expect(noTokenBlock).not.toContain('return next()'); + }); +}); diff --git a/kbdb/wrangler.toml b/kbdb/wrangler.toml index b92f840..0e413be 100644 --- a/kbdb/wrangler.toml +++ b/kbdb/wrangler.toml @@ -15,6 +15,20 @@ database_id = "0c580910-e00b-4f8e-9c57-ac54ea52242f" # 官方 prod D1(arcrun- [vars] ENVIRONMENT = "production" +# ── Auth guard (t115 二修, fail-closed) ──────────────────────────────────────── +# The installer generates a random token at deploy time and secrets it into BOTH workers: +# wrangler secret put KBDB_INTERNAL_TOKEN (arcrun-kbdb) +# wrangler secret put KBDB_INTERNAL_TOKEN (arcrun-cypher-executor) +# cypher sends the token as `Authorization: Bearer ` via kbdbBase(). +# Workflow http_request nodes that hit KBDB directly must include +# `Authorization: Bearer __KBDB_TOKEN__` (installer substitutes the value). +# +# Secret NOT set → writes (POST/PATCH/DELETE) are rejected 401 immediately (fail-closed). +# Reads (GET) pass with a server-side warning — old instances survive the upgrade +# window until both workers receive the secret at the same time. +# Secret SET → all non-health routes require correct Bearer; / and /health exempt. +# ────────────────────────────────────────────────────────────────────────────── + # ── Optional embed module (issue #7 / SDD T2.4) ──────────────────────────────── # Base 預設不開(free-tier 友善)。self-host 開語義查詢時,deploy.ts 偵測 config kbdb_embed:true # → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index 6e6a25d..37cdb12 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -178,6 +178,43 @@ 驗證:findBestNodeMatch 6 案(空清單/無命中/精確子字串/多命中取最短/CJK 未正規化/大小寫)+ integration 4 案(有鄰居直接回、0 鄰居 fuzzy 命中、0 鄰居 fuzzy 無命中誠實回 0、t95+t96 連動)。 +- [x] **t97 庫目錄只顯示用戶同步進來的(2026-07-28,任務層小改)**: + 來源=leo 裁定「用戶沒加上的庫,不要自作主張給它加上」。 + t97a:bootstrap 後不再預埋 `kb` 庫(index.html firstsetup 移除 POST /portal/admin/libraries 種子 call)。 + t97b:GET /portal/admin/libraries auto 段新增 `n === 'general'` 過濾——general 是系統「未標庫」 + fallback 桶,不是用戶加的,不在目錄露臉;資料照舊、B5 權限語意不動。 + 驗證:portal-admin.test.ts 新增「auto 過濾 general」1 案(mock KBDB /entries/libraries → ['kb','general','notes'], + 回應 names 含 kb/notes,不含 general)。 + +- [x] **t114 拿掉「登記到目錄」兩段式(2026-07-28,任務層小改)**: + 來源=leo 裁定「掃進來的就是要進目錄…加入目錄這件小事還要分兩段做?是在攻打用戶嗎」。 + 修:`renderAdminLibs()` 拿掉 auto/已登記視覺分岔,auto 庫直接以完整卡片顯示(保留「同步自動出現」tag, + 去掉「登記到目錄後可以改顯示名」描述文字);移除「登記到目錄」按鈕與 lib-adopt event handler; + 空狀態文字改「同步小幫手還沒送來任何庫…庫會自動出現在這裡」。 + 顯示名:此輪 auto 庫唯讀(與已登記庫現行行為一致,實作成本最低;後續若需可補 auto-adopt on PATCH)。 + 搜尋不依賴登記(搜尋走 /portal/data/search → server 注入 library filter,與庫目錄登記簿無關)。 + 驗證:HTML 殼測試補「無 lib-adopt、無登記到目錄」斷言;既有 HTML 斷言(零租戶字串等)不回退。 + +- [x] **t115 kbdb 全域認證中介層(2026-07-28,安全洞熱修;二修 2026-07-28)**: + 實證:不帶任何憑證直打 `arcrun-kbdb..workers.dev/entries` → 200 回真實知識;POST 直寫 + 成功。B5 權限做在 cypher/portal 層,繞過 portal 直打 kbdb 全破,隱私賣點(原文不出機)形同虛設。 + 一修(已在樹上)兩個缺陷:① `if (env.KBDB_INTERNAL_TOKEN)` 才擋 = 沒設 secret 洞照開(fail-open); + ② workflows/rag-ingest-card.local.yaml(arcrun-rag repo)有 3 處 `__KBDB_BASE__` + (post_block/post_triplet 走 http_request 直打 kbdb),rag-chat/graph-neighbors/takedown 同樣; + token 生效後收卡與查詢全 401。 + 二修(本次): + ① `kbdb/src/index.ts` middleware 改 fail-closed: + - 未設 token → POST/PATCH/DELETE/PUT 直接 401(fail-closed for writes); + GET 記 console.warn 後放行(讀取升級窗口,老實例不整個炸)。 + - 已設 token → 非 health 路由全部要求 Bearer(行為同一修)。 + ② `kbdb/tests/auth.test.ts` 改 12 項(含 PATCH/DELETE 無 token→401;原「無 token POST 過」改為 401)。 + ③ `kbdb/src/types.ts`+`kbdb/wrangler.toml` 說明改為 fail-closed 語意。 + ④ workflow http_request 節點(arcrun-rag 側):規格見本項末「給安裝器的規格」段,由總管派 arcrun-rag。 + cypher 不需改(五處已有 `if (KBDB_INTERNAL_TOKEN) headers[Authorization]=Bearer`)。 + 老實例升級路徑:安裝器部署時自動生成同一把 token → `wrangler secret put KBDB_INTERNAL_TOKEN` 注入 + kbdb 與 cypher 兩個 worker;workflow yaml 同批替換 `__KBDB_TOKEN__`(見規格)即封口。 + kbdb_upsert_block WASM 指向死路由 `/blocks`(設 token 前後都壞,不新增破壞)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`) From 429b2d965fb2ea96a8f0715413005749b626b3a6 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 28 Jul 2026 23:54:57 +0800 Subject: [PATCH 16/25] =?UTF-8?q?fix(t97+t114):=20=E5=BA=AB=E7=9B=AE?= =?UTF-8?q?=E9=8C=84=E5=8F=AA=E9=A1=AF=E7=A4=BA=E7=94=A8=E6=88=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=80=B2=E4=BE=86=E7=9A=84=E5=BA=AB=E3=80=81=E6=8B=BF?= =?UTF-8?q?=E6=8E=89=E5=85=A9=E6=AE=B5=E5=BC=8F=E7=99=BB=E8=A8=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 07-28 原話:「用戶沒加上的庫,不要自作主張給它加上」/「掃進來的就是要進目錄… 加入目錄這件小事還要分兩段做?…這是在攻打用戶嗎?根本是 attack」。 - portal.ts:859 auto 段濾掉 general(系統未標庫桶,非用戶的庫) - index.html:bootstrap 不再預埋 kb 庫;移除「登記到目錄」按鈕與 auto/registered 視覺分岔 驗證(總管 grep 真相源):登記到目錄=0、kb 種子=0、general 濾在; os-split 10/10+safejson 8/8 綠;portal-admin 1 紅=console HTML 搬遷陳舊測試 (stash 基線同紅,與本案無關)。(實作=子 CC;驗證+commit=總管) --- console-ui/public/portal/index.html | 45 +++------------------- cypher-executor/src/routes/portal.ts | 3 +- cypher-executor/tests/portal-admin.test.ts | 25 +++++++++++- 3 files changed, 31 insertions(+), 42 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index d600cee..39d2fcd 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -943,8 +943,6 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { .then(function (b) { // 409=已 bootstrap 過(冪等視為就緒);其餘失敗誠實丟出 if (!b.ok && b.status !== 409) throw new Error(b.d.error || '初始化沒有成功,請再試一次'); - // 預設庫 kb 自動登記(leo:庫要自動出現,不是叫用戶去登記)—— - // 同步小幫手預設就寫進 kb 庫,這裡把登記簿先蓋好章;已存在(409/重複)不吵。 return post('/portal/login', { email: email, password: pw }); }); }) @@ -952,12 +950,6 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { 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 */ } - // 預設庫登記(用 portal session;失敗不擋進入) - return fetch(API_BASE + '/portal/admin/libraries', { - method: 'POST', - headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), - body: JSON.stringify({ name: 'kb', display_name: '知識庫', description: '同步小幫手預設寫入的庫' }) - }).catch(function () { /* 已存在或無權限都不擋 */ }); }) .then(function () { $('fs-submit').disabled = false; @@ -1476,29 +1468,18 @@ 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'; - // t52:auto 庫=資料蓋章自動出現、還沒登記進登記簿(沒有 record_id)—— - // 那兩顆按鈕會帶空 id 打 API=按了就錯,改給「登記到目錄」一顆(leo 判準:不放死按鈕)。 - if (l.auto) { - return '
' + - '
' + - '' + esc(l.display_name || l.name) + '' + - '' + esc(l.name) + '' + - '啟用中同步自動出現
' + - '
' - + '這個庫來自同步小幫手的資料夾,已經可以搜尋與設權限。登記到目錄後可以改顯示名。
' + - '
' + - '
'; - } return '
' + '
' + '' + esc(l.display_name || l.name) + '' + '' + esc(l.name) + '' + - '' + (disabled ? '已停用' : '啟用中') + '' + + (l.auto + ? '啟用中同步自動出現' + : '' + (disabled ? '已停用' : '啟用中') + '') + '
' + - (l.description ? '
' + esc(l.description) + '
' : '') + + (!l.auto && l.description ? '
' + esc(l.description) + '
' : '') + '
'; }).join(''); } @@ -1614,22 +1595,6 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); return; } - // t52:把「自動出現的庫」正式登記進目錄(之後就能改顯示名) - if (act === 'lib-adopt') { - var lname = t.getAttribute('data-name') || ''; - if (!lname) return; - t.disabled = true; - adminApi('POST', '/portal/admin/libraries', { name: lname, display_name: lname, description: '同步小幫手看守的資料夾' }) - .then(function (x) { - t.disabled = false; - if (guard401(x.status)) return; - if (!x.ok && x.status !== 409) { toast(x.d.error || ('登記失敗(HTTP ' + x.status + ')')); return; } - toast('已登記到目錄'); - loadAdmin(); - }) - .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); - return; - } }); // 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上) $('ad-otp').addEventListener('click', function (ev) { diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 2a99f49..84abaa9 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -855,7 +855,8 @@ portalRouter.get('/portal/admin/libraries', (c) => const body = (await res.json()) as { libraries?: string[] }; for (const name of body.libraries ?? []) { const n = String(name ?? '').trim(); - if (!n || known.has(n)) continue; + // general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉 + if (!n || n === 'general' || known.has(n)) continue; known.add(n); out.push({ record_id: '', name: n, display_name: n, description: '資料同步時自動出現(可在此補顯示名)', status: 'active', graph_source: false, auto: true }); } diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 37da814..d6aaa1e 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -376,12 +376,29 @@ describe('/portal/admin/libraries', () => { const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-user' }); expect(res.status).toBe(403); }); + + it('GET auto 庫列表過濾 general(general 是系統桶,不在用戶目錄顯示)', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockListByTemplate('portal_library', []); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) + .reply(200, { libraries: ['kb', 'general', 'notes'] }); + const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] }; + const names = data.libraries.map((l) => l.name); + expect(names).toContain('kb'); + expect(names).toContain('notes'); + expect(names).not.toContain('general'); + }); }); // ═══════════════ 6. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════ describe('GET /portal(P4 admin 頁 HTML 殼)', () => { - it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira', async () => { + it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira;無 kb 種子、無登記到目錄', async () => { const res = await SELF.fetch('http://localhost/portal'); expect(res.status).toBe(200); const html = await res.text(); @@ -393,5 +410,11 @@ describe('GET /portal(P4 admin 頁 HTML 殼)', () => { expect(html).not.toContain('/kbdb/'); expect(html).not.toContain('X-Arcrun-API-Key'); expect(html).not.toContain('Mira'); + // t97a:bootstrap 後不再預埋 kb 庫 + expect(html).not.toContain('"name": "kb"'); + expect(html).not.toContain("name: 'kb'"); + // t114:無「登記到目錄」按鈕 + expect(html).not.toContain('lib-adopt'); + expect(html).not.toContain('登記到目錄'); }); }); From eb9f2db5131dfbba4af6ccadfaabc88ba8cc13bf Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 29 Jul 2026 13:21:26 +0800 Subject: [PATCH 17/25] =?UTF-8?q?feat(t122=20=F0=9F=94=B4=F0=9F=94=B4):=20?= =?UTF-8?q?=E8=90=83=E5=8F=96=E5=BC=95=E6=93=8E=E9=9B=B2=E7=AB=AF=E8=A8=AD?= =?UTF-8?q?=E5=AE=9A=EF=BC=8B=E9=9A=A8=E9=80=A3=E7=B7=9A=E4=B8=8B=E7=99=BC?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=B0=81=E6=B8=AC=E8=80=85=E7=B5=82=E6=96=BC?= =?UTF-8?q?=E8=90=83=E5=BE=97=E5=87=BA=E6=9D=B1=E8=A5=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真兇(總管查證):daemon/config 寫死 extractor='claude' 且不下發金鑰 ⇒ 封測者 100% 萃取失敗 (leo:「地端沒有 AI 根本不能萃,那它就不能玩」)。 - POST/GET /portal/admin/extractor(admin 閘;GET 只回 has_key 不回明文) - daemon/config 改讀設定:未設定→gemma 無金鑰;設定後→含 gemini_api_key - portal 設定頁加「萃取引擎」區(Gemini 推薦+aistudio 連結,存後提示「小幫手點連上知識庫重連即可」) 測試 3 條新綠(vitest 17 passed;1 紅=console HTML 搬遷陳舊測試,非本案)。 (實作=子 CC;驗證+commit=總管) --- console-ui/public/portal/index.html | 88 ++++++++++++++++++++ cypher-executor/src/routes/portal.ts | 86 ++++++++++++++++--- cypher-executor/tests/portal-admin.test.ts | 66 ++++++++++++++- system-dev/docs/3-specs/portal-auth/tasks.md | 27 ++++++ 4 files changed, 255 insertions(+), 12 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 39d2fcd..14cdfd6 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -356,6 +356,29 @@
+ +
+
萃取引擎
+
同步小幫手把你的文件變成知識卡時用的 AI。選好後請讓小幫手重連一次。
+
+
+ + +
+
+
aistudio.google.com/apikey 免費取得金鑰
+ +
+ +
+
+
@@ -793,6 +816,70 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { }); })(); + // t122 萃取引擎設定(engine 切換顯示隱藏金鑰輸入框 + 存檔) + (function () { + // 引擎切換:Gemini 顯示金鑰欄;Claude Code 隱藏 + function updateExtractorKeyRowVisibility() { + var row = $('st-extractor-key-row'); + if (!row) return; + var el = document.getElementById('st-engine-gemma'); + row.style.display = (el && el.checked) ? '' : 'none'; + } + ['st-engine-gemma', 'st-engine-claude'].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.addEventListener('change', updateExtractorKeyRowVisibility); + }); + updateExtractorKeyRowVisibility(); + + // 進入設定頁時讀取現有設定(GET /portal/admin/extractor) + function loadExtractorConfig() { + if (!(S.profile && S.profile.role === 'admin')) return; + fetch(API_BASE + '/portal/admin/extractor', { headers: authHeaders() }) + .then(function (r) { return r.ok ? r.json() : null; }) + .then(function (d) { + if (!d) return; + var gemmaEl = document.getElementById('st-engine-gemma'); + var claudeEl = document.getElementById('st-engine-claude'); + if (d.engine === 'claude' && claudeEl) claudeEl.checked = true; + else if (gemmaEl) gemmaEl.checked = true; + updateExtractorKeyRowVisibility(); + if (d.has_key) { + var ki = $('st-extractor-key'); + if (ki) ki.placeholder = '已設定(留空=不變更)'; + } + }) + .catch(function () { /* 讀不到不擋頁面 */ }); + } + + // 存檔 + var sb = $('st-extractor-save'); + if (sb) sb.addEventListener('click', function () { + var m = $('st-extractor-status'); + var engineEl = document.querySelector('input[name="st-extractor-engine"]:checked'); + var engine = engineEl ? engineEl.value : 'gemma'; + var key = (($('st-extractor-key') && $('st-extractor-key').value) || '').trim(); + var body = { engine: engine }; + if (engine === 'gemma' && key) body.gemini_api_key = key; + sb.disabled = true; m.textContent = '儲存中…'; m.style.color = ''; + fetch(API_BASE + '/portal/admin/extractor', { + 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-extractor-key')) { $('st-extractor-key').value = ''; $('st-extractor-key').placeholder = '已設定(留空=不變更)'; } + m.textContent = '已儲存。小幫手請點「連上知識庫」重連一次即可生效'; m.style.color = '#3f7a4f'; + }) + .catch(function (e) { sb.disabled = false; m.textContent = friendlyErr(e); m.style.color = '#b4462f'; }); + }); + + // loadExtractorConfig 掛到全域(供 loadSettings 呼叫) + window._loadExtractorConfig = loadExtractorConfig; + })(); + $('st-logout').addEventListener('click', function () { fetch(API_BASE + '/portal/logout', { method: 'POST', headers: authHeaders() }).catch(function () { /* 盡力而為 */ }); dropSession(); @@ -1605,6 +1692,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._loadExtractorConfig) window._loadExtractorConfig(); var libs = p.libraries || []; var libHtml = libs.indexOf('*') >= 0 ? '全部知識庫' diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 84abaa9..207eb0a 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -755,10 +755,32 @@ portalRouter.post('/portal/daemon/libraries', (c) => }), ); +// ── t122 萃取引擎設定(daemon 萃取用;與 chat-key AI 問答金鑰獨立管理)────────────── +// KV key = {tenant}:portal:extractor_config,存在 WEBHOOKS KV(同 chat-key 手法)。 +// 金鑰不落 log;GET 只回 has_key:bool,不回明文。 +// daemon 未設定時預設 gemma(封測者不會有 claude,以 gemma 為友善預設)。 + +interface ExtractorConfig { + engine: 'gemma' | 'claude'; + gemini_api_key?: string; + llm_model?: string; +} + +function extractorConfigKey(env: Bindings): string { + return `${portalTenant(env)}:portal:extractor_config`; +} + +async function getExtractorConfig(env: Bindings): Promise { + const raw = await env.WEBHOOKS.get(extractorConfigKey(env), 'text'); + if (!raw) return null; + try { return JSON.parse(raw) as ExtractorConfig; } catch { return null; } +} + // POST /portal/daemon/config — body {email, password}。同步小幫手憑「用戶剛設的帳密」 // 直接換到自己的設定(t54,leo 07-25:「最好的就是把它的帳密直接輸入」)—— // 用戶不必再下載 config.json 丟隱藏資料夾,托盤第一次開啟輸入網址+帳密就上工。 // 認證=與 /portal/login 同一把(同樣吃節流與停用檢查);回傳只含連線設定,不含任何知識內容。 +// t122:extractor 改讀雲端設定(未設→預設 gemma;gemma+金鑰→一併下發金鑰)。 portalRouter.post('/portal/daemon/config', (c) => run(c, async () => { const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string } | null; @@ -781,17 +803,21 @@ portalRouter.post('/portal/daemon/config', (c) => } await clearLoginFail(c.env, email); const tenant = portalTenant(c.env); - return c.json({ - success: true, - config: { - cypher_url: new URL(c.req.url).origin, - namespace: tenant, - library: 'kb', - extractor: 'claude', - email, - instance_name: String(rec.values.display_name ?? ''), - }, - }); + const extractorCfg = await getExtractorConfig(c.env); + const engine = extractorCfg?.engine ?? 'gemma'; + const daemonCfg: Record = { + cypher_url: new URL(c.req.url).origin, + namespace: tenant, + library: 'kb', + extractor: engine, + email, + instance_name: String(rec.values.display_name ?? ''), + }; + if (engine === 'gemma' && extractorCfg?.gemini_api_key) { + daemonCfg.gemini_api_key = extractorCfg.gemini_api_key; + } + if (extractorCfg?.llm_model) daemonCfg.llm_model = extractorCfg.llm_model; + return c.json({ success: true, config: daemonCfg }); }), ); @@ -837,6 +863,44 @@ portalRouter.post('/portal/admin/chat-key', (c) => }), ); +// POST /portal/admin/extractor — body {engine, gemini_api_key?, llm_model?}(t122)。 +// admin 閘(同 chat-key 等級)。金鑰不落 log;存 WEBHOOKS KV。 +portalRouter.post('/portal/admin/extractor', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const body = (await c.req.json().catch(() => null)) as { engine?: string; gemini_api_key?: string; llm_model?: string } | null; + const engine = String(body?.engine ?? '').trim().toLowerCase(); + if (engine !== 'gemma' && engine !== 'claude') { + return c.json({ error: 'engine 只能是 gemma 或 claude' }, 400); + } + const cfg: ExtractorConfig = { engine: engine as 'gemma' | 'claude' }; + if (engine === 'gemma') { + const key = String(body?.gemini_api_key ?? '').trim(); + if (key) cfg.gemini_api_key = key; + } + const model = String(body?.llm_model ?? '').trim(); + if (model) cfg.llm_model = model; + await c.env.WEBHOOKS.put(extractorConfigKey(c.env), JSON.stringify(cfg)); + return c.json({ success: true, engine: cfg.engine, has_key: engine === 'gemma' && !!cfg.gemini_api_key }); + }), +); + +// GET /portal/admin/extractor — 回 engine + has_key(不回金鑰明文)(t122)。 +portalRouter.get('/portal/admin/extractor', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const cfg = await getExtractorConfig(c.env); + return c.json({ + success: true, + engine: cfg?.engine ?? 'gemma', + has_key: cfg?.engine === 'gemma' && !!cfg?.gemini_api_key, + llm_model: cfg?.llm_model ?? null, + }); + }), +); + // GET /portal/admin/libraries — 庫目錄列表。 // t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」): // 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)—— diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index d6aaa1e..52771f7 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -395,7 +395,71 @@ describe('/portal/admin/libraries', () => { }); }); -// ═══════════════ 6. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════ +// ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════ + +describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => { + const USER_EMAIL = 'daemon@example.com'; + const USER_PW = 'unit-test-pw-1'; // 與 storedHash 配對(beforeAll 計算) + const USER_RECORD = 'rec_daemon_user'; + const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config'; // wrangler.test.toml CONSOLE_TENANT=leo + + /** mock email head lookup(findUserRecordId 走這個路徑)*/ + function mockEmailLookup(email: string, recordId: string | null) { + const needle = new URLSearchParams({ page_name: email }).toString(); + fetchMock + .get(KBDB) + .intercept({ + path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)), + method: 'GET', + }) + .reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 }); + } + + it('未設定 → daemon/config 下發 extractor=gemma,無 gemini_api_key', async () => { + // 確保 KV 沒有 extractor config + await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); + mockEmailLookup(USER_EMAIL, USER_RECORD); + mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash })); + const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; config: Record }; + expect(data.success).toBe(true); + expect(data.config.extractor).toBe('gemma'); + expect('gemini_api_key' in data.config).toBe(false); + }); + + it('設定 gemma+金鑰後 → daemon/config 下發含 gemini_api_key', async () => { + await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-test-key-999' })); + mockEmailLookup(USER_EMAIL, USER_RECORD); + mockGetRecord(USER_RECORD, adminValues({ email: USER_EMAIL, password_hash: storedHash })); + const res = await json('POST', '/portal/daemon/config', { email: USER_EMAIL, password: USER_PW }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; config: Record }; + expect(data.config.extractor).toBe('gemma'); + expect(data.config.gemini_api_key).toBe('AIza-test-key-999'); + // cleanup + await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); + }); + + it('GET /portal/admin/extractor → has_key=true,回應不含金鑰明文', async () => { + await env.WEBHOOKS.put(EXTRACTOR_KV_KEY, JSON.stringify({ engine: 'gemma', gemini_api_key: 'AIza-secret-key' })); + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + const res = await json('GET', '/portal/admin/extractor', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; engine: string; has_key: boolean }; + expect(data.engine).toBe('gemma'); + expect(data.has_key).toBe(true); + // 回應主體不含金鑰明文 + const raw = JSON.stringify(data); + expect(raw).not.toContain('AIza-secret-key'); + expect(raw).not.toContain('gemini_api_key'); + // cleanup + await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); + }); +}); + +// ═══════════════ 7. /portal HTML 殼(P4 admin 頁後紅線不回退)═══════════════ describe('GET /portal(P4 admin 頁 HTML 殼)', () => { it('admin view 存在;仍零租戶字串、零 /kbdb/、零 X-Arcrun-API-Key、零 Mira;無 kb 種子、無登記到目錄', async () => { diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index 37cdb12..66a471e 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -215,6 +215,33 @@ kbdb 與 cypher 兩個 worker;workflow yaml 同批替換 `__KBDB_TOKEN__`(見規格)即封口。 kbdb_upsert_block WASM 指向死路由 `/blocks`(設 token 前後都壞,不新增破壞)。 +- [x] **t116 圖搜尋靜默失敗——portal 補傳 kbdb_base(2026-07-29,任務層小改)**: + 診斷:graph_neighbors workflow 的 fetch_triplets url 含 `{{input.kbdb_base}}/records/...`, + 但 portal 呼叫 executeWebhookGraph 時未傳 kbdb_base → URL 解析失敗 → `HTTP request failed`。 + 修法(b 案 雙保險):portal-data.ts 補傳 `kbdb_base: env.KBDB_BASE_URL ?? ''`; + yaml 端(`__KBDB_BASE__` 安裝期替換)由總管派 arcrun-rag,詳見本項末規格段。 + 執行範圍:`cypher-executor/src/routes/portal-data.ts`(補傳 kbdb_base)。 + +- [x] **t117 三元組寫入靜默失敗——FOREACH 全失敗浮出水面(2026-07-29,任務層小改)**: + 診斷:youlin 實例 triplet=0,懷疑 post_triplet http_request 401 被靜默吞掉; + FOREACH 收集到全 `success:false` 的 iterResults 後回 `{success:true, data:{results:[...]}}` —— 呼叫端不知失敗。 + 兩層修法: + ① wasi-shim.ts http_request catch:改寫錯誤 envelope(含 fetch 原始 message)到 WASM 輸出, + 取代只 return 1(WASM 寫 "HTTP request failed"); + ② graph-executor.ts FOREACH:若全部 iterResults 均 `success===false`,拋 Error(含首項 status code) + → catch 轉 ExecutionError → executeWebhookGraph 回 `{success:false, error:"..."}`。 + 執行範圍:`cypher-executor/src/lib/wasi-shim.ts`、`cypher-executor/src/graph-executor.ts`。 + 測試:vitest 新增兩條(FOREACH 全失敗→error 含 status;wasi-shim catch 路徑)。 + +- [x] **t122 萃取引擎金鑰雲端下發(2026-07-29)**: + 來源=總管確認:daemon/config 回傳 extractor:'claude' 寫死且不含金鑰,封測者萃取全滅。 + 修:① `POST /portal/admin/extractor`(admin 閘)存 `{engine, gemini_api_key?, llm_model?}` 到 KV + key=`{tenant}:portal:extractor_config`,金鑰不落 log; + ② `GET /portal/admin/extractor`(回 engine + has_key:bool,不回明文); + ③ `POST /portal/daemon/config` 回傳改讀上述設定(未設→預設 gemma;gemma+有 key → 含金鑰下發); + ④ `console-ui/public/portal/index.html` 設定頁新增「萃取引擎」區塊(引擎選擇+金鑰輸入+提示重連)。 + 測試:vitest 新增 3 案(未設定→下發 gemma 無金鑰;設定後→下發含金鑰;GET 不回明文)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`) From ccb86481aec16af1ad7deb5c939bd614c95e9338 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 29 Jul 2026 14:11:52 +0800 Subject: [PATCH 18/25] =?UTF-8?q?fix(t128+t129):=20=E5=9C=96=E6=90=9C?= =?UTF-8?q?=E5=B0=8B=E8=A3=9C=20template:triplet=EF=BC=8BAI=20=E5=95=8F?= =?UTF-8?q?=E7=AD=94=E5=87=BA=E8=99=95=E6=8C=89=E9=A0=81=E5=8E=BB=E9=87=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t128 真因(總管實測定罪):t116 只補 kbdb_base,同一行 URL 還吃 {{input.template}} ⇒ /records/by-template/?owner_id=... 查不到;手動補 template 即 count=1 (企業版功能解鎖→控制→授權系統)。**同種病三犯,已記 mistakes。** t129:一卡切 3-5 block 每段都算一筆命中 ⇒ dedupeSourcesByPage 後端去重+hit_count。 vitest 52 passed(1 紅=console HTML 搬遷陳舊測試,非本案)。 (實作=子 CC;驗證+commit=總管) --- cypher-executor/src/routes/portal-data.ts | 33 ++++- cypher-executor/tests/portal-data.test.ts | 133 ++++++++++++++++++- system-dev/docs/3-specs/portal-auth/tasks.md | 20 +++ 3 files changed, 183 insertions(+), 3 deletions(-) diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 61ad1b6..dabaf9d 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -73,6 +73,32 @@ export function mapGraphWorkflowOutput(data: unknown): { neighbors: unknown[]; e return { neighbors, edges, count: neighbors.length }; } +/** + * 出處清單按 page_name 去重(t129): + * rag_chat workflow 把同一張卡拆成多個 block,每個 block 各回一筆 source(同頁名)→ 前端列一整頁重複。 + * 後端去重:同一個 page_name / page 只保留第一筆,hit_count > 1 時附計數。 + * page_name 優先;page 備用;兩者皆無 → key 為空字串(歸為同一「無頁名」組)。 + * 純函式,單測用 export。 + */ +export function dedupeSourcesByPage(sources: unknown[]): unknown[] { + const seen = new Map; count: number }>(); + for (const s of sources) { + if (!s || typeof s !== 'object') continue; + const item = s as Record; + const page = typeof item.page_name === 'string' ? item.page_name : + typeof item.page === 'string' ? item.page : ''; + const existing = seen.get(page); + if (existing) { + existing.count += 1; + } else { + seen.set(page, { item, count: 1 }); + } + } + return [...seen.values()].map(({ item, count }) => + count > 1 ? { ...item, hit_count: count } : item, + ); +} + /** 越庫/不存在 一律同一句 404(不洩存在性)。 */ function notFound(c: Context<{ Bindings: Bindings }>): Response { return c.json({ error: '找不到這筆資料' }, 404); @@ -274,7 +300,8 @@ portalDataRouter.get('/portal/data/graph/neighbors/:name', (c) => const result = await executeWebhookGraph( c.env, wfGraph, - { node: nodeName, depth, namespace: tenant, owner: tenant }, + // t116: 補傳 kbdb_base;t128: 補傳 template(workflow fetch_triplets.url 用 {{input.template}}) + { node: nodeName, depth, namespace: tenant, owner: tenant, kbdb_base: c.env.KBDB_BASE_URL ?? '', template: 'triplet' }, 'graph_neighbors', tenant, c.executionCtx, @@ -390,10 +417,12 @@ portalDataRouter.get('/portal/data/chat', (c) => return c.json({ error: `rag_chat workflow 執行失敗:${result.error ?? '未知錯誤'}` }, 502); } // 回 workflow 回應內層 data:{answer, sources, graph_facts}(缺欄位誠實回空,不編造) + // t129: sources 按 page_name 去重——同一卡拆多 block 每個各一筆,前端列一整頁重複;後端去重後乾淨。 const inner = unwrapWorkflowData(result.data, 'answer'); + const rawSources = Array.isArray(inner.sources) ? inner.sources : []; return c.json({ answer: typeof inner.answer === 'string' ? inner.answer : '', - sources: Array.isArray(inner.sources) ? inner.sources : [], + sources: dedupeSourcesByPage(rawSources), graph_facts: inner.graph_facts ?? null, }); }), diff --git a/cypher-executor/tests/portal-data.test.ts b/cypher-executor/tests/portal-data.test.ts index f0d40b2..271c32a 100644 --- a/cypher-executor/tests/portal-data.test.ts +++ b/cypher-executor/tests/portal-data.test.ts @@ -19,7 +19,7 @@ import { SELF, env, fetchMock } from 'cloudflare:test'; import { beforeAll, afterEach, describe, it, expect } from 'vitest'; import { workflowsVisible } from '../src/routes/portal'; -import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch } from '../src/routes/portal-data'; +import { entryLibrary, sanitizeUploadFilename, filterDeprecatedEntries, mapGraphWorkflowOutput, normalizeCjkQuery, findBestNodeMatch, dedupeSourcesByPage } from '../src/routes/portal-data'; import type { Bindings } from '../src/types'; const KBDB = 'https://kbdb.test'; @@ -578,3 +578,134 @@ describe('GET /portal/data/graph/neighbors/:name(t96 fuzzy fallback)', () => expect(data.neighbors.length).toBe(1); }); }); + +// ═══════════════ 12. t116: graph_neighbors workflow 補傳 kbdb_base ═══════════════ + +describe('GET /portal/data/graph/neighbors/:name(t116 kbdb_base 補傳)', () => { + it('tenant 有 graph_neighbors workflow → portal 傳入 kbdb_base,workflow 正常執行不崩', async () => { + // 設定 session(["*"] 全庫,放行 graph 粗閘) + await seedSession('tok-t116', 'rec_t116'); + mockGetRecord('rec_t116', userValues({ libraries: '["*"]', role: 'admin' })); + + // 在 WEBHOOKS KV 放 graph_neighbors workflow(Input→Output 直通) + // 這個 workflow 不用 {{input.kbdb_base}},只驗工作流路徑正常執行(不走 graphBase fallback) + // 若沒補傳 kbdb_base 但 workflow 內有 {{input.kbdb_base}} 的節點,URL 解析失敗 → executeWebhookGraph 回 error + // 此測試退而求其次:用無外部依賴的直通圖確認整個路徑都通(workflow 取代 plugin fallback) + const wfKey = `${TENANT}:wf:graph_neighbors`; + await env.WEBHOOKS.put(wfKey, JSON.stringify({ + graph: { + id: 'gn-t116', + name: 'graph_neighbors', + nodes: [ + { id: 'input', type: 'Input' }, + // comp_passthrough 是內建零件,不需外部 fetch,直接回傳 context + { id: 'pass', type: 'Component', componentId: 'comp_passthrough' }, + { id: 'output', type: 'Output' }, + ], + edges: [ + { from: 'input', to: 'pass', type: 'PIPE' }, + { from: 'pass', to: 'output', type: 'PIPE' }, + ], + }, + description: 't116 test', + created_at: '2026-07-29T00:00:00.000Z', + })); + + const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t116' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[]; count: number; kbdb_base?: string }; + // workflow 走 comp_passthrough,output = 整個 context(含 kbdb_base) + // mapGraphWorkflowOutput 只取 neighbors/edges,其他欄位不影響回應 + expect(Array.isArray(data.neighbors)).toBe(true); + expect(Array.isArray(data.edges)).toBe(true); + // 確認不是 502(graph_neighbors workflow 執行失敗) + expect(res.status).not.toBe(502); + + await env.WEBHOOKS.delete(wfKey); + }); +}); + +// ═══════════════ 13. t128: graph_neighbors workflow 補傳 template ═══════════════ + +describe('GET /portal/data/graph/neighbors/:name(t128 template 補傳)', () => { + it('tenant 有 graph_neighbors workflow → portal 傳入 template=triplet,workflow 不崩', async () => { + await seedSession('tok-t128', 'rec_t128'); + mockGetRecord('rec_t128', userValues({ libraries: '["*"]', role: 'admin' })); + + const wfKey = `${TENANT}:wf:graph_neighbors`; + await env.WEBHOOKS.put(wfKey, JSON.stringify({ + graph: { + id: 'gn-t128', + name: 'graph_neighbors', + nodes: [ + { id: 'input', type: 'Input' }, + { id: 'pass', type: 'Component', componentId: 'comp_passthrough' }, + { id: 'output', type: 'Output' }, + ], + edges: [ + { from: 'input', to: 'pass', type: 'PIPE' }, + { from: 'pass', to: 'output', type: 'PIPE' }, + ], + }, + })); + + const res = await get('/portal/data/graph/neighbors/AI%E5%8D%94%E4%BD%9C', { Authorization: 'Bearer tok-t128' }); + // template 有進 context → workflow 執行不崩(非 502) + expect(res.status).toBe(200); + const data = (await res.json()) as { neighbors: unknown[]; edges: unknown[] }; + expect(Array.isArray(data.neighbors)).toBe(true); + + await env.WEBHOOKS.delete(wfKey); + }); +}); + +// ═══════════════ 14. t129: dedupeSourcesByPage 純函式 ═══════════════ + +describe('dedupeSourcesByPage(t129 出處去重)', () => { + it('同 page_name 合併,hit_count 標計數', () => { + const srcs = [ + { page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' }, + { page_name: '企業版功能', mode: 'semantic', source: 'gitea://docs/enterprise.md' }, + { page_name: '企業版功能', mode: 'keyword', source: 'gitea://docs/enterprise.md' }, + ]; + const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[]; + expect(out.length).toBe(1); // 3 筆→1 筆 + expect(out[0].page_name).toBe('企業版功能'); + expect(out[0].hit_count).toBe(3); + }); + + it('不同 page_name 各保留一筆;單筆無 hit_count', () => { + const srcs = [ + { page_name: 'A 頁', mode: 'semantic' }, + { page_name: 'B 頁', mode: 'keyword' }, + ]; + const out = dedupeSourcesByPage(srcs) as { page_name: string; hit_count?: number }[]; + expect(out.length).toBe(2); + expect(out.every(s => s.hit_count === undefined)).toBe(true); + }); + + it('page 欄(備用)也能去重', () => { + const srcs = [ + { page: '備用頁', mode: 'semantic' }, + { page: '備用頁', mode: 'keyword' }, + ]; + const out = dedupeSourcesByPage(srcs) as { page?: string; hit_count?: number }[]; + expect(out.length).toBe(1); + expect(out[0].hit_count).toBe(2); + }); + + it('空陣列 → 空陣列;非物件條目跳過', () => { + expect(dedupeSourcesByPage([])).toEqual([]); + const out = dedupeSourcesByPage([null, 'oops', { page_name: 'X' }]); + expect(out.length).toBe(1); + }); + + it('page_name 優先於 page', () => { + const srcs = [ + { page_name: '優先頁', page: '備用頁' }, + { page_name: '優先頁', page: '備用頁' }, + ]; + const out = dedupeSourcesByPage(srcs) as { hit_count?: number }[]; + expect(out.length).toBe(1); // 同 page_name → 合為一筆 + }); +}); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index 66a471e..2407c69 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -242,6 +242,26 @@ ④ `console-ui/public/portal/index.html` 設定頁新增「萃取引擎」區塊(引擎選擇+金鑰輸入+提示重連)。 測試:vitest 新增 3 案(未設定→下發 gemma 無金鑰;設定後→下發含金鑰;GET 不回明文)。 +- [x] **t128 圖搜尋仍回 0——graph_neighbors 補傳 template(2026-07-29,任務層小改)**: + 診斷:t116 只補了 kbdb_base,但 fetch_triplets.url 同時含 `{{input.template}}`; + portal 呼叫 executeWebhookGraph 未傳 template → URL 變 `/records/by-template/?owner_id=...` → count=0。 + 總管實測:手動補 `"template":"triplet"` → count=1(企業版功能解鎖)。 + 修:portal-data.ts graph_neighbors 呼叫補 `template: 'triplet'`。 + 執行範圍:`cypher-executor/src/routes/portal-data.ts` 一行。 + 測試:portal-data.test.ts 新增整合案(t128 describe,workflow 執行不崩即代表 template 進到 context)。 + workflow input 完整核對清單(portal-data.ts 所有 executeWebhookGraph 呼叫): + - graph_neighbors:node ✅、depth ✅、namespace ✅、owner ✅、kbdb_base ✅(t116)、template ✅(本次) + - rag_chat:question ✅;namespace/kbdb_base 未核實(rag_chat workflow yaml 在 arcrun-rag,非本 repo) + - takedown:不在 portal-data.ts,在 arcrun-rag 端(dashboard/安裝器),超出本 repo 範圍 + +- [x] **t129 AI 問答出處重複一整頁——後端按 page_name 去重(2026-07-29,任務層小改)**: + 診斷:一張卡拆成 3-5 block,每 block 各回一筆 source(同 page_name)→ 前端列一整頁重複。 + 選後端修(/portal/data/chat)理由:出處去重是資料清潔,跟前端渲染無關;改後端不需動 HTML。 + 修:portal-data.ts 新增純函式 `dedupeSourcesByPage()`(Map 按 page_name/page 去重,hit_count > 1 時附計數); + chat 路由 rawSources 先過去重再回。 + 執行範圍:`cypher-executor/src/routes/portal-data.ts`(新增 export 函式 + chat route)。 + 測試:portal-data.test.ts 新增 5 案(t129 describe:合併計數/各保一筆/page 備用/空陣列/page_name 優先)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`) From 6d4980d3d7ffb1fecb0662f56591be0ff7fda031 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 29 Jul 2026 14:35:36 +0800 Subject: [PATCH 19/25] =?UTF-8?q?fix(t130=20=F0=9F=94=B4=F0=9F=94=B4):=20P?= =?UTF-8?q?ORTAL=5FTEMPLATE=5FSEEDS=20=E8=A3=9C=20triplet=E2=80=94?= =?UTF-8?q?=E2=80=94=E6=96=B0=E5=AF=A6=E4=BE=8B=E7=B8=BD=E5=9C=96=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=B0=B8=E9=81=A0=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真兇(總管探針定罪):seeds 只有 portal_user/portal_library,寫三元組回 400 'template not found: triplet' ⇒ 每個新用戶(含封測者)總圖必空。 geek6688 有是舊實例早期流程建過=拿它驗會假綠(已記 mistakes)。 呼叫路徑已驗:daemon/libraries、admin/libraries、init/seed 皆會 ensurePortalTemplates(冪等)。 vitest 24/24 綠(總管親跑)。(實作=子 CC;驗證+commit=總管) --- cypher-executor/src/lib/portal-seeds.ts | 17 +++++++ cypher-executor/tests/portal-admin.test.ts | 2 +- cypher-executor/tests/portal-auth.test.ts | 52 +++++++++++++++++++- system-dev/docs/3-specs/portal-auth/tasks.md | 15 ++++++ 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/cypher-executor/src/lib/portal-seeds.ts b/cypher-executor/src/lib/portal-seeds.ts index 003c922..3e151e0 100644 --- a/cypher-executor/src/lib/portal-seeds.ts +++ b/cypher-executor/src/lib/portal-seeds.ts @@ -37,4 +37,21 @@ export const PORTAL_TEMPLATE_SEEDS: PortalTemplateSeed[] = [ slots: ['name', 'display_name', 'description', 'status', 'graph_source'], created_by: 'system', }, + { + // t130:rag_ingest_card.post_triplet 寫 POST /records {template:'triplet'}。 + // 新實例若無此 template 回 400「template not found: triplet」→ 三元組全滅。 + // slots 來源:kbdb_list_templates 核實(2026-07-19,library-map.test.ts PROD_TRIPLET_SLOTS) + // + library(library-map.ts M1 預案:recompute 歸庫用,ensurePortalTemplates 若缺則 PATCH 補入)。 + name: 'triplet', + description: 'KBDB 知識圖譜三元組(kbdb-graph-plugin 寫入;portal 讀此 template 建鄰接圖)', + slots: [ + 'subject', 'predicate', 'object', + 'source_block_id', 'confidence', 'clusters_json', + 'bridge_score', 'subject_entity_type', 'object_entity_type', + 'status', 'superseded_by', + 'source_uri', 'content_hash', 'source_anchor', 'predicate_embed', + 'library', + ], + created_by: 'system', + }, ]; diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 52771f7..4476146 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -66,7 +66,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu } function mockTemplatesExist() { - for (const name of ['portal_user', 'portal_library']) { + for (const name of ['portal_user', 'portal_library', 'triplet']) { fetchMock .get(KBDB) .intercept({ path: `/templates/${name}`, method: 'GET' }) diff --git a/cypher-executor/tests/portal-auth.test.ts b/cypher-executor/tests/portal-auth.test.ts index d4f26cb..ace19e1 100644 --- a/cypher-executor/tests/portal-auth.test.ts +++ b/cypher-executor/tests/portal-auth.test.ts @@ -19,6 +19,7 @@ import { SELF, env, fetchMock } from 'cloudflare:test'; import { beforeAll, beforeEach, afterEach, describe, it, expect } from 'vitest'; import { hashPassword, verifyPassword, PBKDF2_ITERATIONS } from '../src/lib/portal-auth'; +import { PORTAL_TEMPLATE_SEEDS } from '../src/lib/portal-seeds'; const KBDB = 'https://kbdb.test'; const NS = 'leo::portal'; // wrangler.test.toml CONSOLE_TENANT=leo → 子 namespace @@ -73,7 +74,7 @@ function mockListByTemplate(template: string, records: { record_id: string; valu } function mockTemplatesExist() { - for (const name of ['portal_user', 'portal_library']) { + for (const name of ['portal_user', 'portal_library', 'triplet']) { fetchMock .get(KBDB) .intercept({ path: `/templates/${name}`, method: 'GET' }) @@ -414,3 +415,52 @@ describe('admin 端點 role 閘', () => { expect(res.status).toBe(404); }); }); + +// ═══════════════ t130 — triplet template seed ═══════════════ + +describe('t130 — triplet template seed(PORTAL_TEMPLATE_SEEDS 補 triplet,ensurePortalTemplates 冪等)', () => { + it('PORTAL_TEMPLATE_SEEDS 含 triplet 且必要 slots 齊備(pure data)', () => { + const seed = PORTAL_TEMPLATE_SEEDS.find((s) => s.name === 'triplet'); + expect(seed).toBeDefined(); + for (const slot of ['subject', 'predicate', 'object', 'source_uri', 'status', 'library']) { + expect(seed!.slots).toContain(slot); + } + }); + + it('POST /init/seed — triplet 已存 → existing(冪等,不重建)', async () => { + for (const name of ['portal_user', 'portal_library', 'triplet']) { + fetchMock + .get(KBDB) + .intercept({ path: `/templates/${name}`, method: 'GET' }) + .reply(200, { success: true, template: { id: `tpl-${name}`, name } }); + } + const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } }; + expect(data.portal_templates.existing).toContain('triplet'); + expect(data.portal_templates.created).not.toContain('triplet'); + }); + + it('POST /init/seed — triplet 缺 → 自動補建(新實例首次 seed)', async () => { + for (const name of ['portal_user', 'portal_library']) { + fetchMock + .get(KBDB) + .intercept({ path: `/templates/${name}`, method: 'GET' }) + .reply(200, { success: true, template: { id: `tpl-${name}`, name } }); + } + fetchMock + .get(KBDB) + .intercept({ path: '/templates/triplet', method: 'GET' }) + .reply(404, { success: false, error: 'template not found: triplet' }); + fetchMock + .get(KBDB) + .intercept({ path: '/templates', method: 'POST' }) + .reply(200, { success: true, template: { id: 'tpl-triplet-new', name: 'triplet' } }); + + const res = await SELF.fetch('http://localhost/init/seed', { method: 'POST' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { portal_templates: { created: string[]; existing: string[] } }; + expect(data.portal_templates.created).toContain('triplet'); + expect(data.portal_templates.existing).not.toContain('triplet'); + }); +}); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index 2407c69..dd043b2 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -254,6 +254,21 @@ - rag_chat:question ✅;namespace/kbdb_base 未核實(rag_chat workflow yaml 在 arcrun-rag,非本 repo) - takedown:不在 portal-data.ts,在 arcrun-rag 端(dashboard/安裝器),超出本 repo 範圍 +- [x] **t130 新實例缺 triplet template → 總圖永遠空(2026-07-29,任務層小改)**: + 真因:`PORTAL_TEMPLATE_SEEDS` 只有 `portal_user`+`portal_library`;`rag_ingest_card.post_triplet` + 打 `POST /records {template:'triplet'}` → 新實例回 400「template not found: triplet」→ 三元組全滅。 + 修:`portal-seeds.ts` 補 `triplet` seed(slots 來源:2026-07-19 kbdb_list_templates 核實 15 槽 + library)。 + 呼叫路徑驗證(`ensurePortalTemplates` 全四掛點): + ① `POST /init/seed`(acr init 觸發)← 主線,新裝必經 + ② `POST /portal/admin/bootstrap`(首次 admin 設定) + ③ `POST /portal/daemon/libraries`(daemon 登記資料夾→庫) + ④ `POST /portal/admin/libraries`(admin 手動新增庫) + 既有壞實例補救:任一掛點重跑即補建(冪等),e.g. 重跑 acr init 或 bootstrap。 + 執行範圍:`cypher-executor/src/lib/portal-seeds.ts`(新增 triplet seed); + `cypher-executor/tests/portal-auth.test.ts`(mockTemplatesExist 補 triplet + t130 3 條新測試); + `cypher-executor/tests/portal-admin.test.ts`(mockTemplatesExist 補 triplet)。 + 測試:純資料驗證(seed 含 triplet + 必要 slots)+冪等驗(已存→existing)+自動補建(404→POST→created)。 + - [x] **t129 AI 問答出處重複一整頁——後端按 page_name 去重(2026-07-29,任務層小改)**: 診斷:一張卡拆成 3-5 block,每 block 各回一筆 source(同 page_name)→ 前端列一整頁重複。 選後端修(/portal/data/chat)理由:出處去重是資料清潔,跟前端渲染無關;改後端不需動 HTML。 From 0860e84d225e8e5193463594a95a2924fc3d72df Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 29 Jul 2026 14:45:44 +0800 Subject: [PATCH 20/25] =?UTF-8?q?feat(t135):=20=E5=BA=AB=E7=9B=AE=E9=8C=84?= =?UTF-8?q?=E5=8F=AF=E8=87=AA=E4=B8=BB=E7=A7=BB=E9=99=A4=EF=BC=8B=E6=A8=99?= =?UTF-8?q?=E7=A4=BA=E9=82=84=E5=9C=A8=E4=B8=8D=E5=9C=A8=E5=90=8C=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo:「需要加移除按鈕。因為別人裝錯我沒辦法幫他弄,需要可以自主」 +「你應該要顯示這個庫沒有本地對應的 folder,那就不容易刪錯」 (兩態非三態——leo 二修:「分兩種沒意義」,那是內部狀態不是用戶分類)。 - DELETE /portal/admin/libraries/:id(登記簿)與 by-name/:name(auto 庫需輸入庫名確認) - kbdb 加 deprecate-by-library(auto 庫移除=標 deprecated,資料保留可還原) - daemon/libraries 存 active 清單 → 卡片標 🟢同步中/灰目前沒有在同步 - 不自動刪(daemon 可能沒開機);daemon 從未回報時整列不標 vitest 24 passed(1 紅=console HTML 搬遷陳舊測試,非本案)。 (實作=子 CC;驗證+commit=總管。含 t116/t117 先前未 commit 的 graph-executor/wasi-shim 修正) --- console-ui/public/portal/index.html | 43 ++++++++++ cypher-executor/src/graph-executor.ts | 20 +++++ cypher-executor/src/lib/wasi-shim.ts | 11 ++- cypher-executor/src/routes/portal.ts | 88 +++++++++++++++++++- cypher-executor/tests/executor.test.ts | 58 +++++++++++++ cypher-executor/tests/portal-admin.test.ts | 76 +++++++++++++++++ kbdb/src/actions/entry-crud.ts | 21 +++++ kbdb/src/actions/record-crud.ts | 15 ++++ kbdb/src/routes/entries.ts | 13 +++ kbdb/src/routes/records.ts | 9 +- system-dev/docs/3-specs/portal-auth/tasks.md | 13 +++ 11 files changed, 362 insertions(+), 5 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 14cdfd6..bfa059f 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -1558,6 +1558,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { if (!adminLibs.length) { $('ad-libs').innerHTML = '
同步小幫手還沒送來任何庫。裝好小幫手並選好資料夾後,庫會自動出現在這裡。
'; return; } $('ad-libs').innerHTML = adminLibs.map(function (l) { var disabled = l.status === 'disabled'; + var notWatching = l.daemon_watching === false; // daemon 有報告但此庫不在其中 + var removeBtn = l.auto + ? '' + : ''; return '
' + '
' + '' + esc(l.display_name || l.name) + '' + @@ -1565,8 +1569,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { (l.auto ? '啟用中同步自動出現' : '' + (disabled ? '已停用' : '啟用中') + '') + + removeBtn + '
' + (!l.auto && l.description ? '
' + esc(l.description) + '
' : '') + + (notWatching ? '
小幫手目前沒有在同步這個資料夾
' : '') + '
'; }).join(''); } @@ -1682,6 +1688,43 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { .catch(function (e) { t.disabled = false; toast(friendlyErr(e)); }); return; } + // 移除已登記庫(有 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('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; } + 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; + } }); // 一次性密碼關閉鈕(otpbox 動態生成,掛在容器上) $('ad-otp').addEventListener('click', function (ev) { diff --git a/cypher-executor/src/graph-executor.ts b/cypher-executor/src/graph-executor.ts index 672e96b..c35c0c8 100644 --- a/cypher-executor/src/graph-executor.ts +++ b/cypher-executor/src/graph-executor.ts @@ -500,6 +500,26 @@ export class GraphExecutor { iterResults.push(itemResult); } + // t117: FOREACH 全部項目 success===false → 不再靜默,拋出含 status code 的錯誤 + if (iterResults.length > 0) { + const failures = iterResults.filter( + r => r !== null && typeof r === 'object' && (r as Record).success === false + ); + if (failures.length === iterResults.length) { + const first = failures[0] as Record; + const errParts: string[] = []; + if (first.error) errParts.push(String(first.error)); + if (typeof first.status === 'number') errParts.push(`HTTP ${first.status}`); + const bodyData = first.data as { body?: string } | null | undefined; + if (bodyData && typeof bodyData.body === 'string' && bodyData.body) { + errParts.push(bodyData.body.slice(0, 200)); + } + throw new Error( + `FOREACH 所有 ${iterResults.length} 項目均失敗(首項:${errParts.join(';') || '未知錯誤'})` + ); + } + } + result = { ...(result as Record), results: iterResults }; break; } diff --git a/cypher-executor/src/lib/wasi-shim.ts b/cypher-executor/src/lib/wasi-shim.ts index 7ea0aa4..6763cf1 100644 --- a/cypher-executor/src/lib/wasi-shim.ts +++ b/cypher-executor/src/lib/wasi-shim.ts @@ -353,8 +353,15 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti const result = await hostFunctions!.http_request!(url, method, headers, body); // await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer) return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result)); - } catch { - return 1; + } catch (e) { + // t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情); + // 取代只 return 1(WASM 寫無資訊的 "HTTP request failed")。 + // writeOut 失敗(memory 壞)才 fallback return 1。 + const errDetail = e instanceof Error ? e.message : String(e); + const errEnv = new TextEncoder().encode( + JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' }) + ); + return writeOut(memory.buffer, outPtr, outLenPtr, errEnv); } }) : () => 1, diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 207eb0a..cf3ac55 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -184,6 +184,18 @@ async function patchRecordValues(env: Bindings, recordId: string, values: Record return body.record; } +async function deleteKbdbRecord(env: Bindings, recordId: string): Promise { + const res = await kbdbFetch(env, `/records/${encodeURIComponent(recordId)}`, { method: 'DELETE' }); + if (res.status === 404) return false; + if (!res.ok) throw new KbdbError(`DELETE /records/${recordId} → ${res.status}`); + return true; +} + +/** KV key for daemon's most-recently-reported active library names(t135 daemon hint)。 */ +function daemonActiveKey(env: Bindings): string { + return `${portalTenant(env)}:portal:daemon_active_libs`; +} + export async function listRecordsByTemplate(env: Bindings, template: string): Promise { const ns = portalNamespace(env); const res = await kbdbFetch(env, `/records/by-template/${encodeURIComponent(template)}?owner_id=${encodeURIComponent(ns)}`); @@ -751,6 +763,11 @@ portalRouter.post('/portal/daemon/libraries', (c) => created.push(name); } const after = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); + // t135:記下本次 daemon 回報的所有庫名(48h TTL)供 GET /portal/admin/libraries 顯示「未同步」提示。 + const activeNames = wanted.map((item) => String(item?.name ?? '').trim()).filter(Boolean); + if (activeNames.length > 0) { + await c.env.WEBHOOKS.put(daemonActiveKey(c.env), JSON.stringify(activeNames), { expirationTtl: 172800 }); + } return c.json({ success: true, created, libraries: after.map(toPublicLibrary) }); }), ); @@ -905,12 +922,23 @@ portalRouter.get('/portal/admin/extractor', (c) => // t52(leo 2026-07-26:「地端 2 個資料夾、雲端就要 2 個庫,只有一個一定被罵」): // 除了登記簿裡的庫,**也把資料裡實際蓋過章的庫一併列出**(標 auto:true)—— // 蓋章即現身,用戶不必先去登記;登記簿只負責顯示名/圖譜來源這些額外設定。 +// t135:讀 daemon 最近回報的 active libs(KV TTL 48h),已登記的庫若不在其中標 daemon_watching:false。 portalRouter.get('/portal/admin/libraries', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); if (!auth.ok) return auth.res; const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); - const out = libs.map(toPublicLibrary); + // 讀 daemon 最近回報的 active lib names(若 KV 不存在 = daemon 從未回報,不標 hint) + let daemonActive: Set | null = null; + try { + const raw = await c.env.WEBHOOKS.get(daemonActiveKey(c.env), 'text'); + if (raw) daemonActive = new Set((JSON.parse(raw) as string[]).map((n) => String(n).trim())); + } catch { /* KV 不可達不擋主流程 */ } + const out = libs.map((rec) => { + const lib = toPublicLibrary(rec); + const watching = daemonActive === null ? undefined : daemonActive.has(lib.name); + return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) }; + }); const known = new Set(out.map((l) => l.name)); // 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library) try { @@ -922,7 +950,13 @@ portalRouter.get('/portal/admin/libraries', (c) => // general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉 if (!n || n === 'general' || known.has(n)) continue; known.add(n); - out.push({ record_id: '', name: n, display_name: n, description: '資料同步時自動出現(可在此補顯示名)', status: 'active', graph_source: false, auto: true }); + const watching = daemonActive === null ? undefined : daemonActive.has(n); + out.push({ + record_id: '', name: n, display_name: n, + description: '資料同步時自動出現(可在此補顯示名)', + status: 'active', graph_source: false, auto: true, + ...(watching !== undefined ? { daemon_watching: watching } : {}), + }); } } } catch { @@ -1005,3 +1039,53 @@ portalRouter.patch('/portal/admin/libraries/:id', (c) => return c.json({ success: true, library: toPublicLibrary(updated) }); }), ); + +// DELETE /portal/admin/libraries/by-name/:name — 移除 auto 庫(只有資料章記、無登記簿 record)。 +// 語意:把該庫的所有 entries 標 deprecated → 資料不刪、重新 ingest 可還原。 +// ⚠️ 影響資料可搜性,要求 body.confirm 等於庫名才執行(二次確認)。 +// ⚠️ 此路由必須在 DELETE /:id 之前宣告(Hono 先到先比;by-name 否則被當成 :id)。 +portalRouter.delete('/portal/admin/libraries/by-name/:name', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const name = decodeURIComponent(c.req.param('name')); + const body = await c.req.json().catch(() => null); + const confirm = String(body?.confirm ?? '').trim(); + if (!confirm) return c.json({ error: 'body 須帶 { confirm: "<庫名>" } 才執行(移除會影響資料可搜性)' }, 400); + if (confirm !== name) return c.json({ error: `confirm 值「${confirm}」與庫名「${name}」不符` }, 400); + const ownerId = portalTenant(c.env); + const res = await kbdbFetch(c.env, '/entries/deprecate-by-library', { + method: 'PATCH', + body: JSON.stringify({ owner_id: ownerId, library: name }), + }); + if (!res.ok) throw new KbdbError(`PATCH /entries/deprecate-by-library → ${res.status}`); + const data = (await res.json()) as { deprecated_count?: number }; + return c.json({ + success: true, + deprecated_count: data.deprecated_count ?? 0, + message: `已從自動清單移除「${name}」(共標記 ${data.deprecated_count ?? 0} 筆資料不可搜)。資料保留可還原——重新同步時會再出現。`, + }); + }), +); + +// DELETE /portal/admin/libraries/:id — 移除已登記庫(有 record_id 的登記簿 record)。 +// 只刪登記簿那筆 record;知識資料(entries with library=name)完全不動。 +// 資料若有的話,重新同步後會以 auto 庫重新出現。 +portalRouter.delete('/portal/admin/libraries/:id', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const recordId = c.req.param('id'); + // 成員資格驗(防憑空 id 打到不相干 record) + const libs = await listRecordsByTemplate(c.env, LIBRARY_TEMPLATE); + const target = libs.find((l) => l.record_id === recordId); + if (!target) return c.json({ error: '庫不存在' }, 404); + const found = await deleteKbdbRecord(c.env, recordId); + if (!found) return c.json({ error: '庫不存在' }, 404); + return c.json({ + success: true, + name: target.values.name ?? '', + message: `已從目錄移除「${target.values.display_name ?? target.values.name ?? ''}」。資料仍在,重新同步會再出現。`, + }); + }), +); diff --git a/cypher-executor/tests/executor.test.ts b/cypher-executor/tests/executor.test.ts index e192244..bb7bbd1 100644 --- a/cypher-executor/tests/executor.test.ts +++ b/cypher-executor/tests/executor.test.ts @@ -1,6 +1,8 @@ // Cypher Executor 端到端測試 import { SELF } from 'cloudflare:test'; import { describe, it, expect } from 'vitest'; +import { GraphExecutor } from '../src/graph-executor'; +import type { ComponentRunner, ExecutionGraph } from '../src/types'; describe('GET /', () => { it('回傳服務狀態', async () => { @@ -191,4 +193,60 @@ describe('POST /execute', () => { }); expect(res.status).toBe(400); }); + +}); + +// t117: FOREACH 全部項目失敗 → 錯誤訊息含 status code(GraphExecutor 單元測試) +describe('t117: FOREACH 全項失敗 → ExecutionError 含 status code', () => { + it('FOREACH 所有項目 success:false(含 status 401)→ executor.execute() 拋出含 "401" 的錯誤', async () => { + // mock loader:任何零件都回 {success:false, status:401, error:"HTTP 401"} + const failLoader = async (_: string): Promise => + async () => ({ success: false, status: 401, error: 'HTTP 401', data: { body: 'Unauthorized' } }); + + const executor = new GraphExecutor(failLoader); + + const graph: ExecutionGraph = { + id: 'foreach-fail-t117', + name: 'FOREACH 全失敗', + nodes: [ + { id: 'input', type: 'Input', data: { items: ['a', 'b'] } }, + { id: 'writer', type: 'Component', componentId: 'http_request' }, + ], + edges: [ + { from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' }, + ], + }; + + // t117 核心驗證:全部失敗 → throw(不再靜默) + await expect(executor.execute(graph, {})).rejects.toThrow(/401/); + }); + + it('FOREACH 部分項目成功 → 不拋出(只有全部失敗才報錯)', async () => { + let callCount = 0; + // 第一次呼叫失敗,第二次成功(部分失敗不觸發 t117 all-fail 路徑) + const mixedLoader = async (_: string): Promise => + async () => { + callCount++; + if (callCount === 1) return { success: false, status: 401, error: 'HTTP 401' }; + return { success: true, data: { ok: true } }; + }; + + const executor = new GraphExecutor(mixedLoader); + + const graph: ExecutionGraph = { + id: 'foreach-mixed-t117', + name: 'FOREACH 部分失敗', + nodes: [ + { id: 'input', type: 'Input', data: { items: ['a', 'b'] } }, + { id: 'writer', type: 'Component', componentId: 'http_request' }, + ], + edges: [ + { from: 'input', to: 'writer', type: 'FOREACH', iterator: 'item' }, + ], + }; + + // 部分失敗 → 不拋出,正常回傳 results 陣列 + const result = await executor.execute(graph, {}); + expect(result).toBeDefined(); + }); }); diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 4476146..88ce2a1 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -395,6 +395,82 @@ describe('/portal/admin/libraries', () => { }); }); +// ═══════════════ t135 庫目錄移除 ═══════════════ + +describe('DELETE /portal/admin/libraries(t135)', () => { + it('DELETE /:id — 成功移除已登記庫;KBDB /records/:id DELETE 被呼叫', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + // 成員驗證:list by template 回有該 record + mockListByTemplate('portal_library', [ + { record_id: 'rec_lib1', values: { name: 'finance', display_name: '財務庫', status: 'active' } }, + ]); + let deleteCalled = false; + fetchMock + .get(KBDB) + .intercept({ path: '/records/rec_lib1', method: 'DELETE' }) + .reply(200, () => { deleteCalled = true; return { success: true }; }); + const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; name: string; message: string }; + expect(data.success).toBe(true); + expect(data.name).toBe('finance'); + expect(deleteCalled).toBe(true); + }); + + it('DELETE /:id — 庫不在目錄 → 404', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockListByTemplate('portal_library', []); // 空目錄 + const res = await json('DELETE', '/portal/admin/libraries/rec_lib_x', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(404); + }); + + it('DELETE /:id — 非 admin → 403', async () => { + await seedAdminSession('tok-user', 'rec_u1'); + mockGetRecord('rec_u1', userValues()); + const res = await json('DELETE', '/portal/admin/libraries/rec_lib1', undefined, { Authorization: 'Bearer tok-user' }); + expect(res.status).toBe(403); + }); + + it('DELETE /by-name/:name — confirm 符合 → 呼叫 KBDB deprecate-by-library', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + let deprecateCalled = false; + fetchMock + .get(KBDB) + .intercept({ path: '/entries/deprecate-by-library', method: 'PATCH' }) + .reply(200, () => { deprecateCalled = true; return { success: true, deprecated_count: 12 }; }); + const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; deprecated_count: number }; + expect(data.success).toBe(true); + expect(data.deprecated_count).toBe(12); + expect(deprecateCalled).toBe(true); + }); + + it('DELETE /by-name/:name — 無 confirm → 400', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', {}, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(400); + }); + + it('DELETE /by-name/:name — confirm 不符 → 400', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'wrong' }, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(400); + }); + + it('DELETE /by-name/:name — 非 admin → 403', async () => { + await seedAdminSession('tok-user', 'rec_u1'); + mockGetRecord('rec_u1', userValues()); + const res = await json('DELETE', '/portal/admin/libraries/by-name/kb', { confirm: 'kb' }, { Authorization: 'Bearer tok-user' }); + expect(res.status).toBe(403); + }); +}); + // ═══════════════ 6. t122 萃取引擎金鑰雲端下發 ═══════════════ describe('/portal/admin/extractor + /portal/daemon/config 萃取引擎(t122)', () => { diff --git a/kbdb/src/actions/entry-crud.ts b/kbdb/src/actions/entry-crud.ts index e443d86..548ba37 100644 --- a/kbdb/src/actions/entry-crud.ts +++ b/kbdb/src/actions/entry-crud.ts @@ -126,6 +126,27 @@ export async function deleteEntry(db: D1Database, id: string): Promise { await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run(); } +/** + * 把某 owner 下某庫的所有 entries 標 deprecated(t135 by-name 移除語意)。 + * 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。 + * 回 deprecated 的筆數(0 = 庫名不存在或早已全部 deprecated)。 + */ +export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string, library: string): Promise { + const result = await db + .prepare( + `UPDATE entries + SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.status', 'deprecated'), + updated_at = unixepoch() + WHERE owner_id = ? + AND COALESCE(json_extract(metadata_json, '$.library'), 'general') = ? + AND (json_extract(metadata_json, '$.status') IS NULL + OR json_extract(metadata_json, '$.status') != 'deprecated')`, + ) + .bind(ownerId, library) + .run(); + return (result.meta?.changes as number | undefined) ?? 0; +} + // 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。 // COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))—— // 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。 diff --git a/kbdb/src/actions/record-crud.ts b/kbdb/src/actions/record-crud.ts index 68f9cd3..83e181c 100644 --- a/kbdb/src/actions/record-crud.ts +++ b/kbdb/src/actions/record-crud.ts @@ -209,3 +209,18 @@ export async function searchByTemplate(db: D1Database, template: string, owner_i } return ids.map((id) => byId.get(id)).filter((r): r is RecordResult => !!r); } + +/** 刪除一筆 record:先刪 entry_values(FK),再刪底層 entries。回 false 表示 record 不存在。 */ +export async function deleteRecord(db: D1Database, recordId: string): Promise { + const evRes = await db + .prepare('SELECT entry_id FROM entry_values WHERE record_id = ?') + .bind(recordId) + .all<{ entry_id: string }>(); + const rows = evRes.results ?? []; + if (rows.length === 0) return false; + await db.prepare('DELETE FROM entry_values WHERE record_id = ?').bind(recordId).run(); + for (const { entry_id } of rows) { + await db.prepare('DELETE FROM entries WHERE id = ?').bind(entry_id).run(); + } + return true; +} diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index e02f928..99954cb 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -3,6 +3,7 @@ import { Hono } from 'hono'; import type { Bindings } from '../types'; import { createEntry, + deprecateEntriesByLibrary, getEntry, listEntries, updateEntry, @@ -139,6 +140,18 @@ entryRoutes.get('/:id', async (c) => { return c.json({ success: true, entry }); }); +// PATCH /entries/deprecate-by-library — body {owner_id, library}。 +// t135:把某租戶某庫的所有 entries 標 deprecated,讓庫從 auto 清單消失。 +// 此路由必須在 '/:id' 之前,否則 'deprecate-by-library' 會被當成 id 參數。 +entryRoutes.patch('/deprecate-by-library', async (c) => { + const body = (await c.req.json().catch(() => null)) as { owner_id?: string; library?: string } | null; + const ownerId = String(body?.owner_id ?? '').trim(); + const library = String(body?.library ?? '').trim(); + if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400); + const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library); + return c.json({ success: true, deprecated_count: count }); +}); + // PATCH /entries/:id entryRoutes.patch('/:id', async (c) => { const body = await c.req.json().catch(() => ({})); diff --git a/kbdb/src/routes/records.ts b/kbdb/src/routes/records.ts index 7a2d891..cc24de0 100644 --- a/kbdb/src/routes/records.ts +++ b/kbdb/src/routes/records.ts @@ -1,7 +1,7 @@ // Records route — structured records (entry_values composed by a template). import { Hono } from 'hono'; import type { Bindings } from '../types'; -import { createRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud'; +import { createRecord, deleteRecord, getRecord, searchByTemplate, updateRecord } from '../actions/record-crud'; export const recordRoutes = new Hono<{ Bindings: Bindings }>(); @@ -47,3 +47,10 @@ recordRoutes.patch('/:recordId', async (c) => { return c.json({ success: false, error: e instanceof Error ? e.message : String(e) }, 400); } }); + +// DELETE /records/:recordId — 刪除一筆 record 及其底層 entries。 +recordRoutes.delete('/:recordId', async (c) => { + const found = await deleteRecord(c.env.DB, c.req.param('recordId')); + if (!found) return c.json({ success: false, error: 'not found' }, 404); + return c.json({ success: true }); +}); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index dd043b2..bab03c6 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -277,6 +277,19 @@ 執行範圍:`cypher-executor/src/routes/portal-data.ts`(新增 export 函式 + chat route)。 測試:portal-data.test.ts 新增 5 案(t129 describe:合併計數/各保一筆/page 備用/空陣列/page_name 優先)。 +- [x] **t135 庫目錄移除(2026-07-29,任務層小改)**: + 來源=leo 裁定「需要加移除按鈕,別人裝錯我沒辦法幫他弄,需要可以自主」。 + 後端:`DELETE /portal/admin/libraries/:id`(刪登記簿 record,資料不動)+ + `DELETE /portal/admin/libraries/by-name/:name`(auto 庫;body `confirm:<庫名>` 才執行; + 呼叫 KBDB `PATCH /entries/deprecate-by-library`,entries 標 deprecated → 從 auto 清單消失)。 + KBDB:`deleteRecord` action + `DELETE /records/:id` route; + `deprecateEntriesByLibrary` action + `PATCH /entries/deprecate-by-library` route(此路由在 `/:id` 之前)。 + Daemon hint(根治只增不減):`POST /portal/daemon/libraries` 存 active lib names 到 KV(TTL 48h); + `GET /portal/admin/libraries` 讀 KV 後對登記庫標 `daemon_watching: false`(若 daemon 有回報但未含該庫)。 + 前端:每張庫卡片加「移除」按鈕(登記庫=`confirm()` 對話框;auto 庫=`prompt()` 輸入庫名確認); + 移除後即時從列表消失(不必重整);`daemon_watching=false` → 顯示灰字「小幫手目前沒有在同步這個資料夾」。 + 測試:portal-admin.test.ts 新增 7 案(DELETE/:id 成功/404/非admin403;by-name 成功/無confirm/confirm不符/非admin403)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`) From 9d38d580f2fa5f291fc6f0b0d9b5b74549c4c46a Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 29 Jul 2026 15:28:10 +0800 Subject: [PATCH 21/25] =?UTF-8?q?feat(t131):=20AI=20=E8=A8=AD=E5=AE=9A?= =?UTF-8?q?=E5=90=88=E4=BD=B5=E6=88=90=E4=B8=80=E6=8A=8A=E9=87=91=E9=91=B0?= =?UTF-8?q?=E2=80=94=E2=80=94=E8=81=8A=E5=A4=A9=E8=88=87=E8=90=83=E5=8F=96?= =?UTF-8?q?=E5=85=B1=E7=94=A8=EF=BC=8CClaude=20=E7=82=BA=E9=81=B8=E5=A1=AB?= =?UTF-8?q?=E5=8A=A0=E5=BC=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo:「拿到一把就很難了,還要拿兩把。一律規定先輸入 gemini api key, 如果想要強化本地萃取,可以選擇 claude……前面那個,聊天和萃都一次設好,後面那把,不填就是 gemini」 +「重點是更好的模型萃取知識更能抓重點,不然他也不知道加強什麼」(文案講感覺得到的差別) +「本地如果有裝 claude,掃到,只要一個 checkbox 就好」(daemon 偵測回報,沒偵測到就停用選項)。 - POST/GET /portal/admin/ai(一次寫 chat-key 與 extractor)+/portal/daemon/report-capabilities - 舊 chat-key/extractor 端點保留相容;UI 兩區合併成「AI 設定」 t131 新測 7 條全綠(總體 229 passed/9 紅皆 pre-existing:HTML shell 搬遷×2、library-map 未實作×6、 executor 零件×1)。(實作=子 CC;驗證+commit=總管) --- console-ui/public/portal/index.html | 215 ++++++++----------- cypher-executor/src/routes/portal.ts | 149 ++++++++++++- cypher-executor/tests/portal-admin.test.ts | 160 ++++++++++++++ system-dev/docs/3-specs/portal-auth/tasks.md | 15 ++ 4 files changed, 412 insertions(+), 127 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index bfa059f..9e8d9f3 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -347,36 +347,32 @@
封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。
-
-
AI 問答金鑰
-
用來啟用「問 AI」。到 aistudio.google.com 免費申請。金鑰只存在你自己的知識庫裡。
-
- - -
-
-
- -
-
萃取引擎
-
同步小幫手把你的文件變成知識卡時用的 AI。選好後請讓小幫手重連一次。
-
-
- - + +
+
AI 設定
+
+ +
+
Gemini API Key
+
聊天問答與文件萃取都用這一把。免費申請,金鑰只存在你自己的知識庫裡。
+
-
-
aistudio.google.com/apikey 免費取得金鑰
- + +
+
讓知識卡整理得更好(選填)
+ +
+ 讀你文件、整理成知識卡的那個 AI,換成更強的模型。卡片會更抓得到重點、關聯也連得更準,之後搜尋和問答的品質跟著提升。需要你的電腦已安裝 Claude Code。不填就用上面那把 Gemini,一樣能用。 +
+ +
+
+ +
- -
@@ -788,97 +784,72 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { if (dl.parentNode) dl.parentNode.insertBefore(both, dl.nextSibling); } } - var kb = $('st-key-save'); - if (kb) kb.addEventListener('click', function () { - var k = ($('st-key').value || '').trim(); - var m = $('st-key-status'); - if (!k) { m.textContent = '請先貼上金鑰'; m.style.color = ''; return; } - kb.disabled = true; m.textContent = '儲存中…'; m.style.color = ''; - fetch(API_BASE + '/portal/admin/chat-key', { - method: 'POST', - headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), - body: JSON.stringify({ 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 (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'; - }) - .catch(function (e) { kb.disabled = false; m.textContent = friendlyErr(e); m.style.color = '#b4462f'; }); - }); - })(); + // 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; - // t122 萃取引擎設定(engine 切換顯示隱藏金鑰輸入框 + 存檔) - (function () { - // 引擎切換:Gemini 顯示金鑰欄;Claude Code 隱藏 - function updateExtractorKeyRowVisibility() { - var row = $('st-extractor-key-row'); - if (!row) return; - var el = document.getElementById('st-engine-gemma'); - row.style.display = (el && el.checked) ? '' : 'none'; - } - ['st-engine-gemma', 'st-engine-claude'].forEach(function (id) { - var el = document.getElementById(id); - if (el) el.addEventListener('change', updateExtractorKeyRowVisibility); - }); - updateExtractorKeyRowVisibility(); - - // 進入設定頁時讀取現有設定(GET /portal/admin/extractor) - function loadExtractorConfig() { - if (!(S.profile && S.profile.role === 'admin')) return; - fetch(API_BASE + '/portal/admin/extractor', { headers: authHeaders() }) - .then(function (r) { return r.ok ? r.json() : null; }) - .then(function (d) { - if (!d) return; - var gemmaEl = document.getElementById('st-engine-gemma'); - var claudeEl = document.getElementById('st-engine-claude'); - if (d.engine === 'claude' && claudeEl) claudeEl.checked = true; - else if (gemmaEl) gemmaEl.checked = true; - updateExtractorKeyRowVisibility(); - if (d.has_key) { - var ki = $('st-extractor-key'); - if (ki) ki.placeholder = '已設定(留空=不變更)'; - } - }) - .catch(function () { /* 讀不到不擋頁面 */ }); - } - - // 存檔 - var sb = $('st-extractor-save'); - if (sb) sb.addEventListener('click', function () { - var m = $('st-extractor-status'); - var engineEl = document.querySelector('input[name="st-extractor-engine"]:checked'); - var engine = engineEl ? engineEl.value : 'gemma'; - var key = (($('st-extractor-key') && $('st-extractor-key').value) || '').trim(); - var body = { engine: engine }; - if (engine === 'gemma' && key) body.gemini_api_key = key; - sb.disabled = true; m.textContent = '儲存中…'; m.style.color = ''; - fetch(API_BASE + '/portal/admin/extractor', { - 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-extractor-key')) { $('st-extractor-key').value = ''; $('st-extractor-key').placeholder = '已設定(留空=不變更)'; } - m.textContent = '已儲存。小幫手請點「連上知識庫」重連一次即可生效'; m.style.color = '#3f7a4f'; - }) - .catch(function (e) { sb.disabled = false; m.textContent = friendlyErr(e); m.style.color = '#b4462f'; }); - }); - - // loadExtractorConfig 掛到全域(供 loadSettings 呼叫) - window._loadExtractorConfig = loadExtractorConfig; - })(); + // 儲存 + 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 () { /* 盡力而為 */ }); @@ -973,15 +944,13 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { var m = document.getElementById('sc-key-msg'); if (!k) { m.textContent = '請先貼上金鑰'; return; } kb.disabled = true; m.textContent = '啟用中…'; - fetch(API_BASE + '/portal/admin/chat-key', { + fetch(API_BASE + '/portal/admin/ai', { method: 'POST', headers: Object.assign({ 'Content-Type': 'application/json' }, authHeaders()), - body: JSON.stringify({ key: k }) + 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; - // 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'); }) @@ -1735,7 +1704,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._loadExtractorConfig) window._loadExtractorConfig(); + if (window._loadAiConfig) window._loadAiConfig(); var libs = p.libraries || []; var libHtml = libs.indexOf('*') >= 0 ? '全部知識庫' diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index cf3ac55..3220318 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -838,10 +838,149 @@ portalRouter.post('/portal/daemon/config', (c) => }), ); -// POST /portal/admin/chat-key — body {key}。站內啟用 AI 問答(t53,leo 07-25: -// 「他進到網站要去給 gemini API Key」——不再回安裝器)。手法=G10 直嵌同款: -// 改寫 tenant 的 rag_chat workflow 記錄,把 x-goog-api-key 換成實值(KV 覆寫=重推)。 -// 金鑰只過境不落 log;admin 閘(與庫登記同級)。 +// ── t131 合併 AI 設定(Gemini API Key 同時設 chat+extractor;has_claude 由 daemon 回報)───── +// KV key = {tenant}:portal:ai_config,存在 WEBHOOKS KV。 +// KV key = {tenant}:portal:daemon_caps,存 daemon 回報的能力(TTL 7 天)。 + +interface AiConfig { + gemini_api_key?: string; + use_claude_for_extract?: boolean; +} +interface DaemonCapabilities { + has_claude: boolean; + daemon_version?: string; + os?: string; +} + +function aiConfigKey(env: Bindings): string { return `${portalTenant(env)}:portal:ai_config`; } +function daemonCapsKey(env: Bindings): string { return `${portalTenant(env)}:portal:daemon_caps`; } + +async function getAiConfig(env: Bindings): Promise { + const raw = await env.WEBHOOKS.get(aiConfigKey(env), 'text'); + if (!raw) return null; + try { return JSON.parse(raw) as AiConfig; } catch { return null; } +} +async function getDaemonCaps(env: Bindings): Promise { + const raw = await env.WEBHOOKS.get(daemonCapsKey(env), 'text'); + if (!raw) return null; + try { return JSON.parse(raw) as DaemonCapabilities; } catch { return null; } +} + +// 將 ai_config 同步回 extractor_config(daemon/config 讀 extractor_config,保持相容)。 +async function syncExtractorFromAiConfig(env: Bindings, cfg: AiConfig): Promise { + const exCfg: ExtractorConfig = { + engine: cfg.use_claude_for_extract ? 'claude' : 'gemma', + }; + if (!cfg.use_claude_for_extract && cfg.gemini_api_key) { + exCfg.gemini_api_key = cfg.gemini_api_key; + } + await env.WEBHOOKS.put(extractorConfigKey(env), JSON.stringify(exCfg)); +} + +// POST /portal/admin/ai — body {gemini_api_key?, use_claude_for_extract?}(t131)。 +// 同時設定 AI 問答金鑰(chat)與萃取引擎(extractor)。admin 閘。 +portalRouter.post('/portal/admin/ai', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const body = (await c.req.json().catch(() => null)) as { gemini_api_key?: string; use_claude_for_extract?: boolean } | null; + const newKey = String(body?.gemini_api_key ?? '').trim(); + const useClause = typeof body?.use_claude_for_extract === 'boolean' ? body.use_claude_for_extract : undefined; + + // 讀現有設定做合併(留空欄位=不變更) + const existing = await getAiConfig(c.env) ?? {}; + const merged: AiConfig = { + gemini_api_key: newKey || existing.gemini_api_key, + use_claude_for_extract: useClause !== undefined ? useClause : (existing.use_claude_for_extract ?? false), + }; + if (!merged.gemini_api_key) return c.json({ error: '請貼上你的 Gemini API Key' }, 400); + + // 更新 chat(rag_chat workflow)——容忍 404(workflow 未安裝時暫存,安裝後再寫入) + if (newKey) { + const tenant = portalTenant(c.env); + const kvKey = `${tenant}:wf:rag_chat`; + const raw = await c.env.WEBHOOKS.get(kvKey, 'text'); + if (raw) { + try { + const record = JSON.parse(raw) as Record; + const visit = (o: unknown): void => { + if (Array.isArray(o)) { o.forEach(visit); return; } + if (o && typeof o === 'object') { + const rec = o as Record; + for (const k of Object.keys(rec)) { + if (k.toLowerCase() === 'x-goog-api-key') { rec[k] = newKey; } + else visit(rec[k]); + } + } + }; + visit(record['graph']); + visit(record['config']); + await c.env.WEBHOOKS.put(kvKey, JSON.stringify(record)); + } catch { /* 工作流記錄損壞時靜默略過,金鑰仍存 ai_config */ } + } + // 若 rag_chat 不存在(raw===null),跳過,等 acr init 安裝後再用舊 chat-key 端點補入 + } + + // 存合併設定 + await c.env.WEBHOOKS.put(aiConfigKey(c.env), JSON.stringify(merged)); + // 同步回 extractor_config(daemon/config 走這個) + await syncExtractorFromAiConfig(c.env, merged); + + return c.json({ + success: true, + has_key: true, + use_claude_for_extract: merged.use_claude_for_extract ?? false, + }); + }), +); + +// GET /portal/admin/ai — 回 has_key/use_claude_for_extract/claude_available(t131)。 +portalRouter.get('/portal/admin/ai', (c) => + run(c, async () => { + const auth = await requirePortalAdmin(c); + if (!auth.ok) return auth.res; + const cfg = await getAiConfig(c.env); + const caps = await getDaemonCaps(c.env); + return c.json({ + success: true, + has_key: !!(cfg?.gemini_api_key), + use_claude_for_extract: cfg?.use_claude_for_extract ?? false, + claude_available: caps?.has_claude ?? false, + }); + }), +); + +// POST /portal/daemon/report-capabilities — body {email, password, has_claude, daemon_version?, os?}(t131)。 +// daemon 連線成功後回報本機能力;認證同 /portal/daemon/config(帳密)。 +// ⚠️ daemon 端改動屬 arcrun-rag repo,本端只做「收端點+存 KV+供 GET /portal/admin/ai 用」。 +portalRouter.post('/portal/daemon/report-capabilities', (c) => + run(c, async () => { + const body = (await c.req.json().catch(() => null)) as { email?: string; password?: string; has_claude?: boolean; daemon_version?: string; os?: string } | null; + const email = String(body?.email ?? '').trim().toLowerCase(); + const password = String(body?.password ?? ''); + if (!email || !password) return c.json({ error: 'email 與 password 必填' }, 400); + if (await isLocked(c.env, email)) return c.json({ error: '登入失敗次數過多', }, 429); + const recordId = await findUserRecordId(c.env, email); + const rec = recordId ? await getRecordById(c.env, recordId) : null; + if (!rec) { await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); } + if ((rec.values.status ?? '') !== 'active') return c.json({ error: '帳號已停用' }, 403); + if (!(await verifyPassword(password, rec.values.password_hash ?? ''))) { + await recordLoginFail(c.env, email); return c.json({ error: 'email 或密碼錯誤' }, 401); + } + await clearLoginFail(c.env, email); + const caps: DaemonCapabilities = { + has_claude: body?.has_claude === true, + ...(body?.daemon_version ? { daemon_version: String(body.daemon_version) } : {}), + ...(body?.os ? { os: String(body.os) } : {}), + }; + const TTL_7D = 7 * 24 * 60 * 60; + await c.env.WEBHOOKS.put(daemonCapsKey(c.env), JSON.stringify(caps), { expirationTtl: TTL_7D }); + return c.json({ success: true }); + }), +); + +// POST /portal/admin/chat-key — body {key}。保留舊端點相容(新 UI 走 /portal/admin/ai)。 +// 舊版 setup checklist / 舊 UI 仍走這裡;只更新 rag_chat workflow,不同步 ai_config。 portalRouter.post('/portal/admin/chat-key', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); @@ -881,6 +1020,7 @@ portalRouter.post('/portal/admin/chat-key', (c) => ); // POST /portal/admin/extractor — body {engine, gemini_api_key?, llm_model?}(t122)。 +// 保留舊端點相容(新 UI 走 /portal/admin/ai)。 // admin 閘(同 chat-key 等級)。金鑰不落 log;存 WEBHOOKS KV。 portalRouter.post('/portal/admin/extractor', (c) => run(c, async () => { @@ -904,6 +1044,7 @@ portalRouter.post('/portal/admin/extractor', (c) => ); // GET /portal/admin/extractor — 回 engine + has_key(不回金鑰明文)(t122)。 +// 保留舊端點相容(新 UI 走 /portal/admin/ai)。 portalRouter.get('/portal/admin/extractor', (c) => run(c, async () => { const auth = await requirePortalAdmin(c); diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 88ce2a1..55ffbc1 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -556,5 +556,165 @@ describe('GET /portal(P4 admin 頁 HTML 殼)', () => { // t114:無「登記到目錄」按鈕 expect(html).not.toContain('lib-adopt'); expect(html).not.toContain('登記到目錄'); + // t131:合併 AI 設定(舊兩區塊已移除) + expect(html).toContain('st-ai-panel'); + expect(html).toContain('st-ai-key'); + expect(html).toContain('st-ai-use-claude'); + expect(html).not.toContain('st-extractor-panel'); + expect(html).not.toContain('st-key-save'); // 舊 chat-key 存檔鈕已移除 + }); +}); + +// ═══════════════ 8. t131 合併 AI 設定 ═══════════════ + +describe('/portal/admin/ai + /portal/daemon/report-capabilities(t131)', () => { + const USER_EMAIL = 'ai-test@example.com'; + const USER_PW = 'unit-test-pw-1'; + const USER_RECORD = 'rec_ai_user'; + const AI_CONFIG_KEY = 'leo:portal:ai_config'; + const EXTRACTOR_KV_KEY = 'leo:portal:extractor_config'; + const DAEMON_CAPS_KEY = 'leo:portal:daemon_caps'; + + function aiAdminVals(): Record { + return { email: USER_EMAIL, display_name: 'AI 測試 admin', status: 'active', role: 'admin', password_hash: storedHash }; + } + + // 與全域 seedAdminSession 相同格式(JSON.stringify({record_id})),fetchMock 由各測試自行 mock + async function seedAiSession(token = 'tok-ai-admin', recordId = USER_RECORD) { + await env.SESSIONS_KV.put(`portal_sess:${token}`, JSON.stringify({ record_id: recordId })); + } + + function mockAiRecord(recordId = USER_RECORD) { + fetchMock.get(KBDB).intercept({ path: `/records/${recordId}`, method: 'GET' }).reply(200, { + success: true, + record: { record_id: recordId, template_id: 'tpl_pu', values: aiAdminVals() }, + }); + } + + function mockEmailLookup(email: string, recordId: string | null) { + const needle = new URLSearchParams({ page_name: email }).toString(); + fetchMock.get(KBDB).intercept({ + path: (p: string) => p.startsWith('/entries?') && p.includes(needle) && p.includes(encodeURIComponent(NS)), + method: 'GET', + }).reply(200, { success: true, entries: recordId ? [{ content: recordId }] : [], count: recordId ? 1 : 0 }); + } + + afterEach(async () => { + await env.WEBHOOKS.delete(AI_CONFIG_KEY); + await env.WEBHOOKS.delete(EXTRACTOR_KV_KEY); + await env.WEBHOOKS.delete(DAEMON_CAPS_KEY); + }); + + it('POST /ai — 首次設定:同時寫 ai_config+extractor_config+更新 rag_chat workflow', async () => { + const ragChatKey = 'leo:wf:rag_chat'; + const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': '{{credential.gemini}}' } }] }, config: {} }; + await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow)); + await seedAiSession(); + mockAiRecord(); + const res = await json('POST', '/portal/admin/ai', + { gemini_api_key: 'AIza-new-key-123', use_claude_for_extract: false }, + { Authorization: 'Bearer tok-ai-admin' } + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean }; + expect(data.success).toBe(true); + expect(data.has_key).toBe(true); + expect(data.use_claude_for_extract).toBe(false); + + const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}'); + expect(stored.gemini_api_key).toBe('AIza-new-key-123'); + expect(stored.use_claude_for_extract).toBe(false); + + const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}'); + expect(exCfg.engine).toBe('gemma'); + expect(exCfg.gemini_api_key).toBe('AIza-new-key-123'); + + const updated = JSON.parse((await env.WEBHOOKS.get(ragChatKey, 'text')) ?? '{}') as typeof workflow; + expect((updated.graph as { nodes: Array<{ config: Record }> }).nodes[0].config['x-goog-api-key']).toBe('AIza-new-key-123'); + await env.WEBHOOKS.delete(ragChatKey); + }); + + it('POST /ai — rag_chat 不存在時不報錯(容忍,金鑰存 ai_config 即可)', async () => { + await seedAiSession(); + mockAiRecord(); + const res = await json('POST', '/portal/admin/ai', + { gemini_api_key: 'AIza-no-workflow-key' }, + { Authorization: 'Bearer tok-ai-admin' } + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; has_key: boolean }; + expect(data.success).toBe(true); + expect(data.has_key).toBe(true); + const stored = JSON.parse((await env.WEBHOOKS.get(AI_CONFIG_KEY, 'text')) ?? '{}'); + expect(stored.gemini_api_key).toBe('AIza-no-workflow-key'); + }); + + it('POST /ai — use_claude_for_extract=true:extractor engine=claude,不附 gemini_api_key', async () => { + await seedAiSession(); + mockAiRecord(); + const res = await json('POST', '/portal/admin/ai', + { gemini_api_key: 'AIza-key-888', use_claude_for_extract: true }, + { Authorization: 'Bearer tok-ai-admin' } + ); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; use_claude_for_extract: boolean }; + expect(data.use_claude_for_extract).toBe(true); + const exCfg = JSON.parse((await env.WEBHOOKS.get(EXTRACTOR_KV_KEY, 'text')) ?? '{}'); + expect(exCfg.engine).toBe('claude'); + expect('gemini_api_key' in exCfg).toBe(false); + }); + + it('GET /ai — 不回明文金鑰;has_key=true;claude_available 依 daemon_caps', async () => { + await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-secret-456', use_claude_for_extract: false })); + await env.WEBHOOKS.put(DAEMON_CAPS_KEY, JSON.stringify({ has_claude: true })); + await seedAiSession(); + mockAiRecord(); + const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; has_key: boolean; use_claude_for_extract: boolean; claude_available: boolean }; + expect(data.has_key).toBe(true); + expect(data.use_claude_for_extract).toBe(false); + expect(data.claude_available).toBe(true); + const raw = JSON.stringify(data); + expect(raw).not.toContain('AIza-secret-456'); + expect(raw).not.toContain('gemini_api_key'); + }); + + it('GET /ai — 沒有 daemon_caps → claude_available=false', async () => { + await env.WEBHOOKS.put(AI_CONFIG_KEY, JSON.stringify({ gemini_api_key: 'AIza-key-777' })); + await seedAiSession(); + mockAiRecord(); + const res = await json('GET', '/portal/admin/ai', undefined, { Authorization: 'Bearer tok-ai-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { claude_available: boolean }; + expect(data.claude_available).toBe(false); + }); + + it('POST /portal/daemon/report-capabilities — 有 claude:daemon_caps 寫入 has_claude=true', async () => { + mockEmailLookup(USER_EMAIL, USER_RECORD); + mockAiRecord(); + const res = await json('POST', '/portal/daemon/report-capabilities', { + email: USER_EMAIL, password: USER_PW, has_claude: true, daemon_version: '1.2.0', os: 'darwin', + }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean }; + expect(data.success).toBe(true); + const caps = JSON.parse((await env.WEBHOOKS.get(DAEMON_CAPS_KEY, 'text')) ?? '{}'); + expect(caps.has_claude).toBe(true); + expect(caps.daemon_version).toBe('1.2.0'); + }); + + it('舊端點 /portal/admin/chat-key 仍可用(相容)', async () => { + const ragChatKey = 'leo:wf:rag_chat'; + const workflow = { graph: { nodes: [{ config: { 'x-goog-api-key': 'old' } }] }, config: {} }; + await env.WEBHOOKS.put(ragChatKey, JSON.stringify(workflow)); + await seedAiSession(); + mockAiRecord(); + const res = await json('POST', '/portal/admin/chat-key', { key: 'AIza-compat-key' }, { Authorization: 'Bearer tok-ai-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { success: boolean; replaced: number }; + expect(data.success).toBe(true); + expect(data.replaced).toBeGreaterThan(0); + await env.WEBHOOKS.delete(ragChatKey); }); }); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index bab03c6..da48a2b 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -290,6 +290,21 @@ 移除後即時從列表消失(不必重整);`daemon_watching=false` → 顯示灰字「小幫手目前沒有在同步這個資料夾」。 測試:portal-admin.test.ts 新增 7 案(DELETE/:id 成功/404/非admin403;by-name 成功/無confirm/confirm不符/非admin403)。 +- [x] **t131 金鑰設定合併(2026-07-29,任務層小改)**: + 來源=leo 裁定「一律規定先輸入 gemini api key,聊天和萃都一次設好;如果有 claude,勾選加強版」。 + 後端:新增 `POST /portal/admin/ai`(同時做 chat-key+extractor 的事)+ + `GET /portal/admin/ai`(回 has_key/use_claude_for_extract/claude_available)+ + `POST /portal/daemon/report-capabilities`(收 daemon 回報的 has_claude); + 舊 `/portal/admin/chat-key`、`/portal/admin/extractor` 保留相容。 + 前端:設定頁兩區塊合一,Gemini Key 必填欄+Claude 選填 checkbox(依 daemon 回報 enable/disable)。 + ⚠️ daemon 端(arcrun-rag repo)需實作 `report-capabilities` 呼叫,規格: + - 連線成功後 POST `{cypher_url}/portal/daemon/report-capabilities` + - body: `{email, password, has_claude: bool, daemon_version: string, os: string}` + - has_claude 由現有 `FindClaudeBin()` 決定(t92 已實作) + - 時機:連線成功後一次+每次啟動時一次(不加新 timer) + KV key 設計:`{tenant}:portal:ai_config`(合併設定)`{tenant}:portal:daemon_caps`(能力回報,TTL 7 天) + 測試:portal-admin.test.ts 新增 7 案(全通;全套 238 tests 229 passed,9 failed 皆 pre-existing)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`) From 159f0b07dc0bde78a5de4c376f896690e794beec Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 29 Jul 2026 17:45:30 +0800 Subject: [PATCH 22/25] =?UTF-8?q?=F0=9F=94=B4=F0=9F=94=B4=20fix:=20portal?= =?UTF-8?q?=20=E7=99=BD=E7=95=AB=E9=9D=A2=E2=80=94=E2=80=94t131=20?= =?UTF-8?q?=E8=AA=A4=E5=88=AA=E4=B8=8A=E4=B8=80=E5=80=8B=20IIFE=20?= =?UTF-8?q?=E7=9A=84=E6=94=B6=E5=B0=BE=20})();?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo:「問題是 portal 是白的」。真因:t131 合併 AI 設定時刪掉舊的 chat-key/extractor 兩區, **連同上一個 IIFE 的收尾 })(); 一起刪掉** ⇒ 整段 JS 語法錯(Unexpected end of file) ⇒ 瀏覽器整支 script 不執行=白畫面。 **總管失職**:t131 驗收只跑了 vitest(測後端)+grep 字串,**從未驗過前端 JS 語法**—— portal/index.html 是純前端檔,vitest 根本測不到它。 修:補回 })();;node --check 通過;os-split/safejson 測試綠。 --- console-ui/public/portal/index.html | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index 9e8d9f3..cb163c3 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -784,8 +784,10 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { if (dl.parentNode) dl.parentNode.insertBefore(both, dl.nextSibling); } } - // t131 AI 設定(合併 Gemini API Key+Claude 加強版) - (function () { + })(); + + // t131 AI 設定(合併 Gemini API Key+Claude 加強版) + (function () { // 進入設定頁時讀取現有設定(GET /portal/admin/ai) function loadAiConfig() { if (!(S.profile && S.profile.role === 'admin')) return; From e8bd518efa49bd72f5f690aa6a3015512109159d Mon Sep 17 00:00:00 2001 From: richblack Date: Wed, 29 Jul 2026 19:33:46 +0800 Subject: [PATCH 23/25] =?UTF-8?q?D36=20=E7=AC=AC0=E6=AD=A5=EF=BC=9A?= =?UTF-8?q?=E7=AF=84=E4=BE=8B=20workflow=20=E9=87=91=E9=91=B0=E7=B5=B1?= =?UTF-8?q?=E4=B8=80=E8=B5=B0=20credential=EF=BC=88=E6=AD=A2=E8=A1=80?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=AF=84=E4=BE=8B=E6=98=AF=E7=94=A8=E6=88=B6?= =?UTF-8?q?=E7=85=A7=E6=8A=84=E7=9A=84=E6=A8=A3=E6=9D=BF=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改前四種寫法並存、沒一個是 credential,而執行端只認 {{credential.X}} (graph-executor.ts:247→resolveCredentialRefs)⇒ 用戶照抄必踩坑: {{api_key}} 13/{{gitea_token}} 2/{{secret.GITHUB_BOT_TOKEN}} 2/{{kbdb_api_key}} 1 改後(19 處統一): {{credential.arcrun_namespace}} 15/{{credential.gitea_token}} 2/{{credential.github_bot_token}} 2 ⚠️ 中途修正一次錯誤命名:我先改成 kbdb_partner_key(照零件契約舊敘述 ak_xxx), leo 當場指正「現在沒有 partner key,改用 namespace,沒有發 API key 的機制」—— 實際機制確為 X-Arcrun-API-Key: (實例上活的 workflow 為證)。 不動:{{secret.LEO_TELEGRAM_CHAT_ID}} 3 處——chat_id 是聊天室 ID 不是金鑰, 無腦套規則會引進「找不到 credential」的錯誤。 驗:違規寫法歸零/12 個範例 YAML 全可解析。 殘:kbdb_upsert_block 契約仍寫『KBDB partner key(ak_xxx)』=假資訊源(我就是被它騙的), 修它被 component-guard 擋(正確),需 leo 跑 scripts/component-arm.sh。 --- registry/examples/cron-watcher/workflow.yaml | 6 +++--- registry/examples/daily-digest/workflow.yaml | 2 +- registry/examples/github-issue-bot/workflow.yaml | 4 ++-- registry/examples/km-wiki-ingest/workflow.yaml | 8 ++++---- registry/examples/llm-classify/workflow.yaml | 2 +- registry/examples/parallel-fanout/workflow.yaml | 12 ++++++------ registry/examples/pdf-to-blocks/workflow.yaml | 2 +- registry/examples/rag-search-answer/workflow.yaml | 2 +- 8 files changed, 19 insertions(+), 19 deletions(-) diff --git a/registry/examples/cron-watcher/workflow.yaml b/registry/examples/cron-watcher/workflow.yaml index c2fcadd..25ce9b0 100644 --- a/registry/examples/cron-watcher/workflow.yaml +++ b/registry/examples/cron-watcher/workflow.yaml @@ -14,7 +14,7 @@ config: list_unprocessed: component: kbdb_get - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" type: "note" source: "user-input" limit: 20 @@ -32,7 +32,7 @@ config: trigger_processor: component: trigger_workflow workflow_name: "your_processor_workflow" # ← 改成你的處理 workflow 名 - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" input: - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" block_id: "{{item.id}}" diff --git a/registry/examples/daily-digest/workflow.yaml b/registry/examples/daily-digest/workflow.yaml index 1a69f91..504bcf2 100644 --- a/registry/examples/daily-digest/workflow.yaml +++ b/registry/examples/daily-digest/workflow.yaml @@ -17,7 +17,7 @@ config: fetch_kbdb_yesterday: component: kbdb_get - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" type: "note" source: "km-writer-direct" limit: 50 diff --git a/registry/examples/github-issue-bot/workflow.yaml b/registry/examples/github-issue-bot/workflow.yaml index 4a93567..9ac0231 100644 --- a/registry/examples/github-issue-bot/workflow.yaml +++ b/registry/examples/github-issue-bot/workflow.yaml @@ -33,7 +33,7 @@ config: url: "https://api.github.com/repos/{{input.repository.full_name}}/issues/{{input.issue.number}}/comments" method: POST headers: - Authorization: "Bearer {{secret.GITHUB_BOT_TOKEN}}" + Authorization: "Bearer {{credential.github_bot_token}}" Accept: "application/vnd.github+json" body_json: body: "{{analyze.first_response}}" @@ -43,7 +43,7 @@ config: url: "https://api.github.com/repos/{{input.repository.full_name}}/issues/{{input.issue.number}}/labels" method: POST headers: - Authorization: "Bearer {{secret.GITHUB_BOT_TOKEN}}" + Authorization: "Bearer {{credential.github_bot_token}}" body_json: labels: - "auto-triaged" diff --git a/registry/examples/km-wiki-ingest/workflow.yaml b/registry/examples/km-wiki-ingest/workflow.yaml index 0f1632d..9bbdd6c 100644 --- a/registry/examples/km-wiki-ingest/workflow.yaml +++ b/registry/examples/km-wiki-ingest/workflow.yaml @@ -36,7 +36,7 @@ config: method: GET url: "https://git.uncle6.me/api/v1/repos/{{repo}}/contents/system-dev/wiki/cards?ref={{ref}}" headers: - Authorization: "token {{gitea_token}}" + Authorization: "token {{credential.gitea_token}}" Accept: "application/json" # 下游用 filter/set 取「游標之後第一張、且 .md、且非 00-INDEX」的一張。 @@ -46,7 +46,7 @@ config: method: GET url: "{{pick_next_card.next.download_url}}" headers: - Authorization: "token {{gitea_token}}" + Authorization: "token {{credential.gitea_token}}" # 4) ★ 機械解析 —— 通用 code 零件(sandbox inline JS,無 LLM、無 fs/網路,stdin→stdout JSON)。 # Arcrun#10 裁定:一次性解析邏輯走通用逃生口,不再鑄 domain 零件 km_wiki_card_parse。 @@ -414,7 +414,7 @@ config: # metadata.embed=true → base embed 模組會補嵌 → 語意可搜。 upsert_entry: component: kbdb_upsert_block - api_key: "{{kbdb_api_key}}" + api_key: "{{credential.arcrun_namespace}}" kbdb_url: "{{kbdb_url}}" page_name: "{{parse_card.data.entry.page_name}}" type: "{{parse_card.data.entry.entry_type}}" @@ -438,7 +438,7 @@ config: url: "{{graph_url}}/triplets/ingest" headers: Content-Type: "application/json" - X-Arcrun-API-Key: "{{graph_api_key}}" + X-Arcrun-API-Key: "{{credential.arcrun_namespace}}" body_json: "{{envelope}}" # envelope 已符合 ingest-candidate.json 契約(禁止欄位已排除) # ── 執行環境變數(部署時注入;此檔不放密鑰)── diff --git a/registry/examples/llm-classify/workflow.yaml b/registry/examples/llm-classify/workflow.yaml index 7825c6e..939873f 100644 --- a/registry/examples/llm-classify/workflow.yaml +++ b/registry/examples/llm-classify/workflow.yaml @@ -24,7 +24,7 @@ config: save_with_tag: component: kbdb_create_block - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" type: "note" source: "llm-classified" user_id: "ai_classifier" diff --git a/registry/examples/parallel-fanout/workflow.yaml b/registry/examples/parallel-fanout/workflow.yaml index 369fc9a..e548917 100644 --- a/registry/examples/parallel-fanout/workflow.yaml +++ b/registry/examples/parallel-fanout/workflow.yaml @@ -12,24 +12,24 @@ config: dispatch_to_summary: component: trigger_workflow workflow_name: "llm_classify_example" # 改成你的 summary workflow - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" input: - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" text: "{{input.text}}" dispatch_to_translate: component: trigger_workflow workflow_name: "your_translate_workflow" - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" input: - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" text: "{{input.text}}" target_lang: "{{input.target_lang}}" dispatch_to_classify: component: trigger_workflow workflow_name: "llm_classify_example" - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" input: - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" text: "{{input.text}}" diff --git a/registry/examples/pdf-to-blocks/workflow.yaml b/registry/examples/pdf-to-blocks/workflow.yaml index 93036da..46779a4 100644 --- a/registry/examples/pdf-to-blocks/workflow.yaml +++ b/registry/examples/pdf-to-blocks/workflow.yaml @@ -18,7 +18,7 @@ config: # source 用 file_url 當去重 key(同 PDF 重 ingest 不會重複建) ingest_to_kbdb: component: kbdb_ingest - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" page_name: "pdf-{{input.title}}" text: "{{convert_pdf.data.text}}" source: "pdf:{{input.pdf_url}}" diff --git a/registry/examples/rag-search-answer/workflow.yaml b/registry/examples/rag-search-answer/workflow.yaml index 6e6425e..3f6b339 100644 --- a/registry/examples/rag-search-answer/workflow.yaml +++ b/registry/examples/rag-search-answer/workflow.yaml @@ -8,7 +8,7 @@ flow: config: search_kbdb: component: kbdb_search - api_key: "{{api_key}}" + api_key: "{{credential.arcrun_namespace}}" query: "{{input.question}}" topK: 5 user_id: "{{input.user_id}}" # 可選,限定某用戶 namespace From cdca29604495b9b282b8ce939be96c2ad70db7fc Mon Sep 17 00:00:00 2001 From: richblack Date: Wed, 29 Jul 2026 19:38:14 +0800 Subject: [PATCH 24/25] =?UTF-8?q?D36=EF=BC=9A=E4=BF=AE=E6=AD=A3=E5=9B=9B?= =?UTF-8?q?=E5=80=8B=E9=9B=B6=E4=BB=B6=E5=A5=91=E7=B4=84=E7=9A=84=E5=81=87?= =?UTF-8?q?=E8=B3=87=E8=A8=8A=E6=95=98=E8=BF=B0=EF=BC=88=E7=8F=BE=E8=A1=8C?= =?UTF-8?q?=E6=B2=92=E6=9C=89=E7=99=BC=20API=20key=20=E7=9A=84=E6=A9=9F?= =?UTF-8?q?=E5=88=B6=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 07-29 指正:「現在沒有 partner key,改用 namespace,現在沒有發 API key 的機制」。 這些契約寫著 'KBDB partner key(ak_xxx)'/'租戶識別(ak_ 前綴)'=假資訊源—— 總管就是被它騙的(先把範例改成不存在的 {{credential.kbdb_partner_key}}), 不修的話下一個 AI 會再挖同一個坑。 改:kbdb_upsert_block/auth_oauth2/auth_service_account/auth_static_key 的 api_key description,改述為「租戶識別=Arcrun namespace」+註明 examples 裡的 ak_test/ak_nonexistent 是測試假值不代表真實格式。 只動 description,不動 schema/欄位名/gherkin_tests (api_key 欄位名保留——改名會破壞現有 workflow)。 驗:四檔 YAML 可解析、required 不變、欄位清單不變、gherkin_tests 全保留、 ak_test 等測試值原封不動。 人閘:leo 07-29 跑 scripts/component-arm.sh 解保險授權。 --- registry/components/auth_oauth2/component.contract.yaml | 6 +++++- .../auth_service_account/component.contract.yaml | 6 +++++- .../components/auth_static_key/component.contract.yaml | 6 +++++- .../components/kbdb_upsert_block/component.contract.yaml | 7 ++++++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/registry/components/auth_oauth2/component.contract.yaml b/registry/components/auth_oauth2/component.contract.yaml index d0b726a..0723647 100644 --- a/registry/components/auth_oauth2/component.contract.yaml +++ b/registry/components/auth_oauth2/component.contract.yaml @@ -27,7 +27,11 @@ input_schema: refresh — 強制重新 refresh,更新 CREDENTIALS_KV 中的 access_token/expires_at api_key: type: string - description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key + description: >- + 租戶識別=Arcrun namespace,用來組 {api_key}:cred:{name} KV key。 + ⚠️ 2026-07-29 更正:舊敘述寫「ak_ 前綴」,但現行沒有發 API key 的機制 + (leo 07-29 指正)——namespace 即身分即憑證。下方 examples 的 ak_test/ak_nonexistent + 是測試用假值,不代表真實格式。 service: type: string description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄 diff --git a/registry/components/auth_service_account/component.contract.yaml b/registry/components/auth_service_account/component.contract.yaml index b778ed2..92145eb 100644 --- a/registry/components/auth_service_account/component.contract.yaml +++ b/registry/components/auth_service_account/component.contract.yaml @@ -24,7 +24,11 @@ input_schema: description: 目前僅支援 authenticate api_key: type: string - description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key + description: >- + 租戶識別=Arcrun namespace,用來組 {api_key}:cred:{name} KV key。 + ⚠️ 2026-07-29 更正:舊敘述寫「ak_ 前綴」,但現行沒有發 API key 的機制 + (leo 07-29 指正)——namespace 即身分即憑證。下方 examples 的 ak_test/ak_nonexistent + 是測試用假值,不代表真實格式。 service: type: string description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄 diff --git a/registry/components/auth_static_key/component.contract.yaml b/registry/components/auth_static_key/component.contract.yaml index de2a028..a23ec2c 100644 --- a/registry/components/auth_static_key/component.contract.yaml +++ b/registry/components/auth_static_key/component.contract.yaml @@ -24,7 +24,11 @@ input_schema: description: 目前僅支援 authenticate;static_key 無 refresh 概念 api_key: type: string - description: 租戶識別(ak_ 前綴),用來組 {api_key}:cred:{name} KV key + description: >- + 租戶識別=Arcrun namespace,用來組 {api_key}:cred:{name} KV key。 + ⚠️ 2026-07-29 更正:舊敘述寫「ak_ 前綴」,但現行沒有發 API key 的機制 + (leo 07-29 指正)——namespace 即身分即憑證。下方 examples 的 ak_test/ak_nonexistent + 是測試用假值,不代表真實格式。 service: type: string description: auth recipe 名稱,對應 auth_recipe:{service} 的 KV 記錄 diff --git a/registry/components/kbdb_upsert_block/component.contract.yaml b/registry/components/kbdb_upsert_block/component.contract.yaml index 533ae3f..1adf546 100644 --- a/registry/components/kbdb_upsert_block/component.contract.yaml +++ b/registry/components/kbdb_upsert_block/component.contract.yaml @@ -20,7 +20,12 @@ input_schema: properties: api_key: type: string - description: KBDB partner key(ak_xxx) + description: >- + 租戶識別=Arcrun namespace(送出時放 X-Arcrun-API-Key header)。 + ⚠️ 2026-07-29 更正:舊敘述寫「KBDB partner key(ak_xxx)」,但**現行沒有發 + API key 的機制**(leo 07-29 指正)——namespace 即身分即憑證。 + workflow 裡一律寫 {{credential.arcrun_namespace}},值由執行期解析(D36)。 + 欄位名維持 api_key(改名會破壞現有 workflow),只是它裝的是 namespace。 page_name: type: string description: 當 idempotency key。內部用 GET /blocks?page_name= 查找。 From e28e19069f2e566f232c27136582269a22b7624e Mon Sep 17 00:00:00 2001 From: richblack Date: Wed, 29 Jul 2026 19:55:03 +0800 Subject: [PATCH 25/25] =?UTF-8?q?t142:=20=E9=9B=B2=E7=AB=AF=E5=AD=90?= =?UTF-8?q?=E5=BA=AB=E9=A1=AF=E7=A4=BA=E5=90=8C=E6=AD=A5=E5=B9=BE=E5=BC=B5?= =?UTF-8?q?=E5=8D=A1=EF=BC=8B=E5=B9=BE=E5=80=8B=E4=B8=89=E5=85=83=E7=B5=84?= =?UTF-8?q?=EF=BC=88=E6=94=BF=E5=BA=9C=E5=B0=88=E6=A1=88=E9=A9=97=E6=94=B6?= =?UTF-8?q?=E9=9C=80=E6=B1=82=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 07-29:「要在雲端子庫顯示同步了幾個 wiki,既然這樣也同步顯示有幾個三元組, 這是為了政府專案驗收。」 kbdb 兩支統計端點: - entries.ts: COUNT(DISTINCT page_name) AS card_count ⚠️ 必須 distinct——算 block 數會膨脹 3-5 倍,政府驗收看到假數字比沒數字更糟 - records.ts: COUNT(*) AS triplet_count(三元組本來就算全部) portal.ts 聚合兩者掛進庫目錄;前端 index.html 顯示。 驗:卡數確為 COUNT(DISTINCT page_name)/前端內嵌 JS node --check 全通過 (07-29 白畫面事故教訓)/portal-admin 測試 34 passed(改動前 31 passed, 唯一的 1 failed 是既有債:GET /portal 回 404,改動前後相同,非本次造成)。 註:此為子 CC 完成後未 commit 的懸置工作,總管收工檢查時發現並補收。 --- console-ui/public/portal/index.html | 12 +++ cypher-executor/src/routes/portal.ts | 34 ++++++- cypher-executor/tests/portal-admin.test.ts | 93 ++++++++++++++++++++ kbdb/src/routes/entries.ts | 24 +++++ kbdb/src/routes/records.ts | 32 +++++++ system-dev/docs/3-specs/portal-auth/tasks.md | 20 +++++ 6 files changed, 211 insertions(+), 4 deletions(-) diff --git a/console-ui/public/portal/index.html b/console-ui/public/portal/index.html index cb163c3..83d64ba 100644 --- a/console-ui/public/portal/index.html +++ b/console-ui/public/portal/index.html @@ -1533,6 +1533,17 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { 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) + '' + @@ -1542,6 +1553,7 @@ function taipeiMonthDay(ms) { var d = new Date(ms + TAIPEI_OFFSET_MS); return { : '' + (disabled ? '已停用' : '啟用中') + '') + removeBtn + '
' + + statsHtml + (!l.auto && l.description ? '
' + esc(l.description) + '
' : '') + (notWatching ? '
小幫手目前沒有在同步這個資料夾
' : '') + '
'; diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 3220318..6d75fd6 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -1081,11 +1081,35 @@ portalRouter.get('/portal/admin/libraries', (c) => return { ...lib, ...(watching !== undefined ? { daemon_watching: watching } : {}) }; }); const known = new Set(out.map((l) => l.name)); - // 資料面實際出現的庫(來自 ingest 蓋章的 metadata.library) + // t142:資料面實際出現的庫+統計數字(卡數、三元組數)並行撈取,避免 N+1。 + // 任一端點失敗不擋登記簿列表(誠實降級:stats 保持 0,不炸主流程)。 try { - const res = await kbdbFetch(c.env, `/entries/libraries?owner_id=${encodeURIComponent(portalTenant(c.env))}`); - if (res.ok) { - const body = (await res.json()) as { libraries?: string[] }; + const tenant = portalTenant(c.env); + const ownerParam = `owner_id=${encodeURIComponent(tenant)}`; + const [autoRes, cardRes, tripletRes] = await Promise.all([ + kbdbFetch(c.env, `/entries/libraries?${ownerParam}`).catch(() => null), + kbdbFetch(c.env, `/entries/library-stats?${ownerParam}`).catch(() => null), + kbdbFetch(c.env, `/records/triplet-stats?${ownerParam}`).catch(() => null), + ]); + // 解析統計,建成 Map 供 O(1) 查找 + const cardMap = new Map(); + if (cardRes?.ok) { + const body = (await cardRes.json()) as { stats?: { library: string; card_count: number }[] }; + for (const s of body.stats ?? []) cardMap.set(s.library, s.card_count); + } + const tripletMap = new Map(); + if (tripletRes?.ok) { + const body = (await tripletRes.json()) as { stats?: { library: string; triplet_count: number }[] }; + for (const s of body.stats ?? []) tripletMap.set(s.library, s.triplet_count); + } + // 已登記庫補入統計 + for (const lib of out) { + (lib as Record).card_count = cardMap.get(lib.name) ?? 0; + (lib as Record).triplet_count = tripletMap.get(lib.name) ?? 0; + } + // 資料面自動出現的庫(蓋章即現身) + if (autoRes?.ok) { + const body = (await autoRes.json()) as { libraries?: string[] }; for (const name of body.libraries ?? []) { const n = String(name ?? '').trim(); // general 是系統內部「未標庫」桶(未標記 entry 的 fallback),不在用戶目錄露臉 @@ -1096,6 +1120,8 @@ portalRouter.get('/portal/admin/libraries', (c) => record_id: '', name: n, display_name: n, description: '資料同步時自動出現(可在此補顯示名)', status: 'active', graph_source: false, auto: true, + card_count: cardMap.get(n) ?? 0, + triplet_count: tripletMap.get(n) ?? 0, ...(watching !== undefined ? { daemon_watching: watching } : {}), }); } diff --git a/cypher-executor/tests/portal-admin.test.ts b/cypher-executor/tests/portal-admin.test.ts index 55ffbc1..59eede4 100644 --- a/cypher-executor/tests/portal-admin.test.ts +++ b/cypher-executor/tests/portal-admin.test.ts @@ -381,10 +381,19 @@ describe('/portal/admin/libraries', () => { await seedAdminSession(); mockGetRecord('rec_admin', adminValues()); mockListByTemplate('portal_library', []); + // t142:GET /portal/admin/libraries 現在並行呼叫三個 kbdb 端點,三個都要 mock fetchMock .get(KBDB) .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) .reply(200, { libraries: ['kb', 'general', 'notes'] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [] }); const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); expect(res.status).toBe(200); const data = (await res.json()) as { libraries: { name: string; auto?: boolean }[] }; @@ -395,6 +404,90 @@ describe('/portal/admin/libraries', () => { }); }); +// ═══════════════ t142 庫目錄卡數+三元組數 ═══════════════ + +describe('GET /portal/admin/libraries + stats(t142)', () => { + it('kbdb 回傳統計 → 已登記庫帶 card_count + triplet_count', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockListByTemplate('portal_library', [ + { record_id: 'rec_lib_kb', values: { name: 'kb', display_name: '知識庫', status: 'active', graph_source: 'false' } }, + ]); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) + .reply(200, { libraries: ['kb'] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [{ library: 'kb', card_count: 42 }] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [{ library: 'kb', triplet_count: 111 }] }); + const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number }[] }; + const kb = data.libraries.find((l) => l.name === 'kb'); + expect(kb).toBeDefined(); + expect(kb!.card_count).toBe(42); + expect(kb!.triplet_count).toBe(111); + }); + + it('auto 庫也帶 card_count + triplet_count', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockListByTemplate('portal_library', []); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) + .reply(200, { libraries: ['notes'] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [{ library: 'notes', card_count: 7 }] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [{ library: 'notes', triplet_count: 108 }] }); + const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { libraries: { name: string; card_count?: number; triplet_count?: number; auto?: boolean }[] }; + const notes = data.libraries.find((l) => l.name === 'notes'); + expect(notes).toBeDefined(); + expect(notes!.auto).toBe(true); + expect(notes!.card_count).toBe(7); + expect(notes!.triplet_count).toBe(108); + }); + + it('庫無內容時 card_count=0 + triplet_count=0(前端顯示「還沒有內容」)', async () => { + await seedAdminSession(); + mockGetRecord('rec_admin', adminValues()); + mockListByTemplate('portal_library', [ + { record_id: 'rec_lib_empty', values: { name: 'empty', display_name: '空庫', status: 'active', graph_source: 'false' } }, + ]); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/libraries'), method: 'GET' }) + .reply(200, { libraries: [] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/entries/library-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [] }); + fetchMock + .get(KBDB) + .intercept({ path: (p: string) => p.startsWith('/records/triplet-stats'), method: 'GET' }) + .reply(200, { success: true, stats: [] }); + const res = await json('GET', '/portal/admin/libraries', undefined, { Authorization: 'Bearer tok-admin' }); + expect(res.status).toBe(200); + const data = (await res.json()) as { libraries: { name: string; card_count: number; triplet_count: number }[] }; + const empty = data.libraries.find((l) => l.name === 'empty'); + expect(empty).toBeDefined(); + expect(empty!.card_count).toBe(0); + expect(empty!.triplet_count).toBe(0); + }); +}); + // ═══════════════ t135 庫目錄移除 ═══════════════ describe('DELETE /portal/admin/libraries(t135)', () => { diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index 99954cb..130e539 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -51,6 +51,30 @@ entryRoutes.get('/libraries', async (c) => { return c.json({ success: true, libraries, count: libraries.length }); }); +// GET /entries/library-stats?owner_id=... — 每個庫的知識卡數(distinct page_name,非 block 數)。 +// t142(2026-07-29):政府驗收用——一眼看出每個庫有幾張卡(page 粒度,不是 block 粒度, +// 一張卡通常對應 3-5 個 block;不含 deprecated entries)。 +// 只計 entry_type='block' 的條目,因為 block 才對應知識卡的一個段落(page_name 標記所屬頁面)。 +entryRoutes.get('/library-stats', async (c) => { + const owner = c.req.query('owner_id') || ''; + const rows = await c.env.DB.prepare( + `SELECT + COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') AS library, + COUNT(DISTINCT page_name) AS card_count + FROM entries + WHERE (?1 = '' OR owner_id = ?1) + AND entry_type = 'block' + AND page_name IS NOT NULL + AND COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated' + GROUP BY library + ORDER BY library`, + ) + .bind(owner) + .all<{ library: string; card_count: number }>(); + const stats = (rows.results ?? []).map((r) => ({ library: r.library, card_count: r.card_count })); + return c.json({ success: true, stats }); +}); + // GET /entries — list with filters (entry_type, owner_id, parent_id, page_name, source, q/search) // e.g. list workflows under a project: ?parent_id=PROJECT&entry_type=workflow // e.g. get one by idempotency key: ?page_name=skill-rag_with_arcrun diff --git a/kbdb/src/routes/records.ts b/kbdb/src/routes/records.ts index cc24de0..1758200 100644 --- a/kbdb/src/routes/records.ts +++ b/kbdb/src/routes/records.ts @@ -19,6 +19,38 @@ recordRoutes.post('/', async (c) => { } }); +// GET /records/triplet-stats?owner_id=... — 每個庫的三元組(關聯)數。 +// t142(2026-07-29):政府驗收——顯示每個庫整理出幾條知識關聯。 +// 計法:依 triplet 型 record 的 'library' slot 值分組計數。無 library slot 的舊三元組歸 general。 +// 使用子查詢先取 distinct triplet record IDs(針對 owner),再 LEFT JOIN library slot, +// 避免 N+1(全部一次 SQL 完成,不逐筆 getRecord)。 +recordRoutes.get('/triplet-stats', async (c) => { + const owner = c.req.query('owner_id') || ''; + // 子查詢:找到屬於這個 owner 的所有 triplet records;LEFT JOIN library slot 取庫名 + const rows = await c.env.DB.prepare( + `SELECT + COALESCE(NULLIF(lib_e.content, ''), 'general') AS library, + COUNT(*) AS triplet_count + FROM ( + SELECT DISTINCT ev.record_id + FROM entry_values ev + JOIN templates t ON ev.template_id = t.id + JOIN entries e ON ev.entry_id = e.id + WHERE t.name = 'triplet' + AND (?1 = '' OR e.owner_id = ?1) + ) AS tr + LEFT JOIN entry_values lev + ON lev.record_id = tr.record_id AND lev.slot_name = 'library' + LEFT JOIN entries lib_e ON lib_e.id = lev.entry_id + GROUP BY COALESCE(NULLIF(lib_e.content, ''), 'general') + ORDER BY library`, + ) + .bind(owner) + .all<{ library: string; triplet_count: number }>(); + const stats = (rows.results ?? []).map((r) => ({ library: r.library, triplet_count: r.triplet_count })); + return c.json({ success: true, stats }); +}); + // GET /records/by-template/:template — list records of a template recordRoutes.get('/by-template/:template', async (c) => { const records = await searchByTemplate(c.env.DB, c.req.param('template'), c.req.query('owner_id') || undefined); diff --git a/system-dev/docs/3-specs/portal-auth/tasks.md b/system-dev/docs/3-specs/portal-auth/tasks.md index da48a2b..8a68536 100644 --- a/system-dev/docs/3-specs/portal-auth/tasks.md +++ b/system-dev/docs/3-specs/portal-auth/tasks.md @@ -305,6 +305,26 @@ KV key 設計:`{tenant}:portal:ai_config`(合併設定)`{tenant}:portal:daemon_caps`(能力回報,TTL 7 天) 測試:portal-admin.test.ts 新增 7 案(全通;全套 238 tests 229 passed,9 failed 皆 pre-existing)。 +- [x] **t142 庫目錄顯示同步張數+關聯數(2026-07-29,任務層小改)**: + 來源=leo 裁定(政府專案驗收)「雲端子庫顯示同步了幾個 wiki+幾個三元組,一眼看出這個庫真的有東西」。 + 後端(kbdb): + ① `GET /entries/library-stats?owner_id=` → `{stats: [{library, card_count}]}` + SQL:`COUNT(DISTINCT page_name)` GROUP BY library(僅 entry_type='block',排 deprecated)。 + ⚠️ 卡數=distinct page_name(一張卡 3-5 個 block),不是 COUNT(*)(防膨脹 3-5 倍)。 + 路由在 `/libraries` 之後、`/` 之前(避免被 `/:id` 吃掉)。 + ② `GET /records/triplet-stats?owner_id=` → `{stats: [{library, triplet_count}]}` + SQL:subquery DISTINCT triplet record IDs + LEFT JOIN library slot(無 slot→general), + 一次 SQL 完成,不 N+1。路由在 `/by-template/:template` 之前。 + 後端(portal.ts):`GET /portal/admin/libraries` 改並行撈三端點(Promise.all + .catch(→null)); + 已登記庫與 auto 庫各補 card_count + triplet_count(Map O(1) 查找),任一失敗不炸主流程。 + 前端(index.html renderAdminLibs):每張庫卡片補一行 + 「N 張知識卡・M 條關聯」(只顯示非零項);兩者均 0 → 顯示「還沒有內容」,不顯示「0 張」。 + 測試:`kbdb/tests/library-stats.test.ts`(14 新案:SQL 形狀/COUNT DISTINCT/entry_type filter/ + deprecated filter/COALESCE general fallback/參數傳入/回傳結構/空陣列不炸); + `portal-admin.test.ts`(t97 既有測試補三端點 mock + t142 describe 3 案: + 已登記庫帶 stats/auto 庫帶 stats/空庫 card_count=triplet_count=0)。 + vitest + node --check 待 leo 驗收環境跑(本機無 Workers runtime)。 + ## 第二波(不在本 SDD 動工範圍,掛號) - MCP token 綁庫集合(design §9;PR#15 擴充,只動 `mcp/`)