feat(t131): AI 設定合併成一把金鑰——聊天與萃取共用,Claude 為選填加強

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=總管)
This commit is contained in:
uncle6me-web
2026-07-29 15:28:10 +08:00
parent 0860e84d22
commit 9d38d580f2
4 changed files with 412 additions and 127 deletions
+92 -123
View File
@@ -347,36 +347,32 @@
<div style="margin-top:8px;font-size:13px;line-height:1.7;color:rgba(var(--ink-rgb),.5)">封測版未簽章,第一次請右鍵→打開。裝好第一次開啟時,貼上這個網址+你的帳號密碼就連上了。</div>
</div>
</div>
<div class="panel">
<div style="font-size:17px;font-weight:600">AI 問答金鑰</div>
<div style="margin-top:4px;font-size:14px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">用來啟用「問 AI」。到 <a href="https://aistudio.google.com" target="_blank" rel="noopener">aistudio.google.com</a> 免費申請。金鑰只存在你自己的知識庫裡。</div>
<div style="margin-top:12px;display:flex;flex-direction:column;gap:10px">
<input type="password" id="st-key" class="txt" placeholder="貼上 Google AI 金鑰(留空=不變更)" autocomplete="off">
<button class="btn" id="st-key-save">儲存金鑰</button>
<div id="st-key-status" style="font-size:14px;min-height:1.2em"></div>
</div>
</div>
<!-- t122 萃取引擎(daemon 萃取知識卡用的 AIadmin-level,同 chat-keyserver 再強制閘) -->
<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>
<!-- t131 合併 AI 設定(Gemini API Key 必填;Claude 加強版視 daemon 回報 enable -->
<div class="panel" id="st-ai-panel">
<div style="font-size:17px;font-weight:600">AI 設定</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:16px">
<!-- 第一格:Gemini API Key(必填,聊天+萃取共用) -->
<div>
<div style="font-size:14px;font-weight:500;margin-bottom:4px">Gemini API Key</div>
<div style="font-size:13px;line-height:1.6;color:rgba(var(--ink-rgb),.55);margin-bottom:8px">聊天問答與文件萃取都用這一把。<a href="https://aistudio.google.com/apikey" target="_blank" rel="noopener">免費申請</a>,金鑰只存在你自己的知識庫裡。</div>
<input type="password" id="st-ai-key" class="txt" placeholder="貼上 Gemini API Key" autocomplete="off">
</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">
<!-- 第二格:Claude 加強版(選填,依 daemon 回報 enable -->
<div style="padding-top:12px;border-top:1px solid rgba(var(--ink-rgb),.08)">
<div style="font-size:14px;font-weight:500;margin-bottom:6px">讓知識卡整理得更好(選填)</div>
<label style="display:flex;gap:8px;align-items:flex-start;cursor:pointer">
<input type="checkbox" id="st-ai-use-claude" style="margin-top:3px;flex:none" disabled>
<span style="font-size:14px">本地萃取改用 Claude Code</span>
</label>
<div id="st-ai-claude-desc" style="margin-top:8px;font-size:13px;line-height:1.65;color:rgba(var(--ink-rgb),.55)">
讀你文件、整理成知識卡的那個 AI,換成更強的模型。<b style="color:var(--ink)">卡片會更抓得到重點、關聯也連得更準</b>,之後搜尋和問答的品質跟著提升。需要你的電腦已安裝 Claude Code。不填就用上面那把 Gemini,一樣能用。
</div>
<div id="st-ai-claude-hint" style="margin-top:6px;font-size:13px;color:rgba(var(--ink-rgb),.4);display:none"></div>
</div>
<div>
<button class="btn" id="st-ai-save">儲存 AI 設定</button>
<div id="st-ai-status" style="font-size:14px;min-height:1.2em;margin-top:8px"></div>
</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>
@@ -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 KeyClaude 加強版)
(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 = '<div class="muted">載入中…</div>'; return; }
if (window._loadExtractorConfig) window._loadExtractorConfig();
if (window._loadAiConfig) window._loadAiConfig();
var libs = p.libraries || [];
var libHtml = libs.indexOf('*') >= 0
? '<span class="tag green">全部知識庫</span>'
+145 -4
View File
@@ -838,10 +838,149 @@ portalRouter.post('/portal/daemon/config', (c) =>
}),
);
// POST /portal/admin/chat-key — body {key}。站內啟用 AI 問答(t53leo 07-25
// 「他進到網站要去給 gemini API Key」——不再回安裝器)。手法=G10 直嵌同款:
// 改寫 tenant 的 rag_chat workflow 記錄,把 x-goog-api-key 換成實值(KV 覆寫=重推)。
// 金鑰只過境不落 log;admin 閘(與庫登記同級)。
// ── t131 合併 AI 設定(Gemini API Key 同時設 chatextractorhas_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<AiConfig | null> {
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<DaemonCapabilities | null> {
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_configdaemon/config 讀 extractor_config,保持相容)。
async function syncExtractorFromAiConfig(env: Bindings, cfg: AiConfig): Promise<void> {
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);
// 更新 chatrag_chat workflow)——容忍 404workflow 未安裝時暫存,安裝後再寫入)
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<string, unknown>;
const visit = (o: unknown): void => {
if (Array.isArray(o)) { o.forEach(visit); return; }
if (o && typeof o === 'object') {
const rec = o as Record<string, unknown>;
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_configdaemon/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_availablet131)。
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);
+160
View File
@@ -556,5 +556,165 @@ describe('GET /portalP4 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-capabilitiest131', () => {
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<string, string> {
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_configextractor_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<string, string> }> }).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=trueextractor 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=trueclaude_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 — 有 claudedaemon_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);
});
});
@@ -290,6 +290,21 @@
移除後即時從列表消失(不必重整);`daemon_watching=false` → 顯示灰字「小幫手目前沒有在同步這個資料夾」。
測試:portal-admin.test.ts 新增 7 案(DELETE/:id 成功/404/非admin403by-name 成功/無confirm/confirm不符/非admin403)。
- [x] **t131 金鑰設定合併(2026-07-29,任務層小改)**
來源=leo 裁定「一律規定先輸入 gemini api key,聊天和萃都一次設好;如果有 claude,勾選加強版」。
後端:新增 `POST /portal/admin/ai`(同時做 chat-keyextractor 的事)+
`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 passed9 failed 皆 pre-existing)。
## 第二波(不在本 SDD 動工範圍,掛號)
- MCP token 綁庫集合(design §9PR#15 擴充,只動 `mcp/`