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 @@
+
+
@@ -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/`)