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
+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);
});
});