feat(t122 🔴🔴): 萃取引擎雲端設定+隨連線下發——封測者終於萃得出東西
真兇(總管查證):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=總管)
This commit is contained in:
@@ -356,6 +356,29 @@
|
||||
<div id="st-key-status" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- t122 萃取引擎(daemon 萃取知識卡用的 AI;admin-level,同 chat-key;server 再強制閘) -->
|
||||
<div class="panel" id="st-extractor-panel">
|
||||
<div style="font-size:17px;font-weight:600">萃取引擎</div>
|
||||
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">同步小幫手把你的文件變成知識卡時用的 AI。選好後請讓小幫手重連一次。</div>
|
||||
<div style="margin-top:14px;display:flex;flex-direction:column;gap:10px">
|
||||
<div style="display:flex;gap:16px;align-items:center">
|
||||
<label style="display:flex;gap:6px;align-items:center;cursor:pointer;font-size:14px">
|
||||
<input type="radio" name="st-extractor-engine" id="st-engine-gemma" value="gemma" checked>
|
||||
<span>Gemini(推薦,免費)</span>
|
||||
</label>
|
||||
<label style="display:flex;gap:6px;align-items:center;cursor:pointer;font-size:14px">
|
||||
<input type="radio" name="st-extractor-engine" id="st-engine-claude" value="claude">
|
||||
<span>Claude Code(本機已裝才選)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="st-extractor-key-row" style="display:flex;flex-direction:column;gap:8px">
|
||||
<div style="font-size:13px;color:rgba(var(--ink-rgb),.55)">到 <a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener">aistudio.google.com/apikey</a> 免費取得金鑰</div>
|
||||
<input type="password" id="st-extractor-key" class="txt" placeholder="貼上 Gemini API 金鑰(留空=不變更)" autocomplete="off">
|
||||
</div>
|
||||
<button class="btn" id="st-extractor-save">儲存萃取引擎設定</button>
|
||||
<div id="st-extractor-status" style="font-size:14px;min-height:1.2em"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn3" id="st-logout" style="padding:14px;font-size:16px;border-radius:11px">登出</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 = '<div class="muted">載入中…</div>'; return; }
|
||||
if (window._loadExtractorConfig) window._loadExtractorConfig();
|
||||
var libs = p.libraries || [];
|
||||
var libHtml = libs.indexOf('*') >= 0
|
||||
? '<span class="tag green">全部知識庫</span>'
|
||||
|
||||
@@ -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<ExtractorConfig | null> {
|
||||
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<string, string> = {
|
||||
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)——
|
||||
|
||||
@@ -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<string, string> };
|
||||
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<string, string> };
|
||||
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 () => {
|
||||
|
||||
@@ -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/`)
|
||||
|
||||
Reference in New Issue
Block a user