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', () => {
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')
+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.
${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.
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 }[] }[] = [
{
@@ -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();
+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.
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.
@@ -20,7 +20,7 @@
<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">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 />
<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>
@@ -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<Choice | null>(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<Choice | null>('routstr')
const key = ref('')
const model = ref('')
const status = ref<ProviderStatus | null>(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') })
</script>
@@ -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: '<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)! }
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)
@@ -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<PublishRoute[]>([])
const html = ref('')
const hostname = ref('')
const destination = ref('')
const prompt = ref('')
const model = ref('')
const showAiConnection = ref(false)
const aiConnection = ref<InstanceType<typeof AIConnectionModal>>()
function openAiCredit() { showAiConnection.value = true; aiConnection.value?.showRoutstr() }
const dns = ref<DnsPlan | null>(null)
const httpsCheck = ref<HttpsCheck | null>(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)
</section>
</template>
<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">
<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>
@@ -397,12 +401,6 @@ onMounted(refresh)
<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>
<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">
<label class="block">Website HTML<textarea v-model="html" rows="8" class="field mt-2 font-mono text-xs" spellcheck="false" /></label>
</div></details>
@@ -584,6 +582,7 @@ onMounted(refresh)
</section>
</template>
</SetupWalkthrough>
<AIConnectionModal ref="aiConnection" :show="showAiConnection" @close="showAiConnection = false" />
</main>
</template>
@@ -6,6 +6,7 @@ const appStore = vi.hoisted(() => ({ installPackage: vi.fn(), data: { 'package-d
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('@/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 }))
import PublishingSetup from '../PublishingSetup.vue'
import { rpcClient } from '@/api/rpc-client'
@@ -18,6 +19,19 @@ beforeEach(() => {
api.update.mockResolvedValue({ state: { ...state(), version: 3 }, project_id: null })
})
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 () => {
page.name = 'publish-website'
appStore.data['package-data'].blossom = { state: 'installed' }
@@ -64,8 +64,8 @@ async function apply() {
>{{ active ? 'enabled' : 'off' }}</span>
</div>
<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
model can't take a request. It never spends without a prepaid ceiling you set here —
Routstr is pay-per-use AI inference, paid in sats from this node’s ecash wallet.
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
when the ceiling is reached; raising it widens the remainder without erasing the
spend history.