diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php index 511ec9c..ea6f14d 100644 --- a/app/Http/Controllers/BillingController.php +++ b/app/Http/Controllers/BillingController.php @@ -17,6 +17,10 @@ class BillingController extends Controller public function __construct(XenditService $xenditService) { $this->xenditService = $xenditService; + + if (config('app.edition') !== 'cloud') { + abort(404); + } } public function index(Request $request) @@ -36,6 +40,7 @@ public function index(Request $request) 'updated_at' => $transaction->updated_at, 'amount' => $transaction->amount, 'type' => $transaction->type, + 'transaction_type' => $transaction->transaction_type, 'description' => $transaction->description, 'status' => $currentStatus, 'payment_method' => $transaction->payment_method, @@ -43,7 +48,7 @@ public function index(Request $request) 'expired_at' => $transaction->expired_at, 'formatted_status' => ucfirst($currentStatus), 'status_color' => $this->getStatusColor($currentStatus), - 'formatted_type' => ucfirst($transaction->type), + 'formatted_type' => ucfirst(str_replace('_', ' ', $transaction->type)), 'type_color' => $this->getTypeColor($transaction->type), ]; }); @@ -103,6 +108,7 @@ public function topup(Request $request) 'team_id' => $team->id, 'amount' => $request->amount, 'type' => 'topup', + 'transaction_type' => 'credit', 'description' => 'Credit top-up', 'status' => 'pending', 'external_id' => $externalId, @@ -216,7 +222,10 @@ public function handleWebhook(Request $request) ['team_id' => $transaction->team_id], ['amount' => 0] ); - $balance->amount += $payload['amount']; + // Amount is now stored as integer (multiplied by 1,000,000) + // The setAmountAttribute in Balance model will handle the conversion + $currentDecimalAmount = $balance->decimal_amount; + $balance->amount = $currentDecimalAmount + $payload['amount']; $balance->save(); Log::info('Successfully processed Xendit payment', [ diff --git a/app/Http/Controllers/BotController.php b/app/Http/Controllers/BotController.php index 5288119..3528be2 100644 --- a/app/Http/Controllers/BotController.php +++ b/app/Http/Controllers/BotController.php @@ -7,20 +7,20 @@ use App\Models\ChatHistory; use App\Models\Knowledge; use App\Models\Tool; -use App\Services\AIResponseService; +use App\Services\MessageHandlerService; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; class BotController extends Controller { - protected $aiResponseService; + protected $messageHandlerService; - public function __construct(AIResponseService $aiResponseService) + public function __construct(MessageHandlerService $messageHandlerService) { $this->authorizeResource(Bot::class); - $this->aiResponseService = $aiResponseService; + $this->messageHandlerService = $messageHandlerService; } public function index(Request $request) @@ -191,35 +191,44 @@ public function disconnectTool(Request $request, Bot $bot) public function testMessage(Request $request, Bot $bot) { $validated = $request->validate([ - 'message' => 'required|string|max:1000', + 'message' => 'nullable|string|max:1000', + 'media_file' => 'nullable|file|max:20480|mimes:jpg,jpeg,png,gif,webp,mp3,wav,ogg,m4a,webm,flac,mp4,avi,mov,pdf,doc,docx,txt', ]); try { - $response = $this->aiResponseService->generateResponse( - $bot, - $validated['message'], - 'testing', + $messageContent = $validated['message'] ?? null; + $mediaFile = $request->hasFile('media_file') ? $request->file('media_file') : null; + + // Use handleMessage method with bot parameter for testing + $result = $this->messageHandlerService->handleMessage( null, // no channel for testing - null, - 'html' + 'testing', + $messageContent, + $mediaFile, + $bot, ); + $response = $result['response']; + $media = $result['media']; + // Return back with flash data return back()->with('chatResponse', [ 'success' => true, 'response' => $response, 'timestamp' => now()->toISOString(), + 'hasMedia' => $media !== null, ]); } catch (\Exception $e) { Log::error('Failed to generate test response: ' . $e->getMessage(), [ 'bot_id' => $bot->id, - 'message' => $validated['message'], + 'message' => $validated['message'] ?? null, + 'has_media' => $request->hasFile('media_file'), 'exception' => $e->getTraceAsString() ]); return back()->with('chatResponse', [ 'success' => false, - 'error' => 'Failed to generate response. Please try again.', + 'error' => $e->getMessage(), 'timestamp' => now()->toISOString(), ]); } diff --git a/app/Http/Controllers/PricingController.php b/app/Http/Controllers/PricingController.php new file mode 100644 index 0000000..4b570ab --- /dev/null +++ b/app/Http/Controllers/PricingController.php @@ -0,0 +1,54 @@ +tokenPricingService = $tokenPricingService; + + if (config('app.edition') !== 'cloud') { + abort(404); + } + } + + /** + * Display the pricing page with token pricing for all models. + */ + public function index(Request $request) + { + // Get all pricing data + $allPricing = $this->tokenPricingService->getAllPricing(); + + // Transform pricing data to include both USD and credits + $pricingData = []; + + foreach ($allPricing as $provider => $models) { + $pricingData[$provider] = []; + + foreach ($models as $modelName => $pricing) { + $pricingData[$provider][$modelName] = [ + 'input_usd' => $pricing['input'], + 'output_usd' => $pricing['output'], + 'input_credits' => $this->tokenPricingService->usdToCredits($pricing['input']), + 'output_credits' => $this->tokenPricingService->usdToCredits($pricing['output']), + ]; + } + } + + return Inertia::render('Pricing/Index', [ + 'pricing' => $pricingData, + 'exchange_rate' => [ + 'usd_to_credits' => 16500, + 'credits_to_usd' => 1 / 16500, + ], + ]); + } +} diff --git a/app/Http/Controllers/UsageController.php b/app/Http/Controllers/UsageController.php new file mode 100644 index 0000000..5f6bfcd --- /dev/null +++ b/app/Http/Controllers/UsageController.php @@ -0,0 +1,106 @@ +user()->currentTeam; + + // Get token usage data for the current team + $usages = TokenUsage::where('team_id', $team->id) + ->with('bot:id,name') + ->orderBy('id', 'desc') + ->paginate(50); + + // Calculate summary statistics + $totalCreditsRaw = TokenUsage::where('team_id', $team->id)->sum('credits'); + $totalCredits = $totalCreditsRaw / 1000000; // Convert from integer to decimal + $totalInputTokens = TokenUsage::where('team_id', $team->id)->sum('input_tokens'); + $totalOutputTokens = TokenUsage::where('team_id', $team->id)->sum('output_tokens'); + + // Get usage by provider + $usageByProvider = TokenUsage::where('team_id', $team->id) + ->selectRaw('provider, SUM(credits) as total_credits, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens') + ->groupBy('provider') + ->get() + ->map(function ($item) { + $item->total_input_tokens = (int) $item->total_input_tokens; + $item->total_output_tokens = (int) $item->total_output_tokens; + $item->total_credits = (float) ($item->total_credits / 1000000); // Convert from integer to decimal + return $item; + }); + + // Get usage by model + $usageByModel = TokenUsage::where('team_id', $team->id) + ->selectRaw('provider, model, SUM(credits) as total_credits, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens, COUNT(*) as usage_count') + ->groupBy('provider', 'model') + ->orderBy('total_credits', 'desc') + ->get() + ->map(function ($item) { + $item->total_input_tokens = (int) $item->total_input_tokens; + $item->total_output_tokens = (int) $item->total_output_tokens; + $item->total_credits = (float) ($item->total_credits / 1000000); // Convert from integer to decimal + $item->usage_count = (int) $item->usage_count; + return $item; + }); + + // Get usage by bot + $usageByBot = TokenUsage::where('team_id', $team->id) + ->with('bot:id,name') + ->selectRaw('bot_id, SUM(credits) as total_credits, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens, COUNT(*) as usage_count') + ->groupBy('bot_id') + ->orderBy('total_credits', 'desc') + ->get() + ->map(function ($item) { + $item->total_input_tokens = (int) $item->total_input_tokens; + $item->total_output_tokens = (int) $item->total_output_tokens; + $item->total_credits = (float) ($item->total_credits / 1000000); // Convert from integer to decimal + $item->usage_count = (int) $item->usage_count; + return $item; + }); + + // Get daily usage for the last 30 days + $dailyUsage = TokenUsage::where('team_id', $team->id) + ->selectRaw('DATE(created_at) as date, SUM(credits) as total_credits, SUM(input_tokens) as total_input_tokens, SUM(output_tokens) as total_output_tokens') + ->where('created_at', '>=', now()->subDays(30)) + ->groupBy('date') + ->orderBy('date', 'desc') + ->get() + ->map(function ($item) { + $item->total_input_tokens = (int) $item->total_input_tokens; + $item->total_output_tokens = (int) $item->total_output_tokens; + $item->total_credits = (float) ($item->total_credits / 1000000); // Convert from integer to decimal + return $item; + }); + + return Inertia::render('Usage/Index', [ + 'usages' => $usages, + 'summary' => [ + 'total_credits' => round($totalCredits, 2), + 'total_input_tokens' => $totalInputTokens, + 'total_output_tokens' => $totalOutputTokens, + 'total_tokens' => $totalInputTokens + $totalOutputTokens, + ], + 'usage_by_provider' => $usageByProvider, + 'usage_by_model' => $usageByModel, + 'usage_by_bot' => $usageByBot, + 'daily_usage' => $dailyUsage, + ]); + } +} diff --git a/app/Http/Controllers/WhatsAppMessageController.php b/app/Http/Controllers/WhatsAppMessageController.php index aa66a7c..387e162 100644 --- a/app/Http/Controllers/WhatsAppMessageController.php +++ b/app/Http/Controllers/WhatsAppMessageController.php @@ -2,60 +2,35 @@ namespace App\Http\Controllers; -use App\Models\ChatHistory; -use App\Models\Channel; -use App\Services\AIResponseService; -use App\Services\MediaProcessingService; -use App\Services\TransactionService; +use App\Services\MessageHandlerService; use Illuminate\Http\Request; class WhatsAppMessageController extends Controller { - protected $aiResponseService; - protected $transactionService; - protected $mediaProcessingService; - - public function __construct( - AIResponseService $aiResponseService, - TransactionService $transactionService, - MediaProcessingService $mediaProcessingService - ) { - $this->aiResponseService = $aiResponseService; - $this->transactionService = $transactionService; - $this->mediaProcessingService = $mediaProcessingService; + protected $messageHandlerService; + + public function __construct(MessageHandlerService $messageHandlerService) + { + $this->messageHandlerService = $messageHandlerService; } public function handleIncomingMessage(Request $request) { - $validated = $request->validate([ - 'channelId' => 'required|integer', - 'sender' => 'required|string', - 'message' => 'nullable|string', - 'media_file' => 'nullable|file|max:20480|mimes:jpg,jpeg,png,gif,webp,mp3,wav,ogg,m4a,webm,flac,mp4,avi,mov,pdf,doc,docx,txt', - ]); - - $channelId = $validated['channelId']; - $sender = $validated['sender']; - $messageContent = $validated['message']; - - $media = null; - if ($request->hasFile('media_file')) { - $media = $this->mediaProcessingService->process($request->file('media_file'), $messageContent); - } - - $channel = Channel::with(['bots', 'team.balance'])->findOrFail($channelId); - $bot = $channel->bots->first(); + try { + // Validate the request data + $validated = $this->messageHandlerService->validateMessageData($request->all()); - if (!$bot) { - return response()->json(['error' => 'No bot found for this channel'], 404); - } - - $response = $this->aiResponseService->generateResponse($bot, $messageContent, $sender, $channelId, $media); + $channelId = $validated['channelId']; + $sender = $validated['sender']; + $messageContent = $validated['message'] ?? null; + $mediaFile = $request->hasFile('media_file') ? $request->file('media_file') : null; - // Record usage transaction and deduct credits - $this->transactionService->recordUsage($channel->team); + // Handle the message using the service + $result = $this->messageHandlerService->handleMessage($channelId, $sender, $messageContent, $mediaFile, null, 'whatsapp'); - return response()->json(['response' => $response]); + return response()->json(['response' => $result['response']]); + } catch (\Exception $e) { + return response()->json(['error' => $e->getMessage()], 404); + } } - } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 3f0572d..b61935e 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -42,6 +42,7 @@ public function share(Request $request): array return array_merge(parent::share($request), [ 'flash' => $session->all(), + 'appEdition' => config('app.edition'), ]); } } diff --git a/app/Models/Balance.php b/app/Models/Balance.php index e76e033..7cbb6ba 100644 --- a/app/Models/Balance.php +++ b/app/Models/Balance.php @@ -4,6 +4,8 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\HasMany; class Balance extends Model { @@ -11,8 +13,34 @@ class Balance extends Model protected $fillable = ['team_id', 'amount']; - public function team() + protected $casts = [ + 'amount' => 'integer', + ]; + + public function team(): BelongsTo { return $this->belongsTo(Team::class); } + + public function transactions(): HasMany + { + return $this->hasMany(Transaction::class, 'team_id', 'team_id'); + } + + /** + * Convert integer amount to decimal for display + */ + public function getDecimalAmountAttribute() + { + return $this->amount / 1000000; + } + + /** + * Set amount from decimal value (converts to integer for storage) + */ + public function setAmountAttribute($value) + { + $this->attributes['amount'] = round($value * 1000000); + } + } diff --git a/app/Models/TokenUsage.php b/app/Models/TokenUsage.php new file mode 100644 index 0000000..0902800 --- /dev/null +++ b/app/Models/TokenUsage.php @@ -0,0 +1,96 @@ + 'integer', + 'output_tokens' => 'integer', + 'tokens_per_second' => 'decimal:2', + 'credits' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + /** + * Get the team that owns the token usage. + */ + public function team(): BelongsTo + { + return $this->belongsTo(Team::class); + } + + /** + * Get the bot that owns the token usage. + */ + public function bot(): BelongsTo + { + return $this->belongsTo(Bot::class); + } + + /** + * Get the total tokens used. + */ + public function getTotalTokensAttribute(): int + { + return $this->input_tokens + $this->output_tokens; + } + + /** + * Scope to filter by provider. + */ + public function scopeProvider($query, string $provider) + { + return $query->where('provider', $provider); + } + + /** + * Scope to filter by model. + */ + public function scopeModel($query, string $model) + { + return $query->where('model', $model); + } + + /** + * Scope to filter by date range. + */ + public function scopeDateRange($query, $startDate, $endDate) + { + return $query->whereBetween('created_at', [$startDate, $endDate]); + } + + /** + * Convert integer credits to decimal for display + */ + public function getDecimalCreditsAttribute() + { + return $this->credits / 1000000; + } + + /** + * Set credits from decimal value (converts to integer for storage) + */ + public function setCreditsAttribute($value) + { + $this->attributes['credits'] = round($value * 1000000); + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 81ce1fc..3b51665 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -13,6 +13,7 @@ class Transaction extends Model 'team_id', 'amount', 'type', + 'transaction_type', 'description', 'status', 'payment_id', @@ -23,6 +24,7 @@ class Transaction extends Model ]; protected $casts = [ + 'amount' => 'integer', 'payment_details' => 'array', 'expired_at' => 'datetime', ]; @@ -31,4 +33,52 @@ public function team() { return $this->belongsTo(Team::class); } + + /** + * Convert integer amount to decimal for display + */ + public function getDecimalAmountAttribute() + { + return $this->amount / 1000000; + } + + /** + * Get the formatted amount in credits + */ + public function getFormattedAmountAttribute() + { + return number_format($this->decimal_amount, 0, ',', '.') . ' credits'; + } + + /** + * Get the display amount with proper sign based on transaction type + */ + public function getDisplayAmountAttribute() + { + $sign = $this->transaction_type === 'debit' ? '-' : '+'; + return $sign . ' ' . $this->formatted_amount; + } + + /** + * Set amount from decimal value (converts to integer for storage) + */ + public function setAmountAttribute($value) + { + $this->attributes['amount'] = round($value * 1000000); + } + + /** + * Determine transaction type based on the type field + */ + public static function boot() + { + parent::boot(); + + static::creating(function ($transaction) { + if (empty($transaction->transaction_type)) { + // Set transaction_type based on the type field + $transaction->transaction_type = in_array($transaction->type, ['usage', 'refund']) ? 'debit' : 'credit'; + } + }); + } } diff --git a/app/Services/AIResponseService.php b/app/Services/AIResponseService.php index b3fc254..9660047 100644 --- a/app/Services/AIResponseService.php +++ b/app/Services/AIResponseService.php @@ -2,42 +2,67 @@ namespace App\Services; -use App\Services\AIServiceFactory; use App\Services\Contracts\EmbeddingServiceInterface; +use App\Services\Contracts\ChatServiceInterface; +use App\Services\TokenPricingService; +use App\Services\Traits\AIServiceHelperTrait; +use App\Models\Bot; +use App\Models\Channel; use App\Models\ChatMedia; use App\Models\Tool; use App\Models\ChatHistory; +use App\Models\TokenUsage; +use App\Services\TokenUsageService; use Illuminate\Support\Facades\Log; - +use Illuminate\Support\Collection; + +/** + * AI Response Service + * + * Handles AI response generation, tool calling, knowledge search, + * and chat history management for bots. + */ class AIResponseService { + use AIServiceHelperTrait; + protected ToolService $toolService; + protected TokenPricingService $tokenPricingService; + protected TokenUsageService $tokenUsageService; protected bool $toolCallingEnabled = true; - public function __construct(ToolService $toolService) + /** + * Constructor + * + * @param TokenPricingService $tokenPricingService Service for calculating token costs + * @param TokenUsageService $tokenUsageService Service for handling token usage + */ + public function __construct(ToolService $toolService, TokenPricingService $tokenPricingService, TokenUsageService $tokenUsageService) { $this->toolService = $toolService; + $this->tokenPricingService = $tokenPricingService; + $this->tokenUsageService = $tokenUsageService; } /** * Generate AI response for a bot with message and chat history. * - * @param object $bot Instance model bot (memiliki properti "prompt") - * @param string $message Pesan terbaru dari pengguna - * @param string $sender Sender identifier - * @param int|null $channelId Channel ID (nullable for testing) - * @param \App\Models\ChatMedia|null $media Media data (optional) - * @param string $format Output format: 'whatsapp' or 'html' (default: 'whatsapp') - * @return string|bool String berisi jawaban terformat, atau false kalau gagal + * @param Bot $bot Bot instance with prompt property + * @param Channel $channel Channel instance + * @param string $message Latest message from user + * @param string $sender Sender identifier + * @param ChatMedia|null $media Media data (optional) + * @param string $format Output format: 'whatsapp' or 'html' + * @return string|false Formatted response string or false on failure */ - public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia $media, $format = 'whatsapp') + public function generateResponse(Bot $bot, ?Channel $channel, string $message, string $sender, ?ChatMedia $media = null, string $format = 'whatsapp'): string|false { try { // Get chat history from database - $chatHistory = $this->getChatHistory($bot->id, $channelId, $sender, 5); + $chatHistory = $this->getChatHistory($bot->id, $channel->id ?? null, $sender, 5); // Save user message to chat history $this->saveChatHistory([ - 'channel_id' => $channelId, + 'channel_id' => $channel->id ?? null, 'bot_id' => $bot->id, 'sender' => $sender, 'message' => $message, @@ -54,11 +79,14 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia ]); // Get separately configured services - $chatService = AIServiceFactory::createChatService(); - $embeddingService = AIServiceFactory::createEmbeddingService(); + $services = $this->getAIServices(); + $chatService = $services['chat']; + $embeddingService = $services['embedding']; // Search for relevant knowledge using embedding service - $relevantKnowledge = $this->searchSimilarKnowledge($embeddingService, $message, $bot, 3); + $embeddingResult = $this->searchSimilarKnowledge($embeddingService, $message, $bot, 3); + $relevantKnowledge = $embeddingResult['knowledge']; + $embeddingTokenUsage = $embeddingResult['token_usage'] ?? null; // Build system prompt $systemPrompt = $this->buildSystemPrompt($bot, $relevantKnowledge); @@ -72,6 +100,7 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia : []; // Generate response using chat service + $startTime = microtime(true); $response = $chatService->generateResponse( $messages, null, // model parameter @@ -79,10 +108,14 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia $media ? $media->mime_type : null, $tools ); + $endTime = microtime(true); + $responseTime = $endTime - $startTime; $toolCalls = null; $toolResponses = null; $rawContent = null; + $chatTokenUsage = $response['token_usage'] ?? null; + $finalTokenUsage = null; // Handle tool calls if present in the response if (is_array($response) && isset($response['tool_calls']) && !empty($response['tool_calls'])) { @@ -90,7 +123,7 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia // Save assistant message with tool calls first $this->saveChatHistory([ - 'channel_id' => $channelId, + 'channel_id' => $channel->id ?? null, 'bot_id' => $bot->id, 'sender' => $sender, 'message' => $response['content'] ?? '', @@ -106,7 +139,7 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia ] ]); - $toolResponses = $this->handleToolCalls($response['tool_calls'], $bot, $channelId, $sender); + $toolResponses = $this->handleToolCalls($response['tool_calls'], $bot, $channel->id ?? null, $sender); // Add assistant message with tool calls $messages[] = [ @@ -119,11 +152,24 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia $messages = array_merge($messages, $toolResponses); // Generate final response + $finalStartTime = microtime(true); $finalResponse = $chatService->generateResponse($messages, null, null, null, []); + $finalEndTime = microtime(true); + $finalResponseTime = $finalEndTime - $finalStartTime; + $responseContent = is_array($finalResponse) ? ($finalResponse['content'] ?? '') : $finalResponse; $rawContent = $responseContent; + $finalTokenUsage = $finalResponse['token_usage'] ?? null; + + // Combine token usage from both calls + if ($chatTokenUsage && $finalTokenUsage) { + $chatTokenUsage['input_tokens'] += $finalTokenUsage['input_tokens']; + $chatTokenUsage['output_tokens'] += $finalTokenUsage['output_tokens']; + $chatTokenUsage['total_tokens'] += $finalTokenUsage['total_tokens']; + } + $responseTime += $finalResponseTime; } else { - $responseContent = is_array($response) ? ($response['content'] ?? $response) : $response; + $responseContent = is_array($response) ? ($response['content'] ?? '') : $response; $rawContent = $responseContent; } @@ -135,9 +181,15 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia $formattedResponse = $this->convertMarkdownToWhatsApp($responseContent); } + // Record token usage and calculate costs + if ($embeddingTokenUsage) { + $this->recordTokenUsage($bot, $embeddingService, $embeddingTokenUsage, 0, 'embedding'); + } + $this->recordTokenUsage($bot, $chatService, $chatTokenUsage, $responseTime); + // Save assistant response to chat history $this->saveChatHistory([ - 'channel_id' => $channelId, + 'channel_id' => $channel->id ?? null, 'bot_id' => $bot->id, 'sender' => $sender, 'message' => $formattedResponse, @@ -148,7 +200,9 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia 'format' => $format, 'model_used' => get_class($chatService), 'timestamp' => now()->toISOString(), - 'knowledge_used' => $relevantKnowledge->isNotEmpty() + 'knowledge_used' => $relevantKnowledge->isNotEmpty(), + 'token_usage' => $chatTokenUsage, + 'embedding_token_usage' => $embeddingTokenUsage ] ]); @@ -159,29 +213,16 @@ public function generateResponse($bot, $message, $sender, $channelId, ?ChatMedia } } - /** - * Build system prompt with bot prompt and relevant knowledge. - */ - private function buildSystemPrompt($bot, $relevantKnowledge) - { - $systemPrompt = $bot->prompt; - - if ($relevantKnowledge->isNotEmpty()) { - $systemPrompt .= "\n\nGunakan informasi berikut untuk menjawab pertanyaan:\n\n"; - foreach ($relevantKnowledge as $knowledge) { - $systemPrompt .= "{$knowledge['text']}\n\n"; - } - } else { - $systemPrompt .= "\n\nTidak ada informasi spesifik yang ditemukan dalam basis pengetahuan."; - } - - return $systemPrompt; - } /** * Build messages array from system prompt, chat history, and current message. + * + * @param string $systemPrompt System prompt text + * @param Collection $chatHistory Collection of chat history items + * @param string $message Current user message + * @return array Array of messages formatted for AI service */ - private function buildMessagesArray($systemPrompt, $chatHistory, $message) + private function buildMessagesArray(string $systemPrompt, Collection $chatHistory, string $message): array { $messages = [ ['role' => 'system', 'content' => $systemPrompt], @@ -221,8 +262,11 @@ private function buildMessagesArray($systemPrompt, $chatHistory, $message) /** * Convert markdown formatting to HTML. + * + * @param string $text Markdown text to convert + * @return string HTML formatted text */ - public function convertMarkdownToHtml($text) + public function convertMarkdownToHtml(string $text): string { // Convert headers: # text to

