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', () => {
|
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')
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user