Skip to content

Commit f64cd39

Browse files
committed
Add AI Response Generator plugin files
0 parents  commit f64cd39

File tree

10 files changed

+566
-0
lines changed

10 files changed

+566
-0
lines changed

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2025 Mateusz Hajder
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# AI Response Generator Plugin for osTicket
2+
3+
This plugin adds an AI-powered "Generate Response" button to the agent ticket view in osTicket. It allows agents to generate suggested replies using an OpenAI-compatible API and optionally enriches responses with custom context (RAG content).
4+
5+
## Features
6+
7+
- Adds a "Generate AI Response" button to each ticket for agents
8+
- **Supports multiple plugin instances:** You can add and configure multiple instances of the plugin, each with its own API URL, key, model, and settings. This allows you to use different AI providers or configurations for different teams or workflows.
9+
- Configurable API URL, API key, and model
10+
- Optional system prompt and response template
11+
- Supports pasting additional context (RAG content) to enrich AI responses
12+
- Secure API key storage (with PasswordField)
13+
14+
## Requirements
15+
16+
- osTicket (latest stable version recommended)
17+
- Access to an OpenAI-compatible API (e.g., OpenAI, OpenRouter, or local Ollama server)
18+
19+
## Installation
20+
21+
1. Copy the plugin folder to your osTicket `include/plugins/` directory.
22+
2. In the osTicket admin panel, go to **Manage → Plugins**.
23+
3. Click **Add New Plugin** and select **AI Response Generator**.
24+
4. Configure the plugin:
25+
- Set the API URL (e.g., `https://api.openai.com/v1/chat/completions`)
26+
- Enter your API key
27+
- Specify the model (e.g., `gpt-5-nano-2025-08-07`)
28+
- (Optional) Add a system prompt or response template
29+
- (Optional) Paste RAG content to provide extra context for AI replies
30+
5. Save changes.
31+
32+
## Usage
33+
34+
- In the agent panel, open any ticket.
35+
- Click the **Generate AI Response** button in the ticket actions menu.
36+
- The plugin will call the configured API and insert the suggested reply into the response box.
37+
38+
## Configuration Options
39+
40+
- **API URL**: The endpoint for your OpenAI-compatible API.
41+
- **API Key**: The key used for authentication (stored securely).
42+
- **Model Name**: The model to use (e.g., `gpt-5-nano-2025-08-07`).
43+
- **AI System Prompt**: (Optional) Custom instructions for the AI.
44+
- **Response Template**: (Optional) Template for formatting the AI response.
45+
- **RAG Content**: (Optional) Paste additional context to enrich AI responses.
46+
47+
## Security
48+
49+
- Only staff with reply permission can use the AI response feature.
50+
51+
## License
52+
53+
MIT License

api/OpenAIClient.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
/*********************************************************************
3+
* Simple OpenAI-compatible client
4+
* Supports OpenAI Chat Completions compatible APIs.
5+
*********************************************************************/
6+
7+
class OpenAIClient {
8+
private $baseUrl;
9+
private $apiKey;
10+
11+
function __construct($baseUrl, $apiKey=null) {
12+
$this->baseUrl = rtrim($baseUrl, '/');
13+
$this->apiKey = $apiKey;
14+
}
15+
16+
/**
17+
* $messages: array of [role => 'system'|'user'|'assistant', content => '...']
18+
* Returns string reply content
19+
*/
20+
function generateResponse($model, array $messages, $temperature = 0.2, $max_tokens = 512) {
21+
// Detect whether the given base URL points to a specific endpoint
22+
// If it appears to be the bare API root, append /chat/completions
23+
$url = $this->baseUrl;
24+
if (!preg_match('#/chat/(?:completions|complete)$#', $url)) {
25+
$url .= '/chat/completions';
26+
}
27+
28+
$payload = array(
29+
'model' => $model,
30+
'messages' => $messages,
31+
'temperature' => $temperature,
32+
'max_tokens' => $max_tokens,
33+
);
34+
35+
$headers = array('Content-Type: application/json');
36+
if ($this->apiKey)
37+
$headers[] = 'Authorization: Bearer ' . $this->apiKey;
38+
39+
$ch = curl_init($url);
40+
curl_setopt($ch, CURLOPT_POST, true);
41+
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
42+
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
43+
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
44+
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
45+
46+
$resp = curl_exec($ch);
47+
if ($resp === false) {
48+
$err = curl_error($ch);
49+
curl_close($ch);
50+
throw new Exception('cURL error: ' . $err);
51+
}
52+
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
53+
curl_close($ch);
54+
55+
$json = JsonDataParser::decode($resp, true);
56+
if ($code >= 400)
57+
throw new Exception('API error: HTTP ' . $code . ' ' . ($json['error']['message'] ?? $resp));
58+
59+
// OpenAI-style response
60+
if (isset($json['choices'][0]['message']['content']))
61+
return (string) $json['choices'][0]['message']['content'];
62+
if (isset($json['choices'][0]['text']))
63+
return (string) $json['choices'][0]['text'];
64+
65+
// Some compatible servers may use 'output'
66+
if (isset($json['output']))
67+
return (string) $json['output'];
68+
69+
// Fallback: return the whole body, best-effort
70+
return is_string($resp) ? $resp : '';
71+
}
72+
}