text

, ## text to

text

, etc. $text = preg_replace_callback('/^(#{1,6})\s+(.*)$/m', function($matches) { @@ -263,8 +307,11 @@ public function convertMarkdownToHtml($text) /** * Convert markdown formatting to WhatsApp-compatible formatting. + * + * @param string $text Markdown text to convert + * @return string WhatsApp formatted text */ - public function convertMarkdownToWhatsApp($text) + public function convertMarkdownToWhatsApp(string $text): string { // Convert italic: *text* or _text_ to _text_ $text = preg_replace('/(?createEmbedding($query); + $embeddingResult = $embeddingService->createEmbedding($query); + $queryEmbedding = $embeddingResult['embeddings'][0] ?? $embeddingResult; + $tokenUsage = $embeddingResult['token_usage'] ?? null; // Get only necessary vectors with optimized query $knowledgeVectors = $bot->knowledge() @@ -311,21 +369,33 @@ public function searchSimilarKnowledge(EmbeddingServiceInterface $embeddingServi }); // Sort and limit results - return $knowledgeVectors->sortByDesc('similarity') + $knowledge = $knowledgeVectors->sortByDesc('similarity') ->take($limit) ->values(); + return [ + 'knowledge' => $knowledge, + 'token_usage' => $tokenUsage + ]; + } catch (\Exception $e) { Log::error('Error searching similar knowledge: ' . $e->getMessage()); - return collect(); + return [ + 'knowledge' => collect(), + 'token_usage' => null + ]; } } /** * Calculate similarity between two vectors using fast C extension if available, * otherwise fallback to PHP implementation. + * + * @param array $vector1 First vector + * @param array $vector2 Second vector + * @return float Similarity score between 0 and 1 */ - protected function calculateSimilarity($vector1, $vector2) + protected function calculateSimilarity(array $vector1, array $vector2): float { if (function_exists('fast_cosine_similarity')) { return fast_cosine_similarity($vector1, $vector2); @@ -336,8 +406,12 @@ protected function calculateSimilarity($vector1, $vector2) /** * Calculate cosine similarity between two vectors using PHP implementation. + * + * @param array $vector1 First vector + * @param array $vector2 Second vector + * @return float Cosine similarity score between -1 and 1 */ - protected function cosineSimilarity($vector1, $vector2) + protected function cosineSimilarity(array $vector1, array $vector2): float { $dotProduct = 0; $norm1 = 0; @@ -357,8 +431,11 @@ protected function cosineSimilarity($vector1, $vector2) /** * Get available tools for a bot. + * + * @param Bot $bot Bot instance with team_id property + * @return array Array of formatted tools for AI service */ - protected function getAvailableToolsForBot($bot): array + protected function getAvailableToolsForBot(Bot $bot): array { $tools = Tool::where('team_id', $bot->team_id) ->where('is_active', true) @@ -378,8 +455,14 @@ protected function getAvailableToolsForBot($bot): array /** * Handle tool calls from AI response. + * + * @param array $toolCalls Array of tool calls from AI response + * @param Bot $bot Bot instance + * @param int|null $channelId Channel ID (optional) + * @param string|null $sender Sender identifier (optional) + * @return array Array of tool responses */ - protected function handleToolCalls(array $toolCalls, $bot, $channelId = null, $sender = null): array + protected function handleToolCalls(array $toolCalls, Bot $bot, ?int $channelId = null, ?string $sender = null): array { $toolResponses = []; @@ -575,6 +658,10 @@ protected function handleToolCalls(array $toolCalls, $bot, $channelId = null, $s * - Must start with a letter or underscore * - Must be alphanumeric (a-z, A-Z, 0-9), underscores (_), dots (.) or dashes (-) * - Maximum length of 64 characters + * + * @param int $id Tool ID to append to function name + * @param string $name Original function name + * @return string Sanitized function name */ private function sanitizeFunctionName(int $id, string $name): string { @@ -607,8 +694,14 @@ private function sanitizeFunctionName(int $id, string $name): string /** * Get chat history for a specific channel, sender, and bot. + * + * @param int $botId Bot ID + * @param int|null $channelId Channel ID (nullable) + * @param string $sender Sender identifier + * @param int $limit Maximum number of history items to retrieve + * @return Collection Collection of chat history items */ - protected function getChatHistory($botId, $channelId, $sender, $limit = 5) + protected function getChatHistory(int $botId, ?int $channelId, string $sender, int $limit = 5): Collection { $query = ChatHistory::where('bot_id', $botId) ->where('sender', $sender) @@ -624,10 +717,59 @@ protected function getChatHistory($botId, $channelId, $sender, $limit = 5) return $query->get()->reverse()->values(); } + /** + * Record token usage and calculate costs. + * + * @param Bot $bot Bot instance with team_id property + * @param ChatServiceInterface|EmbeddingServiceInterface $service AI service instance + * @param array|null $tokenUsage Token usage data with input_tokens and output_tokens + * @param float $responseTime Response time in seconds + * @param string $type Usage type ('chat' or 'embedding') + * @return void + */ + protected function recordTokenUsage(Bot $bot, ChatServiceInterface|EmbeddingServiceInterface $service, ?array $tokenUsage, float $responseTime, string $type = 'chat'): void + { + if (!$tokenUsage || !isset($tokenUsage['input_tokens'], $tokenUsage['output_tokens'])) { + return; + } + + // Determine provider and model + $provider = $service->getProvider(); + $model = $type === 'chat' ? $service->getModel() : $service->getEmbeddingModel(); + + // Calculate tokens per second + $totalTokens = $tokenUsage['output_tokens']; + $tokensPerSecond = $responseTime > 0 && $totalTokens > 0 ? round($totalTokens / $responseTime, 2) : null; + + // Calculate cost + $credits = $this->calculateCreditsForTokens( + $this->tokenPricingService, + $provider, + $model, + $tokenUsage['input_tokens'], + $tokenUsage['output_tokens'] + ); + + // Store token usage and create daily transaction + $this->tokenUsageService->createTokenUsage([ + 'team_id' => $bot->team_id, + 'bot_id' => $bot->id, + 'provider' => $provider, + 'model' => $model, + 'input_tokens' => $tokenUsage['input_tokens'], + 'output_tokens' => $tokenUsage['output_tokens'], + 'tokens_per_second' => $tokensPerSecond, + 'credits' => $credits, + ]); + } + /** * Save chat history entry. + * + * @param array $data Chat history data to save + * @return ChatHistory Created chat history instance */ - protected function saveChatHistory(array $data) + protected function saveChatHistory(array $data): ChatHistory { return ChatHistory::create($data); } diff --git a/app/Services/Contracts/ChatServiceInterface.php b/app/Services/Contracts/ChatServiceInterface.php index 86c9010..3bb5dbf 100644 --- a/app/Services/Contracts/ChatServiceInterface.php +++ b/app/Services/Contracts/ChatServiceInterface.php @@ -5,4 +5,8 @@ interface ChatServiceInterface { public function generateResponse(array $messages, ?string $model = null, ?string $media = null, ?string $mimeType = null, array $tools = []): array|string; + + public function getProvider(): string; + + public function getModel(): string; } \ No newline at end of file diff --git a/app/Services/Contracts/EmbeddingServiceInterface.php b/app/Services/Contracts/EmbeddingServiceInterface.php index ea3c428..f07f532 100644 --- a/app/Services/Contracts/EmbeddingServiceInterface.php +++ b/app/Services/Contracts/EmbeddingServiceInterface.php @@ -5,4 +5,8 @@ interface EmbeddingServiceInterface { public function createEmbedding(string|array $text): array; + + public function getProvider(): string; + + public function getEmbeddingModel(): string; } \ No newline at end of file diff --git a/app/Services/GeminiService.php b/app/Services/GeminiService.php index d05d1cb..3d1994b 100644 --- a/app/Services/GeminiService.php +++ b/app/Services/GeminiService.php @@ -29,7 +29,31 @@ public function __construct() ]); } - public function generateResponse(array $messages, ?string $model = null, ?string $media = null, ?string $mimeType = null, array $tools = []): array|string + /** + * Get the configured provider name + */ + public function getProvider(): string + { + return 'Gemini'; + } + + /** + * Get the configured chat model name + */ + public function getModel(): string + { + return $this->model; + } + + /** + * Get the configured embedding model name + */ + public function getEmbeddingModel(): string + { + return $this->embeddingModel; + } + + public function generateResponse(array $messages, ?string $model = null, ?string $media = null, ?string $mimeType = null, array $tools = []): array { $model = $model ?? $this->model; @@ -114,6 +138,14 @@ public function generateResponse(array $messages, ?string $model = null, ?string throw new \Exception('Invalid Gemini chat response format: no candidates'); } + // Extract token usage data + $usage = $responseData['usageMetadata'] ?? []; + $tokenUsage = [ + 'input_tokens' => $usage['promptTokenCount'] ?? 0, + 'output_tokens' => $usage['candidatesTokenCount'] ?? 0, + 'total_tokens' => $usage['totalTokenCount'] ?? 0, + ]; + // Check for function calls if (isset($candidate['content']['parts'])) { $functionCalls = []; @@ -137,12 +169,16 @@ public function generateResponse(array $messages, ?string $model = null, ?string if (!empty($functionCalls)) { return [ 'content' => $textContent, - 'tool_calls' => $functionCalls + 'tool_calls' => $functionCalls, + 'token_usage' => $tokenUsage ]; } if (!empty($textContent)) { - return $textContent; + return [ + 'content' => $textContent, + 'token_usage' => $tokenUsage + ]; } } @@ -175,7 +211,21 @@ public function createEmbedding(string|array $text): array } $responseData = $response->json(); - return $responseData['embedding']['values'] ?? []; + + Log::info('Gemini Embedding API', [ + 'status' => $response->status(), + 'request' => $payload, + 'response' => $responseData + ]); + + return [ + 'embeddings' => [$responseData['embedding']['values'] ?? []], + 'token_usage' => [ + 'input_tokens' => $responseData['usageMetadata']['promptTokenCount'] ?? 0, + 'output_tokens' => 0, // Embeddings don't have output tokens + 'total_tokens' => $responseData['usageMetadata']['totalTokenCount'] ?? 0, + ] + ]; } catch (\Exception $e) { Log::error('Gemini Embedding Error', ['error' => $e->getMessage()]); throw $e; @@ -226,7 +276,14 @@ public function createBatchEmbeddings(array $texts): array } } - return $embeddings; + return [ + 'embeddings' => $embeddings, + 'token_usage' => [ + 'input_tokens' => $responseData['usageMetadata']['promptTokenCount'] ?? 0, + 'output_tokens' => 0, // Embeddings don't have output tokens + 'total_tokens' => $responseData['usageMetadata']['totalTokenCount'] ?? 0, + ] + ]; } catch (\Exception $e) { Log::error('Gemini Batch Embedding Error', ['error' => $e->getMessage()]); throw $e; diff --git a/app/Services/MediaProcessingService.php b/app/Services/MediaProcessingService.php index 61394df..87fdd76 100644 --- a/app/Services/MediaProcessingService.php +++ b/app/Services/MediaProcessingService.php @@ -3,12 +3,14 @@ namespace App\Services; use App\Models\ChatMedia; -use App\Services\AIServiceFactory; +use App\Services\Traits\AIServiceHelperTrait; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Log; class MediaProcessingService { + use AIServiceHelperTrait; + /** * Process an uploaded media file. * @@ -28,11 +30,11 @@ public function process(UploadedFile $mediaFile, ?string &$messageContent): ?Cha // Synthesize a default prompt if the message is empty if (is_null($messageContent) || trim($messageContent) === '') { - $messageContent = $this->generateDefaultPrompt($mimeType); + $messageContent = $this->generateDefaultPromptForMimeType($mimeType); } // Transcribe audio and append to the message - if (str_starts_with($mimeType, 'audio/')) { + if ($this->isAudioMimeType($mimeType)) { $transcription = $this->transcribeAudio($base64Data, $mimeType); if (!empty($transcription)) { $messageContent = rtrim((string) $messageContent) . "\n\n[Audio transcription: " . $transcription . ']'; @@ -48,28 +50,6 @@ public function process(UploadedFile $mediaFile, ?string &$messageContent): ?Cha return $media; } - /** - * Generate a default prompt based on the media's MIME type. - * - * @param string $mimeType - * @return string - */ - private function generateDefaultPrompt(string $mimeType): string - { - if (str_starts_with($mimeType, 'image/')) { - return 'Please respond based on the attached image.'; - } - - if (str_starts_with($mimeType, 'audio/')) { - return 'Please respond based on the attached audio.'; - } - - if (str_starts_with($mimeType, 'video/')) { - return 'Please respond based on the attached video.'; - } - - return 'Please respond based on the attached document.'; - } /** * Transcribe audio data using the configured speech-to-text service. @@ -81,7 +61,7 @@ private function generateDefaultPrompt(string $mimeType): string private function transcribeAudio(string $base64Data, string $mimeType): ?string { try { - $speechToTextService = AIServiceFactory::createSpeechToTextService(); + $speechToTextService = \App\Services\AIServiceFactory::createSpeechToTextService(); return $speechToTextService->transcribe($base64Data, $mimeType); } catch (\Exception $e) { Log::warning('Failed to transcribe audio', ['error' => $e->getMessage()]); diff --git a/app/Services/MessageHandlerService.php b/app/Services/MessageHandlerService.php new file mode 100644 index 0000000..1434b71 --- /dev/null +++ b/app/Services/MessageHandlerService.php @@ -0,0 +1,209 @@ +aiResponseService = $aiResponseService; + $this->tokenPricingService = $tokenPricingService; + $this->mediaProcessingService = $mediaProcessingService; + } + + /** + * Handle incoming message from any platform + * + * @param int|null $channelId + * @param string $sender + * @param string|null $messageContent + * @param UploadedFile|null $mediaFile + * @param \App\Models\Bot|null $bot + * @param string $format + * @return array + * @throws \Exception + */ + public function handleMessage(?int $channelId, string $sender, ?string $messageContent = null, ?UploadedFile $mediaFile = null, $bot = null, string $format = 'html'): array + { + $channel = null; + + if ($channelId) { + $channel = Channel::with(['bots', 'team.balance'])->findOrFail($channelId); + + $bot = $channel->bots->first(); + + if (!$bot) { + throw new \Exception('No bot found for this channel'); + } + } + + // Check if team has sufficient credits + $isCloud = config('app.edition') === 'cloud'; + if ($isCloud && $bot && $bot->team && $bot->team->balance) { + // Get current balance + $currentBalance = $bot->team->balance->amount / 1000000; + + // Calculate estimated credits needed before processing + $estimatedCredits = $this->calculateEstimatedCredits($bot, $messageContent, $mediaFile); + + if ($currentBalance < $estimatedCredits) { + throw new \Exception( + sprintf( + 'Insufficient credits. Required: %.2f, Available: %.2f', + $estimatedCredits, + $currentBalance + ) + ); + } + } + + // Process media if provided (only after credit check) + $media = null; + if ($mediaFile) { + $media = $this->mediaProcessingService->process($mediaFile, $messageContent); + } + + // Generate AI response (only after credit check) + $response = $this->aiResponseService->generateResponse($bot, $channel, $messageContent, $sender, $media, $format); + + return [ + 'response' => $response, + 'channel' => $channel, + 'bot' => $bot, + 'media' => $media, + ]; + } + + /** + * Validate message data + * + * @param array $data + * @return array + */ + public function validateMessageData(array $data): array + { + $rules = [ + 'channelId' => 'required|integer', + 'sender' => 'required|string', + 'message' => 'nullable|string', + 'media_file' => 'nullable|file|max:20480|mimes:jpg,jpeg,png,gif,webp,mp3,wav,ogg,m4a,webm,flac,mp4,avi,mov,pdf,doc,docx,txt', + ]; + + return validator($data, $rules)->validate(); + } + + /** + * Calculate estimated credits needed for processing message and media + * + * @param \App\Models\Bot|null $bot + * @param string|null $messageContent + * @param UploadedFile|null $mediaFile + * @return float + */ + protected function calculateEstimatedCredits($bot, ?string $messageContent, ?UploadedFile $mediaFile): float + { + if (!$bot) { + return 0.0; + } + + try { + // Get AI services to determine provider and model + $services = $this->getAIServices(); + $chatService = $services['chat']; + $embeddingService = $services['embedding']; + + $provider = $chatService->getProvider(); + $model = $chatService->getModel(); + $embeddingModel = $embeddingService->getEmbeddingModel(); + + // Estimate tokens for different components + $totalEstimatedCredits = 0.0; + + // 1. Estimate embedding tokens (for knowledge search) + if ($messageContent) { + $embeddingTokens = $this->estimateTokens($messageContent); + $embeddingCredits = $this->calculateCreditsForTokens( + $this->tokenPricingService, + $provider, + $embeddingModel, + $embeddingTokens, + 0 + ); + $totalEstimatedCredits += $embeddingCredits; + } + + // 2. Estimate main chat tokens + $systemPrompt = $this->buildSystemPrompt($bot, null, true); + $fullPrompt = $systemPrompt; + + if ($messageContent) { + $fullPrompt .= "\n\nUser: " . $messageContent; + } + + // Add media processing overhead if media file exists + if ($mediaFile) { + $mimeType = $mediaFile->getMimeType(); + + // Add transcription cost for audio files + if ($this->isAudioMimeType($mimeType)) { + $estimatedTranscriptionTokens = $this->getTranscriptionTokenEstimate(); + $transcriptionCredits = $this->calculateCreditsForTokens( + $this->tokenPricingService, + $provider, + $model, + $estimatedTranscriptionTokens, + 0 + ); + $totalEstimatedCredits += $transcriptionCredits; + } + + // Add vision processing overhead for images/videos + if ($this->requiresVisionProcessing($mimeType)) { + $fullPrompt .= "\n\n[Image/Video content analysis]"; + } + } + + // Estimate input and output tokens for main chat + $inputTokens = $this->estimateTokens($fullPrompt); + $outputTokens = $this->getResponseTokenEstimate(); + + $chatCredits = $this->calculateCreditsForTokens( + $this->tokenPricingService, + $provider, + $model, + $inputTokens, + $outputTokens + ); + + $totalEstimatedCredits += $chatCredits; + + // 3. Add buffer for tool calls (if any tools are available) + $tools = $bot->team->tools()->where('is_active', true)->count(); + $totalEstimatedCredits = $this->applyToolCallBuffer($totalEstimatedCredits, $tools); + + // Add safety margin + $totalEstimatedCredits = $this->applySafetyMargin($totalEstimatedCredits); + + return round($totalEstimatedCredits, 6); + } catch (\Exception $e) { + // Fallback to conservative estimate if calculation fails + $baseTokens = $messageContent ? $this->estimateTokens($messageContent) : 100; + $fallbackCredits = ($baseTokens / 1000000) * 50000; // Conservative fallback + return round($fallbackCredits, 6); + } + } + +} diff --git a/app/Services/OpenAIService.php b/app/Services/OpenAIService.php index 95df5c8..ddedfc3 100644 --- a/app/Services/OpenAIService.php +++ b/app/Services/OpenAIService.php @@ -30,7 +30,31 @@ public function __construct() ->timeout(30); } - public function generateResponse(array $messages, ?string $model = null, ?string $media = null, ?string $mimeType = null, array $tools = []): array|string + /** + * Get the configured provider name + */ + public function getProvider(): string + { + return 'OpenAI'; + } + + /** + * Get the configured chat model name + */ + public function getModel(): string + { + return $this->model; + } + + /** + * Get the configured embedding model name + */ + public function getEmbeddingModel(): string + { + return $this->embeddingModel; + } + + public function generateResponse(array $messages, ?string $model = null, ?string $media = null, ?string $mimeType = null, array $tools = []): array { $model = $model ?? $this->model; @@ -72,17 +96,29 @@ public function generateResponse(array $messages, ?string $model = null, ?string throw new \Exception('Invalid OpenAI chat response format: no message'); } + // Extract token usage data + $usage = $responseData['usage'] ?? []; + $tokenUsage = [ + 'input_tokens' => $usage['prompt_tokens'] ?? 0, + 'output_tokens' => $usage['completion_tokens'] ?? 0, + 'total_tokens' => $usage['total_tokens'] ?? 0, + ]; + // Check for tool calls if (isset($message['tool_calls']) && !empty($message['tool_calls'])) { return [ 'content' => $message['content'] ?? '', - 'tool_calls' => $message['tool_calls'] + 'tool_calls' => $message['tool_calls'], + 'token_usage' => $tokenUsage ]; } // Return content if available if (isset($message['content'])) { - return $message['content']; + return [ + 'content' => $message['content'], + 'token_usage' => $tokenUsage + ]; } throw new \Exception('Invalid OpenAI chat response format: no content or tool calls'); @@ -97,10 +133,31 @@ public function createEmbedding(string|array $text): array ]); if ($response->successful()) { - return collect($response->json()['data']) + $responseData = $response->json(); + + Log::info('OpenAI Embedding API', [ + 'status' => $response->status(), + 'request' => [ + 'model' => $this->embeddingModel, + 'input' => $text, + ], + 'response' => $responseData + ]); + + $embeddings = collect($responseData['data']) ->sortBy('index') ->pluck('embedding') ->all(); + + // Return embeddings with token usage + return [ + 'embeddings' => $embeddings, + 'token_usage' => [ + 'input_tokens' => $responseData['usage']['prompt_tokens'] ?? 0, + 'output_tokens' => 0, // Embeddings don't have output tokens + 'total_tokens' => $responseData['usage']['total_tokens'] ?? 0, + ] + ]; } throw new \Exception('Failed to create embedding: ' . $response->body()); diff --git a/app/Services/TokenPricingService.php b/app/Services/TokenPricingService.php new file mode 100644 index 0000000..254b095 --- /dev/null +++ b/app/Services/TokenPricingService.php @@ -0,0 +1,174 @@ + [ + // GPT-5 Series + 'gpt-5' => ['input' => 1.25, 'output' => 10.0], + 'gpt-5-mini' => ['input' => 0.25, 'output' => 2.0], + 'gpt-5-nano' => ['input' => 0.05, 'output' => 0.4], + + // GPT-4.1 Series + 'gpt-4.1' => ['input' => 2.0, 'output' => 8.0], + 'gpt-4.1-mini' => ['input' => 0.4, 'output' => 1.6], + 'gpt-4.1-nano' => ['input' => 0.1, 'output' => 0.4], + + // GPT-4o Series + 'gpt-4o' => ['input' => 2.5, 'output' => 10.0], + 'gpt-4o-mini' => ['input' => 0.15, 'output' => 0.6], + + // Embeddings + 'text-embedding-3-small' => ['input' => 0.01, 'output' => 0], + 'text-embedding-3-large' => ['input' => 0.065, 'output' => 0], + 'text-embedding-ada-002' => ['input' => 0.05, 'output' => 0], + ], + 'Gemini' => [ + // Gemini 2.5 Series + 'gemini-2.5-pro' => ['input' => 1.25, 'output' => 10.0], + 'gemini-2.5-flash' => ['input' => 0.3, 'output' => 2.5], + 'gemini-2.5-flash-lite' => ['input' => 0.1, 'output' => 0.4], + + // Embeddings + 'text-embedding-004' => ['input' => 0.15, 'output' => 0], + ], + ]; + + /** + * Calculate the cost in credits for token usage. + * + * @param string $provider The AI provider (OpenAI, Gemini) + * @param string $model The model name + * @param int $inputTokens Number of input tokens + * @param int $outputTokens Number of output tokens + * @return float Cost in credits + */ + public function calculateCost(string $provider, string $model, int $inputTokens, int $outputTokens): float + { + $pricing = $this->getPricing($provider, $model); + + if (!$pricing) { + // Fallback pricing if model not found + $inputCost = ($inputTokens / 1000000) * 1000; // 1000 credits per 1M tokens + $outputCost = ($outputTokens / 1000000) * 5000; // 5000 credits per 1M tokens + return round($inputCost + $outputCost, 6); + } + + // Convert USD pricing to credits (1 USD = 16,500 credits) + $inputCostUsd = ($inputTokens / 1000000) * $pricing['input']; + $outputCostUsd = ($outputTokens / 1000000) * $pricing['output']; + $totalCostUsd = $inputCostUsd + $outputCostUsd; + + // Convert to credits + $totalCostCredits = $totalCostUsd * 16500; + + return round($totalCostCredits, 6); + } + + /** + * Get pricing for a specific provider and model. + * + * @param string $provider + * @param string $model + * @return array|null + */ + public function getPricing(string $provider, string $model): ?array + { + return self::PRICING[$provider][$model] ?? null; + } + + /** + * Get all available pricing. + * + * @return array + */ + public function getAllPricing(): array + { + return self::PRICING; + } + + /** + * Check if a provider and model combination is supported. + * + * @param string $provider + * @param string $model + * @return bool + */ + public function isSupported(string $provider, string $model): bool + { + return isset(self::PRICING[$provider][$model]); + } + + /** + * Get the input token price for a model. + * + * @param string $provider + * @param string $model + * @return float Credits per 1M tokens + */ + public function getInputPrice(string $provider, string $model): float + { + $pricing = $this->getPricing($provider, $model); + if (!$pricing) { + return 1000; // Fallback in credits + } + // Convert USD to credits (1 USD = 16,500 credits) + return $pricing['input'] * 16500; + } + + /** + * Get the output token price for a model. + * + * @param string $provider + * @param string $model + * @return float Credits per 1M tokens + */ + public function getOutputPrice(string $provider, string $model): float + { + $pricing = $this->getPricing($provider, $model); + if (!$pricing) { + return 5000; // Fallback in credits + } + // Convert USD to credits (1 USD = 16,500 credits) + return $pricing['output'] * 16500; + } + + /** + * Format credits to display with currency. + * + * @param float $credits + * @return string + */ + public function formatCredits(float $credits): string + { + return number_format($credits, 6) . ' credits'; + } + + /** + * Convert credits to USD. + * + * @param float $credits + * @return float + */ + public function creditsToUsd(float $credits): float + { + return round($credits / 16500, 4); + } + + /** + * Convert USD to credits. + * + * @param float $usd + * @return float + */ + public function usdToCredits(float $usd): float + { + return round($usd * 16500, 6); + } +} diff --git a/app/Services/TokenUsageService.php b/app/Services/TokenUsageService.php new file mode 100644 index 0000000..0ef3320 --- /dev/null +++ b/app/Services/TokenUsageService.php @@ -0,0 +1,81 @@ +updateDailyTransaction($data['team_id'], $data['credits']); + + return $tokenUsage; + }); + } + + /** + * Update or create daily transaction for token usage. + */ + private function updateDailyTransaction(int $teamId, float $credits): void + { + $today = Carbon::today(); + + // Find existing transaction for today + $transaction = Transaction::where('team_id', $teamId) + ->where('type', 'AI usage') + ->whereDate('created_at', $today) + ->first(); + + if ($transaction) { + // Update existing transaction - amount is stored as integer + $currentDecimalAmount = $transaction->decimal_amount; + $transaction->amount = $currentDecimalAmount + $credits; + $transaction->update([ + 'description' => 'AI usage credits - ' . $today->format('Y-m-d'), + ]); + } else { + // Create new daily transaction + Transaction::create([ + 'team_id' => $teamId, + 'amount' => $credits, + 'type' => 'AI usage', + 'transaction_type' => 'debit', + 'description' => 'AI usage credits - ' . $today->format('Y-m-d'), + 'status' => 'completed', + 'payment_method' => 'credits', + ]); + } + + // Update team balance after transaction + $this->updateTeamBalance($teamId, $credits); + } + + /** + * Update team balance by deducting credits. + */ + private function updateTeamBalance(int $teamId, float $credits): void + { + $balance = Balance::firstOrCreate( + ['team_id' => $teamId], + ['amount' => 0] + ); + + // Amount is stored as integer, use decimal_amount for calculation + $currentDecimalAmount = $balance->decimal_amount; + $balance->amount = $currentDecimalAmount - $credits; + $balance->save(); + } +} diff --git a/app/Services/Traits/AIServiceHelperTrait.php b/app/Services/Traits/AIServiceHelperTrait.php new file mode 100644 index 0000000..f516cee --- /dev/null +++ b/app/Services/Traits/AIServiceHelperTrait.php @@ -0,0 +1,208 @@ +prompt; + + if ($useEstimate) { + // For credit estimation - use conservative estimate + $knowledgeCount = $bot->knowledge()->where('status', 'completed')->count(); + if ($knowledgeCount > 0) { + $systemPrompt .= "\n\nUse the following information to answer questions:\n\n"; + // Estimate average knowledge chunk size + $systemPrompt .= str_repeat("[Knowledge context placeholder] ", 200); // ~800 characters + } + } else { + // For actual usage - use real knowledge + if ($relevantKnowledge && $relevantKnowledge->isNotEmpty()) { + $systemPrompt .= "\n\nUse the following information to answer questions:\n\n"; + foreach ($relevantKnowledge as $knowledge) { + $systemPrompt .= "{$knowledge['text']}\n\n"; + } + } + } + + return $systemPrompt; + } + + /** + * Get AI services (chat and embedding) + * + * @return array + */ + protected function getAIServices(): array + { + try { + return [ + 'chat' => AIServiceFactory::createChatService(), + 'embedding' => AIServiceFactory::createEmbeddingService(), + ]; + } catch (\Exception $e) { + Log::error('Failed to create AI services: ' . $e->getMessage()); + throw $e; + } + } + + /** + * Calculate credits for token usage + * + * @param TokenPricingService $tokenPricingService + * @param string $provider + * @param string $model + * @param int $inputTokens + * @param int $outputTokens + * @return float + */ + protected function calculateCreditsForTokens( + TokenPricingService $tokenPricingService, + string $provider, + string $model, + int $inputTokens, + int $outputTokens + ): float { + return $tokenPricingService->calculateCost($provider, $model, $inputTokens, $outputTokens); + } + + /** + * Check if MIME type is audio + * + * @param string $mimeType + * @return bool + */ + protected function isAudioMimeType(string $mimeType): bool + { + return str_starts_with($mimeType, 'audio/'); + } + + /** + * Check if MIME type is image + * + * @param string $mimeType + * @return bool + */ + protected function isImageMimeType(string $mimeType): bool + { + return str_starts_with($mimeType, 'image/'); + } + + /** + * Check if MIME type is video + * + * @param string $mimeType + * @return bool + */ + protected function isVideoMimeType(string $mimeType): bool + { + return str_starts_with($mimeType, 'video/'); + } + + /** + * Check if MIME type requires vision processing + * + * @param string $mimeType + * @return bool + */ + protected function requiresVisionProcessing(string $mimeType): bool + { + return $this->isImageMimeType($mimeType) || $this->isVideoMimeType($mimeType); + } + + /** + * Generate default prompt based on media MIME type + * + * @param string $mimeType + * @return string + */ + protected function generateDefaultPromptForMimeType(string $mimeType): string + { + if ($this->isImageMimeType($mimeType)) { + return 'Please respond based on the attached image.'; + } + + if ($this->isAudioMimeType($mimeType)) { + return 'Please respond based on the attached audio.'; + } + + if ($this->isVideoMimeType($mimeType)) { + return 'Please respond based on the attached video.'; + } + + return 'Please respond based on the attached document.'; + } + + /** + * Get conservative token estimate for transcription + * + * @return int + */ + protected function getTranscriptionTokenEstimate(): int + { + return 500; // Conservative estimate for audio transcription + } + + /** + * Get conservative token estimate for response + * + * @return int + */ + protected function getResponseTokenEstimate(): int + { + return 300; // Conservative estimate for response length + } + + /** + * Apply safety margin to credit calculation + * + * @param float $credits + * @param float $margin + * @return float + */ + protected function applySafetyMargin(float $credits, float $margin = 0.1): float + { + return $credits * (1 + $margin); + } + + /** + * Apply tool call buffer to credit calculation + * + * @param float $credits + * @param int $toolCount + * @param float $buffer + * @return float + */ + protected function applyToolCallBuffer(float $credits, int $toolCount, float $buffer = 0.2): float + { + return $toolCount > 0 ? $credits * (1 + $buffer) : $credits; + } +} diff --git a/app/Services/TransactionService.php b/app/Services/TransactionService.php deleted file mode 100644 index f9591b3..0000000 --- a/app/Services/TransactionService.php +++ /dev/null @@ -1,60 +0,0 @@ -responseCost = config('herobot.response_cost', 150); - } - - /** - * Process a new AI response usage transaction for a team. - * - * @param \App\Models\Team $team - * @return void - */ - public function recordUsage(Team $team): void - { - // Find the latest usage transaction for today - $latestTransaction = Transaction::where('team_id', $team->id) - ->where('type', 'usage') - ->whereDate('created_at', today()) - ->latest() - ->first(); - - if ($latestTransaction) { - // Update existing transaction for today - $totalResponses = ($latestTransaction->amount / $this->responseCost) + 1; - $latestTransaction->update([ - 'amount' => $latestTransaction->amount + $this->responseCost, - 'description' => 'AI Response Credits Usage (Total responses: ' . $totalResponses . ')', - ]); - } else { - // Create new transaction for today - Transaction::create([ - 'team_id' => $team->id, - 'amount' => $this->responseCost, - 'type' => 'usage', - 'description' => 'AI Response Credits Usage (Total responses: 1)', - 'status' => 'completed', - ]); - } - - // Deduct credits from the team's balance - if ($team->balance) { - $team->balance->decrement('amount', $this->responseCost); - } - } -} \ No newline at end of file diff --git a/config/app.php b/config/app.php index edd0b26..fec9bd5 100644 --- a/config/app.php +++ b/config/app.php @@ -17,6 +17,19 @@ 'name' => env('APP_NAME', 'Herobot'), + /* + |-------------------------------------------------------------------------- + | Application Edition + |-------------------------------------------------------------------------- + | + | This value determines the "edition" of your application, which can be + | "cloud" or "self-hosted". This is used to enable or disable certain + | features depending on the edition. + | + */ + + 'edition' => env('APP_EDITION', 'self-hosted'), + /* |-------------------------------------------------------------------------- | Application Environment diff --git a/database/migrations/2025_08_22_063003_create_token_usages_table.php b/database/migrations/2025_08_22_063003_create_token_usages_table.php new file mode 100644 index 0000000..ab364ab --- /dev/null +++ b/database/migrations/2025_08_22_063003_create_token_usages_table.php @@ -0,0 +1,40 @@ +id(); + $table->foreignId('team_id')->constrained()->onDelete('cascade'); + $table->foreignId('bot_id')->constrained()->onDelete('cascade'); + $table->string('provider'); // 'openai' or 'gemini' + $table->string('model'); // e.g., 'gpt-4o', 'gemini-2.5-flash' + $table->integer('input_tokens'); + $table->integer('output_tokens'); + $table->decimal('tokens_per_second', 8, 2)->nullable(); // TPS calculation + $table->decimal('credits', 12, 6); // Cost in credits (1 credit = 1 Rupiah) - higher precision for small costs + $table->timestamps(); + + // Indexes for performance + $table->index(['team_id', 'created_at']); + $table->index(['bot_id', 'created_at']); + $table->index(['provider', 'model']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('token_usages'); + } +}; diff --git a/database/migrations/2025_08_23_052442_modify_balances_table_amount_precision.php b/database/migrations/2025_08_23_052442_modify_balances_table_amount_precision.php new file mode 100644 index 0000000..0f40d54 --- /dev/null +++ b/database/migrations/2025_08_23_052442_modify_balances_table_amount_precision.php @@ -0,0 +1,32 @@ +decimal('amount', 10, 6)->change(); + }); + + Schema::table('transactions', function (Blueprint $table) { + $table->decimal('amount', 10, 6)->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('balances', function (Blueprint $table) { + $table->decimal('amount', 8, 2)->change(); + }); + } +}; diff --git a/database/migrations/2025_08_23_060326_add_transaction_type_column_to_transactions_table.php b/database/migrations/2025_08_23_060326_add_transaction_type_column_to_transactions_table.php new file mode 100644 index 0000000..38a1250 --- /dev/null +++ b/database/migrations/2025_08_23_060326_add_transaction_type_column_to_transactions_table.php @@ -0,0 +1,28 @@ +enum('transaction_type', ['debit', 'credit'])->after('type')->default('credit'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('transactions', function (Blueprint $table) { + $table->dropColumn('transaction_type'); + }); + } +}; diff --git a/database/migrations/2025_08_23_060551_update_existing_transactions_with_transaction_type.php b/database/migrations/2025_08_23_060551_update_existing_transactions_with_transaction_type.php new file mode 100644 index 0000000..299f2fd --- /dev/null +++ b/database/migrations/2025_08_23_060551_update_existing_transactions_with_transaction_type.php @@ -0,0 +1,32 @@ +whereIn('type', ['usage', 'token_usage', 'refund']) + ->update(['transaction_type' => 'debit']); + + DB::table('transactions') + ->whereIn('type', ['topup', 'bonus', 'adjustment']) + ->update(['transaction_type' => 'credit']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // + } +}; diff --git a/database/migrations/2025_08_23_061702_convert_decimal_columns_to_bigint.php b/database/migrations/2025_08_23_061702_convert_decimal_columns_to_bigint.php new file mode 100644 index 0000000..8e42dba --- /dev/null +++ b/database/migrations/2025_08_23_061702_convert_decimal_columns_to_bigint.php @@ -0,0 +1,44 @@ +bigInteger('amount')->change(); + }); + + Schema::table('transactions', function (Blueprint $table) { + $table->bigInteger('amount')->change(); + }); + + Schema::table('token_usages', function (Blueprint $table) { + $table->bigInteger('credits')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('balances', function (Blueprint $table) { + $table->decimal('amount', 10, 6)->change(); + }); + + Schema::table('transactions', function (Blueprint $table) { + $table->decimal('amount', 10, 6)->change(); + }); + + Schema::table('token_usages', function (Blueprint $table) { + $table->decimal('credits', 12, 6)->change(); + }); + } +}; diff --git a/resources/js/Layouts/Partials/Sidebar.vue b/resources/js/Layouts/Partials/Sidebar.vue index ffeb872..eb12b02 100644 --- a/resources/js/Layouts/Partials/Sidebar.vue +++ b/resources/js/Layouts/Partials/Sidebar.vue @@ -35,9 +35,9 @@