Quellcode durchsuchen

Ticket #5675 - AI Agents: Manual agent for chatting.

AntonLV vor 1 Monat
Ursprung
Commit
4b8b7ccd47

+ 902 - 24
inc/classes/BxDolAI.php

@@ -28,6 +28,8 @@ class BxDolAI extends BxDolFactory implements iBxDolSingleton
 
     protected $_bWriteLog;
 
+    protected $_aChatContext;
+
     protected function __construct()
     {
         if (isset($GLOBALS['bxDolClasses'][get_class($this)]))
@@ -47,6 +49,8 @@ class BxDolAI extends BxDolFactory implements iBxDolSingleton
         $this->_sCmtsAssistantsChats = 'sys_agents_assistants_chats';
 
         $this->_bWriteLog = true;
+
+        $this->_aChatContext = null;
     }
 
     /**
@@ -396,6 +400,24 @@ class BxDolAI extends BxDolFactory implements iBxDolSingleton
             $aParams = ['chat_history_subindex' => (int)($mixedParams['sender_profile_id'] ?? 0)];
         }
 
+        if ('message' == $sType && is_array($mixedParams) && isset($mixedParams['message_text'])) {
+            $mixedParams['message_text'] = $this->applyChatInputLimit((string)$mixedParams['message_text'], $aAgent);
+            $sParams = json_encode($mixedParams);
+        }
+        else if (is_string($mixedParams) && in_array($sType, ['manual', 'message'], true)) {
+            $sParams = $this->applyChatInputLimit($sParams, $aAgent);
+        }
+
+        if (in_array($sType, ['manual', 'message'], true) && $this->isChatTurnLimitReached($aAgent, $this->getChatUserTurnCount($aAgent['id'], $aParams))) {
+            $this->emitConversationClosed('limit', '', $aAgent, $aParams);
+            return $this->getChatLimitMessage($aAgent);
+        }
+
+        if (in_array($sType, ['manual', 'message'], true) && $this->isChatSessionRateLimited($aAgent, $aParams))
+            throw new Exception($this->getChatSessionRateLimitError());
+
+        $this->_aChatContext = ['agent' => $aAgent, 'params' => $aParams];
+
         // call agent
         $mixed = '';
         try {                        
@@ -450,6 +472,160 @@ class BxDolAI extends BxDolFactory implements iBxDolSingleton
         return in_array($aAgent['trigger'] ?? '', ['manual', 'message'], true);
     }
 
+    /**
+     * Runner limits from the agent row. 0 = no cap. Never put these in the prompt.
+     */
+    public function applyChatInputLimit($sPrompt, $aAgent)
+    {
+        $sPrompt = (string)$sPrompt;
+        $iMax = (int)($aAgent['max_input_chars'] ?? 0);
+        if ($iMax <= 0)
+            return $sPrompt;
+
+        if (function_exists('mb_strlen') && function_exists('mb_substr')) {
+            if (mb_strlen($sPrompt, 'UTF-8') > $iMax)
+                return mb_substr($sPrompt, 0, $iMax, 'UTF-8');
+            return $sPrompt;
+        }
+
+        return strlen($sPrompt) > $iMax ? substr($sPrompt, 0, $iMax) : $sPrompt;
+    }
+
+    public function getChatLimitMessage($aAgent)
+    {
+        $s = trim((string)($aAgent['limit_message'] ?? ''));
+        return $s !== '' ? $s : 'This chat has reached its limit.';
+    }
+
+    public function getChatSessionRateLimitError()
+    {
+        $s = _t('_sys_agents_err_session_rate_limit');
+        return ($s !== '' && $s !== '_sys_agents_err_session_rate_limit')
+            ? $s
+            : 'Too many chat sessions from this IP. Try again later.';
+    }
+
+    public function countChatUserTurns($aUiMessages)
+    {
+        $i = 0;
+        if (!is_array($aUiMessages))
+            return 0;
+        foreach ($aUiMessages as $aMessage) {
+            if (is_array($aMessage) && ($aMessage['role'] ?? '') === 'user')
+                $i++;
+        }
+        return $i;
+    }
+
+    public function isChatTurnLimitReached($aAgent, $iTurns, $iRequestUserTurns = 0)
+    {
+        $iMax = (int)($aAgent['max_turns'] ?? 0);
+        if ($iMax <= 0)
+            return false;
+        if ((int)$iTurns >= $iMax)
+            return true;
+        // Client transcript includes the message being submitted.
+        return (int)$iRequestUserTurns > $iMax;
+    }
+
+    public function getChatUserTurnCount($iAgentId, $aParams = [])
+    {
+        if (!isset($aParams['chat_history_subindex']))
+            $aParams = array_merge($this->resolveChatHistoryParams($iAgentId), $aParams);
+
+        try {
+            $o = self::getAgentInstance((int)$iAgentId, $aParams);
+        } catch (Throwable $oException) {
+            return 0;
+        }
+
+        $i = 0;
+        foreach ($o->getChatHistory()->getMessages() as $oMessage) {
+            if ($oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage
+                || $oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage
+            ) {
+                continue;
+            }
+            if ($oMessage instanceof NeuronAI\Chat\Messages\UserMessage)
+                $i++;
+        }
+        return $i;
+    }
+
+    /**
+     * New chat threads per IP for this agent. 0 on the agent = no cap.
+     * Continuation of an existing thread is not a new session.
+     */
+    public function isChatSessionRateLimited($aAgent, $aParams = [])
+    {
+        $iHour = (int)($aAgent['max_sessions_per_hour'] ?? 0);
+        $iDay = (int)($aAgent['max_sessions_per_day'] ?? 0);
+        if ($iHour <= 0 && $iDay <= 0)
+            return false;
+
+        if (!isset($aParams['chat_history_subindex']))
+            $aParams = array_merge($this->resolveChatHistoryParams((int)($aAgent['id'] ?? 0)), $aParams);
+
+        $sThreadId = self::chatHistoryThreadId($aAgent, $aParams);
+        if (!$this->isNewChatSession($sThreadId))
+            return false;
+
+        $sIp = $this->getVisitorIpHash();
+        if ($sIp === '0' || $sIp === '')
+            return false;
+
+        $sPrefix = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        if ($sPrefix === ':')
+            return false;
+
+        if ($iHour > 0 && $this->countChatSessionsByIp($sIp, $sPrefix, 3600) >= $iHour)
+            return true;
+        if ($iDay > 0 && $this->countChatSessionsByIp($sIp, $sPrefix, 86400) >= $iDay)
+            return true;
+
+        return false;
+    }
+
+    protected function getVisitorIpHash()
+    {
+        if (!function_exists('getVisitorIP') || !function_exists('bx_get_ip_hash'))
+            return '0';
+        return (string)bx_get_ip_hash(getVisitorIP());
+    }
+
+    protected function isNewChatSession($sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        if ($sThreadId === '')
+            return true;
+
+        $aRow = $this->_oDb->getRow("SELECT `ip`, `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => $sThreadId,
+        ]);
+        if (!$aRow)
+            return true;
+
+        $iIp = (int)($aRow['ip'] ?? 0);
+        $sMessages = trim((string)($aRow['messages'] ?? ''));
+        return $iIp <= 0 || $sMessages === '' || $sMessages === '[]';
+    }
+
+    protected function countChatSessionsByIp($sIp, $sPrefix, $iWindowSec)
+    {
+        $sSince = date('Y-m-d H:i:s', time() - (int)$iWindowSec);
+        return (int)$this->_oDb->getOne("
+            SELECT COUNT(*) FROM `sys_agents_chat_history`
+            WHERE `ip` = :ip
+              AND (`thread_id` = :prefix OR `thread_id` LIKE :prefix_like)
+              AND `created_at` >= :since
+        ", [
+            'ip' => $sIp,
+            'prefix' => $sPrefix,
+            'prefix_like' => $sPrefix . ':%',
+            'since' => $sSince,
+        ]);
+    }
+
     public function extractChatPromptFromRequest($aData)
     {
         if (!is_array($aData))
@@ -507,7 +683,7 @@ class BxDolAI extends BxDolFactory implements iBxDolSingleton
                 foreach (array_unique(array_map('intval', $aIds)) as $iStoredAgentId) {
                     if (!$iStoredAgentId || $sSessionId === '')
                         continue;
-                    $aAgent = BxDolAiQuery::getAgentObject($iStoredAgentId);
+                    $aAgent = BxDolAIQuery::getAgentObject($iStoredAgentId);
                     if ($aAgent)
                         $this->_oDb->adoptGuestChatHistory($aAgent, $sSessionId, $iProfileId);
                 }
@@ -551,6 +727,293 @@ class BxDolAI extends BxDolFactory implements iBxDolSingleton
         return $s;
     }
 
