diff --git a/apps/context/apps/backend/src/ai/ai.controller.ts b/apps/context/apps/backend/src/ai/ai.controller.ts index 627564350..e672cf069 100644 --- a/apps/context/apps/backend/src/ai/ai.controller.ts +++ b/apps/context/apps/backend/src/ai/ai.controller.ts @@ -1,13 +1,29 @@ -import { Controller, Post, Body, UseGuards, BadRequestException } from '@nestjs/common'; +import { Controller, Post, Body, UseGuards, BadRequestException, Req } from '@nestjs/common'; import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth'; import { AiService } from './ai.service'; +import type { Request } from 'express'; + +/** + * Extract userId from a JWT token payload without JWKS verification. + * Used as fallback for Supabase tokens when mana-core-auth guard fails. + */ +function extractUserIdFromToken(authHeader: string | undefined): string | null { + if (!authHeader?.startsWith('Bearer ')) return null; + try { + const token = authHeader.slice(7); + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString()); + return payload.sub || null; + } catch { + return null; + } +} @Controller('ai') -@UseGuards(JwtAuthGuard) export class AiController { constructor(private readonly aiService: AiService) {} @Post('generate') + @UseGuards(JwtAuthGuard) async generate( @CurrentUser() user: CurrentUserData, @Body() @@ -28,7 +44,38 @@ export class AiController { return result; } + /** + * Generate endpoint that accepts Supabase tokens (for mobile app). + * Falls back to extracting userId from JWT payload when mana-core-auth is not available. + */ + @Post('generate/mobile') + async generateMobile( + @Req() req: Request, + @Body() + body: { + prompt: string; + model?: string; + temperature?: number; + maxTokens?: number; + documentId?: string; + referencedDocuments?: { title: string; content: string }[]; + } + ) { + if (!body.prompt) { + throw new BadRequestException('prompt is required'); + } + + const userId = extractUserIdFromToken(req.headers.authorization); + if (!userId) { + throw new BadRequestException('Authorization required'); + } + + const result = await this.aiService.generate(userId, body); + return result; + } + @Post('estimate') + @UseGuards(JwtAuthGuard) async estimateCost( @CurrentUser() user: CurrentUserData, @Body() @@ -42,4 +89,27 @@ export class AiController { const estimate = await this.aiService.estimateCost(user.userId, body); return estimate; } + + /** + * Estimate endpoint that accepts Supabase tokens (for mobile app). + */ + @Post('estimate/mobile') + async estimateCostMobile( + @Req() req: Request, + @Body() + body: { + prompt: string; + model?: string; + estimatedCompletionLength?: number; + referencedDocuments?: { title: string; content: string }[]; + } + ) { + const userId = extractUserIdFromToken(req.headers.authorization); + if (!userId) { + throw new BadRequestException('Authorization required'); + } + + const estimate = await this.aiService.estimateCost(userId, body); + return estimate; + } } diff --git a/apps/context/apps/mobile/services/aiService.ts b/apps/context/apps/mobile/services/aiService.ts index 666dca398..555ba0df4 100644 --- a/apps/context/apps/mobile/services/aiService.ts +++ b/apps/context/apps/mobile/services/aiService.ts @@ -1,8 +1,4 @@ -import { OpenAI } from 'openai'; -import { GoogleGenerativeAI } from '@google/generative-ai'; import { supabase } from '../utils/supabase'; -import { estimateTokens, estimateCostForPrompt, calculateCost } from './tokenCountingService'; -import { logTokenUsage, hasEnoughTokens, getCurrentTokenBalance } from './tokenTransactionService'; // Typdefinitionen export type AIProvider = 'azure' | 'google'; @@ -18,8 +14,8 @@ export type AIGenerationOptions = { temperature?: number; maxTokens?: number; prompt?: string; - documentId?: string; // ID des Dokuments, für das der Text generiert wird - referencedDocuments?: { title: string; content: string }[]; // Referenzierte Dokumente für die Token-Berechnung + documentId?: string; + referencedDocuments?: { title: string; content: string }[]; }; export type AIGenerationResult = { @@ -33,42 +29,47 @@ export type AIGenerationResult = { }; }; -// Verfügbare Modelle +// Verfügbare Modelle (routed through mana-llm on the backend) export const availableModels: AIModelOption[] = [ - { label: 'GPT-4.1', value: 'gpt-4.1', provider: 'azure' }, - { label: 'Gemini Pro', value: 'gemini-pro', provider: 'google' }, - { label: 'Gemini Flash', value: 'gemini-flash', provider: 'google' }, + { label: 'Gemma 3 4B (Lokal)', value: 'ollama/gemma3:4b', provider: 'azure' }, + { + label: 'Llama 3.1 8B', + value: 'openrouter/meta-llama/llama-3.1-8b-instruct', + provider: 'azure', + }, ]; -// Konfiguration der API-Clients -// Azure OpenAI Konfiguration -const AZURE_OPENAI_KEY = process.env.EXPO_PUBLIC_OPENAI_API_KEY || process.env.OPENAI_API_KEY || ''; -const AZURE_OPENAI_ENDPOINT = 'https://memoroseopenai.openai.azure.com/'; -const AZURE_OPENAI_DEPLOYMENT = 'gpt-4.1'; -const AZURE_OPENAI_API_VERSION = '2025-01-01-preview'; +const BACKEND_URL = + process.env.EXPO_PUBLIC_BACKEND_URL || + process.env.EXPO_PUBLIC_CONTEXT_BACKEND_URL || + 'http://localhost:3020'; -// Google AI Konfiguration -const GOOGLE_API_KEY = process.env.EXPO_PUBLIC_GOOGLE_API_KEY || process.env.GOOGLE_API_KEY || ''; +/** + * Get the current Supabase access token for backend auth + */ +const getAuthToken = async (): Promise => { + const { data } = await supabase.auth.getSession(); + const token = data?.session?.access_token; + if (!token) { + throw new Error('Nicht angemeldet'); + } + return token; +}; -// Initialisiere Azure OpenAI Client -const azureClient = new OpenAI({ - apiKey: AZURE_OPENAI_KEY, - baseURL: `${AZURE_OPENAI_ENDPOINT}openai/deployments/${AZURE_OPENAI_DEPLOYMENT}`, - defaultQuery: { 'api-version': AZURE_OPENAI_API_VERSION }, - defaultHeaders: { 'api-key': AZURE_OPENAI_KEY }, - dangerouslyAllowBrowser: true, // Erlaubt die Ausführung im Browser (für Entwicklungszwecke) -}); - -// Initialisiere Google AI Client -const googleAI = new GoogleGenerativeAI(GOOGLE_API_KEY); +/** + * Get the current user ID from Supabase session + */ +const getUserId = async (): Promise => { + const { data } = await supabase.auth.getSession(); + const userId = data?.session?.user?.id; + if (!userId) { + throw new Error('Nicht angemeldet'); + } + return userId; +}; /** * Prüft, ob der Benutzer genügend Tokens für eine Anfrage hat - * - * @param prompt Der Hauptprompt (ohne referenzierte Dokumente) - * @param model Das zu verwendende KI-Modell - * @param estimatedCompletionLength Geschätzte Länge der Antwort in Tokens - * @param referencedDocuments Optional: Array von referenzierten Dokumenten, die zum Prompt hinzugefügt werden */ export const checkTokenBalance = async ( prompt: string, @@ -77,98 +78,32 @@ export const checkTokenBalance = async ( referencedDocuments?: { title: string; content: string }[] ): Promise<{ hasEnough: boolean; estimate: any; balance: number }> => { try { - console.log('checkTokenBalance aufgerufen mit:', { - promptLength: prompt.length, - model, - estimatedCompletionLength, - referencedDocumentsCount: referencedDocuments?.length || 0, + const token = await getAuthToken(); + + const response = await fetch(`${BACKEND_URL}/api/v1/ai/estimate/mobile`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + prompt, + model, + estimatedCompletionLength, + referencedDocuments, + }), }); - // Hole den aktuellen Benutzer - const { data: sessionData } = await supabase.auth.getSession(); - const userId = sessionData?.session?.user?.id; - - if (!userId) { - throw new Error('Nicht angemeldet'); + if (!response.ok) { + throw new Error(`Backend error: ${response.status}`); } - // Berechne die Token-Anzahl des Basis-Prompts - const basePromptTokens = estimateTokens(prompt); - console.log('Basis-Prompt Tokens:', basePromptTokens); - - // Berechne die Token-Anzahl der referenzierten Dokumente - let documentTokens = 0; - let fullPrompt = prompt; - - // Füge referenzierte Dokumente hinzu, falls vorhanden - if (referencedDocuments && referencedDocuments.length > 0) { - console.log(`Verarbeite ${referencedDocuments.length} referenzierte Dokumente:`); - - // Formatierungs-Overhead für die Dokumente - const formattingOverhead = 20 + referencedDocuments.length * 10; - documentTokens += formattingOverhead; - console.log('Formatierungs-Overhead:', formattingOverhead); - - fullPrompt += '\n\nReferenzierte Dokumente:\n\n'; - - referencedDocuments.forEach((doc, index) => { - console.log( - `Dokument ${index + 1}: Titel="${doc.title}", Inhaltslänge=${doc.content?.length || 0}` - ); - - const docContent = `Dokument ${index + 1} (${doc.title}):\n${doc.content || ''}\n\n`; - fullPrompt += docContent; - - // Berechne die Token-Anzahl für dieses Dokument - const docTokens = estimateTokens(doc.content || ''); - documentTokens += docTokens; - console.log(`Dokument ${index + 1} Tokens:`, docTokens); - }); - - console.log('Gesamte Dokument-Tokens:', documentTokens); - } else { - console.log('Keine referenzierten Dokumente vorhanden'); - } - - // WICHTIG: Hier liegt möglicherweise das Problem! - // Statt den vollständigen Prompt zu übergeben, berechnen wir die Tokens separat - // und übergeben die Summe an estimateCostForPrompt - const totalInputTokens = basePromptTokens + documentTokens; - console.log('Gesamte Input-Tokens (Basis + Dokumente):', totalInputTokens); - - // Erstelle einen Dummy-Prompt mit der richtigen Länge für die Kostenberechnung - // Dies stellt sicher, dass die richtige Anzahl von Tokens verwendet wird - const dummyPrompt = 'X'.repeat(totalInputTokens * 4); // 4 Zeichen pro Token - - // Schätze die Kosten basierend auf der Gesamtzahl der Tokens - console.log('Rufe estimateCostForPrompt mit Dummy-Prompt der Länge', dummyPrompt.length, 'auf'); - const estimate = await estimateCostForPrompt(dummyPrompt, model, estimatedCompletionLength); - - // Füge die Aufschlüsselung der Token-Anzahl zur Schätzung hinzu - estimate.basePromptTokens = basePromptTokens; - estimate.documentTokens = documentTokens; - - // Überprüfe, ob die Gesamtzahl der Input-Tokens korrekt ist - console.log('Input-Tokens in der Schätzung:', estimate.inputTokens); - console.log('Erwartete Input-Tokens (Basis + Dokumente):', totalInputTokens); - - if (estimate.inputTokens !== totalInputTokens) { - console.warn( - 'WARNUNG: Die Anzahl der Input-Tokens in der Schätzung stimmt nicht mit der erwarteten Anzahl überein!' - ); - // Korrigiere die Werte in der Schätzung - estimate.inputTokens = totalInputTokens; - estimate.totalTokens = totalInputTokens + estimate.outputTokens; - console.log('Korrigierte Schätzung:', estimate); - } - - // Hole das aktuelle Token-Guthaben - const balance = await getCurrentTokenBalance(userId); - - // Prüfe, ob genügend Tokens vorhanden sind - const hasEnough = balance >= estimate.appTokens; - - return { hasEnough, estimate, balance }; + const data = await response.json(); + return { + hasEnough: data.hasEnough, + estimate: data.estimate, + balance: data.balance, + }; } catch (error) { console.error('Fehler beim Prüfen des Token-Guthabens:', error); return { hasEnough: false, estimate: null, balance: 0 }; @@ -176,114 +111,42 @@ export const checkTokenBalance = async ( }; /** - * Generiert Text mit dem angegebenen KI-Modell + * Generiert Text über das Backend (welches mana-llm nutzt) */ export const generateText = async ( prompt: string, - provider: AIProvider = 'azure', + _provider: AIProvider = 'azure', options: AIGenerationOptions = {} ): Promise => { try { - // Hole den aktuellen Benutzer - const { data: sessionData } = await supabase.auth.getSession(); - const userId = sessionData?.session?.user?.id; + const token = await getAuthToken(); - if (!userId) { - throw new Error('Nicht angemeldet'); - } - - // Bestimme das Modell - const model = options.model || (provider === 'azure' ? 'gpt-4.1' : 'gemini-pro'); - - // Prüfe, ob eine manuelle Token-Schätzung übergeben wurde - let manualInputTokens = 0; - if (options.prompt) { - // Versuche, die manuelle Token-Schätzung zu parsen - try { - manualInputTokens = parseInt(options.prompt, 10); - } catch (e) { - console.warn('Konnte manuelle Token-Schätzung nicht parsen:', options.prompt); - } - } - - // Schätze die Kosten - let hasEnough = false; - let estimate: any = null; - - if (manualInputTokens > 0) { - // Verwende die manuelle Token-Schätzung - const result = await calculateCost(model, manualInputTokens, options.maxTokens || 2000); // Standard auf 2000 Tokens statt 500 - estimate = result; - - // Prüfe, ob genügend Tokens vorhanden sind - const balance = await getCurrentTokenBalance(userId); - hasEnough = balance >= result.appTokens; - } else { - // Verwende die automatische Token-Schätzung - const result = await checkTokenBalance( + const response = await fetch(`${BACKEND_URL}/api/v1/ai/generate/mobile`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ prompt, - model, - options.maxTokens || 2000 // Standard auf 2000 Tokens statt 500 - ); - - hasEnough = result.hasEnough; - estimate = result.estimate; - } - - if (!hasEnough) { - throw new Error('Nicht genügend Tokens für diese Anfrage. Bitte kaufen Sie weitere Tokens.'); - } - - // Generiere den Text - let completionText = ''; - switch (provider) { - case 'azure': - completionText = await generateWithAzureOpenAI(prompt, options); - break; - case 'google': - completionText = await generateWithGoogle(prompt, options); - break; - default: - throw new Error(`Unbekannter Provider: ${provider}`); - } - - // Berechne die tatsächliche Anzahl der Output-Tokens - const completionTokens = estimateTokens(completionText); - - // Berechne die tatsächlichen Kosten mit den realen Output-Tokens - const realCost = await calculateCost(model, estimate.inputTokens, completionTokens); - - // Deutliche Protokollierung der tatsächlichen Kosten - console.log('=== TATSÄCHLICHE TOKEN-KOSTEN NACH GENERIERUNG ==='); - console.log('Geschätzte Kosten vor Generierung:', { - inputTokens: estimate.inputTokens, - outputTokens: options.maxTokens || 500, - appTokens: estimate.appTokens, - costUsd: estimate.costUsd, - }); - console.log('Tatsächliche Kosten nach Generierung:', { - inputTokens: estimate.inputTokens, - outputTokens: completionTokens, - appTokens: realCost.appTokens, - costUsd: realCost.costUsd, - differenz: estimate.appTokens - realCost.appTokens, + model: options.model || 'ollama/gemma3:4b', + temperature: options.temperature, + maxTokens: options.maxTokens, + documentId: options.documentId, + referencedDocuments: options.referencedDocuments, + }), }); - // Protokolliere die Token-Nutzung mit den tatsächlichen Werten - await logTokenUsage(userId, model, prompt, completionText, options.documentId); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || `Backend error: ${response.status}`); + } - // Hole das aktualisierte Token-Guthaben - const remainingTokens = await getCurrentTokenBalance(userId); + const result = await response.json(); return { - text: completionText, - tokenInfo: { - promptTokens: estimate.inputTokens, - completionTokens: completionTokens, - totalTokens: estimate.inputTokens + completionTokens, - tokensUsed: realCost.appTokens, // Verwende die tatsächlichen Kosten - remainingTokens: remainingTokens, - }, + text: result.text, + tokenInfo: result.tokenInfo, }; } catch (error: any) { console.error('Fehler bei der Textgenerierung:', error); @@ -291,71 +154,16 @@ export const generateText = async ( } }; -/** - * Generiert Text mit Azure OpenAI-Modellen - */ -const generateWithAzureOpenAI = async ( - prompt: string, - options: AIGenerationOptions = {} -): Promise => { - if (!AZURE_OPENAI_KEY) { - throw new Error('Azure OpenAI API-Schlüssel nicht konfiguriert'); - } - - try { - const response = await azureClient.chat.completions.create({ - model: AZURE_OPENAI_DEPLOYMENT, - messages: [ - { role: 'system', content: 'You are a helpful assistant.' }, - { role: 'user', content: prompt }, - ], - temperature: options.temperature || 0.7, - max_tokens: options.maxTokens || 500, - }); - - return response.choices[0].message.content || ''; - } catch (error) { - console.error('Fehler bei Azure OpenAI-Anfrage:', error); - throw error; - } -}; - -/** - * Generiert Text mit Google Gemini-Modellen - */ -const generateWithGoogle = async ( - prompt: string, - options: AIGenerationOptions = {} -): Promise => { - if (!GOOGLE_API_KEY) { - throw new Error('Google API-Schlüssel nicht konfiguriert'); - } - - try { - const model = googleAI.getGenerativeModel({ - model: options.model || 'gemini-pro', - }); - - const result = await model.generateContent(prompt); - const response = await result.response; - return response.text(); - } catch (error) { - console.error('Fehler bei Google AI-Anfrage:', error); - throw error; - } -}; - /** * Hilfsfunktion zum Abrufen von Modelloptionen für einen bestimmten Provider */ -export const getModelsByProvider = (provider: AIProvider): AIModelOption[] => { - return availableModels.filter((model) => model.provider === provider); +export const getModelsByProvider = (_provider: AIProvider): AIModelOption[] => { + return availableModels; }; /** * Hilfsfunktion zum Abrufen des Providers für ein bestimmtes Modell */ -export const getProviderForModel = (modelValue: string): AIProvider => { - const model = availableModels.find((m) => m.value === modelValue); - return model?.provider || 'azure'; +export const getProviderForModel = (_modelValue: string): AIProvider => { + return 'azure'; };