diff --git a/neode-ui/src/components/AIConnectionModal.vue b/neode-ui/src/components/AIConnectionModal.vue index 8f216e0f..0121e56a 100644 --- a/neode-ui/src/components/AIConnectionModal.vue +++ b/neode-ui/src/components/AIConnectionModal.vue @@ -8,14 +8,17 @@

{{ mode === 'openai' ? 'Use an OpenAI API key with API billing. Choose a chat model your project can access.' : 'Use your Anthropic API key. Usage is billed to that API account.' }}

-

A key is already configured. Leave this field empty to keep it.

- - +

Your {{ mode === 'claude' ? 'Claude' : 'OpenAI' }} API key from Settings is ready to use.

+ + - +

Pay for AI with ecash. Add funds to this node’s wallet and set the most it may spend.

@@ -57,6 +60,7 @@ const choices: { id: Choice; label: string }[] = [{ id: 'routstr', label: 'Routs useModalHistory(computed(() => props.show), close) const mode = ref('routstr') const key = ref('') +const replacingKey = ref(false) const model = ref('') const status = ref(null) const balance = ref(null) @@ -101,7 +105,7 @@ async function refreshBalance() { try { const result = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 8000 }); balance.value = Number.isFinite(result.balance_sats) ? result.balance_sats : null } catch { balance.value = null } } -function choose(choice: Choice) { key.value = ''; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() } +function choose(choice: Choice) { key.value = ''; replacingKey.value = false; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() } function close() { key.value = ''; funding.value = false; scanning.value = false; emit('close') } async function save() { if ((mode.value !== 'claude' && mode.value !== 'openai') || saving.value) return @@ -136,7 +140,7 @@ async function continueRoutstr() { } async function finishScan() { scanning.value = false; await refreshBalance() } async function finishFunding() { funding.value = false; await refreshBalance() } -watch(() => props.show, open => { if (open) { error.value = ''; void refresh(); if (mode.value === 'routstr') void refreshBalance() } else { key.value = ''; funding.value = false; scanning.value = false } }) +watch(() => props.show, open => { if (open) { error.value = ''; void refresh(); if (mode.value === 'routstr') void refreshBalance() } else { key.value = ''; replacingKey.value = false; funding.value = false; scanning.value = false } }) async function syncSelection() { const state = await refresh(); if (state) emit('configured', state.settings.provider, state.settings.provider === 'openai' ? state.settings.openai_model : undefined) } defineExpose({ checkNeeded, syncSelection, showRoutstr: () => choose('routstr') }) diff --git a/neode-ui/src/components/__tests__/AIConnectionModal.test.ts b/neode-ui/src/components/__tests__/AIConnectionModal.test.ts index aec2f26f..451f28fd 100644 --- a/neode-ui/src/components/__tests__/AIConnectionModal.test.ts +++ b/neode-ui/src/components/__tests__/AIConnectionModal.test.ts @@ -13,6 +13,22 @@ function mountModal() { return mount(AIConnectionModal, { props: { show: false } function button(w: ReturnType, label: string) { return w.findAll('button').find(b => b.text() === label)! } beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: state() } : {}) }) describe('AI connection setup', () => { + it('recognizes the existing Claude key and selects it without reading or rewriting the secret', async () => { + vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: { ...state(), claude_configured: true } } : {}) + const w = mountModal(); await w.setProps({ show: true }); await flushPromises() + await button(w, 'Claude API').trigger('click') + expect(w.text()).toContain('Your Claude API key from Settings is ready to use.') + expect(w.find('#ai-connection-key').exists()).toBe(false) + expect(button(w, 'Use Claude').attributes('disabled')).toBeUndefined() + await w.get('form').trigger('submit'); await flushPromises() + const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set') + expect(writes.map(r => r.params)).toEqual([{ key: 'ai_provider', value: JSON.stringify({ provider: 'claude', openai_model: '' }) }]) + expect(w.emitted('configured')).toEqual([['claude', undefined]]) + await button(w, 'Change API key').trigger('click') + expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('') + await w.setProps({ show: false }); w.unmount() + }) + it('opens on Routstr with a top-up action without authorizing spending', async () => { const w = mountModal(); await w.setProps({ show: true }); await flushPromises() expect(button(w, 'Routstr · sats').attributes('aria-pressed')).toBe('true') @@ -44,7 +60,7 @@ describe('AI connection setup', () => { await w.get('form').trigger('submit'); await flushPromises() const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set') expect(writes.map(r => r.params)).toEqual([{ key: 'openai_api_key', value: 'test-private-key' }, { key: 'ai_provider', value: JSON.stringify({ provider: 'openai', openai_model: 'test-chat-model' }) }]) - expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('') + expect(w.find('#ai-connection-key').exists()).toBe(false) expect(w.emitted('configured')).toEqual([['openai', 'test-chat-model']]) expect(JSON.stringify(w.emitted())).not.toContain('test-private-key') w.unmount()