FreeScoutGPT/Http/Controllers/FreeScoutGPTController.php
2026-02-23 22:16:39 -08:00

690 lines
28 KiB
PHP

<?php
namespace Modules\FreeScoutGPT\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cache;
use App\Thread;
use App\Mailbox;
use Modules\FreeScoutGPT\Entities\GPTSettings;
class FreeScoutGPTController extends Controller
{
/**
* Display a listing of the resource.
* @return Response
*/
public function index()
{
return view('freescoutgpt::index');
}
/**
* Show the form for creating a new resource.
* @return Response
*/
public function create()
{
return view('freescoutgpt::create');
}
/**
* Store a newly created resource in storage.
* @param Request $request
* @return Response
*/
public function store(Request $request)
{
}
/**
* Show the specified resource.
* @return Response
*/
public function show()
{
return view('freescoutgpt::show');
}
/**
* Show the form for editing the specified resource.
* @return Response
*/
public function edit()
{
return view('freescoutgpt::edit');
}
/**
* Update the specified resource in storage.
* @param Request $request
* @return Response
*/
public function update(Request $request)
{
}
/**
* Remove the specified resource from storage.
* @return Response
*/
public function destroy()
{
}
public function getAvailableModels(Request $request)
{
$apiKey = $request->input('api_key');
if (!$apiKey) {
return response()->json(['error' => 'API key is required'], 400);
}
$cacheKey = 'openai_models_' . md5($apiKey);
// Check if models are cached
if (Cache::has($cacheKey)) {
return response()->json(['data' => Cache::get($cacheKey)]);
}
try {
$client = new \GuzzleHttp\Client();
$response = $client->get('https://api.openai.com/v1/models', [
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Accept' => 'application/json',
],
]);
$models = json_decode($response->getBody(), true);
// Filter models by version (o1, o3, o4) and remove older models (like 3.5, 4)
$filteredModels = array_filter($models['data'], function ($model) {
// Skip non-chat models like whisper, babbage, tts, etc.
$nonChatModels = ['search','transcribe', 'realtime', 'whisper', 'babbage', 'davinci', 'curie', 'text-to-speech', 'dall-e', '-audio', 'tts', 'embedding', '2024', '2025'];
foreach ($nonChatModels as $nonChatModel) {
if (strpos($model['id'], $nonChatModel) !== false) {
return false;
}
}
if (
strpos($model['id'], 'gpt-4o') !== false ||
strpos($model['id'], 'gpt-4.5') !== false ||
strpos($model['id'], 'gpt-4.1') !== false ||
strpos($model['id'], 'gpt-5') !== false
) {
return true;
}
// Check if model is one of the newer versions (o1, o3, o4)
if (preg_match('/(o[1-4]{1})/', $model['id'], $matches)) {
return true;
}
// Skip models that are older (e.g., 'gpt-3.5', 'gpt-4')
if (strpos($model['id'], 'gpt-3.5') !== false || strpos($model['id'], 'gpt-4-') !== false) {
return false;
}
return false;
});
// Sort filtered models alphabetically by 'id'
usort($filteredModels, function($a, $b) {
return strcmp($a['id'], $b['id']);
});
// Cache filtered models for 10 minutes
Cache::put($cacheKey, $filteredModels, now()->addMinutes(10));
return response()->json(['data' => $filteredModels]);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
/**
* Get available models from Infomaniak API
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getAvailableInfomaniakModels(Request $request)
{
$apiKey = $request->input('infomaniak_api_key');
$productId = $request->input('infomaniak_product_id');
if (!$apiKey || !$productId) {
return response()->json(['error' => 'Infomaniak API key and Product ID are required'], 400);
}
$cacheKey = 'infomaniak_models_' . md5($apiKey . '_' . $productId);
// Check if models are cached
if (Cache::has($cacheKey)) {
return response()->json(['data' => Cache::get($cacheKey)]);
}
try {
$client = new \GuzzleHttp\Client();
$url = "https://api.infomaniak.com/1/ai/{$productId}/openai/models";
$response = $client->get($url, [
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Accept' => 'application/json',
],
]);
$models = json_decode($response->getBody(), true);
$filteredModels = $models['data'] ?? [];
// Filter out non-chat models (e.g., whisper, photomaker, flux, bge_multilingual_gemma2)
$nonChatModels = ['whisper', 'photomaker', 'flux', 'bge_multilingual_gemma2', 'mini_lm_l12_v2'];
$filteredModels = array_filter($filteredModels, function ($model) use ($nonChatModels) {
foreach ($nonChatModels as $nonChatModel) {
if (isset($model['id']) && stripos($model['id'], $nonChatModel) !== false) {
return false;
}
}
return true;
});
// Optionally, sort alphabetically by 'id' if present
usort($filteredModels, function($a, $b) {
return strcmp($a['id'] ?? '', $b['id'] ?? '');
});
// Cache filtered models for 10 minutes
Cache::put($cacheKey, $filteredModels, now()->addMinutes(10));
return response()->json(['data' => $filteredModels]);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
/**
* Get available Infomaniak Product IDs for the account
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getAvailableInfomaniakProductIds(Request $request)
{
$apiKey = $request->input('infomaniak_api_key');
if (!$apiKey) {
return response()->json(['error' => 'Infomaniak API key is required'], 400);
}
$cacheKey = 'infomaniak_product_ids_' . md5($apiKey);
if (Cache::has($cacheKey)) {
return response()->json(['data' => Cache::get($cacheKey)]);
}
try {
$client = new \GuzzleHttp\Client();
$url = "https://api.infomaniak.com/1/ai";
$response = $client->get($url, [
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Accept' => 'application/json',
],
]);
$result = json_decode($response->getBody(), true);
$productIds = [];
if (!empty($result['data']) && is_array($result['data'])) {
foreach ($result['data'] as $item) {
if (isset($item['product_id'])) {
$productIds[] = $item['product_id'];
}
}
}
// Cache for 10 minutes
Cache::put($cacheKey, $productIds, now()->addMinutes(10));
return response()->json(['data' => $productIds]);
} catch (\Exception $e) {
return response()->json(['error' => $e->getMessage()], 500);
}
}
public function generate(Request $request)
{
if (Auth::user() === null) return Response::json(["error" => "Unauthorized"], 401);
$settings = GPTSettings::findOrFail($request->get("mailbox_id"));
// Get ajax system prompt, and use it below if set
$ajax_cmd = $request->get("command");
if (!empty($ajax_cmd)) {
$ajax_cmd = trim($ajax_cmd);
\Log::info('Using Reply Prompt Override: ' . $ajax_cmd);
}
// If Responses API is enabled, use it instead of Chat Completions
if (!empty($settings->use_responses_api)) {
$articleUrls = array_filter(array_map('trim', preg_split('/\r?\n/', $settings->article_urls ?? '')));
$fetchResult = $this->fetchArticlesContext($articleUrls, $settings, $request);
if (!empty($fetchResult['error'])) {
return Response::json([
'query' => $fetchResult['userQuery'] ?? '',
'answer' => $fetchResult['error']
], 200);
}
$context = $fetchResult['context'];
$userQuery = $request->get('query');
// Build prompt: use responses_api_prompt if set, otherwise use hardcoded default
$prompt = (!empty($ajax_cmd) ? $ajax_cmd : $settings->start_message) . "\n\n";
if (isset($settings->responses_api_prompt) && $settings->responses_api_prompt) {
$prompt .= $settings->responses_api_prompt . "\n\n";
} else {
$prompt .= __('If relevant given the customer\'s query, and the articles included, find the single article that best answers the user\'s question. Summarize the relevant part of that article as a support answer, and provide the article URL. If no article is relevant, reply with a concise best attempt to answer their concerns.\n\n');
}
// Use Guzzle to call OpenAI Responses API
try {
$guzzle = new \GuzzleHttp\Client(['timeout' => 30]);
$apiKey = $settings->api_key;
$payload = [
'model' => is_string($settings->model) ? $settings->model : (is_array($settings->model) ? reset($settings->model) : ''),
'input' => (string)($prompt . "\n" . $context),
'max_output_tokens' => (integer) $settings->token_limit
];
$jsonPayload = json_encode($payload);
$response = $guzzle->post('https://api.openai.com/v1/responses', [
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
],
'body' => $jsonPayload,
]);
$data = json_decode($response->getBody(), true);
$answerText = '';
if (isset($data['output']) && is_array($data['output'])) {
foreach ($data['output'] as $item) {
if (
isset($item['type'], $item['content'][0]['text']) &&
$item['type'] === 'message' &&
is_string($item['content'][0]['text'])
) {
$answerText = trim($item['content'][0]['text'], "\n");
break; // Message found, no need to continue looping
}
}
}
} catch (\Exception $e) {
$errorMsg = $e->getMessage();
$openAiError = '';
if (method_exists($e, 'getResponse') && $e->getResponse()) {
$body = (string) $e->getResponse()->getBody();
$json = json_decode($body, true);
if (isset($json['error']['message'])) {
$openAiError = $json['error']['message'];
} else {
$openAiError = $body;
}
}
$answerText = $openAiError ?: $errorMsg;
\Log::error('Error on Responses API Request: ' . $answerText);
return Response::json([
'query' => $userQuery ?? '',
'answer' => $answerText
], 200);
}
$thread = Thread::find($request->get('thread_id'));
$answers = $thread->chatgpt ? json_decode($thread->chatgpt, true) : [];
if ($answers === null) $answers = [];
$answers[] = $answerText;
$thread->chatgpt = json_encode($answers, JSON_UNESCAPED_UNICODE);
$thread->save();
return Response::json([
'query' => $userQuery,
'answer' => $answerText
], 200);
} // End of Non-Edit-Prompt & Responses API Call
// Infomaniak API: use if enabled, before OpenAI
if (!empty($settings->infomaniak_enabled)) {
\Log::info('Using Infomaniak API for answers');
$articleUrls = array_filter(array_map('trim', preg_split('/\r?\n/', $settings->article_urls)));
$fetchResult = $this->fetchArticlesContext($articleUrls, $settings, $request);
if (!empty($fetchResult['error'])) {
\Log::error('Infomaniak API Articles Error: ' . $fetchResult['error']);
return Response::json([
'query' => $fetchResult['userQuery'] ?? '',
'answer' => $fetchResult['error']
], 200);
}
$context = $fetchResult['context'];
$apiKey = $settings->infomaniak_api_key;
$productId = $settings->infomaniak_product_id;
$model = $settings->infomaniak_model;
$tokenLimit = (int) $settings->token_limit;
$userQuery = $request->get('query');
$systemPrompt = (!empty($ajax_cmd) ? $ajax_cmd : $settings->start_message);
if (!empty($settings->infomaniak_api_prompt)) {
$systemPrompt .= "\n\n" . $settings->infomaniak_api_prompt;
} else {
$systemPrompt .= __('\n\nIf relevant given the customer\'s query, and the articles included, find the single article that best answers the user\'s question. Summarize the relevant part of that article as a support answer, and provide the article URL. If no article is relevant, reply with a concise best attempt to answer their concerns.');
}
$systemPrompt .= "\n\n" . $context;
\Log::info('Infomaniak API system prompt: ' . $systemPrompt);
$messages = [
[
'role' => 'system',
'content' => $systemPrompt
],
[
'role' => 'user',
'content' => $userQuery
]
];
$payload = [
'model' => $model,
'messages' => $messages,
'max_tokens' => $tokenLimit
];
try {
$client = new \GuzzleHttp\Client(['timeout' => 30]);
$response = $client->post("https://api.infomaniak.com/1/ai/{$productId}/openai/chat/completions", [
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
],
'body' => json_encode($payload),
]);
$data = json_decode($response->getBody(), true);
\Log::info('Infomaniak API Call Response ' . json_encode($data));
// This may need to be more robust like Responses API checks above?
$answerText = $data['choices'][0]['message']['content'] ?? '';
} catch (\Exception $e) {
\Log::error('Infomaniak API Response Error: ' . $e->getMessage());
$answerText = $e->getMessage();
}
$thread = Thread::find($request->get('thread_id'));
$answers = $thread->chatgpt ? json_decode($thread->chatgpt, true) : [];
if ($answers === null) $answers = [];
$answers[] = $answerText;
$thread->chatgpt = json_encode($answers, JSON_UNESCAPED_UNICODE);
$thread->save();
\Log::info('Infomaniak API Generate Answer: ' . $answerText);
return Response::json([
'query' => $userQuery,
'answer' => $answerText
], 200);
}
// OpenAI Chat Completions API
$openaiClient = \Tectalic\OpenAi\Manager::build(new \GuzzleHttp\Client(
[
'timeout' => config('app.curl_timeout'),
'connect_timeout' => config('app.curl_connect_timeout'),
'proxy' => config('app.proxy'),
]
), new \Tectalic\OpenAi\Authentication($settings->api_key));
// Determine role based on model
if (strpos($settings->model, 'o1') !== false || strpos($settings->model, 'o3') !== false) {
$req_role = 'user';
} else {
$req_role = 'developer'; // Default role, adjust as needed
}
$command = $request->get("command");
$messages = [[
'role' => $req_role,
'content' => $command ?? $settings->start_message
]];
if ($settings->client_data_enabled) {
$customerName = $request->get("customer_name");
$customerEmail = $request->get("customer_email");
$conversationSubject = $request->get("conversation_subject");
array_push($messages, [
'role' => $req_role,
'content' => __('Conversation subject is: ":subject"\nCustomer name is: ":name"\n', [
'subject' => $conversationSubject,
'name' => $customerName
])
]);
}
array_push($messages, [
'role' => 'user',
'content' => $request->get('query')
]);
$response = $openaiClient->chatCompletions()->create(
new \Tectalic\OpenAi\Models\ChatCompletions\CreateRequest([
'model' => $settings->model,
'messages' => $messages,
'max_output_tokens' => (integer) $settings->token_limit
])
)->toModel();
$thread = Thread::find($request->get('thread_id'));
if ($thread->chatgpt === null) {
$answers = [];
} else {
$answers = json_decode($thread->chatgpt, true);
}
if ($answers === null) {
$answers = [];
}
array_push($answers, trim($response->choices[0]->message->content, "\n"));
$thread->chatgpt = json_encode($answers, JSON_UNESCAPED_UNICODE);
$thread->save();
return Response::json([
'query' => $request->get('query'),
'answer' => $response->choices[0]->message->content
], 200);
}
public function answers(Request $request)
{
if (Auth::user() === null) return Response::json(["error" => "Unauthorized"], 401);
$conversation = $request->query('conversation');
$threads = Thread::where("conversation_id", $conversation)->get();
$result = [];
foreach ($threads as $thread) {
if ($thread->chatgpt !== "{}" && $thread->chatgpt !== null) {
$answers = [];
$answers_text = json_decode($thread->chatgpt, true);
if ($answers_text === null) continue;
foreach ($answers_text as $answer_text) {
array_push($answers, $answer_text);
}
$answer = ["thread" => $thread->id, "answers" => $answers];
array_push($result, $answer);
}
}
return Response::json(["answers" => $result], 200);
}
public function settings($mailbox_id)
{
$mailbox = Mailbox::findOrFail($mailbox_id);
$settings = GPTSettings::find($mailbox_id);
if (empty($settings)) {
$settings['mailbox_id'] = $mailbox_id;
$settings['api_key'] = "";
$settings['token_limit'] = "";
$settings['start_message'] = "";
$settings['enabled'] = false;
$settings['model'] = "";
$settings['client_data_enabled'] = false;
}
return view('freescoutgpt::settings', [
'mailbox' => $mailbox,
'settings' => $settings
]);
}
public function saveSettings($mailbox_id, Request $request)
{
GPTSettings::updateOrCreate(
['mailbox_id' => $mailbox_id],
[
'api_key' => $request->get("api_key"),
'enabled' => isset($_POST['gpt_enabled']),
'token_limit' => $request->get('token_limit'),
'start_message' => $request->get('start_message'),
'model' => $request->get('model'),
'client_data_enabled' => isset($_POST['show_client_data_enabled']),
'use_responses_api' => isset($_POST['use_responses_api']),
'article_urls' => $request->get('article_urls'),
'responses_api_prompt' => $request->get('responses_api_prompt'),
'infomaniak_enabled' => isset($_POST['infomaniak_enabled']),
'infomaniak_api_key' => $request->get('infomaniak_api_key'),
'infomaniak_product_id' => $request->get('infomaniak_product_id'),
'infomaniak_model' => $request->get('infomaniak_model'),
'infomaniak_api_prompt' => $request->get('infomaniak_api_prompt'),
]
);
\Session::flash('flash_success_floating', __('Settings updated'));
return redirect()->route('freescoutgpt.settings', ['mailbox_id' => $mailbox_id]);
}
public function checkIsEnabled(Request $request)
{
$settings = GPTSettings::find($request->query("mailbox"));
if (empty($settings)) {
return Response::json(['enabled' => false], 200);
}
return Response::json(['enabled' => $settings['enabled']], 200);
}
/**
* Fetch and parse articles from URLs for context.
* @param array $articleUrls
* @return array [ 'context' => string, 'articles' => array ]
*/
protected function fetchArticlesContext(array $articleUrls, $settings, $request)
{
$articles = [];
$client = new \GuzzleHttp\Client(['timeout' => 20]);
$userQuery = $request->get('query');
foreach ($articleUrls as $url) {
try {
$res = $client->get($url, [
'headers' => [
'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36'
]
]);
$body = (string) $res->getBody();
$contentType = $res->getHeaderLine('Content-Type');
$isText = preg_match('/\.txt$/i', $url) || stripos($contentType, 'text/plain') !== false;
if ($isText) {
$safeText = htmlspecialchars($body, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5);
$body = '<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body><pre>' . $safeText . '</pre></body></html>';
}
// Use the new parseArticleHtml function for parsing
$text = $this->parseArticleHtml($body);
$articles[] = [
'url' => $url,
'text' => mb_substr($text, 0, 12000)
];
} catch (\Exception $e) {
$errorMsg = $e->getMessage();
\Log::error('Error on Article Fetch: ' . $errorMsg ?? 'Error fetching or parsing the article.');
return [
'context' => '',
'articles' => [],
'error' => $errorMsg ?? 'Error fetching or parsing the article.',
'userQuery' => $userQuery
];
}
}
$context = "";
if ($settings->client_data_enabled) {
$customerName = $request->get("customer_name");
$customerEmail = $request->get("customer_email");
$conversationSubject = $request->get("conversation_subject");
$context .= __("Conversation subject is: :subject\nCustomer name is: :name\n", [
'subject' => $conversationSubject,
'name' => $customerName
]) . "\n";
}
$context .= __('Customer query: :query', ['query' => $userQuery]) . "\n";
if (empty($articles)) {
$context .= __('No articles were included.') . "\n";
} else {
$context .= __('Articles to use for context:') . "\n";
foreach ($articles as $i => $article) {
$context .= __('[Article #:num] URL: :url', ['num' => ($i + 1), 'url' => $article['url']]) . "\n";
$context .= (is_string($article['text']) ? $article['text'] : '') . "\n\n";
}
}
return [
'context' => $context,
'articles' => $articles
];
}
private function parseArticleHtml($html)
{
if (empty($html)) {
return '';
}
// Normalize: ensure UTF-8 and decode any entities
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
libxml_use_internal_errors(true);
$dom = new \DOMDocument();
// Force UTF-8 parsing
if ($dom->loadHTML('<?xml encoding="utf-8" ?>' . $html, LIBXML_NOERROR | LIBXML_NOWARNING)) {
$xpath = new \DOMXPath($dom);
$nodes = $xpath->query('/*');
$text = '';
foreach ($nodes as $node) {
// Replace <a> tags with "text: href"
$aTags = $node->getElementsByTagName('a');
foreach (iterator_to_array($aTags) as $a) {
$linkText = trim($a->textContent);
$href = $a->getAttribute('href');
if ($linkText && $href) {
$replacement = $linkText . ': ' . $href . "\n";
$a->parentNode->replaceChild($dom->createTextNode($replacement), $a);
}
}
// Collect inner content
$innerHTML = '';
foreach ($node->childNodes as $child) {
$innerHTML .= $dom->saveHTML($child);
}
// Strip unwanted elements
$innerHTML = preg_replace('/<style[\s\S]*?<\/style>/i', '', $innerHTML);
$innerHTML = preg_replace('/<script[\s\S]*?<\/script>/i', '', $innerHTML);
$plainText = strip_tags($innerHTML);
// Normalize spacing and entities
$plainText = html_entity_decode($plainText, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$plainText = str_replace("\xc2\xa0", ' ', $plainText); // non-breaking space
$plainText = preg_replace('/[ \t]+/', ' ', $plainText);
// Remove space(s) before newlines
$plainText = preg_replace('/ +\n/', "\n", $plainText);
// Collapse 2+ newlines to 1
$plainText = preg_replace('/[\r\n]{2,}/', "\n", $plainText);
$plainText = preg_replace('/[\n]{2,}/', "\n", $plainText);
$plainText = preg_replace('/^[\s\n\r]+|[\s\n\r]+$/u', '', $plainText);
$text .= trim($plainText) . "";
}
libxml_clear_errors();
return trim($text);
}
return '';
}
}