+    public function getChatHistoryRowId($aAgent, $aParams = [])
+    {
+        $sThreadId = self::chatHistoryThreadId($aAgent, $aParams);
+        if ($sThreadId === '' || $sThreadId === ':')
+            return 0;
+        return (int)$this->_oDb->getOne("SELECT `id` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => $sThreadId,
+        ]);
+    }
+
+    public function getCurrentChatHistoryId()
+    {
+        if (!is_array($this->_aChatContext))
+            return 0;
+
+        $aAgent = $this->_aChatContext['agent'] ?? null;
+        $aParams = $this->_aChatContext['params'] ?? [];
+        if (!is_array($aAgent))
+            return 0;
+
+        return $this->getChatHistoryRowId($aAgent, is_array($aParams) ? $aParams : []);
+    }
+
+    public function emitConversationClosed($sReason, $sSummary = '', $aAgent = null, $aParams = null)
+    {
+        if (!is_array($aAgent) && is_array($this->_aChatContext))
+            $aAgent = $this->_aChatContext['agent'] ?? null;
+        if (!is_array($aParams) && is_array($this->_aChatContext))
+            $aParams = $this->_aChatContext['params'] ?? [];
+        if (!is_array($aAgent) || empty($aAgent['id']))
+            return false;
+
+        $iHistoryId = $this->getChatHistoryRowId($aAgent, is_array($aParams) ? $aParams : []);
+        if ($iHistoryId <= 0)
+            return false;
+
+        if ($this->_oDb->isFieldExists('sys_agents_chat_history', 'closed_reason')) {
+            $sClosed = (string)$this->_oDb->getOne(
+                "SELECT `closed_reason` FROM `sys_agents_chat_history` WHERE `id` = :id",
+                ['id' => $iHistoryId]
+            );
+            if ($sClosed !== '')
+                return false;
+        }
+
+        bx_alert('system', 'agent_conversation_closed', $iHistoryId, 0, [
+            'agent_id' => (int)$aAgent['id'],
+            'summary' => (string)$sSummary,
+            'reason' => (string)$sReason,
+        ]);
+        return true;
+    }
+
+    /**
+     * @return array{thread_id:string,chat_history_context_pid:int,chat_history_subindex:string}|false
+     */
+    public static function parseChatHistoryThreadId($aAgent, $sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        $sBase = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        if ($sBase === ':' || $sThreadId === '')
+            return false;
+        if ($sThreadId !== $sBase && strpos($sThreadId, $sBase . ':') !== 0)
+            return false;
+
+        $sRest = $sThreadId === $sBase ? '' : substr($sThreadId, strlen($sBase) + 1);
+        $aParts = $sRest === '' ? [] : explode(':', $sRest);
+        $iContext = 0;
+        $sSub = '';
+        if (count($aParts) >= 2) {
+            $iContext = (int)$aParts[0];
+            $sSub = implode(':', array_slice($aParts, 1));
+        } elseif (count($aParts) === 1) {
+            $sSub = $aParts[0];
+        }
+
+        return [
+            'thread_id' => $sThreadId,
+            'chat_history_context_pid' => $iContext,
+            'chat_history_subindex' => $sSub,
+        ];
+    }
+
+    public function isOwnAgentChatThread($aAgent, $sThreadId)
+    {
+        $aParsed = self::parseChatHistoryThreadId($aAgent, $sThreadId);
+        if (!$aParsed)
+            return false;
+
+        $aMine = $this->resolveChatHistoryParams((int)$aAgent['id']);
+        $sMine = (string)($aMine['chat_history_subindex'] ?? '');
+        return $sMine !== '' && $aParsed['chat_history_subindex'] === $sMine;
+    }
+
+    public function storedChatJsonToUiMessages($sJson)
+    {
+        $aStored = json_decode((string)$sJson, true);
+        if (!is_array($aStored) || $aStored === [])
+            return [];
+
+        $aResult = [];
+        $i = 0;
+        foreach ($aStored as $aMessage) {
+            if (!is_array($aMessage))
+                continue;
+
+            $sType = (string)($aMessage['type'] ?? '');
+            if ($sType === 'tool_call' || $sType === 'tool_call_result')
+                continue;
+
+            $sRole = (string)($aMessage['role'] ?? '');
+            if ($sRole === 'model')
+                $sRole = 'assistant';
+            if ($sRole !== 'user' && $sRole !== 'assistant')
+                continue;
+
+            $aParts = [];
+            $mixedContent = $aMessage['content'] ?? '';
+            if (is_string($mixedContent) && $mixedContent !== '') {
+                $aParts[] = ['type' => 'text', 'content' => $mixedContent];
+            } elseif (is_array($mixedContent)) {
+                foreach ($mixedContent as $aBlock) {
+                    if (!is_array($aBlock))
+                        continue;
+                    $sBlockType = (string)($aBlock['type'] ?? '');
+                    $sBlockText = (string)($aBlock['content'] ?? '');
+                    if ($sBlockText === '')
+                        continue;
+                    if ($sBlockType === 'text' || $sBlockType === '')
+                        $aParts[] = ['type' => 'text', 'content' => $sBlockText];
+                }
+            }
+            $sId = '';
+            if (!empty($aMessage['__id']) && is_string($aMessage['__id']))
+                $sId = $aMessage['__id'];
+            elseif (!empty($aMessage['metadata']['__id']) && is_string($aMessage['metadata']['__id']))
+                $sId = $aMessage['metadata']['__id'];
+
+            $mixedActions = $aMessage['actions'] ?? ($aMessage['metadata']['actions'] ?? null);
+            $aUi = $this->finalizeUiChatMessage($sId !== '' ? $sId : ('msg_' . $i), $sRole, $aParts, $mixedActions);
+            if (!$aUi)
+                continue;
+
+            $aResult[] = $aUi;
+            $i++;
+        }
+
+        return $aResult;
+    }
+
+    public function getChatHistoryUiMessagesByThread($aAgent, $sThreadId)
+    {
+        if (!self::parseChatHistoryThreadId($aAgent, $sThreadId))
+            return false;
+
+        $sJson = $this->_oDb->getOne("SELECT `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => (string)$sThreadId,
+        ]);
+
+        return $this->storedChatJsonToUiMessages($sJson);
+    }
+
+    public function getChatHistoryArtifactsByThread($aAgent, $sThreadId)
+    {
+        if (!self::parseChatHistoryThreadId($aAgent, $sThreadId))
+            return [];
+
+        return $this->_oDb->getChatArtifactsByThreadId((string)$sThreadId);
+    }
+
+    public function listAgentChatThreads($aAgent)
+    {
+        $aMineParams = $this->resolveChatHistoryParams((int)$aAgent['id']);
+        $sMySub = (string)($aMineParams['chat_history_subindex'] ?? '');
+        $sMyDefault = self::chatHistoryThreadId($aAgent, $aMineParams);
+
+        $aOut = [];
+        $bHaveMineDefault = false;
+        $aRows = $this->_oDb->getAgentChatHistoryRows($aAgent);
+        if (!is_array($aRows))
+            $aRows = [];
+
+        $aHistoryIds = [];
+        foreach ($aRows as $aRow)
+            $aHistoryIds[] = (int)($aRow['id'] ?? 0);
+        $aArtifactsMap = $this->_oDb->getChatArtifactsForAgent($aAgent);
+        if (!$aArtifactsMap && $aHistoryIds)
+            $aArtifactsMap = $this->_oDb->getChatArtifactsByHistoryIds($aHistoryIds);
+
+        foreach ($aRows as $aRow) {
+            $sThreadId = (string)($aRow['thread_id'] ?? '');
+            $aParsed = self::parseChatHistoryThreadId($aAgent, $sThreadId);
+            if (!$aParsed)
+                continue;
+
+            $aUi = $this->storedChatJsonToUiMessages($aRow['messages'] ?? '');
+            if (!$aUi && $sThreadId !== $sMyDefault)
+                continue;
+
+            $bMine = ($sMySub !== '' && $aParsed['chat_history_subindex'] === $sMySub);
+            if ($sThreadId === $sMyDefault)
+                $bHaveMineDefault = true;
+
+            $iHistoryId = (int)($aRow['id'] ?? 0);
+            $aArtifacts = ($iHistoryId && !empty($aArtifactsMap[$iHistoryId])) ? $aArtifactsMap[$iHistoryId] : [];
+            $aOut[] = $this->formatAgentChatThread($aParsed, $aRow, $aUi, $bMine, $aArtifacts);
+        }
+
+        if (!$bHaveMineDefault && $sMyDefault !== '') {
+            $aParsedMine = self::parseChatHistoryThreadId($aAgent, $sMyDefault);
+            if ($aParsedMine)
+                $aOut[] = $this->formatAgentChatThread($aParsedMine, [], [], true);
+        }
+
+        usort($aOut, function ($a, $b) {
+            return strcmp((string)$b['updated_sort'], (string)$a['updated_sort']);
+        });
+
+        return $aOut;
+    }
+
+    protected function formatAgentChatThread($aParsed, $aRow, $aUi, $bMine, $aArtifacts = [])
+    {
+        $iContext = (int)$aParsed['chat_history_context_pid'];
+        $sSub = (string)$aParsed['chat_history_subindex'];
+        $sContextName = '';
+        if ($iContext > 0) {
+            $oContext = BxDolProfile::getInstance($iContext);
+            $sContextName = $oContext ? $oContext->getDisplayName() : ('#' . $iContext);
+        }
+
+        $sTitle = _t('_sys_agents_agents_txt_guest_chat');
+        $oProfile = (ctype_digit($sSub) && (int)$sSub > 0) ? BxDolProfile::getInstance((int)$sSub) : false;
+        if ($oProfile)
+            $sTitle = $oProfile->getDisplayName();
+        if ($sContextName !== '')
+            $sTitle .= ' · ' . $sContextName;
+
+        $sReason = trim((string)($aRow['closed_reason'] ?? ''));
+        $sStatus = $sReason !== '' ? 'closed' : 'opened';
+        $sStatusLabel = $sStatus;
+        if ($sStatus === 'closed' && $sReason !== '')
+            $sStatusLabel .= ' (' . $sReason . ')';
+
+        $aArtifacts = is_array($aArtifacts) ? $aArtifacts : [];
+        $sCreated = $this->formatAgentChatThreadTime($aRow['created_at'] ?? '');
+        $sUpdated = $this->formatAgentChatThreadTime($aRow['updated_at'] ?? '');
+        $sUpdatedRaw = (string)($aRow['updated_at'] ?? '');
+
+        $sMeta = 'status: ' . $sStatusLabel;
+        $sDates = '';
+        if ($sCreated !== '' && $sUpdated !== '')
+            $sDates = $sCreated . ' - ' . $sUpdated;
+        else if ($sCreated !== '')
+            $sDates = $sCreated;
+        else if ($sUpdated !== '')
+            $sDates = $sUpdated;
+        if ($sDates !== '')
+            $sMeta .= "\n" . $sDates;
+
+        return [
+            'thread_id' => $aParsed['thread_id'],
+            'writable' => $bMine ? 1 : 0,
+            'context_pid' => $iContext,
+            'title' => $sTitle,
+            'meta' => $sMeta,
+            'status' => $sStatus,
+            'closed_reason' => $sReason,
+            'created_at' => $sCreated,
+            'updated_at' => $sUpdated,
+            'artifacts' => is_array($aArtifacts) ? $aArtifacts : [],
+            'updated_sort' => $sUpdatedRaw,
+            'class_mine' => $bMine ? ' bx-agents-popup-chat-thread-mine' : '',
+        ];
+    }
+
+    protected function formatAgentChatThreadTime($mixed): string
+    {
+        $s = trim((string)$mixed);
+        if ($s === '' || $s === '0')
+            return '';
+        $i = ctype_digit($s) ? (int)$s : (int)strtotime($s);
+        if ($i <= 86400)
+            return '';
+        return date('d.m.Y H:i', $i);
+    }
+
     /**
      * Context from chat HTTP (`?context=`). Missing/0 = site-wide. Invalid/denied = false (403).
      * Posting in a group is not the same as viewing it; chat requires post by default.
@@ -636,13 +1099,15 @@ class BxDolAI extends BxDolFactory implements iBxDolSingleton
     /**
      * 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 = [])
+    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);
+        $aAgent = BxDolAIQuery::getAgentObject((int)$iAgentId);
         if (!$aAgent || empty($aAgent['chat_history_context']))
             return [];
 
@@ -661,7 +1126,16 @@ class BxDolAI extends BxDolFactory implements iBxDolSingleton
             return [];
         }
 
-        return $this->neuronMessagesToUiMessages($o->getChatHistory()->getMessages());
+        $aResult = $this->neuronMessagesToUiMessages($o->getChatHistory()->getMessages());
+        if ($bAppendLimitMessage && $this->isChatTurnLimitReached($aAgent, $this->countChatUserTurns($aResult))) {
+            $aResult[] = [
+                'id' => 'agent_limit_message',
+                'role' => 'assistant',
+                'parts' => [['type' => 'text', 'content' => $this->getChatLimitMessage($aAgent)]],
+            ];
+        }
+
+        return $aResult;
     }
 
     /**
@@ -699,19 +1173,337 @@ class BxDolAI extends BxDolFactory implements iBxDolSingleton
                     $aParts[] = ['type' => 'text', 'content' => $oBlock->content];
             }
 
-            if (!$aParts)
+            $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;
 
-            $sId = $oMessage->getMetadata('__id');
-            $aResult[] = [
-                'id' => is_string($sId) && $sId !== '' ? $sId : ('msg_' . $i),
+            $aResult[] = $aUi;
+            $i++;
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * Split assistant JSON {content, actions} into UI text + actions.
+     * User messages pass through unchanged.
+     *
+     * @param array<int, array<string, mixed>> $aParts
+     * @return array<string, mixed>|null
+     */
+    protected function finalizeUiChatMessage($sId, $sRole, $aParts, $mixedActions = null)
+    {
+        $aActions = $sRole === 'assistant' ? $this->sanitizeChatActions($mixedActions) : [];
+        if ($sRole === 'assistant') {
+            $sText = '';
+            foreach ($aParts as $aPart) {
+                if (($aPart['type'] ?? '') === 'text')
+                    $sText .= (string)($aPart['content'] ?? '');
+            }
+            $aParsed = $this->parseAssistantChatPayload($sText);
+            if ($aParsed) {
+                $aKept = [];
+                foreach ($aParts as $aPart) {
+                    if (($aPart['type'] ?? '') === 'text')
+                continue;
+                    $aKept[] = $aPart;
+                }
+                if ($aParsed['content'] !== '')
+                    $aKept[] = ['type' => 'text', 'content' => $aParsed['content']];
+                $aParts = $aKept;
+                if (!$aActions)
+                    $aActions = $aParsed['actions'];
+            }
+        }
+
+        if (!$aParts && !$aActions)
+            return null;
+
+        $aOut = [
+            'id' => $sId,
                 'role' => $sRole,
                 'parts' => $aParts,
             ];
-            $i++;
+        if ($sRole === 'assistant')
+            $aOut['actions'] = $aActions;
+
+        return $aOut;
+    }
+
+    /**
+     * @return array{content: string, actions: array}|null
+     */
+    protected function parseAssistantChatPayload($sText)
+    {
+        $s = trim((string)$sText);
+        if ($s === '')
+            return null;
+
+        if (preg_match('/^```(?:json)?\s*(\{.*\})\s*```$/s', $s, $aM))
+            $s = trim($aM[1]);
+
+        if ($s === '' || $s[0] !== '{') {
+            $iStart = strpos($s, '{');
+            $iEnd = strrpos($s, '}');
+            if ($iStart === false || $iEnd === false || $iEnd <= $iStart)
+                return null;
+            $s = substr($s, $iStart, $iEnd - $iStart + 1);
         }
 
-        return $aResult;
+        $a = json_decode($s, true);
+        if (!is_array($a) || !array_key_exists('content', $a) || !is_string($a['content']))
+            return null;
+        if (!array_key_exists('actions', $a))
+            return null;
+
+        return [
+            'content' => $a['content'],
+            'actions' => $this->sanitizeChatActions($a['actions']),
+        ];
+    }
+
+    /**
+     * @return array<int, array<string, mixed>>
+     */
+    protected function sanitizeChatActions($mixed)
+    {
+        if (!is_array($mixed))
+            return [];
+
+        $aOut = [];
+        foreach ($mixed as $aItem) {
+            if (!is_array($aItem))
+                continue;
+
+            $sType = strtolower(trim((string)($aItem['type'] ?? '')));
+            $sLabel = trim((string)($aItem['label'] ?? ''));
+            if ($sLabel === '')
+                continue;
+            if (function_exists('mb_substr'))
+                $sLabel = mb_substr($sLabel, 0, 80);
+            else
+                $sLabel = substr($sLabel, 0, 80);
+
+            if ($sType === 'reply') {
+                $aOut[] = ['type' => 'reply', 'label' => $sLabel];
+            } elseif ($sType === 'link') {
+                $sUrl = trim((string)($aItem['url'] ?? ''));
+                if (!$this->isAllowedChatActionUrl($sUrl))
+                    continue;
+                $aOut[] = ['type' => 'link', 'label' => $sLabel, 'url' => $sUrl];
+            }
+
+            if (count($aOut) >= 8)
+                break;
+        }
+
+        return $aOut;
+    }
+
+    protected function isAllowedChatActionUrl($sUrl)
+    {
+        $a = parse_url((string)$sUrl);
+        if (($a['scheme'] ?? '') !== 'https' || empty($a['host']))
+            return false;
+
+        $sHost = strtolower((string)$a['host']);
+        $aAllowed = ['hiweave.com', 'www.hiweave.com'];
+        if (defined('BX_DOL_URL_ROOT')) {
+            $aSite = parse_url(BX_DOL_URL_ROOT);
+            if (!empty($aSite['host']))
+                $aAllowed[] = strtolower((string)$aSite['host']);
+        }
+
+        return in_array($sHost, $aAllowed, true);
+    }
+
+    protected function sseChatEvent($aPayload)
+    {
+        return 'data: ' . json_encode($aPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
+    }
+
+    protected function parseSseChatEvent($sEvent)
+    {
+        $s = trim((string)$sEvent);
+        if (!preg_match('/^data:\s*(.+)$/s', $s, $aM))
+            return null;
+
+        $a = json_decode($aM[1], true);
+        return is_array($a) ? $a : null;
+    }
+
+    protected function resetChatActionsSseState()
+    {
+        return [
+            'mode' => 'undecided',
+            'buf' => '',
+            'messageId' => '',
+            'held' => [],
+        ];
+    }
+
+    protected function emitHeldChatActionsSse(&$aState, $fEmit)
+    {
+        foreach ($aState['held'] as $sHeld)
+            $fEmit($sHeld);
+        $aState['held'] = [];
+    }
+
+    protected function emitParsedChatActionsSse(&$aState, $fEmit, $aParsed)
+    {
+        $sId = $aState['messageId'] !== '' ? $aState['messageId'] : ('msg_' . uniqid());
+        $fEmit($this->sseChatEvent([
+            'type' => 'TEXT_MESSAGE_START',
+            'messageId' => $sId,
+            'role' => 'assistant',
+        ]));
+        if ($aParsed['content'] !== '') {
+            $fEmit($this->sseChatEvent([
+                'type' => 'TEXT_MESSAGE_CONTENT',
+                'messageId' => $sId,
+                'delta' => $aParsed['content'],
+            ]));
+        }
+        $fEmit($this->sseChatEvent([
+            'type' => 'TEXT_MESSAGE_END',
+            'messageId' => $sId,
+        ]));
+        if (!empty($aParsed['actions'])) {
+            $fEmit($this->sseChatEvent([
+                'type' => 'CUSTOM',
+                'name' => 'chat_actions',
+                'messageId' => $sId,
+                'value' => $aParsed['actions'],
+            ]));
+        }
+        $aState = $this->resetChatActionsSseState();
+    }
+
+    protected function processChatActionsSseEvent($sEvent, &$aState, $fEmit)
+    {
+        $aPayload = $this->parseSseChatEvent($sEvent);
+        $sType = is_array($aPayload) ? (string)($aPayload['type'] ?? '') : '';
+
+        if ($sType === 'TEXT_MESSAGE_START') {
+            if ($aState['held'] || $aState['buf'] !== '')
+                $this->flushChatActionsSseState($aState, $fEmit);
+            $aState['messageId'] = (string)($aPayload['messageId'] ?? '');
+            $aState['held'][] = $sEvent;
+            return;
+        }
+
+        if ($sType === 'TEXT_MESSAGE_CONTENT') {
+            $sDelta = (string)($aPayload['delta'] ?? '');
+            $aState['buf'] .= $sDelta;
+            if ($aState['mode'] === 'text') {
+                $fEmit($sEvent);
+                return;
+            }
+            if ($aState['mode'] === 'undecided') {
+                $mixed = $this->chatActionsStreamLooksLikeJson($aState['buf']);
+                if ($mixed === false) {
+                    $aState['mode'] = 'text';
+                    $this->emitHeldChatActionsSse($aState, $fEmit);
+                    $fEmit($sEvent);
+                    return;
+                }
+                if ($mixed === true)
+                    $aState['mode'] = 'json';
+            }
+            $aState['held'][] = $sEvent;
+            return;
+        }
+
+        if ($sType === 'TEXT_MESSAGE_END') {
+            $aParsed = $this->parseAssistantChatPayload($aState['buf']);
+            if ($aParsed && ($aState['mode'] === 'json' || $aState['mode'] === 'undecided')) {
+                $this->emitParsedChatActionsSse($aState, $fEmit, $aParsed);
+                return;
+            }
+            $this->emitHeldChatActionsSse($aState, $fEmit);
+            $fEmit($sEvent);
+            $aState = $this->resetChatActionsSseState();
+            return;
+        }
+
+        if ($sType === 'RUN_ERROR' || $sType === 'RUN_FINISHED') {
+            $this->flushChatActionsSseState($aState, $fEmit);
+            $fEmit($sEvent);
+            return;
+        }
+
+        if ($aState['mode'] === 'json' || $aState['held'])
+            $this->flushChatActionsSseState($aState, $fEmit);
+
+        $fEmit($sEvent);
+    }
+
+    protected function chatActionsStreamLooksLikeJson($sBuf)
+    {
+        $s = ltrim((string)$sBuf);
+        if ($s === '')
+            return null;
+        $sFirst = $s[0];
+        if ($sFirst === '{' || $sFirst === '`')
+            return true;
+        return false;
+    }
+
+    protected function flushChatActionsSseState(&$aState, $fEmit)
+    {
+        if ($aState['buf'] === '' && !$aState['held'])
+            return;
+
+        $aParsed = $this->parseAssistantChatPayload($aState['buf']);
+        if ($aParsed && $aState['mode'] !== 'text') {
+            $this->emitParsedChatActionsSse($aState, $fEmit, $aParsed);
+            return;
+        }
+
+        $this->emitHeldChatActionsSse($aState, $fEmit);
+        $aState = $this->resetChatActionsSseState();
+    }
+
+    protected function persistAssistantChatActions($oAgent)
+    {
+        if (!is_object($oAgent) || !method_exists($oAgent, 'getChatHistory'))
+            return;
+
+        $oHistory = $oAgent->getChatHistory();
+        if (!($oHistory instanceof BxDolAiChatHistory) || !method_exists($oHistory, 'persistMessages'))
+            return;
+
+        $aMessages = $oHistory->getMessages();
+        for ($i = count($aMessages) - 1; $i >= 0; $i--) {
+            $oMessage = $aMessages[$i];
+            if (
+                $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage
+                || $oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage
+            ) {
+                continue;
+        }
+
+            $sRole = $oMessage->getRole();
+            if ($sRole === 'model')
+                $sRole = 'assistant';
+            if ($sRole !== 'assistant')
+                return;
+
+            $aParsed = $this->parseAssistantChatPayload((string)$oMessage->getContent());
+            if (!$aParsed)
+                return;
+
+            $oMessage->setContents($aParsed['content']);
+            $oMessage->addMetadata('actions', $aParsed['actions']);
+            $oHistory->persistMessages();
+            return;
+        }
     }
 
     public function streamAgentChat($iAgentId, $sPrompt, $aParams = [], $sThreadId = null)
@@ -719,37 +1511,123 @@ class BxDolAI extends BxDolFactory implements iBxDolSingleton
         if (!isset($aParams['chat_history_subindex']))
             $aParams = array_merge($this->resolveChatHistoryParams($iAgentId), $aParams);
 
+        // Staging header.inc.php dumps HTML on fatals and warns on null error_get_last().
+        // Any HTML after SSE is parsed by TanStack as "unterminated trailing data".
+        @ini_set('display_errors', '0');
+        @ini_set('zlib.output_compression', '0');
+        @ini_set('implicit_flush', '1');
+        if (function_exists('apache_setenv'))
+            @apache_setenv('no-gzip', '1');
+        while (ob_get_level())
+            ob_end_clean();
+
+        // UNA BxDolDb::pdoExceptionHandler dumps HTML 503 on uncaught PDOException.
+        restore_exception_handler();
+
         $oAdapter = new NeuronAI\Chat\Messages\Stream\Adapters\AGUIAdapter($sThreadId);
 
+        http_response_code(200);
         foreach ($oAdapter->getHeaders() as $sName => $sValue)
             header($sName . ': ' . $sValue);
 
-        if (function_exists('apache_setenv'))
-            @apache_setenv('no-gzip', '1');
-        @ini_set('zlib.output_compression', '0');
-        @ini_set('implicit_flush', '1');
-        while (ob_get_level())
-            ob_end_flush();
+        $fEmit = function ($sEvent) {
+            echo $sEvent;
+            if (ob_get_level())
+                ob_flush();
+            flush();
+        };
+
+        $aAgent = BxDolAIQuery::getAgentObject((int)$iAgentId);
+        if ($aAgent)
+            $this->_aChatContext = ['agent' => $aAgent, 'params' => $aParams];
 
+        if ($aAgent && $this->isChatSessionRateLimited($aAgent, $aParams)) {
+            $this->emitChatErrorSse($oAdapter, $fEmit, $this->getChatSessionRateLimitError());
+            error_clear_last();
+            @ini_set('display_errors', '0');
+            exit;
+        }
+
+        $iRequestTurns = (int)($aParams['request_user_turns'] ?? 0);
+        if ($aAgent && $this->isChatTurnLimitReached($aAgent, $this->getChatUserTurnCount((int)$iAgentId, $aParams), $iRequestTurns)) {
+            $this->emitConversationClosed('limit', '', $aAgent, $aParams);
+            $this->emitChatLimitSse($oAdapter, $fEmit, $this->getChatLimitMessage($aAgent));
+            error_clear_last();
+            @ini_set('display_errors', '0');
+            exit;
+        }
+
+        $sPrompt = $this->applyChatInputLimit((string)$sPrompt, $aAgent ?: []);
+
+        $bStarted = false;
+        $aSseState = $this->resetChatActionsSseState();
+        $o = null;
         try {
             $o = self::getAgentInstance((int)$iAgentId, $aParams);
             $oHandler = $o->stream(new NeuronAI\Chat\Messages\UserMessage($sPrompt));
 
             foreach ($oHandler->events($oAdapter) as $sEvent) {
-                echo $sEvent;
-                if (ob_get_level())
-                    ob_flush();
-                flush();
+                $bStarted = true;
+                $this->processChatActionsSseEvent($sEvent, $aSseState, $fEmit);
             }
-        } catch (Exception $oException) {
-            bx_log('sys_agents', "Stream exception for agent {$iAgentId}: " . $oException->getMessage() . " INPUT:" . $sPrompt, BX_LOG_ERR);
-            echo 'data: ' . json_encode(['type' => 'RUN_ERROR', 'message' => _t('_sys_agents_exception')]) . "\n\n";
-            flush();
+
+            $this->flushChatActionsSseState($aSseState, $fEmit);
+            $this->persistAssistantChatActions($o);
+
+            if ($aAgent && $this->isChatTurnLimitReached($aAgent, $this->getChatUserTurnCount((int)$iAgentId, $aParams)))
+                $this->emitConversationClosed('limit', '', $aAgent, $aParams);
+        } catch (Throwable $oException) {
+            bx_log('sys_agents', "Stream exception for agent {$iAgentId}: " . $oException->getMessage() . " INPUT:" . $sPrompt);
+            $this->flushChatActionsSseState($aSseState, $fEmit);
+            if (!$bStarted) {
+                foreach ($oAdapter->start() as $sEvent)
+                    $fEmit($sEvent);
+            }
+            $sMsg = $oException->getMessage();
+            $fEmit('data: ' . json_encode(['type' => 'RUN_ERROR', 'message' => $sMsg !== '' ? $sMsg : _t('_sys_agents_exception')]) . "\n\n");
+            foreach ($oAdapter->end() as $sEvent)
+                $fEmit($sEvent);
         }
 
+        error_clear_last();
+        @ini_set('display_errors', '0');
         exit;
     }
 
