<?php
// worker.php - Background CLI queue processor
include('./setup.php');
$queueFile = __DIR__ . '/queue.json';
$chatDir = __DIR__ . '/chats/';
$indexFile = __DIR__ . '/chats.json';
// Prevent multiple concurrent workers from overlapping
$lockFile = __DIR__ . '/worker.lock';
$fp = fopen($lockFile, 'c+');
if (!flock($fp, LOCK_EX | LOCK_NB)) {
exit("Worker already running.\n");
}
while (true) {
if (!file_exists($queueFile)) {
usleep(500000); // Sleep 0.5s if no queue file
continue;
}
$queueData = json_decode(file_get_contents($queueFile), true);
if (!is_array($queueData) || empty($queueData)) {
usleep(500000);
continue;
}
// Find first pending job and store its unique job_id
$targetJobId = null;
$job = null;
foreach ($queueData as $j) {
if ($j['status'] === 'pending') {
$targetJobId = $j['job_id'];
$job = $j;
break;
}
}
if ($targetJobId === null) {
break;
}
// Helper function to update job status safely by job_id
function updateJobStatus(&$queueArr, $jobId, $newStatus) {
foreach ($queueArr as &$j) {
if ($j['job_id'] === $jobId) {
$j['status'] = $newStatus;
break;
}
}
}
workerLog("found job to process {$job['type']} - {$job['chat_id']}");
// Mark job as processing
$queueData = json_decode(file_get_contents($queueFile), true) ?: [];
updateJobStatus($queueData, $targetJobId, 'processing');
file_put_contents($queueFile, json_encode($queueData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
$chatId = $job['chat_id'];
$type = $job['type'];
$payload = $job['payload'];
$jobSuccess = false;
try {
switch($type) {
case "ai_chat":
// 1. Handle File Uploads
$attachmentPaths = array();
$isImageUpload = false;
$fileContextContent = "";
$base64Images = array(); // Array to store multiple images
$deleteuploads=array(); // cleanup
if (!isset($fileContextContent)) $fileContextContent = "";
for($i=0;$i<count($payload['attachments']);$i++) {
$targetFile = $payload['attachments'][0];
$fileName = preg_replace('/[^a-zA-Z0-9\.\-_]/', '_', $targetFile);
if (file_exists($targetFile)) {
$attachmentPaths[] = './chats/' . $chatId . '/uploads/' . basename($targetFile);
$mimeType = mime_content_type($targetFile);
if (strpos($mimeType, 'image/') === 0) {
$isImageUpload = true;
$imgBinary = file_get_contents($targetFile);
// Push the newly encoded string into our array
$base64Images[] = base64_encode($imgBinary);
} else {
$fileContextContent .= "\n\n[Attached File Content (" . $fileName . ")]:\n" . file_get_contents($targetFile);
}
array_push($deleteuploads,$targetFile);
}
}
// Process Ollama Chat Request
$fullPromptForAI = $payload['prompt'] . $fileContextContent;
//
$backendUrl = 'http://'.$BACKEND_IP.':'.$BACKEND_AI_PORT.'/api/chat';
$actualModel = $payload['model'] ?? 'gemma4-uncensored';
// Load existing history
$filePath = $chatDir . $chatId . '.json';
$messagesPayload = [];
if (file_exists($filePath)) {
$oldHistory = json_decode(file_get_contents($filePath), true) ?: [];
foreach ($oldHistory as $msg) {
if (empty($msg['is_image']) && isset($msg['role'], $msg['content'])) {
$messagesPayload[] = [
"role" => $msg['role'],
"content" => $msg['content']
];
}
}
}
// Build user message structure for Ollama (supports multi-modal images array if vision model is used)
$currentUserMessage = [
"role" => "user",
"content" => $fullPromptForAI
];
if ($isImageUpload && !empty($base64Images)) {
// Assign the entire accumulated array directly
$currentUserMessage["images"] = $base64Images;
}
$messagesPayload[] = $currentUserMessage;
//--updateChatStatusWorker($chatId, 'idle');
$options=array(
"num_ctx" => 65536,
"num_predict" => 4096
);
$ch = curl_init($backendUrl);
curl_setopt($ch, CURLOPT_TIMEOUT, $curl_timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $curl_connout);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
"model" => $actualModel,
"messages" => $messagesPayload,
"stream" => false,
"options" => $options
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$apiResult = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if($cleanup_uploads) {
for($i=0;$i<count($deleteuploads);$i++) {
if($deleteuploads[$i]<>"") {
if(file_exists($deleteuploads[$i])) {
unlink($deleteuploads[$i]);
}
}
}
}
// Reset status to idle when done
updateChatStatusWorker($chatId, 'idle');
if ($apiResult && $httpCode === 200) {
$data = json_decode($apiResult, true);
$output = $data['message']['content'] ?? "No response generated.";
saveChatHistory($chatId, $payload['prompt'], $output, false, $payload['attachments'] ?? [], $payload['is_upload'] ?? false);
$jobSuccess = true;
workerLog("job completed ai_chat - {$chatId}");
} else {
saveChatHistory($chatId, $payload['prompt'], "System Error: Failed to reach Ollama backend.", false, [], false);
}
// DEBUG: Log all Ollama responses to a file to inspect what happened
//file_put_contents(__DIR__ . '/ollama_debug.log', "Model: {$actualModel} | HTTP: {$httpCode} | Error: {$curlError} | Result: {$apiResult}\n", FILE_APPEND);
break;
case "generate_image":
$prompt = $payload['prompt'];
$steps = $payload['steps'];
$width = $payload['width'];
$height = $payload['height'];
$attachments = $payload['attachments'] ?? [];
$isUpload = $payload['is_upload'] ?? false;
$deleteuploads=array(); // cleanup
$initImagesBase64 = [];
if ($isUpload && !empty($attachments)) {
foreach ($attachments as $attPath) {
$localFile = __DIR__ . '/' . ltrim($attPath, './');
if (file_exists($localFile)) {
$mimeType = mime_content_type($localFile);
$imgBinary = file_get_contents($localFile);
$initImagesBase64[] = 'data:' . $mimeType . ';base64,' . base64_encode($imgBinary);
array_push($deleteuploads,$localFile);
}
}
}
$endpoint = '/sdapi/v1/txt2img';
$postData = [
"prompt" => $prompt,
"steps" => (int)$steps,
"width" => (int)$width,
"height" => (int)$height
];
if (!empty($initImagesBase64)) {
$endpoint = '/sdapi/v1/img2img';
$postData["init_images"] = $initImagesBase64;
$postData["denoising_strength"] = 0.75;
}
$ch = curl_init('http://'.$BACKEND_IP.':'.$BACKEND_IMG_PORT.$endpoint);
curl_setopt($ch, CURLOPT_TIMEOUT, $curl_timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $curl_connout);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($postData));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$apiResult = curl_exec($ch);
$curlError = curl_error($ch);
curl_close($ch);
if($cleanup_uploads) {
for($i=0;$i<count($deleteuploads);$i++) {
if($deleteuploads[$i]<>"") {
if(file_exists($deleteuploads[$i])) {
unlink($deleteuploads[$i]);
}
}
}
}
// Reset status to idle when done
updateChatStatusWorker($chatId, 'idle');
if ($apiResult) {
$data = json_decode($apiResult, true);
if (isset($data['images'][0])) {
$imgBinary = base64_decode($data['images'][0]);
$genDir = __DIR__ . "/chats/{$chatId}/generated/";
$prevDir = __DIR__ . "/chats/{$chatId}/generated_prev/";
if (!is_dir($genDir)) mkdir($genDir, 0777, true);
if (!is_dir($prevDir)) mkdir($prevDir, 0777, true);
$filename = 'img-' . time();
$pngPath = $genDir . $filename . '.png';
$jpgPath = $prevDir . $filename . '.jpg';
file_put_contents($pngPath, $imgBinary);
if (function_exists('imagecreatefromstring')) {
$srcImg = @imagecreatefromstring($imgBinary);
if ($srcImg) {
imagejpeg($srcImg, $jpgPath, 75);
imagedestroy($srcImg);
}
}
$imgPathRelative = './chats/' . $chatId . '/generated/' . $filename . '.png';
saveChatHistory($chatId, $prompt, $imgPathRelative, true, $attachments, $isUpload);
$jobSuccess = true;
workerLog("job completed generate_image - {$chatId}");
} else {
workerLog("generate_image apiResult no image0? {$apiResult}");
}
} else{
workerLog("generate_image invalid apiResult? {$apiResult}");
}
break;
case "upscale_image":
$msgIndex = $payload['message_index'];
$pngRelativePath = $payload['png_path'];
$localPng = __DIR__ . '/' . ltrim($pngRelativePath, './');
$filePath = __DIR__ . "/chats/{$chatId}.json";
// Start precise sub-second timer
$tstart = microtime(true);
if (file_exists($localPng) && file_exists($filePath)) {
$imgData = file_get_contents($localPng);
$base64Image = 'data:image/png;base64,' . base64_encode($imgData);
// Optional: Quick dynamic upscaler check or default fallback
$upscalerName = "R-ESRGAN 4x+";
$payloadData = [
"resize_mode" => 0,
"show_extras_results" => true,
"gfpgan_visibility" => 0,
"codeformer_visibility" => 0,
"codeformer_weight" => 0,
"upscaling_resize" => 2.0,
"upscaling_crop" => true,
"upscaler_1" => $upscalerName,
"upscaler_2" => "None",
"extras_upscaler_2_visibility" => 0,
"upscale_first" => false,
"image" => $base64Image
];
$ch = curl_init('http://'.$BACKEND_IP.':'.$BACKEND_IMG_PORT.'/sdapi/v1/extra-single-image');
curl_setopt($ch, CURLOPT_TIMEOUT, $curl_timeout);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $curl_connout);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payloadData));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
$apiResult = curl_exec($ch);
curl_close($ch);
// Stop precise timer
$tend = microtime(true);
$duration = $tend - $tstart;
// STRICT CHECK: If it completed in under 3 seconds, it's a false/unprocessed return!
if ($duration < 3.0) {
$response = array("status" => "error", "message" => "Upscale failed: Backend responded too fast (" . round($duration, 2) . "s), likely unprocessed.");
break;
}
if ($apiResult) {
$data = json_decode($apiResult, true);
if (isset($data['image']) && !empty($data['image'])) {
$upscaledBinary = base64_decode($data['image']);
$genDir = __DIR__ . "/chats/{$chatId}/generated/";
$prevDir = __DIR__ . "/chats/{$chatId}/generated_prev/";
if (!is_dir($genDir)) mkdir($genDir, 0777, true);
if (!is_dir($prevDir)) mkdir($prevDir, 0777, true);
$origFilename = pathinfo($localPng, PATHINFO_FILENAME);
$baseFilename = str_replace('-upscaled', '', $origFilename);
$filename = $baseFilename . "-upscaled";
$newPngPath = $genDir . $filename . ".png";
$newJpgPath = $prevDir . $filename . ".jpg";
file_put_contents($newPngPath, $upscaledBinary);
if (function_exists('imagecreatefromstring')) {
$srcImg = @imagecreatefromstring($upscaledBinary);
if ($srcImg) {
imagejpeg($srcImg, $newJpgPath, 75);
imagedestroy($srcImg);
}
}
// Cleanup old file versions if needed
$localJpgOld = str_replace(['/generated/', '.png'], ['/generated_prev/', '.jpg'], $localPng);
if (file_exists($localPng) && $localPng !== $newPngPath) @unlink($localPng);
if (file_exists($localJpgOld) && $localJpgOld !== $newJpgPath) @unlink($localJpgOld);
// Update message ledger
$messages = json_decode(file_get_contents($filePath), true) ?: [];
if (isset($messages[$msgIndex])) {
$messages[$msgIndex]['content'] = './chats/' . $chatId . '/generated/' . $filename . '.png';
$messages[$msgIndex]['is_upscaled'] = true;
$messages[$msgIndex]['time'] = time();
file_put_contents($filePath, json_encode($messages, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), LOCK_EX);
$jobSuccess = true;
workerLog("job completed upload_image - {$chatId}");
}
}
}
}
break;
}
} catch(Exception $e) {
$jobSuccess = false;
workerLog("job failed {$type} - {$chatId} [" . get_class($e) . "]: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}");
}
// Re-read queue, update final status by job_id, and save with LOCK_EX
$queueData = json_decode(file_get_contents($queueFile), true) ?: [];
updateJobStatus($queueData, $targetJobId, $jobSuccess ? 'completed' : 'error');
file_put_contents($queueFile, json_encode($queueData, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), LOCK_EX);
updateChatStatusWorker($chatId, 'idle');
}
flock($fp, LOCK_UN);
fclose($fp);
function saveChatHistory($chatId, $userText, $aiText, $isImage, $attachmentPaths, $isUpload) {
global $chatDir, $indexFile;
$filePath = $chatDir . $chatId . '.json';
$history = [];
if (file_exists($filePath)) {
$history = json_decode(file_get_contents($filePath), true) ?: [];
}
// Append user message with optional attachment & upload flags
$pos=count($history);
$history[] = [
"role" => "user",
"content" => $userText,
"is_image" => false,
"attachment" => $attachmentPaths,
"is_upload" => $isUpload,
"time" => time()
];
// pos length is now index match
if(count($attachmentPaths)>0) {
$history[$pos]['attachment'] = $attachmentPaths;
}
if($isUpload) {
$history[$pos]['is_upload'] = $isUpload;
}
// Append assistant response
$history[] = [
"role" => "assistant",
"content" => $aiText,
"is_image" => $isImage,
"time" => time()
];
file_put_contents($filePath, json_encode($history, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
// Update central index
$index = [];
if (file_exists($indexFile)) {
$index = json_decode(file_get_contents($indexFile), true) ?: [];
}
$found = false;
foreach ($index as &$item) {
if ($item['id'] === $chatId) {
$found = true;
// Auto-update title from first user prompt if it's still default
if ($item['title'] === 'New Chat' || empty($item['title'])) {
$item['title'] = strlen($userText) > 25 ? substr($userText, 0, 25) . '...' : $userText;
}
break;
}
}
if (!$found) {
$title = strlen($userText) > 25 ? substr($userText, 0, 25) . '...' : $userText;
$index[] = ["id" => $chatId, "title" => $title, "time" => time()];
}
file_put_contents($indexFile, json_encode($index, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
}
function updateChatStatusWorker($chatId, $status) {
global $indexFile;
if (!file_exists($indexFile)) return;
$index = json_decode(file_get_contents($indexFile), true) ?: [];
foreach ($index as &$item) {
if ($item['id'] === $chatId) {
$item['status'] = $status;
$item['time'] = time();
break;
}
}
file_put_contents($indexFile, json_encode($index, JSON_PRETTY_PRINT));
}
function workerLog($data) {
$ts=time();
file_put_contents(__DIR__ . '/worker.log', "{$ts}: {$data}\n", FILE_APPEND);
}
?>
Top