| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531 |
- <?php defined('BX_DOL') or die('hack attempt');
- /**
- * Copyright (c) UNA, Inc - https://una.io
- * MIT License - https://opensource.org/licenses/MIT
- *
- * @defgroup UnaCore UNA Core
- * @{
- */
- class BxDolAiChatUi
- {
- protected $_oImages;
- public static function getInstance()
- {
- if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
- $GLOBALS['bxDolClasses'][__CLASS__] = new self();
- return $GLOBALS['bxDolClasses'][__CLASS__];
- }
- public function __construct()
- {
- $this->_oImages = BxDolAiChatImages::getInstance();
- }
- public function storedChatJsonToUiMessages($sJson)
- {
- $aStored = json_decode((string)$sJson, true);
- if (is_string($aStored))
- $aStored = json_decode($aStored, true);
- if (!is_array($aStored) || $aStored === [])
- return [];
- if (isset($aStored['messages']) && is_array($aStored['messages']))
- $aStored = $aStored['messages'];
- if (!isset($aStored[0]) && isset($aStored['role']))
- $aStored = [$aStored];
- $aResult = [];
- $i = 0;
- foreach ($aStored as $aMessage) {
- if (!is_array($aMessage))
- continue;
- // A tool call turn keeps the assistant text said before the call
- // (then chat_buttons, etc.): show it, only the tool result is internal.
- $sType = $this->storedEnumString($aMessage['type'] ?? '');
- if ($sType === 'tool_call_result')
- continue;
- $sRole = $this->storedEnumString($aMessage['role'] ?? $aMessage['author'] ?? '');
- if ($sRole === 'model' || $sType === 'tool_call')
- $sRole = 'assistant';
- if ($sRole === 'human')
- $sRole = 'user';
- if ($sRole !== 'user' && $sRole !== 'assistant')
- continue;
- $aParts = [];
- if (!empty($aMessage['parts']) && is_array($aMessage['parts'])) {
- foreach ($aMessage['parts'] as $aBlock) {
- $aPart = $this->storedContentBlockToUiPart($aBlock);
- if ($aPart)
- $aParts[] = $aPart;
- }
- }
- $mixedContent = $aMessage['content'] ?? $aMessage['contents'] ?? '';
- if (is_string($mixedContent) && $mixedContent !== '') {
- $aParts[] = ['type' => 'text', 'content' => $mixedContent];
- } elseif (is_array($mixedContent)) {
- if (isset($mixedContent['type']) && !isset($mixedContent[0]))
- $mixedContent = [$mixedContent];
- foreach ($mixedContent as $aBlock) {
- $aPart = $this->storedContentBlockToUiPart($aBlock);
- if ($aPart)
- $aParts[] = $aPart;
- }
- }
- // trigger payloads (message / alert / form-input / webhook) are stored as raw JSON
- // in the user turn — show the human text instead of the escaped blob
- if ($sRole === 'user') {
- foreach ($aParts as &$aPart) {
- if (($aPart['type'] ?? '') === 'text')
- $aPart['content'] = $this->humanizeTriggerPayload((string)$aPart['content']);
- }
- unset($aPart);
- }
- $sId = '';
- if (!empty($aMessage['__id']) && is_string($aMessage['__id']))
- $sId = $aMessage['__id'];
- elseif (!empty($aMessage['metadata']['__id']) && is_string($aMessage['metadata']['__id']))
- $sId = $aMessage['metadata']['__id'];
- $mixedActions = $aMessage['actions'] ?? ($aMessage['metadata']['actions'] ?? null);
- $aUi = $this->finalizeUiChatMessage($sId !== '' ? $sId : ('msg_' . $i), $sRole, $aParts, $mixedActions);
- if (!$aUi)
- continue;
- $aResult[] = $aUi;
- $i++;
- }
- return $this->foldUiChatMessages($aResult);
- }
- /**
- * @param NeuronAI\Chat\Messages\Message[] $aMessages
- * @return array<int, array<string, mixed>>
- */
- public function neuronMessagesToUiMessages($aMessages)
- {
- $aResult = [];
- $i = 0;
- foreach ($aMessages as $oMessage) {
- // ToolCallMessage is an assistant message holding the text said before
- // the call; only the tool result is skipped (see storedChatJsonToUiMessages).
- if ($oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage)
- continue;
- $sRole = $oMessage->getRole();
- if ($sRole === 'model' || $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage)
- $sRole = 'assistant';
- if ($sRole !== 'user' && $sRole !== 'assistant')
- continue;
- $aParts = [];
- foreach ($oMessage->getContentBlocks() as $oBlock) {
- if ($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\ReasoningContent) {
- if ($oBlock->content !== '')
- $aParts[] = ['type' => 'thinking', 'content' => $oBlock->content];
- continue;
- }
- if ($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\TextContent && $oBlock->content !== '')
- $aParts[] = ['type' => 'text', 'content' => $oBlock->content];
- $aImage = $this->_oImages->neuronBlockToUiPart($oBlock);
- if ($aImage)
- $aParts[] = $aImage;
- }
- $sId = $oMessage->getMetadata('__id');
- $aUi = $this->finalizeUiChatMessage(
- is_string($sId) && $sId !== '' ? $sId : ('msg_' . $i),
- $sRole,
- $aParts,
- $sRole === 'assistant' ? $oMessage->getMetadata('actions') : null
- );
- if (!$aUi)
- continue;
- $aResult[] = $aUi;
- $i++;
- }
- return $this->foldUiChatMessages($aResult);
- }
- /**
- * Split assistant JSON {content, actions} into UI text + actions.
- * User messages pass through unchanged.
- *
- * @param array<int, array<string, mixed>> $aParts
- * @return array<string, mixed>|null
- */
- public function finalizeUiChatMessage($sId, $sRole, $aParts, $mixedActions = null)
- {
- $aActions = $sRole === 'assistant' ? $this->sanitizeChatActions($mixedActions) : [];
- if ($sRole === 'assistant') {
- $sText = '';
- foreach ($aParts as $aPart) {
- if (($aPart['type'] ?? '') === 'text')
- $sText .= (string)($aPart['content'] ?? '');
- }
- $aParsed = $this->parseAssistantChatPayload($sText);
- if ($aParsed) {
- $aKept = [];
- foreach ($aParts as $aPart) {
- if (($aPart['type'] ?? '') === 'text')
- continue;
- $aKept[] = $aPart;
- }
- if ($aParsed['content'] !== '')
- $aKept[] = ['type' => 'text', 'content' => $aParsed['content']];
- $aParts = $aKept;
- $aActions = $this->mergeChatActions($aActions, $aParsed['actions']);
- }
- }
- if (!$aParts && !$aActions)
- return null;
- $aOut = [
- 'id' => $sId,
- 'role' => $sRole,
- 'parts' => $aParts,
- ];
- if ($sRole === 'assistant') {
- $aOut['actions'] = $aActions;
- if ($aActions)
- $aOut['metadata'] = ['actions' => $aActions];
- }
- return $aOut;
- }
- /**
- * One model turn can be stored as several assistant messages: the text before a
- * tool call (ToolCallMessage), then the reply after the tool result — often empty
- * and carrying only `actions`. Show them as one bubble, like the live stream does.
- * The first message's id is kept; text is joined, actions are merged.
- *
- * @param array<int, array<string, mixed>> $aMessages
- * @return array<int, array<string, mixed>>
- */
- public function foldUiChatMessages($aMessages)
- {
- $aOut = [];
- foreach ($aMessages as $aMessage) {
- $iLast = count($aOut) - 1;
- $aPrev = $iLast >= 0 ? $aOut[$iLast] : null;
- if (($aMessage['role'] ?? '') !== 'assistant' || ($aPrev['role'] ?? '') !== 'assistant') {
- $aOut[] = $aMessage;
- continue;
- }
- $sText = '';
- $aMedia = [];
- foreach (array_merge($aPrev['parts'] ?? [], $aMessage['parts'] ?? []) as $aPart) {
- if (($aPart['type'] ?? '') === 'text') {
- $sChunk = (string)($aPart['content'] ?? '');
- if ($sChunk !== '')
- $sText .= ($sText !== '' ? "\n\n" : '') . $sChunk;
- continue;
- }
- $aMedia[] = $aPart;
- }
- $aParts = $sText !== '' ? [['type' => 'text', 'content' => $sText]] : [];
- $aActions = $this->mergeChatActions($aPrev['actions'] ?? [], $aMessage['actions'] ?? []);
- $aMerged = array_merge($aPrev, [
- 'parts' => array_merge($aParts, $aMedia),
- 'actions' => $aActions,
- ]);
- if ($aActions)
- $aMerged['metadata'] = ['actions' => $aActions];
- else
- unset($aMerged['metadata']);
- $aOut[$iLast] = $aMerged;
- }
- return $aOut;
- }
- /**
- * @return array{content: string, actions: array}|null
- */
- public function parseAssistantChatPayload($sText)
- {
- $s = trim((string)$sText);
- if ($s === '')
- return null;
- if (preg_match('/^```(?:json)?\s*(\{.*\})\s*```$/s', $s, $aM))
- $s = trim($aM[1]);
- if ($s === '' || $s[0] !== '{') {
- $iStart = strpos($s, '{');
- $iEnd = strrpos($s, '}');
- if ($iStart === false || $iEnd === false || $iEnd <= $iStart)
- return null;
- $s = substr($s, $iStart, $iEnd - $iStart + 1);
- }
- $a = json_decode($s, true);
- if (!is_array($a) || !array_key_exists('content', $a) || !is_string($a['content']))
- return null;
- if (!array_key_exists('actions', $a))
- return null;
- return [
- 'content' => $a['content'],
- 'actions' => $this->sanitizeChatActions($a['actions']),
- ];
- }
- /**
- * Union of two action lists, duplicates (same type + label + url) dropped, at most 8.
- *
- * @return array<int, array<string, mixed>>
- */
- public function mergeChatActions($a, $b)
- {
- $aAll = array_merge($this->sanitizeChatActions($a), $this->sanitizeChatActions($b));
- $aOut = [];
- $aSeen = [];
- foreach ($aAll as $aItem) {
- $sKey = ($aItem['type'] ?? '') . '|' . ($aItem['label'] ?? '') . '|' . ($aItem['url'] ?? ($aItem['form_url'] ?? ''));
- if (isset($aSeen[$sKey]))
- continue;
- $aSeen[$sKey] = true;
- $aOut[] = $aItem;
- if (count($aOut) >= 8)
- break;
- }
- return $aOut;
- }
- /**
- * @return array<int, array<string, mixed>>
- */
- public function sanitizeChatActions($mixed)
- {
- if (!is_array($mixed))
- return [];
- $aOut = [];
- foreach ($mixed as $aItem) {
- if (!is_array($aItem))
- continue;
- $sType = strtolower(trim((string)($aItem['type'] ?? '')));
- $sLabel = trim((string)($aItem['label'] ?? ''));
- if ($sLabel === '')
- continue;
- if (function_exists('mb_substr'))
- $sLabel = mb_substr($sLabel, 0, 80);
- else
- $sLabel = substr($sLabel, 0, 80);
- if ($sType === 'reply') {
- $aOut[] = ['type' => 'reply', 'label' => $sLabel];
- } elseif ($sType === 'link') {
- $sUrl = trim((string)($aItem['url'] ?? ''));
- if (!$this->isAllowedChatActionUrl($sUrl))
- continue;
- $aOut[] = ['type' => 'link', 'label' => $sLabel, 'url' => $sUrl];
- } elseif ($sType === 'form') {
- // form opened in a popup over the chat: 'module/method/¶ms[]=..' API service call
- $sFormUrl = trim((string)($aItem['form_url'] ?? ''));
- if (!preg_match('/^[a-z0-9_]+\/[a-z0-9_]+\/?(¶ms\[\]=[A-Za-z0-9_\-\.]*)*$/', $sFormUrl))
- continue;
- $aForm = ['type' => 'form', 'label' => $sLabel, 'form_url' => $sFormUrl];
- $sMessage = trim(strip_tags((string)($aItem['message'] ?? '')));
- if ($sMessage !== '')
- $aForm['message'] = function_exists('mb_substr') ? mb_substr($sMessage, 0, 200) : substr($sMessage, 0, 200);
- $aOut[] = $aForm;
- }
- if (count($aOut) >= 8)
- break;
- }
- return $aOut;
- }
- /** Text parts of a UI message joined, tags stripped, whitespace collapsed. */
- public function uiChatMessagePlainText($aMessage)
- {
- $aTexts = [];
- foreach ((array)($aMessage['parts'] ?? []) as $aPart) {
- if (is_array($aPart) && ($aPart['type'] ?? '') === 'text' && is_string($aPart['content'] ?? null))
- $aTexts[] = $aPart['content'];
- }
- $s = strip_tags(html_entity_decode(implode(' ', $aTexts), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
- return trim(preg_replace('/\s+/u', ' ', $s));
- }
- /**
- * A user turn produced by a trigger is the JSON that callAgent() built
- * (BxDolAlerts, BxDolAiAlertResponse, webhook, form-input). Return the text
- * a person would want to read; anything that is not such a payload is returned as is.
- */
- public function humanizeTriggerPayload($sText)
- {
- $s = trim((string)$sText);
- if ($s === '' || $s[0] !== '{')
- return $sText;
- $a = json_decode($s, true);
- if (!is_array($a))
- return $sText;
- $sTrigger = is_string($a['trigger'] ?? null) ? $a['trigger'] : '';
- if ($sTrigger === '' && (isset($a['form_values']) || isset($a['user_prompt'])))
- $sTrigger = 'form-input';
- if ($sTrigger === '')
- return $sText;
- $fText = function ($m) {
- $s = trim((string)$m);
- return $s !== '' ? trim(strip_tags(html_entity_decode($s, ENT_QUOTES, 'UTF-8'))) : '';
- };
- switch ($sTrigger) {
- case 'message':
- $sMsg = $fText($a['message_text'] ?? ($a['message_info']['message'] ?? ''));
- return $sMsg !== '' ? $sMsg : $sText;
- case 'alert':
- $sUnit = (string)($a['unit'] ?? '');
- $sAction = (string)($a['action'] ?? '');
- $sHead = trim($sUnit . ':' . $sAction, ':');
- if (!empty($a['object_id']))
- $sHead .= ' #' . (int)$a['object_id'];
- $sBody = '';
- $aExtra = is_array($a['extra'] ?? null) ? $a['extra'] : [];
- foreach (['comment_text', 'cmt_text', 'text', 'message', 'title', 'content'] as $sKey) {
- if (!empty($aExtra[$sKey]) && is_string($aExtra[$sKey])) {
- $sBody = $fText($aExtra[$sKey]);
- break;
- }
- }
- if ($sBody === '' && !empty($aExtra['comment_id']))
- $sBody = 'comment #' . (int)$aExtra['comment_id'];
- return $sHead . ($sBody !== '' ? "\n" . $sBody : '');
- case 'form-input':
- $aOut = [];
- $sPrompt = $fText($a['user_prompt'] ?? '');
- if ($sPrompt !== '')
- $aOut[] = $sPrompt;
- $aValues = is_array($a['form_values'] ?? null) ? $a['form_values'] : [];
- foreach ($aValues as $sKey => $mixedVal) {
- if (is_scalar($mixedVal) && (string)$mixedVal !== '')
- $aOut[] = $sKey . ': ' . $fText($mixedVal);
- }
- $sHead = trim((string)($a['form_field_name'] ?? ''));
- return trim($sHead . ($aOut ? "\n" . implode("\n", $aOut) : ''));
- case 'webhook':
- $aOut = [];
- foreach ($a as $sKey => $mixedVal) {
- if ($sKey === 'trigger')
- continue;
- if (is_scalar($mixedVal) && (string)$mixedVal !== '')
- $aOut[] = $sKey . ': ' . $fText($mixedVal);
- elseif (is_array($mixedVal))
- $aOut[] = $sKey . ': ' . json_encode($mixedVal, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
- }
- return 'webhook' . ($aOut ? "\n" . implode("\n", $aOut) : '');
- }
- return $sText;
- }
- /**
- * Neuron / UI content block from stored chat JSON → UI part.
- *
- * @param mixed $mixedBlock
- * @return array<string, mixed>|null
- */
- protected function storedContentBlockToUiPart($mixedBlock)
- {
- if (is_string($mixedBlock) && $mixedBlock !== '')
- return ['type' => 'text', 'content' => $mixedBlock];
- if (!is_array($mixedBlock))
- return null;
- $sType = $this->storedEnumString($mixedBlock['type'] ?? '');
- if (strpos($sType, 'textcontent') !== false)
- $sType = 'text';
- if (strpos($sType, 'imagecontent') !== false)
- $sType = 'image';
- $sText = (string)($mixedBlock['content'] ?? $mixedBlock['text'] ?? '');
- if ($sType === 'text' || $sType === '' || $sType === 'array')
- return $sText !== '' ? ['type' => 'text', 'content' => $sText] : null;
- if ($sType === 'reasoning' || $sType === 'thinking')
- return $sText !== '' ? ['type' => 'thinking', 'content' => $sText] : null;
- $aImage = $this->storedImageBlockToUiPart($mixedBlock);
- return $aImage ?: null;
- }
- /**
- * @param array<string, mixed> $aBlock
- * @return array<string, mixed>|null
- */
- protected function storedImageBlockToUiPart($aBlock)
- {
- $aSource = is_array($aBlock['source'] ?? null) ? $aBlock['source'] : [];
- $sSourceKind = strtolower((string)($aSource['type'] ?? ''));
- $sNeuronSource = $this->storedEnumString($aBlock['source_type'] ?? $aBlock['sourceType'] ?? '');
- $sMime = $this->_oImages->sanitizeMime($aSource['mimeType'] ?? $aBlock['media_type'] ?? $aBlock['mediaType'] ?? $aBlock['mime'] ?? $aBlock['mimeType'] ?? 'image/jpeg');
- if ($sSourceKind === 'data' && (string)($aSource['value'] ?? '') !== '')
- return ['type' => 'image', 'source' => ['type' => 'data', 'value' => (string)$aSource['value'], 'mimeType' => $sMime]];
- if (($sNeuronSource === 'base64' || $sNeuronSource === 'data') && (string)($aBlock['content'] ?? '') !== '')
- return ['type' => 'image', 'source' => ['type' => 'data', 'value' => (string)$aBlock['content'], 'mimeType' => $sMime]];
- $aParsed = $this->_oImages->parsePart($aBlock);
- if ($aParsed)
- return ['type' => 'image', 'source' => ['type' => 'url', 'value' => $aParsed['url'], 'mimeType' => $aParsed['mime']]];
- return null;
- }
- /**
- * Enum (object, `{value}` array or string) from stored JSON → lower-case string.
- *
- * @param mixed $mixed
- */
- protected function storedEnumString($mixed)
- {
- if (is_object($mixed) && $mixed instanceof \BackedEnum)
- return strtolower((string)$mixed->value);
- if (is_array($mixed))
- return strtolower((string)($mixed['value'] ?? $mixed['name'] ?? ''));
- return strtolower((string)$mixed);
- }
- protected function isAllowedChatActionUrl($sUrl)
- {
- $a = parse_url((string)$sUrl);
- if (($a['scheme'] ?? '') !== 'https' || empty($a['host']))
- return false;
- $sHost = strtolower((string)$a['host']);
- if ($sHost === 'localhost' || str_ends_with($sHost, '.localhost'))
- return false;
- if (filter_var($sHost, FILTER_VALIDATE_IP))
- return (bool)filter_var($sHost, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
- return true;
- }
- }
- /** @} */
|