+    /**
+     * SSE assistant reply without calling the model. Used when max_turns is hit.
+     */
+    protected function emitChatLimitSse($oAdapter, $fEmit, $sMessage)
+    {
+        foreach ($oAdapter->start() as $sEvent)
+            $fEmit($sEvent);
+
+        $oChunk = new NeuronAI\Chat\Messages\Stream\Chunks\TextChunk('msg_limit', (string)$sMessage);
+        foreach ($oAdapter->transform($oChunk) as $sEvent)
+            $fEmit($sEvent);
+
+        foreach ($oAdapter->end() as $sEvent)
+            $fEmit($sEvent);
+    }
+
+    /**
+     * SSE error without an assistant bubble. Used when a new session is rate-limited.
+     */
+    protected function emitChatErrorSse($oAdapter, $fEmit, $sMessage)
+    {
+        foreach ($oAdapter->start() as $sEvent)
+            $fEmit($sEvent);
+
+        $sMsg = trim((string)$sMessage);
+        $fEmit('data: ' . json_encode([
+            'type' => 'RUN_ERROR',
+            'message' => $sMsg !== '' ? $sMsg : _t('_sys_agents_exception'),
+        ]) . "\n\n");
+
+        foreach ($oAdapter->end() as $sEvent)
+            $fEmit($sEvent);
+    }
+
     public function sendMessengerMessage ($iSender, $iRecipient, $sMsg) 
     {        
         $oMessengerModule = BxDolModule::getInstance('bx_messenger');

+ 42 - 6
inc/classes/BxDolAIModelFactory.php

@@ -9,18 +9,21 @@
 
 class BxDolAIModelFactory extends BxDolFactory
 {
-    public static function getModelInstance(int $iId): NeuronAI\Providers\AIProviderInterface | NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface
+    public static function getModelInstance(int $iId, array $aOverrides = []): NeuronAI\Providers\AIProviderInterface | NeuronAI\RAG\Embeddings\EmbeddingsProviderInterface
     {
-        if (isset($GLOBALS['bxDolClasses'][__CLASS__ . '_Model_' . $iId]))
-            return $GLOBALS['bxDolClasses'][__CLASS__ . '_Model_' . $iId];
+        $iMaxTokensOverride = (int)($aOverrides['max_tokens'] ?? 0);
+        $sCacheKey = __CLASS__ . '_Model_' . $iId . ($iMaxTokensOverride > 0 ? '_' . $iMaxTokensOverride : '');
+        if (isset($GLOBALS['bxDolClasses'][$sCacheKey]))
+            return $GLOBALS['bxDolClasses'][$sCacheKey];
 
-        $aProvidersWithKey = ['anthropic'];
+        $aProvidersWithKey = ['anthropic', 'openai-embeddings', 'voyageai-embeddings', 'openai-like-embeddings', 'openai-responses', 'openai-like'];
         $a = BxDolAIQuery::getModelObject($iId);
         if (!$a) {
             bx_log('sys_agents', "Agent AI Model with id {$iId} not found", BX_LOG_ERR);
             throw new Exception("Agent AI Model with id {$iId} not found");
         }
-        if (in_array($a['type'], $aProvidersWithKey) && empty($a['key'])) {
+        $a['key'] = (string)$a['key'];
+        if (in_array($a['type'], $aProvidersWithKey, true) && $a['key'] === '') {
             bx_log('sys_agents', "Model with id {$iId} has empty key, can't be used", BX_LOG_ERR);
             throw new Exception("Model with id {$iId} has empty key, can't be used");
         }
@@ -40,6 +43,39 @@ class BxDolAIModelFactory extends BxDolFactory
             'model' => $a['model']
         ]);
 
+        if ($iMaxTokensOverride > 0) {
+            $aParameters['max_tokens'] = $iMaxTokensOverride;
+            if (!isset($aParameters['parameters']) || !is_array($aParameters['parameters']))
+                $aParameters['parameters'] = [];
+
+            switch ($a['type']) {
+                case 'openai-responses':
+                    $aParameters['parameters']['max_output_tokens'] = $iMaxTokensOverride;
+                    break;
+                case 'azure-openai':
+                    $aParameters['parameters']['max_completion_tokens'] = $iMaxTokensOverride;
+                    break;
+                case 'gemini':
+                    if (!isset($aParameters['parameters']['generationConfig']) || !is_array($aParameters['parameters']['generationConfig']))
+                        $aParameters['parameters']['generationConfig'] = [];
+                    $aParameters['parameters']['generationConfig']['maxOutputTokens'] = $iMaxTokensOverride;
+                    break;
+                case 'ollama':
+                    if (!isset($aParameters['parameters']['options']) || !is_array($aParameters['parameters']['options']))
+                        $aParameters['parameters']['options'] = [];
+                    $aParameters['parameters']['options']['num_predict'] = $iMaxTokensOverride;
+                    break;
+                case 'aws-bedrock':
+                    if (!isset($aParameters['inferenceConfig']) || !is_array($aParameters['inferenceConfig']))
+                        $aParameters['inferenceConfig'] = [];
+                    $aParameters['inferenceConfig']['maxTokens'] = $iMaxTokensOverride;
+                    break;
+                default:
+                    $aParameters['parameters']['max_tokens'] = $iMaxTokensOverride;
+                    break;
+            }
+        }
+
         switch($a['type']) {
             // regular AI providers ------------------------
             case 'anthropic':
@@ -186,7 +222,7 @@ class BxDolAIModelFactory extends BxDolFactory
                 throw new Exception("Model type {$a['type']} is not supported");
         }
 
-        $GLOBALS['bxDolClasses'][__CLASS__ . '_Model_' . $iId] = $o;
+        $GLOBALS['bxDolClasses'][$sCacheKey] = $o;
 
         return $o;
     }

+ 118 - 0
inc/classes/BxDolAIQuery.php

@@ -986,6 +986,124 @@ class BxDolAIQuery extends BxDolDb
         return $iAffected;
     }
 
