feat: prioritize Routstr and expose website AI funding setup
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user