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

Ticket #5654 - AI agents improvements. Renaming.

AntonLV 1 месяц назад
Родитель
Сommit
03aaae0e79

+ 1 - 1
inc/classes/BxDolAIModelFactory.php

@@ -17,7 +17,7 @@ class BxDolAIModelFactory extends BxDolFactory
             return $GLOBALS['bxDolClasses'][$sCacheKey];
 
         $aProvidersWithKey = ['anthropic', 'openai-embeddings', 'voyageai-embeddings', 'openai-like-embeddings', 'openai-responses', 'openai-like'];
-        $a = BxDolAIQuery::getModelObject($iId);
+        $a = BxDolAiQuery::getModelObject($iId);
         if (!$a) {
             bx_log('sys_agents', "Agent AI Model with id {$iId} not found", BX_LOG_ERR);
             throw new Exception("Agent AI Model with id {$iId} not found");

+ 1 - 1
inc/classes/BxDolAIToolChatArtifactSave.php

@@ -46,7 +46,7 @@ class BxDolAIToolChatArtifactSave extends BxDolAITool
         if ($sName === '' || $sValue === '')
             return 'ignored';
 
-        $iHistoryId = (int)BxDolAI::getInstance()->getCurrentChatHistoryId();
+        $iHistoryId = (int)BxDolAi::getInstance()->getCurrentChatHistoryId();
         if ($iHistoryId <= 0)
             return 'no_history';
 

+ 1 - 1
inc/classes/BxDolAIToolChatGoalReached.php

@@ -34,7 +34,7 @@ class BxDolAIToolChatGoalReached extends BxDolAITool
 
     public function __invoke(string $sSummary = ''): string
     {
-        $b = BxDolAI::getInstance()->emitConversationClosed('goal', trim((string)$sSummary));
+        $b = BxDolAi::getInstance()->emitConversationClosed('goal', trim((string)$sSummary));
         return $b ? 'ok' : 'already_closed';
     }
 }

+ 1 - 1
inc/classes/BxDolAIToolFactory.php

@@ -14,7 +14,7 @@ class BxDolAIToolFactory extends BxDolFactory
         if (isset($GLOBALS['bxDolClasses'][__CLASS__ . '_AiAgentTool_' . $iId]))
             return $GLOBALS['bxDolClasses'][__CLASS__ . '_AiAgentTool_' . $iId];
 
-        $a = BxDolAIQuery::getToolObject($iId);
+        $a = BxDolAiQuery::getToolObject($iId);
         if (!$a) {
             bx_log('sys_agents', "Tool with id {$iId} not found", BX_LOG_ERR);
             throw new Exception("Tool with id {$iId} not found");

+ 1 - 1
inc/classes/BxDolAIVectorStoreFactory.php

@@ -15,7 +15,7 @@ class BxDolAIVectorStoreFactory extends BxDolFactory
         if (isset($GLOBALS['bxDolClasses'][__CLASS__ . '_VectorStore_' . $iId]))
             return $GLOBALS['bxDolClasses'][__CLASS__ . '_VectorStore_' . $iId];
 
-        $a = BxDolAIQuery::getVectorStoreObject($iId);
+        $a = BxDolAiQuery::getVectorStoreObject($iId);
         if (!$a) {
             bx_log('sys_agents', "Vector store with id {$iId} not found", BX_LOG_ERR);
             throw new Exception("Vector store with id {$iId} not found");

+ 1533 - 0
inc/classes/BxDolAi.php

@@ -0,0 +1,1533 @@
+<?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 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);
+    }
+}

+ 3 - 3
inc/classes/BxDolAiAgent.php

@@ -54,7 +54,7 @@ class BxDolAiAgent extends RAG
                 $aPromptTools[] = "Return array only, modified version of 'extra' array. Modifyable keys: " . $this->getAlertTriggerModifyableKeys() . ".";
             }
 
