diff --git a/aiui/packages/app/src/__tests__/useAI.test.ts b/aiui/packages/app/src/__tests__/useAI.test.ts index dcad6757..d0f03df5 100644 --- a/aiui/packages/app/src/__tests__/useAI.test.ts +++ b/aiui/packages/app/src/__tests__/useAI.test.ts @@ -61,6 +61,12 @@ function mockClaudeResponse(events: string[]) { } } +it('starts on Routstr before any saved provider selection', () => { + setActivePinia(createPinia()) + const { activeProvider } = useAI() + expect(activeProvider.value).toBe('routstr') +}) + describe('useAI', () => { beforeEach(() => { setActivePinia(createPinia()) @@ -74,11 +80,6 @@ describe('useAI', () => { }) describe('provider selection', () => { - it('defaults to claude provider', () => { - const { activeProvider } = useAI() - expect(activeProvider.value).toBe('claude') - }) - it('switches provider via setProvider', () => { const { setProvider, activeProvider, activeModel } = useAI() setProvider('openrouter') diff --git a/aiui/packages/app/src/composables/useAI.ts b/aiui/packages/app/src/composables/useAI.ts index f1520a3a..588db2f1 100644 --- a/aiui/packages/app/src/composables/useAI.ts +++ b/aiui/packages/app/src/composables/useAI.ts @@ -120,9 +120,9 @@ Prioritize Podcasting 2.0–friendly platforms: Fountain.fm, Podcast Index, Cast Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out. ${librarySection}` -const activeProvider = ref(archyBridge.isInArchy() ? 'auto' : 'claude') +const activeProvider = ref('routstr') -const activeModel = ref('claude-haiku-4.5') +const activeModel = ref('routstr-unavailable') // Credentials require setup; network failures and provider outages require retry. const needsApiKey = ref(false) @@ -160,11 +160,11 @@ async function refreshRoutstrModels() { const availableProviders = computed(() => { if (archyBridge.isInArchy()) return [ - { id: 'local' as Provider, name: 'Local AI', models: [{ id: 'node', name: 'Node configuration' }] }, - { id: 'auto' as Provider, name: 'Node AI', models: [{ id: 'node', name: 'Node configuration' }] }, + { id: 'routstr' as Provider, name: 'Routstr (sats)', models: routstrModels.value.length ? routstrModels.value : [{ id: 'routstr-unavailable', name: 'Models unavailable — retry' }] }, { id: 'claude' as Provider, name: 'Claude API', models: [{ id: 'node', name: 'Node configuration' }] }, { id: 'openai' as Provider, name: 'OpenAI API', models: [{ id: activeProvider.value === 'openai' ? activeModel.value : 'node', name: activeProvider.value === 'openai' ? activeModel.value : 'Configure model' }] }, - { id: 'routstr' as Provider, name: 'Routstr (sats)', models: routstrModels.value.length ? routstrModels.value : [{ id: 'routstr-unavailable', name: 'Models unavailable — retry' }] }, + { id: 'auto' as Provider, name: 'Node AI', models: [{ id: 'node', name: 'Node configuration' }] }, + { id: 'local' as Provider, name: 'Local AI', models: [{ id: 'node', name: 'Node configuration' }] }, ] const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [ { diff --git a/core/archipelago/src/settings/model_provider.rs b/core/archipelago/src/settings/model_provider.rs index f7b33d17..17867628 100644 --- a/core/archipelago/src/settings/model_provider.rs +++ b/core/archipelago/src/settings/model_provider.rs @@ -8,11 +8,11 @@ use tokio::{fs, io::AsyncWriteExt}; #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum Provider { - #[default] Auto, Claude, Openai, Local, + #[default] Routstr, } @@ -124,6 +124,33 @@ async fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { #[cfg(test)] mod tests { use super::*; + #[tokio::test] + async fn fresh_nodes_default_to_routstr_and_saved_choices_are_preserved() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!( + ModelProvider::load(dir.path()).await.unwrap().provider, + Provider::Routstr + ); + for provider in [ + Provider::Claude, + Provider::Openai, + Provider::Auto, + Provider::Local, + ] { + ModelProvider { + provider, + openai_model: "test-model".into(), + } + .save(dir.path()) + .await + .unwrap(); + assert_eq!( + ModelProvider::load(dir.path()).await.unwrap().provider, + provider + ); + } + } + #[tokio::test] async fn private_keys_replace_atomically_and_never_enter_public_settings() { use std::os::unix::fs::PermissionsExt; @@ -168,7 +195,7 @@ mod tests { assert!(invalid.save(dir.path()).await.is_err()); assert_eq!( ModelProvider::load(dir.path()).await.unwrap().provider, - Provider::Auto + Provider::Routstr ); let path = dir.path().join("settings/model-provider.json"); fs::write(&path, b"broken").await.unwrap(); diff --git a/docs/external-access-and-websites.md b/docs/external-access-and-websites.md index 577b9af6..e726766c 100644 --- a/docs/external-access-and-websites.md +++ b/docs/external-access-and-websites.md @@ -367,3 +367,21 @@ hidden, no generic login request occurred, refusal prevented upload and explicit approval stored the synthetic file. Actual phone confirmation is still pending. The archive UI is deployed with backend capability gating; UI tests cover fresh consent on snapshot changes and independent revocation. No public release made. + +### AI provider direction — 2026-10-08 + +The operator selected Routstr as the default, with Claude and OpenAI API keys as +alternatives, and deprioritized Ollama. The isolated publishing branch merged the +already accepted provider setup through commit `83ba98ab`, preserving its history. +Website design now opens that trusted dashboard setup and its existing ecash +Receive flow directly. New/missing provider settings default to Routstr; saved +provider selections remain unchanged. AIUI lists Routstr first. The Ollama draft +form was removed from the walkthrough; its compatibility RPC remains available. +The failed Framework Ollama test installation was removed through normal package +uninstall with `preserve_data: true`; no model was downloaded. + +Funding and spending permission remain separate. Opening setup/top-up does not +change the allowance, send a payment, start inference, or publish content. API +keys use the existing private node credential store and are not passed to AIUI. +Focused provider/publishing tests and production qualification are in progress; +these changes are not yet deployed or publicly released. diff --git a/neode-ui/src/components/AIConnectionModal.vue b/neode-ui/src/components/AIConnectionModal.vue index cdf574f4..efe82d8f 100644 --- a/neode-ui/src/components/AIConnectionModal.vue +++ b/neode-ui/src/components/AIConnectionModal.vue @@ -20,7 +20,7 @@

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

Ecash balance: {{ balance === null ? 'Unavailable' : balance.toLocaleString() + ' sats' }}

- +

The selected model’s price and accepted mint still apply. No payment is sent by opening this setup.

@@ -49,8 +49,8 @@ interface ProviderStatus { } const props = defineProps<{ show: boolean }>() const emit = defineEmits<{ close: []; configured: [provider: Choice | 'auto' | 'local', model?: string] }>() -const choices: { id: Choice; label: string }[] = [{ id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }, { id: 'routstr', label: 'Routstr · sats' }] -const mode = ref(null) +const choices: { id: Choice; label: string }[] = [{ id: 'routstr', label: 'Routstr · sats' }, { id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }] +const mode = ref('routstr') const key = ref('') const model = ref('') const status = ref(null) @@ -129,7 +129,7 @@ async function continueRoutstr() { } catch { error.value = 'Could not select Routstr. Try again.' } } async function finishFunding() { funding.value = false; await refreshBalance() } -watch(() => props.show, open => { if (open) { error.value = ''; void refresh() } else { key.value = ''; funding.value = false } }) +watch(() => props.show, open => { if (open) { error.value = ''; void refresh(); if (mode.value === 'routstr') void refreshBalance() } else { key.value = ''; funding.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 9210973f..48724442 100644 --- a/neode-ui/src/components/__tests__/AIConnectionModal.test.ts +++ b/neode-ui/src/components/__tests__/AIConnectionModal.test.ts @@ -1,6 +1,7 @@ import { mount, flushPromises } from '@vue/test-utils' import { beforeEach, describe, expect, it, vi } from 'vitest' import AIConnectionModal from '../AIConnectionModal.vue' +import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue' import { rpcClient } from '@/api/rpc-client' vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod'], template: '
' } })) @@ -10,6 +11,16 @@ 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('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') + expect(button(w, 'Top up with ecash').exists()).toBe(true) + expect(vi.mocked(rpcClient.call).mock.calls.every(([r]) => !['assistant.budget-set', 'system.settings.set'].includes(r.method))).toBe(true) + await button(w, 'Top up with ecash').trigger('click') + expect(w.findComponent(ReceiveBitcoinModal).props()).toMatchObject({ show: true, initialMethod: 'ecash' }) + w.unmount() + }) + it('detects absent configuration without treating a failed status query as missing keys', async () => { const w = mountModal() expect(await (w.vm as any).checkNeeded()).toBe(true) diff --git a/neode-ui/src/views/publishing/PublishingSetup.vue b/neode-ui/src/views/publishing/PublishingSetup.vue index 7d7d38a6..7178e7fc 100644 --- a/neode-ui/src/views/publishing/PublishingSetup.vue +++ b/neode-ui/src/views/publishing/PublishingSetup.vue @@ -13,6 +13,7 @@ import BackButton from '@/components/BackButton.vue' import SetupWalkthrough from '@/components/SetupWalkthrough.vue' import WebsiteArchiveSharing from '@/components/WebsiteArchiveSharing.vue' import SearchableAppSelect from '@/components/SearchableAppSelect.vue' +import AIConnectionModal from '@/components/AIConnectionModal.vue' const router = useRouter() const route = useRoute() @@ -29,8 +30,9 @@ const selected = ref([]) const html = ref('') const hostname = ref('') const destination = ref('') -const prompt = ref('') -const model = ref('') +const showAiConnection = ref(false) +const aiConnection = ref>() +function openAiCredit() { showAiConnection.value = true; aiConnection.value?.showRoutstr() } const dns = ref(null) const httpsCheck = ref(null) watch([projectId, hostname, destination, () => status.value?.state.version], () => { httpsCheck.value = null }) @@ -203,13 +205,6 @@ async function setArchiveSharing(route: 'fips' | 'tor', enable: boolean) { message.value = enable ? 'This archived snapshot is available on the selected website connection. Other Blossom files remain private.' : 'File sharing stopped on this connection. Copies already downloaded may remain.' }) } -async function generate() { - await perform(async () => { - const result = await publishing.generate(prompt.value, model.value.trim()) - html.value = result.html - message.value = 'Local model draft ready to preview. Save it to keep this revision.' - }) -} async function setTorPublication(enable: boolean) { await perform(async () => { if (!status.value || !current.value) return @@ -372,6 +367,15 @@ onMounted(refresh)