feat(server): stripe integration and credits system (#1024)
This commit is contained in:
+10
-1
@@ -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
@@ -8,6 +8,13 @@
|
||||
"when": 1772532674887,
|
||||
"tag": "0000_mean_slipstream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1772532802115,
|
||||
"tag": "0001_magenta_skrulls",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 })
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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',
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export const SERVER_URL = import.meta.env.VITE_SERVER_URL || 'https://airi-api.moeru.ai'
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user