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

Merge branch 'master' of https://github.com/unacms/una

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

+ 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)BxDolAiChat::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 = BxDolAiChat::getInstance()->emitConversationClosed('goal', trim((string)$sSummary));
         return $b ? 'ok' : 'already_closed';
     }
 }

Разница между файлами не показана из-за своего большого размера
+ 9 - 1229
inc/classes/BxDolAi.php


+ 1 - 1
inc/classes/BxDolAiAgent.php

@@ -129,7 +129,7 @@ class BxDolAiAgent extends RAG
 
     protected function getСhatHistoryThreadId(): string
     {
-        return BxDolAi::chatHistoryThreadId($this->aAgent, $this->aParams);
+        return BxDolAiChat::threadId($this->aAgent, $this->aParams);
     }
 
     protected function getAlertTriggerModifyableKeys(): string

+ 1 - 61
inc/classes/BxDolAiAlertResponse.php

@@ -9,68 +9,8 @@
  * @{
  */
 
-class BxDolAiAlertResponse extends BxDolAlertsResponse
+class BxDolAiAlertResponse extends BxDolAiTriggerMessage
 {
-    public function response($oAlert)
-    {        
-        if ('bx_messenger' == $oAlert->sUnit && 'got_jot' == $oAlert->sAction) {
-            $iProfileIdSender = $oAlert->iSender;
-            $this->processMesssage ($oAlert->iSender, $oAlert->aExtras['recipient_id'], $oAlert->iObject, $oAlert->aExtras['subobject_id'], $oAlert->aExtras['subobject_info']);
-        }
-    }
-
-    protected function processMesssage ($iSender, $iRecipient, $iLotId, $iJotId, $aJotInfo) 
-    {
-        if ($iSender == $iRecipient)
-            return;
-
-        // call agents
-        $oAi = BxDolAi::getInstance();
-        if($aAgents = $oAi->getAgentsByProfileId($iRecipient)) {
-            $GLOBALS['glAgentsCallQueue'] = [];
-            foreach($aAgents as $a) {
-                if (!$oAi->canInteract($a, $iSender))
-                    continue;
-                if (!$a['message_profile_id'] || $iSender == $a['message_profile_id']) {
-
-                    $aParams = [
-                        'trigger' => 'message',
-                        'sender_profile_id' => $iSender,
-                        'recipient_profile_id' => $iRecipient,
-                        'message_lot_id' => $iLotId, 
-                        'message_id' => $iJotId,
-                        'message_text' => $aJotInfo['message'],
-                        'message_info' => $aJotInfo,
-                    ];
-                    if ($a['async']) {
-                        BxDolBackgroundJobs::getInstance()->add(bin2hex(random_bytes(16)), [
-                            'system', 'call_agent', 
-                            ['message', $a, $aParams], 
-                            'TemplServices'
-                        ]);
-                    }
-                    else {
-                        $GLOBALS['glAgentsCallQueue'][] = [
-                            'type' => 'message',
-                            'agent' => $a,
-                            'params' => $aParams,
-                        ];
-                    }
-                }
-            }
-            if (!empty($GLOBALS['glAgentsCallQueue'])) {
-                ignore_user_abort(true); 
-                set_time_limit(0);
-
-                // TODO: need a way to force messenger to show new message from the server side
-                // register_shutdown_function(function () { 
-                //     bx_ai_process_agents_call_queue();
-                // });
-                bx_ai_process_agents_call_queue(false, false); // TODO: for a while do synchronically
-            }
-
-        }
-    }
 }
 
 /** @} */

+ 443 - 0
inc/classes/BxDolAiChat.php

@@ -0,0 +1,443 @@
+<?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 BxDolAiChat
+{
+    protected $_oDb;
+    protected $_oUi;
+    protected $_aChatContext;
+
+    public static function getInstance()
+    {
+        if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new self();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public function __construct()
+    {
+        $this->_oDb = new BxDolAiQuery();
+        $this->_oUi = BxDolAiChatUi::getInstance();
+        $this->_aChatContext = null;
+    }
+
+    public function setChatContext($aAgent, $aParams)
+    {
+        $this->_aChatContext = ['agent' => $aAgent, 'params' => $aParams];
+    }
+
+    /**
+     * 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 threadId($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;
+    }
+
+    /**
+     * @return array{thread_id:string,chat_history_context_pid:int,chat_history_subindex:string}|false
+     */
+    public static function parseThreadId($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 getChatHistoryRowId($aAgent, $aParams = [])
+    {
+        return $this->_oDb->getChatHistoryIdByThreadId(self::threadId($aAgent, $aParams));
+    }
+
+    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;
+
+        $sClosed = $this->_oDb->getChatHistoryClosedReasonById($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;
+    }
+
+    public function isOwnAgentChatThread($aAgent, $sThreadId)
+    {
+        $aParsed = self::parseThreadId($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 getChatHistoryUiMessagesByThread($aAgent, $sThreadId)
+    {
+        if (!self::parseThreadId($aAgent, $sThreadId))
+            return false;
+
+        $sJson = $this->_oDb->getChatHistoryMessagesByThreadId((string)$sThreadId);
+
+        return $this->_oUi->storedChatJsonToUiMessages($sJson);
+    }
+
+    public function getChatHistoryArtifactsByThread($aAgent, $sThreadId)
+    {
+        if (!self::parseThreadId($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::threadId($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::parseThreadId($aAgent, $sThreadId);
+            if (!$aParsed)
+                continue;
+
+            $aUi = $this->_oUi->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::parseThreadId($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;
+    }
+
+    /**
+     * 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;
+    }
+
+    /**
+     * 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::threadId($aAgent, $aParams);
+        $sJson = $this->_oDb->getChatHistoryMessagesByThreadId($sThreadId);
+        $aStored = json_decode((string)$sJson, true);
+        if (!is_array($aStored) || $aStored === [])
+            return [];
+
+        try {
+            $o = BxDolAi::getAgentInstance((int)$iAgentId, $aParams);
+        } catch (Exception $oException) {
+            bx_log('sys_agents', "Hydrate exception for agent {$iAgentId}: " . $oException->getMessage(), BX_LOG_ERR);
+            return [];
+        }
+
+        $aResult = $this->_oUi->neuronMessagesToUiMessages($o->getChatHistory()->getMessages());
+        $oLimits = BxDolAiChatLimits::getInstance();
+        if ($bAppendLimitMessage && $oLimits->isChatTurnLimitReached($aAgent, $oLimits->countChatUserTurns($aResult))) {
+            $aResult[] = [
+                'id' => 'agent_limit_message',
+                'role' => 'assistant',
+                'parts' => [['type' => 'text', 'content' => $oLimits->getChatLimitMessage($aAgent)]],
+            ];
+        }
+
+        return $aResult;
+    }
+
+    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);
+    }
+
+    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;
+    }
+}
+
+/** @} */

+ 149 - 0
inc/classes/BxDolAiChatLimits.php

@@ -0,0 +1,149 @@
+<?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 BxDolAiChatLimits
+{
+    protected $_oDb;
+
+    public static function getInstance()
+    {
+        if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new self();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    public function __construct()
+    {
+        $this->_oDb = new BxDolAiQuery();
+    }
+
+    /**
+     * 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(BxDolAiChat::getInstance()->resolveChatHistoryParams($iAgentId), $aParams);
+
+        try {
+            $o = BxDolAi::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(BxDolAiChat::getInstance()->resolveChatHistoryParams((int)($aAgent['id'] ?? 0)), $aParams);
+
+        $sThreadId = BxDolAiChat::threadId($aAgent, $aParams);
+        if (!$this->_oDb->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->_oDb->countChatSessionsByIp($sIp, $sPrefix, 3600) >= $iHour)
+            return true;
+        if ($iDay > 0 && $this->_oDb->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());
+    }
+}
+
+/** @} */

+ 265 - 0
inc/classes/BxDolAiChatUi.php

@@ -0,0 +1,265 @@
+<?php defined('BX_DOL') or die('hack attempt');
+/**
+ * Copyright (c) UNA, Inc - https://una.io
+ * MIT License - https://opensource.org/licenses/MIT
+ *
+ * @defgroup    UnaCore UNA Core
+ * @{
+ */
+
+class BxDolAiChatUi
+{
+    public static function getInstance()
+    {
+        if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new self();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    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;
+    }
+
+    /**
+     * @param NeuronAI\Chat\Messages\Message[] $aMessages
+     * @return array<int, array<string, mixed>>
+     */
+    public 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
+     */
+    public function finalizeUiChatMessage($sId, $sRole, $aParts, $mixedActions = null)
+    {
+        $aActions = $sRole === 'assistant' ? $this->sanitizeChatActions($mixedActions) : [];
+        if ($sRole === 'assistant') {
+            $sText = '';
+            foreach ($aParts as $aPart) {
+                if (($aPart['type'] ?? '') === 'text')
+                    $sText .= (string)($aPart['content'] ?? '');
+            }
+            $aParsed = $this->parseAssistantChatPayload($sText);
+            if ($aParsed) {
+                $aKept = [];
+                foreach ($aParts as $aPart) {
+                    if (($aPart['type'] ?? '') === 'text')
+                        continue;
+                    $aKept[] = $aPart;
+                }
+                if ($aParsed['content'] !== '')
+                    $aKept[] = ['type' => 'text', 'content' => $aParsed['content']];
+                $aParts = $aKept;
+                if (!$aActions)
+                    $aActions = $aParsed['actions'];
+            }
+        }
+
+        if (!$aParts && !$aActions)
+            return null;
+
+        $aOut = [
+            'id' => $sId,
+            'role' => $sRole,
+            'parts' => $aParts,
+        ];
+        if ($sRole === 'assistant') {
+            $aOut['actions'] = $aActions;
+            if ($aActions)
+                $aOut['metadata'] = ['actions' => $aActions];
+        }
+
+        return $aOut;
+    }
+
+    /**
+     * @return array{content: string, actions: array}|null
+     */
+    public function parseAssistantChatPayload($sText)
+    {
+        $s = trim((string)$sText);
+        if ($s === '')
+            return null;
+
+        if (preg_match('/^```(?:json)?\s*(\{.*\})\s*```$/s', $s, $aM))
+            $s = trim($aM[1]);
+
+        if ($s === '' || $s[0] !== '{') {
+            $iStart = strpos($s, '{');
+            $iEnd = strrpos($s, '}');
+            if ($iStart === false || $iEnd === false || $iEnd <= $iStart)
+                return null;
+            $s = substr($s, $iStart, $iEnd - $iStart + 1);
+        }
+
+        $a = json_decode($s, true);
+        if (!is_array($a) || !array_key_exists('content', $a) || !is_string($a['content']))
+            return null;
+        if (!array_key_exists('actions', $a))
+            return null;
+
+        return [
+            'content' => $a['content'],
+            'actions' => $this->sanitizeChatActions($a['actions']),
+        ];
+    }
+
+    /**
+     * @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;
+        if (isset($a['user']) || isset($a['pass']))
+            return false;
+
+        $sHost = strtolower((string)$a['host']);
+        if ($sHost === 'localhost' || str_ends_with($sHost, '.localhost'))
+            return false;
+        if (filter_var($sHost, FILTER_VALIDATE_IP))
+            return (bool)filter_var($sHost, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE);
+
+        return true;
+    }
+}
+
+/** @} */

+ 65 - 1
inc/classes/BxDolAiQuery.php

@@ -222,7 +222,7 @@ class BxDolAiQuery extends BxDolDb
 
     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]);
+        return $this->getRow("SELECT * FROM `sys_agents_agents` WHERE `trigger` = 'webhook' AND `webhook_key` = :key AND `active` = :active", ['key' => $sKey, 'active' => $bActiveOnly ? 1 : 0]);
     }
     
     public function getAgentsByTriggerType($sTrigger, $bActiveOnly = true)
@@ -282,6 +282,70 @@ class BxDolAiQuery extends BxDolDb
         ]);
     }
 
+    public function getChatHistoryIdByThreadId($sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        if ($sThreadId === '' || $sThreadId === ':')
+            return 0;
+        return (int)$this->getOne("SELECT `id` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => $sThreadId,
+        ]);
+    }
+
+    public function getChatHistoryMessagesByThreadId($sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        if ($sThreadId === '')
+            return '';
+        return $this->getOne("SELECT `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => $sThreadId,
+        ]);
+    }
+
+    public function getChatHistoryClosedReasonById($iHistoryId)
+    {
+        $iHistoryId = (int)$iHistoryId;
+        if ($iHistoryId <= 0 || !$this->isFieldExists('sys_agents_chat_history', 'closed_reason'))
+            return '';
+        return (string)$this->getOne(
+            "SELECT `closed_reason` FROM `sys_agents_chat_history` WHERE `id` = :id",
+            ['id' => $iHistoryId]
+        );
+    }
+
+    public function isNewChatSession($sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        if ($sThreadId === '')
+            return true;
+
+        $aRow = $this->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 === '[]';
+    }
+
+    public function countChatSessionsByIp($sIp, $sPrefix, $iWindowSec)
+    {
+        $sSince = date('Y-m-d H:i:s', time() - (int)$iWindowSec);
+        return (int)$this->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 getChatArtifactsByHistoryIds($aIds)
     {
         $aIds = array_values(array_unique(array_filter(array_map('intval', (array)$aIds))));

+ 181 - 0
inc/classes/BxDolAiTrigger.php

@@ -0,0 +1,181 @@
+<?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
+ * @{
+ */
+
+interface iBxDolAiTrigger
+{
+    public function getType();
+
+    /**
+     * Host entry point (alert, cron, webhook, HTTP).
+     * @param mixed $mixed host-specific context
+     */
+    public function handle($mixed = null);
+
+    /**
+     * Run one agent. Used by handle() and background jobs.
+     */
+    public function call($aAgent, $mixedParams = []);
+}
+
+class BxDolAiTrigger extends BxDol implements iBxDolAiTrigger
+{
+    protected $_sType = '';
+
+    public static function getInstance($sType)
+    {
+        if ('chat' === $sType)
+            $sType = 'manual';
+
+        $aMap = [
+            'alert' => 'BxDolAiTriggerAlert',
+            'message' => 'BxDolAiTriggerMessage',
+            'webhook' => 'BxDolAiTriggerWebhook',
+            'scheduler' => 'BxDolAiTriggerScheduler',
+            'form-input' => 'BxDolAiTriggerFormInput',
+            'manual' => 'BxDolAiTriggerChat',
+        ];
+
+        $sClass = $aMap[$sType] ?? 'BxDolAiTrigger';
+        $sKey = __CLASS__ . '_' . $sType;
+        if (!isset($GLOBALS['bxDolClasses'][$sKey])) {
+            $o = new $sClass();
+            if ('BxDolAiTrigger' === $sClass)
+                $o->_sType = $sType;
+            $GLOBALS['bxDolClasses'][$sKey] = $o;
+        }
+
+        return $GLOBALS['bxDolClasses'][$sKey];
+    }
+
+    public function getType()
+    {
+        return $this->_sType;
+    }
+
+    public function handle($mixed = null)
+    {
+        return false;
+    }
+
+    public function call($aAgent, $mixedParams = [])
+    {
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return false;
+
+        if ($mixedParams)
+            $sParams = is_string($mixedParams) ? $mixedParams : json_encode($mixedParams);
+        else
+            $sParams = 'START';
+
+        $sSampleField = $this->getSampleField();
+        if ($sSampleField && empty($aAgent[$sSampleField])) {
+            $oDb = new BxDolAiQuery();
+            $oDb->updateAgentField($aAgent['id'], $sSampleField, $sParams);
+        }
+
+        $aParams = $this->getCallChatHistoryParams($aAgent, $mixedParams);
+        $sParams = $this->limitCallInput($aAgent, $mixedParams, $sParams);
+
+        $oLimits = BxDolAiChatLimits::getInstance();
+        $oChat = BxDolAiChat::getInstance();
+        if ($this->appliesChatLimits()) {
+            if ($oLimits->isChatTurnLimitReached($aAgent, $oLimits->getChatUserTurnCount($aAgent['id'], $aParams))) {
+                $oChat->emitConversationClosed('limit', '', $aAgent, $aParams);
+                return $oLimits->getChatLimitMessage($aAgent);
+            }
+
+            if ($oLimits->isChatSessionRateLimited($aAgent, $aParams))
+                throw new Exception($oLimits->getChatSessionRateLimitError());
+        }
+
+        $oChat->setChatContext($aAgent, $aParams);
+
+        $mixed = '';
+        try {
+            $o = BxDolAi::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;
+    }
+
+    protected function getAi()
+    {
+        return BxDolAi::getInstance();
+    }
+
+    protected function getSampleField()
+    {
+        return '';
+    }
+
+    protected function usesSenderChatHistory()
+    {
+        return false;
+    }
+
+    protected function appliesChatLimits()
+    {
+        return false;
+    }
+
+    protected function getCallChatHistoryParams($aAgent, $mixedParams)
+    {
+        if (!$this->usesSenderChatHistory() || !is_array($mixedParams))
+            return [];
+
+        return ['chat_history_subindex' => (int)($mixedParams['sender_profile_id'] ?? 0)];
+    }
+
+    protected function limitCallInput($aAgent, $mixedParams, $sParams)
+    {
+        if (!$this->appliesChatLimits())
+            return $sParams;
+
+        $oLimits = BxDolAiChatLimits::getInstance();
+        if (is_array($mixedParams) && isset($mixedParams['message_text'])) {
+            $mixedParams['message_text'] = $oLimits->applyChatInputLimit((string)$mixedParams['message_text'], $aAgent);
+            return json_encode($mixedParams);
+        }
+
+        if (is_string($mixedParams))
+            return $oLimits->applyChatInputLimit($sParams, $aAgent);
+
+        return $sParams;
+    }
+
+    protected function enqueue($aAgent, $mixedParams)
+    {
+        BxDolBackgroundJobs::getInstance()->add(bin2hex(random_bytes(16)), [
+            'system', 'call_agent',
+            [$this->getType(), $aAgent, $mixedParams],
+            'TemplServices'
+        ]);
+    }
+
+    protected function runOrEnqueue($aAgent, $mixedParams)
+    {
+        if (!empty($aAgent['async'])) {
+            $this->enqueue($aAgent, $mixedParams);
+            return null;
+        }
+
+        return $this->call($aAgent, $mixedParams);
+    }
+}
+
+/** @} */

+ 82 - 0
inc/classes/BxDolAiTriggerAlert.php

@@ -0,0 +1,82 @@
+<?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 BxDolAiTriggerAlert extends BxDolAiTrigger
+{
+    protected $_sType = 'alert';
+
+    protected function usesSenderChatHistory()
+    {
+        return true;
+    }
+
+    /**
+     * Sync: mutate alert extras from the model JSON. Async: background job.
+     */
+    public function handle($mixed = null)
+    {
+        $oAlert = $mixed;
+        if (!$oAlert || !BxDolAi::getInstance())
+            return false;
+
+        $aAgents = $this->getAgentsByUnitAndAction($oAlert->sUnit, $oAlert->sAction);
+        if (!$aAgents)
+            return false;
+
+        foreach ($aAgents as $a) {
+            $aParams = [
+                'trigger' => $this->getType(),
+                'object_id' => $oAlert->iObject,
+                'sender_profile_id' => $oAlert->iSender,
+                'unit' => $oAlert->sUnit,
+                'action' => $oAlert->sAction,
+                'extra' => $oAlert->aExtras,
+            ];
+
+            if (!empty($a['async'])) {
+                $this->enqueue($a, $aParams);
+                continue;
+            }
+
+            $this->applyExtraMutation($oAlert, $this->call($a, $aParams));
+        }
+
+        return true;
+    }
+
+    public function getAgentsByUnitAndAction($sUnit, $sAction)
+    {
+        $aAgents = [];
+        $oDb = new BxDolAiQuery();
+        foreach ($oDb->getAgentsWithAlert() 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;
+    }
+
+    protected function applyExtraMutation($oAlert, $sExtraJSON)
+    {
+        $aExtra = [];
+        if ($sExtraJSON && 0 === bx_mb_strpos($sExtraJSON, '{'))
+            $aExtra = json_decode($sExtraJSON, true);
+
+        if (!$aExtra || !is_array($aExtra))
+            return;
+
+        foreach ($aExtra as $k => $v) {
+            if (isset($oAlert->aExtras[$k]))
+                $oAlert->aExtras[$k] = $v;
+        }
+    }
+}
+
+/** @} */

+ 488 - 0
inc/classes/BxDolAiTriggerChat.php

@@ -0,0 +1,488 @@
+<?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 BxDolAiTriggerChat extends BxDolAiTrigger
+{
+    protected $_sType = 'manual';
+
+    protected function appliesChatLimits()
+    {
+        return true;
+    }
+
+    /**
+     * HTTP/SSE only. GET hydrates transcript; POST streams (or falls back to call()).
+     */
+    public function handle($mixed = null)
+    {
+        $iAgentId = (int)$mixed;
+        $sMethod = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '';
+        if ('GET' === $sMethod)
+            return $this->hydrate($iAgentId);
+
+        if ('POST' !== $sMethod)
+            return echoJson(['code' => 405, 'msg' => _t('_error occured')]);
+
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return echoJson(['code' => $iAgentId ? 404 : 400, 'msg' => _t('_sys_agents_agent_not_found')]);
+
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return echoJson(['code' => 503, 'msg' => _t('_sys_agents_exception')]);
+
+        if (!$oAi->canChatDirectly($aAgent))
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
+
+        $sJson = file_get_contents('php://input');
+        $aData = json_decode($sJson, true);
+        if (json_last_error() !== JSON_ERROR_NONE)
+            return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
+
+        $sPrompt = $this->extractPromptFromRequest($aData);
+        if (!$sPrompt)
+            return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
+
+        $aParams = $this->historyParams($aAgent);
+        if (!$aParams)
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
+
+        $aParams['request_user_turns'] = BxDolAiChatLimits::getInstance()->countChatUserTurns($aData['messages'] ?? []);
+
+        $sThreadId = !empty($aData['threadId']) ? $aData['threadId'] : null;
+
+        if (class_exists('NeuronAI\Chat\Messages\Stream\Adapters\AGUIAdapter')) {
+            $this->stream($aAgent['id'], $sPrompt, $aParams, $sThreadId);
+            return;
+        }
+
+        try {
+            $sReply = $this->call($aAgent, $sPrompt);
+        } catch (Throwable $o) {
+            return echoJson(['code' => 500, 'msg' => $o->getMessage(), 'messages' => []]);
+        }
+        return echoJson(['code' => 200, 'msg' => $sReply, 'text' => $sReply]);
+    }
+
+    public function extractPromptFromRequest($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 '';
+    }
+
+    public function stream($iAgentId, $sPrompt, $aParams = [], $sThreadId = null)
+    {
+        $oChat = BxDolAiChat::getInstance();
+        $oLimits = BxDolAiChatLimits::getInstance();
+        if (!isset($aParams['chat_history_subindex']))
+            $aParams = array_merge($oChat->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)
+            $oChat->setChatContext($aAgent, $aParams);
+
+        if ($aAgent && $oLimits->isChatSessionRateLimited($aAgent, $aParams)) {
+            $this->emitErrorSse($oAdapter, $fEmit, $oLimits->getChatSessionRateLimitError());
+            error_clear_last();
+            @ini_set('display_errors', '0');
+            exit;
+        }
+
+        $iRequestTurns = (int)($aParams['request_user_turns'] ?? 0);
+        if ($aAgent && $oLimits->isChatTurnLimitReached($aAgent, $oLimits->getChatUserTurnCount((int)$iAgentId, $aParams), $iRequestTurns)) {
+            $oChat->emitConversationClosed('limit', '', $aAgent, $aParams);
+            $this->emitLimitSse($oAdapter, $fEmit, $oLimits->getChatLimitMessage($aAgent));
+            error_clear_last();
+            @ini_set('display_errors', '0');
+            exit;
+        }
+
+        $sPrompt = $oLimits->applyChatInputLimit((string)$sPrompt, $aAgent ?: []);
+
+        $bStarted = false;
+        $aSseState = $this->resetActionsSseState();
+        $o = null;
+        try {
+            $o = BxDolAi::getAgentInstance((int)$iAgentId, $aParams);
+            $oHandler = $o->stream(new NeuronAI\Chat\Messages\UserMessage($sPrompt));
+
+            foreach ($oHandler->events($oAdapter) as $sEvent) {
+                $bStarted = true;
+                $this->processActionsSseEvent($sEvent, $aSseState, $fEmit);
+            }
+
+            $this->flushActionsSseState($aSseState, $fEmit);
+            $this->persistAssistantActions($o);
+
+            if ($aAgent && $oLimits->isChatTurnLimitReached($aAgent, $oLimits->getChatUserTurnCount((int)$iAgentId, $aParams)))
+                $oChat->emitConversationClosed('limit', '', $aAgent, $aParams);
+        } catch (Throwable $oException) {
+            bx_log('sys_agents', "Stream exception for agent {$iAgentId}: " . $oException->getMessage() . " INPUT:" . $sPrompt);
+            $this->flushActionsSseState($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;
+    }
+
+    protected function hydrate($iAgentId)
+    {
+        if (false !== bx_get('offset'))
+            return echoJson(['code' => 405, 'msg' => _t('_error occured'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return echoJson(['code' => (int)$iAgentId ? 404 : 400, 'msg' => _t('_sys_agents_agent_not_found'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return echoJson(['code' => 503, 'msg' => _t('_sys_agents_exception'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        if (!$oAi->canChatDirectly($aAgent))
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        $aParams = $this->historyParams($aAgent);
+        if (!$aParams)
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
+
+        try {
+            $aMessages = BxDolAiChat::getInstance()->getChatHistoryUiMessages($aAgent['id'], $aParams);
+        } catch (Throwable $o) {
+            $aMessages = [];
+        }
+
+        if (!is_array($aMessages))
+            $aMessages = [];
+
+        return echoJson([
+            'messages' => $aMessages,
+            'activeRun' => null,
+            'interrupts' => null,
+        ]);
+    }
+
+    /**
+     * Member/guest subindex + optional `?context=` (group/org profile id).
+     * Invalid context is 403 — never fall back to the site-wide thread.
+     */
+    protected function historyParams($aAgent)
+    {
+        $oChat = BxDolAiChat::getInstance();
+        $mixedContext = $oChat->resolveChatHistoryContextPid();
+        if ($mixedContext === false)
+            return false;
+
+        $aParams = $oChat->resolveChatHistoryParams((int)$aAgent['id']);
+        $aParams['chat_history_context_pid'] = (int)$mixedContext;
+        return $aParams;
+    }
+
+    protected function loadAgent($iAgentId)
+    {
+        $iAgentId = (int)$iAgentId;
+        if (!$iAgentId)
+            return false;
+
+        $aAgent = BxDolAiQuery::getAgentObject($iAgentId);
+        if (!$aAgent || empty($aAgent['active']))
+            return false;
+
+        return $aAgent;
+    }
+
+    protected function sseEvent($aPayload)
+    {
+        return 'data: ' . json_encode($aPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
+    }
+
+    protected function parseSseEvent($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 resetActionsSseState()
+    {
+        return [
+            'mode' => 'undecided',
+            'buf' => '',
+            'messageId' => '',
+            'held' => [],
+        ];
+    }
+
+    protected function emitHeldActionsSse(&$aState, $fEmit)
+    {
+        foreach ($aState['held'] as $sHeld)
+            $fEmit($sHeld);
+        $aState['held'] = [];
+    }
+
+    protected function emitParsedActionsSse(&$aState, $fEmit, $aParsed)
+    {
+        $sId = $aState['messageId'] !== '' ? $aState['messageId'] : ('msg_' . uniqid());
+        $fEmit($this->sseEvent([
+            'type' => 'TEXT_MESSAGE_START',
+            'messageId' => $sId,
+            'role' => 'assistant',
+        ]));
+        if ($aParsed['content'] !== '') {
+            $fEmit($this->sseEvent([
+                'type' => 'TEXT_MESSAGE_CONTENT',
+                'messageId' => $sId,
+                'delta' => $aParsed['content'],
+            ]));
+        }
+        $fEmit($this->sseEvent([
+            'type' => 'TEXT_MESSAGE_END',
+            'messageId' => $sId,
+        ]));
+        if (!empty($aParsed['actions'])) {
+            $fEmit($this->sseEvent([
+                'type' => 'CUSTOM',
+                'name' => 'chat_actions',
+                'value' => [
+                    'messageId' => $sId,
+                    'actions' => $aParsed['actions'],
+                ],
+            ]));
+        }
+        $aState = $this->resetActionsSseState();
+    }
+
+    protected function processActionsSseEvent($sEvent, &$aState, $fEmit)
+    {
+        $oUi = BxDolAiChatUi::getInstance();
+        $aPayload = $this->parseSseEvent($sEvent);
+        $sType = is_array($aPayload) ? (string)($aPayload['type'] ?? '') : '';
+
+        if ($sType === 'TEXT_MESSAGE_START') {
+            if ($aState['held'] || $aState['buf'] !== '')
+                $this->flushActionsSseState($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->actionsStreamLooksLikeJson($aState['buf']);
+                if ($mixed === false) {
+                    $aState['mode'] = 'text';
+                    $this->emitHeldActionsSse($aState, $fEmit);
+                    $fEmit($sEvent);
+                    return;
+                }
+                if ($mixed === true)
+                    $aState['mode'] = 'json';
+            }
+            $aState['held'][] = $sEvent;
+            return;
+        }
+
+        if ($sType === 'TEXT_MESSAGE_END') {
+            $aParsed = $oUi->parseAssistantChatPayload($aState['buf']);
+            if ($aParsed && ($aState['mode'] === 'json' || $aState['mode'] === 'undecided')) {
+                $this->emitParsedActionsSse($aState, $fEmit, $aParsed);
+                return;
+            }
+            $this->emitHeldActionsSse($aState, $fEmit);
+            $fEmit($sEvent);
+            $aState = $this->resetActionsSseState();
+            return;
+        }
+
+        if ($sType === 'RUN_ERROR' || $sType === 'RUN_FINISHED') {
+            $this->flushActionsSseState($aState, $fEmit);
+            $fEmit($sEvent);
+            return;
+        }
+
+        if ($aState['mode'] === 'json' || $aState['held'])
+            $this->flushActionsSseState($aState, $fEmit);
+
+        $fEmit($sEvent);
+    }
+
+    protected function actionsStreamLooksLikeJson($sBuf)
+    {
+        $s = ltrim((string)$sBuf);
+        if ($s === '')
+            return null;
+        $sFirst = $s[0];
+        if ($sFirst === '{' || $sFirst === '`')
+            return true;
+        return false;
+    }
+
+    protected function flushActionsSseState(&$aState, $fEmit)
+    {
+        $oUi = BxDolAiChatUi::getInstance();
+        if ($aState['buf'] === '' && !$aState['held'])
+            return;
+
+        $aParsed = $oUi->parseAssistantChatPayload($aState['buf']);
+        if ($aParsed && $aState['mode'] !== 'text') {
+            $this->emitParsedActionsSse($aState, $fEmit, $aParsed);
+            return;
+        }
+
+        $this->emitHeldActionsSse($aState, $fEmit);
+        $aState = $this->resetActionsSseState();
+    }
+
+    protected function persistAssistantActions($oAgent)
+    {
+        $oUi = BxDolAiChatUi::getInstance();
+        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 = $oUi->parseAssistantChatPayload((string)$oMessage->getContent());
+            if (!$aParsed)
+                return;
+
+            $oMessage->setContents($aParsed['content']);
+            $oMessage->addMetadata('actions', $aParsed['actions']);
+            $oHistory->persistMessages();
+            return;
+        }
+    }
+
+    /**
+     * SSE assistant reply without calling the model. Used when max_turns is hit.
+     */
+    protected function emitLimitSse($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 emitErrorSse($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);
+    }
+}
+
+/** @} */

+ 67 - 0
inc/classes/BxDolAiTriggerFormInput.php

@@ -0,0 +1,67 @@
+<?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 BxDolAiTriggerFormInput extends BxDolAiTrigger
+{
+    protected $_sType = 'form-input';
+
+    protected function usesSenderChatHistory()
+    {
+        return true;
+    }
+
+    public function handle($mixed = null)
+    {
+        $iAgentId = (int)$mixed;
+        $sJson = file_get_contents('php://input');
+        $aData = json_decode($sJson, true);
+
+        if (json_last_error() !== JSON_ERROR_NONE)
+            return echoJson(['code' => 500, 'msg' => _t('_sys_agents_json_field_err')]);
+
+        $sPrompt = $aData['prompt'] ?? null;
+        $sInputName = $aData['input_name'] ?? null;
+        $aValues = $aData['values'] ?? [];
+
+        $aAgent = BxDolAiQuery::getAgentObject($iAgentId);
+        if (!$aAgent || !$aAgent['active'] || $aAgent['trigger'] !== $this->getType())
+            return echoJson(['code' => 404, 'msg' => _t('_sys_agents_agent_not_found')]);
+
+        $oAi = $this->getAi();
+        if (!$oAi || !$oAi->canInteract($aAgent))
+            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
+
+        $aParams = [
+            'sender_profile_id' => bx_get_logged_profile_id(),
+            'user_prompt' => $sPrompt,
+            'form_field_name' => $sInputName,
+            'form_values' => $aValues,
+        ];
+
+        return echoJson(['code' => 200, 'msg' => $this->call($aAgent, $aParams)]);
+    }
+
+    public function getAgentsForForm($sFormObject)
+    {
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return [];
+
+        $oDb = new BxDolAiQuery();
+        $aAgents = $oDb->getAgentsByFormObject($sFormObject);
+        if (!$aAgents || !is_array($aAgents))
+            return [];
+
+        return array_filter($aAgents, function ($a) use ($oAi) {
+            return $oAi->canInteract($a);
+        });
+    }
+}
+
+/** @} */

+ 150 - 0
inc/classes/BxDolAiTriggerMessage.php

@@ -0,0 +1,150 @@
+<?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 BxDolAiTriggerMessage extends BxDolAiTrigger
+{
+    protected $_sType = 'message';
+
+    protected function usesSenderChatHistory()
+    {
+        return true;
+    }
+
+    protected function appliesChatLimits()
+    {
+        return true;
+    }
+
+    public function response($oAlert)
+    {
+        BxDolAiTrigger::getInstance($this->getType())->handle($oAlert);
+    }
+
+    public function handle($mixed = null)
+    {
+        $oAlert = $mixed;
+        if (!$oAlert || 'bx_messenger' != $oAlert->sUnit || 'got_jot' != $oAlert->sAction)
+            return false;
+
+        return $this->processMessage(
+            $oAlert->iSender,
+            $oAlert->aExtras['recipient_id'],
+            $oAlert->iObject,
+            $oAlert->aExtras['subobject_id'],
+            $oAlert->aExtras['subobject_info']
+        );
+    }
+
+    public function getAgentsByProfileId($iProfileId)
+    {
+        $oDb = new BxDolAiQuery();
+        return $oDb->getAgentsByProfileId($iProfileId);
+    }
+
+    public function processCallQueue($bFinishRequest = true, $bExit = true)
+    {
+        if ($bFinishRequest) {
+            if (function_exists('fastcgi_finish_request')) {
+                fastcgi_finish_request();
+            } else {
+                @ob_end_flush();
+                @flush();
+            }
+        }
+
+        if (!empty($GLOBALS['glAgentsCallQueue'])) {
+            foreach ($GLOBALS['glAgentsCallQueue'] as $r) {
+                $sType = $r['type'] ?? $this->getType();
+                $sMessage = BxDolAiTrigger::getInstance($sType)->call($r['agent'], $r['params']);
+                if (null == $sMessage) {
+                    // TODO: maybe reply with some empty message
+                    continue;
+                }
+                $oParsedown = new Parsedown();
+                $oParsedown->setSafeMode(true);
+                $sMessageHtml = $oParsedown->text($sMessage);
+                $this->sendMessengerMessage($r['agent']['profile_id'], $r['params']['sender_profile_id'], str_replace('\n', '', $sMessageHtml));
+            }
+        }
+
+        if ($bExit)
+            exit(0);
+    }
+
+    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;
+    }
+
+    protected function processMessage($iSender, $iRecipient, $iLotId, $iJotId, $aJotInfo)
+    {
+        if ($iSender == $iRecipient)
+            return false;
+
+        $oAi = $this->getAi();
+        if (!$oAi)
+            return false;
+
+        $aAgents = $this->getAgentsByProfileId($iRecipient);
+        if (!$aAgents)
+            return false;
+
+        $GLOBALS['glAgentsCallQueue'] = [];
+        foreach ($aAgents as $a) {
+            if (!$oAi->canInteract($a, $iSender))
+                continue;
+            if (!$a['message_profile_id'] || $iSender == $a['message_profile_id']) {
+                $aParams = [
+                    'trigger' => $this->getType(),
+                    'sender_profile_id' => $iSender,
+                    'recipient_profile_id' => $iRecipient,
+                    'message_lot_id' => $iLotId,
+                    'message_id' => $iJotId,
+                    'message_text' => $aJotInfo['message'],
+                    'message_info' => $aJotInfo,
+                ];
+                if ($a['async']) {
+                    $this->enqueue($a, $aParams);
+                } else {
+                    $GLOBALS['glAgentsCallQueue'][] = [
+                        'type' => $this->getType(),
+                        'agent' => $a,
+                        'params' => $aParams,
+                    ];
+                }
+            }
+        }
+
+        if (!empty($GLOBALS['glAgentsCallQueue'])) {
+            ignore_user_abort(true);
+            set_time_limit(0);
+
+            // TODO: need a way to force messenger to show new message from the server side
+            // register_shutdown_function(function () {
+            //     bx_ai_process_agents_call_queue();
+            // });
+            $this->processCallQueue(false, false); // TODO: for a while do synchronically
+        }
+
+        return true;
+    }
+}
+
+/** @} */

+ 39 - 0
inc/classes/BxDolAiTriggerScheduler.php

@@ -0,0 +1,39 @@
+<?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 BxDolAiTriggerScheduler extends BxDolAiTrigger
+{
+    protected $_sType = 'scheduler';
+
+    public function handle($mixed = null)
+    {
+        if (!BxDolAi::getInstance())
+            return false;
+
+        $aDate = is_array($mixed) ? $mixed : getdate(time());
+        $aAgents = $this->getAgents();
+        if (!$aAgents)
+            return false;
+
+        foreach ($aAgents as $aAgent) {
+            if (function_exists('checkCronJob') && checkCronJob($aAgent['scheduler_cron'], $aDate))
+                $this->call($aAgent);
+        }
+
+        return true;
+    }
+
+    public function getAgents()
+    {
+        $oDb = new BxDolAiQuery();
+        return $oDb->getAgentsByTriggerType($this->getType());
+    }
+}
+
+/** @} */

+ 66 - 0
inc/classes/BxDolAiTriggerWebhook.php

@@ -0,0 +1,66 @@
+<?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 BxDolAiTriggerWebhook extends BxDolAiTrigger
+{
+    protected $_sType = 'webhook';
+
+    protected function getSampleField()
+    {
+        return 'webhook_sample';
+    }
+
+    public function handle($mixed = null)
+    {
+        if (!BxDolAi::getInstance()) {
+            header('Content-Type: application/json');
+            header('HTTP/1.0 403 Forbidden');
+            BxDolLanguages::getInstance();
+            echo json_encode(['status' => 403, 'error' => _t("_Access denied")]);
+            exit;
+        }
+
+        header('Content-Type: application/json');
+
+        $aHeaders = function_exists('getallheaders') ? getallheaders() : false;
+        if ($aHeaders) {
+            $sAuthHeader = isset($aHeaders['Authorization']) ? $aHeaders['Authorization'] : (isset($aHeaders['authorization']) ? $aHeaders['authorization'] : false);
+        } else {
+            $sAuthHeader = isset($_SERVER['HTTP_AUTHORIZATION']) ? $_SERVER['HTTP_AUTHORIZATION'] : false;
+        }
+        $sKey = str_replace('Bearer ', '', $sAuthHeader);
+
+        $aAgent = $sKey ? $this->getAgentByKey($sKey) : false;
+        if (!$aAgent) {
+            header('HTTP/1.0 403 Forbidden');
+            BxDolLanguages::getInstance();
+            echo json_encode(['status' => 403, 'error' => _t("_Access denied")]);
+            exit;
+        }
+
+        $aParams = is_array($mixed) ? $mixed : $_REQUEST;
+        $aParams['trigger'] = $this->getType();
+
+        if (!empty($aAgent['async'])) {
+            $this->enqueue($aAgent, $aParams);
+            echo json_encode(['result' => 'scheduled']);
+            return;
+        }
+
+        echo json_encode($this->call($aAgent, $aParams));
+    }
+
+    public function getAgentByKey($sKey)
+    {
+        $oDb = new BxDolAiQuery();
+        return $oDb->getAgentByTriggerWebhookKey($sKey);
+    }
+}
+
+/** @} */

+ 2 - 34
inc/classes/BxDolAlerts.php

@@ -153,40 +153,8 @@ class BxDolAlerts extends BxDol
         BxDolAlertsStats::log($this);
 
         // call agents
-        $oAi = BxDolAi::getInstance();
-        if($aAgents = $oAi->getAgentsByAlertUnitAndAction($this->sUnit, $this->sAction)) {
-            foreach($aAgents as $a) {
-                $aParams = [
-                    'trigger' => 'alert',
-                    'object_id' => $this->iObject,
-                    'sender_profile_id' => $this->iSender,
-                    'unit' => $this->sUnit, 
-                    'action' => $this->sAction,
-                    'extra' => $this->aExtras,
-                ];
-                if ($a['async']) {
-                    BxDolBackgroundJobs::getInstance()->add(bin2hex(random_bytes(16)), [
-                        'system', 'call_agent', 
-                        ['alert', $a, $aParams], 
-                        'TemplServices'
-                    ]);
-                }
-                else {
-                    $sExtraJSON = $oAi->callAgent('alert', $a, $aParams);
-
-                    $aExtra = [];
-                    if ($sExtraJSON && 0 === bx_mb_strpos($sExtraJSON, '{'))
-                        $aExtra = json_decode($sExtraJSON, true);
-                    
-                    if ($aExtra && is_array($aExtra)) {
-                        foreach ($aExtra as $k => $v) {
-                            if (isset($this->aExtras[$k]))
-                                $this->aExtras[$k] = $v;
-                        }
-                    }
-                }
-            }
-        }
+        if (BxDolAi::getInstance())
+            BxDolAiTrigger::getInstance('alert')->handle($this);
     }
 
     /**

+ 3 - 29
inc/utils.inc.php

@@ -2931,35 +2931,9 @@ function bx_get_search_class_name(): string
 
 function bx_ai_process_agents_call_queue($bFinishRequest = true, $bExit = true)
 {
-    if ($bFinishRequest) {
-        if (function_exists('fastcgi_finish_request')) {
-            fastcgi_finish_request();
-        } else {
-            @ob_end_flush();
-            @flush();
-        }
-    }
-
-    if (!empty($GLOBALS['glAgentsCallQueue']))
-    {
-        $oAi = BxDolAi::getInstance();
-        foreach ($GLOBALS['glAgentsCallQueue'] as $r) 
-        {
-            $sMessage = $oAi->callAgent($r['type'], $r['agent'], $r['params']);
-            if (null == $sMessage) {
-                // TODO: maybe reply with some empty message
-                continue;
-            }
-            $oParsedown = new Parsedown();
-            $oParsedown->setSafeMode(true);
-            $sMessageHtml = $oParsedown->text($sMessage);
-            $oAi->sendMessengerMessage($r['agent']['profile_id'], $r['params']['sender_profile_id'], str_replace('\n', '', $sMessageHtml));
-        }
-    }
-    
-    if ($bExit) {
-        exit(0);
-    }
+    $oTrigger = BxDolAiTrigger::getInstance('message');
+    if ($oTrigger)
+        $oTrigger->processCallQueue($bFinishRequest, $bExit);
 }
 
 /** @} */

+ 3 - 3
install/sql/system.sql

@@ -2304,7 +2304,7 @@ INSERT INTO `sys_alerts` (`unit`, `action`, `handler_id`) VALUES
 
 
 INSERT INTO `sys_alerts_handlers` (`name`, `class`, `file`) VALUES
-('sys_agents', 'BxDolAiAlertResponse', 'inc/classes/BxDolAiAlertResponse.php');
+('sys_agents', 'BxDolAiTriggerMessage', 'inc/classes/BxDolAiTriggerMessage.php');
 SET @iIdHandler = LAST_INSERT_ID();
 
 INSERT INTO `sys_alerts` (`unit`, `action`, `handler_id`) VALUES
@@ -7051,8 +7051,8 @@ INSERT INTO `sys_agents_tools` (`type`, `title`, `docs`, `params`, `params_user`
 
 ('comment_get', 'Comment get', 'This tool allows agents to get single comment by global comment id.', '{}', NULL, 0, 0, 1, 'BxDolAIToolCmtsGetSingle', ''),
 
-('chat_artifact_save', 'Chat artifact save', 'Save one field from the conversation into sys_agents_chat_artifacts.', '{}', NULL, 0, 0, 1, 'BxDolAIToolChatArtifactSave', ''),
-('chat_goal_reached', 'Chat goal reached', 'Call once when the conversation goal is done. Fires agent_conversation_closed with reason=goal.', '{}', NULL, 0, 0, 1, 'BxDolAIToolChatGoalReached', '');
+('chat_artifact_save', 'Chat artifact save', 'Save one field from the conversation into sys_agents_chat_artifacts.', '{}', NULL, 0, 0, 0, 'BxDolAIToolChatArtifactSave', ''),
+('chat_goal_reached', 'Chat goal reached', 'Call once when the conversation goal is done. Fires agent_conversation_closed with reason=goal.', '{}', NULL, 0, 0, 0, 'BxDolAIToolChatGoalReached', '');
 
 CREATE TABLE IF NOT EXISTS `sys_agents_chat_history` (
   `id` bigint unsigned NOT NULL AUTO_INCREMENT,

+ 2 - 6
periodic/cron.php

@@ -160,10 +160,6 @@ foreach($aJobs as $aRow) {
 }
 
 // run agents (schedulers)
-$oAi = BxDolAI::getInstance();
-$aAgents = $oAi->getAgentsByTriggerType('scheduler');
-foreach($aAgents as $aAgent) {
-    if(checkCronJob($aAgent['scheduler_cron'], $aDate))
-        $oAi->callAgent('scheduler', $aAgent);
-}
+if (BxDolAi::getInstance())
+    BxDolAiTrigger::getInstance('scheduler')->handle($aDate);
 /** @} */

+ 177 - 1
src/chat/index.tsx

@@ -15,6 +15,19 @@ export interface ChatOptions {
     threadId?: string;
 }
 
+type ChatActionReply = {
+    type: 'reply';
+    label: string;
+};
+
+type ChatActionLink = {
+    type: 'link';
+    label: string;
+    url: string;
+};
+
+type ChatAction = ChatActionReply | ChatActionLink;
+
 declare const sUrlRoot: string | undefined;
 
 const CLASSES = {
@@ -29,6 +42,9 @@ const CLASSES = {
     bubbleUser: 'bx-ai-chat-message-inner max-w-[85%] px-3 py-2 rounded-2xl rounded-br-md text-sm leading-relaxed break-words whitespace-pre-wrap bg-blue-600 text-white dark:bg-blue-500',
     bubbleAssistant: 'bx-ai-chat-message-inner max-w-[85%] px-3 py-2 rounded-2xl rounded-bl-md text-sm leading-relaxed break-words whitespace-pre-wrap bg-white text-gray-800 ring-1 ring-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:ring-gray-600',
     markdown: 'bx-ai-chat-markdown bx-def-vanilla-html bx-def-vh-sm max-w-none whitespace-normal',
+    actions: 'bx-ai-chat-actions flex flex-wrap gap-1.5 mt-2',
+    action: 'bx-ai-chat-action bx-btn bx-btn-small',
+    actionLink: 'bx-ai-chat-action bx-btn bx-btn-small underline',
     error: 'bx-ai-chat-error text-sm text-red-600 dark:text-red-400 px-1',
 };
 
@@ -83,6 +99,80 @@ function getMessageText(message: UIMessage): string
         .join('');
 }
 
+function isAllowedChatActionUrl(url: string): boolean
+{
+    try {
+        const parsed = new URL(url);
+        return parsed.protocol === 'https:';
+    }
+    catch {
+        return false;
+    }
+}
+
+function sanitizeChatActions(raw: unknown): ChatAction[]
+{
+    if (!Array.isArray(raw))
+        return [];
+
+    const out: ChatAction[] = [];
+    for (const item of raw) {
+        if (!item || typeof item !== 'object')
+            continue;
+
+        const type = String((item as { type?: unknown }).type ?? '').toLowerCase().trim();
+        const label = String((item as { label?: unknown }).label ?? '').trim().slice(0, 80);
+        if (!label)
+            continue;
+
+        if (type === 'reply') {
+            out.push({ type: 'reply', label });
+        }
+        else if (type === 'link') {
+            const url = String((item as { url?: unknown }).url ?? '').trim();
+            if (!isAllowedChatActionUrl(url))
+                continue;
+            out.push({ type: 'link', label, url });
+        }
+
+        if (out.length >= 8)
+            break;
+    }
+
+    return out;
+}
+
+function getMessageActions(message: UIMessage, streamed?: ChatAction[]): ChatAction[]
+{
+    const extra = message as UIMessage & { actions?: unknown };
+    const fromMessage = sanitizeChatActions(extra.actions ?? extra.metadata?.actions);
+    if (fromMessage.length)
+        return fromMessage;
+
+    return streamed && streamed.length ? streamed : [];
+}
+
+function parseChatActionsEvent(data: unknown): { messageId: string; actions: ChatAction[] } | null
+{
+    if (Array.isArray(data)) {
+        const actions = sanitizeChatActions(data);
+        return actions.length ? { messageId: '', actions } : null;
+    }
+
+    if (!data || typeof data !== 'object')
+        return null;
+
+    const payload = data as { messageId?: unknown; actions?: unknown };
+    const actions = sanitizeChatActions(payload.actions ?? data);
+    if (!actions.length)
+        return null;
+
+    return {
+        messageId: typeof payload.messageId === 'string' ? payload.messageId : '',
+        actions,
+    };
+}
+
 function getChatEndpoint(agentId: number | string, endpoint?: string): string
 {
     if (endpoint)
@@ -98,6 +188,52 @@ function getChatThreadId(agentId: number | string, threadId?: string): string
     return threadId || `agent:${agentId}`;
 }
 
+function MessageActions({
+    actions,
+    disabled,
+    onReply,
+}: {
+    actions: ChatAction[];
+    disabled: boolean;
+    onReply: (label: string) => void;
+})
+{
+    if (!actions.length)
+        return null;
+
+    return (
+        <div className={CLASSES.actions}>
+            {actions.map((action, index) => {
+                if (action.type === 'link') {
+                    return (
+                        <a
+                            key={`${action.type}-${index}-${action.label}`}
+                            className={CLASSES.actionLink}
+                            href={action.url}
+                            target="_blank"
+                            rel="noopener noreferrer"
+                        >
+                            {action.label}
+                        </a>
+                    );
+                }
+
+                return (
+                    <button
+                        key={`${action.type}-${index}-${action.label}`}
+                        type="button"
+                        className={`${CLASSES.action}${disabled ? ' bx-btn-disabled opacity-70' : ''}`}
+                        disabled={disabled}
+                        onClick={() => onReply(action.label)}
+                    >
+                        {action.label}
+                    </button>
+                );
+            })}
+        </div>
+    );
+}
+
 function ChatView({
     agentId,
     endpoint,
@@ -109,7 +245,9 @@ function ChatView({
 }: ChatOptions)
 {
     const [input, setInput] = useState('');
+    const [streamedActions, setStreamedActions] = useState<Record<string, ChatAction[]>>({});
     const messagesRef = useRef<HTMLDivElement>(null);
+    const messagesSnapshotRef = useRef<UIMessage[]>([]);
     const chatThreadId = getChatThreadId(agentId, threadId);
 
     const connection = useMemo(
@@ -122,13 +260,32 @@ function ChatView({
         persistence: true,
         threadId: chatThreadId,
         initialMessages: initialMessages ?? [],
+        onCustomEvent: (eventType, data) => {
+            if (eventType !== 'chat_actions')
+                return;
+
+            const parsed = parseChatActionsEvent(data);
+            if (!parsed)
+                return;
+
+            const messageId = parsed.messageId || [...messagesSnapshotRef.current]
+                .reverse()
+                .find((message) => message.role === 'assistant')
+                ?.id;
+            if (!messageId)
+                return;
+
+            setStreamedActions((current) => ({ ...current, [messageId]: parsed.actions }));
+        },
     });
 
+    messagesSnapshotRef.current = messages;
+
     useEffect(() => {
         const list = messagesRef.current;
         if (list)
             list.scrollTop = list.scrollHeight;
-    }, [messages, error]);
+    }, [messages, streamedActions, error]);
 
     function handleSubmit(event: FormEvent<HTMLFormElement>)
     {
@@ -142,12 +299,22 @@ function ChatView({
         void sendMessage(text);
     }
 
+    function handleReply(label: string)
+    {
+        const text = label.trim();
+        if (!text || isLoading)
+            return;
+
+        void sendMessage(text);
+    }
+
     return (
         <div className={`${CLASSES.root}${isLoading ? ' bx-ai-chat-loading' : ''}`}>
             <div ref={messagesRef} className={CLASSES.messages}>
                 {messages.map((message) => {
                     const isUser = message.role === 'user';
                     const text = getMessageText(message);
+                    const actions = isUser ? [] : getMessageActions(message, streamedActions[message.id]);
 
                     return (
                         <div
@@ -158,6 +325,15 @@ function ChatView({
                                 {formatting && !isUser && text
                                     ? <MarkdownContent text={text} />
                                     : (text || '\u00a0')}
+                                {!isUser
+                                    ? (
+                                        <MessageActions
+                                            actions={actions}
+                                            disabled={isLoading}
+                                            onReply={handleReply}
+                                        />
+                                    )
+                                    : null}
                             </div>
                         </div>
                     );

+ 22 - 0
studio/js/agents.js

@@ -294,6 +294,7 @@ BxDolStudioPageAgents.prototype._renderReadonlyAgentChat = function(el, aMessage
         html += '<div class="bx-ai-chat-message" style="display:flex;justify-content:' + (isUser ? 'flex-end' : 'flex-start') + ';margin:6px 0;">' +
             '<div class="bx-ai-chat-message-inner bx-def-padding-sec bx-def-round-corners" style="max-width:85%;background:' + (isUser ? '#2563eb' : '#fff') + ';color:' + (isUser ? '#fff' : 'inherit') + ';">' +
             this._escAgentChat(text).replace(/\n/g, '<br />') +
+            this._renderReadonlyAgentChatActions(m.actions || (m.metadata && m.metadata.actions) || []) +
             '</div></div>';
     }
     html += '</div></div>';
@@ -303,6 +304,27 @@ BxDolStudioPageAgents.prototype._renderReadonlyAgentChat = function(el, aMessage
         list.scrollTop = list.scrollHeight;
 };
 
+BxDolStudioPageAgents.prototype._renderReadonlyAgentChatActions = function(aActions) {
+    if (!aActions || !aActions.length)
+        return '';
+
+    var html = '<div class="bx-ai-chat-actions" style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.5rem;">';
+    for (var i = 0; i < aActions.length; i++) {
+        var a = aActions[i] || {};
+        var sLabel = this._escAgentChat(a.label || '');
+        if (!sLabel)
+            continue;
+        if (a.type === 'link' && a.url) {
+            html += '<a class="bx-btn bx-btn-small underline" href="' + this._escAgentChat(a.url) + '" target="_blank" rel="noopener noreferrer">' + sLabel + '</a>';
+        }
+        else {
+            html += '<span class="bx-btn bx-btn-small bx-btn-disabled">' + sLabel + '</span>';
+        }
+    }
+    html += '</div>';
+    return html;
+};
+
 BxDolStudioPageAgents.prototype._escAgentChat = function(s) {
     return String(s == null ? '' : s)
         .replace(/&/g, '&amp;')

+ 9 - 13
studio/template/scripts/BxBaseStudioAgentsAgents.php

@@ -272,8 +272,8 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
         $sChatId = 'bx-ai-chat-' . $iId;
         $sName = !empty($aAgent['title']) ? $aAgent['title'] : $aAgent['name'];
         $aThreadsTpl = [];
-        $oAi = BxDolAi::getInstance();
-        $aThreads = $oAi ? $oAi->listAgentChatThreads($aAgent) : [];
+        $oChat = BxDolAiChat::getInstance();
+        $aThreads = $oChat->listAgentChatThreads($aAgent);
         foreach ($aThreads as $aThread) {
             $aThreadsTpl[] = [
                 'thread_id' => bx_html_attribute($aThread['thread_id']),
@@ -321,8 +321,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
         }
 
         $aThreadsJs = [];
-        $oAi = BxDolAi::getInstance();
-        $aThreads = $oAi ? $oAi->listAgentChatThreads($aAgent) : [];
+        $aThreads = BxDolAiChat::getInstance()->listAgentChatThreads($aAgent);
         foreach ($aThreads as $aThread)
             $aThreadsJs[] = $this->_agentChatThreadJs($aThread);
 
@@ -339,13 +338,9 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
         }
 
         $sThreadId = trim((string)bx_get('thread_id'));
-        $oAi = BxDolAi::getInstance();
-        if (!$oAi) {
-            echoJson(['code' => 503, 'msg' => _t('_sys_txt_error_occured'), 'messages' => []]);
-            return;
-        }
+        $oChat = BxDolAiChat::getInstance();
 
-        $aMessages = $oAi->getChatHistoryUiMessagesByThread($aAgent, $sThreadId);
+        $aMessages = $oChat->getChatHistoryUiMessagesByThread($aAgent, $sThreadId);
         if ($aMessages === false) {
             echoJson(['code' => 404, 'msg' => _t('_sys_txt_error_occured'), 'messages' => []]);
             return;
@@ -354,8 +349,8 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
         echoJson([
             'code' => 200,
             'messages' => $aMessages,
-            'writable' => $oAi->isOwnAgentChatThread($aAgent, $sThreadId) ? 1 : 0,
-            'artifacts' => $oAi->getChatHistoryArtifactsByThread($aAgent, $sThreadId),
+            'writable' => $oChat->isOwnAgentChatThread($aAgent, $sThreadId) ? 1 : 0,
+            'artifacts' => $oChat->getChatHistoryArtifactsByThread($aAgent, $sThreadId),
         ]);
     }
 
@@ -951,7 +946,8 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
 
         $this->_getMultiField('tools', $aAgent, 'getTools', 'toolAdd', 'agents_agents_form_tools.html', $aForm);
 
-        BxDolStudioUtils::getVisibilityValues((int)($aAgent['acl_levels'] ?? 0), $aForm['inputs']['acl_levels']['values'], $aForm['inputs']['acl_levels']['value']);
+        BxDolStudioUtils::getVisibilityValues(0, $aForm['inputs']['acl_levels']['values'], $aForm['inputs']['acl_levels']['value']);
+        $aForm['inputs']['acl_levels']['value'] = (int)($aAgent['acl_levels'] ?? 0);
 
         return $aForm;
     }

+ 3 - 7
template/scripts/BxBaseFormView.php

@@ -154,13 +154,9 @@ class BxBaseFormView extends BxDolForm
         );
 
         $this->_sAgentFormObject = $aInfo['params']['object'] ?? $this->id;
-        $oAi = BxDolAI::getInstance();
-        $this->_aAgentsFormObject = $oAi ? $oAi->getAgentsByFormObject($this->_sAgentFormObject) : [];
-        if ($this->_aAgentsFormObject && is_array($this->_aAgentsFormObject)) {
-            $this->_aAgentsFormObject = array_filter($this->_aAgentsFormObject, function ($a) use ($oAi) {
-                return $oAi->canInteract($a);
-            });
-        }
+        $this->_aAgentsFormObject = BxDolAi::getInstance()
+            ? BxDolAiTrigger::getInstance('form-input')->getAgentsForForm($this->_sAgentFormObject)
+            : [];
     }
 
     public function performActionGetHelp()

+ 8 - 144
template/scripts/BxBaseServices.php

@@ -872,12 +872,13 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
         if(!$aAgent || !is_array($aAgent))
             return $bIsApi ? [] : '';
 
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
+        $oChat = BxDolAiChat::getInstance();
         $bRequirePost = (bool)$bRequirePost;
         if ($mixedContextPid === false || $mixedContextPid === null || $mixedContextPid === '')
-            $iContextPid = $oAi ? (int)$oAi->resolveChatHistoryContextPidFromPage($bRequirePost) : 0;
+            $iContextPid = $oAi ? (int)$oChat->resolveChatHistoryContextPidFromPage($bRequirePost) : 0;
         else
-            $iContextPid = $oAi ? $oAi->filterContextPid((int)$mixedContextPid, $bRequirePost) : 0;
+            $iContextPid = $oAi ? $oChat->filterContextPid((int)$mixedContextPid, $bRequirePost) : 0;
 
         if ($bIsApi)
             return [bx_api_get_block('ai_agent', [
@@ -1588,7 +1589,7 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
 
     public function serviceGetOptionsAgentsModel()
     {
-        return ['' => _t('_Select_one')] + BxDolAI::getInstance()->getModels(['active' => true]);
+        return ['' => _t('_Select_one')] + BxDolAi::getInstance()->getModels(['active' => true]);
     }
 
     public function serviceGetOptionsAgentsProfile($bSelectOne = true, $sSelectOneLangKey = '_Select_one')
@@ -1776,155 +1777,18 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
 
     public function serviceCallAgent($sType, $aAgent, $aParams)
     {
-        $oAi = BxDolAI::getInstance();
+        $oAi = BxDolAi::getInstance();
         return $oAi->callAgent($sType, $aAgent, $aParams);
     }
 
     public function serviceCallAgentForFormInput($iAgentId)
     {
-        $sJson = file_get_contents('php://input');
-        $aData = json_decode($sJson, true);
-
-        if (json_last_error() !== JSON_ERROR_NONE)
-            return echoJson(['code' => 500, 'msg' => _t('_sys_agents_json_field_err')]);
-        
-        $sPrompt = $aData['prompt'] ?? null;
-        $sInputName = $aData['input_name'] ?? null;
-        $aValues = $aData['values'] ?? [];
-
-        $aAgent = BxDolAiQuery::getAgentObject($iAgentId);
-        if (!$aAgent || !$aAgent['active'] || $aAgent['trigger'] !== 'form-input')
-            return echoJson(['code' => 404, 'msg' => _t('_sys_agents_agent_not_found')]);
-
-        $oAi = BxDolAI::getInstance();
-        if (!$oAi || !$oAi->canInteract($aAgent))
-            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
-        
-        $aParams = [
-            'sender_profile_id' => bx_get_logged_profile_id(),
-            'user_prompt' => $sPrompt,
-            'form_field_name' => $sInputName,
-            'form_values' => $aValues,
-        ];
-
-        $mixed = $oAi->callAgent('form-input', $aAgent, $aParams);
-
-        return echoJson(['code' => 200, 'msg' => $mixed]);
+        return BxDolAiTrigger::getInstance('form-input')->handle($iAgentId);
     }
 
     public function serviceAiChat($iAgentId)
     {
-        $sMethod = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '';
-        if('GET' === $sMethod)
-            return $this->_aiChatHydrate($iAgentId);
-
-        if('POST' !== $sMethod)
-            return echoJson(['code' => 405, 'msg' => _t('_error occured')]);
-
-        $aAgent = $this->_aiChatLoadAgent($iAgentId);
-        if(!$aAgent)
-            return echoJson(['code' => (int)$iAgentId ? 404 : 400, 'msg' => _t('_sys_agents_agent_not_found')]);
-
-        $oAi = BxDolAI::getInstance();
-        if(!$oAi)
-            return echoJson(['code' => 503, 'msg' => _t('_sys_agents_exception')]);
-
-        if(!$oAi->canChatDirectly($aAgent))
-            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
-
-        $sJson = file_get_contents('php://input');
-        $aData = json_decode($sJson, true);
-        if(json_last_error() !== JSON_ERROR_NONE)
-            return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
-
-        $sPrompt = $oAi->extractChatPromptFromRequest($aData);
-        if(!$sPrompt)
-            return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
-
-        $aParams = $this->_aiChatHistoryParams($aAgent);
-        if (!$aParams)
-            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
-
-        $aParams['request_user_turns'] = $oAi->countChatUserTurns($aData['messages'] ?? []);
-
-        $sThreadId = !empty($aData['threadId']) ? $aData['threadId'] : null;
-
-        if(class_exists('NeuronAI\Chat\Messages\Stream\Adapters\AGUIAdapter')) {
-            $oAi->streamAgentChat($aAgent['id'], $sPrompt, $aParams, $sThreadId);
-            return;
-        }
-
-        try {
-            $sReply = $oAi->callAgent('manual', $aAgent, $sPrompt);
-        } catch (Throwable $o) {
-            return echoJson(['code' => 500, 'msg' => $o->getMessage(), 'messages' => []]);
-        }
-        return echoJson(['code' => 200, 'msg' => $sReply, 'text' => $sReply]);
-    }
-
-    protected function _aiChatHydrate($iAgentId)
-    {
-        if(false !== bx_get('offset'))
-            return echoJson(['code' => 405, 'msg' => _t('_error occured'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
-
-        $aAgent = $this->_aiChatLoadAgent($iAgentId);
-        if(!$aAgent)
-            return echoJson(['code' => (int)$iAgentId ? 404 : 400, 'msg' => _t('_sys_agents_agent_not_found'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
-
-        $oAi = BxDolAI::getInstance();
-        if(!$oAi)
-            return echoJson(['code' => 503, 'msg' => _t('_sys_agents_exception'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
-
-        if(!$oAi->canChatDirectly($aAgent))
-            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
-
-        $aParams = $this->_aiChatHistoryParams($aAgent);
-        if (!$aParams)
-            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
-
-        try {
-            $aMessages = $oAi->getChatHistoryUiMessages($aAgent['id'], $aParams);
-        } catch (Throwable $o) {
-            $aMessages = [];
-        }
-
-        if(!is_array($aMessages))
-            $aMessages = [];
-
-        return echoJson([
-            'messages' => $aMessages,
-            'activeRun' => null,
-            'interrupts' => null,
-        ]);
-    }
-
-    /**
-     * Member/guest subindex + optional `?context=` (group/org profile id).
-     * Invalid context is 403 — never fall back to the site-wide thread.
-     */
-    protected function _aiChatHistoryParams($aAgent)
-    {
-        $oAi = BxDolAI::getInstance();
-        $mixedContext = $oAi->resolveChatHistoryContextPid();
-        if ($mixedContext === false)
-            return false;
-
-        $aParams = $oAi->resolveChatHistoryParams((int)$aAgent['id']);
-        $aParams['chat_history_context_pid'] = (int)$mixedContext;
-        return $aParams;
-    }
-
-    protected function _aiChatLoadAgent($iAgentId)
-    {
-        $iAgentId = (int)$iAgentId;
-        if (!$iAgentId)
-            return false;
-
-        $aAgent = BxDolAIQuery::getAgentObject($iAgentId);
-        if(!$aAgent || empty($aAgent['active']))
-            return false;
-
-        return $aAgent;
+        return BxDolAiTrigger::getInstance('chat')->handle($iAgentId);
     }
 }
 

+ 1 - 35
webhook.php

@@ -8,38 +8,4 @@ require_once(BX_DIRECTORY_PATH_INC . "design.inc.php");
 
 bx_import('BxDolLanguages');
 
-header('Content-Type: application/json');
-
-$aHeaders = function_exists('getallheaders') ? getallheaders() : false;
-if ($aHeaders) {
-    $sAuthHeader = isset($aHeaders['Authorization']) ? $aHeaders['Authorization'] : (isset($aHeaders['authorization']) ? $aHeaders['authorization'] : false);
-}
-else {
-    $sAuthHeader = isset($_SERVER['HTTP_AUTHORIZATION']) ? $_SERVER['HTTP_AUTHORIZATION'] : false;
-}
-$sKey = str_replace('Bearer ', '', $sAuthHeader);
-
-$oAi = BxDolAI::getInstance();
-$aAgent = $sKey ? $oAi->getAgentByTriggerWebhookKey($sKey) : false;
-if (!$aAgent) {
-    header('HTTP/1.0 403 Forbidden');
-    BxDolLanguages::getInstance();
-    echo json_encode(['status' => 403, 'error' => _t("_Access denied")]);
-    exit;
-}
-
-$aParams = $_REQUEST;
-$aParams['trigger'] = 'webhook';
-
-if ($aAgent['async']) {
-    BxDolBackgroundJobs::getInstance()->add(bin2hex(random_bytes(16)), [
-        'system', 'call_agent', 
-        ['webhook', $aAgent, $aParams], 
-        'TemplServices'
-    ]);
-    echo json_encode(['result' => 'scheduled']);
-}
-else {
-    $mixed = $oAi->callAgent('webhook', $aAgent, $aParams);
-    echo json_encode($mixed);
-}
+BxDolAiTrigger::getInstance('webhook')->handle();

Некоторые файлы не были показаны из-за большого количества измененных файлов