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 @@
-
+ :class="[item.current ? 'bg-gray-50 text-indigo-600' : 'text-gray-700 hover:text-gray-900 hover:bg-gray-100', 'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold']">
{{ item.name }}
@@ -49,9 +49,9 @@
-
+ :class="[!route().current('teams.create') && team.id === $page.props.auth.user.current_team_id ? 'bg-gray-50 text-indigo-600' : 'text-gray-700 hover:text-gray-900 hover:bg-gray-100', 'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold']">
{{
+ :class="[!route().current('teams.create') && team.id === $page.props.auth.user.current_team_id ? 'text-indigo-600 border-indigo-600' : 'text-gray-400 border-gray-200 group-hover:border-gray-400 group-hover:text-gray-600', 'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white']">{{
team.name.charAt(0).toUpperCase() }}
{{ team.name }}
@@ -59,9 +59,9 @@
-
+ :class="[route().current('teams.create') ? 'bg-gray-50 text-indigo-600' : 'text-gray-700 hover:text-gray-900 hover:bg-gray-100', 'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold']">
+ :class="[route().current('teams.create') ? 'text-indigo-600 border-indigo-600' : 'text-gray-400 border-gray-200 group-hover:border-gray-400 group-hover:text-gray-600', 'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border text-[0.625rem] font-medium bg-white']">
+
Create New Team
@@ -73,7 +73,7 @@
Settings
@@ -100,9 +100,9 @@
-
+ :class="[item.current ? 'bg-gray-50 text-indigo-600' : 'text-gray-700 hover:text-gray-900 hover:bg-gray-100', 'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold']">
{{ item.name }}
@@ -114,9 +114,9 @@
- -
-
-
- {{ item.name }}
-
-
+
+ -
+
+
+ {{ item.name }}
+
+
+
@@ -168,26 +170,30 @@ import {
XMarkIcon,
CreditCardIcon,
WrenchScrewdriverIcon,
+ ChartBarIcon,
+ CurrencyDollarIcon,
} from '@heroicons/vue/24/outline'
import { LinkIcon } from '@heroicons/vue/20/solid'
import ApplicationLogo from '@/Components/ApplicationLogo.vue';
import BotIcon from '@/Assets/Icons/BotIcon.svg';
const page = usePage();
+const isSelfHosted = page.props.appEdition !== 'cloud';
const navigation = [
{ name: 'Dashboard', href: route('dashboard'), icon: HomeIcon, current: route().current('dashboard') },
{ name: 'Bot Management', href: route('bots.index'), icon: BotIcon, current: route().current('bots*') },
{ name: 'Knowledge', href: route('knowledges.index'), icon: DocumentDuplicateIcon, current: route().current('knowledges*') },
{ name: 'Tools', href: route('tools.index'), icon: WrenchScrewdriverIcon, current: route().current('tools*') },
- // { name: 'Reports', href: route('reports'), icon: ChartPieIcon, current: route().current('reports*') },
{ name: 'Channels', href: route('channels.index'), icon: LinkIcon, current: route().current('channels*') },
]
const bottomNavigation = [
{ name: 'Team Settings', href: route('teams.show', page.props.auth.user.current_team), icon: UserGroupIcon, current: route().current('teams.show') },
+ { name: 'Billing', href: route('billing.index'), icon: CreditCardIcon, current: route().current('billing*'), hide: isSelfHosted },
+ { name: 'Usage', href: route('usage.index'), icon: ChartBarIcon, current: route().current('usage*'), hide: isSelfHosted },
+ { name: 'Pricing', href: route('pricing.index'), icon: CurrencyDollarIcon, current: route().current('pricing*'), hide: isSelfHosted },
{ name: 'Settings', href: route('profile.show'), icon: Cog6ToothIcon, current: route().current('profile.show') },
- // { name: 'Billing & Usage', href: route('billing.index'), icon: CreditCardIcon, current: route().current('billing*') },
]
const switchToTeam = (team) => {
diff --git a/resources/js/Pages/Billing/Index.vue b/resources/js/Pages/Billing/Index.vue
index 903b86e..05f57a1 100644
--- a/resources/js/Pages/Billing/Index.vue
+++ b/resources/js/Pages/Billing/Index.vue
@@ -1,6 +1,6 @@
-
-
+
+
-
+
-
Current Balance
-
-
- {{ Number(balance.amount).toLocaleString('id-ID') }}
-
-
Available Credits
+
+
Billing & Credits
+
Manage your credit balance and billing information.
-
-
-
-
-
Credit Usage Rate:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Available Credits
+
{{ formatNumber(balance.amount / 1000000) }} credits
+
+
+
+
+
+
+
+
+
+
USD Equivalent
+
${{ formatUSD((balance.amount / 1000000) / 16500) }}
+
+
+
+
+
+
+
+
+
+
+
Exchange Rate
+
1 USD = {{ formatNumber(16500) }} Credits
+
+
+
-
Each AI response costs 150 credits