Просмотр исходного кода

Ticket #5654 - AI agents improvements. Renaming.

AntonLV 1 месяц назад
Родитель
Сommit
d96a96fc4a
2 измененных файлов с 0 добавлено и 2068 удалено
  1. 0 1544
      inc/classes/BxDolAI.php
  2. 0 524
      inc/classes/BxDolAIQuery.php

+ 0 - 1544
inc/classes/BxDolAI.php

@@ -1,1544 +0,0 @@
-<?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
- * @{
- */
-
-define('BX_DOL_AI_ASSISTANT', 'assistant');
-define('BX_DOL_AI_AUTOMATOR_EVENT', 'event');
-define('BX_DOL_AI_AUTOMATOR_SCHEDULER', 'scheduler');
-define('BX_DOL_AI_AUTOMATOR_WEBHOOK', 'webhook');
-
-define('BX_DOL_AI_AUTOMATOR_STATUS_AUTO', 'auto');
-define('BX_DOL_AI_AUTOMATOR_STATUS_MANUAL', 'manual');
-define('BX_DOL_AI_AUTOMATOR_STATUS_READY', 'ready');
-
-class BxDolAI extends BxDolFactory implements iBxDolSingleton
-{
-    protected $_oDb;
-    protected $_iProfileId;
-    
-    protected $_aExcludeAlertUnits;
-
-    protected $_bWriteLog;
-
-    protected $_aChatContext;
-
-    protected function __construct()
-    {
-        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
-            trigger_error ('Multiple instances are not allowed for the class: ' . get_class($this), E_USER_ERROR);
-
-        parent::__construct();
-
-        $this->_oDb = new BxDolAIQuery();
-
-        $this->_iProfileId = (int)getParam('sys_profile_bot'); 
-
-        $this->_aExcludeAlertUnits = [
-            'system', 'module_template_method_call'
-        ];
-
-        $this->_bWriteLog = true;
-
-        $this->_aChatContext = null;
-    }
-
-    /**
-     * Prevent cloning the instance
-     */
-    public function __clone()
-    {
-        if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
-            trigger_error('Clone is not allowed for the class: ' . get_class($this), E_USER_ERROR);
-    }
-
-    /**
-     * Get singleton instance of the class
-     */
-    public static function getInstance()
-    {
-        if (!isset($GLOBALS['bxDolClasses'][__CLASS__])) {
-            $GLOBALS['bxDolClasses'][__CLASS__] = BxDolDb::getInstance()->isTableExists('sys_agents_agents') ? new BxDolAI() : null;
-        }
-
-        return $GLOBALS['bxDolClasses'][__CLASS__];
-    }
-
-    public static function getAgentInstance(int $iId, array $aParams = []): NeuronAI\Agent\Agent
-    {
-        if (isset($GLOBALS['bxDolClasses'][__CLASS__ . '_Agent_' . $iId]))
-            return $GLOBALS['bxDolClasses'][__CLASS__ . '_Agent_' . $iId];
-
-        $a = BxDolAIQuery::getAgentObject($iId);
-        if (!$a) {
-            $s = "Agent with id {$iId} not found";
-            bx_log('sys_agents', $s, BX_LOG_ERR);
-            throw new Exception($s);
-        }
-        if (!$a['active']) {
-            $s = "Agent with id {$iId} isn't active";
-            bx_log('sys_agents', $s, BX_LOG_ERR);
-            throw new Exception($s);
-        }
-        if (!$a['prompt_system'] || !$a['model_id']) {
-            $s = "Agent with id {$iId} can't be used because it haven't prompt or AI model";
-            bx_log('sys_agents', $s, BX_LOG_ERR);
-            throw new Exception($s);
-        }
-
-        $o = BxDolAiAgent::make($a, $aParams);
-
-        if ((int)$a['tools_max_run'] > 0)
-            $o->toolMaxRuns($a['tools_max_run']);
-
-        if ($a['vector_store_id']) {
-            $aVectorStore = BxDolAIQuery::getVectorStoreObject($a['vector_store_id']);
-            if ($aVectorStore && $aVectorStore['embedding_provider_id']) {
-                $oEmbedder = self::getAiEmbeddingsProviderInstance($aVectorStore['embedding_provider_id']);
-                $o->setEmbeddingsProvider($oEmbedder);
-            }
-        }
-
-        $GLOBALS['bxDolClasses'][__CLASS__ . '_Agent_' . $iId] = $o;
-
-        return $o;
-    }
-
-    public static function getAiProviderInstance(int $iId):NeuronAI\Providers\AIProviderInterface
-    {
-        return BxDolAIModelFactory::getModelInstance($iId);
-    }   
-
-    public static function getAiEmbeddingsProviderInstance(int $iId):NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface
-    {
-        return BxDolAIModelFactory::getModelInstance($iId);
-    }
-
-    public static function callHelper($mixedHelper, $sMessage)
-    {
-        $oAI = BxDolAI::getInstance();
-        if (is_numeric($mixedHelper))
-            $aHelper = $oAI->getHelperById($mixedHelper);
-        else
-             $aHelper = $oAI->getHelperByName($mixedHelper);
-        $oAIModel = $oAI->getModelObject($aHelper['model_id']);
-        return $oAIModel->getResponseText($aHelper['prompt'], $sMessage);
-    }
-
-    public static function pruning() {}
-
-    public static function getDefaultApiKey()
-    {
-        return getParam('sys_agents_api_key');
-    }
-
-    public static function getDefaultModel()
-    {
-        return (int)getParam('sys_agents_model');
-    }
-
-    public function getProfileId()
-    {
-        return $this->_iProfileId;
-    }
-
-    public function getModels($aParams = [])
-    {
-        $aParamsDb = ['sample' => 'all_pairs'];
-        if(isset($aParams['active']))
-            $aParamsDb['active'] = $aParams['active'] === true ? 1 : 0;
-        if(isset($aParams['capabilities']))
-            $aParamsDb['capabilities'] = $aParams['capabilities'];
-
-        return $this->_oDb->getModelsBy($aParamsDb);
-    }
-
-    public function getModel($iId)
-    {
-        $aModel = $this->_oDb->getModelsBy(['sample' => 'id', 'id' => $iId]);
-        if(!empty($aModel['params']))
-            $aModel['params'] = json_decode($aModel['params'], true);
-
-        return $aModel;
-    }
-
-    public function callAgent($sType, $aAgent, $mixedParams = [])
-    {
-        if ($mixedParams)
-            $sParams = is_string($mixedParams) ? $mixedParams : json_encode($mixedParams);
-        else
-            $sParams = 'START';
-
-        // update sample data
-        $a = ['webhook' => 'webhook_sample'];
-        if (isset($a[$sType]) && empty($aAgent[$a[$sType]])) {
-            $this->_oDb->updateAgentField($aAgent['id'], $a[$sType], $sParams);
-        }
-
-        // set additional params
-        $aParams = [];
-        if ('message' == $sType || 'form-input' == $sType || 'alert' == $sType) {
-            $aParams = ['chat_history_subindex' => (int)($mixedParams['sender_profile_id'] ?? 0)];
-        }
-
-        if ('message' == $sType && is_array($mixedParams) && isset($mixedParams['message_text'])) {
-            $mixedParams['message_text'] = $this->applyChatInputLimit((string)$mixedParams['message_text'], $aAgent);
-            $sParams = json_encode($mixedParams);
-        }
-        else if (is_string($mixedParams) && in_array($sType, ['manual', 'message'], true)) {
-            $sParams = $this->applyChatInputLimit($sParams, $aAgent);
-        }
-
-        if (in_array($sType, ['manual', 'message'], true) && $this->isChatTurnLimitReached($aAgent, $this->getChatUserTurnCount($aAgent['id'], $aParams))) {
-            $this->emitConversationClosed('limit', '', $aAgent, $aParams);
-            return $this->getChatLimitMessage($aAgent);
-        }
-
-        if (in_array($sType, ['manual', 'message'], true) && $this->isChatSessionRateLimited($aAgent, $aParams))
-            throw new Exception($this->getChatSessionRateLimitError());
-
-        $this->_aChatContext = ['agent' => $aAgent, 'params' => $aParams];
-
-        // call agent
-        $mixed = '';
-        try {                        
-            $o = self::getAgentInstance($aAgent['id'], $aParams);
-            if (!$o)
-                return false;
-
-            $oMessage = $o->chat(new NeuronAI\Chat\Messages\UserMessage($sParams))->getMessage();
-
-            $mixed = $oMessage->getContent();
-
-        } catch (Exception $exception) {            
-            bx_log('sys_agents', "Exception in '{$aAgent['name']}' agent: " . $exception->getMessage() . " INPUT:" . $sParams, BX_LOG_ERR);
-            $mixed = _t('_sys_agents_exception');
-        }
-
-        return $mixed;
-    }
-
-    /**
-     * Whether the current (or given) member may interact with the agent
-     * via chat, messenger, or form-input. Empty acl_levels means Nobody.
-     * Site admins / Studio operators always may, so they can debug.
-     */
-    public function canInteract($aAgent, $iProfileId = false, $bAllowOperators = true)
-    {
-        if (!is_array($aAgent) || empty($aAgent['id']))
-            return false;
-
-        if ($bAllowOperators && isAdmin())
-            return true;
-
-        $iLevels = (int)($aAgent['acl_levels'] ?? 0);
-        if (!$iLevels)
-            return false;
-
-        return (bool)BxDolAcl::getInstance()->isMemberLevelInSet($iLevels, $iProfileId);
-    }
-
-    /**
-     * Direct HTTP chat (`sys-ai-chat`). Operators may talk to any trigger;
-     * everyone else only `manual` and `message` agents they can interact with.
-     */
-    public function canChatDirectly($aAgent, $iProfileId = false)
-    {
-        if (!$this->canInteract($aAgent, $iProfileId))
-            return false;
-
-        if (isAdmin())
-            return true;
-
-        return in_array($aAgent['trigger'] ?? '', ['manual', 'message'], true);
-    }
-
-    /**
-     * Runner limits from the agent row. 0 = no cap. Never put these in the prompt.
-     */
-    public function applyChatInputLimit($sPrompt, $aAgent)
-    {
-        $sPrompt = (string)$sPrompt;
-        $iMax = (int)($aAgent['max_input_chars'] ?? 0);
-        if ($iMax <= 0)
-            return $sPrompt;
-
-        if (function_exists('mb_strlen') && function_exists('mb_substr')) {
-            if (mb_strlen($sPrompt, 'UTF-8') > $iMax)
-                return mb_substr($sPrompt, 0, $iMax, 'UTF-8');
-            return $sPrompt;
-        }
-
-        return strlen($sPrompt) > $iMax ? substr($sPrompt, 0, $iMax) : $sPrompt;
-    }
-
-    public function getChatLimitMessage($aAgent)
-    {
-        $s = trim((string)($aAgent['limit_message'] ?? ''));
-        return $s !== '' ? $s : 'This chat has reached its limit.';
-    }
-
-    public function getChatSessionRateLimitError()
-    {
-        $s = _t('_sys_agents_err_session_rate_limit');
-        return ($s !== '' && $s !== '_sys_agents_err_session_rate_limit')
-            ? $s
-            : 'Too many chat sessions from this IP. Try again later.';
-    }
-
-    public function countChatUserTurns($aUiMessages)
-    {
-        $i = 0;
-        if (!is_array($aUiMessages))
-            return 0;
-        foreach ($aUiMessages as $aMessage) {
-            if (is_array($aMessage) && ($aMessage['role'] ?? '') === 'user')
-                $i++;
-        }
-        return $i;
-    }
-
-    public function isChatTurnLimitReached($aAgent, $iTurns, $iRequestUserTurns = 0)
-    {
-        $iMax = (int)($aAgent['max_turns'] ?? 0);
-        if ($iMax <= 0)
-            return false;
-        if ((int)$iTurns >= $iMax)
-            return true;
-        // Client transcript includes the message being submitted.
-        return (int)$iRequestUserTurns > $iMax;
-    }
-
-    public function getChatUserTurnCount($iAgentId, $aParams = [])
-    {
-        if (!isset($aParams['chat_history_subindex']))
-            $aParams = array_merge($this->resolveChatHistoryParams($iAgentId), $aParams);
-
-        try {
-            $o = self::getAgentInstance((int)$iAgentId, $aParams);
-        } catch (Throwable $oException) {
-            return 0;
-        }
-
-        $i = 0;
-        foreach ($o->getChatHistory()->getMessages() as $oMessage) {
-            if ($oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage
-                || $oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage
-            ) {
-                continue;
-            }
-            if ($oMessage instanceof NeuronAI\Chat\Messages\UserMessage)
-                $i++;
-        }
-        return $i;
-    }
-
-    /**
-     * New chat threads per IP for this agent. 0 on the agent = no cap.
-     * Continuation of an existing thread is not a new session.
-     */
-    public function isChatSessionRateLimited($aAgent, $aParams = [])
-    {
-        $iHour = (int)($aAgent['max_sessions_per_hour'] ?? 0);
-        $iDay = (int)($aAgent['max_sessions_per_day'] ?? 0);
-        if ($iHour <= 0 && $iDay <= 0)
-            return false;
-
-        if (!isset($aParams['chat_history_subindex']))
-            $aParams = array_merge($this->resolveChatHistoryParams((int)($aAgent['id'] ?? 0)), $aParams);
-
-        $sThreadId = self::chatHistoryThreadId($aAgent, $aParams);
-        if (!$this->isNewChatSession($sThreadId))
-            return false;
-
-        $sIp = $this->getVisitorIpHash();
-        if ($sIp === '0' || $sIp === '')
-            return false;
-
-        $sPrefix = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
-        if ($sPrefix === ':')
-            return false;
-
-        if ($iHour > 0 && $this->countChatSessionsByIp($sIp, $sPrefix, 3600) >= $iHour)
-            return true;
-        if ($iDay > 0 && $this->countChatSessionsByIp($sIp, $sPrefix, 86400) >= $iDay)
-            return true;
-
-        return false;
-    }
-
-    protected function getVisitorIpHash()
-    {
-        if (!function_exists('getVisitorIP') || !function_exists('bx_get_ip_hash'))
-            return '0';
-        return (string)bx_get_ip_hash(getVisitorIP());
-    }
-
-    protected function isNewChatSession($sThreadId)
-    {
-        $sThreadId = (string)$sThreadId;
-        if ($sThreadId === '')
-            return true;
-
-        $aRow = $this->_oDb->getRow("SELECT `ip`, `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
-            't' => $sThreadId,
-        ]);
-        if (!$aRow)
-            return true;
-
-        $iIp = (int)($aRow['ip'] ?? 0);
-        $sMessages = trim((string)($aRow['messages'] ?? ''));
-        return $iIp <= 0 || $sMessages === '' || $sMessages === '[]';
-    }
-
-    protected function countChatSessionsByIp($sIp, $sPrefix, $iWindowSec)
-    {
-        $sSince = date('Y-m-d H:i:s', time() - (int)$iWindowSec);
-        return (int)$this->_oDb->getOne("
-            SELECT COUNT(*) FROM `sys_agents_chat_history`
-            WHERE `ip` = :ip
-              AND (`thread_id` = :prefix OR `thread_id` LIKE :prefix_like)
-              AND `created_at` >= :since
-        ", [
-            'ip' => $sIp,
-            'prefix' => $sPrefix,
-            'prefix_like' => $sPrefix . ':%',
-            'since' => $sSince,
-        ]);
-    }
-
-    public function extractChatPromptFromRequest($aData)
-    {
-        if (!is_array($aData))
-            return '';
-
-        if (!empty($aData['prompt']) && is_string($aData['prompt']))
-            return trim($aData['prompt']);
-
-        if (empty($aData['messages']) || !is_array($aData['messages']))
-            return '';
-
-        for ($i = count($aData['messages']) - 1; $i >= 0; $i--) {
-            $aMessage = $aData['messages'][$i];
-            if (!is_array($aMessage))
-                continue;
-
-            if (($aMessage['role'] ?? '') !== 'user')
-                continue;
-
-            if (isset($aMessage['content']) && is_string($aMessage['content']))
-                return trim($aMessage['content']);
-
-            if (!empty($aMessage['parts']) && is_array($aMessage['parts'])) {
-                $aText = [];
-                foreach ($aMessage['parts'] as $aPart) {
-                    if (is_array($aPart) && ($aPart['type'] ?? '') === 'text' && isset($aPart['content']))
-                        $aText[] = $aPart['content'];
-                }
-                $s = trim(implode("\n", $aText));
-                if ($s !== '')
-                    return $s;
-            }
-        }
-
-        return '';
-    }
-
-    /**
-     * Guests: UNA session id. Members: profile id.
-     * Guest chats remember agent ids in session so login can adopt those threads.
-     */
-    public function resolveChatHistoryParams($iAgentId = 0)
-    {
-        $iAgentId = (int)$iAgentId;
-        $iProfileId = (int)bx_get_logged_profile_id();
-        $oSession = BxDolSession::getInstance();
-        $sSessionKey = 'ai_chat_agent_ids';
-
-        if ($iProfileId) {
-            $aIds = $oSession->getValue($sSessionKey);
-            if (is_array($aIds) && $aIds) {
-                $sSessionId = (string)$oSession->getId();
-                if ($iAgentId)
-                    $aIds[] = $iAgentId;
-                foreach (array_unique(array_map('intval', $aIds)) as $iStoredAgentId) {
-                    if (!$iStoredAgentId || $sSessionId === '')
-                        continue;
-                    $aAgent = BxDolAIQuery::getAgentObject($iStoredAgentId);
-                    if ($aAgent)
-                        $this->_oDb->adoptGuestChatHistory($aAgent, $sSessionId, $iProfileId);
-                }
-                $oSession->unsetValue($sSessionKey);
-            }
-
-            return [
-                'sender_profile_id' => $iProfileId,
-                'chat_history_subindex' => (string)$iProfileId,
-            ];
-        }
-
-        if ($iAgentId) {
-            $aIds = $oSession->getValue($sSessionKey);
-            $aIds = array_map('intval', is_array($aIds) ? $aIds : []);
-            if (!in_array($iAgentId, $aIds, true)) {
-                $aIds[] = $iAgentId;
-                $oSession->setValue($sSessionKey, $aIds);
-            }
-        }
-
-        return [
-            'sender_profile_id' => 0,
-            'chat_history_subindex' => (string)$oSession->getId(),
-        ];
-    }
-
-    /**
-     * `{trigger}:{agentId}:{contextPid}:{userSubindex}`.
-     * Context is omitted when 0 so existing site-wide threads still load.
-     * User suffix (session id or profile id) stays last so adopt can replace it.
-     */
-    public static function chatHistoryThreadId($aAgent, $aParams = [])
-    {
-        $s = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
-        $iContextPid = (int)($aParams['chat_history_context_pid'] ?? 0);
-        if ($iContextPid > 0)
-            $s .= ':' . $iContextPid;
-        if (isset($aParams['chat_history_subindex']) && $aParams['chat_history_subindex'] !== '')
-            $s .= ':' . (string)$aParams['chat_history_subindex'];
-        return $s;
-    }
-
-    public function getChatHistoryRowId($aAgent, $aParams = [])
-    {
-        $sThreadId = self::chatHistoryThreadId($aAgent, $aParams);
-        if ($sThreadId === '' || $sThreadId === ':')
-            return 0;
-        return (int)$this->_oDb->getOne("SELECT `id` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
-            't' => $sThreadId,
-        ]);
-    }
-
-    public function getCurrentChatHistoryId()
-    {
-        if (!is_array($this->_aChatContext))
-            return 0;
-
-        $aAgent = $this->_aChatContext['agent'] ?? null;
-        $aParams = $this->_aChatContext['params'] ?? [];
-        if (!is_array($aAgent))
-            return 0;
-
-        return $this->getChatHistoryRowId($aAgent, is_array($aParams) ? $aParams : []);
-    }
-
-    public function emitConversationClosed($sReason, $sSummary = '', $aAgent = null, $aParams = null)
-    {
-        if (!is_array($aAgent) && is_array($this->_aChatContext))
-            $aAgent = $this->_aChatContext['agent'] ?? null;
-        if (!is_array($aParams) && is_array($this->_aChatContext))
-            $aParams = $this->_aChatContext['params'] ?? [];
-        if (!is_array($aAgent) || empty($aAgent['id']))
-            return false;
-
-        $iHistoryId = $this->getChatHistoryRowId($aAgent, is_array($aParams) ? $aParams : []);
-        if ($iHistoryId <= 0)
-            return false;
-
-        if ($this->_oDb->isFieldExists('sys_agents_chat_history', 'closed_reason')) {
-            $sClosed = (string)$this->_oDb->getOne(
-                "SELECT `closed_reason` FROM `sys_agents_chat_history` WHERE `id` = :id",
-                ['id' => $iHistoryId]
-            );
-            if ($sClosed !== '')
-                return false;
-        }
-
-        bx_alert('system', 'agent_conversation_closed', $iHistoryId, 0, [
-            'agent_id' => (int)$aAgent['id'],
-            'summary' => (string)$sSummary,
-            'reason' => (string)$sReason,
-        ]);
-        return true;
-    }
-
-    /**
-     * @return array{thread_id:string,chat_history_context_pid:int,chat_history_subindex:string}|false
-     */
-    public static function parseChatHistoryThreadId($aAgent, $sThreadId)
-    {
-        $sThreadId = (string)$sThreadId;
-        $sBase = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
-        if ($sBase === ':' || $sThreadId === '')
-            return false;
-        if ($sThreadId !== $sBase && strpos($sThreadId, $sBase . ':') !== 0)
-            return false;
-
-        $sRest = $sThreadId === $sBase ? '' : substr($sThreadId, strlen($sBase) + 1);
-        $aParts = $sRest === '' ? [] : explode(':', $sRest);
-        $iContext = 0;
-        $sSub = '';
-        if (count($aParts) >= 2) {
-            $iContext = (int)$aParts[0];
-            $sSub = implode(':', array_slice($aParts, 1));
-        } elseif (count($aParts) === 1) {
-            $sSub = $aParts[0];
-        }
-
-        return [
-            'thread_id' => $sThreadId,
-            'chat_history_context_pid' => $iContext,
-            'chat_history_subindex' => $sSub,
-        ];
-    }
-
-    public function isOwnAgentChatThread($aAgent, $sThreadId)
-    {
-        $aParsed = self::parseChatHistoryThreadId($aAgent, $sThreadId);
-        if (!$aParsed)
-            return false;
-
-        $aMine = $this->resolveChatHistoryParams((int)$aAgent['id']);
-        $sMine = (string)($aMine['chat_history_subindex'] ?? '');
-        return $sMine !== '' && $aParsed['chat_history_subindex'] === $sMine;
-    }
-
-    public function storedChatJsonToUiMessages($sJson)
-    {
-        $aStored = json_decode((string)$sJson, true);
-        if (!is_array($aStored) || $aStored === [])
-            return [];
-
-        $aResult = [];
-        $i = 0;
-        foreach ($aStored as $aMessage) {
-            if (!is_array($aMessage))
-                continue;
-
-            $sType = (string)($aMessage['type'] ?? '');
-            if ($sType === 'tool_call' || $sType === 'tool_call_result')
-                continue;
-
-            $sRole = (string)($aMessage['role'] ?? '');
-            if ($sRole === 'model')
-                $sRole = 'assistant';
-            if ($sRole !== 'user' && $sRole !== 'assistant')
-                continue;
-
-            $aParts = [];
-            $mixedContent = $aMessage['content'] ?? '';
-            if (is_string($mixedContent) && $mixedContent !== '') {
-                $aParts[] = ['type' => 'text', 'content' => $mixedContent];
-            } elseif (is_array($mixedContent)) {
-                foreach ($mixedContent as $aBlock) {
-                    if (!is_array($aBlock))
-                        continue;
-                    $sBlockType = (string)($aBlock['type'] ?? '');
-                    $sBlockText = (string)($aBlock['content'] ?? '');
-                    if ($sBlockText === '')
-                        continue;
-                    if ($sBlockType === 'text' || $sBlockType === '')
-                        $aParts[] = ['type' => 'text', 'content' => $sBlockText];
-                }
-            }
-            $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 $aResult;
-    }
-
-    public function getChatHistoryUiMessagesByThread($aAgent, $sThreadId)
-    {
-        if (!self::parseChatHistoryThreadId($aAgent, $sThreadId))
-            return false;
-
-        $sJson = $this->_oDb->getOne("SELECT `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
-            't' => (string)$sThreadId,
-        ]);
-
-        return $this->storedChatJsonToUiMessages($sJson);
-    }
-
-    public function getChatHistoryArtifactsByThread($aAgent, $sThreadId)
-    {
-        if (!self::parseChatHistoryThreadId($aAgent, $sThreadId))
-            return [];
-
-        return $this->_oDb->getChatArtifactsByThreadId((string)$sThreadId);
-    }
-
-    public function listAgentChatThreads($aAgent)
-    {
-        $aMineParams = $this->resolveChatHistoryParams((int)$aAgent['id']);
-        $sMySub = (string)($aMineParams['chat_history_subindex'] ?? '');
-        $sMyDefault = self::chatHistoryThreadId($aAgent, $aMineParams);
-
-        $aOut = [];
-        $bHaveMineDefault = false;
-        $aRows = $this->_oDb->getAgentChatHistoryRows($aAgent);
-        if (!is_array($aRows))
-            $aRows = [];
-
-        $aHistoryIds = [];
-        foreach ($aRows as $aRow)
-            $aHistoryIds[] = (int)($aRow['id'] ?? 0);
-        $aArtifactsMap = $this->_oDb->getChatArtifactsForAgent($aAgent);
-        if (!$aArtifactsMap && $aHistoryIds)
-            $aArtifactsMap = $this->_oDb->getChatArtifactsByHistoryIds($aHistoryIds);
-
-        foreach ($aRows as $aRow) {
-            $sThreadId = (string)($aRow['thread_id'] ?? '');
-            $aParsed = self::parseChatHistoryThreadId($aAgent, $sThreadId);
-            if (!$aParsed)
-                continue;
-
-            $aUi = $this->storedChatJsonToUiMessages($aRow['messages'] ?? '');
-            if (!$aUi && $sThreadId !== $sMyDefault)
-                continue;
-
-            $bMine = ($sMySub !== '' && $aParsed['chat_history_subindex'] === $sMySub);
-            if ($sThreadId === $sMyDefault)
-                $bHaveMineDefault = true;
-
-            $iHistoryId = (int)($aRow['id'] ?? 0);
-            $aArtifacts = ($iHistoryId && !empty($aArtifactsMap[$iHistoryId])) ? $aArtifactsMap[$iHistoryId] : [];
-            $aOut[] = $this->formatAgentChatThread($aParsed, $aRow, $aUi, $bMine, $aArtifacts);
-        }
-
-        if (!$bHaveMineDefault && $sMyDefault !== '') {
-            $aParsedMine = self::parseChatHistoryThreadId($aAgent, $sMyDefault);
-            if ($aParsedMine)
-                $aOut[] = $this->formatAgentChatThread($aParsedMine, [], [], true);
-        }
-
-        usort($aOut, function ($a, $b) {
-            return strcmp((string)$b['updated_sort'], (string)$a['updated_sort']);
-        });
-
-        return $aOut;
-    }
-
-    protected function formatAgentChatThread($aParsed, $aRow, $aUi, $bMine, $aArtifacts = [])
-    {
-        $iContext = (int)$aParsed['chat_history_context_pid'];
-        $sSub = (string)$aParsed['chat_history_subindex'];
-        $sContextName = '';
-        if ($iContext > 0) {
-            $oContext = BxDolProfile::getInstance($iContext);
-            $sContextName = $oContext ? $oContext->getDisplayName() : ('#' . $iContext);
-        }
-
-        $sTitle = _t('_sys_agents_agents_txt_guest_chat');
-        $oProfile = (ctype_digit($sSub) && (int)$sSub > 0) ? BxDolProfile::getInstance((int)$sSub) : false;
-        if ($oProfile)
-            $sTitle = $oProfile->getDisplayName();
-        if ($sContextName !== '')
-            $sTitle .= ' · ' . $sContextName;
-
-        $sReason = trim((string)($aRow['closed_reason'] ?? ''));
-        $sStatus = $sReason !== '' ? 'closed' : 'opened';
-        $sStatusLabel = $sStatus;
-        if ($sStatus === 'closed' && $sReason !== '')
-            $sStatusLabel .= ' (' . $sReason . ')';
-
-        $aArtifacts = is_array($aArtifacts) ? $aArtifacts : [];
-        $sCreated = $this->formatAgentChatThreadTime($aRow['created_at'] ?? '');
-        $sUpdated = $this->formatAgentChatThreadTime($aRow['updated_at'] ?? '');
-        $sUpdatedRaw = (string)($aRow['updated_at'] ?? '');
-
-        $sMeta = 'status: ' . $sStatusLabel;
-        $sDates = '';
-        if ($sCreated !== '' && $sUpdated !== '')
-            $sDates = $sCreated . ' - ' . $sUpdated;
-        else if ($sCreated !== '')
-            $sDates = $sCreated;
-        else if ($sUpdated !== '')
-            $sDates = $sUpdated;
-        if ($sDates !== '')
-            $sMeta .= "\n" . $sDates;
-
-        return [
-            'thread_id' => $aParsed['thread_id'],
-            'writable' => $bMine ? 1 : 0,
-            'context_pid' => $iContext,
-            'title' => $sTitle,
-            'meta' => $sMeta,
-            'status' => $sStatus,
-            'closed_reason' => $sReason,
-            'created_at' => $sCreated,
-            'updated_at' => $sUpdated,
-            'artifacts' => is_array($aArtifacts) ? $aArtifacts : [],
-            'updated_sort' => $sUpdatedRaw,
-            'class_mine' => $bMine ? ' bx-agents-popup-chat-thread-mine' : '',
-        ];
-    }
-
-    protected function formatAgentChatThreadTime($mixed): string
-    {
-        $s = trim((string)$mixed);
-        if ($s === '' || $s === '0')
-            return '';
-        $i = ctype_digit($s) ? (int)$s : (int)strtotime($s);
-        if ($i <= 86400)
-            return '';
-        return date('d.m.Y H:i', $i);
-    }
-
-    /**
-     * Context from chat HTTP (`?context=`). Missing/0 = site-wide. Invalid/denied = false (403).
-     * Posting in a group is not the same as viewing it; chat requires post by default.
-     * @return int|false
-     */
-    public function resolveChatHistoryContextPid($bRequirePost = true)
-    {
-        $mixed = bx_get('context');
-        if ($mixed === false)
-            return 0;
-
-        $iPid = (int)$mixed;
-        if ($iPid <= 0)
-            return 0;
-
-        $oProfile = BxDolProfile::getInstance($iPid);
-        if (!$oProfile || !bx_srv('system', 'is_module_context', [$oProfile->getModule()]))
-            return false;
-
-        if (!$this->isAllowedContextChat($oProfile, $bRequirePost))
-            return false;
-
-        return $iPid;
-    }
-
-    /**
-     * Context of the current page (AI agent block).
-     * `bx_get_page_info()` reads the URL; App page JSON merges `i`/`id` into $_GET instead.
-     * @param bool $bRequirePost also require posting into the context (membership / post ACL)
-     */
-    public function resolveChatHistoryContextPidFromPage($bRequirePost = false)
-    {
-        $aInfo = bx_get_page_info();
-        if ($aInfo && !empty($aInfo['context_profile_id']))
-            return $this->filterContextPid((int)$aInfo['context_profile_id'], $bRequirePost);
-
-        $sUri = bx_get('i');
-        $iContentId = (int)bx_process_input(bx_get('id'), BX_DATA_INT);
-        if ($sUri && $iContentId > 0) {
-            $oPage = BxDolPage::getObjectInstanceByURI($sUri);
-            $sModule = $oPage ? (string)$oPage->getModule() : '';
-            if ($sModule !== '' && bx_srv('system', 'is_module_context', [$sModule])) {
-                $oProfile = BxDolProfile::getInstanceByContentAndType($iContentId, $sModule);
-                if ($oProfile && ($iPid = $this->filterContextPid((int)$oProfile->id(), $bRequirePost)))
-                    return $iPid;
-            }
-        }
-
-        $iPid = (int)bx_process_input(bx_get('profile_id'), BX_DATA_INT);
-        if ($iPid > 0 && ($iPid = $this->filterContextPid($iPid, $bRequirePost)))
-            return $iPid;
-
-        return 0;
-    }
-
-    public function filterContextPid($iPid, $bRequirePost = false)
-    {
-        $iPid = (int)$iPid;
-        if ($iPid <= 0)
-            return 0;
-
-        $oProfile = BxDolProfile::getInstance($iPid);
-        if (!$oProfile || !bx_srv('system', 'is_module_context', [$oProfile->getModule()]))
-            return 0;
-
-        if (!$this->isAllowedContextChat($oProfile, $bRequirePost))
-            return 0;
-
-        return $iPid;
-    }
-
-    protected function isAllowedContextChat($oProfile, $bRequirePost = false)
-    {
-        if ($oProfile->checkAllowedProfileView() !== CHECK_ACTION_RESULT_ALLOWED)
-            return false;
-
-        if ($bRequirePost && $oProfile->checkAllowedPostInProfile() !== CHECK_ACTION_RESULT_ALLOWED)
-            return false;
-
-        return true;
-    }
-
-    /**
-     * Transcript for TanStack `useChat` hydrate / `initialMessages`.
-     * Loads the same NeuronAI chat history the agent uses when streaming.
-     *
-     * @param bool $bAppendLimitMessage when the session is at max_turns, append limit_message for the UI
-     */
-    public function getChatHistoryUiMessages($iAgentId, $aParams = [], $bAppendLimitMessage = true)
-    {
-        if (!isset($aParams['chat_history_subindex']))
-            $aParams = array_merge($this->resolveChatHistoryParams($iAgentId), $aParams);
-
-        $aAgent = BxDolAIQuery::getAgentObject((int)$iAgentId);
-        if (!$aAgent || empty($aAgent['chat_history_context']))
-            return [];
-
-        $sThreadId = self::chatHistoryThreadId($aAgent, $aParams);
-        $sJson = $this->_oDb->getOne("SELECT `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
-            't' => $sThreadId,
-        ]);
-        $aStored = json_decode((string)$sJson, true);
-        if (!is_array($aStored) || $aStored === [])
-            return [];
-
-        try {
-            $o = self::getAgentInstance((int)$iAgentId, $aParams);
-        } catch (Exception $oException) {
-            bx_log('sys_agents', "Hydrate exception for agent {$iAgentId}: " . $oException->getMessage(), BX_LOG_ERR);
-            return [];
-        }
-
-        $aResult = $this->neuronMessagesToUiMessages($o->getChatHistory()->getMessages());
-        if ($bAppendLimitMessage && $this->isChatTurnLimitReached($aAgent, $this->countChatUserTurns($aResult))) {
-            $aResult[] = [
-                'id' => 'agent_limit_message',
-                'role' => 'assistant',
-                'parts' => [['type' => 'text', 'content' => $this->getChatLimitMessage($aAgent)]],
-            ];
-        }
-
-        return $aResult;
-    }
-
-    /**
-     * @param NeuronAI\Chat\Messages\Message[] $aMessages
-     * @return array<int, array<string, mixed>>
-     */
-    protected function neuronMessagesToUiMessages($aMessages)
-    {
-        $aResult = [];
-        $i = 0;
-
-        foreach ($aMessages as $oMessage) {
-            if (
-                $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage
-                || $oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage
-            ) {
-                continue;
-            }
-
-            $sRole = $oMessage->getRole();
-            if ($sRole === 'model')
-                $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];
-            }
-
-            $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 $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
-     */
-    protected 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;
-                if (!$aActions)
-                    $aActions = $aParsed['actions'];
-            }
-        }
-
-        if (!$aParts && !$aActions)
-            return null;
-
-        $aOut = [
-            'id' => $sId,
-                'role' => $sRole,
-                'parts' => $aParts,
-            ];
-        if ($sRole === 'assistant')
-            $aOut['actions'] = $aActions;
-
-        return $aOut;
-    }
-
-    /**
-     * @return array{content: string, actions: array}|null
-     */
-    protected 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']),
-        ];
-    }
-
-    /**
-     * @return array<int, array<string, mixed>>
-     */
-    protected 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];
-            }
-
-            if (count($aOut) >= 8)
-                break;
-        }
-
-        return $aOut;
-    }
-
-    protected function isAllowedChatActionUrl($sUrl)
-    {
-        $a = parse_url((string)$sUrl);
-        if (($a['scheme'] ?? '') !== 'https' || empty($a['host']))
-            return false;
-
-        $sHost = strtolower((string)$a['host']);
-        $aAllowed = ['hiweave.com', 'www.hiweave.com'];
-        if (defined('BX_DOL_URL_ROOT')) {
-            $aSite = parse_url(BX_DOL_URL_ROOT);
-            if (!empty($aSite['host']))
-                $aAllowed[] = strtolower((string)$aSite['host']);
-        }
-
-        return in_array($sHost, $aAllowed, true);
-    }
-
-    protected function sseChatEvent($aPayload)
-    {
-        return 'data: ' . json_encode($aPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
-    }
-
-    protected function parseSseChatEvent($sEvent)
-    {
-        $s = trim((string)$sEvent);
-        if (!preg_match('/^data:\s*(.+)$/s', $s, $aM))
-            return null;
-
-        $a = json_decode($aM[1], true);
-        return is_array($a) ? $a : null;
-    }
-
-    protected function resetChatActionsSseState()
-    {
-        return [
-            'mode' => 'undecided',
-            'buf' => '',
-            'messageId' => '',
-            'held' => [],
-        ];
-    }
-
-    protected function emitHeldChatActionsSse(&$aState, $fEmit)
-    {
-        foreach ($aState['held'] as $sHeld)
-            $fEmit($sHeld);
-        $aState['held'] = [];
-    }
-
-    protected function emitParsedChatActionsSse(&$aState, $fEmit, $aParsed)
-    {
-        $sId = $aState['messageId'] !== '' ? $aState['messageId'] : ('msg_' . uniqid());
-        $fEmit($this->sseChatEvent([
-            'type' => 'TEXT_MESSAGE_START',
-            'messageId' => $sId,
-            'role' => 'assistant',
-        ]));
-        if ($aParsed['content'] !== '') {
-            $fEmit($this->sseChatEvent([
-                'type' => 'TEXT_MESSAGE_CONTENT',
-                'messageId' => $sId,
-                'delta' => $aParsed['content'],
-            ]));
-        }
-        $fEmit($this->sseChatEvent([
-            'type' => 'TEXT_MESSAGE_END',
-            'messageId' => $sId,
-        ]));
-        if (!empty($aParsed['actions'])) {
-            $fEmit($this->sseChatEvent([
-                'type' => 'CUSTOM',
-                'name' => 'chat_actions',
-                'messageId' => $sId,
-                'value' => $aParsed['actions'],
-            ]));
-        }
-        $aState = $this->resetChatActionsSseState();
-    }
-
-    protected function processChatActionsSseEvent($sEvent, &$aState, $fEmit)
-    {
-        $aPayload = $this->parseSseChatEvent($sEvent);
-        $sType = is_array($aPayload) ? (string)($aPayload['type'] ?? '') : '';
-
-        if ($sType === 'TEXT_MESSAGE_START') {
-            if ($aState['held'] || $aState['buf'] !== '')
-                $this->flushChatActionsSseState($aState, $fEmit);
-            $aState['messageId'] = (string)($aPayload['messageId'] ?? '');
-            $aState['held'][] = $sEvent;
-            return;
-        }
-
-        if ($sType === 'TEXT_MESSAGE_CONTENT') {
-            $sDelta = (string)($aPayload['delta'] ?? '');
-            $aState['buf'] .= $sDelta;
-            if ($aState['mode'] === 'text') {
-                $fEmit($sEvent);
-                return;
-            }
-            if ($aState['mode'] === 'undecided') {
-                $mixed = $this->chatActionsStreamLooksLikeJson($aState['buf']);
-                if ($mixed === false) {
-                    $aState['mode'] = 'text';
-                    $this->emitHeldChatActionsSse($aState, $fEmit);
-                    $fEmit($sEvent);
-                    return;
-                }
-                if ($mixed === true)
-                    $aState['mode'] = 'json';
-            }
-            $aState['held'][] = $sEvent;
-            return;
-        }
-
-        if ($sType === 'TEXT_MESSAGE_END') {
-            $aParsed = $this->parseAssistantChatPayload($aState['buf']);
-            if ($aParsed && ($aState['mode'] === 'json' || $aState['mode'] === 'undecided')) {
-                $this->emitParsedChatActionsSse($aState, $fEmit, $aParsed);
-                return;
-            }
-            $this->emitHeldChatActionsSse($aState, $fEmit);
-            $fEmit($sEvent);
-            $aState = $this->resetChatActionsSseState();
-            return;
-        }
-
-        if ($sType === 'RUN_ERROR' || $sType === 'RUN_FINISHED') {
-            $this->flushChatActionsSseState($aState, $fEmit);
-            $fEmit($sEvent);
-            return;
-        }
-
-        if ($aState['mode'] === 'json' || $aState['held'])
-            $this->flushChatActionsSseState($aState, $fEmit);
-
-        $fEmit($sEvent);
-    }
-
-    protected function chatActionsStreamLooksLikeJson($sBuf)
-    {
-        $s = ltrim((string)$sBuf);
-        if ($s === '')
-            return null;
-        $sFirst = $s[0];
-        if ($sFirst === '{' || $sFirst === '`')
-            return true;
-        return false;
-    }
-
-    protected function flushChatActionsSseState(&$aState, $fEmit)
-    {
-        if ($aState['buf'] === '' && !$aState['held'])
-            return;
-
-        $aParsed = $this->parseAssistantChatPayload($aState['buf']);
-        if ($aParsed && $aState['mode'] !== 'text') {
-            $this->emitParsedChatActionsSse($aState, $fEmit, $aParsed);
-            return;
-        }
-
-        $this->emitHeldChatActionsSse($aState, $fEmit);
-        $aState = $this->resetChatActionsSseState();
-    }
-
-    protected function persistAssistantChatActions($oAgent)
-    {
-        if (!is_object($oAgent) || !method_exists($oAgent, 'getChatHistory'))
-            return;
-
-        $oHistory = $oAgent->getChatHistory();
-        if (!($oHistory instanceof BxDolAiChatHistory) || !method_exists($oHistory, 'persistMessages'))
-            return;
-
-        $aMessages = $oHistory->getMessages();
-        for ($i = count($aMessages) - 1; $i >= 0; $i--) {
-            $oMessage = $aMessages[$i];
-            if (
-                $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage
-                || $oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage
-            ) {
-                continue;
-        }
-
-            $sRole = $oMessage->getRole();
-            if ($sRole === 'model')
-                $sRole = 'assistant';
-            if ($sRole !== 'assistant')
-                return;
-
-            $aParsed = $this->parseAssistantChatPayload((string)$oMessage->getContent());
-            if (!$aParsed)
-                return;
-
-            $oMessage->setContents($aParsed['content']);
-            $oMessage->addMetadata('actions', $aParsed['actions']);
-            $oHistory->persistMessages();
-            return;
-        }
-    }
-
-    public function streamAgentChat($iAgentId, $sPrompt, $aParams = [], $sThreadId = null)
-    {
-        if (!isset($aParams['chat_history_subindex']))
-            $aParams = array_merge($this->resolveChatHistoryParams($iAgentId), $aParams);
-
-        // Staging header.inc.php dumps HTML on fatals and warns on null error_get_last().
-        // Any HTML after SSE is parsed by TanStack as "unterminated trailing data".
-        @ini_set('display_errors', '0');
-        @ini_set('zlib.output_compression', '0');
-        @ini_set('implicit_flush', '1');
-        if (function_exists('apache_setenv'))
-            @apache_setenv('no-gzip', '1');
-        while (ob_get_level())
-            ob_end_clean();
-
-        // UNA BxDolDb::pdoExceptionHandler dumps HTML 503 on uncaught PDOException.
-        restore_exception_handler();
-
-        $oAdapter = new NeuronAI\Chat\Messages\Stream\Adapters\AGUIAdapter($sThreadId);
-
-        http_response_code(200);
-        foreach ($oAdapter->getHeaders() as $sName => $sValue)
-            header($sName . ': ' . $sValue);
-
-        $fEmit = function ($sEvent) {
-            echo $sEvent;
-            if (ob_get_level())
-                ob_flush();
-            flush();
-        };
-
-        $aAgent = BxDolAIQuery::getAgentObject((int)$iAgentId);
-        if ($aAgent)
-            $this->_aChatContext = ['agent' => $aAgent, 'params' => $aParams];
-
-        if ($aAgent && $this->isChatSessionRateLimited($aAgent, $aParams)) {
-            $this->emitChatErrorSse($oAdapter, $fEmit, $this->getChatSessionRateLimitError());
-            error_clear_last();
-            @ini_set('display_errors', '0');
-            exit;
-        }
-
-        $iRequestTurns = (int)($aParams['request_user_turns'] ?? 0);
-        if ($aAgent && $this->isChatTurnLimitReached($aAgent, $this->getChatUserTurnCount((int)$iAgentId, $aParams), $iRequestTurns)) {
-            $this->emitConversationClosed('limit', '', $aAgent, $aParams);
-            $this->emitChatLimitSse($oAdapter, $fEmit, $this->getChatLimitMessage($aAgent));
-            error_clear_last();
-            @ini_set('display_errors', '0');
-            exit;
-        }
-
-        $sPrompt = $this->applyChatInputLimit((string)$sPrompt, $aAgent ?: []);
-
-        $bStarted = false;
-        $aSseState = $this->resetChatActionsSseState();
-        $o = null;
-        try {
-            $o = self::getAgentInstance((int)$iAgentId, $aParams);
-            $oHandler = $o->stream(new NeuronAI\Chat\Messages\UserMessage($sPrompt));
-
-            foreach ($oHandler->events($oAdapter) as $sEvent) {
-                $bStarted = true;
-                $this->processChatActionsSseEvent($sEvent, $aSseState, $fEmit);
-            }
-
-            $this->flushChatActionsSseState($aSseState, $fEmit);
-            $this->persistAssistantChatActions($o);
-
-            if ($aAgent && $this->isChatTurnLimitReached($aAgent, $this->getChatUserTurnCount((int)$iAgentId, $aParams)))
-                $this->emitConversationClosed('limit', '', $aAgent, $aParams);
-        } catch (Throwable $oException) {
-            bx_log('sys_agents', "Stream exception for agent {$iAgentId}: " . $oException->getMessage() . " INPUT:" . $sPrompt);
-            $this->flushChatActionsSseState($aSseState, $fEmit);
-            if (!$bStarted) {
-                foreach ($oAdapter->start() as $sEvent)
-                    $fEmit($sEvent);
-            }
-            $sMsg = $oException->getMessage();
-            $fEmit('data: ' . json_encode(['type' => 'RUN_ERROR', 'message' => $sMsg !== '' ? $sMsg : _t('_sys_agents_exception')]) . "\n\n");
-            foreach ($oAdapter->end() as $sEvent)
-                $fEmit($sEvent);
-        }
-
-        error_clear_last();
-        @ini_set('display_errors', '0');
-        exit;
-    }
-
-    /**
-     * SSE assistant reply without calling the model. Used when max_turns is hit.
-     */
-    protected function emitChatLimitSse($oAdapter, $fEmit, $sMessage)
-    {
-        foreach ($oAdapter->start() as $sEvent)
-            $fEmit($sEvent);
-
-        $oChunk = new NeuronAI\Chat\Messages\Stream\Chunks\TextChunk('msg_limit', (string)$sMessage);
-        foreach ($oAdapter->transform($oChunk) as $sEvent)
-            $fEmit($sEvent);
-
-        foreach ($oAdapter->end() as $sEvent)
-            $fEmit($sEvent);
-    }
-
-    /**
-     * SSE error without an assistant bubble. Used when a new session is rate-limited.
-     */
-    protected function emitChatErrorSse($oAdapter, $fEmit, $sMessage)
-    {
-        foreach ($oAdapter->start() as $sEvent)
-            $fEmit($sEvent);
-
-        $sMsg = trim((string)$sMessage);
-        $fEmit('data: ' . json_encode([
-            'type' => 'RUN_ERROR',
-            'message' => $sMsg !== '' ? $sMsg : _t('_sys_agents_exception'),
-        ]) . "\n\n");
-
-        foreach ($oAdapter->end() as $sEvent)
-            $fEmit($sEvent);
-    }
-
-    public function sendMessengerMessage ($iSender, $iRecipient, $sMsg) 
-    {        
-        $oMessengerModule = BxDolModule::getInstance('bx_messenger');
-
-        $aAutoReplyData = [
-            'message' => $sMsg,
-            'participants' => [$iSender, $iRecipient],
-        ];
-
-        $iSaveProfileId = $oMessengerModule->setProfileId($iSender);
-        $a = $oMessengerModule->sendMessage($aAutoReplyData, $iRecipient, $iSender);
-        $oMessengerModule->setProfileId($iSaveProfileId);
-
-        return $a;
-    }
-
-    public function getAgentsByAlertUnitAndAction($sUnit, $sAction)
-    {
-        $aAgents = [];
-        $a = $this->_oDb->getAgentsWithAlert();
-        foreach ($a as $r) {
-            $aAlert = explode(':', $r['alert']); // TODO: remake to concantenate $sUnit and $sAction and then compare
-            if (count($aAlert) == 2 && $aAlert[0] == $sUnit && $aAlert[1] == $sAction)
-                $aAgents[] = $r;
-        }
-
-        return $aAgents;
-    }
-
-    public function getAgentsBy($aParams)
-    {
-        return $this->_oDb->getAgentsBy($aParams);
-    }
-
-    public function getAgentsByProfileId($iProfileId)
-    {
-        return $this->_oDb->getAgentsByProfileId($iProfileId);
-    }
-    
-    public function getAgentsByFormObject($sFormObject)
-    {
-        return $this->_oDb->getAgentsByFormObject($sFormObject);
-    }
-
-    public function getAgentsByTriggerType($sTrigger)
-    {
-        return $this->_oDb->getAgentsByTriggerType($sTrigger);
-    }
-
-    public function getAgentById($iId)
-    {
-        return $this->_oDb->getAgentById($iId);
-    }
-
-    public function getAgentByTriggerWebhookKey($sKey)
-    {
-        return $this->_oDb->getAgentByTriggerWebhookKey($sKey);
-    }
-
-    public function evalCode($aAutomator, $aParams = [])
-    {
-        try {
-            $this->_evalCode($aAutomator, $aParams);
-        }
-        catch (Exception $oException) {
-            $this->log($oException->getFile() . ':' . $oException->getLine() . ' ' . $oException->getMessage());
-        }
-        catch (Error $oError) {
-            $this->log($oError->getFile() . ':' . $oError->getLine() . ' ' . $oError->getMessage());
-        }
-    }
-
-    public function emulCode($aAutomator, $aParams = [])
-    {
-        ob_start();
-
-        try {
-            $this->_evalCode($aAutomator, $aParams);
-        }
-        catch (Exception $oException) {
-            return $oException->getMessage();
-        }
-        catch (Error $oError) {
-            return $oError->getMessage();
-        }
-        finally {
-            $sOutput = ob_get_clean();
-
-            if(!empty($sOutput))
-                return $sOutput;
-        }
-    }
-
-    public function log($mixedContents, $sSection = '')
-    {
-        if(!$this->_bWriteLog)
-            return;
-
-        if(is_array($mixedContents))
-            $mixedContents = var_export($mixedContents, true);	
-        else if(is_object($mixedContents))
-            $mixedContents = json_encode($mixedContents);
-
-        if(empty($sSection))
-            $sSection = "Core";
-
-        bx_log('sys_agents', ":\n[" . $sSection . "] " . $mixedContents, BX_LOG_ERR);
-    }
-
-    protected function _evalCode($aAutomator, $aParams = [])
-    {
-        $sCode = '';
-        switch($aAutomator['type']) {
-            case BX_DOL_AI_AUTOMATOR_EVENT:
-                $sCode = $aAutomator['code']. '; onAlert($aParams["alert"]->iObject , $aParams["alert"]->iSender , $aParams["alert"]->aExtras);';
-                break;
-
-            case BX_DOL_AI_AUTOMATOR_SCHEDULER:
-                $sCode = $aAutomator['code'] . '; onCron();';
-                break;
-
-            case BX_DOL_AI_AUTOMATOR_WEBHOOK:
-                $sCode = $aAutomator['code'] . '; onHook();';
-                break;
-        }
-
-        eval($sCode);
-    }
-}

