feat(server): stripe integration and credits system (#1024)

This commit is contained in:
RainbowBird
2026-03-28 02:25:44 +08:00
parent f92b07defb
commit fc51738209
27 changed files with 2477 additions and 87 deletions
+10 -1
View File
@@ -6,4 +6,13 @@ AUTH_GOOGLE_CLIENT_SECRET="change-me"
AUTH_GITHUB_CLIENT_ID="change-me"
AUTH_GITHUB_CLIENT_SECRET="change-me"
# OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318"
STRIPE_SECRET_KEY="change-me"
STRIPE_WEBHOOK_SECRET="change-me"
BACKEND_LLM_API_KEY="change-me"
BACKEND_LLM_BASE_URL="change-me"
CLIENT_URL="change-me"
FLUX_PER_CENT=1
FLUX_PER_REQUEST=1
@@ -0,0 +1,8 @@
CREATE TABLE "user_flux" (
"user_id" text PRIMARY KEY NOT NULL,
"flux" integer DEFAULT 0 NOT NULL,
"stripe_customer_id" text,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_flux" ADD CONSTRAINT "user_flux_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1772532674887,
"tag": "0000_mean_slipstream",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1772532802115,
"tag": "0001_magenta_skrulls",
"breakpoints": true
}
]
}
+2
View File
@@ -39,6 +39,8 @@
"hono": "catalog:",
"injeca": "catalog:",
"pg": "^8.20.0",
"postgres": "^3.4.8",
"stripe": "catalog:",
"tsx": "^4.21.0",
"valibot": "catalog:"
},
+32 -4
View File
@@ -1,3 +1,4 @@
import type { Env } from './libs/env'
import type { HonoEnv } from './types/hono'
import process from 'node:process'
@@ -18,9 +19,13 @@ import { sessionMiddleware } from './middlewares/auth'
import { otelMiddleware } from './middlewares/otel'
import { createCharacterRoutes } from './routes/characters'
import { createChatRoutes } from './routes/chats'
import { createFluxRoutes } from './routes/flux'
import { createProviderRoutes } from './routes/providers'
import { createStripeRoutes } from './routes/stripe'
import { createV1CompletionsRoutes } from './routes/v1completions'
import { createCharacterService } from './services/characters'
import { createChatService } from './services/chats'
import { createFluxService } from './services/flux'
import { createProviderService } from './services/providers'
import { ApiError, createInternalError } from './utils/error'
import { getTrustedOrigin } from './utils/origin'
@@ -29,6 +34,7 @@ type AuthService = ReturnType<typeof createAuth>
type CharacterService = ReturnType<typeof createCharacterService>
type ChatService = ReturnType<typeof createChatService>
type ProviderService = ReturnType<typeof createProviderService>
type FluxService = ReturnType<typeof createFluxService>
type OtelMetrics = ReturnType<typeof initOtel>
@@ -37,10 +43,11 @@ interface AppDeps {
characterService: CharacterService
chatService: ChatService
providerService: ProviderService
otel: OtelMetrics | null
fluxService: FluxService
env: Env
}
function buildApp({ auth, characterService, chatService, providerService, otel }: AppDeps) {
function buildApp({ auth, characterService, chatService, providerService, fluxService, env }: AppDeps) {
const logger = useLogger('app').useGlobalConfig()
const app = new Hono<HonoEnv>()
@@ -104,6 +111,21 @@ function buildApp({ auth, characterService, chatService, providerService, otel }
* Chat routes are handled by the chat service.
*/
.route('/api/chats', createChatRoutes(chatService))
/**
* V1 routes for official provider.
*/
.route('/v1', createV1CompletionsRoutes(fluxService, env))
/**
* Flux routes.
*/
.route('/api/flux', createFluxRoutes(fluxService))
/**
* Stripe routes.
*/
.route('/api/stripe', createStripeRoutes(fluxService, env))
}
export type AppType = ReturnType<typeof buildApp>
@@ -159,14 +181,20 @@ async function createApp() {
build: ({ dependsOn }) => createChatService(dependsOn.db),
})
const fluxService = injeca.provide('services:flux', {
dependsOn: { db },
build: ({ dependsOn }) => createFluxService(dependsOn.db),
})
await injeca.start()
const resolved = await injeca.resolve({ auth, characterService, chatService, providerService, otel })
const resolved = await injeca.resolve({ auth, characterService, chatService, providerService, fluxService, env: parsedEnv })
const app = buildApp({
auth: resolved.auth,
characterService: resolved.characterService,
chatService: resolved.chatService,
providerService: resolved.providerService,
otel: resolved.otel,
fluxService: resolved.fluxService,
env: resolved.env,
})
logger.withFields({ port: 3000 }).log('Server started')
+10 -8
View File
@@ -4,7 +4,7 @@ import { env, exit } from 'node:process'
import { useLogger } from '@guiiai/logg'
import { injeca } from 'injeca'
import { nonEmpty, object, optional, parse, pipe, string } from 'valibot'
import { nonEmpty, object, optional, parse, pipe, string, transform } from 'valibot'
const EnvSchema = object({
API_SERVER_URL: optional(string(), 'http://localhost:3000'),
@@ -16,13 +16,15 @@ const EnvSchema = object({
AUTH_GITHUB_CLIENT_ID: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_ID is required')),
AUTH_GITHUB_CLIENT_SECRET: pipe(string(), nonEmpty('AUTH_GITHUB_CLIENT_SECRET is required')),
// OpenTelemetry
OTEL_SERVICE_NAMESPACE: optional(string(), 'airi'),
OTEL_SERVICE_NAME: optional(string(), 'server'),
OTEL_TRACES_SAMPLING_RATIO: optional(string(), '1.0'),
OTEL_EXPORTER_OTLP_ENDPOINT: optional(string()),
OTEL_EXPORTER_OTLP_HEADERS: optional(string()),
OTEL_DEBUG: optional(string()),
STRIPE_SECRET_KEY: optional(string()),
STRIPE_WEBHOOK_SECRET: optional(string()),
CLIENT_URL: pipe(string(), nonEmpty('CLIENT_URL is required')),
FLUX_PER_CENT: optional(pipe(string(), transform(Number)), '1'),
FLUX_PER_REQUEST: optional(pipe(string(), transform(Number)), '1'),
BACKEND_LLM_API_KEY: pipe(string(), nonEmpty('BACKEND_LLM_API_KEY is required')),
BACKEND_LLM_BASE_URL: pipe(string(), nonEmpty('BACKEND_LLM_BASE_URL is required')),
})
export type Env = InferOutput<typeof EnvSchema>
+16
View File
@@ -0,0 +1,16 @@
import type { FluxService } from '../services/flux'
import type { HonoEnv } from '../types/hono'
import { Hono } from 'hono'
import { authGuard } from '../middlewares/auth'
export function createFluxRoutes(fluxService: FluxService) {
return new Hono<HonoEnv>()
.use('*', authGuard)
.get('/', async (c) => {
const user = c.get('user')!
const flux = await fluxService.getFlux(user.id)
return c.json(flux)
})
}
+92
View File
@@ -0,0 +1,92 @@
import type { Env } from '../libs/env'
import type { FluxService } from '../services/flux'
import type { HonoEnv } from '../types/hono'
import Stripe from 'stripe'
import { Hono } from 'hono'
import { integer, minValue, number, object, pipe, safeParse } from 'valibot'
import { authGuard } from '../middlewares/auth'
import { ApiError, createBadRequestError } from '../utils/error'
const CheckoutBodySchema = object({
amount: pipe(number(), integer(), minValue(1)),
})
export function createStripeRoutes(fluxService: FluxService, env: Env) {
const stripe = env.STRIPE_SECRET_KEY ? new Stripe(env.STRIPE_SECRET_KEY) : null
return new Hono<HonoEnv>()
.post('/checkout', authGuard, async (c) => {
if (!stripe)
throw new ApiError(503, 'STRIPE_NOT_CONFIGURED', 'Stripe is not configured')
const user = c.get('user')!
const body = await c.req.json()
const result = safeParse(CheckoutBodySchema, body)
if (!result.success)
throw createBadRequestError('Invalid checkout amount', 'INVALID_REQUEST', result.issues)
const { amount } = result.output
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price_data: {
currency: 'usd',
product_data: {
name: 'Flux Top-up',
},
unit_amount: amount,
},
quantity: 1,
},
],
mode: 'payment',
success_url: `${env.CLIENT_URL}/settings/flux?success=true`,
cancel_url: `${env.CLIENT_URL}/settings/flux?canceled=true`,
customer_email: user.email,
metadata: {
userId: user.id,
},
})
return c.json({ url: session.url })
})
.post('/webhook', async (c) => {
if (!stripe || !env.STRIPE_WEBHOOK_SECRET)
throw new ApiError(503, 'STRIPE_NOT_CONFIGURED', 'Stripe is not configured')
const sig = c.req.header('stripe-signature')
if (!sig)
return c.json({ error: 'No signature' }, 400)
let event: Stripe.Event
try {
const body = await c.req.text()
event = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET)
}
catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error'
return c.json({ error: `Webhook Error: ${message}` }, 400)
}
if (event.type === 'checkout.session.completed') {
const session = event.data.object as Stripe.Checkout.Session
const userId = session.metadata?.userId
const amount = session.amount_total
if (userId && amount) {
await fluxService.addFlux(userId, amount * env.FLUX_PER_CENT)
if (typeof session.customer === 'string')
await fluxService.updateStripeCustomerId(userId, session.customer)
}
}
return c.json({ received: true })
})
}
+56
View File
@@ -0,0 +1,56 @@
import type { Context } from 'hono'
import type { Env } from '../libs/env'
import type { FluxService } from '../services/flux'
import type { HonoEnv } from '../types/hono'
import { Hono } from 'hono'
import { authGuard } from '../middlewares/auth'
// Only forward these headers from the upstream LLM response
const SAFE_RESPONSE_HEADERS = new Set([
'content-type',
'content-length',
'transfer-encoding',
'cache-control',
])
export function createV1CompletionsRoutes(fluxService: FluxService, env: Env) {
async function handleCompletion(c: Context<HonoEnv>) {
const user = c.get('user')!
const flux = await fluxService.getFlux(user.id)
if (flux.flux <= 0) {
return c.json({ error: 'Insufficient flux' }, 402)
}
const body = await c.req.json()
await fluxService.consumeFlux(user.id, env.FLUX_PER_REQUEST)
const response = await fetch(`${env.BACKEND_LLM_BASE_URL}chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.BACKEND_LLM_API_KEY}`,
},
body: JSON.stringify(body),
})
const headers = new Headers()
for (const [key, value] of response.headers) {
if (SAFE_RESPONSE_HEADERS.has(key.toLowerCase()))
headers.set(key, value)
}
return new Response(response.body, {
status: response.status,
headers,
})
}
return new Hono<HonoEnv>()
.use('*', authGuard)
.post('/chat/completions', handleCompletion)
.post('/chat/completion', handleCompletion)
}
+10
View File
@@ -0,0 +1,10 @@
import { integer, pgTable, text, timestamp } from 'drizzle-orm/pg-core'
import { user } from './accounts'
export const userFlux = pgTable('user_flux', {
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
flux: integer('flux').notNull().default(0),
stripeCustomerId: text('stripe_customer_id'),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
})
+1
View File
@@ -1,5 +1,6 @@
export * from './accounts'
export * from './characters'
export * from './chats'
export * from './flux'
export * from './providers'
export * from './user-character'
@@ -0,0 +1,155 @@
import { beforeAll, describe, expect, it } from 'vitest'
import { mockDB } from '../../libs/mock-db'
import { createFluxService } from '../flux'
import * as schema from '../../schemas'
describe('fluxService', () => {
let db: any
let service: ReturnType<typeof createFluxService>
let testUser: any
beforeAll(async () => {
db = await mockDB(schema)
service = createFluxService(db)
// Create a test user for foreign key constraints
const [user] = await db.insert(schema.user).values({
id: 'user-1',
name: 'Test User',
email: 'test@example.com',
}).returning()
testUser = user
})
// --- getFlux ---
it('getFlux should create a new record with 100 default flux for a new user', async () => {
const record = await service.getFlux(testUser.id)
expect(record).toBeDefined()
expect(record.userId).toBe(testUser.id)
expect(record.flux).toBe(100)
})
it('getFlux should return existing record on subsequent calls', async () => {
const first = await service.getFlux(testUser.id)
const second = await service.getFlux(testUser.id)
// Same record, no duplicate insert
expect(second.userId).toBe(first.userId)
expect(second.flux).toBe(first.flux)
})
// --- consumeFlux ---
it('consumeFlux should deduct flux correctly', async () => {
const result = await service.consumeFlux(testUser.id, 10)
// Started at 100, consumed 10
expect(result.flux).toBe(90)
})
it('consumeFlux should throw when balance is insufficient', async () => {
// Current balance is 90 after previous test; consuming 91 should fail
await expect(service.consumeFlux(testUser.id, 91))
.rejects
.toThrow('Insufficient flux')
})
it('consumeFlux should throw when trying to consume more than available', async () => {
await expect(service.consumeFlux(testUser.id, 999))
.rejects
.toThrow('Insufficient flux')
})
// --- addFlux ---
it('addFlux should add flux correctly', async () => {
// Balance is 90 from previous consume test
const result = await service.addFlux(testUser.id, 50)
expect(result.flux).toBe(140)
})
it('addFlux should accumulate across multiple calls', async () => {
// Balance is 140; add 10 three times
await service.addFlux(testUser.id, 10)
await service.addFlux(testUser.id, 10)
const result = await service.addFlux(testUser.id, 10)
expect(result.flux).toBe(170)
})
// --- updateStripeCustomerId ---
it('updateStripeCustomerId should update the stripe customer ID', async () => {
const result = await service.updateStripeCustomerId(testUser.id, 'cus_abc123')
expect(result.stripeCustomerId).toBe('cus_abc123')
// Verify it persists via getFlux
const record = await service.getFlux(testUser.id)
expect(record.stripeCustomerId).toBe('cus_abc123')
})
// --- Concurrent consumeFlux ---
it('concurrent consumeFlux should not over-deduct flux', async () => {
// Set up a fresh user to isolate this test from previous state
const [user2] = await db.insert(schema.user).values({
id: 'user-concurrent-consume',
name: 'Concurrent Consumer',
email: 'concurrent-consume@example.com',
}).returning()
// Initialize flux record (100 default)
await service.getFlux(user2.id)
// Fire 10 concurrent consume calls of 10 each (total 100, exactly the balance)
const results = await Promise.allSettled(
Array.from({ length: 10 }, () => service.consumeFlux(user2.id, 10)),
)
const fulfilled = results.filter(r => r.status === 'fulfilled')
const rejected = results.filter(r => r.status === 'rejected')
// All 10 should succeed since total equals balance, but under concurrency
// some may fail if the atomic check-and-deduct fires after balance drops.
// The key invariant: final balance must never go negative.
const finalRecord = await service.getFlux(user2.id)
expect(finalRecord.flux).toBeGreaterThanOrEqual(0)
// Total consumed must equal (fulfilled count * 10)
expect(finalRecord.flux).toBe(100 - fulfilled.length * 10)
// Every rejection should be 'Insufficient flux'
for (const r of rejected) {
expect((r as PromiseRejectedResult).reason.message).toBe('Insufficient flux')
}
})
// --- Concurrent addFlux ---
it('concurrent addFlux should accumulate correctly without lost updates', async () => {
// Set up a fresh user to isolate this test
const [user3] = await db.insert(schema.user).values({
id: 'user-concurrent-add',
name: 'Concurrent Adder',
email: 'concurrent-add@example.com',
}).returning()
// Initialize flux record (100 default)
await service.getFlux(user3.id)
// Fire 10 concurrent add calls of 5 each (expect +50 total)
await Promise.all(
Array.from({ length: 10 }, () => service.addFlux(user3.id, 5)),
)
const finalRecord = await service.getFlux(user3.id)
// 100 initial + 10 * 5 = 150
expect(finalRecord.flux).toBe(150)
})
})
+78
View File
@@ -0,0 +1,78 @@
import type * as fullSchema from '../schemas'
import type { Database } from './db'
import { and, eq, gte, sql } from 'drizzle-orm'
import * as schema from '../schemas/flux'
export function createFluxService(db: Database<typeof fullSchema>) {
return {
async getFlux(userId: string) {
let record = await db.query.userFlux.findFirst({
where: eq(schema.userFlux.userId, userId),
})
if (!record) {
[record] = await db.insert(schema.userFlux).values({
userId,
flux: 100, // Default initial flux
}).returning()
}
return record
},
async consumeFlux(userId: string, amount: number) {
// Ensure the user has a flux record
await this.getFlux(userId)
// Atomic check-and-deduct to prevent race conditions
const result = await db.update(schema.userFlux)
.set({
flux: sql`${schema.userFlux.flux} - ${amount}`,
updatedAt: new Date(),
})
.where(and(
eq(schema.userFlux.userId, userId),
gte(schema.userFlux.flux, amount),
))
.returning()
if (result.length === 0) {
throw new Error('Insufficient flux')
}
return result[0]
},
async addFlux(userId: string, amount: number) {
// Ensure the user has a flux record
await this.getFlux(userId)
// Atomic addition to prevent race conditions
const [updated] = await db.update(schema.userFlux)
.set({
flux: sql`${schema.userFlux.flux} + ${amount}`,
updatedAt: new Date(),
})
.where(eq(schema.userFlux.userId, userId))
.returning()
return updated
},
async updateStripeCustomerId(userId: string, stripeCustomerId: string) {
const [updated] = await db.update(schema.userFlux)
.set({
stripeCustomerId,
updatedAt: new Date(),
})
.where(eq(schema.userFlux.userId, userId))
.returning()
return updated
},
}
}
export type FluxService = ReturnType<typeof createFluxService>
+32 -3
View File
@@ -31,6 +31,11 @@ dialogs:
next: Next
retry: Retry
start: Let's do it!
loginPrompt: Login to use the official AIRI provider for the best experience.
loginAction: Login
localSetup: Configure Local Provider
flux: Flux
buyFlux: Charge Flux
select-model: Choose model
no-models: No available models
no-models-help: >-
@@ -477,9 +482,28 @@ pages:
apply-and-restart: Save and restart stdio MCP
messages:
opened: Config file opened at {path}
restarted: >-
MCP servers restarted. Started {started}, failed {failed}, skipped
{skipped}
restarted: MCP servers restarted. Started {started}, failed {failed}, skipped {skipped}
flux:
title: Flux
buy: Charge
description: Current Flux
checkout:
success: Payment successful! Your Flux has been topped up.
canceled: Payment was canceled.
error: Something went wrong. Please try again later.
packages:
title: Flux Packages
buy: Charge
description: Flux packages to choose from.
amount_500:
label: 500 Flux
price: '$5'
amount_1000:
label: 1000 Flux
price: '$10'
amount_5000:
label: 5000 Flux
price: '$45'
providers:
explained:
chat: Text generation model providers. e.g. OpenRouter, OpenAI, Ollama.
@@ -846,6 +870,9 @@ pages:
browser-web-speech-api:
description: Browser-native STT (requires Chrome/Edge/Safari)
title: Web Speech API
official:
title: Official Provider
description: Official AI provider by AIRI.
transcriptions:
playground:
title: Transcription Playground
@@ -1012,6 +1039,8 @@ pages:
open-devtools:
title: Open Developer Tools
button: Open
credits:
buy: Buy
sections:
section:
general:
@@ -30,6 +30,11 @@ dialogs:
next: 下一步
retry: 重试
start: 开始吧!
loginPrompt: 登录以使用官方 AIRI 服务来源以获得最佳体验。
loginAction: 登录
localSetup: 配置本地服务来源
flux: Flux
buyFlux: 充能 Flux
select-model: 选择模型
no-models: 找不到可用模型
no-models-help: >-
@@ -462,9 +467,26 @@ pages:
open-config: 打开 mcp.json
apply-and-restart: 保存并重启 stdio MCP
messages:
opened: 配置文件已于{path}打开
restarted: >-
MCP 服务已重新启动,启动了{started},未启动{failed},跳过{skipped}
opened: Config file opened at {path}
restarted: MCP servers restarted. Started {started}, failed {failed}, skipped {skipped}
flux:
title: Flux
buy: 充能
description: 当前 Flux
checkout:
success: 支付成功!Flux 已充值。
canceled: 支付已取消。
error: 出了点问题,请稍后再试。
packages:
amount_500:
label: 500 Flux
price: '$5'
amount_1000:
label: 1000 Flux
price: '$10'
amount_5000:
label: 5000 Flux
price: '$45'
providers:
explained:
chat: 文本生成模型服务来源,例如 OpenRouter, OpenAI, Ollama
@@ -8,7 +8,7 @@ import { RouterLink } from 'vue-router'
import { toast } from 'vue-sonner'
const authStore = useAuthStore()
const { isAuthenticated, user } = storeToRefs(authStore)
const { isAuthenticated, user, credits } = storeToRefs(authStore)
const isMobile = useMediaQuery('(max-width: 768px)')
@@ -128,6 +128,10 @@ async function handleListSessions() {
<p class="truncate text-sm text-neutral-900 font-medium dark:text-white">
{{ userName }}
</p>
<div class="mt-1 flex items-center gap-1.5 text-xs text-primary-600 font-medium dark:text-primary-400">
<div class="i-solar:battery-charge-bold-duotone text-sm" />
<span>{{ credits }} Flux</span>
</div>
</div>
<div class="py-1">
@@ -139,6 +143,15 @@ async function handleListSessions() {
Active Sessions
</button>
<RouterLink
to="/settings/flux"
class="group w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800"
@click="showDropdown = false"
>
<div class="i-solar:battery-charge-bold-duotone text-lg text-neutral-400 transition group-hover:text-primary-500" />
Flux
</RouterLink>
<RouterLink
to="/settings"
class="group w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-neutral-700 transition hover:bg-neutral-100 dark:text-neutral-200 dark:hover:bg-neutral-800"
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { client } from '@proj-airi/stage-ui/composables/api'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { Button } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const { credits } = storeToRefs(authStore)
const loadingAmount = ref<number | null>(null)
const message = ref<{ type: 'success' | 'error', text: string } | null>(null)
// Packages with i18n labels
const packages = computed(() => [
{ amount: 500, label: t('settings.pages.flux.packages.amount_500.label'), price: t('settings.pages.flux.packages.amount_500.price') },
{ amount: 1000, label: t('settings.pages.flux.packages.amount_1000.label'), price: t('settings.pages.flux.packages.amount_1000.price') },
{ amount: 5000, label: t('settings.pages.flux.packages.amount_5000.label'), price: t('settings.pages.flux.packages.amount_5000.price') },
])
onMounted(async () => {
if (route.query.success === 'true') {
message.value = { type: 'success', text: t('settings.pages.flux.checkout.success') }
await authStore.updateCredits()
router.replace({ query: {} })
}
else if (route.query.canceled === 'true') {
message.value = { type: 'error', text: t('settings.pages.flux.checkout.canceled') }
router.replace({ query: {} })
}
})
async function handleBuy(amount: number) {
loadingAmount.value = amount
message.value = null
try {
const res = await client.api.stripe.checkout.$post({ json: { amount } })
if (!res.ok) {
const data = await res.json() as { error?: string, message?: string }
message.value = { type: 'error', text: data.message || t('settings.pages.flux.checkout.error') }
return
}
const data = await res.json()
if (data.url) {
window.location.href = data.url
}
}
catch {
message.value = { type: 'error', text: t('settings.pages.flux.checkout.error') }
}
finally {
loadingAmount.value = null
}
}
</script>
<template>
<div flex="~ col gap-6" p-4>
<!-- Message banner -->
<div
v-if="message"
rounded-lg p-3 text-sm
:class="message.type === 'success'
? 'bg-green-500/10 text-green-600 dark:text-green-400'
: 'bg-red-500/10 text-red-600 dark:text-red-400'"
>
{{ message.text }}
</div>
<div bg="primary-500/10 dark:primary-400/10" rounded-xl p-6 text-center>
<div i-solar:battery-charge-bold-duotone mx-auto size-16 text-primary-500 />
<h2 mt-4 text-3xl font-bold>
{{ credits }}
</h2>
<p text="sm neutral-500">
{{ t('settings.pages.flux.description') }}
</p>
</div>
<div grid="~ cols-1 sm:cols-3 gap-4">
<div
v-for="pkg in packages" :key="pkg.amount"
border="1 neutral-200 dark:neutral-800" flex="~ col gap-2" items-center rounded-xl p-4
>
<div font-bold>
{{ pkg.label }}
</div>
<div text="2xl" font-bold>
{{ pkg.price }}
</div>
<Button
:label="t('settings.pages.flux.buy')"
:loading="loadingAmount === pkg.amount"
:disabled="loadingAmount !== null && loadingAmount !== pkg.amount"
@click="handleBuy(pkg.amount)"
/>
</div>
</div>
</div>
</template>
<route lang="yaml">
meta:
layout: settings
titleKey: settings.pages.flux.title
icon: i-solar:battery-charge-bold-duotone
</route>
@@ -0,0 +1,99 @@
<script setup lang="ts">
import {
ProviderSettingsContainer,
ProviderSettingsLayout,
} from '@proj-airi/stage-ui/components'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { useProvidersStore } from '@proj-airi/stage-ui/stores/providers'
import { Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
const router = useRouter()
const { t } = useI18n()
const authStore = useAuthStore()
const providersStore = useProvidersStore()
const { isAuthenticated, credits, isLoginOpen } = storeToRefs(authStore)
const providerId = 'official-provider'
const providerMetadata = providersStore.getProviderMetadata(providerId)
// Automatically enable official provider when authenticated
watch(isAuthenticated, (val) => {
if (val) {
providersStore.forceProviderConfigured(providerId)
}
}, { immediate: true })
function handleLogin() {
isLoginOpen.value = true
}
</script>
<template>
<ProviderSettingsLayout
:provider-name="providerMetadata?.localizedName"
:provider-icon-color="providerMetadata?.iconColor"
:on-back="() => router.back()"
>
<ProviderSettingsContainer>
<div v-if="!isAuthenticated" flex flex-col gap-4>
<Callout theme="primary">
<template #label>
{{ t('settings.dialogs.onboarding.official.title') }}
</template>
<div flex flex-col gap-3>
<p>{{ t('settings.dialogs.onboarding.loginPrompt') }}</p>
<button
type="button"
class="w-fit rounded-lg bg-primary-500 px-4 py-2 text-white transition-colors active:scale-95 hover:bg-primary-600"
@click="handleLogin"
>
{{ t('settings.dialogs.onboarding.loginAction') }}
</button>
</div>
</Callout>
</div>
<div v-else flex flex-col gap-6>
<div class="rounded-xl bg-neutral-100/50 p-6 backdrop-blur-sm dark:bg-neutral-800/50">
<div flex items-center justify-between>
<div flex flex-col gap-1>
<span text="sm neutral-500 dark:neutral-400 font-medium uppercase tracking-wider">
{{ t('settings.dialogs.onboarding.flux') }}
</span>
<span text="3xl font-bold text-primary-600 dark:text-primary-400">
{{ credits }}
</span>
</div>
<button
type="button"
class="rounded-full bg-primary-500/10 px-6 py-2 text-sm text-primary-600 font-semibold transition-all dark:bg-primary-400/10 hover:bg-primary-500 dark:text-primary-400 hover:text-white dark:hover:bg-primary-400 dark:hover:text-neutral-900"
@click="router.push('/settings/flux')"
>
{{ t('settings.dialogs.onboarding.buyFlux') }}
</button>
</div>
</div>
<div class="border border-neutral-200/50 rounded-xl p-4 dark:border-neutral-700/50">
<div flex items-center gap-3>
<div class="h-2 w-2 animate-pulse rounded-full bg-green-500" />
<span text="sm neutral-600 dark:neutral-300">
{{ t('settings.pages.providers.provider.common.status.valid') }}
</span>
</div>
</div>
</div>
</ProviderSettingsContainer>
</ProviderSettingsLayout>
</template>
<route lang="yaml">
meta:
layout: settings
stageTransition:
name: slide
</route>
@@ -1,28 +1,33 @@
<script setup lang="ts">
import type { OnboardingStepNextHandler } from './types'
import { all } from '@proj-airi/i18n'
import { Button, FieldCombobox } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { computed, inject } from 'vue'
import { useI18n } from 'vue-i18n'
import onboardingLogo from '../../../../assets/onboarding.avif'
import { useAuthStore } from '../../../../stores/auth'
import { useSettingsGeneral } from '../../../../stores/settings'
import { OnboardingContextKey } from './utils'
interface Props {
onNext: OnboardingStepNextHandler
}
const props = defineProps<Props>()
const { t } = useI18n()
const context = inject(OnboardingContextKey)!
const authStore = useAuthStore()
const settingsStore = useSettingsGeneral()
const { language } = storeToRefs(settingsStore)
const languages = computed(() => {
return Object.entries(all).map(([value, label]) => ({ value, label }))
})
function handleLogin() {
authStore.isLoginOpen = true
}
function handleLocalSetup() {
context.handleNextStep()
}
</script>
<template>
@@ -74,14 +79,28 @@ const languages = computed(() => {
/>
</div>
</div>
<Button
v-motion
:initial="{ opacity: 0 }"
:enter="{ opacity: 1 }"
:duration="500"
:delay="200"
:label="t('settings.dialogs.onboarding.start')"
@click="props.onNext"
/>
<div flex="~ row gap-3">
<Button
v-motion
:initial="{ opacity: 0 }"
:visible="{ opacity: 1 }"
:duration="500"
:delay="200"
:label="t('settings.dialogs.onboarding.loginAction')"
class="flex-1"
@click="handleLogin"
/>
<Button
v-motion
:initial="{ opacity: 0 }"
:visible="{ opacity: 1 }"
:duration="500"
:delay="250"
variant="secondary"
:label="t('settings.dialogs.onboarding.localSetup')"
class="flex-1"
@click="handleLocalSetup"
/>
</div>
</div>
</template>
+1 -1
View File
@@ -2,7 +2,7 @@ import type { AppType } from '../../../../apps/server/src/app'
import { hc } from 'hono/client'
import { SERVER_URL } from '../libs/auth'
import { SERVER_URL } from '../libs/server'
export const client = hc<AppType>(SERVER_URL, {
fetch: (input: RequestInfo | URL, init?: RequestInit) => {
+1 -2
View File
@@ -1,11 +1,10 @@
import { createAuthClient } from 'better-auth/vue'
import { useAuthStore } from '../stores/auth'
import { SERVER_URL } from './server'
export type OAuthProvider = 'google' | 'github'
export const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'https://airi-api.moeru.ai'
export const authClient = createAuthClient({
baseURL: SERVER_URL,
credentials: 'include',
+1
View File
@@ -0,0 +1 @@
export const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'https://airi-api.moeru.ai'
+45 -4
View File
@@ -1,9 +1,12 @@
import type { Session, User } from 'better-auth'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { client } from '../composables/api'
import { fetchSession } from '../libs/auth'
import { useConsciousnessStore } from './modules/consciousness'
import { useProvidersStore } from './providers'
export const useAuthStore = defineStore('auth', () => {
const user = ref<User>()
@@ -11,8 +14,9 @@ export const useAuthStore = defineStore('auth', () => {
const isAuthenticated = computed(() => !!user.value && !!session.value)
const userId = computed(() => user.value?.id ?? 'local')
// For controlling the login drawer on mobile
const isLoginDrawerOpen = ref(false)
const credits = ref<number>(0)
const isLoginOpen = ref(false)
const initialized = ref(false)
const initialize = () => {
@@ -24,6 +28,41 @@ export const useAuthStore = defineStore('auth', () => {
initialized.value = true
}
const updateCredits = async () => {
if (!isAuthenticated.value)
return
const res = await client.api.flux.$get()
if (res.ok) {
const data = await res.json()
credits.value = data.flux
}
}
// Get store references once
const providersStore = useProvidersStore()
const consciousnessStore = useConsciousnessStore()
watch(isAuthenticated, async (val) => {
if (val) {
updateCredits()
// Automatically enable official provider when authenticated
const officialProviderId = 'official-provider'
providersStore.forceProviderConfigured(officialProviderId)
consciousnessStore.activeProvider = officialProviderId
await nextTick()
try {
await consciousnessStore.loadModelsForProvider(officialProviderId)
}
catch (err) {
console.error('error loading models for official provider', err)
}
}
else {
credits.value = 0
}
}, { immediate: true })
initialize()
return {
@@ -31,6 +70,8 @@ export const useAuthStore = defineStore('auth', () => {
userId,
session,
isAuthenticated,
isLoginDrawerOpen,
credits,
updateCredits,
isLoginOpen,
}
})
+11 -15
View File
@@ -23,21 +23,17 @@ export const useOnboardingStore = defineStore('onboarding', () => {
// Check if any essential provider is configured
const hasEssentialProviderConfigured = computed(() => {
return essentialProviderIds.some(providerId => providersStore.configuredProviders[providerId])
})
// Fallback for app startup timing:
// If configured state has not been revalidated yet, infer "configured"
// from persisted essential credentials.
const hasEssentialProviderCredentialConfigured = computed(() => {
return credentialBasedEssentialProviderIds.some((providerId) => {
const providerConfig = providersStore.providers[providerId] as Record<string, unknown> | undefined
if (!providerConfig) {
return false
}
return hasNonEmptyText(providerConfig.apiKey)
})
const essentialProviders = [
'openai',
'anthropic',
'google-generative-ai',
'openrouter-ai',
'ollama',
'deepseek',
'openai-compatible',
'official-provider',
]
return essentialProviders.some(providerId => providersStore.configuredProviders[providerId])
})
// Check if first-time setup should be shown
+59 -28
View File
@@ -48,7 +48,8 @@ import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { listProviders as listDefinedProviders } from '../libs/providers'
import { getProviderValidationIntervalMs } from '../libs/providers/validators/run'
import { SERVER_URL } from '../libs/server'
import { useAuthStore } from '../stores/auth'
import { getKokoroWorker } from '../workers/kokoro'
import { getDefaultKokoroModel, KOKORO_MODELS, kokoroModelsToModelInfo } from '../workers/kokoro/constants'
import { createAliyunNLSProvider as createAliyunNlsStreamProvider } from './providers/aliyun/stream-transcription'
@@ -249,32 +250,43 @@ export const useProvidersStore = defineStore('providers', () => {
// Centralized provider metadata with provider factory functions
const providerMetadata: Record<string, ProviderMetadata> = {
'speech-noop': {
id: 'speech-noop',
category: 'speech',
tasks: ['text-to-speech', 'tts'],
nameKey: 'settings.pages.providers.provider.speech-noop.title',
name: 'None',
descriptionKey: 'settings.pages.providers.provider.speech-noop.description',
description: 'No speech output.',
icon: 'i-solar:volume-cross-bold-duotone',
defaultOptions: () => ({}),
createProvider: async () => ({
speech: () => ({
baseURL: 'http://speech-noop.invalid/v1/',
model: 'noop',
}),
}),
'official-provider': {
id: 'official-provider',
order: -1,
category: 'chat',
tasks: ['text-generation'],
nameKey: 'settings.pages.providers.provider.official.title',
name: 'Official Provider',
descriptionKey: 'settings.pages.providers.provider.official.description',
description: 'Official AI provider by AIRI.',
icon: 'i-solar:star-bold-duotone',
createProvider: async (_config) => {
const authStore = useAuthStore()
if (!authStore.isAuthenticated) {
throw new Error('User is not authenticated')
}
return createOpenAI('', `${SERVER_URL}/v1/`)
},
capabilities: {
listModels: async () => [],
listVoices: async () => [],
listModels: async () => {
return [
{
id: 'gpt-4o',
name: 'GPT-4o',
provider: 'official-provider',
},
]
},
},
validators: {
validateProviderConfig: () => ({
errors: [],
reason: '',
valid: true,
}),
validateProviderConfig: () => {
const authStore = useAuthStore()
return {
errors: [],
reason: '',
valid: authStore.isAuthenticated,
}
},
},
},
'app-local-audio-speech': buildOpenAICompatibleProvider({
@@ -1788,10 +1800,18 @@ export const useProvidersStore = defineStore('providers', () => {
if (!forceValidation && runtimeState?.validatedCredentialHash === configString && typeof runtimeState.isConfigured === 'boolean')
return runtimeState.isConfigured
if (!forceValidation) {
const pending = providerValidationInFlight.get(cacheKey)
if (pending) {
return pending
// Always cache the current config string to prevent re-validating the same config
if (providerRuntimeState.value[providerId]) {
providerRuntimeState.value[providerId].validatedCredentialHash = configString
}
const validationResult = await metadata.validators.validateProviderConfig(config || {})
if (providerRuntimeState.value[providerId]) {
providerRuntimeState.value[providerId].isConfigured = validationResult.valid
// Auto-mark Web Speech API as added if valid and available
if (validationResult.valid && ['browser-web-speech-api', 'player2', 'official-provider'].includes(providerId)) {
markProviderAdded(providerId)
}
}
@@ -1845,6 +1865,14 @@ export const useProvidersStore = defineStore('providers', () => {
modelLoadError: null,
}
}
// Must run AFTER runtime state is created so forceProviderConfigured can set isConfigured
if (providerId === 'official-provider') {
const authStore = useAuthStore()
if (authStore.isAuthenticated) {
forceProviderConfigured(providerId)
}
}
}
// Initialize all providers
@@ -1891,6 +1919,9 @@ export const useProvidersStore = defineStore('providers', () => {
watch(providerCredentials, updateConfigurationStatus, { deep: true, immediate: true })
startPeriodicRuntimeValidation()
const authStore = useAuthStore()
watch(() => authStore.isAuthenticated, updateConfigurationStatus)
// Available providers (only those that are properly configured)
const availableProviders = computed(() => Object.keys(providerMetadata).filter(providerId => providerRuntimeState.value[providerId]?.isConfigured))
+1
View File
@@ -101,6 +101,7 @@ catalog:
posthog-js: 1.306.1
splitpanes: ^4.0.4
std-env: ^4.0.0
stripe: ^20.3.0
superjson: ^2.2.6
tsdown: ^0.21.4
tsx: ^4.21.0