-            $sDesc = trim(BxDolAIQuery::getAlertDesc($this->aAgent['alert']));
+            $sDesc = trim(BxDolAiQuery::getAlertDesc($this->aAgent['alert']));
             if ('.' != mb_substr($sDesc, -1))
                 $sDesc .= '.';
             $aPromptSystem[] = $sDesc;
@@ -129,7 +129,7 @@ class BxDolAiAgent extends RAG
 
     protected function getСhatHistoryThreadId(): string
     {
-        return BxDolAI::chatHistoryThreadId($this->aAgent, $this->aParams);
+        return BxDolAi::chatHistoryThreadId($this->aAgent, $this->aParams);
     }
 
     protected function getAlertTriggerModifyableKeys(): string
@@ -137,7 +137,7 @@ class BxDolAiAgent extends RAG
         if ('alert' != $this->aAgent['trigger'])
             return 'none';
 
-        $aAlert = BxDolAIQuery::getAlert($this->aAgent['alert']);
+        $aAlert = BxDolAiQuery::getAlert($this->aAgent['alert']);
         if (!$aAlert)
             return 'none';
 

+ 1 - 1
inc/classes/BxDolAiAlertResponse.php

@@ -25,7 +25,7 @@ class BxDolAiAlertResponse extends BxDolAlertsResponse
             return;
 
         // call agents
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
         if($aAgents = $oAi->getAgentsByProfileId($iRecipient)) {
             $GLOBALS['glAgentsCallQueue'] = [];
             foreach($aAgents as $a) {

+ 524 - 0
inc/classes/BxDolAiQuery.php

@@ -0,0 +1,524 @@
+<?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;
+    }
+}
+
+/** @} */

+ 1 - 1
inc/classes/BxDolAlerts.php

