feat: prioritize Routstr and expose website AI funding setup

This commit is contained in:
archipelago
2026-10-08 12:35:07 -04:00
parent 02691b7d0f
commit 82367dc1d5
9 changed files with 103 additions and 33 deletions
@@ -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', () => { describe('useAI', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia()) setActivePinia(createPinia())
@@ -74,11 +80,6 @@ describe('useAI', () => {
}) })
describe('provider selection', () => { describe('provider selection', () => {
it('defaults to claude provider', () => {
const { activeProvider } = useAI()
expect(activeProvider.value).toBe('claude')
})
it('switches provider via setProvider', () => { it('switches provider via setProvider', () => {
const { setProvider, activeProvider, activeModel } = useAI() const { setProvider, activeProvider, activeModel } = useAI()
setProvider('openrouter') setProvider('openrouter')
+5 -5
View File
@@ -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. Always include these tags so the UI can render rich cards. Write a brief reason why each is worth checking out.
${librarySection}` ${librarySection}`
const activeProvider = ref<Provider>(archyBridge.isInArchy() ? 'auto' : 'claude') const activeProvider = ref<Provider>('routstr')
const activeModel = ref('claude-haiku-4.5') const activeModel = ref('routstr-unavailable')
// Credentials require setup; network failures and provider outages require retry. // Credentials require setup; network failures and provider outages require retry.
const needsApiKey = ref(false) const needsApiKey = ref(false)
@@ -160,11 +160,11 @@ async function refreshRoutstrModels() {
const availableProviders = computed(() => { const availableProviders = computed(() => {
if (archyBridge.isInArchy()) return [ if (archyBridge.isInArchy()) return [
{ id: 'local' as Provider, name: 'Local 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: 'auto' as Provider, name: 'Node AI', models: [{ id: 'node', name: 'Node configuration' }] },
{ id: 'claude' as Provider, name: 'Claude API', models: [{ id: 'node', name: 'Node configuration' }] }, { 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: '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 }[] }[] = [ const providers: { id: Provider; name: string; models: { id: string; name: string }[] }[] = [
{ {
@@ -8,11 +8,11 @@ use tokio::{fs, io::AsyncWriteExt};
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum Provider { pub enum Provider {
#[default]
Auto, Auto,
Claude, Claude,
Openai, Openai,
Local, Local,
#[default]
Routstr, Routstr,
} }
@@ -124,6 +124,33 @@ async fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[tokio::test]
async fn private_keys_replace_atomically_and_never_enter_public_settings() { async fn private_keys_replace_atomically_and_never_enter_public_settings() {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
@@ -168,7 +195,7 @@ mod tests {
assert!(invalid.save(dir.path()).await.is_err()); assert!(invalid.save(dir.path()).await.is_err());
assert_eq!( assert_eq!(
ModelProvider::load(dir.path()).await.unwrap().provider, ModelProvider::load(dir.path()).await.unwrap().provider,
Provider::Auto Provider::Routstr
); );
let path = dir.path().join("settings/model-provider.json"); let path = dir.path().join("settings/model-provider.json");
fs::write(&path, b"broken").await.unwrap(); fs::write(&path, b"broken").await.unwrap();
+18
View File
@@ -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. approval stored the synthetic file. Actual phone confirmation is still pending.
The archive UI is deployed with backend capability gating; UI tests cover fresh The archive UI is deployed with backend capability gating; UI tests cover fresh
consent on snapshot changes and independent revocation. No public release made. 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.
@@ -20,7 +20,7 @@
<div v-else-if="mode === 'routstr'" class="space-y-3"> <div v-else-if="mode === 'routstr'" class="space-y-3">
<p class="text-sm text-white/70">Pay for AI with ecash. Add funds to this node’s wallet and set the most it may spend.</p> <p class="text-sm text-white/70">Pay for AI with ecash. Add funds to this node’s wallet and set the most it may spend.</p>
<p class="text-sm text-white/70">Ecash balance: {{ balance === null ? 'Unavailable' : balance.toLocaleString() + ' sats' }}</p> <p class="text-sm text-white/70">Ecash balance: {{ balance === null ? 'Unavailable' : balance.toLocaleString() + ' sats' }}</p>
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Add ecash</button> <button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Top up with ecash</button>
<RoutstrBudgetSection /> <RoutstrBudgetSection />
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" @click="continueRoutstr">Use Routstr</button> <button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" @click="continueRoutstr">Use Routstr</button>
<p class="text-xs text-white/50">The selected model’s price and accepted mint still apply. No payment is sent by opening this setup.</p> <p class="text-xs text-white/50">The selected model’s price and accepted mint still apply. No payment is sent by opening this setup.</p>
@@ -49,8 +49,8 @@ interface ProviderStatus {
} }
const props = defineProps<{ show: boolean }>() const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ close: []; configured: [provider: Choice | 'auto' | 'local', model?: string] }>() 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 choices: { id: Choice; label: string }[] = [{ id: 'routstr', label: 'Routstr · sats' }, { id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }]
const mode = ref<Choice | null>(null) const mode = ref<Choice | null>('routstr')
const key = ref('') const key = ref('')
const model = ref('') const model = ref('')
const status = ref<ProviderStatus | null>(null) const status = ref<ProviderStatus | null>(null)
@@ -129,7 +129,7 @@ async function continueRoutstr() {
} catch { error.value = 'Could not select Routstr. Try again.' } } catch { error.value = 'Could not select Routstr. Try again.' }
} }
async function finishFunding() { funding.value = false; await refreshBalance() } 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) } 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') }) defineExpose({ checkNeeded, syncSelection, showRoutstr: () => choose('routstr') })
</script> </script>
@@ -1,6 +1,7 @@
import { mount, flushPromises } from '@vue/test-utils' import { mount, flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import AIConnectionModal from '../AIConnectionModal.vue' import AIConnectionModal from '../AIConnectionModal.vue'
import ReceiveBitcoinModal from '../ReceiveBitcoinModal.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod'], template: '<div />' } })) vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod'], template: '<div />' } }))
@@ -10,6 +11,16 @@ function mountModal() { return mount(AIConnectionModal, { props: { show: false }
function button(w: ReturnType<typeof mountModal>, label: string) { return w.findAll('button').find(b => b.text() === label)! } function button(w: ReturnType<typeof mountModal>, 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() } : {}) }) beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: state() } : {}) })
describe('AI connection setup', () => { 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 () => { it('detects absent configuration without treating a failed status query as missing keys', async () => {
const w = mountModal() const w = mountModal()
expect(await (w.vm as any).checkNeeded()).toBe(true) expect(await (w.vm as any).checkNeeded()).toBe(true)
@@ -13,6 +13,7 @@ import BackButton from '@/components/BackButton.vue'
import SetupWalkthrough from '@/components/SetupWalkthrough.vue' import SetupWalkthrough from '@/components/SetupWalkthrough.vue'
import WebsiteArchiveSharing from '@/components/WebsiteArchiveSharing.vue' import WebsiteArchiveSharing from '@/components/WebsiteArchiveSharing.vue'
import SearchableAppSelect from '@/components/SearchableAppSelect.vue' import SearchableAppSelect from '@/components/SearchableAppSelect.vue'
import AIConnectionModal from '@/components/AIConnectionModal.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -29,8 +30,9 @@ const selected = ref<PublishRoute[]>([])
const html = ref('') const html = ref('')
const hostname = ref('') const hostname = ref('')
const destination = ref('') const destination = ref('')
const prompt = ref('') const showAiConnection = ref(false)
const model = ref('') const aiConnection = ref<InstanceType<typeof AIConnectionModal>>()
function openAiCredit() { showAiConnection.value = true; aiConnection.value?.showRoutstr() }
const dns = ref<DnsPlan | null>(null) const dns = ref<DnsPlan | null>(null)
const httpsCheck = ref<HttpsCheck | null>(null) const httpsCheck = ref<HttpsCheck | null>(null)
watch([projectId, hostname, destination, () => status.value?.state.version], () => { httpsCheck.value = 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.' 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) { async function setTorPublication(enable: boolean) {
await perform(async () => { await perform(async () => {
if (!status.value || !current.value) return if (!status.value || !current.value) return
@@ -372,6 +367,15 @@ onMounted(refresh)
</section> </section>
</template> </template>
<template #design> <template #design>
<section class="space-y-4">
<h2 class="text-lg font-semibold">Your AI connection</h2>
<p class="text-sm text-white/60">Use Routstr by default, or choose Claude or OpenAI with your API key. Your selected provider creates the draft; you review it here before publishing.</p>
<div class="flex flex-wrap items-center gap-3">
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="showAiConnection = true">Choose AI provider</button>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" @click="openAiCredit">Routstr credit and top up</button>
</div>
<p class="text-xs text-white/50">Routstr uses your node’s ecash balance within the spending allowance you set. Topping up and changing the allowance are separate choices.</p>
</section>
<section v-if="websiteMode && pendingWebsiteHtml !== null" class="space-y-3"> <section v-if="websiteMode && pendingWebsiteHtml !== null" class="space-y-3">
<h2 class="text-lg font-semibold">Continue from AIUI</h2> <h2 class="text-lg font-semibold">Continue from AIUI</h2>
<p class="text-sm text-white/60">Create a new project from the HTML you selected in AIUI. Existing projects remain unchanged.</p> <p class="text-sm text-white/60">Create a new project from the HTML you selected in AIUI. Existing projects remain unchanged.</p>
@@ -397,12 +401,6 @@ onMounted(refresh)
<h2 class="text-lg font-semibold">Describe and preview</h2> <h2 class="text-lg font-semibold">Describe and preview</h2>
<RouterLink to="/dashboard/chat" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium">Create with AIUI <span aria-hidden="true">↗</span></RouterLink> <RouterLink to="/dashboard/chat" class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium">Create with AIUI <span aria-hidden="true">↗</span></RouterLink>
<p class="text-sm text-white/60">In AIUI, use “Continue to website setup” on your HTML preview to bring it back here for review.</p> <p class="text-sm text-white/60">In AIUI, use “Continue to website setup” on your HTML preview to bring it back here for review.</p>
<details class="guide-details"><summary>Generate with a model on this node</summary><div class="space-y-4 pt-4">
<p class="text-sm text-white/60">Use an installed Ollama model to create a draft locally.</p>
<label class="block">What would you like to make?<textarea v-model="prompt" maxlength="16000" rows="3" class="field mt-2" placeholder="A simple website for my community garden…" /></label>
<label class="block">Installed Ollama model<input v-model="model" class="field mt-2" placeholder="Enter the exact installed model name" /></label>
<button class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium" :disabled="!prompt.trim() || !model.trim()" @click="generate">Generate locally</button>
</div></details>
<details class="guide-details"><summary>Edit or paste HTML</summary><div class="pt-4"> <details class="guide-details"><summary>Edit or paste HTML</summary><div class="pt-4">
<label class="block">Website HTML<textarea v-model="html" rows="8" class="field mt-2 font-mono text-xs" spellcheck="false" /></label> <label class="block">Website HTML<textarea v-model="html" rows="8" class="field mt-2 font-mono text-xs" spellcheck="false" /></label>
</div></details> </div></details>
@@ -584,6 +582,7 @@ onMounted(refresh)
</section> </section>
</template> </template>
</SetupWalkthrough> </SetupWalkthrough>
<AIConnectionModal ref="aiConnection" :show="showAiConnection" @close="showAiConnection = false" />
</main> </main>
</template> </template>
@@ -6,6 +6,7 @@ const appStore = vi.hoisted(() => ({ installPackage: vi.fn(), data: { 'package-d
vi.mock('@/stores/app', () => ({ useAppStore: () => appStore })) vi.mock('@/stores/app', () => ({ useAppStore: () => appStore }))
vi.mock('vue-router', () => ({ useRoute: () => page, useRouter: () => ({ push: vi.fn() }), RouterLink: { props: ['to'], template: '<a :href="to"><slot /></a>' } })) vi.mock('vue-router', () => ({ useRoute: () => page, useRouter: () => ({ push: vi.fn() }), RouterLink: { props: ['to'], template: '<a :href="to"><slot /></a>' } }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } })) vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { props: ['show'], template: '<div data-testid="ai-connection" :data-open="show" />', methods: { showRoutstr() {} } } }))
vi.mock('@/services/publishing', async (original) => ({ ...await original<typeof import('@/services/publishing')>(), publishing: api })) vi.mock('@/services/publishing', async (original) => ({ ...await original<typeof import('@/services/publishing')>(), publishing: api }))
import PublishingSetup from '../PublishingSetup.vue' import PublishingSetup from '../PublishingSetup.vue'
import { rpcClient } from '@/api/rpc-client' import { rpcClient } from '@/api/rpc-client'
@@ -18,6 +19,19 @@ beforeEach(() => {
api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null }) api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null })
}) })
describe('publishing setup', () => { describe('publishing setup', () => {
it('offers provider setup and credit from website design without starting generation', async () => {
page.name = 'publish-website'
appStore.data['package-data'].blossom = { state: 'installed' }
const wrapper = mount(PublishingSetup); await flushPromises()
expect(wrapper.text()).toContain('Use Routstr by default')
expect(wrapper.text()).not.toContain('Installed Ollama model')
await wrapper.findAll('button').find(b => b.text() === 'Routstr credit and top up')!.trigger('click')
expect(wrapper.get('[data-testid="ai-connection"]').attributes('data-open')).toBe('true')
expect(api.generate).not.toHaveBeenCalled()
expect(api.update).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps unpublish controls available when a published route is deselected', async () => { it('keeps unpublish controls available when a published route is deselected', async () => {
page.name = 'publish-website' page.name = 'publish-website'
appStore.data['package-data'].blossom = { state: 'installed' } appStore.data['package-data'].blossom = { state: 'installed' }
@@ -64,8 +64,8 @@ async function apply() {
>{{ active ? 'enabled' : 'off' }}</span> >{{ active ? 'enabled' : 'off' }}</span>
</div> </div>
<p class="text-sm text-white/60 mb-5"> <p class="text-sm text-white/60 mb-5">
Routstr is pay-per-use AI inference, paid in sats over Cashu, used when your local Routstr is pay-per-use AI inference, paid in sats from this node’s ecash wallet.
model can't take a request. It never spends without a prepaid ceiling you set here — This allowance limits spending; it does not add funds to the wallet. It never spends without a ceiling you set here —
at <span class="font-mono">0</span> it is completely disabled. The assistant stops at <span class="font-mono">0</span> it is completely disabled. The assistant stops
when the ceiling is reached; raising it widens the remainder without erasing the when the ceiling is reached; raising it widens the remainder without erasing the
spend history. spend history.