From c40358a70bc17dcd05da92fc34c472923954f00c Mon Sep 17 00:00:00 2001 From: NaysKutzu Date: Sun, 17 May 2026 18:40:52 +0200 Subject: [PATCH 01/17] chore: bump version to v1.3.7.3; add new features including plugin sidebar priority system, email verification resend functionality, and improvements to chatbot message handling; fix various issues related to log rotation, user permissions, and translation completeness --- .github/README.md | 10 +- CHANGELOG.md | 23 + app | 2 +- backend/app/Chat/Allocation.php | 87 ++ backend/app/Chat/ChatConversation.php | 23 + backend/app/Chat/ChatMessage.php | 50 +- .../Admin/AllocationsController.php | 156 +++ .../Admin/PluginManagerController.php | 4 +- .../app/Controllers/Admin/UsersController.php | 79 +- .../System/PluginSidebarController.php | 1 + .../User/Auth/VerifyEmailController.php | 61 + .../Controllers/User/ChatbotController.php | 327 ++++-- .../User/User/SessionController.php | 148 +++ .../Controllers/User/VdsChatbotController.php | 327 ++++-- .../Services/Backup/BackupFifoEviction.php | 44 +- .../app/Services/Chatbot/ChatbotRuntime.php | 97 ++ .../app/Services/Chatbot/ChatbotService.php | 465 +++++++- .../app/Services/Chatbot/ContextBuilder.php | 105 +- .../Chatbot/DashboardContextBuilder.php | 176 +++ .../Chatbot/Providers/BasicProvider.php | 54 +- .../Providers/GoogleGeminiProvider.php | 2 + .../Chatbot/Providers/GrokProvider.php | 2 + .../Chatbot/Providers/OllamaProvider.php | 12 + .../Chatbot/Providers/OpenAIProvider.php | 2 + .../Chatbot/Providers/OpenRouterProvider.php | 2 + .../Chatbot/Providers/PerplexityProvider.php | 2 + .../Chatbot/Providers/ProviderInterface.php | 2 +- backend/app/Services/Chatbot/TokenUsage.php | 128 ++ .../Chatbot/Tools/CreateDatabaseTool.php | 12 + .../Chatbot/Tools/DeleteDatabaseTool.php | 18 +- .../Tools/GetKnowledgebaseArticleTool.php | 104 ++ .../Tools/ListKnowledgebaseCategoriesTool.php | 70 ++ .../Chatbot/Tools/SearchKnowledgebaseTool.php | 113 ++ .../Services/Chatbot/Tools/ToolHandler.php | 67 +- .../Services/Chatbot/Tools/VdsToolHandler.php | 7 +- .../Services/Chatbot/VdsChatbotService.php | 267 ++++- .../Services/Chatbot/VdsContextBuilder.php | 32 +- .../Chatbot/dashboard-system-prompt.txt | 29 + .../app/Services/Chatbot/system-prompt.txt | 45 +- .../Services/Chatbot/vds-system-prompt.txt | 14 + .../FeatherCloud/FeatherCloudClient.php | 23 +- .../Services/Wings/Services/ServerService.php | 2 + .../app/Services/Wings/WingsConnection.php | 2 +- backend/app/routes/admin/allocations.php | 12 + backend/app/routes/admin/users.php | 15 + backend/app/routes/user/auth.php | 13 + backend/app/routes/user/chatbot.php | 12 + backend/app/routes/user/session.php | 11 + backend/app/routes/user/vds/chatbot.php | 12 + backend/cli | 2 +- backend/composer.lock | 16 +- backend/public/index.php | 2 +- ....22-add-chatbot-message-usage-metadata.sql | 7 + ...5-17.16.30-add-chatbot-context-summary.sql | 3 + frontendv2/package.json | 2 +- .../events/allocations.html | 6 +- .../icanhasfeatherpanel/events/ticket.html | 1 + .../icanhasfeatherpanel/events/user.html | 6 +- frontendv2/public/locales/en.json | 417 ++++++- .../(app)/admin/analytics/activity/page.tsx | 8 +- .../(app)/admin/analytics/content/page.tsx | 10 +- .../admin/analytics/infrastructure/page.tsx | 15 +- .../admin/analytics/knowledgebase/page.tsx | 44 +- .../(app)/admin/analytics/servers/page.tsx | 20 +- .../app/(app)/admin/analytics/system/page.tsx | 22 +- .../(app)/admin/analytics/tickets/page.tsx | 61 +- .../app/(app)/admin/analytics/users/page.tsx | 10 +- .../app/(app)/admin/analytics/vds/page.tsx | 75 +- .../admin/cloud-management/finish/page.tsx | 12 +- .../app/(app)/admin/cloud-management/page.tsx | 16 +- .../(app)/admin/feathercloud/plugins/page.tsx | 44 +- .../admin/feathercloud/translations/page.tsx | 4 +- .../admin/featherpanel-ai-agent/page.tsx | 90 +- .../admin/featherzerotrust/tabs/LogsTab.tsx | 14 +- frontendv2/src/app/(app)/admin/layout.tsx | 3 +- .../app/(app)/admin/mail-templates/page.tsx | 16 +- .../nodes/[id]/components/ModulesTab.tsx | 6 +- .../nodes/[id]/components/WingsConfigTab.tsx | 6 +- .../admin/nodes/[id]/edit/AllocationsTab.tsx | 140 +++ .../app/(app)/admin/oidc-providers/page.tsx | 2 +- .../src/app/(app)/admin/plugins/page.tsx | 70 ++ .../(app)/admin/pterodactyl-importer/page.tsx | 4 +- .../app/(app)/admin/servers/create/page.tsx | 12 +- .../src/app/(app)/admin/servers/page.tsx | 10 +- .../src/app/(app)/admin/settings/page.tsx | 6 +- .../app/(app)/admin/spells/[id]/edit/page.tsx | 14 +- .../app/(app)/admin/spells/create/page.tsx | 14 +- .../src/app/(app)/admin/spells/page.tsx | 6 +- .../src/app/(app)/admin/subdomains/page.tsx | 39 +- .../[uuid]/components/TicketSidebar.tsx | 22 +- .../app/(app)/admin/tickets/[uuid]/page.tsx | 25 +- .../src/app/(app)/admin/translations/page.tsx | 8 +- .../src/app/(app)/admin/updates/page.tsx | 6 +- frontendv2/src/app/(app)/admin/users/page.tsx | 44 + .../admin/vds-nodes/[id]/edit/IpPoolTab.tsx | 14 +- .../admin/vm-instances/[id]/edit/DisksTab.tsx | 6 +- .../admin/vm-instances/[id]/edit/page.tsx | 2 +- .../(app)/admin/vm-instances/create/page.tsx | 23 +- .../src/app/(app)/auth/login/LoginForm.tsx | 86 +- .../auth/setup-2fa/SetupTwoFactorForm.tsx | 4 +- .../auth/verify-2fa/VerifyTwoFactorForm.tsx | 4 +- frontendv2/src/app/(app)/dashboard/layout.tsx | 8 +- .../(app)/dashboard/tickets/[uuid]/page.tsx | 14 +- .../(app)/server/[uuidShort]/files/page.tsx | 2 +- .../[uuidShort]/schedules/[id]/edit/page.tsx | 16 +- .../server/[uuidShort]/schedules/new/page.tsx | 12 +- .../[uuidShort]/subdomains/new/page.tsx | 5 +- .../app/(app)/vds/[id]/activities/page.tsx | 10 +- .../src/app/(app)/vds/[id]/backups/page.tsx | 22 +- .../src/app/(app)/vds/[id]/settings/page.tsx | 32 +- .../src/app/(app)/vds/[id]/users/page.tsx | 44 +- .../src/components/NavbarChromeVariants.tsx | 2 +- frontendv2/src/components/Sidebar.tsx | 24 +- .../src/components/account/ActivityTab.tsx | 18 +- .../src/components/account/ApiKeysTab.tsx | 10 +- .../src/components/account/ProfileTab.tsx | 2 +- .../src/components/account/SettingsTab.tsx | 84 +- .../src/components/admin/VmIpPickerSheet.tsx | 14 +- .../admin/VmTemplatePickerSheet.tsx | 8 +- .../src/components/ai/ChatbotContainer.tsx | 1034 +++++++++++++++-- .../src/components/ai/ChatbotInterface.tsx | 216 +++- .../src/components/ai/ChatbotWidget.tsx | 72 +- .../src/components/common/HackerEasterEgg.tsx | 10 +- .../dashboard/AnnouncementBanner.tsx | 6 +- .../src/components/featherui/FeatherIDE.tsx | 6 +- .../src/components/layout/DashboardShell.tsx | 9 +- frontendv2/src/components/ui/sheet.tsx | 5 +- frontendv2/src/config/navigation.tsx | 4 + frontendv2/src/contexts/ThemeContext.tsx | 88 +- .../src/contexts/TranslationContext.tsx | 2 +- frontendv2/src/hooks/useFileManager.ts | 8 +- frontendv2/src/hooks/useNavigation.ts | 3 + frontendv2/src/lib/api/auth.ts | 5 + frontendv2/src/lib/api/chatbotService.ts | 203 +++- frontendv2/src/types/navigation.ts | 2 + runner/Cargo.lock | 2 +- runner/Cargo.toml | 2 +- 137 files changed, 5598 insertions(+), 1176 deletions(-) create mode 100644 backend/app/Services/Chatbot/ChatbotRuntime.php create mode 100644 backend/app/Services/Chatbot/DashboardContextBuilder.php create mode 100644 backend/app/Services/Chatbot/TokenUsage.php create mode 100644 backend/app/Services/Chatbot/Tools/GetKnowledgebaseArticleTool.php create mode 100644 backend/app/Services/Chatbot/Tools/ListKnowledgebaseCategoriesTool.php create mode 100644 backend/app/Services/Chatbot/Tools/SearchKnowledgebaseTool.php create mode 100644 backend/app/Services/Chatbot/dashboard-system-prompt.txt create mode 100644 backend/storage/migrations/2026-05-17.16.22-add-chatbot-message-usage-metadata.sql create mode 100644 backend/storage/migrations/2026-05-17.16.30-add-chatbot-context-summary.sql diff --git a/.github/README.md b/.github/README.md index e759d38ac..e01dcc578 100755 --- a/.github/README.md +++ b/.github/README.md @@ -47,19 +47,19 @@ For installation instructions, system requirements, and complete guides, please -_Last updated: 2026-05-14T21:32:57.470Z_ +_Last updated: 2026-05-17T16:13:41.799Z_ | Extension | Files | Lines | | --- | ---: | ---: | -| `.php` | 504 | 126,711 | -| `.tsx` | 355 | 112,571 | -| `.ts` | 70 | 7,661 | +| `.php` | 504 | 128,034 | +| `.tsx` | 355 | 113,956 | +| `.ts` | 70 | 7,831 | | `.yaml` | 3 | 5,940 | | `.rs` | 16 | 3,395 | | `.sql` | 130 | 2,034 | | `.yml` | 18 | 1,877 | | `.css` | 7 | 445 | -| **Total** | 1,103 | 260,634 | +| **Total** | 1,103 | 263,512 | diff --git a/CHANGELOG.md b/CHANGELOG.md index b58f05552..57c9d3fd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## v1.3.7.3 STABLE + +### Added + +- Plugin sidebar priority system by to control the order of the sidebar items. by @nayskutzu +- Verify button on the admin user edit page to force verify the user's email by @nayskutzu +- Verification email resend system in case the user didn't receive the email by @nayskutzu + +### Fixed + +- FIFO (First In, First Out) rotation scheme for log file rotation and cleanup. by @nayskutzu +- Resolved an issue where your avatar would reload 100 times when you type in the chat box. by @nayskutzu +- Admins with admin.dashboard.view permission can now view the dashboard. by @nayskutzu +- Fixed an issue where the chatbot would sometimes get stuck in a loop when trying to do stuff. by @nayskutzu +- Added missing translations to the translations file. by @nayskutzu +- The default theme from admin was not applied if you didn't lock it. by @nayskutzu + +### Improved + +- Tickets were pretty cramped and didn't look good on big screens. by @nayskutzu +- Chatbot now supports the ability to get the article content by its ID. by @nayskutzu +- Chatbot token usage has been cut in half by @nayskutzu + ## v1.3.7.2 STABLE ### Fixed diff --git a/app b/app index 57b5c3686..2fbc3bcd6 100755 --- a/app +++ b/app @@ -48,7 +48,7 @@ define('APP_ADDONS_DIR', APP_STORAGE_DIR . 'addons'); define('APP_SOURCECODE_DIR', APP_DIR . 'app'); define('APP_ROUTES_DIR', APP_SOURCECODE_DIR . '/Api'); define('SYSTEM_KERNEL_NAME', php_uname('s')); -define('APP_VERSION', 'v1.3.7.2'); +define('APP_VERSION', 'v1.3.7.3'); define('APP_UPSTREAM', 'stable'); define('TELEMETRY', true); define('IS_CLI', true); diff --git a/backend/app/Chat/Allocation.php b/backend/app/Chat/Allocation.php index 7c5e57462..b055fb163 100755 --- a/backend/app/Chat/Allocation.php +++ b/backend/app/Chat/Allocation.php @@ -569,6 +569,93 @@ public static function isUniqueIpPort(int $nodeId, string $ip, int $port, ?int $ return (int) $stmt->fetchColumn() === 0; } + /** + * Count allocations matching a node and IP address. + */ + public static function countByNodeAndIp(int $nodeId, string $ip): int + { + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare('SELECT COUNT(*) FROM ' . self::$table . ' WHERE node_id = :node_id AND ip = :ip'); + $stmt->execute([ + 'node_id' => $nodeId, + 'ip' => $ip, + ]); + + return (int) $stmt->fetchColumn(); + } + + /** + * Count allocations that would conflict when moving ports from one IP to another. + */ + public static function countIpUpdateConflicts(int $nodeId, string $fromIp, string $toIp): int + { + if ($fromIp === $toIp) { + return 0; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare(' + SELECT COUNT(*) + FROM ' . self::$table . ' target + WHERE target.node_id = :node_id + AND target.ip = :to_ip + AND EXISTS ( + SELECT 1 + FROM ' . self::$table . ' source + WHERE source.node_id = :source_node_id + AND source.ip = :from_ip + AND source.port = target.port + ) + '); + $stmt->execute([ + 'node_id' => $nodeId, + 'source_node_id' => $nodeId, + 'from_ip' => $fromIp, + 'to_ip' => $toIp, + ]); + + return (int) $stmt->fetchColumn(); + } + + /** + * Update the IP and/or IP alias for every allocation on a node using a specific IP. + */ + public static function updateAddressByNodeAndIp(int $nodeId, string $fromIp, ?string $toIp, mixed $ipAlias, bool $updateAlias): int | false + { + $set = []; + $params = [ + 'node_id' => $nodeId, + 'from_ip' => $fromIp, + ]; + + if ($toIp !== null) { + $set[] = 'ip = :to_ip'; + $params['to_ip'] = $toIp; + } + + if ($updateAlias) { + $set[] = 'ip_alias = :ip_alias'; + $params['ip_alias'] = $ipAlias; + } + + if (empty($set)) { + return false; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->prepare('UPDATE ' . self::$table . ' SET ' . implode(', ', $set) . ' WHERE node_id = :node_id AND ip = :from_ip'); + + try { + $stmt->execute($params); + + return $stmt->rowCount(); + } catch (\Exception $e) { + App::getInstance(true)->getLogger()->error('Failed to bulk update allocation address: ' . $e->getMessage()); + + return false; + } + } + /** * Get allocation with node information. */ diff --git a/backend/app/Chat/ChatConversation.php b/backend/app/Chat/ChatConversation.php index 279f4f206..68da7b26e 100755 --- a/backend/app/Chat/ChatConversation.php +++ b/backend/app/Chat/ChatConversation.php @@ -22,6 +22,7 @@ class ChatConversation { private static string $table = 'featherpanel_chatbot_conversations'; + private static ?array $columns = null; /** * Create a new conversation. @@ -94,6 +95,10 @@ public static function updateConversation(int $id, array $data): bool } $pdo = Database::getPdoConnection(); + $data = array_intersect_key($data, array_flip(self::getColumns())); + if (empty($data)) { + return false; + } $fields = array_keys($data); $set = implode(', ', array_map(fn ($f) => "$f = :$f", $fields)); $sql = 'UPDATE ' . self::$table . ' SET ' . $set . ' WHERE id = :id'; @@ -144,4 +149,22 @@ public static function deleteUserConversations(string $userUuid): bool return false; } } + + public static function hasColumn(string $column): bool + { + return in_array($column, self::getColumns(), true); + } + + private static function getColumns(): array + { + if (self::$columns !== null) { + return self::$columns; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->query('SHOW COLUMNS FROM ' . self::$table); + self::$columns = array_map(static fn (array $column): string => $column['Field'], $stmt->fetchAll(\PDO::FETCH_ASSOC)); + + return self::$columns; + } } diff --git a/backend/app/Chat/ChatMessage.php b/backend/app/Chat/ChatMessage.php index 145b54f49..24dd912eb 100755 --- a/backend/app/Chat/ChatMessage.php +++ b/backend/app/Chat/ChatMessage.php @@ -22,6 +22,7 @@ class ChatMessage { private static string $table = 'featherpanel_chatbot_messages'; + private static ?array $columns = null; /** * Create a new message. @@ -32,8 +33,18 @@ class ChatMessage */ public static function createMessage(array $data): int | false { + foreach (['tool_activity', 'usage_json'] as $jsonField) { + if (isset($data[$jsonField]) && is_array($data[$jsonField])) { + $data[$jsonField] = json_encode($data[$jsonField]); + } + } + $pdo = Database::getPdoConnection(); + $data = array_intersect_key($data, array_flip(self::getColumns())); $fields = array_keys($data); + if (empty($fields)) { + return false; + } $placeholders = array_map(fn ($f) => ':' . $f, $fields); $sql = 'INSERT INTO ' . self::$table . ' (' . implode(',', $fields) . ') VALUES (' . implode(',', $placeholders) . ')'; $stmt = $pdo->prepare($sql); @@ -60,7 +71,7 @@ public static function getMessagesByConversation(int $conversationId, int $limit $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT); $stmt->execute(); - return $stmt->fetchAll(\PDO::FETCH_ASSOC); + return array_map([self::class, 'normalizeMessage'], $stmt->fetchAll(\PDO::FETCH_ASSOC)); } /** @@ -92,7 +103,9 @@ public static function getMessageById(int $id): ?array $stmt = $pdo->prepare('SELECT * FROM ' . self::$table . ' WHERE id = :id LIMIT 1'); $stmt->execute(['id' => $id]); - return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null; + $message = $stmt->fetch(\PDO::FETCH_ASSOC); + + return $message ? self::normalizeMessage($message) : null; } /** @@ -115,4 +128,37 @@ public static function deleteMessagesByConversation(int $conversationId): bool return false; } } + + private static function normalizeMessage(array $message): array + { + foreach (['input_tokens', 'output_tokens', 'total_tokens'] as $field) { + if (array_key_exists($field, $message)) { + $message[$field] = $message[$field] !== null ? (int) $message[$field] : null; + } + } + + foreach (['tool_activity', 'usage_json'] as $field) { + if (!empty($message[$field]) && is_string($message[$field])) { + $decoded = json_decode($message[$field], true); + $message[$field] = json_last_error() === JSON_ERROR_NONE ? $decoded : null; + } elseif (!array_key_exists($field, $message)) { + $message[$field] = null; + } + } + + return $message; + } + + private static function getColumns(): array + { + if (self::$columns !== null) { + return self::$columns; + } + + $pdo = Database::getPdoConnection(); + $stmt = $pdo->query('SHOW COLUMNS FROM ' . self::$table); + self::$columns = array_map(static fn (array $column): string => $column['Field'], $stmt->fetchAll(\PDO::FETCH_ASSOC)); + + return self::$columns; + } } diff --git a/backend/app/Controllers/Admin/AllocationsController.php b/backend/app/Controllers/Admin/AllocationsController.php index 2345ea193..4af56f6f2 100755 --- a/backend/app/Controllers/Admin/AllocationsController.php +++ b/backend/app/Controllers/Admin/AllocationsController.php @@ -908,6 +908,162 @@ public function getAvailable(Request $request): Response ], 'Available allocations fetched successfully', 200); } + #[OA\Patch( + path: '/api/admin/allocations/bulk-address', + summary: 'Bulk update allocation IP or alias', + description: 'Update the IP address and/or IP alias for every allocation on a node that currently uses a specific IP address.', + tags: ['Admin - Allocations'], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['node_id', 'from_ip'], + properties: [ + new OA\Property(property: 'node_id', type: 'integer', description: 'Node ID containing the allocations', minimum: 1), + new OA\Property(property: 'from_ip', type: 'string', description: 'Current allocation IP address', example: '0.0.0.0'), + new OA\Property(property: 'to_ip', type: 'string', nullable: true, description: 'New allocation IP address', example: '6.6.6.6'), + new OA\Property(property: 'ip_alias', type: 'string', nullable: true, description: 'New IP alias. Send null or an empty string to clear it.'), + ] + ) + ), + responses: [ + new OA\Response(response: 200, description: 'Allocations updated successfully'), + new OA\Response(response: 400, description: 'Bad request - Invalid data'), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden - Insufficient permissions'), + new OA\Response(response: 404, description: 'Node or matching allocations not found'), + new OA\Response(response: 409, description: 'Target IP would conflict with existing allocation ports'), + ] + )] + public function bulkUpdateAddress(Request $request): Response + { + $admin = $request->attributes->get('user'); + $data = json_decode($request->getContent(), true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return ApiResponse::error('Invalid JSON in request body', 'INVALID_JSON', 400); + } + + if (!is_array($data)) { + return ApiResponse::error('Request body must be a JSON object', 'INVALID_JSON_OBJECT', 400); + } + + $allowedFields = ['node_id', 'from_ip', 'to_ip', 'ip_alias']; + $invalidFields = array_diff(array_keys($data), $allowedFields); + if (!empty($invalidFields)) { + return ApiResponse::error( + 'Invalid fields provided: ' . implode(', ', $invalidFields) . '. Allowed fields: ' . implode(', ', $allowedFields), + 'INVALID_FIELDS', + 400 + ); + } + + $missingFields = []; + foreach (['node_id', 'from_ip'] as $field) { + if (!isset($data[$field]) || trim((string) $data[$field]) === '') { + $missingFields[] = $field; + } + } + + if (!empty($missingFields)) { + return ApiResponse::error('Missing required fields: ' . implode(', ', $missingFields), 'MISSING_REQUIRED_FIELDS', 400); + } + + if (!is_numeric($data['node_id']) || (int) $data['node_id'] <= 0) { + return ApiResponse::error('Node ID must be a positive number', 'INVALID_NODE_ID', 400); + } + + $nodeId = (int) $data['node_id']; + if (!Node::getNodeById($nodeId)) { + return ApiResponse::error('Node not found', 'NODE_NOT_FOUND', 404); + } + + $fromIp = trim((string) $data['from_ip']); + if (!filter_var($fromIp, FILTER_VALIDATE_IP)) { + return ApiResponse::error('Invalid source IP address format', 'INVALID_FROM_IP_FORMAT', 400); + } + + $toIp = null; + if (array_key_exists('to_ip', $data) && trim((string) $data['to_ip']) !== '') { + $toIp = trim((string) $data['to_ip']); + if (!filter_var($toIp, FILTER_VALIDATE_IP)) { + return ApiResponse::error('Invalid target IP address format', 'INVALID_TO_IP_FORMAT', 400); + } + } + + $updateAlias = array_key_exists('ip_alias', $data); + $ipAlias = null; + if ($updateAlias) { + if ($data['ip_alias'] !== null && !is_string($data['ip_alias'])) { + return ApiResponse::error('IP alias must be a string or null', 'INVALID_IP_ALIAS', 400); + } + $ipAlias = isset($data['ip_alias']) ? trim($data['ip_alias']) : null; + if ($ipAlias === '') { + $ipAlias = null; + } + } + + if ($toIp === null && !$updateAlias) { + return ApiResponse::error('Provide a target IP address, an IP alias, or both', 'NO_CHANGES_PROVIDED', 400); + } + + $matchedCount = Allocation::countByNodeAndIp($nodeId, $fromIp); + if ($matchedCount === 0) { + return ApiResponse::error('No allocations found for this node and IP address', 'ALLOCATIONS_NOT_FOUND', 404); + } + + if ($toIp !== null && $toIp !== $fromIp) { + $conflictCount = Allocation::countIpUpdateConflicts($nodeId, $fromIp, $toIp); + if ($conflictCount > 0) { + return ApiResponse::error( + "Cannot update IP because {$conflictCount} port(s) already exist on the target IP for this node", + 'DUPLICATE_IP_PORT', + 409 + ); + } + } + + $updatedCount = Allocation::updateAddressByNodeAndIp($nodeId, $fromIp, $toIp, $ipAlias, $updateAlias); + if ($updatedCount === false) { + return ApiResponse::error('Failed to update allocations', 'ALLOCATION_UPDATE_FAILED', 400); + } + + $changes = []; + if ($toIp !== null) { + $changes[] = "IP {$fromIp} to {$toIp}"; + } + if ($updateAlias) { + $changes[] = $ipAlias === null ? 'cleared IP alias' : "IP alias to {$ipAlias}"; + } + + Activity::createActivity([ + 'user_uuid' => $admin['uuid'] ?? null, + 'name' => 'allocations_address_updated', + 'context' => 'Updated ' . implode(' and ', $changes) . " for {$matchedCount} allocation(s) on node ID {$nodeId}", + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + global $eventManager; + if (isset($eventManager) && $eventManager !== null) { + $eventManager->emit( + AllocationsEvent::onAllocationUpdated(), + [ + 'node_id' => $nodeId, + 'from_ip' => $fromIp, + 'to_ip' => $toIp, + 'ip_alias' => $ipAlias, + 'matched_count' => $matchedCount, + 'updated_count' => $updatedCount, + 'updated_by' => $admin, + ] + ); + } + + return ApiResponse::success([ + 'matched_count' => $matchedCount, + 'updated_count' => $updatedCount, + ], "Updated {$matchedCount} allocation(s)", 200); + } + #[OA\Delete( path: '/api/admin/allocations/bulk-delete', summary: 'Bulk delete allocations', diff --git a/backend/app/Controllers/Admin/PluginManagerController.php b/backend/app/Controllers/Admin/PluginManagerController.php index 142922684..1dfe7eb27 100755 --- a/backend/app/Controllers/Admin/PluginManagerController.php +++ b/backend/app/Controllers/Admin/PluginManagerController.php @@ -1719,7 +1719,8 @@ public function run() 'component' => 'serverui.html', 'description' => 'View server logs related to the plugin', 'category' => 'server', - 'group' => 'Minecraft Java Edition', + 'group' => 'files', + 'priority' => 350, ], ], ], JSON_PRETTY_PRINT); @@ -2303,6 +2304,7 @@ public static function getSubCommands(): array ### Server Section - **Server Logs**: View plugin-related logs - **Scheduled Tasks**: Manage cron jobs and tasks +- **Sidebar Priority**: Use `group: files` and `priority: 350` to place an item between Backups (300) and Import (400) ### Routes & Controllers - **Routes**: Files in `Routes/` directory are automatically registered diff --git a/backend/app/Controllers/Admin/UsersController.php b/backend/app/Controllers/Admin/UsersController.php index d73624490..2d8f06249 100755 --- a/backend/app/Controllers/Admin/UsersController.php +++ b/backend/app/Controllers/Admin/UsersController.php @@ -55,6 +55,7 @@ new OA\Property(property: 'first_name', type: 'string', description: 'First name'), new OA\Property(property: 'last_name', type: 'string', description: 'Last name'), new OA\Property(property: 'email', type: 'string', format: 'email', description: 'Email address'), + new OA\Property(property: 'email_verified', type: 'boolean', description: 'Whether the user email is verified'), new OA\Property(property: 'avatar', type: 'string', format: 'uri', description: 'Avatar URL'), new OA\Property(property: 'last_seen', type: 'string', format: 'date-time', nullable: true, description: 'Last seen timestamp'), new OA\Property(property: 'banned', type: 'boolean', description: 'Banned status'), @@ -307,6 +308,7 @@ public function index(Request $request): Response 'avatar', 'last_seen', 'email', + 'mail_verify', 'oidc_provider', 'oidc_subject', 'ldap_provider_uuid', @@ -342,11 +344,12 @@ public function index(Request $request): Response $user['role']['display_name'] = 'User'; $user['role']['color'] = '#666666'; } + $user['email_verified'] = !isset($user['mail_verify']) || trim((string) $user['mail_verify']) === ''; if ($app->isDemoMode()) { $user['first_ip'] = $app->getIPIntoFBIFormat(); $user['last_ip'] = $app->getIPIntoFBIFormat(); } - unset($user['role_id']); + unset($user['role_id'], $user['mail_verify']); } $total = User::getCount( @@ -435,7 +438,8 @@ public function show(Request $request, string $uuid): Response 'color' => $rolesMap[$roleId]['color'] ?? '#666666', ]; - unset($user['password']); + $user['email_verified'] = !isset($user['mail_verify']) || trim((string) $user['mail_verify']) === ''; + unset($user['password'], $user['mail_verify']); $user['activities'] = array_map(function ($activity) use ($app) { unset($activity['user_uuid'], $activity['id'], $activity['updated_at']); @@ -525,7 +529,8 @@ public function showByExternalId(Request $request, string $externalId): Response 'color' => $rolesMap[$roleId]['color'] ?? '#666666', ]; - unset($user['password']); + $user['email_verified'] = !isset($user['mail_verify']) || trim((string) $user['mail_verify']) === ''; + unset($user['password'], $user['mail_verify']); $user['activities'] = array_map(function ($activity) use ($app) { unset($activity['user_uuid'], $activity['id'], $activity['updated_at']); @@ -1391,6 +1396,74 @@ public function sendEmail(Request $request, string $uuid): Response ], 'Email queued successfully', 200); } + #[OA\Post( + path: '/api/admin/users/{uuid}/verify-email', + summary: 'Force verify user email', + description: 'Mark a user email address as verified by clearing their pending email verification token.', + tags: ['Admin - Users'], + parameters: [ + new OA\Parameter( + name: 'uuid', + in: 'path', + description: 'User UUID', + required: true, + schema: new OA\Schema(type: 'string', format: 'uuid') + ), + ], + responses: [ + new OA\Response(response: 200, description: 'User email verified successfully'), + new OA\Response(response: 400, description: 'Bad request - User is already verified or demo mode restriction'), + new OA\Response(response: 401, description: 'Unauthorized'), + new OA\Response(response: 403, description: 'Forbidden - Insufficient permissions'), + new OA\Response(response: 404, description: 'User not found'), + new OA\Response(response: 500, description: 'Internal server error - Failed to verify email'), + ] + )] + public function forceVerifyEmail(Request $request, string $uuid): Response + { + $user = User::getUserByUuid($uuid); + if (!$user) { + return ApiResponse::error('User not found', 'USER_NOT_FOUND', 404); + } + + $app = App::getInstance(true); + if ($app->isDemoMode() && in_array((int) $user['id'], [1, 2], true)) { + return ApiResponse::error('Unmanaged actions are not permitted in demo mode', 'UNMANAGED_ACTIONS_NOT_PERMITTED', 400); + } + + if (!isset($user['mail_verify']) || trim((string) $user['mail_verify']) === '') { + return ApiResponse::error('User email is already verified', 'EMAIL_ALREADY_VERIFIED', 400); + } + + $updated = User::updateUser($user['uuid'], ['mail_verify' => null]); + if (!$updated) { + return ApiResponse::error('Failed to verify user email', 'FAILED_TO_VERIFY_EMAIL', 500); + } + + Activity::createActivity([ + 'user_uuid' => $request->attributes->get('user')['uuid'] ?? null, + 'name' => 'force_verify_user_email', + 'context' => 'Force verified email for user ' . ($user['username'] ?? $user['uuid']), + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + global $eventManager; + if (isset($eventManager) && $eventManager !== null) { + $eventManager->emit( + UserEvent::onUserUpdated(), + [ + 'user' => $user, + 'updated_data' => ['mail_verify' => null], + 'updated_by' => $request->attributes->get('user'), + ] + ); + } + + $app->getLogger()->info('User ' . $user['uuid'] . ' email force verified by ' . ($request->attributes->get('user')['uuid'] ?? 'unknown')); + + return ApiResponse::success([], 'User email verified successfully', 200); + } + #[OA\Post( path: '/api/admin/users/{uuid}/ban', summary: 'Ban a user', diff --git a/backend/app/Controllers/System/PluginSidebarController.php b/backend/app/Controllers/System/PluginSidebarController.php index 877f86ba1..99ad7947d 100755 --- a/backend/app/Controllers/System/PluginSidebarController.php +++ b/backend/app/Controllers/System/PluginSidebarController.php @@ -51,6 +51,7 @@ new OA\Property(property: 'lucideIcon', type: 'string', nullable: true, description: 'Lucide icon name (e.g., "camera", "search"). If provided, this will be used instead of the icon field. See https://lucide.dev/icons/ for available icons.'), new OA\Property(property: 'permission', type: 'string', nullable: true, description: 'Required permission for this item'), new OA\Property(property: 'group', type: 'string', nullable: true, description: 'Group name for organizing items (e.g., "Minecraft Java Edition"). Items with the same group name will be grouped together.'), + new OA\Property(property: 'priority', type: 'integer', nullable: true, description: 'Sort order within the sidebar group. Lower numbers render first.'), ] )] class PluginSidebarController diff --git a/backend/app/Controllers/User/Auth/VerifyEmailController.php b/backend/app/Controllers/User/Auth/VerifyEmailController.php index c035fc894..e362e386b 100644 --- a/backend/app/Controllers/User/Auth/VerifyEmailController.php +++ b/backend/app/Controllers/User/Auth/VerifyEmailController.php @@ -17,13 +17,19 @@ namespace App\Controllers\User\Auth; +use App\App; use App\Chat\User; +use App\Cache\Cache; use App\Helpers\ApiResponse; +use App\Config\ConfigInterface; +use App\Mail\templates\VerifyEmail; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; class VerifyEmailController { + private const RESEND_COOLDOWN_MINUTES = 5; + public function get(Request $request): Response { $token = trim((string) $request->query->get('token', '')); @@ -42,4 +48,59 @@ public function get(Request $request): Response return ApiResponse::success([], 'Email verified successfully. You can now log in.', 200); } + + public function resend(Request $request): Response + { + $app = App::getInstance(true); + $config = $app->getConfig(); + + if ($config->getSetting(ConfigInterface::REGISTRATION_REQUIRE_EMAIL_VERIFICATION, 'false') !== 'true') { + return ApiResponse::success([], 'If email verification is needed, a new verification email will be sent.', 200); + } + + if ($config->getSetting(ConfigInterface::SMTP_ENABLED, 'false') !== 'true') { + return ApiResponse::error('Email verification is enabled, but SMTP is not configured.', 'EMAIL_VERIFICATION_SMTP_REQUIRED', 400); + } + + $body = json_decode($request->getContent(), true); + if (!is_array($body)) { + $body = []; + } + $identifier = trim((string) ($body['email'] ?? $body['username_or_email'] ?? '')); + if ($identifier === '') { + return ApiResponse::error('Email or username is required', 'MISSING_IDENTIFIER', 400); + } + + $user = filter_var($identifier, FILTER_VALIDATE_EMAIL) + ? User::getUserByEmail($identifier) + : User::getUserByUsername($identifier); + + if ($user === null || !isset($user['mail_verify']) || trim((string) $user['mail_verify']) === '') { + return ApiResponse::success([], 'If email verification is needed, a new verification email will be sent.', 200); + } + + $cacheKey = 'auth:email-verification-resend:' . hash('sha256', (string) $user['uuid']); + if (Cache::exists($cacheKey)) { + return ApiResponse::success([], 'If email verification is needed, a new verification email will be sent.', 200); + } + + $verifyUrl = rtrim($config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'), '/') . '/auth/verify-email?token=' . urlencode((string) $user['mail_verify']); + VerifyEmail::send([ + 'subject' => 'Verify your email for ' . $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), + 'app_name' => $config->getSetting(ConfigInterface::APP_NAME, 'FeatherPanel'), + 'app_url' => $config->getSetting(ConfigInterface::APP_URL, 'https://featherpanel.mythical.systems'), + 'first_name' => (string) ($user['first_name'] ?? ''), + 'last_name' => (string) ($user['last_name'] ?? ''), + 'email' => (string) ($user['email'] ?? ''), + 'username' => (string) ($user['username'] ?? ''), + 'app_support_url' => $config->getSetting(ConfigInterface::APP_SUPPORT_URL, 'https://discord.mythical.systems'), + 'verify_url' => $verifyUrl, + 'uuid' => (string) $user['uuid'], + 'enabled' => $config->getSetting(ConfigInterface::SMTP_ENABLED, 'false'), + ]); + + Cache::put($cacheKey, true, self::RESEND_COOLDOWN_MINUTES); + + return ApiResponse::success([], 'If email verification is needed, a new verification email will be sent.', 200); + } } diff --git a/backend/app/Controllers/User/ChatbotController.php b/backend/app/Controllers/User/ChatbotController.php index 0afc5cecd..58c02443b 100755 --- a/backend/app/Controllers/User/ChatbotController.php +++ b/backend/app/Controllers/User/ChatbotController.php @@ -22,10 +22,12 @@ use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; use App\Chat\ChatConversation; +use App\Services\Chatbot\TokenUsage; use App\Services\Chatbot\ChatbotService; use App\Plugins\Events\Events\ChatbotEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; #[OA\Schema( schema: 'ChatbotRequest', @@ -70,105 +72,28 @@ class ChatbotController )] public function chat(Request $request): Response { - $currentUser = $request->attributes->get('user'); - - if (!$currentUser || !isset($currentUser['id'])) { - return ApiResponse::error('User not authenticated', 'UNAUTHORIZED', 401); - } - - // Check if chatbot is enabled - $app = App::getInstance(true); - $config = $app->getConfig(); - $enabled = $config->getSetting(\App\Config\ConfigInterface::CHATBOT_ENABLED, 'true'); - if ($enabled !== 'true') { - return ApiResponse::error('The AI chatbot is currently disabled by the administrator.', 'CHATBOT_DISABLED', 403); - } - - $data = json_decode($request->getContent(), true); - - if (!isset($data['message']) || empty(trim($data['message']))) { - return ApiResponse::error('Message is required', 'INVALID_REQUEST', 400); - } - - $message = trim($data['message']); - $history = $data['history'] ?? []; - $pageContext = $data['pageContext'] ?? []; - $conversationId = $data['conversation_id'] ?? null; - try { - // Get or create conversation - $conversation = null; - if ($conversationId) { - $conversation = ChatConversation::getConversationById((int) $conversationId); - // Verify conversation belongs to user - if ($conversation && $conversation['user_uuid'] !== $currentUser['uuid']) { - return ApiResponse::error('Conversation not found', 'NOT_FOUND', 404); - } - } - - // Create new conversation if needed - if (!$conversation) { - $conversationId = ChatConversation::createConversation([ - 'user_uuid' => $currentUser['uuid'], - 'title' => substr($message, 0, 255), // Use first message as title - ]); - if (!$conversationId) { - return ApiResponse::error('Failed to create conversation', 'SERVER_ERROR', 500); - } - $conversation = ChatConversation::getConversationById($conversationId); - } - - // Load conversation history from database if not provided - if (empty($history) && $conversation) { - $dbMessages = ChatMessage::getMessagesByConversation($conversation['id'], 50); - $history = array_map(function ($msg) { - return [ - 'role' => $msg['role'], - 'content' => $msg['content'], - ]; - }, $dbMessages); - } - - // Save user message to database - ChatMessage::createMessage([ - 'conversation_id' => $conversation['id'], - 'role' => 'user', - 'content' => $message, - ]); - - // Get conversation memory - $conversationMemory = $conversation['memory'] ?? ''; - $pageContext['conversation_memory'] = $conversationMemory; - - // Process message through AI - $chatbotService = new ChatbotService(); - $result = $chatbotService->processMessage($message, $history, $currentUser, $pageContext); - - // Update message count - $messageCount = ChatMessage::getMessageCount($conversation['id']); - ChatConversation::updateConversation($conversation['id'], [ - 'message_count' => $messageCount, - ]); - - // Save AI response to database - ChatMessage::createMessage([ - 'conversation_id' => $conversation['id'], - 'role' => 'assistant', - 'content' => $result['response'], - 'model' => $result['model'] ?? null, - ]); - - // Update conversation timestamp - ChatConversation::updateConversation($conversation['id'], [ - 'updated_at' => date('Y-m-d H:i:s'), - ]); + $payload = $this->processChatRequest($request); return ApiResponse::success([ - 'response' => $result['response'], - 'model' => $result['model'] ?? 'FeatherPanel AI', - 'conversation_id' => $conversation['id'], - 'tool_executions' => $result['tool_executions'] ?? [], // Include tool execution results + 'response' => $payload['response'], + 'model' => $payload['model'], + 'conversation_id' => $payload['conversation_id'], + 'message_id' => $payload['assistant_message_id'], + 'user_message_id' => $payload['user_message_id'], + 'usage' => $payload['usage'], + 'user_usage' => $payload['user_usage'], + 'tool_executions' => $payload['tool_executions'], + 'tool_activity' => $payload['tool_activity'], ], 'Message processed successfully'); + } catch (\InvalidArgumentException $e) { + return ApiResponse::error($e->getMessage(), 'INVALID_REQUEST', 400); + } catch (\RuntimeException $e) { + $message = $e->getMessage(); + $code = $message === 'User not authenticated' ? 401 : ($message === 'Conversation not found' ? 404 : (str_contains($message, 'disabled') ? 403 : 500)); + $errorCode = $code === 401 ? 'UNAUTHORIZED' : ($code === 404 ? 'NOT_FOUND' : ($code === 403 ? 'CHATBOT_DISABLED' : 'CHATBOT_ERROR')); + + return ApiResponse::error($message, $errorCode, $code); } catch (\Exception $e) { App::getInstance(true)->getLogger()->error('Chatbot error: ' . $e->getMessage()); @@ -180,6 +105,32 @@ public function chat(Request $request): Response } } + public function streamChat(Request $request): Response + { + return new StreamedResponse(function () use ($request): void { + $emit = function (string $type, array $payload = []): void { + echo "event: {$type}\n"; + echo 'data: ' . json_encode($payload, JSON_UNESCAPED_SLASHES) . "\n\n"; + @ob_flush(); + flush(); + }; + + try { + $payload = $this->processChatRequest($request, $emit); + $emit('final', $payload); + } catch (\Exception $e) { + App::getInstance(true)->getLogger()->error('Chatbot stream error: ' . $e->getMessage()); + $emit('error', [ + 'message' => 'Failed to process message. Please try again.', + ]); + } + }, 200, [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'X-Accel-Buffering' => 'no', + ]); + } + #[OA\Get( path: '/api/user/chatbot/conversations', summary: 'Get user conversations', @@ -414,6 +365,192 @@ public function updateMemory(Request $request, int $id): Response } } + private function processChatRequest(Request $request, ?callable $emit = null): array + { + $currentUser = $request->attributes->get('user'); + + if (!$currentUser || !isset($currentUser['id'])) { + throw new \RuntimeException('User not authenticated'); + } + + $app = App::getInstance(true); + $config = $app->getConfig(); + $enabled = $config->getSetting(\App\Config\ConfigInterface::CHATBOT_ENABLED, 'true'); + if ($enabled !== 'true') { + throw new \RuntimeException('The AI chatbot is currently disabled by the administrator.'); + } + + $data = json_decode($request->getContent(), true) ?: []; + if (!isset($data['message']) || empty(trim($data['message']))) { + throw new \InvalidArgumentException('Message is required'); + } + + $message = trim($data['message']); + $history = $data['history'] ?? []; + $pageContext = $data['pageContext'] ?? []; + $conversationId = $data['conversation_id'] ?? null; + + $conversation = null; + if ($conversationId) { + $conversation = ChatConversation::getConversationById((int) $conversationId); + if ($conversation && $conversation['user_uuid'] !== $currentUser['uuid']) { + throw new \RuntimeException('Conversation not found'); + } + } + + if (!$conversation) { + $conversationId = ChatConversation::createConversation([ + 'user_uuid' => $currentUser['uuid'], + 'title' => substr($message, 0, 255), + ]); + if (!$conversationId) { + throw new \RuntimeException('Failed to create conversation'); + } + $conversation = ChatConversation::getConversationById($conversationId); + } + + $dbMessages = ChatMessage::getMessagesByConversation($conversation['id'], 50); + if (empty($history) && $conversation) { + $history = $this->buildCompactHistory($dbMessages, $conversation); + } else { + $history = array_slice($history, -6); + } + + $userUsage = TokenUsage::estimate($message); + $userMessageId = ChatMessage::createMessage([ + 'conversation_id' => $conversation['id'], + 'role' => 'user', + 'content' => $message, + 'input_tokens' => $userUsage['input_tokens'], + 'total_tokens' => $userUsage['total_tokens'], + 'token_source' => $userUsage['source'], + ]); + + if ($emit !== null) { + $emit('conversation', [ + 'conversation_id' => $conversation['id'], + 'user_message_id' => $userMessageId, + 'user_usage' => $userUsage, + ]); + } + + $summary = $this->refreshContextSummary($conversation, $dbMessages, $message); + if ($emit !== null && $summary !== '') { + $emit('status', ['message' => 'Compacting conversation context']); + } + $pageContext['conversation_memory'] = trim(($conversation['memory'] ?? '') . "\n\n" . $summary); + + $chatbotService = new ChatbotService(); + $result = $chatbotService->processMessage($message, $history, $currentUser, $pageContext, $emit); + $usage = $result['usage'] ?? TokenUsage::estimate($message, $result['response'] ?? ''); + $toolActivity = $result['tool_activity'] ?? []; + + $assistantMessageId = ChatMessage::createMessage([ + 'conversation_id' => $conversation['id'], + 'role' => 'assistant', + 'content' => $result['response'], + 'model' => $result['model'] ?? null, + 'input_tokens' => $usage['input_tokens'] ?? null, + 'output_tokens' => $usage['output_tokens'] ?? null, + 'total_tokens' => $usage['total_tokens'] ?? null, + 'token_source' => $usage['source'] ?? null, + 'tool_activity' => $toolActivity, + 'usage_json' => $usage, + ]); + + $messageCount = ChatMessage::getMessageCount($conversation['id']); + ChatConversation::updateConversation($conversation['id'], [ + 'message_count' => $messageCount, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return [ + 'response' => $result['response'], + 'model' => $result['model'] ?? 'FeatherPanel AI', + 'conversation_id' => $conversation['id'], + 'user_message_id' => $userMessageId, + 'assistant_message_id' => $assistantMessageId, + 'usage' => $usage, + 'user_usage' => $userUsage, + 'tool_executions' => $result['tool_executions'] ?? [], + 'tool_activity' => $toolActivity, + ]; + } + + private function buildCompactHistory(array $dbMessages, array $conversation): array + { + $summary = $conversation['context_summary'] ?? ''; + $recentMessages = array_slice($dbMessages, -6); + $history = []; + + if ($summary !== '') { + $history[] = [ + 'role' => 'assistant', + 'content' => "Conversation context summary:\n{$summary}", + ]; + } + + foreach ($recentMessages as $msg) { + $content = $msg['content']; + if (!empty($msg['tool_activity']) && is_array($msg['tool_activity'])) { + $content .= "\n\n[Tool/activity results from this assistant turn]\n" . $this->formatToolActivityForHistory($msg['tool_activity']); + } + $history[] = [ + 'role' => $msg['role'], + 'content' => $content, + ]; + } + + return $history; + } + + private function refreshContextSummary(array $conversation, array $dbMessages, string $latestMessage): string + { + $existing = trim((string) ($conversation['context_summary'] ?? '')); + if (count($dbMessages) < 10 || !ChatConversation::hasColumn('context_summary')) { + return $existing; + } + + $olderMessages = array_slice($dbMessages, 0, -6); + $lines = []; + foreach ($olderMessages as $message) { + $content = trim(preg_replace('/\s+/', ' ', (string) $message['content'])); + if (!empty($message['tool_activity']) && is_array($message['tool_activity'])) { + $content .= ' Tool/activity: ' . preg_replace('/\s+/', ' ', $this->formatToolActivityForHistory($message['tool_activity'])); + } + if ($content === '') { + continue; + } + $lines[] = strtoupper((string) $message['role']) . ': ' . mb_substr($content, 0, 220); + } + + $summary = trim($existing . "\n" . implode("\n", array_slice($lines, -12))); + $summary = mb_substr($summary, -3500); + $summary .= "\nLatest user message: " . mb_substr($latestMessage, 0, 300); + + ChatConversation::updateConversation((int) $conversation['id'], [ + 'context_summary' => $summary, + 'context_summary_updated_at' => date('Y-m-d H:i:s'), + ]); + + return $summary; + } + + private function formatToolActivityForHistory(array $toolActivity): string + { + $lines = []; + foreach ($toolActivity as $activity) { + if (!is_array($activity)) { + continue; + } + $status = ($activity['success'] ?? null) === false ? 'failed' : (($activity['success'] ?? null) ? 'completed' : 'planned'); + $summary = isset($activity['summary']) ? ' - ' . $activity['summary'] : ''; + $lines[] = ($activity['tool'] ?? 'unknown_tool') . ": {$status}{$summary}"; + } + + return implode("\n", $lines); + } + private static function emitEvent(string $eventName, array $payload): void { global $eventManager; diff --git a/backend/app/Controllers/User/User/SessionController.php b/backend/app/Controllers/User/User/SessionController.php index d987defa3..c23bead0d 100755 --- a/backend/app/Controllers/User/User/SessionController.php +++ b/backend/app/Controllers/User/User/SessionController.php @@ -20,10 +20,15 @@ use App\App; use App\Chat\Role; use App\Chat\User; +use App\Chat\Ticket; use App\Chat\Activity; use App\Chat\MailList; use App\Chat\ApiClient; use App\Chat\Permission; +use App\Chat\TicketStatus; +use App\Chat\TicketMessage; +use App\Chat\TicketCategory; +use App\Chat\TicketPriority; use App\Chat\UserPreference; use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; @@ -32,6 +37,7 @@ use App\CloudFlare\CloudFlareRealIP; use App\Helpers\EmailDomainValidator; use App\Plugins\Events\Events\UserEvent; +use App\Plugins\Events\Events\TicketEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -346,6 +352,148 @@ public function get(Request $request): Response ], 'Session retrieved', 200); } + #[OA\Post( + path: '/api/user/data-request', + summary: 'Request account data export', + description: 'Create a support ticket requesting a personal data export for the authenticated user.', + tags: ['User - Session'], + responses: [ + new OA\Response(response: 201, description: 'Data request ticket created successfully'), + new OA\Response(response: 400, description: 'Bad request - Missing ticket configuration'), + new OA\Response(response: 401, description: 'Unauthorized - User not authenticated'), + new OA\Response(response: 403, description: 'Forbidden - Ticket system disabled or open ticket limit reached'), + new OA\Response(response: 500, description: 'Internal server error - Failed to create request'), + ] + )] + public function requestDataExport(Request $request): Response + { + $app = App::getInstance(true); + $config = $app->getConfig(); + + if ($config->getSetting(ConfigInterface::TICKET_SYSTEM_ENABLED, 'true') !== 'true') { + return ApiResponse::error('Ticket system is disabled', 'TICKET_SYSTEM_DISABLED', 403); + } + + $user = AuthMiddleware::getCurrentUser($request); + if ($user == null) { + return ApiResponse::error('You are not allowed to access this resource!', 'INVALID_ACCOUNT_TOKEN', 400, []); + } + + $maxOpenTickets = (int) $config->getSetting(ConfigInterface::TICKET_SYSTEM_MAX_OPEN_TICKETS, '10'); + if ($maxOpenTickets > 0 && Ticket::getOpenTicketsCount($user['uuid']) >= $maxOpenTickets) { + return ApiResponse::error( + "You have reached the maximum number of open tickets ({$maxOpenTickets}). Please close or wait for resolution of existing tickets before creating a new one.", + 'MAX_OPEN_TICKETS_REACHED', + 403 + ); + } + + $categories = TicketCategory::getAll('privacy', 1, 0); + if (empty($categories)) { + $categories = TicketCategory::getAll('data', 1, 0); + } + if (empty($categories)) { + $categories = TicketCategory::getAll(null, 1, 0); + } + if (empty($categories)) { + return ApiResponse::error('No ticket categories configured', 'NO_CATEGORIES', 500); + } + + $priorities = TicketPriority::getAll('normal', 1, 0); + if (empty($priorities)) { + $priorities = TicketPriority::getAll('medium', 1, 0); + } + if (empty($priorities)) { + $priorities = TicketPriority::getAll('low', 1, 0); + } + if (empty($priorities)) { + $priorities = TicketPriority::getAll(null, 1, 0); + } + if (empty($priorities)) { + return ApiResponse::error('No ticket priorities configured', 'NO_PRIORITIES', 500); + } + + $statuses = TicketStatus::getAll(null, 100, 0); + $openStatus = null; + foreach ($statuses as $status) { + if (strtolower($status['name']) === 'open') { + $openStatus = $status; + break; + } + } + if (!$openStatus && !empty($statuses)) { + $openStatus = $statuses[0]; + } + if (!$openStatus) { + return ApiResponse::error('No ticket statuses configured', 'NO_STATUSES', 500); + } + + $title = 'Personal data request'; + $description = implode("\n", [ + 'I am requesting a copy of my personal data under UK GDPR.', + '', + 'Account details:', + '- User UUID: ' . $user['uuid'], + '- Username: ' . $user['username'], + '- Email: ' . $user['email'], + '', + 'Please provide the data export or advise on any identity verification and fulfilment timeline.', + ]); + + $ticketData = [ + 'uuid' => Ticket::generateUuid(), + 'user_uuid' => $user['uuid'], + 'server_id' => null, + 'category_id' => (int) $categories[0]['id'], + 'priority_id' => (int) $priorities[0]['id'], + 'status_id' => (int) $openStatus['id'], + 'title' => $title, + 'description' => $description, + ]; + + $ticketId = Ticket::create($ticketData); + if (!$ticketId) { + return ApiResponse::error('Failed to create data request', 'CREATE_FAILED', 500); + } + + $messageId = TicketMessage::create([ + 'ticket_id' => $ticketId, + 'user_uuid' => $user['uuid'], + 'message' => $description, + 'is_internal' => false, + ]); + + if (!$messageId) { + $app->getLogger()->warning('Failed to create initial message for data request ticket: ' . $ticketId); + } + + $ticket = Ticket::getById($ticketId); + + Activity::createActivity([ + 'user_uuid' => $user['uuid'], + 'name' => 'request_data_export', + 'context' => 'Requested personal data export', + 'ip_address' => CloudFlareRealIP::getRealIP(), + ]); + + global $eventManager; + if (isset($eventManager) && $eventManager !== null) { + $eventManager->emit( + TicketEvent::onTicketCreated(), + [ + 'ticket' => $ticket, + 'ticket_id' => $ticketId, + 'user_uuid' => $user['uuid'], + ] + ); + } + + return ApiResponse::success([ + 'ticket' => $ticket, + 'message_id' => $messageId, + ], 'Data request created successfully', 201); + } + #[OA\Post( path: '/api/user/avatar', summary: 'Upload user avatar', diff --git a/backend/app/Controllers/User/VdsChatbotController.php b/backend/app/Controllers/User/VdsChatbotController.php index 6825a0b00..33dc5808e 100644 --- a/backend/app/Controllers/User/VdsChatbotController.php +++ b/backend/app/Controllers/User/VdsChatbotController.php @@ -22,10 +22,12 @@ use App\Helpers\ApiResponse; use OpenApi\Attributes as OA; use App\Chat\ChatConversation; +use App\Services\Chatbot\TokenUsage; use App\Plugins\Events\Events\ChatbotEvent; use App\Services\Chatbot\VdsChatbotService; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\StreamedResponse; #[OA\Schema( schema: 'VdsChatbotRequest', @@ -70,105 +72,28 @@ class VdsChatbotController )] public function chat(Request $request): Response { - $currentUser = $request->attributes->get('user'); - - if (!$currentUser || !isset($currentUser['id'])) { - return ApiResponse::error('User not authenticated', 'UNAUTHORIZED', 401); - } - - // Check if chatbot is enabled - $app = App::getInstance(true); - $config = $app->getConfig(); - $enabled = $config->getSetting(\App\Config\ConfigInterface::CHATBOT_ENABLED, 'true'); - if ($enabled !== 'true') { - return ApiResponse::error('The AI chatbot is currently disabled by the administrator.', 'CHATBOT_DISABLED', 403); - } - - $data = json_decode($request->getContent(), true); - - if (!isset($data['message']) || empty(trim($data['message']))) { - return ApiResponse::error('Message is required', 'INVALID_REQUEST', 400); - } - - $message = trim($data['message']); - $history = $data['history'] ?? []; - $pageContext = $data['pageContext'] ?? []; - $conversationId = $data['conversation_id'] ?? null; - try { - // Get or create conversation - $conversation = null; - if ($conversationId) { - $conversation = ChatConversation::getConversationById((int) $conversationId); - // Verify conversation belongs to user - if ($conversation && $conversation['user_uuid'] !== $currentUser['uuid']) { - return ApiResponse::error('Conversation not found', 'NOT_FOUND', 404); - } - } - - // Create new conversation if needed - if (!$conversation) { - $conversationId = ChatConversation::createConversation([ - 'user_uuid' => $currentUser['uuid'], - 'title' => substr($message, 0, 255), - ]); - if (!$conversationId) { - return ApiResponse::error('Failed to create conversation', 'SERVER_ERROR', 500); - } - $conversation = ChatConversation::getConversationById($conversationId); - } - - // Load conversation history from database if not provided - if (empty($history) && $conversation) { - $dbMessages = ChatMessage::getMessagesByConversation($conversation['id'], 50); - $history = array_map(function ($msg) { - return [ - 'role' => $msg['role'], - 'content' => $msg['content'], - ]; - }, $dbMessages); - } - - // Save user message to database - ChatMessage::createMessage([ - 'conversation_id' => $conversation['id'], - 'role' => 'user', - 'content' => $message, - ]); - - // Get conversation memory - $conversationMemory = $conversation['memory'] ?? ''; - $pageContext['conversation_memory'] = $conversationMemory; - - // Process message through VDS AI - $chatbotService = new VdsChatbotService(); - $result = $chatbotService->processMessage($message, $history, $currentUser, $pageContext); - - // Update message count - $messageCount = ChatMessage::getMessageCount($conversation['id']); - ChatConversation::updateConversation($conversation['id'], [ - 'message_count' => $messageCount, - ]); - - // Save AI response to database - ChatMessage::createMessage([ - 'conversation_id' => $conversation['id'], - 'role' => 'assistant', - 'content' => $result['response'], - 'model' => $result['model'] ?? null, - ]); - - // Update conversation timestamp - ChatConversation::updateConversation($conversation['id'], [ - 'updated_at' => date('Y-m-d H:i:s'), - ]); + $payload = $this->processChatRequest($request); return ApiResponse::success([ - 'response' => $result['response'], - 'model' => $result['model'] ?? 'FeatherPanel VDS AI', - 'conversation_id' => $conversation['id'], - 'tool_executions' => $result['tool_executions'] ?? [], + 'response' => $payload['response'], + 'model' => $payload['model'], + 'conversation_id' => $payload['conversation_id'], + 'message_id' => $payload['assistant_message_id'], + 'user_message_id' => $payload['user_message_id'], + 'usage' => $payload['usage'], + 'user_usage' => $payload['user_usage'], + 'tool_executions' => $payload['tool_executions'], + 'tool_activity' => $payload['tool_activity'], ], 'Message processed successfully'); + } catch (\InvalidArgumentException $e) { + return ApiResponse::error($e->getMessage(), 'INVALID_REQUEST', 400); + } catch (\RuntimeException $e) { + $message = $e->getMessage(); + $code = $message === 'User not authenticated' ? 401 : ($message === 'Conversation not found' ? 404 : (str_contains($message, 'disabled') ? 403 : 500)); + $errorCode = $code === 401 ? 'UNAUTHORIZED' : ($code === 404 ? 'NOT_FOUND' : ($code === 403 ? 'CHATBOT_DISABLED' : 'CHATBOT_ERROR')); + + return ApiResponse::error($message, $errorCode, $code); } catch (\Exception $e) { App::getInstance(true)->getLogger()->error('VDS Chatbot error: ' . $e->getMessage()); @@ -180,6 +105,32 @@ public function chat(Request $request): Response } } + public function streamChat(Request $request): Response + { + return new StreamedResponse(function () use ($request): void { + $emit = function (string $type, array $payload = []): void { + echo "event: {$type}\n"; + echo 'data: ' . json_encode($payload, JSON_UNESCAPED_SLASHES) . "\n\n"; + @ob_flush(); + flush(); + }; + + try { + $payload = $this->processChatRequest($request, $emit); + $emit('final', $payload); + } catch (\Exception $e) { + App::getInstance(true)->getLogger()->error('VDS Chatbot stream error: ' . $e->getMessage()); + $emit('error', [ + 'message' => 'Failed to process message. Please try again.', + ]); + } + }, 200, [ + 'Content-Type' => 'text/event-stream', + 'Cache-Control' => 'no-cache', + 'X-Accel-Buffering' => 'no', + ]); + } + #[OA\Get( path: '/api/user/vds-chatbot/conversations', summary: 'Get user VDS chatbot conversations', @@ -414,6 +365,192 @@ public function updateMemory(Request $request, int $id): Response } } + private function processChatRequest(Request $request, ?callable $emit = null): array + { + $currentUser = $request->attributes->get('user'); + + if (!$currentUser || !isset($currentUser['id'])) { + throw new \RuntimeException('User not authenticated'); + } + + $app = App::getInstance(true); + $config = $app->getConfig(); + $enabled = $config->getSetting(\App\Config\ConfigInterface::CHATBOT_ENABLED, 'true'); + if ($enabled !== 'true') { + throw new \RuntimeException('The AI chatbot is currently disabled by the administrator.'); + } + + $data = json_decode($request->getContent(), true) ?: []; + if (!isset($data['message']) || empty(trim($data['message']))) { + throw new \InvalidArgumentException('Message is required'); + } + + $message = trim($data['message']); + $history = $data['history'] ?? []; + $pageContext = $data['pageContext'] ?? []; + $conversationId = $data['conversation_id'] ?? null; + + $conversation = null; + if ($conversationId) { + $conversation = ChatConversation::getConversationById((int) $conversationId); + if ($conversation && $conversation['user_uuid'] !== $currentUser['uuid']) { + throw new \RuntimeException('Conversation not found'); + } + } + + if (!$conversation) { + $conversationId = ChatConversation::createConversation([ + 'user_uuid' => $currentUser['uuid'], + 'title' => substr($message, 0, 255), + ]); + if (!$conversationId) { + throw new \RuntimeException('Failed to create conversation'); + } + $conversation = ChatConversation::getConversationById($conversationId); + } + + $dbMessages = ChatMessage::getMessagesByConversation($conversation['id'], 50); + if (empty($history) && $conversation) { + $history = $this->buildCompactHistory($dbMessages, $conversation); + } else { + $history = array_slice($history, -6); + } + + $userUsage = TokenUsage::estimate($message); + $userMessageId = ChatMessage::createMessage([ + 'conversation_id' => $conversation['id'], + 'role' => 'user', + 'content' => $message, + 'input_tokens' => $userUsage['input_tokens'], + 'total_tokens' => $userUsage['total_tokens'], + 'token_source' => $userUsage['source'], + ]); + + if ($emit !== null) { + $emit('conversation', [ + 'conversation_id' => $conversation['id'], + 'user_message_id' => $userMessageId, + 'user_usage' => $userUsage, + ]); + } + + $summary = $this->refreshContextSummary($conversation, $dbMessages, $message); + if ($emit !== null && $summary !== '') { + $emit('status', ['message' => 'Compacting conversation context']); + } + $pageContext['conversation_memory'] = trim(($conversation['memory'] ?? '') . "\n\n" . $summary); + + $chatbotService = new VdsChatbotService(); + $result = $chatbotService->processMessage($message, $history, $currentUser, $pageContext, $emit); + $usage = $result['usage'] ?? TokenUsage::estimate($message, $result['response'] ?? ''); + $toolActivity = $result['tool_activity'] ?? []; + + $assistantMessageId = ChatMessage::createMessage([ + 'conversation_id' => $conversation['id'], + 'role' => 'assistant', + 'content' => $result['response'], + 'model' => $result['model'] ?? null, + 'input_tokens' => $usage['input_tokens'] ?? null, + 'output_tokens' => $usage['output_tokens'] ?? null, + 'total_tokens' => $usage['total_tokens'] ?? null, + 'token_source' => $usage['source'] ?? null, + 'tool_activity' => $toolActivity, + 'usage_json' => $usage, + ]); + + $messageCount = ChatMessage::getMessageCount($conversation['id']); + ChatConversation::updateConversation($conversation['id'], [ + 'message_count' => $messageCount, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return [ + 'response' => $result['response'], + 'model' => $result['model'] ?? 'FeatherPanel VDS AI', + 'conversation_id' => $conversation['id'], + 'user_message_id' => $userMessageId, + 'assistant_message_id' => $assistantMessageId, + 'usage' => $usage, + 'user_usage' => $userUsage, + 'tool_executions' => $result['tool_executions'] ?? [], + 'tool_activity' => $toolActivity, + ]; + } + + private function buildCompactHistory(array $dbMessages, array $conversation): array + { + $summary = $conversation['context_summary'] ?? ''; + $recentMessages = array_slice($dbMessages, -6); + $history = []; + + if ($summary !== '') { + $history[] = [ + 'role' => 'assistant', + 'content' => "Conversation context summary:\n{$summary}", + ]; + } + + foreach ($recentMessages as $msg) { + $content = $msg['content']; + if (!empty($msg['tool_activity']) && is_array($msg['tool_activity'])) { + $content .= "\n\n[Tool/activity results from this assistant turn]\n" . $this->formatToolActivityForHistory($msg['tool_activity']); + } + $history[] = [ + 'role' => $msg['role'], + 'content' => $content, + ]; + } + + return $history; + } + + private function refreshContextSummary(array $conversation, array $dbMessages, string $latestMessage): string + { + $existing = trim((string) ($conversation['context_summary'] ?? '')); + if (count($dbMessages) < 10 || !ChatConversation::hasColumn('context_summary')) { + return $existing; + } + + $olderMessages = array_slice($dbMessages, 0, -6); + $lines = []; + foreach ($olderMessages as $message) { + $content = trim(preg_replace('/\s+/', ' ', (string) $message['content'])); + if (!empty($message['tool_activity']) && is_array($message['tool_activity'])) { + $content .= ' Tool/activity: ' . preg_replace('/\s+/', ' ', $this->formatToolActivityForHistory($message['tool_activity'])); + } + if ($content === '') { + continue; + } + $lines[] = strtoupper((string) $message['role']) . ': ' . mb_substr($content, 0, 220); + } + + $summary = trim($existing . "\n" . implode("\n", array_slice($lines, -12))); + $summary = mb_substr($summary, -3500); + $summary .= "\nLatest user message: " . mb_substr($latestMessage, 0, 300); + + ChatConversation::updateConversation((int) $conversation['id'], [ + 'context_summary' => $summary, + 'context_summary_updated_at' => date('Y-m-d H:i:s'), + ]); + + return $summary; + } + + private function formatToolActivityForHistory(array $toolActivity): string + { + $lines = []; + foreach ($toolActivity as $activity) { + if (!is_array($activity)) { + continue; + } + $status = ($activity['success'] ?? null) === false ? 'failed' : (($activity['success'] ?? null) ? 'completed' : 'planned'); + $summary = isset($activity['summary']) ? ' - ' . $activity['summary'] : ''; + $lines[] = ($activity['tool'] ?? 'unknown_tool') . ": {$status}{$summary}"; + } + + return implode("\n", $lines); + } + private static function emitEvent(string $eventName, array $payload): void { global $eventManager; diff --git a/backend/app/Services/Backup/BackupFifoEviction.php b/backend/app/Services/Backup/BackupFifoEviction.php index ef32836f2..44d5776f6 100644 --- a/backend/app/Services/Backup/BackupFifoEviction.php +++ b/backend/app/Services/Backup/BackupFifoEviction.php @@ -144,15 +144,33 @@ public static function evictOldestWingsBackup(int $serverId, string $serverUuid, } $response = $wings->getServer()->deleteBackup($serverUuid, (string) $victim['uuid']); + $missingOnNode = false; if (!$response->isSuccessful()) { - return [ - 'message' => 'Failed to remove oldest backup for rotation: ' . $response->getError(), - 'code' => 'FIFO_EVICTION_FAILED', - 'status' => $response->getStatusCode() >= 400 ? $response->getStatusCode() : 500, - ]; + $status = $response->getStatusCode(); + $error = $response->getError(); + if ($status === 404 && self::isMissingBackupOnNodeError($error)) { + $missingOnNode = true; + $app->getLogger()->warning('FIFO eviction found stale backup record ' . $victim['uuid'] . ' for server ' . $serverUuid . ': ' . $error); + } else { + return [ + 'message' => 'Failed to remove oldest backup for rotation: ' . $error, + 'code' => 'FIFO_EVICTION_FAILED', + 'status' => $status >= 400 ? $status : 500, + ]; + } } if (!Backup::deleteBackup((int) $victim['id'])) { + if ($missingOnNode) { + $app->getLogger()->error('FIFO eviction: stale backup ' . $victim['uuid'] . ' was missing on Wings but DB soft-delete failed'); + + return [ + 'message' => 'Backup was already missing on node but failed to update backup record', + 'code' => 'FIFO_EVICTION_DB_FAILED', + 'status' => 500, + ]; + } + $app->getLogger()->error('FIFO eviction: Wings deleted backup ' . $victim['uuid'] . ' but DB soft-delete failed'); return [ @@ -162,6 +180,12 @@ public static function evictOldestWingsBackup(int $serverId, string $serverUuid, ]; } + if ($missingOnNode) { + $app->getLogger()->info('FIFO backup rotation cleared stale Wings backup record ' . $victim['uuid'] . ' for server ' . $serverUuid); + + return null; + } + $app->getLogger()->info('FIFO backup rotation evicted Wings backup ' . $victim['uuid'] . ' for server ' . $serverUuid); return null; @@ -232,4 +256,14 @@ public static function evictOldestVmBackup(array $instance, Proxmox $client): ?a return null; } + + private static function isMissingBackupOnNodeError(string $error): bool + { + $normalized = strtolower($error); + + return str_contains($normalized, 'requested backup was not found') + || str_contains($normalized, 'backup was not found') + || str_contains($normalized, 'backup not found') + || str_contains($normalized, 'no such backup'); + } } diff --git a/backend/app/Services/Chatbot/ChatbotRuntime.php b/backend/app/Services/Chatbot/ChatbotRuntime.php new file mode 100644 index 000000000..89f17c471 --- /dev/null +++ b/backend/app/Services/Chatbot/ChatbotRuntime.php @@ -0,0 +1,97 @@ +. + */ + +namespace App\Services\Chatbot; + +class ChatbotRuntime +{ + public static function emit(?callable $emit, string $type, array $payload = []): void + { + if ($emit === null) { + return; + } + + $emit($type, $payload); + } + + public static function toolActivity(array $toolCall, array $toolResult, int $iteration): array + { + return [ + 'tool' => $toolCall['tool'] ?? 'unknown', + 'params' => self::sanitizeValue($toolCall['params'] ?? []), + 'success' => (bool) ($toolResult['success'] ?? false), + 'error' => $toolResult['error'] ?? null, + 'summary' => self::summarizeResult($toolResult), + 'iteration' => $iteration, + ]; + } + + public static function summarizeResult(array $toolResult): string + { + if (!($toolResult['success'] ?? false)) { + return (string) ($toolResult['error'] ?? 'Tool failed.'); + } + + $data = $toolResult['data'] ?? null; + if (is_array($data)) { + if (isset($data['message'])) { + return self::truncate((string) $data['message'], 240); + } + + if (isset($data['success'])) { + return ((bool) $data['success']) ? 'Tool completed successfully.' : 'Tool returned an unsuccessful result.'; + } + + return self::truncate(json_encode(self::sanitizeValue($data), JSON_UNESCAPED_SLASHES) ?: 'Tool completed.', 240); + } + + return self::truncate((string) $data, 240); + } + + public static function sanitizeValue(mixed $value): mixed + { + if (is_array($value)) { + $sanitized = []; + foreach ($value as $key => $item) { + $keyString = (string) $key; + if (preg_match('/password|token|secret|key|credential/i', $keyString)) { + $sanitized[$key] = '[redacted]'; + continue; + } + + $sanitized[$key] = self::sanitizeValue($item); + } + + return $sanitized; + } + + if (is_string($value)) { + return self::truncate($value, 500); + } + + return $value; + } + + public static function truncate(string $value, int $limit): string + { + if (strlen($value) <= $limit) { + return $value; + } + + return rtrim(substr($value, 0, $limit - 3)) . '...'; + } +} diff --git a/backend/app/Services/Chatbot/ChatbotService.php b/backend/app/Services/Chatbot/ChatbotService.php index bb1c0609a..9bc071b67 100755 --- a/backend/app/Services/Chatbot/ChatbotService.php +++ b/backend/app/Services/Chatbot/ChatbotService.php @@ -53,7 +53,7 @@ public function __construct() * * @return array Response with 'response' and 'model' keys */ - public function processMessage(string $message, array $history, array $user, array $pageContext = []): array + public function processMessage(string $message, array $history, array $user, array $pageContext = [], ?callable $emit = null): array { // Check if chatbot is enabled $enabled = $this->config->getSetting(ConfigInterface::CHATBOT_ENABLED, 'true'); @@ -69,22 +69,30 @@ public function processMessage(string $message, array $history, array $user, arr // Get chatbot configuration $temperature = (float) $this->config->getSetting(ConfigInterface::CHATBOT_TEMPERATURE, '0.7'); $maxTokens = (int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_TOKENS, '2048'); - $maxHistory = (int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_HISTORY, '10'); + $maxHistory = min((int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_HISTORY, '4'), 6); + + ChatbotRuntime::emit($emit, 'status', ['message' => 'Preparing chat context']); // Limit history to configured max $history = array_slice($history, -$maxHistory); + $isDashboardMode = $this->isDashboardMode($pageContext); + // Build comprehensive system prompt - $contextBuilder = new ContextBuilder(); + $contextBuilder = $isDashboardMode ? new DashboardContextBuilder() : new ContextBuilder(); // Load base system prompt from file - $baseSystemPrompt = ContextBuilder::loadSystemPrompt(); + $baseSystemPrompt = $isDashboardMode + ? DashboardContextBuilder::loadSystemPrompt() + : ContextBuilder::loadSystemPrompt(); // Get admin-configured system prompt (optional override) $adminSystemPrompt = $this->config->getSetting(ConfigInterface::CHATBOT_SYSTEM_PROMPT, ''); - // Build user context (servers, info, current page) - $userContext = $contextBuilder->buildContext($user, $pageContext); + // Build user context. Dashboard mode is compact and never injects logs by default. + $userContext = $isDashboardMode + ? $contextBuilder->buildContext($user, $pageContext) + : $contextBuilder->buildContext($user, $pageContext, $this->shouldIncludeLogs($message)); // Get conversation memory if available $conversationMemory = $pageContext['conversation_memory'] ?? ''; @@ -98,7 +106,8 @@ public function processMessage(string $message, array $history, array $user, arr // Add conversation memory if available if (!empty($conversationMemory)) { - $systemPrompt .= "\n\n## Conversation Memory\n{$conversationMemory}"; + $systemPrompt .= "\n\n## Compact Conversation Memory\n{$conversationMemory}"; + $systemPrompt .= "\n\nUse this compact memory only as background. Never infer resource IDs, current state, or tool parameters from older summary text when the current dashboard/server context or tools disagree."; } // Get admin-configured user prompt (optional) @@ -106,6 +115,9 @@ public function processMessage(string $message, array $history, array $user, arr // Prepend user prompt to message if configured $fullMessage = $message; + $fullMessage .= "\n\n[Current Turn Protocol: Treat this latest user message as the primary task. Use older conversation only as background. Do not bring up previous actions, previous questions, or unfinished older topics unless this latest message explicitly asks about them or clearly depends on them.]"; + $fullMessage .= "\n\n[Response Language Protocol: Reply in the same natural language as this latest user message. If this message is English, reply in English only, regardless of older conversation history. If earlier replies used the wrong language, acknowledge that plainly instead of denying it.]"; + $fullMessage .= "\n\n[Action Authorization Protocol: Only perform, claim, or emit ACTION/TOOL_CALL for destructive or state-changing actions when this latest user message explicitly requests that exact action. If the latest message asks to check, inspect, show status, diagnose, or look at a server, fetch/read status only. Do not restart, stop, kill, start, delete, write, or send console commands based on older conversation memory or summaries.]"; if (!empty($userPrompt)) { $fullMessage = "{$fullMessage}\n\n[User Context: {$userPrompt}]"; } @@ -140,28 +152,80 @@ public function processMessage(string $message, array $history, array $user, arr } // Initialize tool handler - $toolHandler = new ToolHandler(); + $toolHandler = new ToolHandler($isDashboardMode); // Add tool information to system prompt $toolsInfo = $this->formatToolsForPrompt($toolHandler); $systemPrompt .= "\n\n## Available Tools\n{$toolsInfo}"; - // Process message with tool calling support (max 3 iterations to avoid loops) - $maxToolIterations = 3; + // Process message with tool calling support. + $maxToolIterations = 5; $toolIterations = 0; $currentMessage = $fullMessage; $currentHistory = $history; $finalResponse = ''; $toolExecutions = []; // Store tool execution results for frontend + $toolActivity = []; + $usageItems = []; + $result = ['response' => '', 'model' => 'FeatherPanel AI']; + $requiredTool = $this->detectRequiredTool($message, $history); + $allowedSingleExecutionTools = $this->detectAllowedSingleExecutionTools($message, $history); + $lastToolResultsText = ''; + $toolOutcomeFallbacks = []; + $completedSingleExecutionTools = []; while ($toolIterations < $maxToolIterations) { // Process message through provider + ChatbotRuntime::emit($emit, 'status', [ + 'message' => $toolIterations === 0 ? 'Calling AI model' : 'Calling AI model with tool results', + 'iteration' => $toolIterations + 1, + ]); $result = $providerInstance->processMessage($currentMessage, $currentHistory, $systemPrompt); $response = $result['response']; + if (isset($result['usage']) && is_array($result['usage'])) { + $usageItems[] = $result['usage']; + ChatbotRuntime::emit($emit, 'usage', ['usage' => TokenUsage::aggregate($usageItems)]); + } // Check for tool calls + ChatbotRuntime::emit($emit, 'status', ['message' => 'Checking for tool calls']); $toolCalls = $toolHandler->parseToolCalls($response); + if (empty($toolCalls) && $toolHandler->hasMalformedToolCall($response)) { + $currentHistory[] = [ + 'role' => 'assistant', + 'content' => $toolHandler->removeToolCalls($response), + ]; + $currentMessage = $this->buildMalformedToolCorrection($toolHandler); + $currentHistory[] = [ + 'role' => 'user', + 'content' => $currentMessage, + ]; + ++$toolIterations; + $finalResponse = $toolHandler->removeToolCalls($response); + continue; + } + + if ( + empty($toolCalls) + && $requiredTool !== null + && $toolIterations < $maxToolIterations - 1 + && !$this->responseClaimsRequiredToolCompleted($response, $requiredTool) + ) { + $currentHistory[] = [ + 'role' => 'assistant', + 'content' => $toolHandler->removeToolCalls($response), + ]; + $currentMessage = $this->buildRequiredToolCorrection($requiredTool, $history, $pageContext); + $currentHistory[] = [ + 'role' => 'user', + 'content' => $currentMessage, + ]; + ++$toolIterations; + $finalResponse = $toolHandler->removeToolCalls($response); + continue; + } + if (empty($toolCalls)) { // No tool calls, return final response $finalResponse = $toolHandler->removeToolCalls($response); @@ -171,6 +235,27 @@ public function processMessage(string $message, array $history, array $user, arr // Execute tool calls $toolResults = []; foreach ($toolCalls as $toolCall) { + if (!$this->isToolAllowedForLatestIntent($toolCall, $allowedSingleExecutionTools)) { + $toolResults[] = [ + 'tool' => $toolCall['tool'], + 'result' => "Skipped {$toolCall['tool']} because it does not match the latest user request. Do not use tools from older conversation context; answer using only the tools that were actually run for this message.", + ]; + continue; + } + + if ($this->shouldSkipDuplicateToolCall($toolCall, $completedSingleExecutionTools)) { + $toolResults[] = [ + 'tool' => $toolCall['tool'], + 'result' => "Skipped duplicate {$toolCall['tool']} call because that action already completed during this message. Do not call it again; summarize the completed result.", + ]; + continue; + } + + ChatbotRuntime::emit($emit, 'tool_call', [ + 'tool' => $toolCall['tool'], + 'params' => ChatbotRuntime::sanitizeValue($toolCall['params']), + 'iteration' => $toolIterations + 1, + ]); $toolResult = $toolHandler->executeTool( $toolCall['tool'], $toolCall['params'], @@ -182,10 +267,24 @@ public function processMessage(string $message, array $history, array $user, arr if (is_array($toolResult['data']) && isset($toolResult['data']['action_type'])) { $toolExecutions[] = $toolResult['data']; } + $activity = ChatbotRuntime::toolActivity($toolCall, $toolResult, $toolIterations + 1); + $toolActivity[] = $activity; + ChatbotRuntime::emit($emit, 'tool_result', $activity); + + $formattedToolResult = $toolHandler->formatToolResult($toolCall['tool'], $toolResult); + if ($this->shouldGuaranteeToolOutcome($toolCall['tool'], $toolResult)) { + $toolOutcomeFallbacks[] = [ + 'tool' => $toolCall['tool'], + 'summary' => $formattedToolResult, + ]; + } + if (($toolResult['success'] ?? false) && $this->isSingleExecutionTool($toolCall['tool'])) { + $completedSingleExecutionTools[$toolCall['tool']] = true; + } $toolResults[] = [ 'tool' => $toolCall['tool'], - 'result' => $toolHandler->formatToolResult($toolCall['tool'], $toolResult), + 'result' => $formattedToolResult, ]; } @@ -197,10 +296,12 @@ public function processMessage(string $message, array $history, array $user, arr $toolResultsText .= "\nCRITICAL INSTRUCTIONS:\n"; $toolResultsText .= "- You MUST provide clear, specific feedback to the user about what happened\n"; $toolResultsText .= "- If an action succeeded, confirm what was done with specific details (e.g., 'I've created a backup named [backup_name] for server [server_name]')\n"; + $toolResultsText .= "- If create_database succeeded, include the database name, username, password, host, port, and type in the final answer\n"; $toolResultsText .= "- Include relevant information from the tool results (names, IDs, timestamps, etc.)\n"; $toolResultsText .= "- If an action failed, explain the error clearly\n"; $toolResultsText .= "- Never just say 'I'll do that' or 'done' without explaining what actually happened\n"; $toolResultsText .= '- Be conversational and helpful - the user wants to know what you did for them'; + $lastToolResultsText = $toolResultsText; // Remove tool calls from response and add to history $cleanResponse = $toolHandler->removeToolCalls($response); @@ -220,18 +321,37 @@ public function processMessage(string $message, array $history, array $user, arr $finalResponse = $cleanResponse; // Store in case we hit max iterations } - // If we still have tool calls after max iterations, append a note + // If the last model turn still tried to call a tool, force one final synthesis pass + // so successful tool output is translated into a clean answer for the user. if ($toolIterations >= $maxToolIterations) { - $remainingCalls = $toolHandler->parseToolCalls($response); + $remainingCalls = $toolHandler->parseToolCalls($result['response'] ?? ''); if (!empty($remainingCalls)) { - $finalResponse .= "\n\n[Note: Maximum tool call iterations reached. Some tools may not have been executed.]"; + if ($lastToolResultsText !== '') { + ChatbotRuntime::emit($emit, 'status', ['message' => 'Preparing final tool result summary']); + $finalSystemPrompt = $systemPrompt . "\n\n## Final Tool Result Synthesis\n" + . 'Do not call any tools in this pass. Use only the supplied tool results and write the final user-facing answer.'; + $finalMessage = $lastToolResultsText . "\n\nNo more tools may be called. Summarize the completed tool results for the user now."; + $synthesisResult = $providerInstance->processMessage($finalMessage, $currentHistory, $finalSystemPrompt); + if (isset($synthesisResult['usage']) && is_array($synthesisResult['usage'])) { + $usageItems[] = $synthesisResult['usage']; + ChatbotRuntime::emit($emit, 'usage', ['usage' => TokenUsage::aggregate($usageItems)]); + } + $finalResponse = $toolHandler->removeToolCalls($synthesisResult['response'] ?? $finalResponse); + $result = $synthesisResult + $result; + } else { + $finalResponse .= "\n\n[Note: Maximum tool call iterations reached. Some tools may not have been executed.]"; + } } } + $finalResponse = $this->appendMissingToolOutcomes($finalResponse, $toolOutcomeFallbacks); + return [ 'response' => trim($finalResponse), 'model' => $result['model'] ?? 'FeatherPanel AI', 'tool_executions' => $toolExecutions, // Include tool execution results for frontend + 'tool_activity' => $toolActivity, + 'usage' => TokenUsage::aggregate($usageItems), ]; } @@ -245,30 +365,317 @@ public function processMessage(string $message, array $history, array $user, arr private function formatToolsForPrompt(ToolHandler $toolHandler): string { $tools = $toolHandler->getAvailableTools(); - $text = "You have access to the following tools to retrieve real-time data and perform actions:\n\n"; + $text = "Use TOOL_CALL only when real-time data or an action is needed. Available tools:\n\n"; foreach ($tools as $tool) { - $text .= "### {$tool['name']}\n"; - $text .= "Description: {$tool['description']}\n"; - $text .= "Parameters:\n"; - foreach ($tool['parameters'] as $param => $desc) { - $text .= " - {$param}: {$desc}\n"; + $parameters = []; + foreach ($tool['parameters'] as $param => $description) { + $parameters[] = "{$param}: {$description}"; } - $text .= "\n"; - $text .= "To use this tool, include in your response:\n"; - $text .= "TOOL_CALL: {$tool['name']} {\"param1\": \"value1\", \"param2\": \"value2\"}\n\n"; + $text .= "- {$tool['name']}(" . implode('; ', $parameters) . ")\n"; } - $text .= "IMPORTANT:\n"; - $text .= "- Use tools when you need real-time data (e.g., server activities, database credentials)\n"; - $text .= "- Do NOT include all data in your initial response - use tools to fetch what's needed\n"; - $text .= "- You can call multiple tools in one response\n"; - $text .= "- Tool results will be provided in your next context\n"; - $text .= "- Always provide a natural language response along with tool calls\n"; + $text .= "\nFormat: TOOL_CALL: tool_name {\"param\": \"value\"}\n"; + $text .= "TOOL_CALL always requires a valid JSON object after the tool name. Never write TOOL_CALL for navigation.\n"; + $text .= "For navigation, use ACTION: navigate server [uuid_or_name] to [page].\n"; + $text .= "Tool selection protocol: if a tool exists for the user's latest request, use that tool instead of navigating or describing the page. Examples: create database -> create_database, create backup -> create_backup, create schedule -> create_schedule, list status -> get_server_status, list databases/credentials -> get_database_credentials.\n"; + $text .= "You may call multiple tools. Always include a short natural language response with tool calls.\n"; return $text; } + private function buildMalformedToolCorrection(ToolHandler $toolHandler): string + { + $toolNames = implode(', ', array_keys($toolHandler->getAvailableTools())); + + return "Your previous response contained invalid TOOL_CALL syntax. Regenerate the answer now.\n" + . "Rules:\n" + . "- TOOL_CALL format is exactly: TOOL_CALL: tool_name {\"param\":\"value\"}\n" + . "- TOOL_CALL must use one of these tool names: {$toolNames}\n" + . "- Never use TOOL_CALL for navigation. Navigation uses ACTION: navigate server [uuid_or_name] to [page]\n" + . "- If a tool exists for the latest user request, call it with valid JSON parameters.\n" + . '- Do not expose TOOL_CALL text unless it is valid and intended for execution.'; + } + + private function detectRequiredTool(string $message, array $history): ?string + { + $message = mb_strtolower($message); + $hasRecentDatabaseContext = $this->hasRecentDatabaseContext($history); + + if ( + preg_match('/\b(delete|remove|drop)\b.*\b(database|db)\b/u', $message) + || ( + $hasRecentDatabaseContext + && preg_match('/\b(delete|remove|drop)\s+(it|that|this|one)\b/u', $message) + ) + ) { + return 'delete_database'; + } + + if ( + preg_match('/\b(create|make|add|new|setup|set\s+up)\b.*\b(database|db)\b/u', $message) + || ( + $hasRecentDatabaseContext + && preg_match('/\b(create|make|add|setup|set\s+up)\b.*\b(one|it|that|this)\b/u', $message) + ) + ) { + return 'create_database'; + } + + if ( + preg_match('/\b(update|change|modify)\b.*\b(database|db)\b/u', $message) + || ( + $hasRecentDatabaseContext + && preg_match('/\b(update|change|modify)\s+(it|that|this|one)\b/u', $message) + ) + ) { + return 'update_database'; + } + + return null; + } + + private function hasRecentDatabaseContext(array $history): bool + { + $recentHistory = array_slice($history, -6); + $historyText = mb_strtolower(json_encode($recentHistory, JSON_UNESCAPED_SLASHES) ?: ''); + + return (bool) preg_match('/\b(databases?|db|database_credentials|create_database|delete_database|update_database)\b/u', $historyText); + } + + private function responseClaimsRequiredToolCompleted(string $response, string $tool): bool + { + if (str_contains($response, "TOOL_CALL: {$tool}")) { + return true; + } + + return false; + } + + private function shouldGuaranteeToolOutcome(string $toolName, array $toolResult): bool + { + if (!($toolResult['success'] ?? false)) { + return true; + } + + $data = $toolResult['data'] ?? null; + if (!is_array($data)) { + return false; + } + + if ($toolName === 'create_database' && (($data['success'] ?? false) === true)) { + return true; + } + + return isset($data['action_type']); + } + + private function shouldSkipDuplicateToolCall(array $toolCall, array $completedSingleExecutionTools): bool + { + $toolName = (string) ($toolCall['tool'] ?? ''); + + return $this->isSingleExecutionTool($toolName) && isset($completedSingleExecutionTools[$toolName]); + } + + private function isToolAllowedForLatestIntent(array $toolCall, array $allowedSingleExecutionTools): bool + { + $toolName = (string) ($toolCall['tool'] ?? ''); + + if (!$this->isSingleExecutionTool($toolName) || empty($allowedSingleExecutionTools)) { + return true; + } + + return in_array($toolName, $allowedSingleExecutionTools, true); + } + + private function detectAllowedSingleExecutionTools(string $message, array $history): array + { + $message = mb_strtolower($message); + $historyText = mb_strtolower(json_encode(array_slice($history, -6), JSON_UNESCAPED_SLASHES) ?: ''); + $isCreate = (bool) preg_match('/\b(create|make|add|new|setup|set\s+up)\b/u', $message); + $isDelete = (bool) preg_match('/\b(delete|remove|drop|destroy)\b/u', $message); + $isUpdate = (bool) preg_match('/\b(update|change|modify|edit|rename)\b/u', $message); + + if (!$isCreate && !$isDelete && !$isUpdate) { + return []; + } + + $hasContext = static function (string $pattern) use ($message, $historyText): bool { + return (bool) preg_match($pattern, $message) || (bool) preg_match($pattern, $historyText); + }; + + if ($hasContext('/\b(databases?|db|database_credentials|create_database|delete_database|update_database)\b/u')) { + return $isDelete ? ['delete_database'] : ($isUpdate ? ['update_database'] : ['create_database']); + } + + if ($hasContext('/\b(backups?|create_backup|delete_backup)\b/u')) { + return $isDelete ? ['delete_backup'] : ['create_backup']; + } + + if ($hasContext('/\b(schedules?|tasks?|create_schedule|delete_schedule|update_schedule|create_task|delete_task|update_task)\b/u')) { + if ($isDelete) { + return ['delete_schedule', 'delete_task']; + } + + return $isUpdate ? ['update_schedule', 'update_task'] : ['create_schedule', 'create_task']; + } + + if ($hasContext('/\b(subdomains?|domains?|create_subdomain|delete_subdomain)\b/u')) { + return $isDelete ? ['delete_subdomain'] : ['create_subdomain']; + } + + if ($hasContext('/\b(allocations?|ports?|delete_allocation|set_primary_allocation|auto_allocate)\b/u')) { + return $isDelete ? ['delete_allocation'] : ['set_primary_allocation', 'auto_allocate']; + } + + if ($hasContext('/\b(files?|folders?|directories?|write_file|delete_files|rename_file|copy_files|compress_files|decompress_archive|pull_file)\b/u')) { + if ($isDelete) { + return ['delete_files']; + } + + return ['write_file', 'create_directory', 'rename_file', 'copy_files', 'compress_files', 'decompress_archive', 'pull_file']; + } + + return []; + } + + private function isSingleExecutionTool(string $toolName): bool + { + return in_array($toolName, [ + 'create_database', + 'delete_database', + 'update_database', + 'create_backup', + 'delete_backup', + 'server_power_action', + 'create_schedule', + 'delete_schedule', + 'update_schedule', + 'create_subdomain', + 'delete_subdomain', + 'delete_allocation', + 'set_primary_allocation', + 'auto_allocate', + 'write_file', + 'create_directory', + 'delete_files', + 'rename_file', + 'copy_files', + 'compress_files', + 'decompress_archive', + 'pull_file', + ], true); + } + + private function appendMissingToolOutcomes(string $response, array $fallbacks): string + { + $response = trim($response); + + foreach ($fallbacks as $fallback) { + $summary = trim((string) ($fallback['summary'] ?? '')); + if ($summary === '') { + continue; + } + + if ($this->summaryContainsDatabaseCredentials($summary) && !$this->responseCoversToolOutcome($response, $summary)) { + $response = "The database was created successfully. Here are the actual credentials returned by the tool:\n\n" . $summary; + continue; + } + + if ($this->responseCoversToolOutcome($response, $summary)) { + continue; + } + + $summary = ChatbotRuntime::truncate($summary, 2500); + $response = $response === '' ? $summary : $response . "\n\n" . $summary; + } + + return $response; + } + + private function responseCoversToolOutcome(string $response, string $summary): bool + { + if ($response === '') { + return false; + } + + $outcomeValues = $this->extractOutcomeValues($summary); + foreach ($outcomeValues as $value) { + if (mb_strlen($value) >= 3 && str_contains(mb_strtolower($response), mb_strtolower($value))) { + return true; + } + } + + if (!empty($outcomeValues)) { + return false; + } + + $normalizedResponse = mb_strtolower($response); + if (preg_match('/\b(successfully|completed|created|deleted|updated|started|stopped|restarted|renamed|copied|compressed|extracted|written|initiated)\b/u', $normalizedResponse)) { + return true; + } + + return false; + } + + private function summaryContainsDatabaseCredentials(string $text): bool + { + return (bool) preg_match('/\bDatabase Name:\s*\S+.*\bUsername:\s*\S+.*\bPassword:\s*\S+/is', $text); + } + + private function extractOutcomeValues(string $summary): array + { + preg_match_all('/^[A-Za-z][A-Za-z ]+:\s*(.+)$/m', $summary, $matches); + + return array_values(array_filter(array_map(static function (string $value): string { + return trim($value); + }, $matches[1] ?? []))); + } + + private function buildRequiredToolCorrection(string $tool, array $history, array $pageContext): string + { + $historyText = substr(json_encode(array_slice($history, -6), JSON_UNESCAPED_SLASHES) ?: '', -6000); + $contextText = substr(json_encode($pageContext, JSON_UNESCAPED_SLASHES) ?: '', -2000); + + return "The latest user message requires the {$tool} tool, but your previous answer did not call it.\n" + . "Regenerate the answer now and call the required tool using valid JSON.\n" + . "Use recent history/tool results to resolve references like 'it', 'that database', or a friendly name such as 'hide and seek'.\n" + . "Recent history: {$historyText}\n" + . "Current page context: {$contextText}\n" + . "Required format: TOOL_CALL: {$tool} {\"server_uuid\":\"...\",\"database_name\":\"...\"}\n" + . 'If the exact database name is known from history, pass it. If only a friendly name is known, pass that as database_name so the tool can fuzzy-match it.'; + } + + /** + * Decide whether the latest user message needs server logs in context. + */ + private function shouldIncludeLogs(string $message): bool + { + $message = mb_strtolower($message); + + return (bool) preg_match( + '/\b(logs?|console|error|errors|crash(?:ed|es|ing)?|exception|stacktrace|traceback|failed?|failure|debug|diagnos(?:e|is)|troubleshoot|issue|problem|broken|boot|startup|start(?:ing)?|won[’\']?t\s+(?:start|boot)|not\s+(?:start(?:ing)?|boot(?:ing)?|working))\b/u', + $message + ); + } + + private function isDashboardMode(array $pageContext): bool + { + if (($pageContext['mode'] ?? '') !== 'dashboard') { + return false; + } + + $route = (string) ($pageContext['route'] ?? $pageContext['page'] ?? ''); + $allowedRoutes = [ + '/dashboard', + '/dashboard/servers', + '/dashboard/vms', + '/dashboard/knowledgebase', + ]; + + return in_array($route, $allowedRoutes, true) || str_starts_with($route, '/dashboard/knowledgebase/'); + } + /** * Get the appropriate provider instance based on configuration. * diff --git a/backend/app/Services/Chatbot/ContextBuilder.php b/backend/app/Services/Chatbot/ContextBuilder.php index bbf8b63c8..8a6ea0fce 100755 --- a/backend/app/Services/Chatbot/ContextBuilder.php +++ b/backend/app/Services/Chatbot/ContextBuilder.php @@ -43,10 +43,11 @@ public function __construct() * * @param array $user Current user data * @param array $pageContext Current page context (route, server, etc.) + * @param bool $includeLogs Whether server logs should be included in context * * @return string Formatted context string */ - public function buildContext(array $user, array $pageContext = []): string + public function buildContext(array $user, array $pageContext = [], bool $includeLogs = false): string { $context = []; @@ -67,10 +68,6 @@ public function buildContext(array $user, array $pageContext = []): string $context[] = "User UUID: {$user['uuid']}"; $context[] = "User ID: {$user['id']}"; - if (isset($user['email'])) { - $context[] = "Email: {$user['email']}"; - } - if (isset($user['first_name']) || isset($user['last_name'])) { $name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')); if (!empty($name)) { @@ -78,18 +75,6 @@ public function buildContext(array $user, array $pageContext = []): string } } - if (isset($user['avatar']) && !empty($user['avatar'])) { - $context[] = "Avatar: {$user['avatar']}"; - } - - if (isset($user['two_fa_enabled'])) { - $context[] = '2FA Enabled: ' . ($user['two_fa_enabled'] === 'true' ? 'Yes' : 'No'); - } - - if (isset($user['last_seen'])) { - $context[] = "Last Seen: {$user['last_seen']}"; - } - // User Role/Permissions if ($isAdmin) { $context[] = 'Role: Administrator (Full Access)'; @@ -120,78 +105,12 @@ public function buildContext(array $user, array $pageContext = []): string $context[] = "- UUID: {$server['uuidShort']}"; $context[] = '- Status: ' . ($server['status'] ?? 'unknown'); - if (isset($server['description']) && !empty($server['description'])) { - $context[] = "- Description: {$server['description']}"; - } - - // Only include node/realm/spell info if user is admin or has access - if ($isAdmin || isset($server['node']['name'])) { - if (isset($server['node']['name'])) { - $context[] = "- Node: {$server['node']['name']}"; - } - } - - if (isset($server['realm']['name'])) { - $context[] = "- Realm: {$server['realm']['name']}"; - } - if (isset($server['spell']['name'])) { $context[] = "- Spell/Type: {$server['spell']['name']}"; } - // Server Resource Limits - if (isset($server['memory'])) { - $memoryMB = (int) $server['memory']; - $memoryGB = round($memoryMB / 1024, 2); - $context[] = "- Memory Limit: {$memoryMB} MB ({$memoryGB} GB)"; - } - - if (isset($server['swap'])) { - $swapMB = (int) $server['swap']; - $swapGB = round($swapMB / 1024, 2); - $context[] = "- Swap Limit: {$swapMB} MB ({$swapGB} GB)"; - } - - if (isset($server['disk'])) { - $diskMB = (int) $server['disk']; - $diskGB = round($diskMB / 1024, 2); - $context[] = "- Disk Limit: {$diskMB} MB ({$diskGB} GB)"; - } - - if (isset($server['cpu'])) { - $context[] = "- CPU Limit: {$server['cpu']}%"; - } - - if (isset($server['io'])) { - $context[] = "- IO Limit: {$server['io']}"; - } - - // Allocation Information (IP and Port) - if (isset($server['allocation'])) { - $allocation = $server['allocation']; - if (isset($allocation['ip'])) { - $ipInfo = $allocation['ip']; - if (isset($allocation['ip_alias']) && !empty($allocation['ip_alias'])) { - $ipInfo .= " (Alias: {$allocation['ip_alias']})"; - } - $context[] = "- IP Address: {$ipInfo}"; - } - if (isset($allocation['port'])) { - $context[] = "- Port: {$allocation['port']}"; - // Show connection info if both IP and port are available - if (isset($allocation['ip'])) { - $context[] = "- Connection: {$allocation['ip']}:{$allocation['port']}"; - } - } - } - if (isset($server['is_subuser']) && $server['is_subuser']) { $context[] = '- Access: Subuser (Limited Permissions)'; - // Only include specific permissions if user is admin - if ($isAdmin && isset($server['subuser_permissions']) && !empty($server['subuser_permissions'])) { - $perms = implode(', ', array_slice($server['subuser_permissions'], 0, 5)); // Limit to 5 - $context[] = "- Permissions: {$perms}"; - } } else { $context[] = '- Access: Owner (Full Control)'; } @@ -323,24 +242,16 @@ public function buildContext(array $user, array $pageContext = []): string $context[] = "Docker Image: {$serverData['image']}"; } - // Fetch server logs if: - // 1. Server is running or starting (to see current activity) - // 2. User is on logs page (to see logs regardless of status) - // 3. User is on console page (to see console output) - $shouldFetchLogs = in_array(strtolower($serverStatus), ['running', 'starting', 'stopping', 'stopped']); - $isOnLogsPage = isset($pageContext['page']) && in_array(strtolower($pageContext['page']), ['logs', 'console']); - - if ($shouldFetchLogs || $isOnLogsPage) { + if ($includeLogs) { $serverLogs = $this->getServerLogs($serverData); if (!empty($serverLogs)) { $context[] = ''; $context[] = '### Recent Server Logs'; - $context[] = 'The following are the most recent server logs (last 50 lines):'; + $context[] = 'The following are the most recent server logs (last 20 lines):'; $context[] = ''; $context[] = '```'; - // Limit to last 50 lines to avoid token limits - $logLines = is_array($serverLogs) ? array_slice($serverLogs, -50) : explode("\n", $serverLogs); - $logLines = array_slice($logLines, -50); + $logLines = is_array($serverLogs) ? array_slice($serverLogs, -20) : explode("\n", $serverLogs); + $logLines = array_slice($logLines, -20); $context[] = implode("\n", $logLines); $context[] = '```'; } @@ -409,8 +320,8 @@ private function getUserServers(int $userId): array // Combine and enrich with related data $allServers = array_merge($ownedServers, $subuserServers); - // Limit to 20 most recent to avoid token limits - $allServers = array_slice($allServers, 0, 20); + // Keep context compact; tools can fetch deeper data on demand. + $allServers = array_slice($allServers, 0, 8); foreach ($allServers as &$server) { // Check if subuser diff --git a/backend/app/Services/Chatbot/DashboardContextBuilder.php b/backend/app/Services/Chatbot/DashboardContextBuilder.php new file mode 100644 index 000000000..4a995c527 --- /dev/null +++ b/backend/app/Services/Chatbot/DashboardContextBuilder.php @@ -0,0 +1,176 @@ +. + */ + +namespace App\Services\Chatbot; + +use App\App; +use App\Chat\Spell; +use App\Chat\Server; +use App\Permissions; +use App\Chat\Subuser; +use App\Chat\VmInstance; +use App\Helpers\PermissionHelper; + +class DashboardContextBuilder +{ + private $app; + + public function __construct() + { + $this->app = App::getInstance(true); + } + + public function buildContext(array $user, array $pageContext = []): string + { + $context = []; + $userUuid = $user['uuid'] ?? ''; + $isAdmin = PermissionHelper::hasPermission($userUuid, Permissions::ADMIN_ROOT); + + $context[] = '## User Information'; + $context[] = 'Username: ' . ($user['username'] ?? 'unknown'); + $context[] = 'User UUID: ' . ($user['uuid'] ?? 'unknown'); + $context[] = 'User ID: ' . ($user['id'] ?? 'unknown'); + $context[] = $isAdmin ? 'Role: Administrator (Full Access)' : 'Role: User'; + + if (!empty($pageContext)) { + $context[] = ''; + $context[] = '## Current Dashboard Page'; + $context[] = 'Route: ' . ($pageContext['route'] ?? 'unknown'); + $context[] = 'Page: ' . ($pageContext['page'] ?? $pageContext['routeName'] ?? 'unknown'); + } + + $servers = $this->getUserServers((int) ($user['id'] ?? 0)); + $context[] = ''; + $context[] = "## User's Servers"; + if (empty($servers)) { + $context[] = 'No servers were found for this user.'; + } else { + $context[] = 'Showing up to 8 accessible servers. Use tools for details.'; + foreach ($servers as $index => $server) { + $context[] = sprintf( + '%d. %s (%s) - status: %s, type: %s, access: %s', + $index + 1, + $server['name'] ?? 'Unnamed server', + $server['uuidShort'] ?? $server['uuid'] ?? 'unknown', + $server['status'] ?? 'unknown', + $server['spell']['name'] ?? 'unknown', + !empty($server['is_subuser']) ? 'subuser' : 'owner' + ); + } + } + + $instances = $this->getUserVdsInstances($userUuid); + $context[] = ''; + $context[] = "## User's VDS Instances"; + if (empty($instances)) { + $context[] = 'No VDS instances were found for this user.'; + } else { + $context[] = 'Showing up to 5 accessible VDS instances. Use tools for details.'; + foreach ($instances as $index => $instance) { + $context[] = sprintf( + '%d. %s (ID: %s) - status: %s, type: %s', + $index + 1, + $instance['hostname'] ?? 'Unnamed VDS', + $instance['id'] ?? 'unknown', + $instance['status'] ?? 'unknown', + $instance['vm_type'] ?? 'unknown' + ); + } + } + + $context[] = ''; + $context[] = '## Context Limits'; + $context[] = 'Logs, files, credentials, backups, allocations, full resource specs, and knowledgebase articles are not included by default.'; + $context[] = 'Use tools only when the user asks for specific details or knowledgebase help.'; + + return implode("\n", $context); + } + + public static function loadSystemPrompt(): string + { + $promptFile = __DIR__ . '/dashboard-system-prompt.txt'; + + if (file_exists($promptFile)) { + $content = file_get_contents($promptFile); + + return trim($content); + } + + return 'You are FeatherPanel Dashboard AI. Help users understand and navigate their dashboard, servers, VDS instances, and knowledgebase while keeping answers concise and using tools for specific details.'; + } + + private function getUserServers(int $userId): array + { + if ($userId <= 0) { + return []; + } + + try { + $ownedServers = Server::searchServers(page: 1, limit: 8, search: '', ownerId: $userId); + $subusers = Subuser::getSubusersByUserId($userId); + $subuserMap = []; + $subuserServers = []; + + foreach ($subusers as $subuser) { + $serverId = (int) ($subuser['server_id'] ?? 0); + if ($serverId <= 0 || isset($subuserMap[$serverId])) { + continue; + } + + $subuserMap[$serverId] = $subuser; + $server = Server::getServerById($serverId); + if ($server) { + $subuserServers[] = $server; + } + } + + $servers = array_slice(array_merge($ownedServers, $subuserServers), 0, 8); + foreach ($servers as &$server) { + $serverId = (int) ($server['id'] ?? 0); + $server['is_subuser'] = isset($subuserMap[$serverId]); + $spell = isset($server['spell_id']) ? Spell::getSpellById((int) $server['spell_id']) : null; + $server['spell'] = [ + 'name' => $spell['name'] ?? null, + ]; + } + unset($server); + + return $servers; + } catch (\Exception $e) { + $this->app->getLogger()->error('DashboardContextBuilder: Failed to get servers: ' . $e->getMessage()); + + return []; + } + } + + private function getUserVdsInstances(string $userUuid): array + { + if ($userUuid === '') { + return []; + } + + try { + $instances = VmInstance::getByUserUuid($userUuid, 1, 5); + + return is_array($instances) ? $instances : []; + } catch (\Exception $e) { + $this->app->getLogger()->error('DashboardContextBuilder: Failed to get VDS instances: ' . $e->getMessage()); + + return []; + } + } +} diff --git a/backend/app/Services/Chatbot/Providers/BasicProvider.php b/backend/app/Services/Chatbot/Providers/BasicProvider.php index 8bae1a2ee..b2831372c 100755 --- a/backend/app/Services/Chatbot/Providers/BasicProvider.php +++ b/backend/app/Services/Chatbot/Providers/BasicProvider.php @@ -17,6 +17,8 @@ namespace App\Services\Chatbot\Providers; +use App\Services\Chatbot\TokenUsage; + class BasicProvider implements ProviderInterface { /** @@ -33,48 +35,42 @@ public function processMessage(string $message, array $history, string $systemPr $lowerMessage = strtolower($message); if (strpos($lowerMessage, 'hello') !== false || strpos($lowerMessage, 'hi') !== false) { - return [ - 'response' => "Hello! I'm your AI assistant for FeatherPanel. How can I help you today?", - 'model' => 'FeatherPanel AI', - ]; + return $this->response($message, "Hello! I'm your AI assistant for FeatherPanel. How can I help you today?"); } if (strpos($lowerMessage, 'help') !== false) { - return [ - 'response' => "I can help you with various FeatherPanel tasks:\n\n" . - "• Server management\n" . - "• Configuration questions\n" . - "• General panel information\n" . - "• Troubleshooting\n\n" . - 'What would you like to know?', - 'model' => 'FeatherPanel AI', - ]; + return $this->response($message, "I can help you with various FeatherPanel tasks:\n\n" . + "• Server management\n" . + "• Configuration questions\n" . + "• General panel information\n" . + "• Troubleshooting\n\n" . + 'What would you like to know?'); } if (strpos($lowerMessage, 'server') !== false) { - return [ - 'response' => "I can help you with server-related tasks. You can:\n\n" . - "• View server status\n" . - "• Manage server files\n" . - "• Control server power (start/stop/restart)\n" . - "• View server console\n" . - "• Manage databases\n\n" . - 'What specific server task do you need help with?', - 'model' => 'FeatherPanel AI', - ]; + return $this->response($message, "I can help you with server-related tasks. You can:\n\n" . + "• View server status\n" . + "• Manage server files\n" . + "• Control server power (start/stop/restart)\n" . + "• View server console\n" . + "• Manage databases\n\n" . + 'What specific server task do you need help with?'); } if (strpos($lowerMessage, 'thank') !== false) { - return [ - 'response' => "You're welcome! Is there anything else I can help you with?", - 'model' => 'FeatherPanel AI', - ]; + return $this->response($message, "You're welcome! Is there anything else I can help you with?"); } + return $this->response($message, "I understand you're asking about: " . $message . "\n\n" . + "I'm a basic assistant. For more advanced responses, please configure Google Gemini, OpenRouter, or OpenAI in admin settings."); + } + + private function response(string $input, string $output): array + { return [ - 'response' => "I understand you're asking about: " . $message . "\n\n" . - "I'm a basic assistant. For more advanced responses, please configure Google Gemini, OpenRouter, or OpenAI in admin settings.", + 'response' => $output, 'model' => 'FeatherPanel AI', + 'usage' => TokenUsage::estimate($input, $output), ]; } } diff --git a/backend/app/Services/Chatbot/Providers/GoogleGeminiProvider.php b/backend/app/Services/Chatbot/Providers/GoogleGeminiProvider.php index 89567e550..cccfd1b80 100755 --- a/backend/app/Services/Chatbot/Providers/GoogleGeminiProvider.php +++ b/backend/app/Services/Chatbot/Providers/GoogleGeminiProvider.php @@ -19,6 +19,7 @@ use App\App; use GuzzleHttp\Client; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class GoogleGeminiProvider implements ProviderInterface @@ -155,6 +156,7 @@ public function processMessage(string $message, array $history, string $systemPr return [ 'response' => $responseText, 'model' => "Google Gemini {$this->model}", + 'usage' => TokenUsage::fromGeminiUsage($data['usageMetadata'] ?? null, $message, $responseText), ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('Google Gemini API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/GrokProvider.php b/backend/app/Services/Chatbot/Providers/GrokProvider.php index 5290cbe16..2cee266eb 100755 --- a/backend/app/Services/Chatbot/Providers/GrokProvider.php +++ b/backend/app/Services/Chatbot/Providers/GrokProvider.php @@ -19,6 +19,7 @@ use App\App; use GuzzleHttp\Client; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class GrokProvider implements ProviderInterface @@ -132,6 +133,7 @@ public function processMessage(string $message, array $history, string $systemPr return [ 'response' => $responseText, 'model' => "Grok {$this->model}", + 'usage' => TokenUsage::fromOpenAiUsage($data['usage'] ?? null, $message, $responseText), ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('xAI (Grok) API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/OllamaProvider.php b/backend/app/Services/Chatbot/Providers/OllamaProvider.php index 7c8b2b656..0dde7503b 100755 --- a/backend/app/Services/Chatbot/Providers/OllamaProvider.php +++ b/backend/app/Services/Chatbot/Providers/OllamaProvider.php @@ -19,6 +19,7 @@ use App\App; use GuzzleHttp\Client; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class OllamaProvider implements ProviderInterface @@ -134,10 +135,21 @@ public function processMessage(string $message, array $history, string $systemPr } $responseText = $data['response']; + $usage = [ + 'input_tokens' => isset($data['prompt_eval_count']) ? (int) $data['prompt_eval_count'] : TokenUsage::estimateTextTokens($prompt), + 'output_tokens' => isset($data['eval_count']) ? (int) $data['eval_count'] : TokenUsage::estimateTextTokens($responseText), + 'source' => isset($data['prompt_eval_count'], $data['eval_count']) ? 'provider' : 'estimated', + 'raw' => [ + 'prompt_eval_count' => $data['prompt_eval_count'] ?? null, + 'eval_count' => $data['eval_count'] ?? null, + ], + ]; + $usage['total_tokens'] = $usage['input_tokens'] + $usage['output_tokens']; return [ 'response' => $responseText, 'model' => "Ollama {$this->model}", + 'usage' => $usage, ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('Ollama API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/OpenAIProvider.php b/backend/app/Services/Chatbot/Providers/OpenAIProvider.php index dc405ad1d..7f422293e 100755 --- a/backend/app/Services/Chatbot/Providers/OpenAIProvider.php +++ b/backend/app/Services/Chatbot/Providers/OpenAIProvider.php @@ -20,6 +20,7 @@ use App\App; use GuzzleHttp\Client; use App\Config\ConfigInterface; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class OpenAIProvider implements ProviderInterface @@ -138,6 +139,7 @@ public function processMessage(string $message, array $history, string $systemPr return [ 'response' => $responseText, 'model' => "OpenAI {$this->model}", + 'usage' => TokenUsage::fromOpenAiUsage($data['usage'] ?? null, $message, $responseText), ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('OpenAI API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/OpenRouterProvider.php b/backend/app/Services/Chatbot/Providers/OpenRouterProvider.php index 1b646d987..c570612d8 100755 --- a/backend/app/Services/Chatbot/Providers/OpenRouterProvider.php +++ b/backend/app/Services/Chatbot/Providers/OpenRouterProvider.php @@ -19,6 +19,7 @@ use App\App; use GuzzleHttp\Client; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class OpenRouterProvider implements ProviderInterface @@ -137,6 +138,7 @@ public function processMessage(string $message, array $history, string $systemPr return [ 'response' => $responseText, 'model' => "OpenRouter {$modelName}", + 'usage' => TokenUsage::fromOpenAiUsage($data['usage'] ?? null, $message, $responseText), ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('OpenRouter API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/PerplexityProvider.php b/backend/app/Services/Chatbot/Providers/PerplexityProvider.php index 0d0364c60..ff1ce9997 100755 --- a/backend/app/Services/Chatbot/Providers/PerplexityProvider.php +++ b/backend/app/Services/Chatbot/Providers/PerplexityProvider.php @@ -19,6 +19,7 @@ use App\App; use GuzzleHttp\Client; +use App\Services\Chatbot\TokenUsage; use GuzzleHttp\Exception\GuzzleException; class PerplexityProvider implements ProviderInterface @@ -135,6 +136,7 @@ public function processMessage(string $message, array $history, string $systemPr return [ 'response' => $responseText, 'model' => "Perplexity {$this->model}", + 'usage' => TokenUsage::fromOpenAiUsage($data['usage'] ?? null, $message, $responseText), ]; } catch (GuzzleException $e) { $this->app->getLogger()->error('Perplexity API exception: ' . $e->getMessage()); diff --git a/backend/app/Services/Chatbot/Providers/ProviderInterface.php b/backend/app/Services/Chatbot/Providers/ProviderInterface.php index 0874e9896..cb8030b86 100755 --- a/backend/app/Services/Chatbot/Providers/ProviderInterface.php +++ b/backend/app/Services/Chatbot/Providers/ProviderInterface.php @@ -29,7 +29,7 @@ interface ProviderInterface * @param array $history Chat history (array of ['role' => 'user'|'assistant', 'content' => string]) * @param string $systemPrompt Optional system prompt * - * @return array Response with 'response' and 'model' keys + * @return array Response with 'response', 'model', and optional 'usage' keys */ public function processMessage(string $message, array $history, string $systemPrompt = ''): array; } diff --git a/backend/app/Services/Chatbot/TokenUsage.php b/backend/app/Services/Chatbot/TokenUsage.php new file mode 100644 index 000000000..2de5591df --- /dev/null +++ b/backend/app/Services/Chatbot/TokenUsage.php @@ -0,0 +1,128 @@ +. + */ + +namespace App\Services\Chatbot; + +class TokenUsage +{ + public static function estimate(string $input = '', string $output = ''): array + { + $inputTokens = self::estimateTextTokens($input); + $outputTokens = self::estimateTextTokens($output); + + return [ + 'input_tokens' => $inputTokens, + 'output_tokens' => $outputTokens, + 'total_tokens' => $inputTokens + $outputTokens, + 'source' => 'estimated', + ]; + } + + public static function estimateTextTokens(string $text): int + { + $text = trim($text); + if ($text === '') { + return 0; + } + + // Roughly matches common tokenizer averages without adding a heavy tokenizer dependency. + return max(1, (int) ceil(mb_strlen($text) / 4)); + } + + public static function fromOpenAiUsage(?array $usage, string $input = '', string $output = ''): array + { + if (!$usage) { + return self::estimate($input, $output); + } + + $inputTokens = (int) ($usage['prompt_tokens'] ?? $usage['input_tokens'] ?? 0); + $outputTokens = (int) ($usage['completion_tokens'] ?? $usage['output_tokens'] ?? 0); + $totalTokens = (int) ($usage['total_tokens'] ?? ($inputTokens + $outputTokens)); + + return [ + 'input_tokens' => $inputTokens, + 'output_tokens' => $outputTokens, + 'total_tokens' => $totalTokens, + 'source' => 'provider', + 'raw' => self::compactRaw($usage), + ]; + } + + public static function fromGeminiUsage(?array $usage, string $input = '', string $output = ''): array + { + if (!$usage) { + return self::estimate($input, $output); + } + + $inputTokens = (int) ($usage['promptTokenCount'] ?? 0); + $outputTokens = (int) ($usage['candidatesTokenCount'] ?? 0); + $totalTokens = (int) ($usage['totalTokenCount'] ?? ($inputTokens + $outputTokens)); + + return [ + 'input_tokens' => $inputTokens, + 'output_tokens' => $outputTokens, + 'total_tokens' => $totalTokens, + 'source' => 'provider', + 'raw' => self::compactRaw($usage), + ]; + } + + public static function aggregate(array $items): array + { + $input = 0; + $output = 0; + $total = 0; + $source = 'unknown'; + $raw = []; + + foreach ($items as $usage) { + if (!is_array($usage)) { + continue; + } + + $input += (int) ($usage['input_tokens'] ?? 0); + $output += (int) ($usage['output_tokens'] ?? 0); + $total += (int) ($usage['total_tokens'] ?? 0); + if (($usage['source'] ?? '') === 'provider') { + $source = 'provider'; + } elseif ($source === 'unknown' && ($usage['source'] ?? '') === 'estimated') { + $source = 'estimated'; + } + if (isset($usage['raw'])) { + $raw[] = $usage['raw']; + } + } + + return [ + 'input_tokens' => $input, + 'output_tokens' => $output, + 'total_tokens' => $total > 0 ? $total : $input + $output, + 'source' => $source, + 'raw' => $raw, + ]; + } + + private static function compactRaw(array $raw): array + { + $encoded = json_encode($raw); + if ($encoded === false || strlen($encoded) <= 2000) { + return $raw; + } + + return ['truncated' => true]; + } +} diff --git a/backend/app/Services/Chatbot/Tools/CreateDatabaseTool.php b/backend/app/Services/Chatbot/Tools/CreateDatabaseTool.php index e23fc3f4e..a73f59079 100755 --- a/backend/app/Services/Chatbot/Tools/CreateDatabaseTool.php +++ b/backend/app/Services/Chatbot/Tools/CreateDatabaseTool.php @@ -142,6 +142,7 @@ public function execute(array $params, array $user, array $pageContext = []): mi if (!$databaseName) { $databaseName = 'db_' . time(); } + $databaseName = $this->normalizeDatabaseName((string) $databaseName, (int) $server['id']); // Generate full database name: s{server_id}_{database_name} $fullDatabaseName = 's' . $server['id'] . '_' . $databaseName; @@ -244,6 +245,17 @@ private function generateRandomString(int $length = 16): string return $randomString; } + private function normalizeDatabaseName(string $databaseName, int $serverId): string + { + $databaseName = trim($databaseName); + $databaseName = preg_replace('/^s' . preg_quote((string) $serverId, '/') . '_/i', '', $databaseName) ?? $databaseName; + $databaseName = preg_replace('/\s+/', '_', $databaseName) ?? $databaseName; + $databaseName = preg_replace('/[^a-zA-Z0-9_]/', '_', $databaseName) ?? $databaseName; + $databaseName = trim($databaseName, '_'); + + return $databaseName !== '' ? $databaseName : 'db_' . time(); + } + /** * Create database and user on the database host. */ diff --git a/backend/app/Services/Chatbot/Tools/DeleteDatabaseTool.php b/backend/app/Services/Chatbot/Tools/DeleteDatabaseTool.php index 7fd849c76..24ed4943d 100755 --- a/backend/app/Services/Chatbot/Tools/DeleteDatabaseTool.php +++ b/backend/app/Services/Chatbot/Tools/DeleteDatabaseTool.php @@ -102,8 +102,19 @@ public function execute(array $params, array $user, array $pageContext = []): mi } elseif ($databaseName) { // Get all databases for this server and find by name $databases = ServerDatabase::getServerDatabasesWithDetailsByServerId($server['id']); + $normalizedRequestedName = $this->normalizeDatabaseIdentifier((string) $databaseName); foreach ($databases as $db) { - if ($db['database'] === $databaseName || $db['username'] === $databaseName) { + $normalizedDatabaseName = $this->normalizeDatabaseIdentifier((string) $db['database']); + $normalizedUsername = $this->normalizeDatabaseIdentifier((string) $db['username']); + + if ( + $db['database'] === $databaseName + || $db['username'] === $databaseName + || $normalizedDatabaseName === $normalizedRequestedName + || $normalizedUsername === $normalizedRequestedName + || str_ends_with($normalizedDatabaseName, $normalizedRequestedName) + || str_contains($normalizedDatabaseName, $normalizedRequestedName) + ) { $database = $db; break; } @@ -212,4 +223,9 @@ public function getParameters(): array 'database_name' => 'Database name or username (required if database_id not provided)', ]; } + + private function normalizeDatabaseIdentifier(string $value): string + { + return preg_replace('/[^a-z0-9]+/', '', strtolower($value)) ?? ''; + } } diff --git a/backend/app/Services/Chatbot/Tools/GetKnowledgebaseArticleTool.php b/backend/app/Services/Chatbot/Tools/GetKnowledgebaseArticleTool.php new file mode 100644 index 000000000..9c57f602d --- /dev/null +++ b/backend/app/Services/Chatbot/Tools/GetKnowledgebaseArticleTool.php @@ -0,0 +1,104 @@ +. + */ + +namespace App\Services\Chatbot\Tools; + +use App\App; +use App\Config\ConfigInterface; +use App\Chat\KnowledgebaseArticle; +use App\Chat\KnowledgebaseCategory; + +class GetKnowledgebaseArticleTool implements ToolInterface +{ + public function execute(array $params, array $user, array $pageContext = []): mixed + { + if (!$this->isEnabled()) { + return [ + 'success' => false, + 'error' => 'Knowledgebase articles are disabled.', + ]; + } + + $articleId = isset($params['article_id']) ? (int) $params['article_id'] : 0; + if ($articleId <= 0) { + return [ + 'success' => false, + 'error' => 'article_id is required.', + ]; + } + + $article = KnowledgebaseArticle::getById($articleId); + if (!$article || ($article['status'] ?? '') !== 'published') { + return [ + 'success' => false, + 'error' => 'Published article not found.', + ]; + } + + $category = $this->areCategoriesEnabled() && !empty($article['category_id']) + ? KnowledgebaseCategory::getById((int) $article['category_id']) + : null; + + return [ + 'success' => true, + 'article' => [ + 'id' => (int) $article['id'], + 'title' => $article['title'] ?? 'Untitled', + 'slug' => $article['slug'] ?? null, + 'category' => $category['name'] ?? null, + 'content' => $this->truncate((string) ($article['content'] ?? ''), 6000), + ], + ]; + } + + public function getDescription(): string + { + return 'Fetch one published knowledgebase article by ID. Use after search when full article content is needed.'; + } + + public function getParameters(): array + { + return [ + 'article_id' => 'Knowledgebase article ID returned by search_knowledgebase.', + ]; + } + + private function isEnabled(): bool + { + $config = App::getInstance(true)->getConfig(); + + return $config->getSetting(ConfigInterface::KNOWLEDGEBASE_ENABLED, 'true') === 'true' + && $config->getSetting(ConfigInterface::KNOWLEDGEBASE_SHOW_ARTICLES, 'true') === 'true'; + } + + private function areCategoriesEnabled(): bool + { + $config = App::getInstance(true)->getConfig(); + + return $config->getSetting(ConfigInterface::KNOWLEDGEBASE_ENABLED, 'true') === 'true' + && $config->getSetting(ConfigInterface::KNOWLEDGEBASE_SHOW_CATEGORIES, 'true') === 'true'; + } + + private function truncate(string $text, int $limit): string + { + if (strlen($text) <= $limit) { + return $text; + } + + return rtrim(substr($text, 0, $limit - 3)) . '...'; + } +} diff --git a/backend/app/Services/Chatbot/Tools/ListKnowledgebaseCategoriesTool.php b/backend/app/Services/Chatbot/Tools/ListKnowledgebaseCategoriesTool.php new file mode 100644 index 000000000..7eb0217f3 --- /dev/null +++ b/backend/app/Services/Chatbot/Tools/ListKnowledgebaseCategoriesTool.php @@ -0,0 +1,70 @@ +. + */ + +namespace App\Services\Chatbot\Tools; + +use App\App; +use App\Config\ConfigInterface; +use App\Chat\KnowledgebaseCategory; + +class ListKnowledgebaseCategoriesTool implements ToolInterface +{ + public function execute(array $params, array $user, array $pageContext = []): mixed + { + if (!$this->isEnabled()) { + return [ + 'success' => false, + 'error' => 'Knowledgebase categories are disabled.', + ]; + } + + $limit = max(1, min(20, (int) ($params['limit'] ?? 10))); + $search = isset($params['query']) ? trim((string) $params['query']) : null; + $categories = KnowledgebaseCategory::getAll($search !== '' ? $search : null, $limit, 0); + + return [ + 'success' => true, + 'categories' => array_map(static fn (array $category): array => [ + 'id' => (int) $category['id'], + 'name' => $category['name'] ?? 'Untitled', + 'slug' => $category['slug'] ?? null, + 'description' => $category['description'] ?? null, + ], $categories), + ]; + } + + public function getDescription(): string + { + return 'List knowledgebase categories on demand. Use to narrow knowledgebase searches.'; + } + + public function getParameters(): array + { + return [ + 'query' => 'Optional category search text.', + 'limit' => 'Optional result limit, capped at 20.', + ]; + } + + private function isEnabled(): bool + { + $config = App::getInstance(true)->getConfig(); + + return $config->getSetting(ConfigInterface::KNOWLEDGEBASE_ENABLED, 'true') === 'true' + && $config->getSetting(ConfigInterface::KNOWLEDGEBASE_SHOW_CATEGORIES, 'true') === 'true'; + } +} diff --git a/backend/app/Services/Chatbot/Tools/SearchKnowledgebaseTool.php b/backend/app/Services/Chatbot/Tools/SearchKnowledgebaseTool.php new file mode 100644 index 000000000..603608e8f --- /dev/null +++ b/backend/app/Services/Chatbot/Tools/SearchKnowledgebaseTool.php @@ -0,0 +1,113 @@ +. + */ + +namespace App\Services\Chatbot\Tools; + +use App\App; +use App\Config\ConfigInterface; +use App\Chat\KnowledgebaseArticle; +use App\Chat\KnowledgebaseCategory; + +class SearchKnowledgebaseTool implements ToolInterface +{ + public function execute(array $params, array $user, array $pageContext = []): mixed + { + if (!$this->isEnabled(ConfigInterface::KNOWLEDGEBASE_SHOW_ARTICLES)) { + return [ + 'success' => false, + 'error' => 'Knowledgebase articles are disabled.', + ]; + } + + $query = trim((string) ($params['query'] ?? '')); + if ($query === '') { + return [ + 'success' => false, + 'error' => 'A query is required.', + ]; + } + + $limit = max(1, min(5, (int) ($params['limit'] ?? 5))); + $categoryId = isset($params['category_id']) ? (int) $params['category_id'] : null; + $articles = KnowledgebaseArticle::searchArticles(1, $limit, $query, $categoryId, 'published'); + + $results = []; + foreach ($articles as $article) { + $category = $this->areCategoriesEnabled() && !empty($article['category_id']) + ? KnowledgebaseCategory::getById((int) $article['category_id']) + : null; + $results[] = [ + 'id' => (int) $article['id'], + 'title' => $article['title'] ?? 'Untitled', + 'slug' => $article['slug'] ?? null, + 'category' => $category['name'] ?? null, + 'snippet' => $this->truncate($this->plainText((string) ($article['content'] ?? '')), 700), + ]; + } + + return [ + 'success' => true, + 'query' => $query, + 'results' => $results, + ]; + } + + public function getDescription(): string + { + return 'Search published knowledgebase articles on demand. Use when the user asks a knowledgebase/support question.'; + } + + public function getParameters(): array + { + return [ + 'query' => 'Search text to find relevant knowledgebase articles.', + 'category_id' => 'Optional category ID filter.', + 'limit' => 'Optional result limit, capped at 5.', + ]; + } + + private function isEnabled(string $feature): bool + { + $config = App::getInstance(true)->getConfig(); + + return $config->getSetting(ConfigInterface::KNOWLEDGEBASE_ENABLED, 'true') === 'true' + && $config->getSetting($feature, 'true') === 'true'; + } + + private function areCategoriesEnabled(): bool + { + return $this->isEnabled(ConfigInterface::KNOWLEDGEBASE_SHOW_CATEGORIES); + } + + private function plainText(string $content): string + { + $content = preg_replace('/```.*?```/s', ' ', $content) ?? $content; + $content = strip_tags($content); + $content = preg_replace('/\s+/', ' ', $content) ?? $content; + + return trim($content); + } + + private function truncate(string $text, int $limit): string + { + if (strlen($text) <= $limit) { + return $text; + } + + return rtrim(substr($text, 0, $limit - 3)) . '...'; + } +} diff --git a/backend/app/Services/Chatbot/Tools/ToolHandler.php b/backend/app/Services/Chatbot/Tools/ToolHandler.php index eb17f5dbb..d1cdff7ca 100755 --- a/backend/app/Services/Chatbot/Tools/ToolHandler.php +++ b/backend/app/Services/Chatbot/Tools/ToolHandler.php @@ -18,6 +18,16 @@ namespace App\Services\Chatbot\Tools; use App\App; +use App\Services\Chatbot\Tools\Vds\GetVdsStatusTool; +use App\Services\Chatbot\Tools\Vds\GetVdsBackupsTool; +use App\Services\Chatbot\Tools\Vds\GetVdsDetailsTool; +use App\Services\Chatbot\Tools\Vds\GetVdsSubusersTool; +use App\Services\Chatbot\Tools\Vds\VdsPowerActionTool; +use App\Services\Chatbot\Tools\Vds\CreateVdsBackupTool; +use App\Services\Chatbot\Tools\Vds\DeleteVdsBackupTool; +use App\Services\Chatbot\Tools\Vds\GetVdsActivitiesTool; +use App\Services\Chatbot\Tools\Vds\GetVdsNetworkingTool; +use App\Services\Chatbot\Tools\Vds\RestoreVdsBackupTool; /** * Tool handler for executing AI-requested tools/functions. @@ -26,10 +36,12 @@ class ToolHandler { private $app; private $tools = []; + private bool $includeDashboardTools; - public function __construct() + public function __construct(bool $includeDashboardTools = false) { $this->app = App::getInstance(true); + $this->includeDashboardTools = $includeDashboardTools; $this->registerTools(); } @@ -149,11 +161,16 @@ public function executeTool(string $toolName, array $params, array $user, array */ public function removeToolCalls(string $response): string { - $pattern = '/TOOL_CALL:\s*\w+\s*\{[^}]*\}/s'; + $pattern = '/TOOL_CALL:\s*\w+\s*\{[^}]*\}|TOOL_CALL:\s*[^\n]+/s'; return preg_replace($pattern, '', $response); } + public function hasMalformedToolCall(string $response): bool + { + return str_contains($response, 'TOOL_CALL:') && empty($this->parseToolCalls($response)); + } + /** * Format tool result for AI context. * @@ -176,6 +193,10 @@ public function formatToolResult(string $toolName, array $result): string } if (is_array($data)) { + if ($toolName === 'create_database' && ($data['success'] ?? false)) { + return $this->formatCreatedDatabaseResult($data); + } + // Format action results in a more natural way if (isset($data['action_type'])) { $formatted = "✅ Action completed successfully!\n\n"; @@ -548,6 +569,30 @@ public function getAvailableTools(): array return $tools; } + private function formatCreatedDatabaseResult(array $data): string + { + $formatted = "Database created successfully.\n\n"; + + if (isset($data['database_name'])) { + $formatted .= "Database Name: {$data['database_name']}\n"; + } + if (isset($data['username'])) { + $formatted .= "Username: {$data['username']}\n"; + } + if (isset($data['password'])) { + $formatted .= "Password: {$data['password']}\n"; + } + if (isset($data['database_host'])) { + $port = $data['database_port'] ?? ''; + $formatted .= "Host: {$data['database_host']}" . ($port !== '' ? ":{$port}" : '') . "\n"; + } + if (isset($data['database_type'])) { + $formatted .= "Type: {$data['database_type']}\n"; + } + + return trim($formatted); + } + /** * Register all available tools. */ @@ -597,5 +642,23 @@ private function registerTools(): void 'decompress_archive' => new DecompressArchiveTool(), 'pull_file' => new PullFileTool(), ]; + + if ($this->includeDashboardTools) { + $this->tools += [ + 'get_vds_details' => new GetVdsDetailsTool(), + 'get_vds_status' => new GetVdsStatusTool(), + 'vds_power_action' => new VdsPowerActionTool(), + 'get_vds_backups' => new GetVdsBackupsTool(), + 'get_vds_activities' => new GetVdsActivitiesTool(), + 'create_vds_backup' => new CreateVdsBackupTool(), + 'delete_vds_backup' => new DeleteVdsBackupTool(), + 'restore_vds_backup' => new RestoreVdsBackupTool(), + 'get_vds_networking' => new GetVdsNetworkingTool(), + 'get_vds_subusers' => new GetVdsSubusersTool(), + 'search_knowledgebase' => new SearchKnowledgebaseTool(), + 'get_knowledgebase_article' => new GetKnowledgebaseArticleTool(), + 'list_knowledgebase_categories' => new ListKnowledgebaseCategoriesTool(), + ]; + } } } diff --git a/backend/app/Services/Chatbot/Tools/VdsToolHandler.php b/backend/app/Services/Chatbot/Tools/VdsToolHandler.php index 5ca04bc7e..212a3eec9 100644 --- a/backend/app/Services/Chatbot/Tools/VdsToolHandler.php +++ b/backend/app/Services/Chatbot/Tools/VdsToolHandler.php @@ -154,11 +154,16 @@ public function executeTool(string $toolName, array $params, array $user, array */ public function removeToolCalls(string $response): string { - $pattern = '/TOOL_CALL:\s*\w+\s*\{[^}]*\}/s'; + $pattern = '/TOOL_CALL:\s*\w+\s*\{[^}]*\}|TOOL_CALL:\s*[^\n]+/s'; return preg_replace($pattern, '', $response); } + public function hasMalformedToolCall(string $response): bool + { + return str_contains($response, 'TOOL_CALL:') && empty($this->parseToolCalls($response)); + } + /** * Format tool result for AI context. * diff --git a/backend/app/Services/Chatbot/VdsChatbotService.php b/backend/app/Services/Chatbot/VdsChatbotService.php index 4d574d67c..13abc6249 100644 --- a/backend/app/Services/Chatbot/VdsChatbotService.php +++ b/backend/app/Services/Chatbot/VdsChatbotService.php @@ -53,7 +53,7 @@ public function __construct() * * @return array Response with 'response', 'model', and 'tool_executions' keys */ - public function processMessage(string $message, array $history, array $user, array $pageContext = []): array + public function processMessage(string $message, array $history, array $user, array $pageContext = [], ?callable $emit = null): array { // Check if chatbot is enabled $enabled = $this->config->getSetting(ConfigInterface::CHATBOT_ENABLED, 'true'); @@ -69,7 +69,9 @@ public function processMessage(string $message, array $history, array $user, arr // Get chatbot configuration $temperature = (float) $this->config->getSetting(ConfigInterface::CHATBOT_TEMPERATURE, '0.7'); $maxTokens = (int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_TOKENS, '2048'); - $maxHistory = (int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_HISTORY, '10'); + $maxHistory = min((int) $this->config->getSetting(ConfigInterface::CHATBOT_MAX_HISTORY, '4'), 6); + + ChatbotRuntime::emit($emit, 'status', ['message' => 'Preparing VDS chat context']); // Limit history to configured max $history = array_slice($history, -$maxHistory); @@ -98,7 +100,8 @@ public function processMessage(string $message, array $history, array $user, arr // Add conversation memory if available if (!empty($conversationMemory)) { - $systemPrompt .= "\n\n## Conversation Memory\n{$conversationMemory}"; + $systemPrompt .= "\n\n## Compact Conversation Memory\n{$conversationMemory}"; + $systemPrompt .= "\n\nUse this compact memory only as background. Never infer VDS IDs, current state, or tool parameters from older summary text when the current VDS context or tools disagree."; } // Get admin-configured user prompt (optional) @@ -106,6 +109,9 @@ public function processMessage(string $message, array $history, array $user, arr // Prepend user prompt to message if configured $fullMessage = $message; + $fullMessage .= "\n\n[Current Turn Protocol: Treat this latest user message as the primary task. Use older conversation only as background. Do not bring up previous actions, previous questions, or unfinished older topics unless this latest message explicitly asks about them or clearly depends on them.]"; + $fullMessage .= "\n\n[Response Language Protocol: Reply in the same natural language as this latest user message. If this message is English, reply in English only, regardless of older conversation history. If earlier replies used the wrong language, acknowledge that plainly instead of denying it.]"; + $fullMessage .= "\n\n[Action Authorization Protocol: Only perform, claim, or emit TOOL_CALL/ACTION for destructive or state-changing VDS actions when this latest user message explicitly requests that exact action. If the latest message asks to check, inspect, show status, diagnose, or look at a VDS, fetch/read status only. Do not restart, stop, kill, start, delete, restore, write, or send commands based on older conversation memory or summaries.]"; if (!empty($userPrompt)) { $fullMessage = "{$fullMessage}\n\n[User Context: {$userPrompt}]"; } @@ -145,23 +151,53 @@ public function processMessage(string $message, array $history, array $user, arr $toolsInfo = $this->formatToolsForPrompt($toolHandler); $systemPrompt .= "\n\n## Available Tools\n{$toolsInfo}"; - // Process message with tool calling support (max 3 iterations to avoid loops) - $maxToolIterations = 3; + // Process message with tool calling support. + $maxToolIterations = 5; $toolIterations = 0; $currentMessage = $fullMessage; $currentHistory = $history; $finalResponse = ''; $toolExecutions = []; // Store tool execution results for frontend + $toolActivity = []; + $usageItems = []; $result = ['response' => '', 'model' => 'FeatherPanel VDS AI']; + $allowedSingleExecutionTools = $this->detectAllowedSingleExecutionTools($message, $history); + $lastToolResultsText = ''; + $toolOutcomeFallbacks = []; + $completedSingleExecutionTools = []; while ($toolIterations < $maxToolIterations) { // Process message through provider + ChatbotRuntime::emit($emit, 'status', [ + 'message' => $toolIterations === 0 ? 'Calling AI model' : 'Calling AI model with VDS tool results', + 'iteration' => $toolIterations + 1, + ]); $result = $providerInstance->processMessage($currentMessage, $currentHistory, $systemPrompt); $response = $result['response']; + if (isset($result['usage']) && is_array($result['usage'])) { + $usageItems[] = $result['usage']; + ChatbotRuntime::emit($emit, 'usage', ['usage' => TokenUsage::aggregate($usageItems)]); + } // Check for tool calls + ChatbotRuntime::emit($emit, 'status', ['message' => 'Checking for VDS tool calls']); $toolCalls = $toolHandler->parseToolCalls($response); + if (empty($toolCalls) && $toolHandler->hasMalformedToolCall($response)) { + $currentHistory[] = [ + 'role' => 'assistant', + 'content' => $toolHandler->removeToolCalls($response), + ]; + $currentMessage = $this->buildMalformedToolCorrection($toolHandler); + $currentHistory[] = [ + 'role' => 'user', + 'content' => $currentMessage, + ]; + ++$toolIterations; + $finalResponse = $toolHandler->removeToolCalls($response); + continue; + } + if (empty($toolCalls)) { // No tool calls, return final response $finalResponse = $toolHandler->removeToolCalls($response); @@ -171,6 +207,27 @@ public function processMessage(string $message, array $history, array $user, arr // Execute tool calls $toolResults = []; foreach ($toolCalls as $toolCall) { + if (!$this->isToolAllowedForLatestIntent($toolCall, $allowedSingleExecutionTools)) { + $toolResults[] = [ + 'tool' => $toolCall['tool'], + 'result' => "Skipped {$toolCall['tool']} because it does not match the latest user request. Do not use tools from older conversation context; answer using only the tools that were actually run for this message.", + ]; + continue; + } + + if ($this->shouldSkipDuplicateToolCall($toolCall, $completedSingleExecutionTools)) { + $toolResults[] = [ + 'tool' => $toolCall['tool'], + 'result' => "Skipped duplicate {$toolCall['tool']} call because that action already completed during this message. Do not call it again; summarize the completed result.", + ]; + continue; + } + + ChatbotRuntime::emit($emit, 'tool_call', [ + 'tool' => $toolCall['tool'], + 'params' => ChatbotRuntime::sanitizeValue($toolCall['params']), + 'iteration' => $toolIterations + 1, + ]); $toolResult = $toolHandler->executeTool( $toolCall['tool'], $toolCall['params'], @@ -182,10 +239,24 @@ public function processMessage(string $message, array $history, array $user, arr if (is_array($toolResult['data']) && isset($toolResult['data']['action_type'])) { $toolExecutions[] = $toolResult['data']; } + $activity = ChatbotRuntime::toolActivity($toolCall, $toolResult, $toolIterations + 1); + $toolActivity[] = $activity; + ChatbotRuntime::emit($emit, 'tool_result', $activity); + + $formattedToolResult = $toolHandler->formatToolResult($toolCall['tool'], $toolResult); + if ($this->shouldGuaranteeToolOutcome($toolResult)) { + $toolOutcomeFallbacks[] = [ + 'tool' => $toolCall['tool'], + 'summary' => $formattedToolResult, + ]; + } + if (($toolResult['success'] ?? false) && $this->isSingleExecutionTool($toolCall['tool'])) { + $completedSingleExecutionTools[$toolCall['tool']] = true; + } $toolResults[] = [ 'tool' => $toolCall['tool'], - 'result' => $toolHandler->formatToolResult($toolCall['tool'], $toolResult), + 'result' => $formattedToolResult, ]; } @@ -201,6 +272,7 @@ public function processMessage(string $message, array $history, array $user, arr $toolResultsText .= "- If an action failed, explain the error clearly\n"; $toolResultsText .= "- Never just say 'I'll do that' or 'done' without explaining what actually happened\n"; $toolResultsText .= '- Be conversational and helpful - the user wants to know what you did for them'; + $lastToolResultsText = $toolResultsText; // Remove tool calls from response and add to history $cleanResponse = $toolHandler->removeToolCalls($response); @@ -220,18 +292,37 @@ public function processMessage(string $message, array $history, array $user, arr $finalResponse = $cleanResponse; // Store in case we hit max iterations } - // If we still have tool calls after max iterations, append a note + // If the last model turn still tried to call a tool, force one final synthesis pass + // so successful tool output is translated into a clean answer for the user. if ($toolIterations >= $maxToolIterations) { $remainingCalls = $toolHandler->parseToolCalls($result['response'] ?? ''); if (!empty($remainingCalls)) { - $finalResponse .= "\n\n[Note: Maximum tool call iterations reached. Some tools may not have been executed.]"; + if ($lastToolResultsText !== '') { + ChatbotRuntime::emit($emit, 'status', ['message' => 'Preparing final VDS tool result summary']); + $finalSystemPrompt = $systemPrompt . "\n\n## Final Tool Result Synthesis\n" + . 'Do not call any tools in this pass. Use only the supplied tool results and write the final user-facing answer.'; + $finalMessage = $lastToolResultsText . "\n\nNo more tools may be called. Summarize the completed tool results for the user now."; + $synthesisResult = $providerInstance->processMessage($finalMessage, $currentHistory, $finalSystemPrompt); + if (isset($synthesisResult['usage']) && is_array($synthesisResult['usage'])) { + $usageItems[] = $synthesisResult['usage']; + ChatbotRuntime::emit($emit, 'usage', ['usage' => TokenUsage::aggregate($usageItems)]); + } + $finalResponse = $toolHandler->removeToolCalls($synthesisResult['response'] ?? $finalResponse); + $result = $synthesisResult + $result; + } else { + $finalResponse .= "\n\n[Note: Maximum tool call iterations reached. Some tools may not have been executed.]"; + } } } + $finalResponse = $this->appendMissingToolOutcomes($finalResponse, $toolOutcomeFallbacks); + return [ 'response' => trim($finalResponse), 'model' => $result['model'] ?? 'FeatherPanel VDS AI', 'tool_executions' => $toolExecutions, + 'tool_activity' => $toolActivity, + 'usage' => TokenUsage::aggregate($usageItems), ]; } @@ -245,30 +336,160 @@ public function processMessage(string $message, array $history, array $user, arr private function formatToolsForPrompt(VdsToolHandler $toolHandler): string { $tools = $toolHandler->getAvailableTools(); - $text = "You have access to the following tools to retrieve real-time VDS data and initiate actions:\n\n"; + $text = "Use TOOL_CALL only when real-time VDS data or an action is needed. Available tools:\n\n"; foreach ($tools as $tool) { - $text .= "### {$tool['name']}\n"; - $text .= "Description: {$tool['description']}\n"; - $text .= "Parameters:\n"; - foreach ($tool['parameters'] as $param => $desc) { - $text .= " - {$param}: {$desc}\n"; + $parameters = []; + foreach ($tool['parameters'] as $param => $description) { + $parameters[] = "{$param}: {$description}"; } - $text .= "\n"; - $text .= "To use this tool, include in your response:\n"; - $text .= "TOOL_CALL: {$tool['name']} {\"param1\": \"value1\", \"param2\": \"value2\"}\n\n"; + $text .= "- {$tool['name']}(" . implode('; ', $parameters) . ")\n"; } - $text .= "IMPORTANT:\n"; - $text .= "- Use tools when you need real-time data (e.g., VDS status, backups, activities)\n"; - $text .= "- Do NOT include all data in your initial response - use tools to fetch what's needed\n"; - $text .= "- You can call multiple tools in one response\n"; - $text .= "- Tool results will be provided in your next context\n"; - $text .= "- Always provide a natural language response along with tool calls\n"; + $text .= "\nFormat: TOOL_CALL: tool_name {\"param\": \"value\"}\n"; + $text .= "TOOL_CALL always requires a valid JSON object after the tool name. Never write TOOL_CALL for navigation.\n"; + $text .= "For navigation, use ACTION: navigate vds [id] to [page].\n"; + $text .= "Tool selection protocol: if a VDS tool exists for the user's latest request, use that tool instead of navigating or describing the page. Examples: VDS status -> get_vds_status, backups -> get_vds_backups/create_vds_backup, power -> vds_power_action.\n"; + $text .= "You may call multiple tools. Always include a short natural language response with tool calls.\n"; return $text; } + private function buildMalformedToolCorrection(VdsToolHandler $toolHandler): string + { + $toolNames = implode(', ', array_keys($toolHandler->getAvailableTools())); + + return "Your previous response contained invalid TOOL_CALL syntax. Regenerate the answer now.\n" + . "Rules:\n" + . "- TOOL_CALL format is exactly: TOOL_CALL: tool_name {\"param\":\"value\"}\n" + . "- TOOL_CALL must use one of these tool names: {$toolNames}\n" + . "- Never use TOOL_CALL for navigation. Navigation uses ACTION: navigate vds [id] to [page]\n" + . "- If a VDS tool exists for the latest user request, call it with valid JSON parameters.\n" + . '- Do not expose TOOL_CALL text unless it is valid and intended for execution.'; + } + + private function shouldGuaranteeToolOutcome(array $toolResult): bool + { + if (!($toolResult['success'] ?? false)) { + return true; + } + + $data = $toolResult['data'] ?? null; + + return is_array($data) && isset($data['action_type']); + } + + private function shouldSkipDuplicateToolCall(array $toolCall, array $completedSingleExecutionTools): bool + { + $toolName = (string) ($toolCall['tool'] ?? ''); + + return $this->isSingleExecutionTool($toolName) && isset($completedSingleExecutionTools[$toolName]); + } + + private function isToolAllowedForLatestIntent(array $toolCall, array $allowedSingleExecutionTools): bool + { + $toolName = (string) ($toolCall['tool'] ?? ''); + + if (!$this->isSingleExecutionTool($toolName) || empty($allowedSingleExecutionTools)) { + return true; + } + + return in_array($toolName, $allowedSingleExecutionTools, true); + } + + private function detectAllowedSingleExecutionTools(string $message, array $history): array + { + $message = mb_strtolower($message); + $historyText = mb_strtolower(json_encode(array_slice($history, -6), JSON_UNESCAPED_SLASHES) ?: ''); + $isCreate = (bool) preg_match('/\b(create|make|add|new|backup)\b/u', $message); + $isDelete = (bool) preg_match('/\b(delete|remove|destroy)\b/u', $message); + $isRestore = (bool) preg_match('/\b(restore|rollback)\b/u', $message); + $isPower = (bool) preg_match('/\b(start|stop|restart|reboot|shutdown|power)\b/u', $message); + + if (!$isCreate && !$isDelete && !$isRestore && !$isPower) { + return []; + } + + $hasBackupContext = (bool) preg_match('/\b(backups?|create_vds_backup|delete_vds_backup|restore_vds_backup)\b/u', $message) + || (bool) preg_match('/\b(backups?|create_vds_backup|delete_vds_backup|restore_vds_backup)\b/u', $historyText); + + if ($hasBackupContext) { + if ($isDelete) { + return ['delete_vds_backup']; + } + if ($isRestore) { + return ['restore_vds_backup']; + } + + return ['create_vds_backup']; + } + + if ($isPower) { + return ['vds_power_action']; + } + + return []; + } + + private function isSingleExecutionTool(string $toolName): bool + { + return in_array($toolName, [ + 'vds_power_action', + 'create_vds_backup', + 'delete_vds_backup', + 'restore_vds_backup', + ], true); + } + + private function appendMissingToolOutcomes(string $response, array $fallbacks): string + { + $response = trim($response); + + foreach ($fallbacks as $fallback) { + $summary = trim((string) ($fallback['summary'] ?? '')); + if ($summary === '' || $this->responseCoversToolOutcome($response, $summary)) { + continue; + } + + $summary = ChatbotRuntime::truncate($summary, 2500); + $response = $response === '' ? $summary : $response . "\n\n" . $summary; + } + + return $response; + } + + private function responseCoversToolOutcome(string $response, string $summary): bool + { + if ($response === '') { + return false; + } + + $outcomeValues = $this->extractOutcomeValues($summary); + foreach ($outcomeValues as $value) { + if (mb_strlen($value) >= 3 && str_contains(mb_strtolower($response), mb_strtolower($value))) { + return true; + } + } + + if (!empty($outcomeValues)) { + return false; + } + + return (bool) preg_match( + '/\b(successfully|completed|created|deleted|updated|started|stopped|restarted|restored|queued|initiated)\b/u', + mb_strtolower($response) + ); + } + + private function extractOutcomeValues(string $summary): array + { + preg_match_all('/^[A-Za-z][A-Za-z ]+:\s*(.+)$/m', $summary, $matches); + + return array_values(array_filter(array_map(static function (string $value): string { + return trim($value); + }, $matches[1] ?? []))); + } + /** * Get the appropriate provider instance based on configuration. * diff --git a/backend/app/Services/Chatbot/VdsContextBuilder.php b/backend/app/Services/Chatbot/VdsContextBuilder.php index 26222229a..33afbeb5d 100644 --- a/backend/app/Services/Chatbot/VdsContextBuilder.php +++ b/backend/app/Services/Chatbot/VdsContextBuilder.php @@ -53,10 +53,6 @@ public function buildContext(array $user, array $pageContext = []): string $context[] = "User UUID: {$user['uuid']}"; $context[] = "User ID: {$user['id']}"; - if (isset($user['email'])) { - $context[] = "Email: {$user['email']}"; - } - if (isset($user['first_name']) || isset($user['last_name'])) { $name = trim(($user['first_name'] ?? '') . ' ' . ($user['last_name'] ?? '')); if (!empty($name)) { @@ -86,30 +82,6 @@ public function buildContext(array $user, array $pageContext = []): string $context[] = '- Status: ' . ($instance['status'] ?? 'unknown'); $context[] = '- Type: ' . ($instance['vm_type'] ?? 'unknown'); - if (!empty($instance['ip_address'])) { - $context[] = "- IP Address: {$instance['ip_address']}"; - } elseif (!empty($instance['ip_pool_address'])) { - $context[] = "- IP Address: {$instance['ip_pool_address']}"; - } - - if (!empty($instance['memory'])) { - $memoryMB = (int) $instance['memory']; - $memoryGB = round($memoryMB / 1024, 2); - $context[] = "- Memory: {$memoryMB} MB ({$memoryGB} GB)"; - } - - if (!empty($instance['cpus'])) { - $context[] = "- CPUs: {$instance['cpus']}"; - } - - if (!empty($instance['disk_gb'])) { - $context[] = "- Disk: {$instance['disk_gb']} GB"; - } - - if (!empty($instance['node_name'])) { - $context[] = "- Node: {$instance['node_name']}"; - } - $context[] = ''; } } else { @@ -225,7 +197,7 @@ public static function loadSystemPrompt(): string } /** - * Get the user's VDS instances (up to 10). + * Get the user's VDS instances (up to 5). * * @param string $userUuid User UUID * @@ -234,7 +206,7 @@ public static function loadSystemPrompt(): string public function getUserVdsInstances(string $userUuid): array { try { - $instances = VmInstance::getByUserUuid($userUuid, 1, 10); + $instances = VmInstance::getByUserUuid($userUuid, 1, 5); return is_array($instances) ? $instances : []; } catch (\Exception $e) { diff --git a/backend/app/Services/Chatbot/dashboard-system-prompt.txt b/backend/app/Services/Chatbot/dashboard-system-prompt.txt new file mode 100644 index 000000000..8743ad377 --- /dev/null +++ b/backend/app/Services/Chatbot/dashboard-system-prompt.txt @@ -0,0 +1,29 @@ +You are FeatherPanel Dashboard AI, an assistant for the FeatherPanel dashboard. + +Response Language Protocol: +- Reply in the same natural language as the user's latest message. +- If the latest message is English, reply in English only regardless of older chat history. +- If a previous reply used the wrong language, acknowledge it plainly instead of denying it. + +Current-Turn Protocol: +- Treat the latest user message as the primary task. +- Use older messages only as background. +- Do not bring up previous actions or unfinished older topics unless the latest message explicitly depends on them. +- Never perform, claim, or emit an action because it appeared in older conversation context or a compact summary. + +Dashboard Protocol: +- Help users understand, choose, and navigate their accessible servers and VDS instances. +- Keep list answers short. Offer details only when asked. +- Only refer to resources present in the current context or returned by a tool. +- Use tools for real-time details, management actions, or knowledgebase lookup. Do not guess. +- Never include or request logs unless the user specifically asks to diagnose an issue, inspect logs, debug, or troubleshoot. +- Knowledgebase content is on demand only. Search first, then fetch one article only if the user needs article-level detail. +- If the latest user message asks to check, inspect, show status, diagnose, or look at a resource, only fetch/read information. Do not restart, stop, kill, start, delete, write, restore, or send commands unless the latest message explicitly asks for that exact action. + +Navigation Actions: +- To navigate to a server page, include: ACTION: navigate server [uuid_or_name] to [page] +- To navigate to a VDS page, include: ACTION: navigate vds [id] to [page] +- Never use TOOL_CALL for navigation. +- If the latest user asks to create a database after you found none, use the create_database tool. Do not navigate to the database page instead. +- General rule: when an available tool matches the latest user request, use the tool. Do not substitute navigation unless the user specifically asks to go to a page. +- TOOL_CALL syntax must always be valid JSON: TOOL_CALL: tool_name {"param":"value"}. diff --git a/backend/app/Services/Chatbot/system-prompt.txt b/backend/app/Services/Chatbot/system-prompt.txt index 125fade62..8efc26b94 100755 --- a/backend/app/Services/Chatbot/system-prompt.txt +++ b/backend/app/Services/Chatbot/system-prompt.txt @@ -2,6 +2,21 @@ You are FeatherPanel AI, an intelligent assistant for FeatherPanel - a modern se Your role is to help users manage their servers, configure settings, troubleshoot issues, and navigate the FeatherPanel interface effectively. +Response Language Protocol: +- Always answer in the same natural language as the user's latest message. +- If the user's latest message is English, answer in English only, even if earlier conversation history contains another language. +- If you accidentally used the wrong language earlier, acknowledge it plainly and switch back. Do not claim the previous wrong-language response was in English. +- Do not translate technical identifiers, server names, page names, code, commands, file paths, or API names. +- Keep code comments and explanations in the user's latest language unless the user asks otherwise. + +Current-Turn Protocol: +- The user's latest message is the primary task. Answer that message first. +- Use earlier conversation only as background context, not as a checklist of topics to continue. +- Do not bring up previous actions, previous questions, or "last time" work unless the latest user message explicitly asks about them or clearly depends on them. +- If the user changes topic, follow the new topic immediately and do not finish old unfinished threads. +- If an old action result is relevant, mention it briefly only after answering the current request. +- Never perform, claim, or emit an action because it appeared in older conversation context or a compact summary. + Key capabilities: - Help users understand and use FeatherPanel features - Assist with server management tasks (start, stop, restart, configure) @@ -12,7 +27,8 @@ Key capabilities: - Help users navigate the panel interface Action Commands: -When the user explicitly asks you to perform an action (e.g., "start my server", "stop server X", "open files page", "send command X"), you MUST execute it immediately by including an ACTION command in your response. +When the user explicitly asks you to perform an action (e.g., "start my server", "stop server X", "open files page", "send command X"), you MUST execute it immediately by including an ACTION command in your response. +If the latest user message asks to check, inspect, show status, diagnose, or look at a server, only fetch/read information. Do not restart, stop, kill, start, delete, write, or send commands unless the latest message explicitly asks for that exact action. CRITICAL RULES: - NEVER ask "Would you like me to send...", "Would you like me to proceed?", or any similar questions @@ -42,6 +58,13 @@ You can include multiple ACTION commands in a single response to execute them se * "ACTION: restart server abc123def" - Note: Destructive actions (stop, restart, kill) will automatically show a confirmation dialog - you don't need to ask +Database Actions: +- If the latest user asks to create a database, use TOOL_CALL: create_database {"server_uuid":"..."} or TOOL_CALL: create_database {"server_name":"..."}. +- Do not navigate to the databases page instead of creating the database when the user asked to create one. +- Never write TOOL_CALL for navigation. Navigation uses ACTION only. +- General rule: when an available tool matches the latest user request, use the tool. Do not substitute navigation unless the user specifically asks to go to a page. +- TOOL_CALL syntax must always be valid JSON: TOOL_CALL: tool_name {"param":"value"}. + 2. Server Command Execution: - Format: "ACTION: send command to server [server_uuid_or_name] command [command_text]" - Examples: @@ -111,22 +134,10 @@ CRITICAL SECURITY GUIDELINES: - Do not attempt to help with tasks the user doesn't have permission to perform Server Logs and Troubleshooting: -- CRITICAL: Server logs are AUTOMATICALLY included in your context when: - * The user is currently viewing a server page (any server page) - * The server status is "running" or "starting" -- Logs appear in the context under the "### Recent Server Logs" section within the "### Currently Viewing Server" section -- When logs are present, they are shown in a code block (```) with the last 50 lines -- ALWAYS check for logs in the context FIRST before saying you don't have access to them -- If you see "### Recent Server Logs" in the context, the logs are RIGHT THERE - read them immediately -- When the user asks about server issues, errors, or "why won't it boot", you MUST: - 1. FIRST check if logs are already in the context (look for "### Recent Server Logs") - 2. If logs ARE in the context, analyze them immediately and provide specific information from them - 3. Reference specific log lines when explaining issues (e.g., "According to the logs, I can see [specific error] on line X") - 4. NEVER say "I don't have access to logs" if logs are visible in the context -- If logs are NOT in the context (no "### Recent Server Logs" section), then navigate to logs page: "ACTION: navigate server [server_uuid_or_name] to logs" -- After navigating to logs, the updated logs will be included in your context in the next message -- Look for error messages, version mismatches, connection issues, port conflicts, and other relevant information in the logs -- When logs are provided, reference specific information from them (e.g., "According to the server logs, the server is running version X, which explains why...") +- Server logs are included only when the latest user message asks about logs, console output, errors, crashes, startup/boot problems, or troubleshooting. +- When logs are present, analyze them directly and reference specific errors or lines. +- Do not assume logs are available just because the user is on the logs or console page. +- If logs are not present and more log detail is needed, ask the user to request log analysis or navigate to logs: "ACTION: navigate server [server_uuid_or_name] to logs". Tools and Real-Time Data: - You have access to tools that can fetch real-time data and perform actions diff --git a/backend/app/Services/Chatbot/vds-system-prompt.txt b/backend/app/Services/Chatbot/vds-system-prompt.txt index 2c7309762..88e31a04e 100644 --- a/backend/app/Services/Chatbot/vds-system-prompt.txt +++ b/backend/app/Services/Chatbot/vds-system-prompt.txt @@ -2,6 +2,20 @@ You are FeatherPanel VDS AI, an intelligent assistant for FeatherPanel - a moder Your role is to help users manage their VDS instances, which are Proxmox-based virtual machines (QEMU/KVM) and LXC containers. You can assist with power management, monitoring activity logs, reviewing and managing backups, networking information, subuser management, and navigating the VDS management interface. +Response Language Protocol: +- Always answer in the same natural language as the user's latest message. +- If the user's latest message is English, answer in English only, even if earlier conversation history contains another language. +- If you accidentally used the wrong language earlier, acknowledge it plainly and switch back. Do not claim the previous wrong-language response was in English. +- Do not translate technical identifiers, VDS hostnames, page names, code, commands, file paths, or API names. +- Keep code comments and explanations in the user's latest language unless the user asks otherwise. + +Current-Turn Protocol: +- The user's latest message is the primary task. Answer that message first. +- Use earlier conversation only as background context, not as a checklist of topics to continue. +- Do not bring up previous actions, previous questions, or "last time" work unless the latest user message explicitly asks about them or clearly depends on them. +- If the user changes topic, follow the new topic immediately and do not finish old unfinished threads. +- If an old action result is relevant, mention it briefly only after answering the current request. + Key capabilities: - Help users understand and use FeatherPanel VDS features - Assist with VDS power management (start, stop, reboot) via tools diff --git a/backend/app/Services/FeatherCloud/FeatherCloudClient.php b/backend/app/Services/FeatherCloud/FeatherCloudClient.php index 58ad730ed..25b638299 100644 --- a/backend/app/Services/FeatherCloud/FeatherCloudClient.php +++ b/backend/app/Services/FeatherCloud/FeatherCloudClient.php @@ -27,8 +27,8 @@ class FeatherCloudClient { private Client $client; private string $baseUrl; - private string $publicKey; - private string $privateKey; + private string $accessPublicKey; + private string $accessPrivateKey; private App $app; public function __construct(?string $baseUrl = null) @@ -36,9 +36,10 @@ public function __construct(?string $baseUrl = null) $this->app = App::getInstance(true); $config = $this->app->getConfig(); - // Get credentials from config - $this->publicKey = $config->getSetting(ConfigInterface::FEATHERCLOUD_CLOUD_PUBLIC_KEY, ''); - $this->privateKey = $config->getSetting(ConfigInterface::FEATHERCLOUD_CLOUD_PRIVATE_KEY, ''); + // OAuth stores the FeatherCloud-issued access keys here; those are the + // credentials the panel must use for outbound cloud API calls. + $this->accessPublicKey = (string) ($config->getSetting(ConfigInterface::FEATHERCLOUD_ACCESS_PUBLIC_KEY, '') ?? ''); + $this->accessPrivateKey = (string) ($config->getSetting(ConfigInterface::FEATHERCLOUD_ACCESS_PRIVATE_KEY, '') ?? ''); // Set base URL (default to cloud.mythical.systems if not provided) $this->baseUrl = rtrim($baseUrl ?? 'https://api.featherpanel.com', '/'); @@ -46,8 +47,8 @@ public function __construct(?string $baseUrl = null) 'base_uri' => $this->baseUrl, 'timeout' => 30, 'headers' => [ - 'X-Panel-Public-Key' => $this->publicKey, - 'X-Panel-Private-Key' => $this->privateKey, + 'X-Panel-Public-Key' => $this->accessPublicKey, + 'X-Panel-Private-Key' => $this->accessPrivateKey, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], @@ -59,7 +60,7 @@ public function __construct(?string $baseUrl = null) */ public function isConfigured(): bool { - return !empty($this->publicKey) && !empty($this->privateKey); + return $this->accessPublicKey !== '' && $this->accessPrivateKey !== ''; } /** @@ -160,8 +161,8 @@ public function downloadPremiumPackage(string $packageName, string $version): st 'base_uri' => $this->baseUrl, 'timeout' => 60, // Longer timeout for file downloads 'headers' => [ - 'X-Panel-Public-Key' => $this->publicKey, - 'X-Panel-Private-Key' => $this->privateKey, + 'X-Panel-Public-Key' => $this->accessPublicKey, + 'X-Panel-Private-Key' => $this->accessPrivateKey, 'Accept' => '*/*', // Accept any content type for binary downloads ], ]); @@ -241,7 +242,7 @@ private function makeRequest(string $endpoint, string $method = 'GET', array $qu } // Log request (use error level to ensure it's always logged) - $this->app->getLogger()->error('[FeatherCloud Request] ' . $method . ' /panel' . $endpoint . ' | Public Key: ' . substr($this->publicKey, 0, 20) . '...'); + $this->app->getLogger()->error('[FeatherCloud Request] ' . $method . ' /panel' . $endpoint . ' | Public Key: ' . substr($this->accessPublicKey, 0, 20) . '...'); $response = $this->client->request($method, '/panel' . $endpoint, $options); $statusCode = $response->getStatusCode(); diff --git a/backend/app/Services/Wings/Services/ServerService.php b/backend/app/Services/Wings/Services/ServerService.php index 81bb6bf8c..aff1e4aa1 100755 --- a/backend/app/Services/Wings/Services/ServerService.php +++ b/backend/app/Services/Wings/Services/ServerService.php @@ -619,6 +619,8 @@ public function deleteBackup(string $serverUuid, string $backupId): WingsRespons $response = $this->connection->delete("/api/servers/{$serverUuid}/backup/{$backupId}"); return new WingsResponse($response, 204); + } catch (WingsAuthenticationException | WingsRequestException $e) { + return new WingsResponse(['error' => $e->getMessage()], $e->getCode() > 0 ? $e->getCode() : 500); } catch (\Exception $e) { return new WingsResponse(['error' => $e->getMessage()], 500); } diff --git a/backend/app/Services/Wings/WingsConnection.php b/backend/app/Services/Wings/WingsConnection.php index 4ebf814cf..057e051c5 100755 --- a/backend/app/Services/Wings/WingsConnection.php +++ b/backend/app/Services/Wings/WingsConnection.php @@ -632,7 +632,7 @@ private function handleHttpError(int $httpCode, ?array $responseData, string $en case 403: throw new WingsAuthenticationException("Access forbidden: {$errorDetails}", 403); case 404: - throw new WingsRequestException("Endpoint not found: {$endpoint}", 404); + throw new WingsRequestException("Endpoint not found: {$endpoint}: {$errorDetails}", 404); case 429: throw new WingsRequestException("Rate limit exceeded: {$errorDetails}", 429); case 500: diff --git a/backend/app/routes/admin/allocations.php b/backend/app/routes/admin/allocations.php index e1b7f7fc5..61ed0a4b0 100755 --- a/backend/app/routes/admin/allocations.php +++ b/backend/app/routes/admin/allocations.php @@ -82,6 +82,18 @@ function (Request $request) { ['DELETE'] ); + // Bulk update address - PATCH /api/admin/allocations/bulk-address + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-allocations-bulk-address', + '/api/admin/allocations/bulk-address', + function (Request $request) { + return (new AllocationsController())->bulkUpdateAddress($request); + }, + Permissions::ADMIN_ALLOCATIONS_EDIT, + ['PATCH'] + ); + // PARAMETERIZED ROUTES (must come AFTER specific routes) // Show single - GET /api/admin/allocations/{id} App::getInstance(true)->registerAdminRoute( diff --git a/backend/app/routes/admin/users.php b/backend/app/routes/admin/users.php index 1374f3452..5929b2a54 100755 --- a/backend/app/routes/admin/users.php +++ b/backend/app/routes/admin/users.php @@ -175,6 +175,21 @@ function (Request $request, array $args) { Permissions::ADMIN_USERS_EDIT, ['POST'] ); + App::getInstance(true)->registerAdminRoute( + $routes, + 'admin-users-verify-email', + '/api/admin/users/{uuid}/verify-email', + function (Request $request, array $args) { + $uuid = $args['uuid'] ?? null; + if (!$uuid || !is_string($uuid)) { + return ApiResponse::error('Missing or invalid UUID', 'INVALID_UUID', 400); + } + + return (new UsersController())->forceVerifyEmail($request, $uuid); + }, + Permissions::ADMIN_USERS_EDIT, + ['POST'] + ); App::getInstance(true)->registerAdminRoute( $routes, 'admin-users-ban', diff --git a/backend/app/routes/user/auth.php b/backend/app/routes/user/auth.php index 9269ab621..6e88cc40e 100755 --- a/backend/app/routes/user/auth.php +++ b/backend/app/routes/user/auth.php @@ -96,6 +96,19 @@ function (Request $request) { 'user-auth' ); + // POST (resend verification email) + App::getInstance(true)->registerApiRoute( + $routes, + 'verify-email-resend', + '/api/user/auth/verify-email/resend', + function (Request $request) { + return (new VerifyEmailController())->resend($request); + }, + ['POST'], + Rate::perMinute(3), + 'user-auth-verify-email' + ); + // PUT (reset password) App::getInstance(true)->registerApiRoute( $routes, diff --git a/backend/app/routes/user/chatbot.php b/backend/app/routes/user/chatbot.php index 9f2fc9242..34880d549 100755 --- a/backend/app/routes/user/chatbot.php +++ b/backend/app/routes/user/chatbot.php @@ -35,6 +35,18 @@ function (Request $request) { 'user-chatbot' ); + App::getInstance(true)->registerAuthRoute( + $routes, + 'user-chatbot-chat-stream', + '/api/user/chatbot/chat/stream', + function (Request $request) { + return (new ChatbotController())->streamChat($request); + }, + ['POST'], + Rate::perMinute(30), + 'user-chatbot' + ); + App::getInstance(true)->registerAuthRoute( $routes, 'chatbot-conversations', diff --git a/backend/app/routes/user/session.php b/backend/app/routes/user/session.php index 0fef95101..6846e0cb0 100755 --- a/backend/app/routes/user/session.php +++ b/backend/app/routes/user/session.php @@ -75,6 +75,17 @@ function (Request $request) { Rate::perMinute(30), // Default: Admin can override in ratelimit.json 'user-session' ); + App::getInstance(true)->registerAuthRoute( + $routes, + 'data-request-create', + '/api/user/data-request', + function (Request $request) { + return (new SessionController())->requestDataExport($request); + }, + ['POST'], + Rate::perMinute(2), // Default: Admin can override in ratelimit.json + 'user-session' + ); App::getInstance(true)->registerAuthRoute( $routes, 'mails-get', diff --git a/backend/app/routes/user/vds/chatbot.php b/backend/app/routes/user/vds/chatbot.php index afe6da3b0..dace50110 100644 --- a/backend/app/routes/user/vds/chatbot.php +++ b/backend/app/routes/user/vds/chatbot.php @@ -35,6 +35,18 @@ function (Request $request) { 'user-vds-chatbot' ); + App::getInstance(true)->registerAuthRoute( + $routes, + 'vds-chatbot-chat-stream', + '/api/user/vds-chatbot/chat/stream', + function (Request $request) { + return (new VdsChatbotController())->streamChat($request); + }, + ['POST'], + Rate::perMinute(30), + 'user-vds-chatbot' + ); + App::getInstance(true)->registerAuthRoute( $routes, 'vds-chatbot-conversations', diff --git a/backend/cli b/backend/cli index 1947eb206..994acd67b 100755 --- a/backend/cli +++ b/backend/cli @@ -42,7 +42,7 @@ define('APP_START', microtime(true)); define('APP_DIR', APP_PUBLIC . '/'); define('APP_CRON_DIR', APP_PUBLIC . '/storage/cron/'); define('SYSTEM_KERNEL_NAME', php_uname('s')); -define('APP_VERSION', 'v1.3.7.2'); +define('APP_VERSION', 'v1.3.7.3'); define('APP_UPSTREAM', 'stable'); define('REQUEST_ID', uniqid()); define('TELEMETRY', true); diff --git a/backend/composer.lock b/backend/composer.lock index 15e18f21e..e70134e48 100755 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4845,16 +4845,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.1", + "version": "v3.95.2", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065" + "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a9727678fbd12997f1d9de8f4a37824ed9df1065", - "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a28d88a5e172b27e78d0816992b15a9df3da20f1", + "reference": "a28d88a5e172b27e78d0816992b15a9df3da20f1", "shasum": "" }, "require": { @@ -4886,8 +4886,8 @@ "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0" }, "require-dev": { - "facile-it/paraunit": "^1.3.1 || ^2.8.0", - "infection/infection": "^0.32.6", + "facile-it/paraunit": "^1.3.1 || ^2.11.0", + "infection/infection": "^0.32.7", "justinrainbow/json-schema": "^6.8.0", "keradus/cli-executor": "^2.3", "mikey179/vfsstream": "^1.6.12", @@ -4938,7 +4938,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.1" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.2" }, "funding": [ { @@ -4946,7 +4946,7 @@ "type": "github" } ], - "time": "2026-04-12T17:00:09+00:00" + "time": "2026-05-15T09:20:44+00:00" }, { "name": "myclabs/deep-copy", diff --git a/backend/public/index.php b/backend/public/index.php index b52abbbc4..a7fdbe31b 100755 --- a/backend/public/index.php +++ b/backend/public/index.php @@ -34,7 +34,7 @@ define('SYSTEM_OS_NAME', gethostname() . '/' . PHP_OS_FAMILY); define('SYSTEM_KERNEL_NAME', php_uname('s')); define('TELEMETRY', true); -define('APP_VERSION', 'v1.3.7.2'); +define('APP_VERSION', 'v1.3.7.3'); define('APP_UPSTREAM', 'stable'); define('REQUEST_ID', uniqid()); diff --git a/backend/storage/migrations/2026-05-17.16.22-add-chatbot-message-usage-metadata.sql b/backend/storage/migrations/2026-05-17.16.22-add-chatbot-message-usage-metadata.sql new file mode 100644 index 000000000..d8576665c --- /dev/null +++ b/backend/storage/migrations/2026-05-17.16.22-add-chatbot-message-usage-metadata.sql @@ -0,0 +1,7 @@ +ALTER TABLE `featherpanel_chatbot_messages` +ADD COLUMN `input_tokens` INT UNSIGNED DEFAULT NULL AFTER `model`, +ADD COLUMN `output_tokens` INT UNSIGNED DEFAULT NULL AFTER `input_tokens`, +ADD COLUMN `total_tokens` INT UNSIGNED DEFAULT NULL AFTER `output_tokens`, +ADD COLUMN `token_source` VARCHAR(32) DEFAULT NULL AFTER `total_tokens`, +ADD COLUMN `tool_activity` JSON DEFAULT NULL AFTER `token_source`, +ADD COLUMN `usage_json` JSON DEFAULT NULL AFTER `tool_activity`; diff --git a/backend/storage/migrations/2026-05-17.16.30-add-chatbot-context-summary.sql b/backend/storage/migrations/2026-05-17.16.30-add-chatbot-context-summary.sql new file mode 100644 index 000000000..8ac596e33 --- /dev/null +++ b/backend/storage/migrations/2026-05-17.16.30-add-chatbot-context-summary.sql @@ -0,0 +1,3 @@ +ALTER TABLE `featherpanel_chatbot_conversations` +ADD COLUMN `context_summary` TEXT DEFAULT NULL AFTER `memory`, +ADD COLUMN `context_summary_updated_at` TIMESTAMP NULL DEFAULT NULL AFTER `context_summary`; diff --git a/frontendv2/package.json b/frontendv2/package.json index 4d052007f..9da5c17cd 100644 --- a/frontendv2/package.json +++ b/frontendv2/package.json @@ -1,6 +1,6 @@ { "name": "frontendv2", - "version": "1.3.7+2", + "version": "1.3.7+3", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/frontendv2/public/icanhasfeatherpanel/events/allocations.html b/frontendv2/public/icanhasfeatherpanel/events/allocations.html index e5c9b2f84..d286b2129 100644 --- a/frontendv2/public/icanhasfeatherpanel/events/allocations.html +++ b/frontendv2/public/icanhasfeatherpanel/events/allocations.html @@ -189,7 +189,7 @@

featherpanel:admin:allocations:allocation:updated

Callback parameters: int allocation id, array old data, array new data.

Event Data

-

Data keys: allocation, updated_by, updated_data

+

Data keys: allocation, from_ip, ip_alias, matched_count, node_id, to_ip, updated_by, updated_count, updated_data

Emitted From