@@ -153,7 +153,7 @@ class BxDolAlerts extends BxDol
         BxDolAlertsStats::log($this);
 
         // call agents
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
         if($aAgents = $oAi->getAgentsByAlertUnitAndAction($this->sUnit, $this->sAction)) {
             foreach($aAgents as $a) {
                 $aParams = [

+ 1 - 1
inc/classes/BxDolCronPruning.php

@@ -73,7 +73,7 @@ class BxDolCronPruning extends BxDolCron
         $iDeletedKeys = $oKey ? $oKey->prune() : 0;
 
         // clean ai related data
-        $iDeletedItems = BxDolAI::pruning();
+        $iDeletedItems = BxDolAi::pruning();
 
         // clean background jobs
         $iDeletedJobs = BxDolBackgroundJobs::pruning();

+ 1 - 1
inc/utils.inc.php

@@ -2942,7 +2942,7 @@ function bx_ai_process_agents_call_queue($bFinishRequest = true, $bExit = true)
 
     if (!empty($GLOBALS['glAgentsCallQueue']))
     {
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
         foreach ($GLOBALS['glAgentsCallQueue'] as $r) 
         {
             $sMessage = $oAi->callAgent($r['type'], $r['agent'], $r['params']);

+ 1 - 1
studio/classes/BxDolStudioAgents.php

@@ -24,7 +24,7 @@ class BxDolStudioAgents extends BxTemplStudioWidget
     {
         parent::__construct('agents');
 
-        $this->oDbAi = new BxDolAIQuery();
+        $this->oDbAi = new BxDolAiQuery();
 
         $this->sPage = BX_DOL_STUDIO_AGENTS_TYPE_DEFAULT;
         if(is_string($sPage) && !empty($sPage))

+ 1 - 1
studio/classes/BxDolStudioAgentsAgents.php

@@ -15,7 +15,7 @@ class BxDolStudioAgentsAgents extends BxTemplStudioGridAgents
     {
         parent::__construct ($aOptions, $oTemplate);
 
-        $this->_iProfileIdAi = BxDolAI::getInstance()->getProfileId();
+        $this->_iProfileIdAi = BxDolAi::getInstance()->getProfileId();
     }
 }
 

+ 1 - 1
studio/classes/BxDolStudioAgentsQuery.php

@@ -7,7 +7,7 @@
  * @{
  */
 
-class BxDolStudioAgentsQuery extends BxDolAIQuery
+class BxDolStudioAgentsQuery extends BxDolAiQuery
 {
     public function __construct()
     {

+ 1 - 1
studio/template/scripts/BxBaseStudioAgents.php

@@ -159,7 +159,7 @@ class BxBaseStudioAgents extends BxDolStudioAgents
         $sJsObject = $this->getPageJsObject();
         $oTemplate = BxDolStudioTemplate::getInstance();
 
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
         $aAgents = $oAi->getAgentsBy(['sample' => 'all']);
 
         $oGrid = $this->getGrid($this->aGridObjects[BX_DOL_STUDIO_AGENTS_TYPE_AGENTS], true);

+ 17 - 17
studio/template/scripts/BxBaseStudioAgentsAgents.php

@@ -53,7 +53,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     public function performActionManual()
     {
         $iId = $this->_getId();        
-        $aAgent = BxDolAIQuery::getAgentObject($iId);
+        $aAgent = BxDolAiQuery::getAgentObject($iId);
         if (!$aAgent) {
             echoJson(['msg' => _t('_sys_txt_error_occured')]);
             return;
@@ -63,7 +63,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
             return;
         }
 
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
         try {
             $sResponse = $oAi->callAgent('manual', $aAgent);
         } catch (Throwable $o) {
@@ -85,13 +85,13 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     public function performActionWipeChatHistory()
     {
         $iId = $this->_getId();        
-        $aAgent = BxDolAIQuery::getAgentObject($iId);
+        $aAgent = BxDolAiQuery::getAgentObject($iId);
         if (!$aAgent) {
             echoJson(['msg' => _t('_sys_txt_error_occured')]);
             return;
         }
 
-        $oDb = new BxDolAIQuery();
+        $oDb = new BxDolAiQuery();
         $oDb->wipeAgentChatHistory($aAgent);
 
         $aRes = ['grid' => $this->getCode(false), 'blink' => $iId];
@@ -103,7 +103,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
         $sAction = 'add';
 
         $oTemplate = BxDolStudioTemplate::getInstance();
-        $oAI = BxDolAI::getInstance();
+        $oAI = BxDolAi::getInstance();
 
         $aForm = $this->_getForm($sAction);
         $oForm = new BxTemplFormView($aForm);
@@ -171,10 +171,10 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
         $sAction = 'edit';
 
         $oTemplate = BxDolStudioTemplate::getInstance();
-        $oAI = BxDolAI::getInstance();
+        $oAI = BxDolAi::getInstance();
 
         $iId = $this->_getId();
-        $aAgent = BxDolAIQuery::getAgentObject($iId);
+        $aAgent = BxDolAiQuery::getAgentObject($iId);
 
         $aForm = $this->_getFormEdit($sAction, $aAgent);
         $oForm = new BxTemplFormView($aForm);
@@ -238,7 +238,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     public function performActionLogs()
     {
         $iId = $this->_getId();
-        $aAgent = BxDolAIQuery::getAgentObject($iId);
+        $aAgent = BxDolAiQuery::getAgentObject($iId);
         if (!$aAgent) {
             echoJson(['msg' => _t('_sys_txt_error_occured')]);
             return;
@@ -259,7 +259,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     public function performActionMessage()
     {
         $iId = $this->_getId();
-        $aAgent = BxDolAIQuery::getAgentObject($iId);
+        $aAgent = BxDolAiQuery::getAgentObject($iId);
         if (!$aAgent) {
             echoJson(['msg' => _t('_sys_txt_error_occured')]);
             return;
@@ -272,7 +272,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
         $sChatId = 'bx-ai-chat-' . $iId;
         $sName = !empty($aAgent['title']) ? $aAgent['title'] : $aAgent['name'];
         $aThreadsTpl = [];
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
         $aThreads = $oAi && method_exists($oAi, 'listAgentChatThreads') ? $oAi->listAgentChatThreads($aAgent) : [];
         foreach ($aThreads as $aThread) {
             $aThreadsTpl[] = [
@@ -314,14 +314,14 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     public function performActionGetChatThreads()
     {
         $iId = $this->_getId();
-        $aAgent = BxDolAIQuery::getAgentObject($iId);
+        $aAgent = BxDolAiQuery::getAgentObject($iId);
         if (!$aAgent) {
             echoJson(['code' => 404, 'threads' => []]);
             return;
         }
 
         $aThreadsJs = [];
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
         $aThreads = $oAi && method_exists($oAi, 'listAgentChatThreads') ? $oAi->listAgentChatThreads($aAgent) : [];
         foreach ($aThreads as $aThread)
             $aThreadsJs[] = $this->_agentChatThreadJs($aThread);
@@ -332,14 +332,14 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     public function performActionGetChatThread()
     {
         $iId = $this->_getId();
-        $aAgent = BxDolAIQuery::getAgentObject($iId);
+        $aAgent = BxDolAiQuery::getAgentObject($iId);
         if (!$aAgent) {
             echoJson(['code' => 404, 'msg' => _t('_sys_txt_error_occured'), 'messages' => []]);
             return;
         }
 
         $sThreadId = trim((string)bx_get('thread_id'));
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
         if (!$oAi || !method_exists($oAi, 'getChatHistoryUiMessagesByThread')) {
             echoJson(['code' => 503, 'msg' => _t('_sys_txt_error_occured'), 'messages' => []]);
             return;
@@ -459,7 +459,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
             // $this->_oDb->deleteAutomatorHelpers(['automator_id' => (int)$mixedId]);
             // $this->_oDb->deleteAutomatorAssistants(['automator_id' => (int)$mixedId]);
 
-            // if(($oCmts = BxDolAI::getInstance()->getAutomatorCmtsObject($mixedId)) !== false)
+            // if(($oCmts = BxDolAi::getInstance()->getAutomatorCmtsObject($mixedId)) !== false)
             //    $oCmts->onObjectDelete();
         }
 
@@ -550,7 +550,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
                     'name' => 'model_id',
                     'caption' => _t('_sys_agents_field_model_id'),
                     'info' => _t('_sys_agents_field_model_id_info'),
-                    'value' => isset($aAgent['model_id']) ? $aAgent['model_id'] : BxDolAI::getDefaultModel(),
+                    'value' => isset($aAgent['model_id']) ? $aAgent['model_id'] : BxDolAi::getDefaultModel(),
                     'values' => $this->_getModelSelectValues(),
                     'required' => '1',
                     'checker' => [
@@ -1240,7 +1240,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
 
     protected function _getAgentWithProfile($iProfileId, $iExcludeAgentId = 0)
     {
-        $aAgents = BxDolAI::getInstance()->getAgentsByProfileId($iProfileId);
+        $aAgents = BxDolAi::getInstance()->getAgentsByProfileId($iProfileId);
         foreach($aAgents as $aAgent) {
             if($aAgent['id'] != $iExcludeAgentId)
                 return $aAgent;

+ 1 - 1
studio/template/scripts/BxBaseStudioAgentsVectorStore.php

@@ -125,7 +125,7 @@ class BxBaseStudioAgentsVectorStore extends BxDolStudioAgentsInstruments
         $sDocs = $oParsedown->text($aVectorStore['docs']);
 
         $aEmbeddingProviders = ['' => _t('_sys_please_select')];
-        $a = BxDolAI::getInstance()->getModels(['capabilities' => 'embeddings', 'active' => true]);
+        $a = BxDolAi::getInstance()->getModels(['capabilities' => 'embeddings', 'active' => true]);
         foreach ($a as $k => $v) {
             $aEmbeddingProviders[$k] = $v;
         }