+    public function getAgentChatHistoryRows($aAgent)
+    {
+        $sExact = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        $sFields = "`id`, `thread_id`, `messages`, `created_at`, `updated_at`";
+        if ($this->isFieldExists('sys_agents_chat_history', 'closed_reason'))
+            $sFields .= ", `closed_reason`";
+        return $this->getAll("
+            SELECT $sFields
+            FROM `sys_agents_chat_history`
+            WHERE `thread_id` = :exact OR `thread_id` LIKE :prefix
+            ORDER BY `updated_at` DESC
+            LIMIT 200
+        ", [
+            'exact' => $sExact,
+            'prefix' => $sExact . ':%',
+        ]);
+    }
+
+    public function getChatArtifactsByHistoryIds($aIds)
+    {
+        $aIds = array_values(array_unique(array_filter(array_map('intval', (array)$aIds))));
+        if (!$aIds)
+            return [];
+
+        $sIn = implode(',', $aIds);
+        $aRows = $this->getAll("
+            SELECT `history_id`, `field_name`, `field_value`
+            FROM `sys_agents_chat_artifacts`
+            WHERE `history_id` IN ($sIn)
+            ORDER BY `id` ASC
+        ");
+        return $this->mapChatArtifactRows($aRows);
+    }
+
+    public function getChatArtifactsByThreadId($sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        if ($sThreadId === '')
+            return [];
+
+        $aRows = $this->getAll("
+            SELECT `a`.`history_id`, `a`.`field_name`, `a`.`field_value`
+            FROM `sys_agents_chat_artifacts` AS `a`
+            INNER JOIN `sys_agents_chat_history` AS `h` ON `h`.`id` = `a`.`history_id`
+            WHERE `h`.`thread_id` = :t
+            ORDER BY `a`.`id` ASC
+        ", [
+            't' => $sThreadId,
+        ]);
+        $aMap = $this->mapChatArtifactRows($aRows);
+        if (!$aMap)
+            return [];
+        return (array)reset($aMap);
+    }
+
+    public function getChatArtifactsForAgent($aAgent)
+    {
+        $sExact = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        if ($sExact === ':')
+            return [];
+
+        $aRows = $this->getAll("
+            SELECT `a`.`history_id`, `a`.`field_name`, `a`.`field_value`
+            FROM `sys_agents_chat_artifacts` AS `a`
+            INNER JOIN `sys_agents_chat_history` AS `h` ON `h`.`id` = `a`.`history_id`
+            WHERE `h`.`thread_id` = :exact OR `h`.`thread_id` LIKE :prefix
+            ORDER BY `a`.`id` ASC
+        ", [
+            'exact' => $sExact,
+            'prefix' => $sExact . ':%',
+        ]);
+        return $this->mapChatArtifactRows($aRows);
+    }
+
+    protected function mapChatArtifactRows($aRows)
+    {
+        if (!is_array($aRows) || !$aRows)
+            return [];
+
+        $aOut = [];
+        foreach ($aRows as $aRow) {
+            if (!is_array($aRow))
+                continue;
+            $iHistoryId = (int)($aRow['history_id'] ?? 0);
+            $sName = trim((string)($aRow['field_name'] ?? ''));
+            if ($iHistoryId <= 0 || $sName === '')
+                continue;
+            $aOut[$iHistoryId][] = [
+                'field_name' => $sName,
+                'field_value' => (string)($aRow['field_value'] ?? ''),
+            ];
+        }
+        return $aOut;
+    }
+
+    public function getExpiredAgentChatHistoryIds($aAgent, $iTtlMin)
+    {
+        if (!$this->isFieldExists('sys_agents_chat_history', 'closed_reason'))
+            return [];
+
+        $sExact = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        $iTtlMin = (int)$iTtlMin;
+        if ($sExact === ':' || $iTtlMin <= 0)
+            return [];
+
+        return $this->getColumn("
+            SELECT `id` FROM `sys_agents_chat_history`
+            WHERE (`thread_id` = :exact OR `thread_id` LIKE :prefix)
+              AND `messages` IS NOT NULL AND `messages` != '' AND `messages` != '[]'
+              AND (`closed_reason` = '' OR `closed_reason` IS NULL)
+              AND `updated_at` <= DATE_SUB(NOW(), INTERVAL :ttl MINUTE)
+        ", [
+            'exact' => $sExact,
+            'prefix' => $sExact . ':%',
+            'ttl' => $iTtlMin,
+        ]);
+    }
+
     /**
      * Rename this agent's guest threads (`…:{sessionId}`) onto a profile id.
      * If the member thread already has messages, drop the guest copy.

+ 70 - 0
inc/classes/BxDolAIToolChatArtifactSave.php

@@ -0,0 +1,70 @@
+<?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
+ * @{
+ */
+
+use NeuronAI\Tools\PropertyType;
+use NeuronAI\Tools\ToolProperty;
+
+class BxDolAIToolChatArtifactSave extends BxDolAITool
+{
+    public function __construct()
+    {
+        parent::__construct(
+            'chat_artifact_save',
+            'Save one collected field from this conversation (for example name, email, group_name). Call as soon as the visitor gives a value. Do not print saved values to the visitor.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty(
+                name: 'field_name',
+                type: PropertyType::STRING,
+                description: 'Field key, e.g. name, email, group_name.',
+                required: true
+            ),
+            new ToolProperty(
+                name: 'field_value',
+                type: PropertyType::STRING,
+                description: 'Field value as given by the visitor.',
+                required: true
+            ),
+        ];
+    }
+
+    public function __invoke(string $sFieldName, string $sFieldValue): string
+    {
+        $sName = strtolower(trim((string)$sFieldName));
+        $sName = preg_replace('/[^a-z0-9_]/', '', $sName);
+        $sValue = trim((string)$sFieldValue);
+        if ($sName === '' || $sValue === '')
+            return 'ignored';
+
+        $iHistoryId = (int)BxDolAI::getInstance()->getCurrentChatHistoryId();
+        if ($iHistoryId <= 0)
+            return 'no_history';
+
+        BxDolDb::getInstance()->query("
+            INSERT INTO `sys_agents_chat_artifacts`
+                (`history_id`, `field_name`, `field_value`, `updated_at`)
+            VALUES
+                (:h, :n, :v, :t)
+            ON DUPLICATE KEY UPDATE
+                `field_value` = VALUES(`field_value`),
+                `updated_at` = VALUES(`updated_at`)
+        ", [
+            'h' => $iHistoryId,
+            'n' => $sName,
+            'v' => $sValue,
+            't' => time(),
+        ]);
+
+        return 'ok';
+    }
+}

+ 40 - 0
inc/classes/BxDolAIToolChatGoalReached.php

@@ -0,0 +1,40 @@
+<?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
+ * @{
+ */
+
+use NeuronAI\Tools\PropertyType;
+use NeuronAI\Tools\ToolProperty;
+
+class BxDolAIToolChatGoalReached extends BxDolAITool
+{
+    public function __construct()
+    {
+        parent::__construct(
+            'chat_goal_reached',
+            'Call once when the session goal is reached (next step given, link sent). Do not print tool output to the visitor. Do not call again in the same session.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty(
+                name: 'summary',
+                type: PropertyType::STRING,
+                description: 'Short internal note: group type, size, readiness, which link was given.',
+                required: false
+            ),
+        ];
+    }
+
+    public function __invoke(string $sSummary = ''): string
+    {
+        $b = BxDolAI::getInstance()->emitConversationClosed('goal', trim((string)$sSummary));
+        return $b ? 'ok' : 'already_closed';
+    }
+}

+ 12 - 6
inc/classes/BxDolAiAgent.php

@@ -33,7 +33,11 @@ class BxDolAiAgent extends RAG
 
     protected function provider(): NeuronAI\Providers\AIProviderInterface
     {
-        return BxDolAIModelFactory::getModelInstance($this->aAgent['model_id']);
+        $aOverrides = [];
+        if ((int)($this->aAgent['max_tokens'] ?? 0) > 0)
+            $aOverrides['max_tokens'] = (int)$this->aAgent['max_tokens'];
+
+        return BxDolAIModelFactory::getModelInstance((int)$this->aAgent['model_id'], $aOverrides);
     }
 
     protected function instructions(): string
@@ -50,7 +54,7 @@ class BxDolAiAgent extends RAG
                 $aPromptTools[] = "Return array only, modified version of 'extra' array. Modifyable keys: " . $this->getAlertTriggerModifyableKeys() . ".";
             }
 
-            $sDesc = trim(BxDolAiQuery::getAlertDesc($this->aAgent['alert']));
+            $sDesc = trim(BxDolAIQuery::getAlertDesc($this->aAgent['alert']));
             if ('.' != mb_substr($sDesc, -1))
                 $sDesc .= '.';
             $aPromptSystem[] = $sDesc;
@@ -73,9 +77,11 @@ class BxDolAiAgent extends RAG
         if ($this->aAgent['tools']) {
             $aTools = explode(',', $this->aAgent['tools']);
             $aToolInstances = [];
-            foreach ($aTools as $iToolId) {
-                $oTool = BxDolAIToolFactory::getToolInstance($iToolId);
-                $aToolInstances[] = $oTool;                
+            foreach ($aTools as $sToolId) {
+                $iToolId = (int)trim($sToolId);
+                if ($iToolId <= 0)
+                    continue;
+                $aToolInstances[] = BxDolAIToolFactory::getToolInstance($iToolId);
             }
             return $aToolInstances;
         }
@@ -131,7 +137,7 @@ class BxDolAiAgent extends RAG
         if ('alert' != $this->aAgent['trigger'])
             return 'none';
 
-        $aAlert = BxDolAiQuery::getAlert($this->aAgent['alert']);
+        $aAlert = BxDolAIQuery::getAlert($this->aAgent['alert']);
         if (!$aAlert)
             return 'none';
 

+ 39 - 1
inc/classes/BxDolAiChatHistory.php

@@ -19,8 +19,41 @@ class BxDolAiChatHistory extends SQLChatHistory
 {
     public function addMessage(Message $message): ChatHistoryInterface
     {
+        $bStampIp = $this->isIncomingFirstUserMessage($message);
         $this->ensureValidTail($message);
-        return parent::addMessage($message);
+        $o = parent::addMessage($message);
+        if ($bStampIp)
+            $this->stampVisitorIp();
+        return $o;
+    }
+
+    protected function isIncomingFirstUserMessage(Message $incoming): bool
+    {
+        if (!($incoming instanceof UserMessage) || $incoming instanceof ToolResultMessage)
+            return false;
+
+        foreach ($this->history as $oMessage) {
+            if ($oMessage instanceof UserMessage && !($oMessage instanceof ToolResultMessage))
+                return false;
+        }
+
+        return true;
+    }
+
+    protected function stampVisitorIp(): void
+    {
+        if (!function_exists('getVisitorIP') || !function_exists('bx_get_ip_hash'))
+            return;
+
+        $iIp = bx_get_ip_hash(getVisitorIP());
+        if ($iIp === '' || $iIp === '0' || $iIp === 0)
+            return;
+
+        $stmt = $this->pdo->prepare("UPDATE {$this->table} SET `ip` = :ip WHERE `thread_id` = :thread_id AND (`ip` = 0 OR `ip` IS NULL)");
+        $stmt->execute([
+            'ip' => $iIp,
+            'thread_id' => $this->thread_id,
+        ]);
     }
 
     protected function ensureValidTail(Message $incoming): void
@@ -54,4 +87,9 @@ class BxDolAiChatHistory extends SQLChatHistory
             $this->setMessages($this->history);
         }
     }
+
+    public function persistMessages(): void
+    {
+        $this->setMessages($this->history);
+    }
 }

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
install/sql/system.sql


+ 29 - 3
modules/boonex/english/data/langs/system/en.xml

@@ -2317,6 +2317,10 @@
     <string name="_sys_agents_agents_txt_model"><![CDATA[Model]]></string>
     <string name="_sys_agents_agents_txt_trigger"><![CDATA[Trigger]]></string>
     <string name="_sys_agents_agents_txt_profile_id"><![CDATA[Profile]]></string>
+    <string name="_sys_agents_agents_txt_chats"><![CDATA[Chats]]></string>
+    <string name="_sys_agents_agents_txt_my_chat"><![CDATA[My chat]]></string>
+    <string name="_sys_agents_agents_txt_guest_chat"><![CDATA[Guest]]></string>
+    <string name="_sys_agents_agents_txt_readonly"><![CDATA[View only]]></string>
     <string name="_sys_agents_agents_popup_add"><![CDATA[Add agent]]></string>
     <string name="_sys_agents_agents_empty"><![CDATA[No agents yet]]></string>
     <string name="_sys_agents_agents_empty_info"><![CDATA[Agents run automated tasks on your site. Add one to get started.]]></string>
@@ -2360,15 +2364,37 @@
     <string name="_sys_agents_field_alert_sample"><![CDATA[Alert: payload]]></string>
     <string name="_sys_agents_waiting_for_sample_data_alert"><![CDATA[Please select an alert to see its payload.]]></string>
     <string name="_sys_agents_waiting_for_sample_data_webhook"><![CDATA[Trigger webhook to see its payload.]]></string>
