|
| 1 | +<?php |
| 2 | +/********************************************************************* |
| 3 | + * AI Response Generator Ajax Controller |
| 4 | + *********************************************************************/ |
| 5 | + |
| 6 | +require_once(INCLUDE_DIR . 'class.ajax.php'); |
| 7 | +require_once(INCLUDE_DIR . 'class.ticket.php'); |
| 8 | +require_once(INCLUDE_DIR . 'class.thread.php'); |
| 9 | +require_once(__DIR__ . '/../api/OpenAIClient.php'); |
| 10 | + |
| 11 | +class AIAjaxController extends AjaxController { |
| 12 | + |
| 13 | + function generate() { |
| 14 | + global $thisstaff; |
| 15 | + $this->staffOnly(); |
| 16 | + |
| 17 | + $ticket_id = (int) ($_POST['ticket_id'] ?? $_GET['ticket_id'] ?? 0); |
| 18 | + if (!$ticket_id || !($ticket = Ticket::lookup($ticket_id))) |
| 19 | + Http::response(404, $this->encode(array('ok' => false, 'error' => __('Unknown ticket')))); |
| 20 | + |
| 21 | + // Permission check: must be able to reply |
| 22 | + $role = $ticket->getRole($thisstaff); |
| 23 | + if (!$role || !$role->hasPerm(Ticket::PERM_REPLY)) |
| 24 | + Http::response(403, $this->encode(array('ok' => false, 'error' => __('Access denied')))); |
| 25 | + |
| 26 | + // Load plugin config from active instance |
| 27 | + // Support per-instance selection via instance_id |
| 28 | + $cfg = null; |
| 29 | + $iid = (int)($_POST['instance_id'] ?? $_GET['instance_id'] ?? 0); |
| 30 | + if ($iid) { |
| 31 | + $all = AIResponseGeneratorPlugin::getAllConfigs(); |
| 32 | + if (isset($all[$iid])) |
| 33 | + $cfg = $all[$iid]; |
| 34 | + } |
| 35 | + if (!$cfg) |
| 36 | + $cfg = AIResponseGeneratorPlugin::getActiveConfig(); |
| 37 | + if (!$cfg) |
| 38 | + Http::response(500, $this->encode(array('ok' => false, 'error' => __('Plugin not configured')))); |
| 39 | + |
| 40 | + $api_url = rtrim($cfg->get('api_url'), '/'); |
| 41 | + $api_key = $cfg->get('api_key'); |
| 42 | + $model = $cfg->get('model'); |
| 43 | + |
| 44 | + if (!$api_url || !$model) |
| 45 | + Http::response(400, $this->encode(array('ok' => false, 'error' => __('Missing API URL or model')))); |
| 46 | + |
| 47 | + // Build prompt using latest thread entries |
| 48 | + $thread = $ticket->getThread(); |
| 49 | + $entries = $thread ? $thread->getEntries() : array(); |
| 50 | + $messages = array(); |
| 51 | + $count = 0; |
| 52 | + foreach ($entries as $E) { |
| 53 | + // Cap to recent context to avoid huge prompts |
| 54 | + if ($count++ > 20) break; |
| 55 | + $type = $E->getType(); |
| 56 | + $body = ThreadEntryBody::clean($E->getBody()); |
| 57 | + $who = $E->getPoster(); |
| 58 | + $who = is_object($who) ? $who->getName() : 'User'; |
| 59 | + $role = ($type == 'M') ? 'user' : 'assistant'; |
| 60 | + $messages[] = array('role' => $role, 'content' => sprintf('[%s] %s', $who, $body)); |
| 61 | + } |
| 62 | + |
| 63 | + // Append instruction for the model (from config or default) |
| 64 | + $system = trim((string)$cfg->get('system_prompt')) ?: "You are a helpful support agent. Draft a concise, professional reply. Quote the relevant ticket details when appropriate. Keep HTML minimal."; |
| 65 | + array_unshift($messages, array('role' => 'system', 'content' => $system)); |
| 66 | + |
| 67 | + // Load RAG documents content (if any) |
| 68 | + $rag_text = $this->loadRagDocuments($cfg); |
| 69 | + if ($rag_text) |
| 70 | + $messages[] = array('role' => 'system', 'content' => "Additional knowledge base context:\n".$rag_text); |
| 71 | + |
| 72 | + try { |
| 73 | + $client = new OpenAIClient($api_url, $api_key); |
| 74 | + $reply = $client->generateResponse($model, $messages); |
| 75 | + if (!$reply) |
| 76 | + throw new Exception(__('Empty response from model')); |
| 77 | + |
| 78 | + // Apply response template if provided |
| 79 | + $tpl = trim((string)$cfg->get('response_template')); |
| 80 | + if ($tpl) { |
| 81 | + global $thisstaff; |
| 82 | + $tpl = $this->expandTemplate($tpl, $ticket, $reply, $thisstaff); |
| 83 | + $reply = $tpl; |
| 84 | + } |
| 85 | + |
| 86 | + return $this->encode(array('ok' => true, 'text' => $reply)); |
| 87 | + } |
| 88 | + catch (Throwable $t) { |
| 89 | + return $this->encode(array('ok' => false, 'error' => $t->getMessage())); |
| 90 | + } |
| 91 | + } |
| 92 | + |
| 93 | + private function loadRagDocuments($cfg) { |
| 94 | + $rag = trim((string)$cfg->get('rag_content')); |
| 95 | + if (!$rag) return ''; |
| 96 | + // Optionally limit to 20,000 chars |
| 97 | + $limit_chars = 20000; |
| 98 | + if (strlen($rag) > $limit_chars) { |
| 99 | + $rag = substr($rag, 0, $limit_chars) . "\n... (truncated)"; |
| 100 | + } |
| 101 | + return $rag; |
| 102 | + } |
| 103 | + |
| 104 | + private function expandTemplate($template, Ticket $ticket, $aiText, $staff=null) { |
| 105 | + $user = $ticket->getOwner(); |
| 106 | + $agentName = ''; |
| 107 | + if ($staff && is_object($staff)) { |
| 108 | + // Prefer display name, fallback to name |
| 109 | + $agentName = method_exists($staff, 'getName') ? (string)$staff->getName() : ''; |
| 110 | + } |
| 111 | + $replacements = array( |
| 112 | + '{ai_text}' => (string)$aiText, |
| 113 | + '{ticket_number}' => (string)$ticket->getNumber(), |
| 114 | + '{ticket_subject}' => (string)$ticket->getSubject(), |
| 115 | + '{user_name}' => $user ? (string)$user->getName() : '', |
| 116 | + '{user_email}' => $user ? (string)$user->getEmail() : '', |
| 117 | + '{agent_name}' => $agentName, |
| 118 | + ); |
| 119 | + return strtr($template, $replacements); |
| 120 | + } |
| 121 | +} |
0 commit comments