BxDolAiChatImages.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup UnaCore UNA Core
  7. * @{
  8. */
  9. /**
  10. * Images in agent chats: composer uploads (`sys_agents_chat_images` storage),
  11. * image parts of an AG-UI request, and NeuronAI content blocks built from them.
  12. */
  13. class BxDolAiChatImages
  14. {
  15. const STORAGE = 'sys_agents_chat_images';
  16. const MAX_PER_TURN = 4;
  17. public static function getInstance()
  18. {
  19. if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
  20. $GLOBALS['bxDolClasses'][__CLASS__] = new self();
  21. return $GLOBALS['bxDolClasses'][__CLASS__];
  22. }
  23. /**
  24. * Store one composer upload (`$_FILES` entry) into the chat images storage.
  25. *
  26. * @return array{file_id:int,url:string,mime:string}|array{error:string}
  27. */
  28. public function storeUpload($aFile, $iProfileId = 0)
  29. {
  30. $oStorage = BxDolStorage::getObjectInstance(self::STORAGE);
  31. if (!$oStorage)
  32. return ['error' => 'Storage is not available'];
  33. $sName = (string)($aFile['name'] ?? '');
  34. $sTmp = (string)($aFile['tmp_name'] ?? '');
  35. $iSize = (int)($aFile['size'] ?? 0);
  36. if ($sTmp === '' || !is_uploaded_file($sTmp))
  37. return ['error' => 'No file'];
  38. $iMax = $this->maxBytes();
  39. if ($iSize <= 0 || ($iMax > 0 && $iSize > $iMax))
  40. return ['error' => 'File is too large'];
  41. $sMime = strtolower((string)($aFile['type'] ?? ''));
  42. $sExt = strtolower(pathinfo($sName, PATHINFO_EXTENSION));
  43. $aMime = [
  44. 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
  45. 'gif' => 'image/gif', 'webp' => 'image/webp',
  46. ];
  47. if (!isset($aMime[$sExt]))
  48. return ['error' => 'Only JPEG, PNG, GIF, WebP'];
  49. if ($sMime && strncmp($sMime, 'image/', 6) !== 0)
  50. return ['error' => 'Only images'];
  51. $iId = (int)$oStorage->storeFileFromForm($aFile, false, (int)$iProfileId);
  52. if (!$iId)
  53. return ['error' => $oStorage->getErrorString() ?: 'Upload failed'];
  54. if (method_exists($oStorage, 'afterUploadCleanup'))
  55. $oStorage->afterUploadCleanup($iId, (int)$iProfileId);
  56. $sUrl = $oStorage->getFileUrlById($iId);
  57. if (!$sUrl)
  58. return ['error' => 'Upload failed'];
  59. $aInfo = $oStorage->getFile($iId) ?: [];
  60. return [
  61. 'file_id' => $iId,
  62. 'url' => $sUrl,
  63. 'mime' => (string)($aInfo['mime_type'] ?? $aMime[$sExt]),
  64. ];
  65. }
  66. /**
  67. * Image part of an AG-UI / stored message: `{type:image, source:{type:url|data,...}}`,
  68. * `{type:image_url, image_url:{url}}`, `{url}`, or a `file_id` from storeUpload.
  69. *
  70. * @return array{url:string,mime:string,file_id:int}|null
  71. */
  72. public function parsePart($aPart)
  73. {
  74. if (!is_array($aPart))
  75. return null;
  76. $sType = strtolower((string)($aPart['type'] ?? ''));
  77. $sUrl = '';
  78. $sMime = 'image/jpeg';
  79. $aSource = is_array($aPart['source'] ?? null) ? $aPart['source'] : [];
  80. $iFileId = (int)($aPart['file_id'] ?? $aSource['file_id'] ?? 0);
  81. if ($iFileId > 0) {
  82. $oStorage = BxDolStorage::getObjectInstance(self::STORAGE);
  83. $sStored = $oStorage ? (string)$oStorage->getFileUrlById($iFileId) : '';
  84. if ($sStored !== '')
  85. $sUrl = $sStored;
  86. }
  87. if ($sType === 'image' || $sType === 'image_url' || $sUrl !== '') {
  88. if ($sUrl === '' && ($aSource['type'] ?? '') === 'url')
  89. $sUrl = (string)($aSource['value'] ?? '');
  90. elseif ($sUrl === '' && !empty($aPart['url']))
  91. $sUrl = (string)$aPart['url'];
  92. elseif ($sUrl === '' && !empty($aPart['image_url']['url']))
  93. $sUrl = (string)$aPart['image_url']['url'];
  94. elseif ($sUrl === '' && ($sType === 'image' || $sType === 'image_url')) {
  95. $sContent = (string)($aPart['content'] ?? '');
  96. if ($sContent !== '' && (strpos($sContent, 'http://') === 0 || strpos($sContent, 'https://') === 0 || (strpos($sContent, '/') === 0 && strpos($sContent, '//') !== 0)))
  97. $sUrl = $sContent;
  98. }
  99. $sMime = (string)($aSource['mimeType'] ?? $aPart['media_type'] ?? $aPart['mediaType'] ?? $aPart['mime'] ?? $aPart['mimeType'] ?? $sMime);
  100. }
  101. $sUrl = $this->sanitizeUrl($sUrl);
  102. if ($sUrl === '')
  103. return null;
  104. return ['url' => $sUrl, 'mime' => $this->sanitizeMime($sMime), 'file_id' => $iFileId];
  105. }
  106. public function sanitizeMime($sMime)
  107. {
  108. $sMime = strtolower(trim((string)$sMime));
  109. return preg_match('#^image/(jpeg|png|gif|webp)$#', $sMime) ? $sMime : 'image/jpeg';
  110. }
  111. /**
  112. * http(s) URLs on this site or on the storage host. Site-relative paths are made absolute.
  113. */
  114. public function sanitizeUrl($sUrl)
  115. {
  116. $sUrl = trim((string)$sUrl);
  117. if ($sUrl === '' || preg_match('#^(javascript|data|file):#i', $sUrl))
  118. return '';
  119. if (strpos($sUrl, '/') === 0 && strpos($sUrl, '//') !== 0)
  120. $sUrl = rtrim(BX_DOL_URL_ROOT, '/') . $sUrl;
  121. $aUrl = parse_url($sUrl);
  122. if (empty($aUrl['scheme']) || empty($aUrl['host']) || !empty($aUrl['user']) || !empty($aUrl['pass']))
  123. return '';
  124. if (!in_array(strtolower($aUrl['scheme']), ['http', 'https'], true))
  125. return '';
  126. $sHost = strtolower(preg_replace('/^www\./', '', $aUrl['host']));
  127. return in_array($sHost, $this->allowedHosts(), true) ? $sUrl : '';
  128. }
  129. /**
  130. * This site, the storage domain, the storage endpoint, and the bucket host.
  131. */
  132. protected function allowedHosts()
  133. {
  134. $aHosts = [];
  135. $aRoot = parse_url(BX_DOL_URL_ROOT);
  136. if (!empty($aRoot['host']))
  137. $aHosts[] = strtolower(preg_replace('/^www\./', '', $aRoot['host']));
  138. $sDomain = trim((string)getParam('sys_storage_s3_domain'));
  139. $sEndpoint = trim((string)getParam('sys_storage_s3_endpoint'));
  140. $sBucket = strtolower(trim((string)getParam('sys_storage_s3_bucket')));
  141. $sEndpointHost = '';
  142. foreach ([$sDomain, $sEndpoint] as $sValue) {
  143. if ($sValue === '')
  144. continue;
  145. $aValue = parse_url(preg_match('#://#', $sValue) ? $sValue : 'https://' . $sValue);
  146. if (empty($aValue['host']))
  147. continue;
  148. $aHosts[] = strtolower($aValue['host']);
  149. if ($sValue === $sEndpoint)
  150. $sEndpointHost = strtolower($aValue['host']);
  151. }
  152. if (preg_match('/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/', $sBucket)) {
  153. if ($sEndpointHost !== '')
  154. $aHosts[] = $sBucket . '.' . $sEndpointHost;
  155. elseif ($sDomain === '')
  156. $aHosts[] = $sBucket . '.s3.amazonaws.com';
  157. }
  158. return $aHosts;
  159. }
  160. /**
  161. * NeuronAI user message: text block plus one image block per image.
  162. * Images that cannot be turned into a block are passed as their URL in text.
  163. *
  164. * @param array<int, array{url:string,mime?:string}> $aImages
  165. * @return NeuronAI\Chat\Messages\UserMessage|null null when there is nothing to send
  166. */
  167. public function makeUserMessage($sText, $aImages = [])
  168. {
  169. $aBlocks = [];
  170. $sText = (string)$sText;
  171. if ($sText !== '')
  172. $aBlocks[] = new NeuronAI\Chat\Messages\ContentBlocks\TextContent($sText);
  173. foreach ((array)$aImages as $aImage) {
  174. $sUrl = trim((string)($aImage['url'] ?? ''));
  175. $sMime = trim((string)($aImage['mime'] ?? 'image/jpeg'));
  176. $oImage = $this->makeNeuronImageContent($sUrl, $sMime);
  177. if ($oImage)
  178. $aBlocks[] = $oImage;
  179. elseif ($sUrl !== '')
  180. $aBlocks[] = new NeuronAI\Chat\Messages\ContentBlocks\TextContent($sUrl);
  181. }
  182. if (!$aBlocks)
  183. return null;
  184. try {
  185. return new NeuronAI\Chat\Messages\UserMessage($aBlocks);
  186. } catch (Throwable $oException) {
  187. $oMessage = new NeuronAI\Chat\Messages\UserMessage($sText !== '' ? $sText : ' ');
  188. foreach ($aBlocks as $oBlock) {
  189. if ($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\TextContent)
  190. continue;
  191. if (method_exists($oMessage, 'addContent'))
  192. $oMessage->addContent($oBlock);
  193. }
  194. return $oMessage;
  195. }
  196. }
  197. /**
  198. * Prefer base64 (the model does not have to fetch our URL); fall back to a URL block.
  199. */
  200. public function makeNeuronImageContent($sUrl, $sMime = 'image/jpeg')
  201. {
  202. $sUrl = $this->sanitizeUrl($sUrl);
  203. if ($sUrl === '')
  204. return null;
  205. $sClass = 'NeuronAI\\Chat\\Messages\\ContentBlocks\\ImageContent';
  206. if (!class_exists($sClass))
  207. return null;
  208. $sData = $this->urlToBase64($sUrl);
  209. if ($sData !== '') {
  210. if (method_exists($sClass, 'fromBase64'))
  211. return $sClass::fromBase64($sData, $sMime);
  212. if (class_exists('NeuronAI\\Chat\\Enums\\SourceType')) {
  213. try {
  214. return new $sClass($sData, NeuronAI\Chat\Enums\SourceType::BASE64, $sMime);
  215. } catch (Throwable $oException) {
  216. }
  217. }
  218. }
  219. if (method_exists($sClass, 'fromUrl'))
  220. return $sClass::fromUrl($sUrl);
  221. try {
  222. if (class_exists('NeuronAI\\Chat\\Enums\\SourceType'))
  223. return new $sClass($sUrl, NeuronAI\Chat\Enums\SourceType::URL, $sMime);
  224. return new $sClass($sUrl);
  225. } catch (Throwable $oException) {
  226. return null;
  227. }
  228. }
  229. /**
  230. * NeuronAI ImageContent block → UI part (`{type:image, source:{type:url|data,...}}`).
  231. */
  232. public function neuronBlockToUiPart($oBlock)
  233. {
  234. if (!($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\ImageContent))
  235. return null;
  236. $sContent = (string)($oBlock->content ?? '');
  237. if ($sContent === '')
  238. return null;
  239. $sMime = (string)($oBlock->mediaType ?? 'image/jpeg');
  240. $sSource = '';
  241. if (isset($oBlock->sourceType)) {
  242. $sSource = $oBlock->sourceType instanceof \BackedEnum
  243. ? (string)$oBlock->sourceType->value
  244. : (string)$oBlock->sourceType;
  245. }
  246. if ($sSource === 'base64')
  247. return ['type' => 'image', 'source' => ['type' => 'data', 'value' => $sContent, 'mimeType' => $sMime]];
  248. $sUrl = $this->sanitizeUrl($sContent);
  249. if ($sUrl === '')
  250. return null;
  251. return ['type' => 'image', 'source' => ['type' => 'url', 'value' => $sUrl, 'mimeType' => $sMime]];
  252. }
  253. protected function urlToBase64($sUrl)
  254. {
  255. $sUrl = $this->sanitizeUrl($sUrl);
  256. if ($sUrl === '')
  257. return '';
  258. $sLocal = $this->urlToLocalPath($sUrl);
  259. $sBin = ($sLocal !== '' && is_readable($sLocal)) ? @file_get_contents($sLocal) : false;
  260. if (!is_string($sBin) || $sBin === '') {
  261. $sCode = null;
  262. $sBin = bx_file_get_contents($sUrl, [], 'get', [], $sCode, [], 10, [CURLOPT_FOLLOWLOCATION => false]);
  263. }
  264. $iMax = $this->maxBytes();
  265. if (!is_string($sBin) || $sBin === '' || ($iMax > 0 && strlen($sBin) > $iMax))
  266. return '';
  267. return base64_encode($sBin);
  268. }
  269. /**
  270. * `max_file_size` of the chat images storage, in bytes. 0 means that object sets no limit.
  271. */
  272. public function maxBytes()
  273. {
  274. $oStorage = BxDolStorage::getObjectInstance(self::STORAGE);
  275. if (!$oStorage)
  276. return 0;
  277. return (int)($oStorage->getObjectData()['max_file_size'] ?? 0);
  278. }
  279. /**
  280. * Local path of a file in the chat images storage, '' when the URL is not ours.
  281. */
  282. protected function urlToLocalPath($sUrl)
  283. {
  284. if (!defined('BX_DIRECTORY_STORAGE'))
  285. return '';
  286. $aUrl = parse_url((string)$sUrl);
  287. $sPath = urldecode((string)($aUrl['path'] ?? ''));
  288. $aQuery = [];
  289. if (!empty($aUrl['query']))
  290. parse_str((string)$aUrl['query'], $aQuery);
  291. $sFile = '';
  292. if (!empty($aQuery['f']))
  293. $sFile = basename((string)$aQuery['f']);
  294. elseif (preg_match('#/' . preg_quote(self::STORAGE, '#') . '/([^/?]+)$#', $sPath, $aMatch))
  295. $sFile = basename((string)$aMatch[1]);
  296. if ($sFile === '' || strpos($sFile, '..') !== false)
  297. return '';
  298. $sLocal = BX_DIRECTORY_STORAGE . self::STORAGE . '/' . $sFile;
  299. return is_file($sLocal) ? $sLocal : '';
  300. }
  301. }
  302. /** @} */