Merge branch 'dev' into till-dev

This commit is contained in:
Wuesteon 2025-12-15 14:10:34 +01:00
commit 660cbd654f
21 changed files with 552 additions and 1679 deletions

View file

@ -20,6 +20,16 @@ function getAuthUrl(): string {
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3014';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3014';
}
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
@ -27,7 +37,10 @@ let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null =
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}

View file

@ -24,6 +24,7 @@ pnpm dev:chat:mobile # Start mobile app
pnpm dev:chat:web # Start web app
pnpm dev:chat:landing # Start landing page
pnpm dev:chat:backend # Start backend server
pnpm dev:chat:full # Start backend + web + auth together
```
### Mobile App (chat/apps/mobile)
@ -43,6 +44,9 @@ pnpm build:prod # Build production version
pnpm start:dev # Start with hot reload
pnpm build # Build for production
pnpm start:prod # Start production server
pnpm db:push # Push schema to database
pnpm db:seed # Seed AI models
pnpm db:studio # Open Drizzle Studio
```
### Web App (chat/apps/web)
@ -66,7 +70,8 @@ pnpm preview # Preview production build
- **Mobile**: React Native 0.76.7 + Expo SDK 52, NativeWind, Expo Router
- **Web**: SvelteKit 2.x, Svelte 5, Tailwind CSS 4
- **Landing**: Astro 5.16, Tailwind CSS
- **Backend**: NestJS 10, OpenRouter/Gemini AI, Supabase
- **Backend**: NestJS 10, OpenRouter AI, Drizzle ORM, PostgreSQL
- **Auth**: Mana Core Auth (JWT)
- **Types**: TypeScript 5.x
## Architecture
@ -75,34 +80,45 @@ pnpm preview # Preview production build
| Endpoint | Method | Description |
| --------------------------------- | ------ | --------------------------- |
| `/api/health` | GET | Health check |
| `/api/chat/models` | GET | List available AI models |
| `/api/chat/completions` | POST | Create chat completion |
| `/api/conversations` | GET | List user conversations |
| `/api/conversations/:id` | GET | Get conversation details |
| `/api/conversations/:id/messages` | GET | Get conversation messages |
| `/api/conversations` | POST | Create new conversation |
| `/api/conversations/:id/messages` | POST | Add message to conversation |
| `/api/v1/health` | GET | Health check |
| `/api/v1/chat/models` | GET | List available AI models |
| `/api/v1/chat/completions` | POST | Create chat completion |
| `/api/v1/conversations` | GET | List user conversations |
| `/api/v1/conversations/:id` | GET | Get conversation details |
| `/api/v1/conversations/:id/messages` | GET | Get conversation messages |
| `/api/v1/conversations` | POST | Create new conversation |
| `/api/v1/conversations/:id/messages` | POST | Add message to conversation |
### Environment Variables
#### Backend (.env)
```
OPENROUTER_API_KEY=... # Get at https://openrouter.ai/keys
GOOGLE_GENAI_API_KEY=... # Optional: For Gemini models
SUPABASE_URL=https://...
SUPABASE_SERVICE_KEY=...
```env
# Required - All AI models via OpenRouter
OPENROUTER_API_KEY=sk-or-v1-xxx # Get at https://openrouter.ai/keys
# Database (uses shared Docker PostgreSQL)
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/chat
# Auth
MANA_CORE_AUTH_URL=http://localhost:3001
# Server
PORT=3002
DEV_BYPASS_AUTH=true # Optional: Skip auth in development
```
#### Mobile (.env)
```env
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
```
EXPO_PUBLIC_SUPABASE_URL=https://...
EXPO_PUBLIC_SUPABASE_ANON_KEY=...
EXPO_PUBLIC_BACKEND_URL=http://localhost:3001
#### Web (.env)
```env
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
PUBLIC_BACKEND_URL=http://localhost:3002
```
## Code Style Guidelines
@ -113,39 +129,43 @@ EXPO_PUBLIC_BACKEND_URL=http://localhost:3001
- **Styling**: Tailwind CSS everywhere
- **Formatting**: 100 char line limit, 2 space tabs, single quotes
## AI Models Available
## AI Models Available (via OpenRouter)
### OpenRouter Models (Recommended)
All models are accessed through OpenRouter, providing access to 100+ models with a single API key.
| Model ID | Name | Price | Best For |
| -------- | ---- | ----- | -------- |
| ...440201 | Llama 3.1 8B | $0.05/M | Everyday tasks, cheap |
| ...440201 | Llama 3.1 8B | $0.05/M | Everyday tasks (default) |
| ...440202 | Llama 3.1 70B | $0.35/M | Complex reasoning |
| ...440203 | DeepSeek V3 | $0.14/M | Reasoning at low cost |
| ...440204 | Mistral Small | $0.10/M | General tasks |
| ...440205 | Claude 3.5 Sonnet | $3/M | Best quality |
| ...440206 | GPT-4o Mini | $0.15/M | Balanced performance |
### Google Gemini Models
## Quick Start
| Model ID | Name | Description | Default |
| -------- | ---- | ----------- | ------- |
| ...440101 | Gemini 2.5 Flash | Fast, efficient responses | Yes |
| ...440102 | Gemini 2.0 Flash-Lite | Ultra-lightweight model | No |
| ...440103 | Gemini 2.5 Pro | Most capable model | No |
## OpenRouter Setup
To enable OpenRouter models:
- [ ] Get API key at https://openrouter.ai/keys
- [ ] Add `OPENROUTER_API_KEY=sk-or-v1-xxx` to `apps/chat/apps/backend/.env`
- [ ] Re-seed database: `pnpm --filter @chat/backend db:seed`
- [ ] Test: `pnpm dev:chat:backend`
1. **Get OpenRouter API key** at https://openrouter.ai/keys
2. **Create `.env`** in `apps/chat/apps/backend/`:
```env
OPENROUTER_API_KEY=sk-or-v1-xxx
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/chat
MANA_CORE_AUTH_URL=http://localhost:3001
PORT=3002
```
3. **Start services**:
```bash
pnpm docker:up # Start PostgreSQL
pnpm dev:chat:full # Start auth + backend + web
```
4. **Seed database** (first time only):
```bash
pnpm --filter @chat/backend db:push
pnpm --filter @chat/backend db:seed
```
## Important Notes
1. **Security**: API keys are stored in the backend only - never in client apps
2. **Authentication**: Uses Supabase Auth, shared with Mana Core ecosystem
3. **Database**: Supabase PostgreSQL with RLS policies
4. **Deployment**: Backend runs on port 3001 by default
2. **Authentication**: Uses Mana Core Auth (JWT tokens)
3. **Database**: PostgreSQL with Drizzle ORM (uses shared Docker container)
4. **Deployment**: Backend runs on port 3002

View file

@ -1,20 +1,14 @@
# OpenRouter Configuration (Recommended - multi-model access)
# OpenRouter Configuration (Required)
# Get your API key at https://openrouter.ai/keys
# All AI models are accessed through OpenRouter
OPENROUTER_API_KEY=your-openrouter-api-key
# Google Gemini Configuration
GOOGLE_GENAI_API_KEY=your-google-api-key
# Azure OpenAI Configuration (Optional)
AZURE_OPENAI_ENDPOINT=https://your-azure-openai-endpoint.openai.azure.com
AZURE_OPENAI_API_KEY=your-api-key-here
AZURE_OPENAI_API_VERSION=2024-12-01-preview
# Mana Core Auth Configuration
MANA_CORE_AUTH_URL=http://localhost:3001
# PostgreSQL Database Configuration
DATABASE_URL=postgresql://chat:password@localhost:5432/chat
# Uses shared Docker PostgreSQL with separate 'chat' database
DATABASE_URL=postgresql://manacore:devpassword@localhost:5432/chat
# Server Configuration
PORT=3002

View file

@ -24,7 +24,6 @@
"docker:clean": "docker compose down -v --rmi local"
},
"dependencies": {
"@google/generative-ai": "^0.24.1",
"@manacore/shared-errors": "workspace:*",
"@manacore/shared-nestjs-auth": "workspace:*",
"@nestjs/common": "^10.4.15",

View file

@ -2,7 +2,6 @@ import { Injectable, Inject, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq } from 'drizzle-orm';
import { AsyncResult, ok, err, ValidationError, ServiceError } from '@manacore/shared-errors';
import { GoogleGenerativeAI } from '@google/generative-ai';
import OpenAI from 'openai';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
@ -14,37 +13,14 @@ import type { ChatCompletionResponseDto } from './dto/chat-completion.dto';
@Injectable()
export class ChatService {
private readonly logger = new Logger(ChatService.name);
// Azure OpenAI config
private readonly azureApiKey: string;
private readonly azureEndpoint: string;
private readonly azureApiVersion: string;
// Google Gemini config
private readonly geminiClient: GoogleGenerativeAI | null = null;
// OpenRouter config
// OpenRouter config (primary provider)
private readonly openRouterClient: OpenAI | null = null;
constructor(
private configService: ConfigService,
@Inject(DATABASE_CONNECTION) private readonly db: Database
) {
// Azure OpenAI setup
this.azureApiKey = this.configService.get<string>('AZURE_OPENAI_API_KEY') || '';
this.azureEndpoint =
this.configService.get<string>('AZURE_OPENAI_ENDPOINT') ||
'https://memoroseopenai.openai.azure.com';
this.azureApiVersion =
this.configService.get<string>('AZURE_OPENAI_API_VERSION') || '2024-12-01-preview';
// Google Gemini setup
const geminiApiKey = this.configService.get<string>('GOOGLE_GENAI_API_KEY');
if (geminiApiKey) {
this.geminiClient = new GoogleGenerativeAI(geminiApiKey);
this.logger.log('Google Gemini client initialized');
} else {
this.logger.warn('GOOGLE_GENAI_API_KEY is not set - Gemini models unavailable');
}
// OpenRouter setup
// OpenRouter setup (primary and only provider)
const openRouterApiKey = this.configService.get<string>('OPENROUTER_API_KEY');
if (openRouterApiKey) {
this.openRouterClient = new OpenAI({
@ -57,11 +33,7 @@ export class ChatService {
});
this.logger.log('OpenRouter client initialized');
} else {
this.logger.warn('OPENROUTER_API_KEY is not set - OpenRouter models unavailable');
}
if (!this.azureApiKey) {
this.logger.warn('AZURE_OPENAI_API_KEY is not set - Azure models unavailable');
this.logger.error('OPENROUTER_API_KEY is not set - Chat will not work!');
}
}
@ -100,176 +72,8 @@ export class ChatService {
this.logger.log(`User ${userId} creating chat completion with model ${dto.modelId}`);
}
// Route to appropriate provider
if (model.provider === 'gemini') {
return this.createGeminiCompletion(model, dto);
} else if (model.provider === 'openrouter') {
return this.createOpenRouterCompletion(model, dto);
} else {
return this.createAzureCompletion(model, dto);
}
}
private async createGeminiCompletion(
model: Model,
dto: ChatCompletionDto
): AsyncResult<ChatCompletionResponseDto> {
if (!this.geminiClient) {
return err(ServiceError.externalError('Google Gemini', 'Gemini client not configured'));
}
const params = model.parameters as {
model?: string;
temperature?: number;
max_tokens?: number;
} | null;
const modelName = params?.model || 'gemini-2.5-flash';
const temperature = dto.temperature ?? params?.temperature ?? 0.7;
const maxTokens = dto.maxTokens ?? params?.max_tokens ?? 8192;
this.logger.log(`Sending request to Google Gemini model: ${modelName}`);
try {
const genModel = this.geminiClient.getGenerativeModel({
model: modelName,
generationConfig: {
temperature,
maxOutputTokens: maxTokens,
},
});
// Convert messages to Gemini format
// Gemini expects alternating user/model messages, with system as first user message
const systemMessages = dto.messages.filter((m) => m.role === 'system');
const chatMessages = dto.messages.filter((m) => m.role !== 'system');
// Build history for chat (all but last message)
const history = chatMessages.slice(0, -1).map((msg) => ({
role: msg.role === 'user' ? 'user' : 'model',
parts: [{ text: msg.content }],
}));
// Last message to send
const lastMessage = chatMessages[chatMessages.length - 1];
let userPrompt = lastMessage?.content || '';
// Prepend system instruction if present
if (systemMessages.length > 0) {
const systemPrompt = systemMessages.map((m) => m.content).join('\n');
userPrompt = `${systemPrompt}\n\n${userPrompt}`;
}
const chat = genModel.startChat({ history });
const result = await chat.sendMessage(userPrompt);
const response = result.response;
const messageContent = response.text();
if (!messageContent) {
this.logger.warn('No message content in Gemini response');
return err(ServiceError.generationFailed('Google Gemini', 'No response generated'));
}
// Gemini provides usage metadata
const usageMetadata = response.usageMetadata;
return ok({
content: messageContent,
usage: {
prompt_tokens: usageMetadata?.promptTokenCount || 0,
completion_tokens: usageMetadata?.candidatesTokenCount || 0,
total_tokens: usageMetadata?.totalTokenCount || 0,
},
});
} catch (error) {
this.logger.error('Error calling Google Gemini API', error);
return err(
ServiceError.generationFailed(
'Google Gemini',
error instanceof Error ? error.message : 'Unknown error',
error instanceof Error ? error : undefined
)
);
}
}
private async createAzureCompletion(
model: Model,
dto: ChatCompletionDto
): AsyncResult<ChatCompletionResponseDto> {
const params = model.parameters as {
deployment?: string;
temperature?: number;
max_tokens?: number;
} | null;
const deployment = params?.deployment || 'gpt-4o-mini-se';
const temperature = dto.temperature ?? params?.temperature ?? 0.7;
const maxTokens = dto.maxTokens ?? params?.max_tokens ?? 1000;
// Prepare request body
const requestBody: Record<string, unknown> = {
messages: dto.messages.map((msg) => ({
role: msg.role,
content: msg.content,
})),
};
// Model-specific parameters
const isGPTOModel = deployment.includes('gpt-o') || deployment.includes('gpt-4o');
if (!isGPTOModel) {
requestBody.max_tokens = maxTokens;
requestBody.temperature = temperature;
}
const url = `${this.azureEndpoint}/openai/deployments/${deployment}/chat/completions?api-version=${this.azureApiVersion}`;
this.logger.log(`Sending request to Azure OpenAI: ${url}`);
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'api-key': this.azureApiKey,
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const errorText = await response.text();
this.logger.error(`API error: ${response.status} - ${errorText}`);
return err(ServiceError.externalError('Azure OpenAI', `API error: ${response.status}`));
}
const data = await response.json();
const messageContent = data.choices?.[0]?.message?.content;
if (!messageContent) {
this.logger.warn('No message content in response');
return err(ServiceError.generationFailed('Azure OpenAI', 'No response generated'));
}
return ok({
content: messageContent,
usage: {
prompt_tokens: data.usage?.prompt_tokens || 0,
completion_tokens: data.usage?.completion_tokens || 0,
total_tokens: data.usage?.total_tokens || 0,
},
});
} catch (error) {
this.logger.error('Error calling Azure OpenAI API', error);
return err(
ServiceError.generationFailed(
'Azure OpenAI',
error instanceof Error ? error.message : 'Unknown error',
error instanceof Error ? error : undefined
)
);
}
// All models go through OpenRouter
return this.createOpenRouterCompletion(model, dto);
}
private async createOpenRouterCompletion(

View file

@ -33,49 +33,7 @@ async function seed() {
const modelData = [
// ============================================
// Google Gemini Models (Primary - fast & cost-effective)
// ============================================
{
id: '550e8400-e29b-41d4-a716-446655440101',
name: 'Gemini 2.5 Flash',
description: 'Fastest & most cost-effective - ideal for everyday tasks',
provider: 'gemini',
parameters: {
model: 'gemini-2.5-flash',
temperature: 0.7,
max_tokens: 8192,
},
isActive: true,
isDefault: true, // Default model
},
{
id: '550e8400-e29b-41d4-a716-446655440102',
name: 'Gemini 2.0 Flash-Lite',
description: 'Ultra-fast lightweight model - minimal latency',
provider: 'gemini',
parameters: {
model: 'gemini-2.0-flash-lite',
temperature: 0.7,
max_tokens: 4096,
},
isActive: true,
isDefault: false,
},
{
id: '550e8400-e29b-41d4-a716-446655440103',
name: 'Gemini 2.5 Pro',
description: 'Most powerful Gemini - complex reasoning & analysis',
provider: 'gemini',
parameters: {
model: 'gemini-2.5-pro',
temperature: 0.7,
max_tokens: 16384,
},
isActive: true,
isDefault: false,
},
// ============================================
// OpenRouter Models (Multi-provider, cost-effective)
// OpenRouter Models (All models via OpenRouter)
// ============================================
{
id: '550e8400-e29b-41d4-a716-446655440201',
@ -88,7 +46,7 @@ async function seed() {
max_tokens: 4096,
},
isActive: true,
isDefault: false,
isDefault: true, // Default model - fast and cost-effective
},
{
id: '550e8400-e29b-41d4-a716-446655440202',
@ -155,101 +113,6 @@ async function seed() {
isActive: true,
isDefault: false,
},
// ============================================
// Azure OpenAI GPT-5 Family (Inactive - no deployment)
// ============================================
{
id: '550e8400-e29b-41d4-a716-446655440001',
name: 'GPT-5 Mini',
description: 'Fast & cost-effective - best for everyday tasks',
provider: 'azure',
parameters: {
temperature: 0.7,
max_tokens: 8192,
deployment: 'gpt-5-mini',
},
isActive: false,
isDefault: false,
},
{
id: '550e8400-e29b-41d4-a716-446655440002',
name: 'GPT-5 Nano',
description: 'Ultra-fast responses with low latency',
provider: 'azure',
parameters: {
temperature: 0.7,
max_tokens: 4096,
deployment: 'gpt-5-nano',
},
isActive: false,
isDefault: false,
},
{
id: '550e8400-e29b-41d4-a716-446655440003',
name: 'GPT-5 Chat',
description: 'Advanced multimodal conversations with emotional intelligence',
provider: 'azure',
parameters: {
temperature: 0.7,
max_tokens: 16384,
deployment: 'gpt-5-chat',
},
isActive: false,
isDefault: false,
},
{
id: '550e8400-e29b-41d4-a716-446655440004',
name: 'GPT-5',
description: 'Most powerful LLM - logic-heavy & multi-step tasks',
provider: 'azure',
parameters: {
temperature: 0.7,
max_tokens: 32768,
deployment: 'gpt-5',
},
isActive: false,
isDefault: false,
},
{
id: '550e8400-e29b-41d4-a716-446655440005',
name: 'GPT-5 Codex',
description: 'Optimized for coding & front-end development',
provider: 'azure',
parameters: {
temperature: 0.7,
max_tokens: 32768,
deployment: 'gpt-5-codex',
},
isActive: false,
isDefault: false,
},
// O-Series Reasoning Models (Inactive - no deployment)
{
id: '550e8400-e29b-41d4-a716-446655440006',
name: 'o4-mini',
description: 'Latest reasoning model - best for STEM & code',
provider: 'azure',
parameters: {
temperature: 1, // Reasoning models work best with temp=1
max_tokens: 16384,
deployment: 'o4-mini',
},
isActive: false,
isDefault: false,
},
{
id: '550e8400-e29b-41d4-a716-446655440007',
name: 'o3',
description: 'Advanced reasoning - 20% fewer errors than o1',
provider: 'azure',
parameters: {
temperature: 1,
max_tokens: 32768,
deployment: 'o3',
},
isActive: false,
isDefault: false,
},
];
await db.insert(models).values(modelData);

View file

@ -20,6 +20,16 @@ function getAuthUrl(): string {
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3002';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3002';
}
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
@ -27,7 +37,10 @@ let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null =
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}

View file

@ -28,15 +28,17 @@
let showVersionsModal = $state(false);
let showDocumentPanel = $state(true);
// Track current request to prevent race conditions
let currentLoadId = $state(0);
// Track current request to prevent race conditions (not reactive to avoid effect loops)
let currentLoadId = 0;
let lastLoadedConversationId = '';
const conversationId = $derived($page.params.id ?? '');
const isDocumentMode = $derived(conversation?.documentMode ?? false);
// React to conversationId changes with race condition protection
$effect(() => {
if (conversationId) {
if (conversationId && conversationId !== lastLoadedConversationId) {
lastLoadedConversationId = conversationId;
loadData(conversationId);
}
});

View file

@ -19,6 +19,16 @@ function getAuthUrl(): string {
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3017';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3017';
}
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
@ -26,7 +36,10 @@ let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null =
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}

View file

@ -7,6 +7,7 @@ import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
import { MANA_AUTH_URL } from '$lib/api/config';
const BACKEND_URL = 'http://localhost:3015';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
@ -15,7 +16,10 @@ let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null =
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
const auth = initializeWebAuth({
baseUrl: MANA_AUTH_URL,
backendUrl: BACKEND_URL, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}

View file

@ -8,7 +8,7 @@ import {
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq, and, count } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { CreditClientService } from '@mana-core/nestjs-integration';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
@ -18,7 +18,6 @@ import { ReplicateService, GenerationParams } from './replicate.service';
import { StorageService } from '../upload/storage.service';
import { GenerateImageDto } from './dto/generate.dto';
const FREE_GENERATIONS_LIMIT = 3;
const CREDITS_PER_GENERATION = 10;
export interface GenerateResponse {
@ -26,14 +25,14 @@ export interface GenerateResponse {
status: string;
image?: Image;
creditsUsed?: number;
freeGenerationsRemaining?: number;
}
@Injectable()
export class GenerateService {
private readonly logger = new Logger(GenerateService.name);
private readonly webhookBaseUrl: string;
private readonly isStaging: boolean;
private readonly isProduction: boolean;
private readonly canUseWebhooks: boolean;
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
@ -44,65 +43,41 @@ export class GenerateService {
) {
this.webhookBaseUrl =
this.configService.get<string>('WEBHOOK_BASE_URL') || 'http://localhost:3003';
// Freemium/credit system only enforced in staging
this.isStaging = this.configService.get<string>('NODE_ENV') === 'staging';
// Credit system only enforced in production
this.isProduction = this.configService.get<string>('NODE_ENV') === 'production';
// Replicate requires HTTPS webhooks - detect if we can use them
this.canUseWebhooks = this.webhookBaseUrl.startsWith('https://');
if (!this.canUseWebhooks) {
this.logger.warn(
`Webhook URL is not HTTPS (${this.webhookBaseUrl}). Falling back to sync mode for all generations.`
);
}
}
/**
* Get count of completed generations for a user
*/
private async getUserGenerationCount(userId: string): Promise<number> {
const result = await this.db
.select({ count: count() })
.from(imageGenerations)
.where(and(eq(imageGenerations.userId, userId), eq(imageGenerations.status, 'completed')));
return result[0]?.count ?? 0;
}
/**
* Check if user can generate (has free generations or credits)
* Returns: { canGenerate, isFree, freeRemaining, creditsRequired }
* Check if user has enough credits to generate
* Credits are only enforced in production (NODE_ENV=production)
*/
async checkGenerationAccess(userId: string): Promise<{
canGenerate: boolean;
isFree: boolean;
freeGenerationsRemaining: number;
creditsRequired: number;
currentBalance?: number;
}> {
const completedCount = await this.getUserGenerationCount(userId);
const freeRemaining = Math.max(0, FREE_GENERATIONS_LIMIT - completedCount);
// If user has free generations, they can proceed
if (freeRemaining > 0) {
// In development, skip credit check (users get 150 free credits on signup anyway)
if (!this.isProduction) {
return {
canGenerate: true,
isFree: true,
freeGenerationsRemaining: freeRemaining,
creditsRequired: 0,
};
}
// No free generations - check credits (only in staging)
if (!this.isStaging) {
// In development/production without credit enforcement, allow generation
return {
canGenerate: true,
isFree: false,
freeGenerationsRemaining: 0,
creditsRequired: CREDITS_PER_GENERATION,
};
}
// In staging, check actual credit balance
// In production, check actual credit balance
try {
const balance = await this.creditClient.getBalance(userId);
const hasEnoughCredits = balance.balance >= CREDITS_PER_GENERATION;
return {
canGenerate: hasEnoughCredits,
isFree: false,
freeGenerationsRemaining: 0,
creditsRequired: CREDITS_PER_GENERATION,
currentBalance: balance.balance,
};
@ -111,8 +86,6 @@ export class GenerateService {
// On error, allow generation (fail open for better UX)
return {
canGenerate: true,
isFree: false,
freeGenerationsRemaining: 0,
creditsRequired: CREDITS_PER_GENERATION,
};
}
@ -123,7 +96,7 @@ export class GenerateService {
*/
async generateImage(userId: string, dto: GenerateImageDto): Promise<GenerateResponse> {
try {
// Check if user can generate (freemium/credit check)
// Check if user has enough credits (only enforced in production)
const access = await this.checkGenerationAccess(userId);
if (!access.canGenerate) {
@ -168,7 +141,6 @@ export class GenerateService {
.returning();
const generation = generationResult[0];
const isFreeGeneration = access.isFree;
// Build generation params
const generationParams: GenerationParams = {
@ -186,25 +158,29 @@ export class GenerateService {
style: dto.style,
};
// If waitForResult is true, use synchronous generation with polling
if (dto.waitForResult) {
// Use sync mode if:
// 1. Client explicitly requested waitForResult
// 2. Webhooks are not available (no HTTPS URL)
const useSyncMode = dto.waitForResult || !this.canUseWebhooks;
if (useSyncMode) {
if (!this.canUseWebhooks && !dto.waitForResult) {
this.logger.debug('Using sync mode because webhooks are not available (no HTTPS)');
}
const result = await this.generateSync(generation, generationParams);
// Consume credits after successful generation (if not free)
if (result.status === 'completed' && !isFreeGeneration && this.isStaging) {
// Consume credits after successful generation (only in production)
if (result.status === 'completed' && this.isProduction) {
await this.consumeCreditsForGeneration(userId, generation.id);
result.creditsUsed = CREDITS_PER_GENERATION;
}
// Add free generations remaining info
const newAccess = await this.checkGenerationAccess(userId);
result.freeGenerationsRemaining = newAccess.freeGenerationsRemaining;
return result;
}
// Otherwise use async generation with webhook (credits consumed on webhook completion)
return this.generateAsync(generation, model, generationParams, isFreeGeneration);
return this.generateAsync(generation, model, generationParams);
} catch (error) {
if (error instanceof NotFoundException || error instanceof HttpException) {
throw error;
@ -329,8 +305,7 @@ export class GenerateService {
private async generateAsync(
generation: ImageGeneration,
model: any,
params: GenerationParams,
isFreeGeneration: boolean
params: GenerationParams
): Promise<GenerateResponse> {
try {
const webhookUrl = `${this.webhookBaseUrl}/api/generate/webhook`;
@ -342,15 +317,12 @@ export class GenerateService {
webhookUrl
);
// Update generation with prediction ID and free generation flag (in metadata)
// Update generation with prediction ID
await this.db
.update(imageGenerations)
.set({
replicatePredictionId: prediction.id,
status: 'processing',
// Store isFreeGeneration in a way that can be retrieved in webhook
// We'll use the errorMessage field temporarily for metadata (cleared on success)
errorMessage: isFreeGeneration ? 'FREE_GENERATION' : null,
})
.where(eq(imageGenerations.id, generation.id));
@ -514,7 +486,7 @@ export class GenerateService {
async handleWebhook(body: any): Promise<{ received: boolean }> {
try {
const { id, status, output, error, metrics } = body;
const { id, status, output, error } = body;
if (!id) {
return { received: false };
@ -534,14 +506,11 @@ export class GenerateService {
const generation = result[0];
// Check if this was a free generation (stored in errorMessage field temporarily)
const isFreeGeneration = generation.errorMessage === 'FREE_GENERATION';
if (status === 'succeeded' && output) {
await this.processCompletedGeneration(generation, output);
// Consume credits for paid generations in staging
if (!isFreeGeneration && this.isStaging) {
// Consume credits in production
if (this.isProduction) {
await this.consumeCreditsForGeneration(generation.userId, generation.id);
}
} else if (status === 'failed') {

View file

@ -2,14 +2,16 @@
* API Client for Picture Backend
* Replaces direct Supabase calls with backend API calls.
*
* Token handling: Uses authStore.getValidToken() which automatically
* refreshes expired tokens before making requests.
* Token handling:
* - Uses authStore.getValidToken() which automatically refreshes expired tokens
* - The fetch interceptor (setupFetchInterceptor) handles 401 responses by refreshing and retrying
* - If refresh fails, the request fails and user should be redirected to login
*/
import { env } from '$env/dynamic/public';
import { authStore } from '$lib/stores/auth.svelte';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3003';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3006';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';

View file

@ -7,6 +7,7 @@ import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
const MANA_AUTH_URL = env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
const BACKEND_URL = env.PUBLIC_BACKEND_URL || 'http://localhost:3006';
export interface UserData {
id: string;
@ -28,7 +29,10 @@ async function getAuthService() {
if (!_authService) {
try {
const { initializeWebAuth } = await import('@manacore/shared-auth');
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
const auth = initializeWebAuth({
baseUrl: MANA_AUTH_URL,
backendUrl: BACKEND_URL, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
} catch (error) {

View file

@ -20,6 +20,16 @@ function getAuthUrl(): string {
return process.env.PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
}
// Get backend URL dynamically at runtime
function getBackendUrl(): string {
if (browser && typeof window !== 'undefined') {
const injectedUrl = (window as unknown as { __PUBLIC_BACKEND_URL__?: string })
.__PUBLIC_BACKEND_URL__;
return injectedUrl || 'http://localhost:3018';
}
return process.env.PUBLIC_BACKEND_URL || 'http://localhost:3018';
}
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
@ -27,7 +37,10 @@ let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null =
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: getAuthUrl() });
const auth = initializeWebAuth({
baseUrl: getAuthUrl(),
backendUrl: getBackendUrl(), // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}

View file

@ -8,8 +8,8 @@ import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
// Initialize Mana Core Auth only on the client side
// TODO: Use PUBLIC_MANA_CORE_AUTH_URL from env when available
const MANA_AUTH_URL = 'http://localhost:3001';
const BACKEND_URL = 'http://localhost:3007';
// Lazy initialization to avoid SSR issues with localStorage
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
@ -18,7 +18,10 @@ let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null =
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
const auth = initializeWebAuth({
baseUrl: MANA_AUTH_URL,
backendUrl: BACKEND_URL, // Enables automatic token refresh on 401 responses
});
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}