+ 0 - 524
inc/classes/BxDolAIQuery.php

@@ -1,524 +0,0 @@
-<?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 BxDolAIQuery extends BxDolDb
-{
-    public function __construct()
-    {
-        parent::__construct();
-    }
-
-    static protected function _getObject(int $iId, string $sTable): array|bool
-    {
-        $oDb = BxDolDb::getInstance();
-
-        $a = $oDb->getRow("SELECT * FROM `$sTable` WHERE `id` = :id", ['id' => $iId]);
-        if(!$a || !is_array($a))
-            return false;
-
-        return $a;
-    }
-
-    static public function getModelObject(int $iId): array|bool
-    {
-        return self::_getObject($iId, 'sys_agents_models');
-    }
-
-    static public function getVectorStoreObject(int $iId): array|bool
-    {
-        return self::_getObject($iId, 'sys_agents_vector_store');
-    }
-
-    static public function getAgentObject(int $iId): array|bool
-    {
-        return self::_getObject($iId, 'sys_agents_agents');
-    }
-
-    static public function getToolObject(int $iId): array|bool
-    {
-        return self::_getObject($iId, 'sys_agents_tools');
-    }
-
-    static public function getAssistantObject($iId)
-    {
-        $oDb = BxDolDb::getInstance();
-
-        $aAssistant = $oDb->getRow("SELECT * FROM `sys_agents_assistants` WHERE `id` = :id", ['id' => $iId]);
-        if(!$aAssistant || !is_array($aAssistant))
-            return false;
-
-        return $aAssistant;
-    }
-
-    public function insertModel($aModel)
-    {
-        if(empty($aModel))
-            return false;
-
-        return (int)$this->query("INSERT INTO `sys_agents_models` SET " . $this->arrayToSQL($aModel));
-    }
-
-    public function getModelsBy($aParams = [])
-    {
-        $aMethod = ['name' => 'getAll', 'params' => [0 => 'query']];
-    	$sWhereClause = "";
-
-        switch($aParams['sample']) {
-            case 'id':
-            	$aMethod['name'] = 'getRow';
-            	$aMethod['params'][1] = [
-                    'id' => $aParams['id']
-                ];
-
-                $sWhereClause .= " AND `id`=:id";
-                break;
-
-            case 'all_pairs':
-                $aMethod['name'] = 'getPairs';
-                $aMethod['params'][1] = 'id';
-                $aMethod['params'][2] = 'title';
-                $aMethod['params'][3] = [];
-
-                if(isset($aParams['active'])) {
-                    $aMethod['params'][3]['active'] = $aParams['active'];
-
-                    $sWhereClause .= " AND `active`=:active";
-                }
-
-                if(isset($aParams['capabilities'])) {
-                    if (is_array($aParams['capabilities']))
-                    {                        
-                        $sWhereClause .= " AND `capabilities` IN (" . $this->implode_escape($aParams['capabilities']) . ")"; 
-                    }
-                    else {
-                        $aMethod['params'][3]['capabilities'] = $aParams['capabilities'];
-                        $sWhereClause .= " AND `capabilities` = :capabilities";
-                    }
-                }
-                break;
-        }
-
-        $aMethod['params'][0] = "SELECT * 
-            FROM `sys_agents_models`
-            WHERE 1" . $sWhereClause;
-
-        return call_user_func_array([$this, $aMethod['name']], $aMethod['params']);
-    }
-
-    public function getVectorStores (): mixed
-    {
-        $sQuery = "SELECT `id`, `title` FROM `sys_agents_vector_store` WHERE `active` = 1 ORDER BY `title` ASC";
-        return $this->getPairs($sQuery, 'id', 'title'); 
-    }
-
-    public function getVectorStoreById (int $iId): mixed
-    {
-        $sQuery = "SELECT * FROM `sys_agents_vector_store` WHERE `id` = :id";
-        return $this->getRow($sQuery, ['id' => $iId]); 
-    }
-
-    public function insertVectorStore($aVectorStore)
-    {
-        if(empty($aVectorStore) || !is_array($aVectorStore))
-            return false;
-
-        return (int)$this->query("INSERT INTO `sys_agents_vector_store` SET " . $this->arrayToSQL($aVectorStore));
-    }
-
-    public function getVectorStoreDataNum (int $iVectorStoreId): int
-    {
-        $sQuery = "SELECT COUNT(*) FROM `sys_agents_vector_store_data` WHERE `vector_store_id` = :vector_store_id";
-        return (int)$this->getOne($sQuery, ['vector_store_id' => $iVectorStoreId]);
-    }
-
-    public function addVectorStoreData (int $iVectorStoreId, string $sType, string $sName, int $iFileSize, string $sMetadata, string $sSettings, string $sContent): bool
-    {
-        $sQuery = "INSERT INTO `sys_agents_vector_store_data` SET  
-            `vector_store_id` = :vector_store_id,
-            `type` = :type, 
-            `name` = :name, 
-            `size` = :size,
-            `metadata` = :metadata, 
-            `settings` = :settings, 
-            `content` = :content,
-            `added` = :ts
-        ";
-        return $this->query($sQuery, [
-            'vector_store_id' => $iVectorStoreId,
-            'type' => $sType, 
-            'name' => $sName, 
-            'size' => $iFileSize,
-            'metadata' => $sMetadata, 
-            'settings' => $sSettings, 
-            'content' => $sContent,
-            'ts' => time()]) > 0;
-    }
-
-    public function getVectorStoreDataById (int $iId): mixed
-    {
-        $sQuery = "SELECT * FROM `sys_agents_vector_store_data` WHERE `id` = :id";
-        return $this->getRow($sQuery, ['id' => $iId]);
-    }
-
-    static public function getVectorStorePendingData (int $iLimit = 1): mixed
-    {
-        $oDb = BxDolDb::getInstance();
-        $sQuery = "SELECT * FROM `sys_agents_vector_store_data` WHERE `status` = 'pending' ORDER BY `added` ASC LIMIT :limit";
-        return $oDb->getAll($sQuery, ['limit' => $iLimit]);
-    }
-
-    static public function updateVectorStoreDataStatus (int $iId, string $sStatus): mixed
-    {
-        $oDb = BxDolDb::getInstance();
-        $sQuery = "UPDATE `sys_agents_vector_store_data` SET `status` = :status WHERE `id` = :id";
-        return $oDb->query($sQuery, ['status' => $sStatus, 'id' => $iId]);
-    }
-
-    public function getAgentsWithAlert($bActiveOnly = true)
-    {
-        return $this->fromCache('sys_agents_with_alert', 'getAll', "SELECT * FROM `sys_agents_agents` WHERE `trigger` = 'alert' AND `active` = :active", ['active' => $bActiveOnly ? 1 : 0]);
-    }
-
-    public function getAgentsByProfileId($iProfileId, $bActiveOnly = true)
-    {
-        return $this->getAll("SELECT * FROM `sys_agents_agents` WHERE `trigger` = 'message' AND `profile_id` = :profile AND `active` = :active", ['profile' => $iProfileId, 'active' => $bActiveOnly ? 1 : 0]);
-    }
-
-    public function getAgentsByFormObject($sFormObject, $bActiveOnly = true)
-    {
-        return $this->fromMemory('sys_agents_with_form_' . $sFormObject, 'getAll', "SELECT * FROM `sys_agents_agents` WHERE `trigger` = 'form-input' AND `form_object` = :form_object AND `active` = :active", ['form_object' => $sFormObject, 'active' => $bActiveOnly ? 1 : 0]);
-    }
-
-    public function getAgentsBy($aParams = [])
-    {
-        $aMethod = ['name' => 'getAll', 'params' => [0 => 'query']];
-    	$sWhereClause = "";
-
-        switch($aParams['sample']) {
-            case 'id':
-            	$aMethod['name'] = 'getRow';
-            	$aMethod['params'][1] = [
-                    'id' => $aParams['id']
-                ];
-
-                $sWhereClause .= " AND `id`=:id";
-                break;
-
-            case 'all':
-                if(isset($aParams['active'])) {
-                    $aMethod['params'][3]['active'] = $aParams['active'];
-
-                    $sWhereClause .= " AND `active`=:active";
-                }
-                break;
-        }
-
-        $aMethod['params'][0] = "SELECT * 
-            FROM `sys_agents_agents`
-            WHERE 1" . $sWhereClause;
-
-        return call_user_func_array([$this, $aMethod['name']], $aMethod['params']);
-    }
-
-    public function getAgentById(int $iId): mixed
-    {
-        return $this->getAgentsBy(['sample' => 'id', 'id' => $iId]);
-    }
-
-    public function getAgentByTriggerWebhookKey($sKey, $bActiveOnly = true)
-    {
-        return $this->getRow("SELECT * FROM `sys_agents_agents` WHERE `trigger` = 'scheduler' AND `webhook_key` = :key AND `active` = :active", ['key' => $sKey, 'active' => $bActiveOnly ? 1 : 0]);
-    }
-    
-    public function getAgentsByTriggerType($sTrigger, $bActiveOnly = true)
-    {
-        return $this->getAll("SELECT * FROM `sys_agents_agents` WHERE `trigger` = :trigger AND `active` = :active", ['trigger' => $sTrigger, 'active' => $bActiveOnly ? 1 : 0]);
-    }
-
-    public function updateAgentField($iId, $sField, $sValue)
-    {
-        return $this->query("UPDATE `sys_agents_agents` SET `$sField` = :value WHERE `id` = :id", ['value' => $sValue, 'id' => $iId]);
-    }
-
-    public function getToolById (int $iId): mixed
-    {
-        $sQuery = "SELECT * FROM `sys_agents_tools` WHERE `id` = :id";
-        return $this->getRow($sQuery, ['id' => $iId]); 
-    }
-
-    public function insertTool(array $aFields): int
-    {
-        if(empty($aFields) || !is_array($aFields))
-            return false;
-
-        return (int)$this->query("INSERT INTO `sys_agents_tools` SET " . $this->arrayToSQL($aFields));
-    }
-
-    public function getTools()
-    {
-        $sQuery = "SELECT `id`, `title` FROM `sys_agents_tools` WHERE `active` = 1 ORDER BY `title` ASC";
-        return $this->getPairs($sQuery, 'id', 'title'); 
-    }
-
-    public function wipeAgentChatHistory($aAgent) 
-    {
-        $sQuery = "DELETE FROM `sys_agents_chat_history` WHERE `thread_id` LIKE :val";
-        $iAffected = 0;
-        $iAffected += $this->query($sQuery, ['val' => $aAgent['trigger'] . ':' . $aAgent['id'] . ':%']);
-        $iAffected += $this->query($sQuery, ['val' => $aAgent['trigger'] . ':' . $aAgent['id']]);
-        return $iAffected;
-    }
-
-    public function getAgentChatHistoryRows($aAgent)
-    {
-        $sExact = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
-        $sFields = "`id`, `thread_id`, `messages`, `created_at`, `updated_at`";
-        if ($this->isFieldExists('sys_agents_chat_history', 'closed_reason'))
-            $sFields .= ", `closed_reason`";
-        return $this->getAll("
-            SELECT $sFields
-            FROM `sys_agents_chat_history`
-            WHERE `thread_id` = :exact OR `thread_id` LIKE :prefix
-            ORDER BY `updated_at` DESC
-            LIMIT 200
-        ", [
-            'exact' => $sExact,
-            'prefix' => $sExact . ':%',
-        ]);
-    }
-
-    public function getChatArtifactsByHistoryIds($aIds)
-    {
-        $aIds = array_values(array_unique(array_filter(array_map('intval', (array)$aIds))));
-        if (!$aIds)
-            return [];
-
-        $sIn = implode(',', $aIds);
-        $aRows = $this->getAll("
-            SELECT `history_id`, `field_name`, `field_value`
-            FROM `sys_agents_chat_artifacts`
-            WHERE `history_id` IN ($sIn)
-            ORDER BY `id` ASC
-        ");
-        return $this->mapChatArtifactRows($aRows);
-    }
-
-    public function getChatArtifactsByThreadId($sThreadId)
-    {
-        $sThreadId = (string)$sThreadId;
-        if ($sThreadId === '')
-            return [];
-
-        $aRows = $this->getAll("
-            SELECT `a`.`history_id`, `a`.`field_name`, `a`.`field_value`
-            FROM `sys_agents_chat_artifacts` AS `a`
-            INNER JOIN `sys_agents_chat_history` AS `h` ON `h`.`id` = `a`.`history_id`
-            WHERE `h`.`thread_id` = :t
-            ORDER BY `a`.`id` ASC
-        ", [
-            't' => $sThreadId,
-        ]);
-        $aMap = $this->mapChatArtifactRows($aRows);
-        if (!$aMap)
-            return [];
-        return (array)reset($aMap);
-    }
-
-    public function getChatArtifactsForAgent($aAgent)
-    {
-        $sExact = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
-        if ($sExact === ':')
-            return [];
-
-        $aRows = $this->getAll("
-            SELECT `a`.`history_id`, `a`.`field_name`, `a`.`field_value`
-            FROM `sys_agents_chat_artifacts` AS `a`
-            INNER JOIN `sys_agents_chat_history` AS `h` ON `h`.`id` = `a`.`history_id`
-            WHERE `h`.`thread_id` = :exact OR `h`.`thread_id` LIKE :prefix
-            ORDER BY `a`.`id` ASC
-        ", [
-            'exact' => $sExact,
-            'prefix' => $sExact . ':%',
-        ]);
-        return $this->mapChatArtifactRows($aRows);
-    }
-
-    protected function mapChatArtifactRows($aRows)
-    {
-        if (!is_array($aRows) || !$aRows)
-            return [];
-
-        $aOut = [];
-        foreach ($aRows as $aRow) {
-            if (!is_array($aRow))
-                continue;
-            $iHistoryId = (int)($aRow['history_id'] ?? 0);
-            $sName = trim((string)($aRow['field_name'] ?? ''));
-            if ($iHistoryId <= 0 || $sName === '')
-                continue;
-            $aOut[$iHistoryId][] = [
-                'field_name' => $sName,
-                'field_value' => (string)($aRow['field_value'] ?? ''),
-            ];
-        }
-        return $aOut;
-    }
-
-    public function getExpiredAgentChatHistoryIds($aAgent, $iTtlMin)
-    {
-        if (!$this->isFieldExists('sys_agents_chat_history', 'closed_reason'))
-            return [];
-
-        $sExact = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
-        $iTtlMin = (int)$iTtlMin;
-        if ($sExact === ':' || $iTtlMin <= 0)
-            return [];
-
-        return $this->getColumn("
-            SELECT `id` FROM `sys_agents_chat_history`
-            WHERE (`thread_id` = :exact OR `thread_id` LIKE :prefix)
-              AND `messages` IS NOT NULL AND `messages` != '' AND `messages` != '[]'
-              AND (`closed_reason` = '' OR `closed_reason` IS NULL)
-              AND `updated_at` <= DATE_SUB(NOW(), INTERVAL :ttl MINUTE)
-        ", [
-            'exact' => $sExact,
-            'prefix' => $sExact . ':%',
-            'ttl' => $iTtlMin,
-        ]);
-    }
-
-    /**
-     * Rename this agent's guest threads (`…:{sessionId}`) onto a profile id.
-     * If the member thread already has messages, drop the guest copy.
-     */
-    public function adoptGuestChatHistory($aAgent, $sSessionId, $iProfileId)
-    {
-        $sSessionId = (string)$sSessionId;
-        $iProfileId = (int)$iProfileId;
-        if ($sSessionId === '' || !$iProfileId || empty($aAgent['id']))
-            return 0;
-
-        $sPrefix = $aAgent['trigger'] . ':' . $aAgent['id'] . ':';
-        $sOldSuffix = ':' . $sSessionId;
-        $sNewSuffix = ':' . $iProfileId;
-        $iLen = strlen($sOldSuffix);
-        $aBindings = [
-            'p' => $sPrefix . '%',
-            'o' => $sOldSuffix,
-            'n' => $sNewSuffix,
-        ];
-
-        $this->query("
-            DELETE `g` FROM `sys_agents_chat_history` AS `g`
-            INNER JOIN `sys_agents_chat_history` AS `m`
-                ON `m`.`thread_id` = CONCAT(LEFT(`g`.`thread_id`, CHAR_LENGTH(`g`.`thread_id`) - $iLen), :n)
-            WHERE `g`.`thread_id` LIKE :p AND RIGHT(`g`.`thread_id`, $iLen) = :o
-              AND `m`.`messages` IS NOT NULL AND `m`.`messages` != '' AND `m`.`messages` != '[]'
-        ", $aBindings);
-
-        $this->query("
-            DELETE `m` FROM `sys_agents_chat_history` AS `m`
-            INNER JOIN `sys_agents_chat_history` AS `g`
-                ON `g`.`thread_id` LIKE :p AND RIGHT(`g`.`thread_id`, $iLen) = :o
-               AND `m`.`thread_id` = CONCAT(LEFT(`g`.`thread_id`, CHAR_LENGTH(`g`.`thread_id`) - $iLen), :n)
-            WHERE `m`.`messages` = '' OR `m`.`messages` = '[]' OR `m`.`messages` IS NULL
-        ", $aBindings);
-
-        $iAffected = (int)$this->query("
-            UPDATE `sys_agents_chat_history`
-            SET `thread_id` = CONCAT(LEFT(`thread_id`, CHAR_LENGTH(`thread_id`) - $iLen), :n)
-            WHERE `thread_id` LIKE :p AND RIGHT(`thread_id`, $iLen) = :o
-        ", $aBindings);
-
-        return $iAffected;
-    }
-
-    static public function getAlert($s) {
-        if (!$s)
-            return false;        
-        $a = explode(':', $s);
-        $oDb = BxDolDb::getInstance();
-        return $oDb->getRow("SELECT * FROM `sys_alerts_log` WHERE `unit` = :unit AND `action` = :action", [
-            'unit' => $a[0] ?? '',
-            'action' => $a[1] ?? '',
-        ]);
-    }
-
-    public function getAlerts()
-    {
-        $aValues = [];
-        $aAlerts = $this->getAll("SELECT `unit`, `action`, `counter_24h`, `counter_per_request` FROM `sys_alerts_log` ORDER BY `unit`, `action`");
-        foreach ($aAlerts as $a) {
-            $sKey = $a['unit'] . ':' . $a['action'];
-            $aValues[$sKey] = [
-                'key' => $sKey,
-                'unit' => $a['unit'],
-                'action' => $a['action'],
-                'name' => $a['unit'] . ' - ' . $a['action'],
-                'desc' => $this->getAlertDesc($a['unit'] . ':' . $a['action']),
-                'counter_24h' => $a['counter_24h'],
-                'counter_per_request' => $a['counter_per_request'],
-            ];
-        }
-        return $aValues;
-    }
-
-    public function getFormDisplay($sObject)
-    {
-        $aDisplays = $this->getColumn("SELECT `display_name` FROM `sys_form_displays` WHERE `object` = :object", ['object' => $sObject]);
-        return $aDisplays ? current($aDisplays) : null;
-    }
-
-    public function getFormObjects()
-    {
-        $aValues = [];
-        $aForms = $this->getAll("SELECT `f`.`object`, `f`.`title`, `f`.`module`, `m`.`title` AS `module_title` FROM `sys_objects_form` AS f INNER JOIN `sys_modules` as `m` ON `m`.`name` = `f`.`module` ORDER BY `f`.`module`, `f`.`object` ASC");
-        foreach ($aForms as $r) {
-            if ('system' != $r['module']) {
-                $oModule = BxDolModule::getInstance($r['module']);
-                if (!$oModule || !$oModule->isEnabled())
-                    continue;
-            }
-            
-            $sDisplay = $this->getFormDisplay($r['object']);
-            if (!$sDisplay) 
-                continue;
-            $oForm = BxDolForm::getObjectInstance($r['object'], $sDisplay);
-            $sFormId = $oForm->getId();
-            $aValues[$r['object']] = [
-                'form_id' => $sFormId,
-                'form_object' => $r['object'],
-                'module' => $r['module'],
-                'module_title' => $r['module_title'],
-                'form_title' => _t($r['title']),
-            ];
-        }
-        return $aValues;
-    }
-
-    static public function getAlertDesc($sAlert) 
-    {
-        if (!$sAlert)
-            return '';
-        $oDb = BxDolDb::getInstance();
-        [$sUnit, $sAction] = explode(':', $sAlert);
-        $sDesc = $oDb->getOne("SELECT `description` FROM `sys_alerts_desc` WHERE `unit` = :unit AND `action` = :action LIMIT 1", [
-            'unit' => $sUnit,
-            'action' => $sAction,
-        ]);
-        if (!$sDesc) {
-            $oDb->getOne("SELECT `description` FROM `sys_alerts_desc` WHERE `unit` LIKE '{%}' AND `action` = :action LIMIT 1", [
-                'action' => $sAction,
-            ]);
-        }
-        return $sDesc;
-    }
-}
-
-/** @} */