+    <string name="_sys_agents_block_header_alert"><![CDATA[Trigger - alert]]></string>
     <string name="_sys_agents_field_alert"><![CDATA[Alert: event name]]></string>
     <string name="_sys_agents_field_alert_info"><![CDATA[Event that triggers the agent. Usually starts with system or module_name. Only events that have occurred at least once are shown. Second column shows alert stats, if 'n/a' then alert haven't happend yet. If 'n/a' everywhere then stats option is disabled in Developer module.]]></string>
+    <string name="_sys_agents_block_header_scheduler"><![CDATA[Trigger - scheduler]]></string>
     <string name="_sys_agents_field_scheduler_cron"><![CDATA[Scheduler (CRON)]]></string>
     <string name="_sys_agents_field_scheduler_cron_info"><![CDATA[CRON expression for scheduling the agent.]]></string>
-    <string name="_sys_agents_field_webhook_key"><![CDATA[Webhook: key]]></string>
+    <string name="_sys_agents_block_header_webhook"><![CDATA[Trigger - webhook]]></string>
+    <string name="_sys_agents_field_webhook_key"><![CDATA[Key]]></string>
     <string name="_sys_agents_field_webhook_key_info"><![CDATA[Unique key for webhook endpoint, used as Bearer Authentication header.]]></string>
-    <string name="_sys_agents_field_webhook_sample"><![CDATA[Webhook: sample payload]]></string>
-    <string name="_sys_agents_field_message_profile"><![CDATA[Message: profile for communication with agent]]></string>
+    <string name="_sys_agents_field_webhook_sample"><![CDATA[Sample payload]]></string>
+    <string name="_sys_agents_block_header_message"><![CDATA[Trigger - message]]></string>
+    <string name="_sys_agents_field_message_profile"><![CDATA[Profile for communication with agent]]></string>
     <string name="_sys_agents_field_message_profile_info"><![CDATA[Profile to use for sending messages to the agent, messages from other users are ignored.]]></string>
+    <string name="_sys_agents_block_header_form_input"><![CDATA[Trigger - form input]]></string>
+    <string name="_sys_agents_block_header_manual"><![CDATA[Trigger - manual]]></string>
+    <string name="_sys_agents_field_manual_max_turns"><![CDATA[Max turns]]></string>
+    <string name="_sys_agents_field_manual_max_turns_info"><![CDATA[Hard limit on visitor messages in one session. When reached, the chat shows the limit message and stops accepting input. Set to '0' for no limit.]]></string>
+    <string name="_sys_agents_field_manual_chat_ttl_min"><![CDATA[Chat TTL (minutes)]]></string>
+    <string name="_sys_agents_field_manual_chat_ttl_min_info"><![CDATA[0 = no time limit. After this idle time the conversation is closed.]]></string>
+    <string name="_sys_agents_field_manual_limit_message"><![CDATA[Limit message]]></string>
+    <string name="_sys_agents_field_manual_limit_message_info"><![CDATA[Shown when the session hits max turns. The visitor cannot send another message after this.]]></string>
+    <string name="_sys_agents_field_manual_max_input_chars"><![CDATA[Max input characters]]></string>
+    <string name="_sys_agents_field_manual_max_input_chars_info"><![CDATA[Maximum number of characters allowed in a single input message. Set to '0' for no limit.]]></string>
+    <string name="_sys_agents_field_manual_max_tokens"><![CDATA[Max tokens]]></string>
+    <string name="_sys_agents_field_manual_max_tokens_info"><![CDATA[Ceiling on one model reply, passed to the API as max_tokens. Set to '0' for no limit.]]></string>
+    <string name="_sys_agents_field_manual_max_sessions_per_hour"><![CDATA[Max sessions per hour]]></string>
+    <string name="_sys_agents_field_manual_max_sessions_per_hour_info"><![CDATA[New chat threads from one IP per hour. Set to '0' for no limit.]]></string>
+    <string name="_sys_agents_field_manual_max_sessions_per_day"><![CDATA[Max sessions per day]]></string>
+    <string name="_sys_agents_field_manual_max_sessions_per_day_info"><![CDATA[New chat threads from one IP per day. Set to '0' for no limit.]]></string>
+    <string name="_sys_agents_field_manual_hidden_first_message"><![CDATA[Hidden first message]]></string>
+    <string name="_sys_agents_field_manual_hidden_first_message_info"><![CDATA[Empty = do not send. A name is a query key (org = ?org=). Prefixes: query:, value:, data:.]]></string>
     <string name="_sys_agents_field_vector_store_id"><![CDATA[Knowledge]]></string>
     <string name="_sys_agents_field_vector_store_id_info"><![CDATA[Vector store for knowledge retrieval.]]></string>
     <string name="_sys_agents_field_tools"><![CDATA[Tools]]></string>

+ 31 - 5
modules/boonex/russian/data/langs/system/ru.xml

@@ -2291,6 +2291,10 @@
         <string name="_sys_agents_agents_txt_model"><![CDATA[Модель]]></string>
         <string name="_sys_agents_agents_txt_trigger"><![CDATA[Триггер]]></string>
         <string name="_sys_agents_agents_txt_profile_id"><![CDATA[Профиль]]></string>
+        <string name="_sys_agents_agents_txt_chats"><![CDATA[Чаты]]></string>
+        <string name="_sys_agents_agents_txt_my_chat"><![CDATA[Мой чат]]></string>
+        <string name="_sys_agents_agents_txt_guest_chat"><![CDATA[Гость]]></string>
+        <string name="_sys_agents_agents_txt_readonly"><![CDATA[Только просмотр]]></string>
         <string name="_sys_agents_agents_popup_add"><![CDATA[Добавить агента]]></string>
         <string name="_sys_agents_agents_empty"><![CDATA[Пока нет агентов]]></string>
         <string name="_sys_agents_agents_empty_info"><![CDATA[Агенты выполняют автоматические задачи на сайте. Добавьте первого, чтобы начать.]]></string>
@@ -2332,16 +2336,38 @@
         <string name="_sys_agents_field_alert_sample"><![CDATA[Alert - пример данных]]></string>
         <string name="_sys_agents_waiting_for_sample_data_alert"><![CDATA[Пожалуйста, выберите алерт, чтобы увидеть его данные.]]></string>
         <string name="_sys_agents_waiting_for_sample_data_webhook"><![CDATA[Пожалуйста, активируйте вебхук, чтобы увидеть его данные.]]></string>       
-        <string name="_sys_agents_field_alert"><![CDATA[Alert - событие]]></string>
+        <string name="_sys_agents_block_header_alert"><![CDATA[Триггер - Событие]]></string>
+        <string name="_sys_agents_field_alert"><![CDATA[Событие]]></string>
         <string name="_sys_agents_field_alert_info"><![CDATA[Событие, запускающее агента. Обычно начинается с system или module_name. Показаны только уже произошедшие события. Во втором столбце отображается статистика уведомлений. Если указано «н/д», значит соответствующее уведомление ещё не срабатывало. Если «н/д» указано везде, то опция статистики уведомлений отключена в модуле «Developer».]]></string>
-        <string name="_sys_agents_field_alert_action"><![CDATA[Alert - действие события]]></string>
-        <string name="_sys_agents_field_scheduler_cron"><![CDATA[Scheduler - Планировщик (CRON)]]></string>
-        <string name="_sys_agents_field_scheduler_cron_info"><![CDATA[Scheduler - CRON строка для планирования агента.]]></string>
+        <string name="_sys_agents_field_alert_action"><![CDATA[Действие события]]></string>
+        <string name="_sys_agents_block_header_scheduler"><![CDATA[Триггер - Планировщик]]></string>
+        <string name="_sys_agents_field_scheduler_cron"><![CDATA[Планировщик (CRON)]]></string>
+        <string name="_sys_agents_field_scheduler_cron_info"><![CDATA[CRON строка для планирования агента.]]></string>
+        <string name="_sys_agents_block_header_webhook"><![CDATA[Триггер - webhook]]></string>
         <string name="_sys_agents_field_webhook_key"><![CDATA[Webhook: ключ]]></string>
         <string name="_sys_agents_field_webhook_key_info"><![CDATA[Уникальный секретный ключ для вебхука, используется для заголовка Bearer Authentication]]></string>
         <string name="_sys_agents_field_webhook_sample"><![CDATA[Webhook: пример данных]]></string>
-        <string name="_sys_agents_field_message_profile"><![CDATA[Message - профиль для отправки сообщений]]></string>
+        <string name="_sys_agents_block_header_message"><![CDATA[Триггер - Сообщение]]></string>
+        <string name="_sys_agents_field_message_profile"><![CDATA[Профиль для отправки сообщений]]></string>
         <string name="_sys_agents_field_message_profile_info"><![CDATA[Профиль для для отправке сообщений агенту, сообщения от других пользователей игногируются.]]></string>
+        <string name="_sys_agents_block_header_form_input"><![CDATA[Триггер - ввод данных в форму]]></string>
+        <string name="_sys_agents_block_header_manual"><![CDATA[Триггер - ручной запуск]]></string>
+        <string name="_sys_agents_field_manual_max_turns"><![CDATA[Максимальное количество ходов]]></string>
+        <string name="_sys_agents_field_manual_max_turns_info"><![CDATA[Жесткий лимит на сообщения посетителя в одной сессии. Когда он достигнут, чат показывает сообщение о достижении лимита и прекращает принимать ввод. Установите '0' для отсутствия лимита.]]></string>
+        <string name="_sys_agents_field_manual_chat_ttl_min"><![CDATA[Время жизни чата (минуты)]]></string>
+        <string name="_sys_agents_field_manual_chat_ttl_min_info"><![CDATA[0 = нет ограничения по времени. После этого времени бездействия разговор закрывается.]]></string>
+        <string name="_sys_agents_field_manual_limit_message"><![CDATA[Сообщение о достижении лимита]]></string>
+        <string name="_sys_agents_field_manual_limit_message_info"><![CDATA[Отображается, когда сессия достигает максимального количества ходов. Посетитель не может отправить еще одно сообщение после этого.]]></string>
+        <string name="_sys_agents_field_manual_max_input_chars"><![CDATA[Максимальное количество символов ввода]]></string>
+        <string name="_sys_agents_field_manual_max_input_chars_info"><![CDATA[Максимальное количество символов, разрешенных в одном сообщении ввода. Установите '0' для отсутствия лимита.]]></string>
+        <string name="_sys_agents_field_manual_max_tokens"><![CDATA[Максимальное количество токенов]]></string>
+        <string name="_sys_agents_field_manual_max_tokens_info"><![CDATA[Потолок на одно ответ модели, передается в API как max_tokens. Установите '0' для отсутствия лимита.]]></string>
+        <string name="_sys_agents_field_manual_max_sessions_per_hour"><![CDATA[Максимальное количество сессий за час]]></string>
+        <string name="_sys_agents_field_manual_max_sessions_per_hour_info"><![CDATA[Новые темы чата от одного IP за час. Установите '0' для отсутствия лимита.]]></string>
+        <string name="_sys_agents_field_manual_max_sessions_per_day"><![CDATA[Максимальное количество сессий за день]]></string>
+        <string name="_sys_agents_field_manual_max_sessions_per_day_info"><![CDATA[Новые темы чата от одного IP за день. Установите '0' для отсутствия лимита.]]></string>
+        <string name="_sys_agents_field_manual_hidden_first_message"><![CDATA[Скрытое первое сообщение]]></string>
+        <string name="_sys_agents_field_manual_hidden_first_message_info"><![CDATA[Пусто = не отправлять. Имя является ключом запроса (org = ?org=). Префиксы: query:, value:, data:.]]></string>
         <string name="_sys_agents_field_vector_store_id"><![CDATA[Хранилище векторов]]></string>
         <string name="_sys_agents_field_vector_store_id_info"><![CDATA[Хранилище векторов для получения знаний.]]></string>
         <string name="_sys_agents_field_tools"><![CDATA[Инструменты]]></string>

+ 291 - 8
studio/js/agents.js

@@ -35,27 +35,310 @@ BxDolStudioPageAgents.prototype.initGrid = function() {
     });
 };
 