assets/.htaccess

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Allow from all

assets/css/style.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
.ai-loading {
2+
opacity: 0.6;
3+
pointer-events: none;
4+
position: relative;
5+
}
6+
7+
.ai-loading:after {
8+
content: '';
9+
position: absolute;
10+
right: -18px;
11+
top: 50%;
12+
width: 14px;
13+
height: 14px;
14+
margin-top: -7px;
15+
border: 2px solid #999;
16+
border-top-color: transparent;
17+
border-radius: 50%;
18+
animation: ai-spin 0.8s linear infinite;
19+
}
20+
21+
@keyframes ai-spin {
22+
from {
23+
transform: rotate(0deg);
24+
}
25+
26+
to {
27+
transform: rotate(360deg);
28+
}
29+
}

assets/js/main.js

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
(function () {
2+
function setReplyText(text) {
3+
var $ta = $('#response');
4+
if (!$ta.length) return false;
5+
6+
// Ensure the Post Reply tab is active so editor is initialized
7+
var $postBtn = $('a.post-response.action-button').first();
8+
if ($postBtn.length && !$postBtn.hasClass('active')) {
9+
try { $postBtn.trigger('click'); } catch (e) { }
10+
}
11+
12+
// Prefer Redactor source.setCode when richtext is enabled
13+
try {
14+
if (typeof $ta.redactor === 'function' && $ta.hasClass('richtext')) {
15+
var current = $ta.redactor('source.getCode') || '';
16+
var newText = current ? (current + "\n\n" + text) : text;
17+
$ta.redactor('source.setCode', newText);
18+
return true;
19+
}
20+
} catch (e) { }
21+
22+
// Fallback to plain textarea append
23+
var current = $ta.val() || '';
24+
$ta.val(current ? (current + "\n\n" + text) : text).trigger('change');
25+
return true;
26+
}
27+
28+
function setLoading($a, loading) {
29+
if (loading) {
30+
$a.addClass('ai-loading');
31+
} else {
32+
$a.removeClass('ai-loading');
33+
}
34+
}
35+
36+
$(document).on('click', 'a.ai-generate-reply', function (e) {
37+
e.preventDefault();
38+
var $a = $(this);
39+
var tid = $a.data('ticket-id');
40+
if (!tid) return false;
41+
42+
setLoading($a, true);
43+
var url = (window.AIResponseGen && window.AIResponseGen.ajaxEndpoint) || 'ajax.php/ai/response';
44+
45+
$.ajax({
46+
url: url,
47+
method: 'POST',
48+
data: { ticket_id: tid, instance_id: $a.data('instance-id') || '' },
49+
dataType: 'json'
50+
}).done(function (resp) {
51+
if (resp && resp.ok) {
52+
if (!setReplyText(resp.text || '')) {
53+
alert('AI response generated, but reply box not found.');
54+
}
55+
} else {
56+
alert((resp && resp.error) ? resp.error : 'Failed to generate response');
57+
}
58+
}).fail(function (xhr) {
59+
var msg = 'Request failed';
60+
try {
61+
var r = JSON.parse(xhr.responseText);
62+
if (r && r.error) msg = r.error;
63+
} catch (e) { }
64+
alert(msg);
65+
}).always(function () {
66+
setLoading($a, false);
67+
});
68+
69+
return false;
70+
});
71+
})();

plugin.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
return array(
3+
'id' => 'ai-response-generator:osticket',
4+
'version' => '0.1.0',
5+
'name' => 'AI Response Generator',
6+
'description' => 'Adds an AI-powered "Generate Response" button to the agent ticket view with configurable API settings and RAG.',
7+
'author' => 'Mateusz Hajder',
8+
'ost_version' => MAJOR_VERSION,
9+
'plugin' => 'src/AIResponsePlugin.php:AIResponseGeneratorPlugin',
10+
'include_path' => '',
11+
);

src/AIAjax.php

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
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

Comments
 (0)