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:
@@ -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 萃取知識卡用的 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>
|
||||
<!-- 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 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 = '<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>'
|
||||
|
||||
@@ -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<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_config(daemon/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);
|
||||
|
||||
// 更新 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<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_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);
|
||||
|
||||
@@ -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<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_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<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=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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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/`)
|
||||
|
||||
Reference in New Issue
Block a user