BxDolAiChatUi.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531
  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. class BxDolAiChatUi
  10. {
  11. protected $_oImages;
  12. public static function getInstance()
  13. {
  14. if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
  15. $GLOBALS['bxDolClasses'][__CLASS__] = new self();
  16. return $GLOBALS['bxDolClasses'][__CLASS__];
  17. }
  18. public function __construct()
  19. {
  20. $this->_oImages = BxDolAiChatImages::getInstance();
  21. }
  22. public function storedChatJsonToUiMessages($sJson)
  23. {
  24. $aStored = json_decode((string)$sJson, true);
  25. if (is_string($aStored))
  26. $aStored = json_decode($aStored, true);
  27. if (!is_array($aStored) || $aStored === [])
  28. return [];
  29. if (isset($aStored['messages']) && is_array($aStored['messages']))
  30. $aStored = $aStored['messages'];
  31. if (!isset($aStored[0]) && isset($aStored['role']))
  32. $aStored = [$aStored];
  33. $aResult = [];
  34. $i = 0;
  35. foreach ($aStored as $aMessage) {
  36. if (!is_array($aMessage))
  37. continue;
  38. // A tool call turn keeps the assistant text said before the call
  39. // (then chat_buttons, etc.): show it, only the tool result is internal.
  40. $sType = $this->storedEnumString($aMessage['type'] ?? '');
  41. if ($sType === 'tool_call_result')
  42. continue;
  43. $sRole = $this->storedEnumString($aMessage['role'] ?? $aMessage['author'] ?? '');
  44. if ($sRole === 'model' || $sType === 'tool_call')
  45. $sRole = 'assistant';
  46. if ($sRole === 'human')
  47. $sRole = 'user';
  48. if ($sRole !== 'user' && $sRole !== 'assistant')
  49. continue;
  50. $aParts = [];
  51. if (!empty($aMessage['parts']) && is_array($aMessage['parts'])) {
  52. foreach ($aMessage['parts'] as $aBlock) {
  53. $aPart = $this->storedContentBlockToUiPart($aBlock);
  54. if ($aPart)
  55. $aParts[] = $aPart;
  56. }
  57. }
  58. $mixedContent = $aMessage['content'] ?? $aMessage['contents'] ?? '';
  59. if (is_string($mixedContent) && $mixedContent !== '') {
  60. $aParts[] = ['type' => 'text', 'content' => $mixedContent];
  61. } elseif (is_array($mixedContent)) {
  62. if (isset($mixedContent['type']) && !isset($mixedContent[0]))
  63. $mixedContent = [$mixedContent];
  64. foreach ($mixedContent as $aBlock) {
  65. $aPart = $this->storedContentBlockToUiPart($aBlock);
  66. if ($aPart)
  67. $aParts[] = $aPart;
  68. }
  69. }
  70. // trigger payloads (message / alert / form-input / webhook) are stored as raw JSON
  71. // in the user turn — show the human text instead of the escaped blob
  72. if ($sRole === 'user') {
  73. foreach ($aParts as &$aPart) {
  74. if (($aPart['type'] ?? '') === 'text')
  75. $aPart['content'] = $this->humanizeTriggerPayload((string)$aPart['content']);
  76. }
  77. unset($aPart);
  78. }
  79. $sId = '';
  80. if (!empty($aMessage['__id']) && is_string($aMessage['__id']))
  81. $sId = $aMessage['__id'];
  82. elseif (!empty($aMessage['metadata']['__id']) && is_string($aMessage['metadata']['__id']))
  83. $sId = $aMessage['metadata']['__id'];
  84. $mixedActions = $aMessage['actions'] ?? ($aMessage['metadata']['actions'] ?? null);
  85. $aUi = $this->finalizeUiChatMessage($sId !== '' ? $sId : ('msg_' . $i), $sRole, $aParts, $mixedActions);
  86. if (!$aUi)
  87. continue;
  88. $aResult[] = $aUi;
  89. $i++;
  90. }
  91. return $this->foldUiChatMessages($aResult);
  92. }
  93. /**
  94. * @param NeuronAI\Chat\Messages\Message[] $aMessages
  95. * @return array<int, array<string, mixed>>
  96. */
  97. public function neuronMessagesToUiMessages($aMessages)
  98. {
  99. $aResult = [];
  100. $i = 0;
  101. foreach ($aMessages as $oMessage) {
  102. // ToolCallMessage is an assistant message holding the text said before
  103. // the call; only the tool result is skipped (see storedChatJsonToUiMessages).
  104. if ($oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage)
  105. continue;
  106. $sRole = $oMessage->getRole();
  107. if ($sRole === 'model' || $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage)
  108. $sRole = 'assistant';
  109. if ($sRole !== 'user' && $sRole !== 'assistant')
  110. continue;
  111. $aParts = [];
  112. foreach ($oMessage->getContentBlocks() as $oBlock) {
  113. if ($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\ReasoningContent) {
  114. if ($oBlock->content !== '')
  115. $aParts[] = ['type' => 'thinking', 'content' => $oBlock->content];
  116. continue;
  117. }
  118. if ($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\TextContent && $oBlock->content !== '')
  119. $aParts[] = ['type' => 'text', 'content' => $oBlock->content];
  120. $aImage = $this->_oImages->neuronBlockToUiPart($oBlock);
  121. if ($aImage)
  122. $aParts[] = $aImage;
  123. }
  124. $sId = $oMessage->getMetadata('__id');
  125. $aUi = $this->finalizeUiChatMessage(
  126. is_string($sId) && $sId !== '' ? $sId : ('msg_' . $i),
  127. $sRole,
  128. $aParts,
  129. $sRole === 'assistant' ? $oMessage->getMetadata('actions') : null
  130. );
  131. if (!$aUi)
  132. continue;
  133. $aResult[] = $aUi;
  134. $i++;
  135. }
  136. return $this->foldUiChatMessages($aResult);
  137. }
  138. /**
  139. * Split assistant JSON {content, actions} into UI text + actions.
  140. * User messages pass through unchanged.
  141. *
  142. * @param array<int, array<string, mixed>> $aParts
  143. * @return array<string, mixed>|null
  144. */
  145. public function finalizeUiChatMessage($sId, $sRole, $aParts, $mixedActions = null)
  146. {
  147. $aActions = $sRole === 'assistant' ? $this->sanitizeChatActions($mixedActions) : [];
  148. if ($sRole === 'assistant') {
  149. $sText = '';
  150. foreach ($aParts as $aPart) {
  151. if (($aPart['type'] ?? '') === 'text')
  152. $sText .= (string)($aPart['content'] ?? '');
  153. }
  154. $aParsed = $this->parseAssistantChatPayload($sText);
  155. if ($aParsed) {
  156. $aKept = [];
  157. foreach ($aParts as $aPart) {
  158. if (($aPart['type'] ?? '') === 'text')
  159. continue;
  160. $aKept[] = $aPart;
  161. }
  162. if ($aParsed['content'] !== '')
  163. $aKept[] = ['type' => 'text', 'content' => $aParsed['content']];
  164. $aParts = $aKept;
  165. $aActions = $this->mergeChatActions($aActions, $aParsed['actions']);
  166. }
  167. }
  168. if (!$aParts && !$aActions)
  169. return null;
  170. $aOut = [
  171. 'id' => $sId,
  172. 'role' => $sRole,
  173. 'parts' => $aParts,
  174. ];
  175. if ($sRole === 'assistant') {
  176. $aOut['actions'] = $aActions;
  177. if ($aActions)
  178. $aOut['metadata'] = ['actions' => $aActions];
  179. }
  180. return $aOut;
  181. }
  182. /**
  183. * One model turn can be stored as several assistant messages: the text before a
  184. * tool call (ToolCallMessage), then the reply after the tool result — often empty
  185. * and carrying only `actions`. Show them as one bubble, like the live stream does.
  186. * The first message's id is kept; text is joined, actions are merged.
  187. *
  188. * @param array<int, array<string, mixed>> $aMessages
  189. * @return array<int, array<string, mixed>>
  190. */
  191. public function foldUiChatMessages($aMessages)
  192. {
  193. $aOut = [];
  194. foreach ($aMessages as $aMessage) {
  195. $iLast = count($aOut) - 1;
  196. $aPrev = $iLast >= 0 ? $aOut[$iLast] : null;
  197. if (($aMessage['role'] ?? '') !== 'assistant' || ($aPrev['role'] ?? '') !== 'assistant') {
  198. $aOut[] = $aMessage;
  199. continue;
  200. }
  201. $sText = '';
  202. $aMedia = [];
  203. foreach (array_merge($aPrev['parts'] ?? [], $aMessage['parts'] ?? []) as $aPart) {
  204. if (($aPart['type'] ?? '') === 'text') {
  205. $sChunk = (string)($aPart['content'] ?? '');
  206. if ($sChunk !== '')
  207. $sText .= ($sText !== '' ? "\n\n" : '') . $sChunk;
  208. continue;
  209. }
  210. $aMedia[] = $aPart;
  211. }
  212. $aParts = $sText !== '' ? [['type' => 'text', 'content' => $sText]] : [];
  213. $aActions = $this->mergeChatActions($aPrev['actions'] ?? [], $aMessage['actions'] ?? []);
  214. $aMerged = array_merge($aPrev, [
  215. 'parts' => array_merge($aParts, $aMedia),
  216. 'actions' => $aActions,
  217. ]);
  218. if ($aActions)
  219. $aMerged['metadata'] = ['actions' => $aActions];
  220. else
  221. unset($aMerged['metadata']);
  222. $aOut[$iLast] = $aMerged;
  223. }
  224. return $aOut;
  225. }
  226. /**
  227. * @return array{content: string, actions: array}|null
  228. */
  229. public function parseAssistantChatPayload($sText)
  230. {
  231. $s = trim((string)$sText);
  232. if ($s === '')
  233. return null;
  234. if (preg_match('/^```(?:json)?\s*(\{.*\})\s*```$/s', $s, $aM))
  235. $s = trim($aM[1]);
  236. if ($s === '' || $s[0] !== '{') {
  237. $iStart = strpos($s, '{');
  238. $iEnd = strrpos($s, '}');
  239. if ($iStart === false || $iEnd === false || $iEnd <= $iStart)
  240. return null;
  241. $s = substr($s, $iStart, $iEnd - $iStart + 1);
  242. }
  243. $a = json_decode($s, true);
  244. if (!is_array($a) || !array_key_exists('content', $a) || !is_string($a['content']))
  245. return null;
  246. if (!array_key_exists('actions', $a))
  247. return null;
  248. return [
  249. 'content' => $a['content'],
  250. 'actions' => $this->sanitizeChatActions($a['actions']),
  251. ];
  252. }
  253. /**
  254. * Union of two action lists, duplicates (same type + label + url) dropped, at most 8.
  255. *
  256. * @return array<int, array<string, mixed>>
  257. */
  258. public function mergeChatActions($a, $b)
  259. {
  260. $aAll = array_merge($this->sanitizeChatActions($a), $this->sanitizeChatActions($b));
  261. $aOut = [];
  262. $aSeen = [];
  263. foreach ($aAll as $aItem) {
  264. $sKey = ($aItem['type'] ?? '') . '|' . ($aItem['label'] ?? '') . '|' . ($aItem['url'] ?? ($aItem['form_url'] ?? ''));
  265. if (isset($aSeen[$sKey]))
  266. continue;
  267. $aSeen[$sKey] = true;
  268. $aOut[] = $aItem;
  269. if (count($aOut) >= 8)
  270. break;
  271. }
  272. return $aOut;
  273. }
  274. /**
  275. * @return array<int, array<string, mixed>>
  276. */
  277. public function sanitizeChatActions($mixed)
  278. {
  279. if (!is_array($mixed))
  280. return [];
  281. $aOut = [];
  282. foreach ($mixed as $aItem) {
  283. if (!is_array($aItem))
  284. continue;
  285. $sType = strtolower(trim((string)($aItem['type'] ?? '')));
  286. $sLabel = trim((string)($aItem['label'] ?? ''));
  287. if ($sLabel === '')
  288. continue;
  289. if (function_exists('mb_substr'))
  290. $sLabel = mb_substr($sLabel, 0, 80);
  291. else
  292. $sLabel = substr($sLabel, 0, 80);
  293. if ($sType === 'reply') {
  294. $aOut[] = ['type' => 'reply', 'label' => $sLabel];
  295. } elseif ($sType === 'link') {
  296. $sUrl = trim((string)($aItem['url'] ?? ''));
  297. if (!$this->isAllowedChatActionUrl($sUrl))
  298. continue;
  299. $aOut[] = ['type' => 'link', 'label' => $sLabel, 'url' => $sUrl];
  300. } elseif ($sType === 'form') {
  301. // form opened in a popup over the chat: 'module/method/&params[]=..' API service call
  302. $sFormUrl = trim((string)($aItem['form_url'] ?? ''));
  303. if (!preg_match('/^[a-z0-9_]+\/[a-z0-9_]+\/?(&params\[\]=[A-Za-z0-9_\-\.]*)*$/', $sFormUrl))
  304. continue;
  305. $aForm = ['type' => 'form', 'label' => $sLabel, 'form_url' => $sFormUrl];
  306. $sMessage = trim(strip_tags((string)($aItem['message'] ?? '')));
  307. if ($sMessage !== '')
  308. $aForm['message'] = function_exists('mb_substr') ? mb_substr($sMessage, 0, 200) : substr($sMessage, 0, 200);
  309. $aOut[] = $aForm;
  310. }
  311. if (count($aOut) >= 8)
  312. break;
  313. }
  314. return $aOut;
  315. }
  316. /** Text parts of a UI message joined, tags stripped, whitespace collapsed. */
  317. public function uiChatMessagePlainText($aMessage)
  318. {
  319. $aTexts = [];
  320. foreach ((array)($aMessage['parts'] ?? []) as $aPart) {
  321. if (is_array($aPart) && ($aPart['type'] ?? '') === 'text' && is_string($aPart['content'] ?? null))
  322. $aTexts[] = $aPart['content'];
  323. }
  324. $s = strip_tags(html_entity_decode(implode(' ', $aTexts), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
  325. return trim(preg_replace('/\s+/u', ' ', $s));
  326. }
  327. /**
  328. * A user turn produced by a trigger is the JSON that callAgent() built
  329. * (BxDolAlerts, BxDolAiAlertResponse, webhook, form-input). Return the text
  330. * a person would want to read; anything that is not such a payload is returned as is.
  331. */
  332. public function humanizeTriggerPayload($sText)
  333. {
  334. $s = trim((string)$sText);
  335. if ($s === '' || $s[0] !== '{')
  336. return $sText;
  337. $a = json_decode($s, true);
  338. if (!is_array($a))
  339. return $sText;
  340. $sTrigger = is_string($a['trigger'] ?? null) ? $a['trigger'] : '';
  341. if ($sTrigger === '' && (isset($a['form_values']) || isset($a['user_prompt'])))
  342. $sTrigger = 'form-input';
  343. if ($sTrigger === '')
  344. return $sText;
  345. $fText = function ($m) {
  346. $s = trim((string)$m);
  347. return $s !== '' ? trim(strip_tags(html_entity_decode($s, ENT_QUOTES, 'UTF-8'))) : '';
  348. };
  349. switch ($sTrigger) {
  350. case 'message':
  351. $sMsg = $fText($a['message_text'] ?? ($a['message_info']['message'] ?? ''));
  352. return $sMsg !== '' ? $sMsg : $sText;
  353. case 'alert':
  354. $sUnit = (string)($a['unit'] ?? '');
  355. $sAction = (string)($a['action'] ?? '');
  356. $sHead = trim($sUnit . ':' . $sAction, ':');
  357. if (!empty($a['object_id']))
  358. $sHead .= ' #' . (int)$a['object_id'];
  359. $sBody = '';
  360. $aExtra = is_array($a['extra'] ?? null) ? $a['extra'] : [];
  361. foreach (['comment_text', 'cmt_text', 'text', 'message', 'title', 'content'] as $sKey) {
  362. if (!empty($aExtra[$sKey]) && is_string($aExtra[$sKey])) {
  363. $sBody = $fText($aExtra[$sKey]);
  364. break;
  365. }
  366. }
  367. if ($sBody === '' && !empty($aExtra['comment_id']))
  368. $sBody = 'comment #' . (int)$aExtra['comment_id'];
  369. return $sHead . ($sBody !== '' ? "\n" . $sBody : '');
  370. case 'form-input':
  371. $aOut = [];
  372. $sPrompt = $fText($a['user_prompt'] ?? '');
  373. if ($sPrompt !== '')
  374. $aOut[] = $sPrompt;
  375. $aValues = is_array($a['form_values'] ?? null) ? $a['form_values'] : [];
  376. foreach ($aValues as $sKey => $mixedVal) {
  377. if (is_scalar($mixedVal) && (string)$mixedVal !== '')
  378. $aOut[] = $sKey . ': ' . $fText($mixedVal);
  379. }
  380. $sHead = trim((string)($a['form_field_name'] ?? ''));
  381. return trim($sHead . ($aOut ? "\n" . implode("\n", $aOut) : ''));
  382. case 'webhook':
  383. $aOut = [];
  384. foreach ($a as $sKey => $mixedVal) {
  385. if ($sKey === 'trigger')
  386. continue;
  387. if (is_scalar($mixedVal) && (string)$mixedVal !== '')
  388. $aOut[] = $sKey . ': ' . $fText($mixedVal);
  389. elseif (is_array($mixedVal))
  390. $aOut[] = $sKey . ': ' . json_encode($mixedVal, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  391. }
  392. return 'webhook' . ($aOut ? "\n" . implode("\n", $aOut) : '');
  393. }
  394. return $sText;
  395. }
  396. /**
  397. * Neuron / UI content block from stored chat JSON → UI part.
  398. *
  399. * @param mixed $mixedBlock
  400. * @return array<string, mixed>|null
  401. */
  402. protected function storedContentBlockToUiPart($mixedBlock)
  403. {
  404. if (is_string($mixedBlock) && $mixedBlock !== '')
  405. return ['type' => 'text', 'content' => $mixedBlock];
  406. if (!is_array($mixedBlock))
  407. return null;
  408. $sType = $this->storedEnumString($mixedBlock['type'] ?? '');
  409. if (strpos($sType, 'textcontent') !== false)
  410. $sType = 'text';
  411. if (strpos($sType, 'imagecontent') !== false)
  412. $sType = 'image';
  413. $sText = (string)($mixedBlock['content'] ?? $mixedBlock['text'] ?? '');
  414. if ($sType === 'text' || $sType === '' || $sType === 'array')
  415. return $sText !== '' ? ['type' => 'text', 'content' => $sText] : null;
  416. if ($sType === 'reasoning' || $sType === 'thinking')
  417. return $sText !== '' ? ['type' => 'thinking', 'content' => $sText] : null;
  418. $aImage = $this->storedImageBlockToUiPart($mixedBlock);
  419. return $aImage ?: null;
  420. }
  421. /**
  422. * @param array<string, mixed> $aBlock
  423. * @return array<string, mixed>|null
  424. */
  425. protected function storedImageBlockToUiPart($aBlock)
  426. {
  427. $aSource = is_array($aBlock['source'] ?? null) ? $aBlock['source'] : [];
  428. $sSourceKind = strtolower((string)($aSource['type'] ?? ''));
  429. $sNeuronSource = $this->storedEnumString($aBlock['source_type'] ?? $aBlock['sourceType'] ?? '');
  430. $sMime = $this->_oImages->sanitizeMime($aSource['mimeType'] ?? $aBlock['media_type'] ?? $aBlock['mediaType'] ?? $aBlock['mime'] ?? $aBlock['mimeType'] ?? 'image/jpeg');
  431. if ($sSourceKind === 'data' && (string)($aSource['value'] ?? '') !== '')
  432. return ['type' => 'image', 'source' => ['type' => 'data', 'value' => (string)$aSource['value'], 'mimeType' => $sMime]];
  433. if (($sNeuronSource === 'base64' || $sNeuronSource === 'data') && (string)($aBlock['content'] ?? '') !== '')
  434. return ['type' => 'image', 'source' => ['type' => 'data', 'value' => (string)$aBlock['content'], 'mimeType' => $sMime]];
  435. $aParsed = $this->_oImages->parsePart($aBlock);
  436. if ($aParsed)
  437. return ['type' => 'image', 'source' => ['type' => 'url', 'value' => $aParsed['url'], 'mimeType' => $aParsed['mime']]];
  438. return null;
  439. }
  440. /**
  441. * Enum (object, `{value}` array or string) from stored JSON → lower-case string.
  442. *
  443. * @param mixed $mixed
  444. */
  445. protected function storedEnumString($mixed)
  446. {
  447. if (is_object($mixed) && $mixed instanceof \BackedEnum)
  448. return strtolower((string)$mixed->value);
  449. if (is_array($mixed))
  450. return strtolower((string)($mixed['value'] ?? $mixed['name'] ?? ''));
  451. return strtolower((string)$mixed);
  452. }
  453. protected function isAllowedChatActionUrl($sUrl)
  454. {
  455. $a = parse_url((string)$sUrl);
  456. if (($a['scheme'] ?? '') !== 'https' || empty($a['host']))
  457. return false;
  458. $sHost = strtolower((string)$a['host']);
  459. if ($sHost === 'localhost' || str_ends_with($sHost, '.localhost'))
  460. return false;
  461. if (filter_var($sHost, FILTER_VALIDATE_IP))
  462. return (bool)filter_var($sHost, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
  463. return true;
  464. }
  465. }
  466. /** @} */