From 686e927249f12518ab458f1115f64fd8107c4903 Mon Sep 17 00:00:00 2001 From: Wojtek Date: Fri, 16 Oct 2026 22:41:08 +0200 Subject: [PATCH] Add Gemini API as an AI provider --- README.md | 2 +- .../components/com_testquestions/config.xml | 1 + .../language/en-GB/com_testquestions.ini | 2 + .../sql/updates/mysql/1.43.28.sql | 1 + .../src/Controller/AiController.php | 6 +- .../src/Model/ResultModel.php | 2 +- .../src/Model/ResultsModel.php | 4 +- .../src/Service/AiService.php | 60 ++++++++++++++----- .../src/View/Ai/HtmlView.php | 1 + .../com_testquestions/tmpl/ai/default.php | 23 ++++++- .../com_testquestions/tmpl/result/default.php | 6 +- .../tmpl/results/default.php | 7 +++ com_testquestions.xml | 2 +- 13 files changed, 89 insertions(+), 28 deletions(-) create mode 100644 administrator/components/com_testquestions/sql/updates/mysql/1.43.28.sql diff --git a/README.md b/README.md index 87b722d..2a2dbf2 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,6 @@ Szkielet komponentu Joomla 6 do gromadzenia pytań testowych. ## Instalacja -W Joomla wybierz **System → Install → Install from Folder** i wskaż ten katalog. Manifestem instalacyjnym jest `com_testquestions.xml` (wersja 1.43.27). +W Joomla wybierz **System → Install → Install from Folder** i wskaż ten katalog. Manifestem instalacyjnym jest `com_testquestions.xml` (wersja 1.43.28). Po instalacji komponent jest dostępny w panelu administratora jako **Pytania testowe**. diff --git a/administrator/components/com_testquestions/config.xml b/administrator/components/com_testquestions/config.xml index c66344b..5b62735 100644 --- a/administrator/components/com_testquestions/config.xml +++ b/administrator/components/com_testquestions/config.xml @@ -6,6 +6,7 @@ + diff --git a/administrator/components/com_testquestions/language/en-GB/com_testquestions.ini b/administrator/components/com_testquestions/language/en-GB/com_testquestions.ini index 29daffa..51f79fb 100644 --- a/administrator/components/com_testquestions/language/en-GB/com_testquestions.ini +++ b/administrator/components/com_testquestions/language/en-GB/com_testquestions.ini @@ -72,9 +72,11 @@ COM_TESTQUESTIONS_AI_API_KEY="Klucz API" COM_TESTQUESTIONS_AI_OPENAI_KEY="Klucz API OpenAI" COM_TESTQUESTIONS_AI_GROK_KEY="Klucz API Grok" COM_TESTQUESTIONS_AI_GROQ_KEY="Klucz API Groq" +COM_TESTQUESTIONS_AI_GEMINI_KEY="Klucz API Gemini" COM_TESTQUESTIONS_AI_OPENAI_MODEL="Model OpenAI" COM_TESTQUESTIONS_AI_GROK_MODEL="Model Grok" COM_TESTQUESTIONS_AI_GROQ_MODEL="Model Groq API" +COM_TESTQUESTIONS_AI_GEMINI_MODEL="Model Gemini" COM_TESTQUESTIONS_AI_MODEL_INVALID="Wybierz prawidłowy model dla każdego dostawcy AI." COM_TESTQUESTIONS_AI_TOKEN_USAGE="Wykorzystanie tokenów API" COM_TESTQUESTIONS_AI_TOKEN_USAGE_INFO="Suma użycia zapisanego przy ocenach AI we wszystkich wynikach testów. Powtórne oceny są zliczane, jeśli zostały wykonane po tej aktualizacji." diff --git a/administrator/components/com_testquestions/sql/updates/mysql/1.43.28.sql b/administrator/components/com_testquestions/sql/updates/mysql/1.43.28.sql new file mode 100644 index 0000000..77820c4 --- /dev/null +++ b/administrator/components/com_testquestions/sql/updates/mysql/1.43.28.sql @@ -0,0 +1 @@ +-- Wydanie 1.43.28: dodano dostawcę Gemini API. diff --git a/administrator/components/com_testquestions/src/Controller/AiController.php b/administrator/components/com_testquestions/src/Controller/AiController.php index 54bd177..5e9de7a 100644 --- a/administrator/components/com_testquestions/src/Controller/AiController.php +++ b/administrator/components/com_testquestions/src/Controller/AiController.php @@ -36,13 +36,13 @@ class AiController extends BaseController $input = Factory::getApplication()->getInput(); $provider = strtolower($input->post->getCmd('ai_provider', '')); - if (!in_array($provider, ['openai', 'grok', 'groq'], true)) { + if (!in_array($provider, ['openai', 'grok', 'groq', 'gemini'], true)) { throw new \RuntimeException(Text::_('COM_TESTQUESTIONS_AI_PROVIDER_INVALID')); } $models = AiService::availableModels(); $selectedModels = []; - foreach (['openai', 'grok', 'groq'] as $modelProvider) { + foreach (['openai', 'grok', 'groq', 'gemini'] as $modelProvider) { $model = trim($input->post->getString('ai_' . $modelProvider . '_model', '')); if (!isset($models[$modelProvider][$model])) { throw new \RuntimeException(Text::_('COM_TESTQUESTIONS_AI_MODEL_INVALID')); @@ -71,7 +71,7 @@ class AiController extends BaseController $settings['ai_' . $modelProvider . '_model'] = $model; } - foreach (['openai', 'grok', 'groq'] as $keyProvider) { + foreach (['openai', 'grok', 'groq', 'gemini'] as $keyProvider) { $fieldName = 'ai_' . $keyProvider . '_api_key'; $apiKey = trim((string) $input->post->get($fieldName, '', 'RAW')); if ($apiKey !== '') { diff --git a/administrator/components/com_testquestions/src/Model/ResultModel.php b/administrator/components/com_testquestions/src/Model/ResultModel.php index 56a4e92..a9e00fa 100644 --- a/administrator/components/com_testquestions/src/Model/ResultModel.php +++ b/administrator/components/com_testquestions/src/Model/ResultModel.php @@ -96,7 +96,7 @@ class ResultModel extends BaseDatabaseModel $keyName = 'ai_' . $provider . '_api_key'; $apiKey = trim((string) $params->get($keyName, $params->get('ai_api_key', ''))); $componentKeyLength = mb_strlen($apiKey); - $defaultModels = ['openai' => 'gpt-4.1-mini', 'grok' => 'grok-4.7', 'groq' => 'openai/gpt-oss-120b']; + $defaultModels = ['openai' => 'gpt-4.1-mini', 'grok' => 'grok-4.7', 'groq' => 'openai/gpt-oss-120b', 'gemini' => 'gemini-3.8-flash']; $model = trim((string) $params->get('ai_' . $provider . '_model', $defaultModels[$provider] ?? '')); // Odczytaj zapisane parametry bezpośrednio z bazy. Dzięki temu nie użyjemy diff --git a/administrator/components/com_testquestions/src/Model/ResultsModel.php b/administrator/components/com_testquestions/src/Model/ResultsModel.php index d1c4358..b3ac1f2 100644 --- a/administrator/components/com_testquestions/src/Model/ResultsModel.php +++ b/administrator/components/com_testquestions/src/Model/ResultsModel.php @@ -18,7 +18,7 @@ class ResultsModel extends ListModel $item->ai_output_tokens = 0; $item->ai_total_tokens = 0; $item->ai_cost_usd = 0.0; - $item->ai_tokens_by_provider = ['openai' => 0, 'grok' => 0, 'groq' => 0]; + $item->ai_tokens_by_provider = ['openai' => 0, 'grok' => 0, 'groq' => 0, 'gemini' => 0]; $answers = json_decode((string) ($item->answers ?? ''), true); if (!is_array($answers)) { @@ -68,7 +68,7 @@ class ResultsModel extends ListModel 'output_tokens' => 0, 'total_tokens' => 0, 'cost_usd' => 0.0, - 'providers' => ['openai' => $emptyProviderTotals, 'grok' => $emptyProviderTotals, 'groq' => $emptyProviderTotals], + 'providers' => ['openai' => $emptyProviderTotals, 'grok' => $emptyProviderTotals, 'groq' => $emptyProviderTotals, 'gemini' => $emptyProviderTotals], ]; foreach ($answersRows as $answersJson) { diff --git a/administrator/components/com_testquestions/src/Service/AiService.php b/administrator/components/com_testquestions/src/Service/AiService.php index 17fae29..37b6994 100644 --- a/administrator/components/com_testquestions/src/Service/AiService.php +++ b/administrator/components/com_testquestions/src/Service/AiService.php @@ -28,6 +28,10 @@ class AiService 'openai/gpt-oss-20b' => 'GPT OSS 20B', 'llama-3.3-70b-versatile' => 'Llama 3.3 70B Versatile', ], + 'gemini' => [ + 'gemini-3.8-flash' => 'Gemini 3.8 Flash', + 'gemini-3.1-pro-preview' => 'Gemini 3.1 Pro (Preview)', + ], ]; } @@ -38,6 +42,7 @@ class AiService $config = match ($provider) { 'grok' => ['url' => 'https://api.x.ai/v1/responses'], 'groq' => ['url' => 'https://api.groq.com/openai/v1/chat/completions'], + 'gemini' => ['url' => 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode($model) . ':generateContent'], default => ['url' => 'https://api.openai.com/v1/responses'], }; @@ -45,7 +50,7 @@ class AiService throw new \RuntimeException('AI_NOT_CONFIGURED'); } - if (!in_array($provider, ['openai', 'grok', 'groq'], true)) { + if (!in_array($provider, ['openai', 'grok', 'groq', 'gemini'], true)) { throw new \RuntimeException('Nieprawidłowy dostawca AI: ' . $provider); } @@ -61,29 +66,40 @@ class AiService . "Odpowiedź ucznia:\n" . ($answer !== '' ? $answer : 'Brak odpowiedzi') . "\n\n" . "Zwróć wyłącznie poprawny obiekt JSON w formacie {\"awarded_points\": liczba_całkowita, \"feedback\": \"krótka ocena po polsku\"}. W ocenie opisz poprawne i brakujące elementy. Nie zmieniaj wzorca odpowiedzi."; - $requestData = $provider === 'groq' - ? [ + $requestData = match ($provider) { + 'groq' => [ 'model' => $model, 'messages' => [['role' => 'user', 'content' => $input]], 'temperature' => 0.1, - ] - : [ + ], + 'gemini' => [ + 'contents' => [['parts' => [['text' => $input]]]], + 'generationConfig' => ['responseMimeType' => 'application/json'], + ], + default => [ 'model' => $model, 'input' => $input, - ]; + ], + }; // GPT-5.x reasoning models do not accept a custom temperature. if ($provider === 'grok' || ($provider === 'openai' && $model === 'gpt-4.1-mini')) { $requestData['temperature'] = 0.1; } $payload = json_encode($requestData, JSON_UNESCAPED_UNICODE); + $headers = [ + 'Content-Type' => 'application/json', + ]; + if ($provider === 'gemini') { + $headers['x-goog-api-key'] = trim($apiKey); + } else { + $headers['Authorization'] = 'Bearer ' . trim($apiKey); + } + $response = HttpFactory::getHttp()->post( $config['url'], $payload, - [ - 'Authorization' => 'Bearer ' . trim($apiKey), - 'Content-Type' => 'application/json', - ], + $headers, 60 ); $body = json_decode((string) $response->body, true); @@ -132,13 +148,15 @@ class AiService return null; } - $inputTokens = max(0, (int) ($usage['input_tokens'] ?? $usage['prompt_tokens'] ?? 0)); - $outputTokens = max(0, (int) ($usage['output_tokens'] ?? $usage['completion_tokens'] ?? 0)); - if ((!isset($usage['input_tokens']) && !isset($usage['prompt_tokens'])) - || (!isset($usage['output_tokens']) && !isset($usage['completion_tokens']))) { + $inputTokens = max(0, (int) ($usage['input_tokens'] ?? $usage['prompt_tokens'] ?? $usage['promptTokenCount'] ?? 0)); + $outputTokens = max(0, (int) ($usage['output_tokens'] ?? $usage['completion_tokens'] ?? ( + (int) ($usage['candidatesTokenCount'] ?? 0) + (int) ($usage['thoughtsTokenCount'] ?? 0) + ))); + if ((!isset($usage['input_tokens']) && !isset($usage['prompt_tokens']) && !isset($usage['promptTokenCount'])) + || (!isset($usage['output_tokens']) && !isset($usage['completion_tokens']) && !isset($usage['candidatesTokenCount']))) { return null; } - $cachedTokens = min($inputTokens, max(0, (int) ($usage['input_tokens_details']['cached_tokens'] ?? 0))); + $cachedTokens = min($inputTokens, max(0, (int) ($usage['input_tokens_details']['cached_tokens'] ?? $usage['cachedContentTokenCount'] ?? 0))); $cost = null; $costSource = ''; @@ -159,7 +177,7 @@ class AiService 'model' => $model, 'input_tokens' => $inputTokens, 'output_tokens' => $outputTokens, - 'total_tokens' => (int) ($usage['total_tokens'] ?? ($inputTokens + $outputTokens)), + 'total_tokens' => (int) ($usage['total_tokens'] ?? $usage['totalTokenCount'] ?? ($inputTokens + $outputTokens)), 'cached_input_tokens' => $cachedTokens, 'cost_usd' => $cost, 'cost_source' => $costSource, @@ -179,6 +197,16 @@ class AiService private function extractText(array $body): string { + if (isset($body['candidates'][0]['content']['parts']) && is_array($body['candidates'][0]['content']['parts'])) { + $parts = []; + foreach ($body['candidates'][0]['content']['parts'] as $part) { + if (isset($part['text']) && is_string($part['text'])) { + $parts[] = $part['text']; + } + } + return trim(implode("\n", $parts)); + } + if (isset($body['choices'][0]['message']['content']) && is_string($body['choices'][0]['message']['content'])) { return trim($body['choices'][0]['message']['content']); } diff --git a/administrator/components/com_testquestions/src/View/Ai/HtmlView.php b/administrator/components/com_testquestions/src/View/Ai/HtmlView.php index 17320f7..76fed95 100644 --- a/administrator/components/com_testquestions/src/View/Ai/HtmlView.php +++ b/administrator/components/com_testquestions/src/View/Ai/HtmlView.php @@ -29,6 +29,7 @@ class HtmlView extends BaseHtmlView 'openai' => ['calls' => 0, 'input_tokens' => 0, 'output_tokens' => 0, 'total_tokens' => 0, 'cost_usd' => 0.0, 'cost_calls' => 0], 'grok' => ['calls' => 0, 'input_tokens' => 0, 'output_tokens' => 0, 'total_tokens' => 0, 'cost_usd' => 0.0, 'cost_calls' => 0], 'groq' => ['calls' => 0, 'input_tokens' => 0, 'output_tokens' => 0, 'total_tokens' => 0, 'cost_usd' => 0.0, 'cost_calls' => 0], + 'gemini' => ['calls' => 0, 'input_tokens' => 0, 'output_tokens' => 0, 'total_tokens' => 0, 'cost_usd' => 0.0, 'cost_calls' => 0], ]; $usageRows = $db->setQuery( diff --git a/administrator/components/com_testquestions/tmpl/ai/default.php b/administrator/components/com_testquestions/tmpl/ai/default.php index 187ef1d..bc40c11 100644 --- a/administrator/components/com_testquestions/tmpl/ai/default.php +++ b/administrator/components/com_testquestions/tmpl/ai/default.php @@ -8,9 +8,11 @@ settings['ai_openai_api_key'] ?? ($provider === 'openai' ? $legacyKey : '')); ?> settings['ai_grok_api_key'] ?? ($provider === 'grok' ? $legacyKey : '')); ?> settings['ai_groq_api_key'] ?? ($provider === 'groq' ? $legacyKey : '')); ?> +settings['ai_gemini_api_key'] ?? ($provider === 'gemini' ? $legacyKey : '')); ?> settings['ai_openai_model'] ?? 'gpt-4.1-mini'); ?> settings['ai_grok_model'] ?? 'grok-4.7'); ?> settings['ai_groq_model'] ?? 'openai/gpt-oss-120b'); ?> +settings['ai_gemini_model'] ?? 'gemini-3.8-flash'); ?>
@@ -20,6 +22,7 @@ +
@@ -76,6 +79,24 @@
+
+ +
+ + +
+ + + +
+
+ + +