-BxDolStudioPageAgents.prototype.initAgentChat = function(sSelector, iAgentId) {
+BxDolStudioPageAgents.prototype.initAgentChat = function(sSelector, iAgentId, aThreads) {
     var $this = this;
-    var fInit = function() {
-        $this.destroyAgentChat();
+    if (typeof aThreads === 'undefined') {
+        this._fetchAgentChatThreads(iAgentId, function(aList) {
+            $this.initAgentChat(sSelector, iAgentId, aList || []);
+        });
+        return;
+    }
+
+    var oChat = $(sSelector);
+    if (!oChat.length)
+        return;
+
+    this.destroyAgentChat();
+    this._ensureAgentChatStyles();
+    this._sAgentChatSelector = sSelector;
+    this._iAgentChatId = iAgentId;
+
+    var oBox = oChat.closest('.bx-agents-popup-chat-wrap');
+    if (!oBox.length)
+        oBox = this._ensureAgentChatWrap(oChat, iAgentId);
+
+    if (!aThreads.length && !oBox.find('.bx-agents-popup-chat-thread').length)
+        aThreads = [{ thread_id: '', writable: 1, context_pid: 0, title: 'Guest', meta: '' }];
+    if (aThreads.length)
+        this._fillAgentChatThreads(oBox, aThreads);
+
+    this._oAgentChatBox = oBox;
+    oBox.off('click.agentChat').on('click.agentChat', '.bx-agents-popup-chat-thread', function(e) {
+        e.preventDefault();
+        $this._openAgentChatThread($(this));
+    });
+
+    var oFirst = oBox.find('.bx-agents-popup-chat-thread').first();
+    if (oFirst.length)
+        this._openAgentChatThread(oFirst);
+    else
+        this._initWritableAgentChat(sSelector, iAgentId, 0);
+};
+
+BxDolStudioPageAgents.prototype._fetchAgentChatThreads = function(iAgentId, fDone) {
+    var sUrl = (typeof sUrlRoot !== 'undefined' ? sUrlRoot : '/') + 'grid.php?o=sys_studio_agents_agents&a=get_chat_threads&id=' + encodeURIComponent(iAgentId) + '&_r=' + Date.now();
+    if (typeof glGrids !== 'undefined' && this.sObjNameGrid && glGrids[this.sObjNameGrid] && glGrids[this.sObjNameGrid]._sCsrfToken)
+        sUrl += '&csrf_token=' + encodeURIComponent(glGrids[this.sObjNameGrid]._sCsrfToken);
+    $.getJSON(sUrl, function(oData) {
+        fDone((oData && oData.threads) ? oData.threads : []);
+    }).fail(function() {
+        fDone([]);
+    });
+};
+
+BxDolStudioPageAgents.prototype._ensureAgentChatWrap = function(oChat, iAgentId) {
+    oChat.wrap('<div class="bx-agents-popup-chat-wrap" data-agent-id="' + parseInt(iAgentId, 10) + '"><div class="bx-agents-popup-chat-main"></div></div>');
+    var oMain = oChat.parent();
+    var oBox = oMain.parent();
+    oMain.append('<div class="bx-agents-popup-chat-note" style="display:none;">View only</div>');
+    oChat.before('<div class="bx-agents-popup-chat-artifacts" style="display:none;"></div>');
+    oBox.prepend('<aside class="bx-agents-popup-chat-list"><div class="bx-agents-popup-chat-list-title">Chats</div><div class="bx-agents-popup-chat-list-items"></div></aside>');
+    return oBox;
+};
+
+BxDolStudioPageAgents.prototype._fillAgentChatThreads = function(oBox, aThreads) {
+    var oItems = oBox.find('.bx-agents-popup-chat-list-items');
+    if (!oItems.length)
+        return;
+    oItems.empty();
+    for (var i = 0; i < aThreads.length; i++) {
+        var t = aThreads[i] || {};
+        var bMine = parseInt(t.writable, 10) === 1;
+        var oBtn = $('<button type="button" class="bx-agents-popup-chat-thread"></button>');
+        if (bMine)
+            oBtn.addClass('bx-agents-popup-chat-thread-mine');
+        oBtn.attr({
+            'data-thread-id': t.thread_id || '',
+            'data-writable': bMine ? 1 : 0,
+            'data-context': parseInt(t.context_pid, 10) || 0
+        });
+        oBtn.data('artifacts', Array.isArray(t.artifacts) ? t.artifacts : []);
+        oBtn.append($('<span class="bx-agents-popup-chat-thread-title"></span>').text(t.title || 'Chat'));
+        var sStatus = t.status === 'closed' ? 'closed' : 'opened';
+        var oMeta = $('<span class="bx-agents-popup-chat-thread-meta"></span>')
+            .addClass(sStatus === 'closed' ? 'bx-agents-popup-chat-thread-status-closed' : 'bx-agents-popup-chat-thread-status-opened')
+            .text(t.meta || ('status: ' + sStatus));
+        oBtn.append(oMeta);
+        oItems.append(oBtn);
+    }
+};
+
+BxDolStudioPageAgents.prototype._ensureAgentChatStyles = function() {
+    if (document.getElementById('bx-agents-popup-chat-inline-css'))
+        return;
+    var oStyle = document.createElement('style');
+    oStyle.id = 'bx-agents-popup-chat-inline-css';
+    oStyle.textContent =
+        '#grid-popup-sys_studio_agents_agents-message .bx-popup-width{width:68rem;max-width:calc(100vw - 2rem);}' +
+        '.bx-agents-popup-chat-wrap{display:flex;gap:0.75rem;min-height:32rem;max-height:min(36rem,calc(100vh - 10rem));}' +
+        '.bx-agents-popup-chat-list{flex:0 0 15.5rem;width:15.5rem;display:flex;flex-direction:column;min-height:0;border-right:1px solid rgba(0,0,0,.08);padding-right:0.75rem;}' +
+        '.bx-agents-popup-chat-list-title{font-weight:600;margin-bottom:0.5rem;}' +
+        '.bx-agents-popup-chat-list-items{overflow-y:auto;flex:1 1 auto;min-height:0;display:flex;flex-direction:column;gap:0.25rem;}' +
+        '.bx-agents-popup-chat-thread{display:block;width:100%;text-align:left;border:0;background:transparent;padding:0.5rem 0.6rem;border-radius:0.4rem;cursor:pointer;}' +
+        '.bx-agents-popup-chat-thread:hover,.bx-agents-popup-chat-thread-active{background:rgba(37,99,235,.08);}' +
+        '.bx-agents-popup-chat-thread-title{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}' +
+        '.bx-agents-popup-chat-thread-meta{display:block;font-size:0.75rem;margin-top:0.15rem;white-space:pre-line;line-height:1.25;}' +
+        '.bx-agents-popup-chat-thread-status-opened{color:#15803d;}' +
+        '.bx-agents-popup-chat-thread-status-closed{color:#b45309;}' +
+        '.bx-agents-popup-chat-thread-dates{display:block;font-size:0.7rem;opacity:.6;margin-top:0.1rem;line-height:1.25;}' +
+        '.bx-agents-popup-chat-thread-dates span{display:block;}' +
+        '.bx-agents-popup-chat-artifacts{flex:0 0 auto;margin:0 0 0.5rem;padding:0.5rem 0.65rem;font-size:0.8rem;line-height:1.35;border:1px solid rgba(0,0,0,.12);border-radius:0.4rem;max-height:8rem;overflow-y:auto;background:#fffbe6;}' +
+        '.bx-agents-popup-chat-artifacts-row{display:flex;gap:0.5rem;min-width:0;}' +
+        '.bx-agents-popup-chat-artifacts-k{flex:0 0 7rem;font-weight:600;opacity:.7;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}' +
+        '.bx-agents-popup-chat-artifacts-v{flex:1 1 auto;min-width:0;overflow-wrap:anywhere;}' +
+        '.bx-agents-popup-chat-artifacts-empty{opacity:.6;}' +
+        '.bx-agents-popup-chat-main{flex:1 1 auto;min-width:0;display:flex;flex-direction:column;}' +
+        '.bx-agents-popup-chat{height:auto;flex:1 1 auto;min-height:16rem;max-height:100%;}' +
+        '.bx-agents-popup-chat-note{margin-top:0.5rem;font-size:0.8rem;opacity:.7;}';
+    document.head.appendChild(oStyle);
+};
+
+BxDolStudioPageAgents.prototype._openAgentChatThread = function(oBtn) {
+    var sSelector = this._sAgentChatSelector;
+    var iAgentId = this._iAgentChatId;
+    var oBox = this._oAgentChatBox;
+    if (!oBtn || !oBtn.length || !sSelector)
+        return;
+
+    if (oBox && oBox.length) {
+        oBox.find('.bx-agents-popup-chat-thread').removeClass('bx-agents-popup-chat-thread-active');
+        oBtn.addClass('bx-agents-popup-chat-thread-active');
+    }
+
+    var bWritable = parseInt(oBtn.attr('data-writable'), 10) === 1;
+    var iContext = parseInt(oBtn.attr('data-context'), 10) || 0;
+    var sThreadId = oBtn.attr('data-thread-id') || '';
+
+    this.destroyAgentChat(true);
+    this._renderAgentChatArtifacts(oBox, oBtn.data('artifacts') || []);
+
+    if (bWritable) {
+        if (oBox && oBox.length)
+            oBox.find('.bx-agents-popup-chat-note').hide();
+        this._initWritableAgentChat(sSelector, iAgentId, iContext);
+        return;
+    }
+
+    if (oBox && oBox.length)
+        oBox.find('.bx-agents-popup-chat-note').show();
+    this._loadReadonlyAgentChat(sSelector, iAgentId, sThreadId);
+};
+
+BxDolStudioPageAgents.prototype._initWritableAgentChat = function(sSelector, iAgentId, iContext) {
         if (typeof una === 'undefined' || !una.Chat)
             return;
-        $this._oAgentChat = una.Chat.init(sSelector, { agentId: iAgentId, formatting: true });
+
+    var sRoot = (typeof sUrlRoot !== 'undefined') ? sUrlRoot : '/';
+    var sEndpoint = sRoot + 'sys-ai-chat.php?id=' + encodeURIComponent(iAgentId);
+    iContext = parseInt(iContext, 10) || 0;
+    if (iContext > 0)
+        sEndpoint += '&context=' + encodeURIComponent(iContext);
+
+    this._oAgentChat = una.Chat.init(sSelector, {
+        agentId: iAgentId,
+        formatting: true,
+        contextProfileId: iContext,
+        endpoint: sEndpoint
+    });
+};
+
+BxDolStudioPageAgents.prototype._loadReadonlyAgentChat = function(sSelector, iAgentId, sThreadId) {
+    var $this = this;
+    var el = typeof sSelector === 'string' ? document.querySelector(sSelector) : sSelector;
+    if (!el)
+        return;
+
+    this._iAgentChatLoad = (this._iAgentChatLoad || 0) + 1;
+    var iLoad = this._iAgentChatLoad;
+    el.innerHTML = '';
+
+    var sUrl = (typeof sUrlRoot !== 'undefined' ? sUrlRoot : '/') + 'grid.php?o=sys_studio_agents_agents&a=get_chat_thread&id=' + encodeURIComponent(iAgentId) + '&thread_id=' + encodeURIComponent(sThreadId) + '&_r=' + Date.now();
+    if (typeof glGrids !== 'undefined' && this.sObjNameGrid && glGrids[this.sObjNameGrid] && glGrids[this.sObjNameGrid]._sCsrfToken)
+        sUrl += '&csrf_token=' + encodeURIComponent(glGrids[this.sObjNameGrid]._sCsrfToken);
+
+    if (this._oAgentChatXhr && this._oAgentChatXhr.abort)
+        this._oAgentChatXhr.abort();
+
+    this._oAgentChatXhr = $.getJSON(sUrl, function(oData) {
+        if (iLoad !== $this._iAgentChatLoad)
+            return;
+        if (!oData || (oData.code && oData.code != 200)) {
+            el.innerHTML = '<div class="bx-def-font-small" style="color:#c0392b;">' + $this._escAgentChat((oData && (oData.msg || oData.message)) || 'Error') + '</div>';
+            return;
+        }
+        if (parseInt(oData.writable, 10) === 1) {
+            $this._initWritableAgentChat(sSelector, iAgentId, 0);
+            return;
+        }
+        $this._renderAgentChatArtifacts($this._oAgentChatBox, oData.artifacts || []);
+        $this._renderReadonlyAgentChat(el, oData.messages || []);
+    }).fail(function() {
+        if (iLoad !== $this._iAgentChatLoad)
+            return;
+        el.innerHTML = '';
+    });
     };
 
-    if (typeof una !== 'undefined' && una.Chat)
-        fInit();
+BxDolStudioPageAgents.prototype._ensureAgentChatArtifactsPanel = function(oBox) {
+    if (!oBox || !oBox.length)
+        return $();
+    var oPanel = oBox.find('.bx-agents-popup-chat-main > .bx-agents-popup-chat-artifacts');
+    if (oPanel.length)
+        return oPanel;
+    oPanel = $('<div class="bx-agents-popup-chat-artifacts"></div>');
+    var oChat = oBox.find('.bx-agents-popup-chat-main > .bx-agents-popup-chat').first();
+    if (oChat.length)
+        oChat.before(oPanel);
+    else
+        oBox.find('.bx-agents-popup-chat-main').prepend(oPanel);
+    return oPanel;
 };
 
-BxDolStudioPageAgents.prototype.destroyAgentChat = function() {
-    if (!this._oAgentChat)
+BxDolStudioPageAgents.prototype._fillAgentChatArtifactsEl = function(oPanel, aArtifacts) {
+    if (!oPanel || !oPanel.length)
+        return;
+    aArtifacts = Array.isArray(aArtifacts) ? aArtifacts : [];
+    oPanel.empty();
+    if (!aArtifacts.length) {
+        oPanel.hide();
         return;
+    }
+    for (var i = 0; i < aArtifacts.length; i++) {
+        var a = aArtifacts[i] || {};
+        var oRow = $('<div class="bx-agents-popup-chat-artifacts-row"></div>');
+        oRow.append($('<span class="bx-agents-popup-chat-artifacts-k"></span>').text(a.field_name || ''));
+        oRow.append($('<span class="bx-agents-popup-chat-artifacts-v"></span>').text(a.field_value || ''));
+        oPanel.append(oRow);
+    }
+    oPanel.show();
+};
+
+BxDolStudioPageAgents.prototype._renderAgentChatArtifacts = function(oBox, aArtifacts) {
+    this._fillAgentChatArtifactsEl(this._ensureAgentChatArtifactsPanel(oBox), aArtifacts);
+};
 
+BxDolStudioPageAgents.prototype._renderReadonlyAgentChat = function(el, aMessages) {
+    var html = '<div class="bx-ai-chat flex flex-col gap-3 h-full min-h-72">' +
+        '<div class="bx-ai-chat-messages bx-def-border bx-def-round-corners bx-def-padding" style="flex:1;overflow-y:auto;min-height:16rem;">';
+    for (var i = 0; i < aMessages.length; i++) {
+        var m = aMessages[i] || {};
+        var isUser = m.role === 'user';
+        var text = '';
+        if (typeof m.content === 'string')
+            text = m.content;
+        else if (m.parts && m.parts.length) {
+            for (var j = 0; j < m.parts.length; j++) {
+                if (m.parts[j] && m.parts[j].type === 'text' && m.parts[j].content)
+                    text += (text ? '\n' : '') + m.parts[j].content;
+            }
+        }
+        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 />') +
+            '</div></div>';
+    }
+    html += '</div></div>';
+    el.innerHTML = html;
+    var list = el.querySelector('.bx-ai-chat-messages');
+    if (list)
+        list.scrollTop = list.scrollHeight;
+};
+
+BxDolStudioPageAgents.prototype._escAgentChat = function(s) {
+    return String(s == null ? '' : s)
+        .replace(/&/g, '&amp;')
+        .replace(/</g, '&lt;')
+        .replace(/>/g, '&gt;')
+        .replace(/"/g, '&quot;');
+};
+
+BxDolStudioPageAgents.prototype.destroyAgentChat = function(bKeepBox) {
+    this._iAgentChatLoad = (this._iAgentChatLoad || 0) + 1;
+    if (this._oAgentChatXhr && this._oAgentChatXhr.abort) {
+        try { this._oAgentChatXhr.abort(); } catch (e) {}
+    }
+    this._oAgentChatXhr = null;
+
+    if (this._oAgentChat) {
     try {
         this._oAgentChat.destroy();
     } catch (e) {}
     this._oAgentChat = null;
+    }
+
+    if (this._sAgentChatSelector) {
+        var el = document.querySelector(this._sAgentChatSelector);
+        if (el)
+            el.innerHTML = '';
+    }
+
+    if (!bKeepBox) {
+        if (this._oAgentChatBox && this._oAgentChatBox.length)
+            this._oAgentChatBox.off('click.agentChat');
+        this._oAgentChatBox = null;
+        this._sAgentChatSelector = '';
+        this._iAgentChatId = 0;
+    }
 };
 
 BxDolStudioPageAgents.prototype.agentActions = function(oSource) {

+ 18 - 1
studio/template/agents_popup_chat.html

@@ -1 +1,18 @@
-<div id="__chat_id__" class="bx-agents-popup-chat h-[32rem]"></div>
+<div class="bx-agents-popup-chat-wrap" data-agent-id="__agent_id__">
+    <aside class="bx-agents-popup-chat-list">
+        <div class="bx-agents-popup-chat-list-title">__txt_chats__</div>
+        <div class="bx-agents-popup-chat-list-items">
+            <bx_repeat:threads>
+                <button type="button" class="bx-agents-popup-chat-thread__class_mine__" data-thread-id="__thread_id__" data-writable="__writable__" data-context="__context_pid__">
+                    <span class="bx-agents-popup-chat-thread-title">__title__</span>
+                    <span class="bx-agents-popup-chat-thread-meta">__meta__</span>
+                </button>
+            </bx_repeat:threads>
+        </div>
+    </aside>
+    <div class="bx-agents-popup-chat-main">
+        <div class="bx-agents-popup-chat-artifacts" style="display:none;"></div>
+        <div id="__chat_id__" class="bx-agents-popup-chat"></div>
+        <div class="bx-agents-popup-chat-note" style="display:none;">__txt_readonly__</div>
+    </div>
+</div>

+ 125 - 2
studio/template/css/agents.css

@@ -14,11 +14,127 @@
 }
 
 #grid-popup-sys_studio_agents_agents-message .bx-popup-width {
-    width: 42rem;
+    width: 68rem;
     max-width: calc(100vw - 2rem);
 }
+.bx-agents-popup-chat-wrap {
+    display: flex;
+    gap: 0.75rem;
+    min-height: 32rem;
+    max-height: min(36rem, calc(100vh - 10rem));
+}
+.bx-agents-popup-chat-list {
+    flex: 0 0 15.5rem;
+    width: 15.5rem;
+    display: flex;
+    flex-direction: column;
+    min-height: 0;
+    border-right: 1px solid rgba(0,0,0,.08);
+    padding-right: 0.75rem;
+}
+.bx-agents-popup-chat-list-title {
+    font-weight: 600;
+    margin-bottom: 0.5rem;
+}
+.bx-agents-popup-chat-list-items {
+    overflow-y: auto;
+    flex: 1 1 auto;
+    min-height: 0;
+    display: flex;
+    flex-direction: column;
+    gap: 0.25rem;
+}
+.bx-agents-popup-chat-thread {
+    display: block;
+    width: 100%;
+    text-align: left;
+    border: 0;
+    background: transparent;
+    padding: 0.5rem 0.6rem;
+    border-radius: 0.4rem;
+    cursor: pointer;
+}
+.bx-agents-popup-chat-thread:hover,
+.bx-agents-popup-chat-thread-active {
+    background: rgba(37, 99, 235, .08);
+}
+.bx-agents-popup-chat-thread-title {
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-agents-popup-chat-thread-meta {
+    display: block;
+    font-size: 0.75rem;
+    margin-top: 0.15rem;
+    white-space: pre-line;
+    line-height: 1.25;
+}
+.bx-agents-popup-chat-thread-status-opened {
+    color: #15803d;
+}
+.bx-agents-popup-chat-thread-status-closed {
+    color: #b45309;
+}
+.bx-agents-popup-chat-thread-dates {
+    display: block;
+    font-size: 0.7rem;
+    opacity: .6;
+    margin-top: 0.1rem;
+    line-height: 1.25;
+}
+.bx-agents-popup-chat-thread-dates span {
+    display: block;
+}
+.bx-agents-popup-chat-artifacts {
+    flex: 0 0 auto;
+    margin: 0 0 0.5rem;
+    padding: 0.5rem 0.65rem;
+    font-size: 0.8rem;
+    line-height: 1.35;
+    border: 1px solid rgba(0,0,0,.12);
+    border-radius: 0.4rem;
+    max-height: 8rem;
+    overflow-y: auto;
+    background: #fffbe6;
+}
+.bx-agents-popup-chat-artifacts-row {
+    display: flex;
+    gap: 0.5rem;
+    min-width: 0;
+}
+.bx-agents-popup-chat-artifacts-k {
+    flex: 0 0 7rem;
+    font-weight: 600;
+    opacity: .7;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+}
+.bx-agents-popup-chat-artifacts-v {
+    flex: 1 1 auto;
+    min-width: 0;
+    overflow-wrap: anywhere;
+}
+.bx-agents-popup-chat-artifacts-empty {
+    opacity: .6;
+}
+.bx-agents-popup-chat-main {
+    flex: 1 1 auto;
+    min-width: 0;
+    display: flex;
+    flex-direction: column;
+}
 .bx-agents-popup-chat {
-    height: 32rem;
+    height: auto;
+    flex: 1 1 auto;
+    min-height: 16rem;
+}
+.bx-agents-popup-chat-note {
+    margin-top: 0.5rem;
+    font-size: 0.8rem;
+    opacity: .7;
 }
 
 /**--- Automators ---**/
@@ -151,6 +267,13 @@
 .select2-container .select2-selection--single {
     height: 30px;
 }
+.bx-agents-automator-form .select2-container {
+    width: 100% !important;
+}
+#bx_std_agents_add_popup .bx-popup,
+#bx_std_agents_edit_popup .bx-popup {
+    overflow: visible;
+}
 
 .select2-results__option .bx-alert-wrap {
     display:flex;

+ 262 - 31
studio/template/scripts/BxBaseStudioAgentsAgents.php

@@ -53,7 +53,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     public function performActionManual()
     {
         $iId = $this->_getId();        
-        $aAgent = BxDolAiQuery::getAgentObject($iId);
+        $aAgent = BxDolAIQuery::getAgentObject($iId);
         if (!$aAgent) {
             echoJson(['msg' => _t('_sys_txt_error_occured')]);
             return;
@@ -64,7 +64,12 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
         }
 
         $oAi = BxDolAI::getInstance();
-        $sResponse = $oAi->callAgent('manual', $aAgent);
+        try {
+            $sResponse = $oAi->callAgent('manual', $aAgent);
+        } catch (Throwable $o) {
+            echoJson(['msg' => $o->getMessage()]);
+            return;
+        }
 
         $oParsedown = new Parsedown();
         $oParsedown->setSafeMode(true);
@@ -80,7 +85,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     public function performActionWipeChatHistory()
     {
         $iId = $this->_getId();        
-        $aAgent = BxDolAiQuery::getAgentObject($iId);
+        $aAgent = BxDolAIQuery::getAgentObject($iId);
         if (!$aAgent) {
             echoJson(['msg' => _t('_sys_txt_error_occured')]);
             return;
@@ -169,7 +174,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
         $oAI = BxDolAI::getInstance();
 
         $iId = $this->_getId();
-        $aAgent = BxDolAiQuery::getAgentObject($iId);
+        $aAgent = BxDolAIQuery::getAgentObject($iId);
 
         $aForm = $this->_getFormEdit($sAction, $aAgent);
         $oForm = new BxTemplFormView($aForm);
@@ -233,7 +238,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     public function performActionLogs()
     {
         $iId = $this->_getId();
-        $aAgent = BxDolAiQuery::getAgentObject($iId);
+        $aAgent = BxDolAIQuery::getAgentObject($iId);
         if (!$aAgent) {
             echoJson(['msg' => _t('_sys_txt_error_occured')]);
             return;
@@ -254,7 +259,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     public function performActionMessage()
     {
         $iId = $this->_getId();
-        $aAgent = BxDolAiQuery::getAgentObject($iId);
+        $aAgent = BxDolAIQuery::getAgentObject($iId);
         if (!$aAgent) {
             echoJson(['msg' => _t('_sys_txt_error_occured')]);
             return;
@@ -266,21 +271,121 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
 
         $sChatId = 'bx-ai-chat-' . $iId;
         $sName = !empty($aAgent['title']) ? $aAgent['title'] : $aAgent['name'];
+        $aThreadsTpl = [];
+        $oAi = BxDolAI::getInstance();
+        $aThreads = $oAi && method_exists($oAi, 'listAgentChatThreads') ? $oAi->listAgentChatThreads($aAgent) : [];
+        foreach ($aThreads as $aThread) {
+            $aThreadsTpl[] = [
+                'thread_id' => bx_html_attribute($aThread['thread_id']),
+                'writable' => (int)$aThread['writable'],
+                'context_pid' => (int)$aThread['context_pid'],
+                'title' => bx_process_output($aThread['title']),
+                'meta' => bx_process_output($aThread['meta']),
+                'class_mine' => $aThread['class_mine'],
+            ];
+        }
+
         $sContent = BxTemplStudioFunctions::getInstance()->popupBox('popup_agent_chat_' . $iId, _t('_sys_agents_agents_popup_message', $sName), $this->_oTemplate->parseHtmlByName('agents_popup_chat.html', [
             'chat_id' => $sChatId,
+            'agent_id' => (int)$iId,
+            'txt_readonly' => _t('_sys_agents_agents_txt_readonly'),
+            'txt_chats' => _t('_sys_agents_agents_txt_chats'),
+            'bx_repeat:threads' => $aThreadsTpl,
         ]));
 
+        $aThreadsJs = [];
+        foreach ($aThreads as $aThread)
+            $aThreadsJs[] = $this->_agentChatThreadJs($aThread);
+        $sThreadsJson = json_encode($aThreadsJs, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
+        if ($sThreadsJson === false)
+            $sThreadsJson = '[]';
+
         $sJsObject = $this->getPageJsObject();
         return echoJson(['popup' => [
             'html' => $sContent,
             'options' => [
                 'closeOnOuterClick' => false,
-                'onShow' => $sJsObject . ".initAgentChat('#" . $sChatId . "', " . $iId . ");",
+                'onShow' => $sJsObject . ".initAgentChat('#" . $sChatId . "', " . (int)$iId . ", " . $sThreadsJson . ");",
                 'onHide' => $sJsObject . ".destroyAgentChat();",
             ]
         ]]);
     }
 
+    public function performActionGetChatThreads()
+    {
+        $iId = $this->_getId();
+        $aAgent = BxDolAIQuery::getAgentObject($iId);
+        if (!$aAgent) {
+            echoJson(['code' => 404, 'threads' => []]);
+            return;
+        }
+
+        $aThreadsJs = [];
+        $oAi = BxDolAI::getInstance();
+        $aThreads = $oAi && method_exists($oAi, 'listAgentChatThreads') ? $oAi->listAgentChatThreads($aAgent) : [];
+        foreach ($aThreads as $aThread)
+            $aThreadsJs[] = $this->_agentChatThreadJs($aThread);
+
+        echoJson(['code' => 200, 'threads' => $aThreadsJs]);
+    }
+
+    public function performActionGetChatThread()
+    {
+        $iId = $this->_getId();
+        $aAgent = BxDolAIQuery::getAgentObject($iId);
+        if (!$aAgent) {
+            echoJson(['code' => 404, 'msg' => _t('_sys_txt_error_occured'), 'messages' => []]);
+            return;
+        }
+
+        $sThreadId = trim((string)bx_get('thread_id'));
+        $oAi = BxDolAI::getInstance();
+        if (!$oAi || !method_exists($oAi, 'getChatHistoryUiMessagesByThread')) {
+            echoJson(['code' => 503, 'msg' => _t('_sys_txt_error_occured'), 'messages' => []]);
+            return;
+        }
+
+        $aMessages = $oAi->getChatHistoryUiMessagesByThread($aAgent, $sThreadId);
+        if ($aMessages === false) {
+            echoJson(['code' => 404, 'msg' => _t('_sys_txt_error_occured'), 'messages' => []]);
+            return;
+        }
+
+        echoJson([
+            'code' => 200,
+            'messages' => $aMessages,
+            'writable' => $oAi->isOwnAgentChatThread($aAgent, $sThreadId) ? 1 : 0,
+            'artifacts' => method_exists($oAi, 'getChatHistoryArtifactsByThread') ? $oAi->getChatHistoryArtifactsByThread($aAgent, $sThreadId) : [],
+        ]);
+    }
+
+    protected function _agentChatThreadJs($aThread)
+    {
+        $aArtifacts = [];
+        if (!empty($aThread['artifacts']) && is_array($aThread['artifacts'])) {
+            foreach ($aThread['artifacts'] as $aItem) {
+                if (!is_array($aItem))
+                    continue;
+                $aArtifacts[] = [
+                    'field_name' => (string)($aItem['field_name'] ?? ''),
+                    'field_value' => (string)($aItem['field_value'] ?? ''),
+                ];
+            }
+        }
+
+        return [
+            'thread_id' => (string)$aThread['thread_id'],
+            'writable' => (int)$aThread['writable'],
+            'context_pid' => (int)$aThread['context_pid'],
+            'title' => (string)$aThread['title'],
+            'meta' => (string)$aThread['meta'],
+            'status' => (string)($aThread['status'] ?? 'opened'),
+            'created_at' => (string)($aThread['created_at'] ?? ''),
+            'updated_at' => (string)($aThread['updated_at'] ?? ''),
+            'artifacts' => $aArtifacts,
+        ];
+    }
+
     protected function _getCellSwitcher ($mixedValue, $sKey, $aField, $aRow)
     {
         // if(empty($aRow['code']) || $aRow['status'] != BX_DOL_AI_AUTOMATOR_STATUS_READY)
@@ -299,7 +404,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
     {
         $sIcon = '';
         if($mixedValue) {
-            list($sIcon, $sIconUrl, $sIconA, $sIconHtml) = $this->_oTemplate->getTemplateFunctions()->getIcon($mixedValue, ['class' => 'sys-colored']);
+            list($sIcon, $sIconUrl, $sIconA, $sIconHtml, $sIconFontWithHtml) = $this->_oTemplate->getTemplateFunctions()->getIcon($mixedValue, ['class' => 'sys-colored']);
 
             if($sIcon)
                 $sIcon = $this->_oTemplate->parseIcon(BxDolIconset::getObjectInstance()->getIcon($sIcon));
@@ -307,6 +412,8 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
                 $sIcon = $sIconHtml;
             else if($sIconUrl)
                 $sIcon = '<img src="' . bx_html_attribute($sIconUrl) . '" />';
+            else if($sIconFontWithHtml)
+                $sIcon = $sIconFontWithHtml;
         }
 
         if (!$sIcon)
@@ -444,7 +551,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
                     'caption' => _t('_sys_agents_field_model_id'),
                     'info' => _t('_sys_agents_field_model_id_info'),
                     'value' => isset($aAgent['model_id']) ? $aAgent['model_id'] : BxDolAI::getDefaultModel(),
-                    'values' => $this->_oDb->getModelsBy(['sample' => 'all_pairs', 'active' => 1, 'capabilities' => ['chatvlm', 'chatllm']]),
+                    'values' => $this->_getModelSelectValues(),
                     'required' => '1',
                     'checker' => [
                         'func' => 'Avail',
@@ -460,7 +567,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
                     'caption' => _t('_sys_agents_field_profile_id'),
                     'info' => _t('_sys_agents_field_profile_id_inf'),
                     'value' => isset($aAgent['profile_id']) ? $aAgent['profile_id'] : 0,
-                    'values' => bx_srv('system', 'get_options_agents_profile', [], 'TemplServices'),
+                    'values' => $this->_getProfileSelectValues(),
                     'required' => '1',
                     'required' => '1',
                     'checker' => [
@@ -517,13 +624,12 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
                     ]
                 ],
 
-                'alert_section' => array(
+                'alert_section' => [
                     'type' => 'block_header',
-                    'caption' => 'Trigger - alert',
+                    'caption' => _t('_sys_agents_block_header_alert'),
                     'collapsable' => true,
                     'collapsed' => true,
-                ),
-
+                ],
                 'alert_sample' => [
                     'type' => 'custom',
                     'name' => 'alert_sample',
@@ -537,7 +643,6 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
                         'payload' => !empty($aAgent['alert']) ? json_encode($this->_getAlertPayload($aAgent['alert']), JSON_PRETTY_PRINT) : '',
                     ]),
                 ],
-
                 'alert' => [
                     'type' => 'custom',
                     'name' => 'alert',
@@ -548,14 +653,16 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
                         'pass' => 'Xss',
                     ],
                 ],
+                'alert_section_end' => [
+                    'type' => 'block_end',
+                ],
 
                 'scheduler_section' => [
                     'type' => 'block_header',
-                    'caption' => 'Trigger - scheduler',
+                    'caption' => _t('_sys_agents_block_header_scheduler'),
                     'collapsable' => true,
                     'collapsed' => true,
                 ],
-
                 'scheduler_cron' => [
                     'type' => 'text',
                     'name' => 'scheduler_cron',
@@ -566,14 +673,16 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
                         'pass' => 'Xss',
                     ],
                 ],
+                'scheduler_section_end' => [
+                    'type' => 'block_end',
+                ],
 
-                'webhook_section' => array(
+                'webhook_section' => [
                     'type' => 'block_header',
-                    'caption' => 'Trigger - webhook',
+                    'caption' => _t('_sys_agents_block_header_webhook'),
                     'collapsable' => true,
                     'collapsed' => true,
-                ),
-
+                ],
                 'webhook_sample' => [
                     'type' => 'custom',
                     'name' => 'webhook_sample',
@@ -590,32 +699,37 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
                         'pass' => 'Xss',
                     ],
                 ],
+                'webhook_section_end' => [
+                    'type' => 'block_end',
+                ],
 
-                'message_section' => array(
+                'message_section' => [
                     'type' => 'block_header',
-                    'caption' => 'Trigger - message',
+                    'caption' => _t('_sys_agents_block_header_message'),
                     'collapsable' => true,
                     'collapsed' => true,
-                ),
-
+                ],
                 'message_profile_id' => [
                     'type' => 'select',
                     'name' => 'message_profile_id',
                     'caption' => _t('_sys_agents_field_message_profile'),
                     'info' => _t('_sys_agents_field_message_profile_info'),
                     'value' => isset($aAgent['message_profile_id']) ? $aAgent['message_profile_id'] : '',
-                    'values' => bx_srv('system', 'get_options_agents_profile', [true, '_adm_nav_txt_items_visible_for_all'], 'TemplServices'),
+                    'values' => $this->_getProfileSelectValues(true, '_adm_nav_txt_items_visible_for_all'),
                     'db' => [
                         'pass' => 'Xss',
                     ],
                 ],
+                'message_section_end' => [
+                    'type' => 'block_end',
+                ],
 
-                'form_field_section' => array(
+                'form_field_section' => [
                     'type' => 'block_header',
-                    'caption' => 'Trigger - form field',
+                    'caption' => _t('_sys_agents_block_header_form_input'),
                     'collapsable' => true,
                     'collapsed' => true,
-                ),
+                ],
 
                 'form_object' => [
                     'type' => 'custom',
@@ -638,11 +752,107 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
                         'pass' => 'Xss',
                     ],
                 ],
-
                 'form_field_section_end' => array(
                     'type' => 'block_end',
                 ),
 
+                'manual_section' => [
+                    'type' => 'block_header',
+                    'caption' => _t('_sys_agents_block_header_manual'),
+                    'collapsable' => true,
+                    'collapsed' => true,
+                ],
+                'max_turns' => [
+                    'type' => 'text',
+                    'name' => 'max_turns',
+                    'caption' => _t('_sys_agents_field_manual_max_turns'),
+                    'info' => _t('_sys_agents_field_manual_max_turns_info'),
+                    'value' => isset($aAgent['max_turns']) ? $aAgent['max_turns'] : '40',
+                    'required' => '1',
+                    'db' => [
+                        'pass' => 'Int',
+                    ],
+                ],
+                'chat_ttl_min' => [
+                    'type' => 'text',
+                    'name' => 'chat_ttl_min',
+                    'caption' => _t('_sys_agents_field_manual_chat_ttl_min'),
+                    'info' => _t('_sys_agents_field_manual_chat_ttl_min_info'),
+                    'value' => isset($aAgent['chat_ttl_min']) ? $aAgent['chat_ttl_min'] : '0',
+                    'required' => '1',
+                    'db' => [
+                        'pass' => 'Int',
+                    ],
+                ],
+                'limit_message' => [
+                    'type' => 'textarea',
+                    'name' => 'limit_message',
+                    'caption' => _t('_sys_agents_field_manual_limit_message'),
+                    'info' => _t('_sys_agents_field_manual_limit_message_info'),
+                    'value' => isset($aAgent['limit_message']) ? $aAgent['limit_message'] : '',
+                    'db' => [
+                        'pass' => 'Xss',
+                    ],
+                ],
+                'max_input_chars' => [
+                    'type' => 'text',
+                    'name' => 'max_input_chars',
+                    'caption' => _t('_sys_agents_field_manual_max_input_chars'),
+                    'info' => _t('_sys_agents_field_manual_max_input_chars_info'),
+                    'value' => isset($aAgent['max_input_chars']) ? $aAgent['max_input_chars'] : '2000',
+                    'required' => '1',
+                    'db' => [
+                        'pass' => 'Int',
+                    ],
+                ],
+                'max_tokens' => [
+                    'type' => 'text',
+                    'name' => 'max_tokens',
+                    'caption' => _t('_sys_agents_field_manual_max_tokens'),
+                    'info' => _t('_sys_agents_field_manual_max_tokens_info'),
+                    'value' => isset($aAgent['max_tokens']) ? $aAgent['max_tokens'] : '1024',
+                    'required' => '1',
+                    'db' => [
+                        'pass' => 'Int',
+                    ],
+                ],
+                'max_sessions_per_hour' => [
+                    'type' => 'text',
+                    'name' => 'max_sessions_per_hour',
+                    'caption' => _t('_sys_agents_field_manual_max_sessions_per_hour'),
+                    'info' => _t('_sys_agents_field_manual_max_sessions_per_hour_info'),
+                    'value' => isset($aAgent['max_sessions_per_hour']) ? $aAgent['max_sessions_per_hour'] : '3',
+                    'required' => '1',
+                    'db' => [
+                        'pass' => 'Int',
+                    ],
+                ],
+                'max_sessions_per_day' => [
+                    'type' => 'text',
+                    'name' => 'max_sessions_per_day',
+                    'caption' => _t('_sys_agents_field_manual_max_sessions_per_day'),
+                    'info' => _t('_sys_agents_field_manual_max_sessions_per_day_info'),
+                    'value' => isset($aAgent['max_sessions_per_day']) ? $aAgent['max_sessions_per_day'] : '10',
+                    'required' => '1',
+                    'db' => [
+                        'pass' => 'Int',
+                    ],
+                ],
+                'hidden_first_message' => [
+                    'type' => 'text',
+                    'name' => 'hidden_first_message',
+                    'caption' => _t('_sys_agents_field_manual_hidden_first_message'),
+                    'info' => _t('_sys_agents_field_manual_hidden_first_message_info'),
+                    'value' => isset($aAgent['hidden_first_message']) ? $aAgent['hidden_first_message'] : '',
+                    'attrs' => ['maxlength' => 64],
+                    'db' => [
+                        'pass' => 'Xss',
+                    ],
+                ],
+                'manual_section_end' => [
+                    'type' => 'block_end',
+                ],
+
                 'vector_store_id' => [
                     'type' => 'select',
                     'name' => 'vector_store_id',
@@ -1030,7 +1240,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
 
     protected function _getAgentWithProfile($iProfileId, $iExcludeAgentId = 0)
     {
-        $aAgents = BxDolAi::getInstance()->getAgentsByProfileId($iProfileId);
+        $aAgents = BxDolAI::getInstance()->getAgentsByProfileId($iProfileId);
         foreach($aAgents as $aAgent) {
             if($aAgent['id'] != $iExcludeAgentId)
                 return $aAgent;
@@ -1039,6 +1249,27 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
         return null;
     }
 
+    protected function _getModelSelectValues()
+    {
+        $aParams = ['sample' => 'all_pairs', 'active' => 1];
+
+        $aModels = $this->_oDb->getModelsBy(array_merge($aParams, ['capabilities' => ['chatvlm', 'chatllm']]));
+        if(!$aModels || !is_array($aModels))
+            $aModels = $this->_oDb->getModelsBy($aParams);
+
+        return ($aModels && is_array($aModels)) ? $aModels : [];
+    }
+
+    protected function _getProfileSelectValues($bSelectOne = true, $sSelectOneLangKey = '_Select_one')
+    {
+        $aProfiles = bx_srv('system', 'get_options_agents_profile', [$bSelectOne, $sSelectOneLangKey], 'TemplServices');
+        if($aProfiles && is_array($aProfiles))
+            return $aProfiles;
+
+        $aProfiles = bx_srv('system', 'get_options_profile_bot', [], 'TemplServices');
+        return ($aProfiles && is_array($aProfiles)) ? $aProfiles : [];
+    }
+
     protected function _addJsCss()
     {
         parent::_addJsCss();

+ 84 - 28
template/scripts/BxBaseServices.php

@@ -879,15 +879,26 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
         else
             $iContextPid = $oAi ? $oAi->filterContextPid((int)$mixedContextPid, $bRequirePost) : 0;
 
-        $aBlock = [
+        if ($bIsApi)
+            return [bx_api_get_block('ai_agent', [
             'agent_id' => $iId,
             'agent_profile' => BxDolProfile::getData($aAgent['profile_id']),
             'context_profile_id' => $iContextPid,
-        ];
+            'hidden_first_message' => (string)($aAgent['hidden_first_message'] ?? ''),
+            ])];
 
-        return $bIsApi ? [bx_api_get_block('ai_agent', $aBlock)] : BxDolTemplate::getInstance()->parseHtmlByName('agent_ai_chat.html', [
-            'id' => $iId,
-            'context_profile_id' => $iContextPid,
+        $sEndpoint = BX_DOL_URL_ROOT . 'sys-ai-chat.php?id=' . (int)$iId;
+        if ($iContextPid > 0)
+            $sEndpoint .= '&context=' . $iContextPid;
+
+        $oTemplate = BxDolTemplate::getInstance();
+        $oTemplate->addJs([BX_DOL_URL_ROOT . 'plugins_public/bundle.js']);
+
+        return $oTemplate->parseHtmlByName('agent_ai_chat.html', [
+            'id' => (int)$iId,
+            'context_profile_id' => (int)$iContextPid,
+            'endpoint_js' => json_encode($sEndpoint),
+            'bundle_url' => BX_DOL_URL_ROOT . 'plugins_public/bundle.js',
         ]);
     }
 
@@ -1831,45 +1842,90 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
 
     public function serviceAiChat($iAgentId)
     {
-        $sMethod = $_SERVER['REQUEST_METHOD'] ?? '';
-        if ('GET' !== $sMethod && 'POST' !== $sMethod)
-            return echoJson(['code' => 405, 'msg' => _t('_error occured')]);
+        $sMethod = isset($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '';
+        if('GET' === $sMethod)
+            return $this->_aiChatHydrate($iAgentId);
 
-        if ('GET' === $sMethod && false !== bx_get('offset'))
+        if('POST' !== $sMethod)
             return echoJson(['code' => 405, 'msg' => _t('_error occured')]);
 
         $aAgent = $this->_aiChatLoadAgent($iAgentId);
-        if (!$aAgent)
+        if(!$aAgent)
             return echoJson(['code' => (int)$iAgentId ? 404 : 400, 'msg' => _t('_sys_agents_agent_not_found')]);
 
         $oAi = BxDolAI::getInstance();
-        if (!$oAi)
+        if(!$oAi)
             return echoJson(['code' => 503, 'msg' => _t('_sys_agents_exception')]);
 
-        if (!$oAi->canChatDirectly($aAgent))
-            return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
-
-        $aParams = $this->_aiChatHistoryParams($aAgent);
-        if (!$aParams)
+        if(!$oAi->canChatDirectly($aAgent))
             return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized')]);
 
-        if ('GET' === $sMethod)
-            return echoJson([
-                'messages' => $oAi->getChatHistoryUiMessages($aAgent['id'], $aParams),
-                'activeRun' => null,
-                'interrupts' => null,
-            ]);
-
         $sJson = file_get_contents('php://input');
         $aData = json_decode($sJson, true);
-        if (json_last_error() !== JSON_ERROR_NONE)
+        if(json_last_error() !== JSON_ERROR_NONE)
             return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
 
         $sPrompt = $oAi->extractChatPromptFromRequest($aData);
-        if (!$sPrompt)
+        if(!$sPrompt)
             return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
 
-        $oAi->streamAgentChat($aAgent['id'], $sPrompt, $aParams, $aData['threadId'] ?? null);
+        $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(method_exists($oAi, 'streamAgentChat') && 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 = method_exists($oAi, 'getChatHistoryUiMessages')
+                ? $oAi->getChatHistoryUiMessages($aAgent['id'], $aParams)
+                : [];
+        } catch (Throwable $o) {
+            $aMessages = [];
+        }
+
+        if(!is_array($aMessages))
+            $aMessages = [];
+
+        return echoJson([
+            'messages' => $aMessages,
+            'activeRun' => null,
+            'interrupts' => null,
+        ]);
     }
 
     /**
@@ -1894,8 +1950,8 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
         if (!$iAgentId)
             return false;
 
-        $aAgent = BxDolAiQuery::getAgentObject($iAgentId);
-        if (!$aAgent || !$aAgent['active'])
+        $aAgent = BxDolAIQuery::getAgentObject($iAgentId);
+        if(!$aAgent || empty($aAgent['active']))
             return false;
 
         return $aAgent;

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.