Agent-Logs-Url: https://github.com/GameServerPanel/GSP/sessions/89922108-1604-44ae-949d-358d32b9d70a Co-authored-by: iaretechnician <2749183+iaretechnician@users.noreply.github.com>
326 lines
12 KiB
PHP
326 lines
12 KiB
PHP
<?php
|
|
/***********************
|
|
* Assistant Chat (Full History) — PHP + cURL
|
|
* - Persistent thread in session
|
|
* - Full history render with Question / Answer labels
|
|
* - SSL verification disabled (your hosting constraint)
|
|
* - Citations: filename + page (when available)
|
|
***********************/
|
|
|
|
// Debug (disable on production)
|
|
ini_set('display_errors', 1);
|
|
ini_set('display_startup_errors', 1);
|
|
error_reporting(E_ALL);
|
|
|
|
/* ------------------- CONFIG ------------------- */
|
|
$OPENAI_API_KEY = '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';
|
|
|
|
$ASSISTANT_ID = 'asst_RAhtGzcy6higJeMwomZSqVjM'; // <-- set to your existing assistant
|
|
$OPENAI_BASE_URL = 'https://api.openai.com/v1';
|
|
$OPENAI_BETA_HDR = 'assistants=v2'; // required for Assistants v2
|
|
$REQUEST_TIMEOUT = 30; // seconds for cURL calls
|
|
$RUN_POLL_DELAY = 500000; // microseconds between run polls (0.5s)
|
|
$RUN_POLL_MAX = 40; // max polls (~20s total); adjust as needed
|
|
/* ---------------------------------------------- */
|
|
|
|
if (session_status() === PHP_SESSION_NONE) {
|
|
session_start();
|
|
}
|
|
if (!isset($_SESSION['thread_id'])) {
|
|
$_SESSION['thread_id'] = null;
|
|
}
|
|
|
|
/** HTML escape helper */
|
|
function h($v) { return htmlspecialchars((string)$v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); }
|
|
|
|
/** Low-level OpenAI request helper */
|
|
function openai_request($method, $endpoint, $payload = null, $query = []) {
|
|
global $OPENAI_API_KEY;
|
|
$url = "https://api.openai.com/v1" . $endpoint;
|
|
if (!empty($query)) $url .= '?' . http_build_query($query);
|
|
|
|
$headers = [
|
|
"Content-Type: application/json",
|
|
"Authorization: Bearer {$OPENAI_API_KEY}",
|
|
"OpenAI-Beta: assistants=v2"
|
|
];
|
|
|
|
$ch = curl_init($url);
|
|
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, strtoupper($method));
|
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
|
|
// Host requires SSL verification disabled
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
|
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
|
|
|
|
if (!is_null($payload)) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
|
|
|
|
$resp = curl_exec($ch);
|
|
if ($resp === false) {
|
|
$err = curl_error($ch);
|
|
curl_close($ch);
|
|
throw new RuntimeException("cURL error: {$err}");
|
|
}
|
|
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
|
curl_close($ch);
|
|
|
|
$data = json_decode($resp, true);
|
|
if ($code >= 400) {
|
|
$msg = isset($data['error']['message']) ? $data['error']['message'] : 'Unknown API error';
|
|
throw new RuntimeException("OpenAI API error ({$code}): {$msg}");
|
|
}
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
/** Create or reuse a per-visitor thread */
|
|
function ensure_thread_id() {
|
|
if (!empty($_SESSION['thread_id'])) return $_SESSION['thread_id'];
|
|
$created = openai_request('POST', '/threads', ['metadata' => ['site' => $_SERVER['HTTP_HOST'] ?? 'unknown']]);
|
|
$tid = $created['id'] ?? null;
|
|
if (!$tid) throw new RuntimeException('Failed to create thread.');
|
|
$_SESSION['thread_id'] = $tid;
|
|
return $tid;
|
|
}
|
|
|
|
/** Add a user message */
|
|
function add_user_message($thread_id, $text) {
|
|
openai_request('POST', "/threads/{$thread_id}/messages", [
|
|
'role' => 'user',
|
|
'content' => $text,
|
|
]);
|
|
}
|
|
|
|
/** Start a run */
|
|
function start_run($thread_id, $assistant_id) {
|
|
$run = openai_request('POST', "/threads/{$thread_id}/runs", [
|
|
'assistant_id' => $assistant_id,
|
|
]);
|
|
$run_id = $run['id'] ?? null;
|
|
if (!$run_id) throw new RuntimeException('Failed to start run.');
|
|
return $run_id;
|
|
}
|
|
|
|
/** Wait for completion (or fail/timeout) */
|
|
function wait_for_run($thread_id, $run_id, $max_tries, $delay_us) {
|
|
$terminal = ['completed', 'failed', 'requires_action', 'cancelled', 'expired'];
|
|
for ($i = 0; $i < $max_tries; $i++) {
|
|
usleep($delay_us);
|
|
$run = openai_request('GET', "/threads/{$thread_id}/runs/{$run_id}");
|
|
$status = $run['status'] ?? '';
|
|
if (in_array($status, $terminal, true)) return $run;
|
|
}
|
|
return ['status' => 'timeout'];
|
|
}
|
|
|
|
/** Cache of file_id => filename (per request) */
|
|
$_FILE_NAME_CACHE = [];
|
|
|
|
/** Resolve file name from file_id (API returns "filename" or sometimes "display_name") */
|
|
function get_file_name_by_id($file_id) {
|
|
global $_FILE_NAME_CACHE;
|
|
if (isset($_FILE_NAME_CACHE[$file_id])) return $_FILE_NAME_CACHE[$file_id];
|
|
$file = openai_request('GET', "/files/{$file_id}");
|
|
$name = $file['filename'] ?? ($file['display_name'] ?? ($file['name'] ?? $file_id));
|
|
$_FILE_NAME_CACHE[$file_id] = $name;
|
|
return $name;
|
|
}
|
|
|
|
/**
|
|
* Extract message text + citations (filename + page if available).
|
|
* Returns an array of entries: ['role' => 'user|assistant', 'text' => '...', 'refs' => [['filename'=>'','page'=>'','file_id'=>'']]]
|
|
*/
|
|
function normalize_messages($messages) {
|
|
$out = [];
|
|
if (empty($messages['data']) || !is_array($messages['data'])) return $out;
|
|
|
|
// The API returns newest first by default if not specifying; we request 'asc' in fetch.
|
|
foreach ((array)$messages['data'] as $m) {
|
|
$role = $m['role'] ?? '';
|
|
if (!in_array($role, ['user', 'assistant', 'system'], true)) continue;
|
|
|
|
if (empty($m['content']) || !is_array($m['content'])) continue;
|
|
|
|
$all_text = [];
|
|
$refs = [];
|
|
foreach ((array)$m['content'] as $part) {
|
|
if (($part['type'] ?? '') === 'text' && !empty($part['text']['value'])) {
|
|
$all_text[] = $part['text']['value'];
|
|
|
|
// Parse annotations for citations (file_citation)
|
|
$anns = $part['text']['annotations'] ?? [];
|
|
if (is_array($anns)) {
|
|
foreach ((array)$anns as $ann) {
|
|
if (($ann['type'] ?? '') === 'file_citation' && !empty($ann['file_citation']['file_id'])) {
|
|
$fid = $ann['file_citation']['file_id'];
|
|
$page = null;
|
|
|
|
// Page can appear under different shapes depending on backend. Try common keys:
|
|
if (isset($ann['file_citation']['page'])) {
|
|
$page = $ann['file_citation']['page'];
|
|
} elseif (isset($ann['file_citation']['page_range']) && is_array($ann['file_citation']['page_range'])) {
|
|
// Example: ['start' => 5, 'end' => 6]
|
|
$start = $ann['file_citation']['page_range']['start'] ?? null;
|
|
$end = $ann['file_citation']['page_range']['end'] ?? null;
|
|
if ($start && $end && $start !== $end) $page = "{$start}-{$end}";
|
|
elseif ($start) $page = (string)$start;
|
|
}
|
|
// Fetch filename
|
|
try {
|
|
$filename = get_file_name_by_id($fid);
|
|
} catch (Throwable $e) {
|
|
$filename = $fid;
|
|
}
|
|
$refs[] = [
|
|
'file_id' => $fid,
|
|
'filename' => $filename,
|
|
'page' => $page ?? 'n/a',
|
|
];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($all_text)) {
|
|
$out[] = [
|
|
'role' => $role,
|
|
'text' => implode("\n", $all_text),
|
|
'refs' => $refs,
|
|
];
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/** Fetch conversation (ascending) */
|
|
function fetch_history($thread_id) {
|
|
$messages = openai_request('GET', "/threads/{$thread_id}/messages", null, ['order' => 'asc', 'limit' => 50]);
|
|
return normalize_messages($messages);
|
|
}
|
|
|
|
/* ------------------- HANDLE POST ------------------- */
|
|
$error = null;
|
|
$history = [];
|
|
|
|
try {
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
if (!empty($_POST['reset_thread'])) {
|
|
$_SESSION['thread_id'] = null;
|
|
} elseif (isset($_POST['user_input'])) {
|
|
$user_text = trim((string)$_POST['user_input']);
|
|
if ($user_text !== '') {
|
|
$thread_id = ensure_thread_id();
|
|
add_user_message($thread_id, $user_text);
|
|
$run_id = start_run($thread_id, $ASSISTANT_ID);
|
|
$run = wait_for_run($thread_id, $run_id, $POLL_MAX_TRIES, $POLL_DELAY_US);
|
|
|
|
if (($run['status'] ?? '') === 'failed') {
|
|
$error = 'Assistant run failed.';
|
|
} elseif (($run['status'] ?? '') === 'requires_action') {
|
|
// If you later support tool calls, handle them here then submit outputs.
|
|
} elseif (($run['status'] ?? '') === 'timeout') {
|
|
$error = 'Assistant timed out. Please try again.';
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!empty($_SESSION['thread_id'])) {
|
|
$history = fetch_history($_SESSION['thread_id']);
|
|
}
|
|
} catch (Throwable $e) {
|
|
$error = $e->getMessage();
|
|
}
|
|
?>
|
|
<?php
|
|
// Include top and menu for website UI (session already started above)
|
|
include(__DIR__ . '/includes/top.php');
|
|
include(__DIR__ . '/includes/menu.php');
|
|
?>
|
|
<!-- UI -->
|
|
<div class="ai-container">
|
|
<h3>Site Assistant</h3>
|
|
<p>Type a question below. Press <b>Enter</b> to send, <b>Shift+Enter</b> for a new line.</p>
|
|
|
|
<?php if ($error): ?>
|
|
<div class="ai-alert" style="border:1px solid #c00;">
|
|
<strong>Error:</strong> <?php echo h($error); ?>
|
|
</div>
|
|
<?php endif; ?>
|
|
|
|
<?php if (!empty($_SESSION['thread_id'])): ?>
|
|
<div class="ai-msg-meta">Thread: <?php echo h($_SESSION['thread_id']); ?></div>
|
|
<?php endif; ?>
|
|
|
|
<form id="chat-form" method="post" style="margin:12px 0;">
|
|
<textarea id="chat-input" name="user_input" rows="3" class="ai-textarea" placeholder="Ask your question..."></textarea>
|
|
<div style="margin-top:8px; display:flex; gap:8px;">
|
|
<button type="submit">Send</button>
|
|
<button type="submit" name="reset_thread" value="1">Reset Conversation</button>
|
|
</div>
|
|
</form>
|
|
|
|
<?php if (!empty($history) && is_array($history)): ?>
|
|
<div style="margin-top:16px; padding:10px; border:1px solid #ccc; border-radius:8px;">
|
|
<?php foreach ((array)$history as $msg):
|
|
// Label mapping: user => Question, assistant => Answer, system => (optional)
|
|
$role = $msg['role'] ?? 'assistant';
|
|
if ($role === 'user') $label = 'Question';
|
|
elseif ($role === 'assistant') $label = 'Answer';
|
|
else $label = ucfirst($role); // e.g., System
|
|
$text = str_replace("\r\n", "\n", $msg['text'] ?? '');
|
|
$refs = $msg['refs'] ?? [];
|
|
?>
|
|
<div style="margin-bottom:14px;">
|
|
<div style="font-weight:bold;"><?php echo h($label); ?></div>
|
|
<div style="white-space:pre-wrap;"><?php echo nl2br(h($text)); ?></div>
|
|
|
|
<?php if (!empty($refs)): ?>
|
|
<div style="margin-top:6px; font-size:12px;">
|
|
<em>References:</em>
|
|
<ul style="margin:6px 0 0 18px; padding:0;">
|
|
<?php foreach ((array)$refs as $r):
|
|
$fname = $r['filename'] ?? 'file';
|
|
$page = $r['page'] ?? 'n/a';
|
|
// If you have your own document links, replace '#' with a real URL.
|
|
?>
|
|
<li>
|
|
<a href="#" title="file_id: <?php echo h($r['file_id']); ?>">
|
|
<?php echo h($fname); ?> — page <?php echo h($page); ?>
|
|
</a>
|
|
</li>
|
|
<?php endforeach; ?>
|
|
</ul>
|
|
</div>
|
|
<?php endif; ?>
|
|
</div>
|
|
<?php endforeach; ?>
|
|
</div>
|
|
<?php else: ?>
|
|
<div style="margin-top:10px; color:#666;">No messages yet.</div>
|
|
<?php endif; ?>
|
|
|
|
<div style="margin-top:10px; font-size:12px; color:#555;">
|
|
Conversation persists until you click “Reset Conversation”.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Submit on Enter (Shift+Enter = newline) -->
|
|
<script>
|
|
(function(){
|
|
var form = document.getElementById('chat-form');
|
|
var input = document.getElementById('chat-input');
|
|
|
|
input.addEventListener('keydown', function(e){
|
|
if (e.key === 'Enter') {
|
|
if (!e.shiftKey) {
|
|
e.preventDefault();
|
|
form.submit();
|
|
}
|
|
// if Shift+Enter, allow newline
|
|
}
|
|
});
|
|
})();
|
|
</script>
|
|
|