@@ -92,7 +113,7 @@ - 'OpenAI', 'grok' => 'Grok', 'groq' => 'Groq'] as $usageProvider => $usageProviderLabel) : ?> + 'OpenAI', 'grok' => 'Grok', 'groq' => 'Groq', 'gemini' => 'Gemini'] as $usageProvider => $usageProviderLabel) : ?> diff --git a/administrator/components/com_testquestions/tmpl/result/default.php b/administrator/components/com_testquestions/tmpl/result/default.php index 0a00ea8..cee56c9 100644 --- a/administrator/components/com_testquestions/tmpl/result/default.php +++ b/administrator/components/com_testquestions/tmpl/result/default.php @@ -64,7 +64,7 @@ $resolveImageUrl = static function ($path) { } ?> 0) : ?> - 'OpenAI', 'grok' => 'Grok', 'groq' => 'Groq'][$usageProvider] ?? $usageProvider; }, array_keys($usageTotals['providers'])); ?> + 'OpenAI', 'grok' => 'Grok', 'groq' => 'Groq', 'gemini' => 'Gemini'][$usageProvider] ?? $usageProvider; }, array_keys($usageTotals['providers'])); ?> ✦ : tokenów, @@ -96,8 +96,8 @@ $resolveImageUrl = static function ($path) { - - · + + · in / out · diff --git a/administrator/components/com_testquestions/tmpl/results/default.php b/administrator/components/com_testquestions/tmpl/results/default.php index 70e6238..2b2211b 100644 --- a/administrator/components/com_testquestions/tmpl/results/default.php +++ b/administrator/components/com_testquestions/tmpl/results/default.php @@ -56,6 +56,12 @@ HTMLHelper::_('behavior.multiselect'); (aiUsageTotals['providers']['groq']['input_tokens'], 0, ',', ' '); ?> wej. / aiUsageTotals['providers']['groq']['output_tokens'], 0, ',', ' '); ?> wyj.) + + Gemini: + aiUsageTotals['providers']['gemini']['total_tokens'], 0, ',', ' '); ?> tokenów + (aiUsageTotals['providers']['gemini']['input_tokens'], 0, ',', ' '); ?> wej. / + aiUsageTotals['providers']['gemini']['output_tokens'], 0, ',', ' '); ?> wyj.) + Łącznie: aiUsageTotals['total_tokens'], 0, ',', ' '); ?> tokenów
@@ -92,6 +98,7 @@ HTMLHelper::_('behavior.multiselect'); OpenAI: ai_tokens_by_provider['openai'], 0, ',', ' '); ?> · Grok: ai_tokens_by_provider['grok'], 0, ',', ' '); ?> · Groq: ai_tokens_by_provider['groq'], 0, ',', ' '); ?> + · Gemini: ai_tokens_by_provider['gemini'], 0, ',', ' '); ?> ai_total_tokens, 0, ',', ' '); ?> łącznie diff --git a/com_testquestions.xml b/com_testquestions.xml index 7f5aae6..0fbdd9b 100644 --- a/com_testquestions.xml +++ b/com_testquestions.xml @@ -4,7 +4,7 @@ 2026-10-14 Komponent GNU General Public License version 2 or later - 1.43.27 + 1.43.28 COM_TESTQUESTIONS_DESCRIPTION com_testquestions Komponent\Component\Testquestions