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

Merge pull request #5695 from unacms/agents-improvements-1509

AI agents improvements: chat threads, images, buttons, thread titles, safe MySQL tools, operator agent
Aleksandr Trofimov 3 недель назад
Родитель
Сommit
dffec248b8

+ 54 - 0
inc/classes/BxDolAIToolChatButtons.php

@@ -0,0 +1,54 @@
+<?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;
+use NeuronAI\Tools\ArrayProperty;
+use NeuronAI\Tools\ObjectProperty;
+
+class BxDolAIToolChatButtons extends BxDolAITool
+{
+    public function __construct()
+    {
+        parent::__construct(
+            'chat_buttons',
+            'Show clickable buttons under THIS assistant reply: Yes/No, suggested names, register/support links. Call once per turn, after the sentence the user should confirm. Do not write another assistant message after this tool. Do not say that buttons are ready.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ArrayProperty(
+                name: 'buttons',
+                description: 'Buttons under this reply. reply = visitor tap sends that label as the next message; link = an https URL (site pages, help, registration). Example: [{"type":"reply","label":"Yes"},{"type":"reply","label":"No"}]',
+                required: true,
+                items: new ObjectProperty(
+                    name: 'button',
+                    properties: [
+                        new ToolProperty('type', PropertyType::STRING, 'reply or link', true),
+                        new ToolProperty('label', PropertyType::STRING, 'Button text shown to the visitor', true),
+                        new ToolProperty('url', PropertyType::STRING, 'Required for link. https URL.', false),
+                    ]
+                ),
+                minItems: 1,
+                maxItems: 8
+            ),
+        ];
+    }
+
+    public function __invoke($buttons): string
+    {
+        $i = BxDolAiChat::getInstance()->setPendingChatActions(is_array($buttons) ? $buttons : []);
+
+        return $i > 0
+            ? 'Buttons are already under your last sentence. Do not send another assistant message this turn. Wait for the user to tap a button.'
+            : 'ignored';
+    }
+}

+ 81 - 0
inc/classes/BxDolAIToolChatTitle.php

@@ -0,0 +1,81 @@
+<?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;
+
+/**
+ * `chat_title` tool: the agent names the conversation it is in, once, as part of
+ * its own reply — no second model request, no settings. Goes into
+ * `sys_agents_chat_history`.`title` for the current thread (BxDolAiChat::setCurrentChatThreadTitle)
+ * and shows up in the "Chats" history panel and the Studio chat popup.
+ *
+ * Without the tool (or when the agent never calls it) the lists fall back to the
+ * first thing the person typed.
+ */
+class BxDolAIToolChatTitle extends BxDolAITool
+{
+    const MAX_WORDS = 6;
+    const MAX_CHARS = 80;
+
+    public function __construct()
+    {
+        parent::__construct(
+            'chat_title',
+            'Name THIS conversation: 3 to 6 words saying what it is about, in the language the user writes in. Call it once, in your first reply after the user\'s first real message (not after "[page opened…"). Do not mention the title to the user.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty('title', PropertyType::STRING, 'Conversation title, 3-6 words, no quotes, no trailing period. Example: "Счётчик комментариев поста 291"', true),
+        ];
+    }
+
+    public function __invoke($title): string
+    {
+        $sTitle = self::clean($title);
+        if ($sTitle === '')
+            return 'ignored: empty title';
+
+        $iSet = BxDolAiChat::getInstance()->setCurrentChatThreadTitle($sTitle);
+        if ($iSet === 0)
+            return 'This conversation already has a title. Continue your reply.';
+
+        return $iSet > 0
+            ? 'Title saved. Continue your reply without mentioning it.'
+            : 'ignored';
+    }
+
+    /**
+     * One line, quotes and trailing punctuation gone, at most MAX_WORDS words and
+     * MAX_CHARS characters.
+     */
+    public static function clean($mixed)
+    {
+        $s = strip_tags(is_string($mixed) ? $mixed : '');
+        $s = preg_split('/\r\n|\r|\n/', trim($s))[0] ?? '';
+        $s = trim($s, " \t\"'«»“”‘’`");
+        $s = preg_replace('/\s+/u', ' ', $s);
+        $s = rtrim($s, " .!。");
+        if ($s === '')
+            return '';
+
+        $aWords = explode(' ', $s);
+        if (count($aWords) > self::MAX_WORDS)
+            $s = implode(' ', array_slice($aWords, 0, self::MAX_WORDS));
+        if (get_mb_len($s) > self::MAX_CHARS)
+            $s = rtrim(get_mb_substr($s, 0, self::MAX_CHARS - 1)) . '…';
+
+        return $s;
+    }
+}
+
+/** @} */

+ 2 - 2
inc/classes/BxDolAIToolFactory.php

@@ -66,8 +66,8 @@ class BxDolAIToolFactory extends BxDolFactory
 
             default:
                 if (!empty($a['class_name'])) {
-                    if (!empty($aObject['class_file']))
-                        require_once(BX_DIRECTORY_PATH_ROOT . $aObject['class_file']);
+                    if (!empty($a['class_file']))
+                        require_once(BX_DIRECTORY_PATH_ROOT . $a['class_file']);
                     $o = $a['class_name']::make();
                 }
                 else {

+ 199 - 0
inc/classes/BxDolAIToolMysqlUndo.php

@@ -0,0 +1,199 @@
+<?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 BxDolAIToolMysqlUndo extends BxDolAITool
+{
+    public function __construct()
+    {
+        parent::__construct(
+            'mysql_undo',
+            'Roll back mysql_write changes from sys_agents_sql_log. scope=last undoes the latest snapshot in this chat; scope=session undoes all remaining snapshots in reverse order. INSERT is undone by deleting that PK internally. UPDATE restores the before snapshot. Do not use mysql_write DELETE.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty(
+                name: 'scope',
+                type: PropertyType::STRING,
+                description: 'last = one newest change in this chat. session = all not-yet-undone changes in this chat, newest first.',
+                required: true
+            ),
+            new ToolProperty(
+                name: 'log_id',
+                type: PropertyType::NUMBER,
+                description: 'Optional sys_agents_sql_log.id. If set, undo that snapshot only (must belong to this chat).',
+                required: false
+            ),
+        ];
+    }
+
+    public function __invoke(string $scope = 'last', $log_id = 0): array
+    {
+        $oDb = BxDolDb::getInstance();
+
+        $sScope = strtolower(trim($scope));
+        if (!in_array($sScope, ['last', 'session'], true))
+            throw new Exception('scope must be last or session.');
+
+        $iLogId = (int)$log_id;
+        $aCtx = BxDolAIToolMysqlWrite::currentLogContext();
+        $sThread = $aCtx['thread_id'];
+        $iProfile = (int)$aCtx['profile_id'];
+
+        $aRows = $this->_pickRows($oDb, $sScope, $iLogId, $sThread, $iProfile);
+        if (!$aRows)
+            throw new Exception('Nothing to undo in this chat.');
+
+        $aDone = [];
+        foreach ($aRows as $aRow) {
+            $aDone[] = $this->_undoRow($oDb, $aRow);
+        }
+
+        return [
+            'ok' => 1,
+            'scope' => $iLogId > 0 ? 'log_id' : $sScope,
+            'undone' => count($aDone),
+            'items' => $aDone,
+        ];
+    }
+
+    protected function _pickRows(BxDolDb $oDb, string $sScope, int $iLogId, string $sThread, int $iProfile): array
+    {
+        if ($iLogId > 0) {
+            $aRow = $oDb->getRow("SELECT * FROM `sys_agents_sql_log` WHERE `id` = :id LIMIT 1", ['id' => $iLogId]);
+            if (!$aRow)
+                throw new Exception("log_id {$iLogId} not found.");
+            if ((int)$aRow['undone'] > 0)
+                throw new Exception("log_id {$iLogId} is already undone.");
+            if ($sThread !== '') {
+                if ((string)$aRow['thread_id'] !== $sThread)
+                    throw new Exception("log_id {$iLogId} belongs to another chat.");
+            }
+            else if ($iProfile <= 0 || (int)$aRow['profile_id'] !== $iProfile) {
+                throw new Exception("log_id {$iLogId} belongs to another chat.");
+            }
+            return [$aRow];
+        }
+
+        if ($sThread !== '') {
+            $sSql = "SELECT * FROM `sys_agents_sql_log` WHERE `thread_id` = :t AND `undone` = 0 ORDER BY `id` DESC";
+            $aBind = ['t' => $sThread];
+        }
+        else if ($iProfile > 0) {
+            $sSql = "SELECT * FROM `sys_agents_sql_log` WHERE `profile_id` = :p AND `undone` = 0 AND `added` > :since ORDER BY `id` DESC";
+            $aBind = ['p' => $iProfile, 'since' => time() - 7200];
+        }
+        else {
+            return [];
+        }
+
+        if ($sScope === 'last')
+            $sSql .= " LIMIT 1";
+        else
+            $sSql .= " LIMIT 40";
+
+        $aRows = $oDb->getAll($sSql, $aBind);
+        return is_array($aRows) ? $aRows : [];
+    }
+
+    protected function _undoRow(BxDolDb $oDb, array $aRow): array
+    {
+        $sOp = strtolower((string)$aRow['op']);
+        $sTable = str_replace('`', '', (string)$aRow['table_name']);
+        $sPk = str_replace('`', '', (string)$aRow['pk_name']);
+        $sPkValue = (string)$aRow['pk_value'];
+
+        if ($sTable === '' || $sPk === '' || $sPkValue === '')
+            throw new Exception("log_id {$aRow['id']} has incomplete snapshot.");
+        if (!$oDb->isValidFieldName($sTable) || !$oDb->isValidFieldName($sPk))
+            throw new Exception("log_id {$aRow['id']} has invalid table/pk.");
+
+        if ($sOp === 'insert')
+            $this->_undoInsert($oDb, $sTable, $sPk, $sPkValue);
+        else if ($sOp === 'update')
+            $this->_undoUpdate($oDb, $aRow, $sTable, $sPk, $sPkValue);
+        else
+            throw new Exception("Cannot undo op {$sOp}.");
+
+        $oDb->query("UPDATE `sys_agents_sql_log` SET `undone` = :ts WHERE `id` = :id", [
+            'ts' => time(),
+            'id' => (int)$aRow['id'],
+        ]);
+
+        return [
+            'log_id' => (int)$aRow['id'],
+            'op' => $sOp,
+            'table' => $sTable,
+            'pk' => $sPk,
+            'pk_value' => $sPkValue,
+        ];
+    }
+
+    protected function _undoInsert(BxDolDb $oDb, string $sTable, string $sPk, string $sPkValue): void
+    {
+        $aBind = ['v' => preg_match('/^\d+$/', $sPkValue) ? (int)$sPkValue : $sPkValue];
+        $oDb->query("DELETE FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", $aBind);
+    }
+
+    protected function _undoUpdate(BxDolDb $oDb, array $aRow, string $sTable, string $sPk, string $sPkValue): void
+    {
+        $aBefore = json_decode((string)$aRow['before_json'], true);
+        if (!is_array($aBefore) || !$aBefore)
+            throw new Exception("log_id {$aRow['id']} has no before snapshot.");
+
+        $aCurrent = $this->_fetchRow($oDb, $sTable, $sPk, $sPkValue);
+        if (!$aCurrent)
+            throw new Exception("Row {$sTable}.{$sPk}={$sPkValue} is gone; cannot restore.");
+
+        $aAfter = json_decode((string)($aRow['after_json'] ?? ''), true);
+        if (is_array($aAfter) && $aAfter && !$this->_rowsClose($aCurrent, $aAfter) && !$this->_rowsClose($aCurrent, $aBefore))
+            throw new Exception("Row {$sTable}.{$sPk}={$sPkValue} changed after the write; refuse undo.");
+
+        $aSet = [];
+        foreach ($aBefore as $sField => $mixedVal) {
+            if (!$oDb->isValidFieldName($sField))
+                throw new Exception("Invalid field {$sField} in snapshot.");
+            if ($sField === $sPk)
+                continue;
+            $aSet[$sField] = $mixedVal;
+        }
+        if (!$aSet)
+            throw new Exception("log_id {$aRow['id']} snapshot has nothing to restore.");
+
+        $sSet = '';
+        foreach ($aSet as $sField => $mixedVal) {
+            $sSet .= ($sSet === '' ? '' : ', ') . '`' . $sField . '` = ' . ($mixedVal === null ? 'NULL' : $oDb->escape($mixedVal));
+        }
+
+        $sPkSql = preg_match('/^\d+$/', $sPkValue) ? (int)$sPkValue : $oDb->escape($sPkValue);
+        $oDb->query("UPDATE `{$sTable}` SET {$sSet} WHERE `{$sPk}` = {$sPkSql} LIMIT 1");
+    }
+
+    protected function _fetchRow(BxDolDb $oDb, string $sTable, string $sPk, string $sPkValue): array|false
+    {
+        $aBind = ['v' => preg_match('/^\d+$/', $sPkValue) ? (int)$sPkValue : $sPkValue];
+        return $oDb->getRow("SELECT * FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", $aBind);
+    }
+
+    protected function _rowsClose(array $aA, array $aB): bool
+    {
+        foreach ($aB as $sKey => $mixedB) {
+            if (!array_key_exists($sKey, $aA))
+                return false;
+            if ((string)($aA[$sKey] ?? '') !== (string)($mixedB ?? ''))
+                return false;
+        }
+        return true;
+    }
+}

+ 257 - 0
inc/classes/BxDolAIToolMysqlWrite.php

@@ -0,0 +1,257 @@
+<?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 BxDolAIToolMysqlWrite extends BxDolAITool
+{
+    protected $_aDenyTables = [
+        'sys_accounts',
+        'sys_profiles',
+        'sys_sessions',
+        'sys_cron_jobs',
+        'sys_modules',
+        'sys_agents_sql_log',
+        'sys_agents_chat_history',
+        'sys_agents_tools',
+        'sys_agents_agents',
+        'sys_agents_prompt_feedback',
+    ];
+
+    public function __construct()
+    {
+        parent::__construct(
+            'mysql_write',
+            'INSERT or UPDATE one row. DELETE/DROP/TRUNCATE/ALTER are forbidden. UPDATE WHERE must identify exactly one row (prefer primary key: WHERE id = N). Snapshots are stored automatically. Prefer mysql_schema and mysql_select first.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty(
+                name: 'query',
+                type: PropertyType::STRING,
+                description: 'Single INSERT or UPDATE. UPDATE needs WHERE that matches one row, e.g. WHERE `id` = 12. Table.column and extra AND conditions are ok. No DELETE. No multiple statements.',
+                required: true
+            ),
+        ];
+    }
+
+    public function __invoke(string $query): array
+    {
+        $oDb = BxDolDb::getInstance();
+        $sSql = $this->_normalize($query);
+        $sBare = $this->_stripLiterals($sSql);
+
+        $this->_assertSafe($sBare);
+
+        if (preg_match('/^insert\b/i', $sSql))
+            $sOp = 'insert';
+        else if (preg_match('/^update\b/i', $sSql))
+            $sOp = 'update';
+        else
+            throw new Exception('Only INSERT or UPDATE is allowed.');
+
+        $sTable = $this->_tableName($sSql, $sOp);
+        $this->_assertTable($sTable);
+
+        $sPk = $this->_primaryKey($oDb, $sTable);
+
+        $sBefore = null;
+        $sPkValue = '';
+        if ($sOp === 'update') {
+            $sPkValue = $this->_wherePkValue($sSql, $sPk, $oDb, $sTable);
+            $aBefore = $this->_fetchRow($oDb, $sTable, $sPk, $sPkValue);
+            if (!$aBefore)
+                throw new Exception("Row not found: {$sTable}.{$sPk} = {$sPkValue}");
+            $sBefore = json_encode($aBefore, JSON_UNESCAPED_UNICODE);
+        }
+
+        $mixedRes = $oDb->query($sSql);
+        if ($mixedRes === false)
+            throw new Exception('Query failed.');
+
+        if ($sOp === 'insert') {
+            $sPkValue = (string)$oDb->lastId();
+            if ($sPkValue === '' || $sPkValue === '0')
+                $sPkValue = $this->_insertedPkFromSql($sSql, $sPk);
+            if ($sPkValue === '')
+                throw new Exception('INSERT succeeded but primary key is unknown.');
+        }
+
+        $aAfter = $this->_fetchRow($oDb, $sTable, $sPk, $sPkValue);
+        $iLogId = $this->_log($sOp, $sTable, $sPk, $sPkValue, $sSql, $sBefore, $aAfter ? json_encode($aAfter, JSON_UNESCAPED_UNICODE) : null);
+
+        return [
+            'ok' => 1,
+            'op' => $sOp,
+            'table' => $sTable,
+            'pk' => $sPk,
+            'pk_value' => $sPkValue,
+            'log_id' => $iLogId,
+            'affected' => is_numeric($mixedRes) ? (int)$mixedRes : 1,
+        ];
+    }
+
+    protected function _normalize(string $s): string
+    {
+        $s = preg_replace('~/\*.*?\*/~s', ' ', $s);
+        $s = preg_replace('/--[^\n]*/', ' ', $s);
+        $s = preg_replace('/#[^\n]*/', ' ', $s);
+        $s = trim($s);
+        $s = rtrim($s, "; \t\n\r");
+        if ($s === '')
+            throw new Exception('Empty query.');
+        if (strpos($s, ';') !== false)
+            throw new Exception('Multiple statements are not allowed.');
+        return $s;
+    }
+
+    protected function _stripLiterals(string $s): string
+    {
+        $s = preg_replace("/'([^'\\\\]|\\\\.)*'/s", "''", $s);
+        $s = preg_replace('/"([^"\\\\]|\\\\.)*"/s', '""', $s);
+        return $s;
+    }
+
+    protected function _assertSafe(string $sBare): void
+    {
+        if (preg_match('/\b(delete|drop|truncate|alter|replace|create|grant|revoke|call|handler|load|outfile|dumpfile|into\s+outfile|lock\s+tables|unlock\s+tables)\b/i', $sBare))
+            throw new Exception('Forbidden SQL keyword.');
+        if (preg_match('/\bon\s+duplicate\s+key\b/i', $sBare))
+            throw new Exception('ON DUPLICATE KEY is not allowed.');
+        if (preg_match('/^insert\b/i', $sBare) && preg_match('/\bselect\b/i', $sBare))
+            throw new Exception('INSERT ... SELECT is not allowed.');
+    }
+
+    protected function _tableName(string $sSql, string $sOp): string
+    {
+        if ($sOp === 'insert' && preg_match('/^insert\s+into\s+`?([a-zA-Z0-9_]+)`?/i', $sSql, $aM))
+            return $aM[1];
+        if ($sOp === 'update' && preg_match('/^update\s+(?:low_priority\s+|ignore\s+)*`?([a-zA-Z0-9_]+)`?/i', $sSql, $aM))
+            return $aM[1];
+        throw new Exception('Cannot parse table name.');
+    }
+
+    protected function _assertTable(string $sTable): void
+    {
+        $sTable = strtolower($sTable);
+        if (in_array($sTable, $this->_aDenyTables, true))
+            throw new Exception("Table {$sTable} is not writable by this tool.");
+        if (preg_match('/^(mysql|information_schema|performance_schema|sys)\./', $sTable))
+            throw new Exception("Table {$sTable} is not writable by this tool.");
+    }
+
+    protected function _primaryKey(BxDolDb $oDb, string $sTable): string
+    {
+        $aKeys = $oDb->getAll("SHOW KEYS FROM `" . str_replace('`', '', $sTable) . "` WHERE `Key_name` = 'PRIMARY'");
+        if (!$aKeys)
+            throw new Exception("Table {$sTable} has no PRIMARY KEY.");
+        if (count($aKeys) > 1)
+            throw new Exception("Composite primary keys are not supported.");
+        return $aKeys[0]['Column_name'];
+    }
+
+    protected function _wherePkValue(string $sSql, string $sPk, BxDolDb $oDb, string $sTable): string
+    {
+        if (!preg_match('/\bwhere\b(.+)$/is', $sSql, $aM))
+            throw new Exception('UPDATE requires WHERE primary_key = value.');
+
+        $sWhere = trim($aM[1]);
+        $sWhere = preg_replace('/\s+(limit|order\s+by|offset)\b.*$/is', '', $sWhere);
+        $sWhere = trim($sWhere, " \t\n\r;");
+        if ($sWhere === '')
+            throw new Exception('UPDATE requires WHERE primary_key = value.');
+
+        $sWhereBare = $this->_stripLiterals($sWhere);
+        if (preg_match('/\b(or|in\s*\(|between|like|exists|select)\b/i', $sWhereBare))
+            throw new Exception('UPDATE WHERE must be a simple equality (no OR / IN / LIKE).');
+
+        $sPkRe = preg_quote($sPk, '/');
+        $sTableRe = preg_quote($sTable, '/');
+        $sIdent = '(?:`?' . $sTableRe . '`?\s*\.\s*)?`?' . $sPkRe . '`?';
+        if (preg_match('/(?:^|\band\s+)\s*\(?\s*' . $sIdent . '\s*=\s*(\d+|\'[^\']*\'|"[^"]*")/i', $sWhere, $aV))
+            return trim($aV[1], "\"'");
+
+        $sTableSafe = str_replace('`', '', $sTable);
+        $sPkSafe = str_replace('`', '', $sPk);
+        $aRows = $oDb->getAll("SELECT `{$sPkSafe}` FROM `{$sTableSafe}` WHERE {$sWhere} LIMIT 2");
+        if (!$aRows)
+            throw new Exception("No rows match UPDATE WHERE. Use `{$sPk}` = value.");
+        if (count($aRows) > 1)
+            throw new Exception("UPDATE WHERE matched more than one row. Use `{$sPk}` = value.");
+
+        return (string)$aRows[0][$sPkSafe];
+    }
+
+    protected function _insertedPkFromSql(string $sSql, string $sPk): string
+    {
+        $sPkRe = preg_quote($sPk, '/');
+        if (preg_match('/`' . $sPkRe . '`\s*=\s*(\d+|\'[^\']*\'|"[^"]*")/i', $sSql, $aM))
+            return trim($aM[1], "\"'");
+        if (preg_match('/\b' . $sPkRe . '\b\s*=\s*(\d+|\'[^\']*\'|"[^"]*")/i', $sSql, $aM))
+            return trim($aM[1], "\"'");
+        return '';
+    }
+
+    protected function _fetchRow(BxDolDb $oDb, string $sTable, string $sPk, string $sPkValue): array|false
+    {
+        $sTable = str_replace('`', '', $sTable);
+        $sPk = str_replace('`', '', $sPk);
+        if (preg_match('/^\d+$/', $sPkValue))
+            return $oDb->getRow("SELECT * FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", ['v' => (int)$sPkValue]);
+        return $oDb->getRow("SELECT * FROM `{$sTable}` WHERE `{$sPk}` = :v LIMIT 1", ['v' => $sPkValue]);
+    }
+
+    public static function currentLogContext(): array
+    {
+        $oDb = BxDolDb::getInstance();
+        $oAi = BxDolAi::getInstance();
+        $iAgentId = 0;
+        $sThread = '';
+        $iProfile = is_object($oAi) ? (int)$oAi->getProfileId() : 0;
+        $iHist = (int)BxDolAiChat::getInstance()->getCurrentChatHistoryId();
+        if ($iHist > 0) {
+            $aRow = $oDb->getRow("SELECT `thread_id` FROM `sys_agents_chat_history` WHERE `id` = :id", ['id' => $iHist]);
+            $sThread = $aRow['thread_id'] ?? '';
+            $aParts = explode(':', $sThread);
+            $iAgentId = (int)($aParts[1] ?? 0);
+        }
+
+        return [
+            'agent_id' => $iAgentId,
+            'profile_id' => $iProfile,
+            'thread_id' => $sThread,
+        ];
+    }
+
+    protected function _log(string $sOp, string $sTable, string $sPk, string $sPkValue, string $sSql, ?string $sBefore, ?string $sAfter): int
+    {
+        $oDb = BxDolDb::getInstance();
+        $aCtx = self::currentLogContext();
+
+        $oDb->query("INSERT INTO `sys_agents_sql_log` SET " . $oDb->arrayToSQL([
+            'agent_id' => $aCtx['agent_id'],
+            'profile_id' => $aCtx['profile_id'],
+            'thread_id' => $aCtx['thread_id'],
+            'op' => $sOp,
+            'table_name' => $sTable,
+            'pk_name' => $sPk,
+            'pk_value' => $sPkValue,
+            'sql_text' => $sSql,
+            'before_json' => $sBefore,
+            'after_json' => $sAfter,
+            'added' => time(),
+        ]));
+
+        return (int)$oDb->lastId();
+    }
+}

+ 409 - 0
inc/classes/BxDolAIToolPromptFeedback.php

@@ -0,0 +1,409 @@
+<?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 BxDolAIToolPromptFeedback extends BxDolAITool
+{
+    public const TABLE = 'sys_agents_prompt_feedback';
+
+    protected const CLIP_FIELD = 20000;
+    protected const CLIP_TURN = 1500;
+    protected const EXCERPT_TURNS = 8;
+
+    public function __construct()
+    {
+        parent::__construct(
+            'prompt_feedback',
+            'Log a wrong answer. Call when the user says you were wrong, corrects you, or you notice you broke a rule. Fill error_reason, expected_behavior, suggested_prompt_rule as reusable prompt text — not an apology. Question and wrong reply are taken from this chat if omitted.',
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty(
+                name: 'error_reason',
+                type: PropertyType::STRING,
+                description: 'Why the previous answer was wrong. Be specific: what fact, policy, tone, or missing step failed.',
+                required: true
+            ),
+            new ToolProperty(
+                name: 'expected_behavior',
+                type: PropertyType::STRING,
+                description: 'What the agent should have said or done instead. Include the correct answer when it is known.',
+                required: true
+            ),
+            new ToolProperty(
+                name: 'suggested_prompt_rule',
+                type: PropertyType::STRING,
+                description: 'One concise instruction to add or change in the system prompt so this failure does not repeat. Write it as a rule the agent can follow, not a description of this chat.',
+                required: true
+            ),
+            new ToolProperty(
+                name: 'category',
+                type: PropertyType::STRING,
+                description: 'hallucination | wrong_policy | wrong_tone | missing_info | tool_misuse | language | other',
+                required: false
+            ),
+            new ToolProperty(
+                name: 'severity',
+                type: PropertyType::STRING,
+                description: 'low | medium | high. high = wrong fact, leaked private data, or a broken business rule.',
+                required: false
+            ),
+            new ToolProperty(
+                name: 'user_question',
+                type: PropertyType::STRING,
+                description: 'Optional override of the user question that got the wrong answer. Leave empty to take it from chat history.',
+                required: false
+            ),
+            new ToolProperty(
+                name: 'wrong_answer',
+                type: PropertyType::STRING,
+                description: 'Optional override of the wrong assistant reply. Leave empty to take it from chat history.',
+                required: false
+            ),
+            new ToolProperty(
+                name: 'user_correction',
+                type: PropertyType::STRING,
+                description: 'Optional override of the user message that flagged the error. Leave empty to take the latest user line.',
+                required: false
+            ),
+        ];
+    }
+
+    public function __invoke(
+        string $error_reason,
+        string $expected_behavior,
+        string $suggested_prompt_rule,
+        ?string $category = null,
+        ?string $severity = null,
+        ?string $user_question = null,
+        ?string $wrong_answer = null,
+        ?string $user_correction = null
+    ): array {
+        $oDb = BxDolDb::getInstance();
+
+        $sReason = self::clip(trim((string)$error_reason), self::CLIP_FIELD);
+        $sExpected = self::clip(trim((string)$expected_behavior), self::CLIP_FIELD);
+        $sRule = self::clip(trim((string)$suggested_prompt_rule), self::CLIP_FIELD);
+        if ($sReason === '' || $sExpected === '' || $sRule === '')
+            throw new Exception('error_reason, expected_behavior and suggested_prompt_rule are required.');
+
+        $aCtx = self::currentContext();
+        $aTurns = self::turnsFromHistory($oDb, $aCtx['thread_id']);
+        $aCase = self::extractCase($aTurns, [
+            'user_question' => $user_question,
+            'wrong_answer' => $wrong_answer,
+            'user_correction' => $user_correction,
+        ]);
+
+        $iAdded = time();
+        $sCategory = self::normalizeCategory($category);
+        $sSeverity = self::normalizeSeverity($severity);
+        $sExcerpt = self::excerpt($aTurns);
+        $sMarkdown = self::caseMarkdown([
+            'added' => $iAdded,
+            'agent_id' => $aCtx['agent_id'],
+            'agent_name' => $aCtx['agent_name'],
+            'category' => $sCategory,
+            'severity' => $sSeverity,
+            'prompt_hash' => $aCtx['prompt_hash'],
+            'prompt_system' => $aCtx['prompt_system'],
+            'prompt_steps' => $aCtx['prompt_steps'],
+            'prompt_output' => $aCtx['prompt_output'],
+            'user_question' => $aCase['user_question'],
+            'wrong_answer' => $aCase['wrong_answer'],
+            'user_correction' => $aCase['user_correction'],
+            'error_reason' => $sReason,
+            'expected_behavior' => $sExpected,
+            'suggested_prompt_rule' => $sRule,
+            'conversation_excerpt' => $sExcerpt,
+        ]);
+
+        $oDb->query("INSERT INTO `" . self::TABLE . "` SET " . $oDb->arrayToSQL([
+            'agent_id' => $aCtx['agent_id'],
+            'agent_name' => $aCtx['agent_name'],
+            'profile_id' => $aCtx['profile_id'],
+            'thread_id' => $aCtx['thread_id'],
+            'history_id' => $aCtx['history_id'],
+            'category' => $sCategory,
+            'severity' => $sSeverity,
+            'user_question' => $aCase['user_question'],
+            'wrong_answer' => $aCase['wrong_answer'],
+            'user_correction' => $aCase['user_correction'],
+            'error_reason' => $sReason,
+            'expected_behavior' => $sExpected,
+            'suggested_prompt_rule' => $sRule,
+            'conversation_excerpt' => $sExcerpt,
+            'prompt_system' => $aCtx['prompt_system'],
+            'prompt_hash' => $aCtx['prompt_hash'],
+            'markdown' => $sMarkdown,
+            'added' => $iAdded,
+        ]));
+
+        return [
+            'ok' => 1,
+            'id' => (int)$oDb->lastId(),
+            'agent_id' => $aCtx['agent_id'],
+            'category' => $sCategory,
+            'severity' => $sSeverity,
+            'msg' => 'Logged. Do not mention the log to the user. Acknowledge the correction and continue with the expected behavior.',
+        ];
+    }
+
+    /**
+     * Paste-ready case: dump `markdown` into an AI to improve the prompt.
+     */
+    public static function caseMarkdown(array $a): string
+    {
+        $sWhen = !empty($a['added']) ? gmdate('Y-m-d H:i', (int)$a['added']) . ' UTC' : '';
+        $sName = (string)($a['agent_name'] ?? '');
+        $iId = (int)($a['agent_id'] ?? 0);
+        $sAgent = $sName !== '' ? '`' . $sName . '` (id ' . $iId . ')' : 'id ' . $iId;
+
+        $aOut = [];
+        $aOut[] = 'Improve this agent prompt from a real failure. Keep correct behavior. Add a concise testable rule. Do not invent product facts.';
+        $aOut[] = '';
+        $aOut[] = 'Agent: ' . $sAgent;
+        $aOut[] = 'When: ' . $sWhen;
+        $aOut[] = 'Category: ' . ($a['category'] ?? 'other') . ' / ' . ($a['severity'] ?? 'medium');
+        $aOut[] = 'Prompt hash: ' . ($a['prompt_hash'] ?? '');
+        $aOut[] = '';
+        $aOut[] = '## Failure';
+        $aOut[] = '';
+        $aOut[] = 'User asked:';
+        $aOut[] = self::mdBlock($a['user_question'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'Agent answered (wrong):';
+        $aOut[] = self::mdBlock($a['wrong_answer'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'User correction:';
+        $aOut[] = self::mdBlock($a['user_correction'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'Why it was wrong:';
+        $aOut[] = self::mdBlock($a['error_reason'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'Expected behavior / correct answer:';
+        $aOut[] = self::mdBlock($a['expected_behavior'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'Suggested prompt rule:';
+        $aOut[] = self::mdBlock($a['suggested_prompt_rule'] ?? '');
+        $aOut[] = '';
+        $aOut[] = '## Conversation excerpt';
+        $aOut[] = '';
+        $aOut[] = self::mdBlock($a['conversation_excerpt'] ?? '');
+        $aOut[] = '';
+        $aOut[] = '## Prompt at the time of failure';
+        $aOut[] = '';
+        $aOut[] = 'prompt_system:';
+        $aOut[] = self::mdBlock($a['prompt_system'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'prompt_steps:';
+        $aOut[] = self::mdBlock($a['prompt_steps'] ?? '');
+        $aOut[] = '';
+        $aOut[] = 'prompt_output:';
+        $aOut[] = self::mdBlock($a['prompt_output'] ?? '');
+
+        return implode("\n", $aOut);
+    }
+
+    /**
+     * @return array{agent_id:int,agent_name:string,profile_id:int,thread_id:string,history_id:int,prompt_system:string,prompt_hash:string,prompt_steps:string,prompt_output:string,prompt_tools:string}
+     */
+    public static function currentContext(): array
+    {
+        $oDb = BxDolDb::getInstance();
+
+        $iAgentId = 0;
+        $sThread = '';
+        $iProfile = (int)bx_get_logged_profile_id();
+        $aAgent = [];
+
+        $iHistory = (int)BxDolAiChat::getInstance()->getCurrentChatHistoryId();
+
+        if ($iHistory > 0) {
+            $aRow = $oDb->getRow("SELECT `thread_id` FROM `sys_agents_chat_history` WHERE `id` = :id", ['id' => $iHistory]);
+            $sThread = (string)($aRow['thread_id'] ?? '');
+            $aParts = explode(':', $sThread);
+            $iAgentId = (int)($aParts[1] ?? 0);
+        }
+
+        if ($iAgentId > 0)
+            $aAgent = BxDolAiQuery::getAgentObject($iAgentId) ?: [];
+
+        $sSystem = (string)($aAgent['prompt_system'] ?? '');
+        $sSteps = (string)($aAgent['prompt_steps'] ?? '');
+        $sOutput = (string)($aAgent['prompt_output'] ?? '');
+        $sTools = (string)($aAgent['prompt_tools'] ?? '');
+
+        return [
+            'agent_id' => $iAgentId,
+            'agent_name' => (string)($aAgent['name'] ?? ''),
+            'profile_id' => $iProfile,
+            'thread_id' => $sThread,
+            'history_id' => $iHistory,
+            'prompt_system' => self::clip($sSystem, self::CLIP_FIELD),
+            'prompt_hash' => hash('sha256', $sSystem . "\n" . $sSteps . "\n" . $sOutput . "\n" . $sTools),
+            'prompt_steps' => self::clip($sSteps, self::CLIP_FIELD),
+            'prompt_output' => self::clip($sOutput, self::CLIP_FIELD),
+            'prompt_tools' => self::clip($sTools, self::CLIP_FIELD),
+        ];
+    }
+
+    /**
+     * @return array<int, array{role:string,text:string}>
+     */
+    public static function turnsFromHistory(BxDolDb $oDb, string $sThread): array
+    {
+        if ($sThread === '')
+            return [];
+
+        $sJson = (string)$oDb->getOne("SELECT `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => $sThread,
+        ]);
+        $aStored = json_decode($sJson, true);
+        if (!is_array($aStored))
+            return [];
+
+        $aTurns = [];
+        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;
+            $sText = self::textFromContent($aMessage['content'] ?? '');
+            if ($sText === '')
+                continue;
+            $aTurns[] = ['role' => $sRole, 'text' => $sText];
+        }
+        return $aTurns;
+    }
+
+    /**
+     * @param array<int, array{role:string,text:string}> $aTurns
+     * @param array{user_question?:?string,wrong_answer?:?string,user_correction?:?string} $aOverride
+     * @return array{user_question:string,wrong_answer:string,user_correction:string}
+     */
+    public static function extractCase(array $aTurns, array $aOverride = []): array
+    {
+        $sQuestion = self::clip(trim((string)($aOverride['user_question'] ?? '')), self::CLIP_FIELD);
+        $sWrong = self::clip(trim((string)($aOverride['wrong_answer'] ?? '')), self::CLIP_FIELD);
+        $sCorrection = self::clip(trim((string)($aOverride['user_correction'] ?? '')), self::CLIP_FIELD);
+
+        $sLastUser = '';
+        $sLastAssistant = '';
+        $sPrevUser = '';
+        for ($i = count($aTurns) - 1; $i >= 0; $i--) {
+            $sRole = $aTurns[$i]['role'];
+            $sText = $aTurns[$i]['text'];
+            if ($sRole === 'user' && $sLastUser === '') {
+                $sLastUser = $sText;
+                continue;
+            }
+            if ($sRole === 'assistant' && $sLastAssistant === '' && $sLastUser !== '') {
+                $sLastAssistant = $sText;
+                continue;
+            }
+            if ($sRole === 'user' && $sLastAssistant !== '' && $sPrevUser === '') {
+                $sPrevUser = $sText;
+                break;
+            }
+        }
+
+        if ($sCorrection === '')
+            $sCorrection = self::clip($sLastUser, self::CLIP_FIELD);
+        if ($sWrong === '')
+            $sWrong = self::clip($sLastAssistant, self::CLIP_FIELD);
+        if ($sQuestion === '')
+            $sQuestion = self::clip($sPrevUser !== '' ? $sPrevUser : $sLastUser, self::CLIP_FIELD);
+
+        return [
+            'user_question' => $sQuestion,
+            'wrong_answer' => $sWrong,
+            'user_correction' => $sCorrection,
+        ];
+    }
+
+    /**
+     * @param array<int, array{role:string,text:string}> $aTurns
+     */
+    public static function excerpt(array $aTurns): string
+    {
+        $aSlice = array_slice($aTurns, -self::EXCERPT_TURNS);
+        $aLines = [];
+        foreach ($aSlice as $aTurn) {
+            $sLabel = $aTurn['role'] === 'assistant' ? 'Assistant' : 'User';
+            $aLines[] = $sLabel . ': ' . self::clip($aTurn['text'], self::CLIP_TURN);
+        }
+        return implode("\n\n", $aLines);
+    }
+
+    public static function clip(string $s, int $iMax): string
+    {
+        if ($iMax <= 0 || mb_strlen($s) <= $iMax)
+            return $s;
+        return mb_substr($s, 0, $iMax - 1) . '…';
+    }
+
+    public static function textFromContent($mixed): string
+    {
+        if (is_string($mixed))
+            return trim($mixed);
+        if (!is_array($mixed))
+            return '';
+
+        $aParts = [];
+        foreach ($mixed as $aBlock) {
+            if (is_string($aBlock)) {
+                $s = trim($aBlock);
+                if ($s !== '')
+                    $aParts[] = $s;
+                continue;
+            }
+            if (!is_array($aBlock))
+                continue;
+            $sType = (string)($aBlock['type'] ?? '');
+            $sText = (string)($aBlock['content'] ?? ($aBlock['text'] ?? ''));
+            if ($sText === '')
+                continue;
+            if ($sType === '' || $sType === 'text')
+                $aParts[] = trim($sText);
+        }
+        return trim(implode("\n", $aParts));
+    }
+
+    protected static function mdBlock($mixed): string
+    {
+        $s = trim((string)$mixed);
+        return $s !== '' ? $s : '(empty)';
+    }
+
+    protected static function normalizeCategory(?string $s): string
+    {
+        $s = strtolower(trim((string)$s));
+        $aOk = ['hallucination', 'wrong_policy', 'wrong_tone', 'missing_info', 'tool_misuse', 'language', 'other'];
+        return in_array($s, $aOk, true) ? $s : 'other';
+    }
+
+    protected static function normalizeSeverity(?string $s): string
+    {
+        $s = strtolower(trim((string)$s));
+        $aOk = ['low', 'medium', 'high'];
+        return in_array($s, $aOk, true) ? $s : 'medium';
+    }
+}

+ 299 - 8
inc/classes/BxDolAiChat.php

@@ -13,6 +13,12 @@ class BxDolAiChat
     protected $_oUi;
     protected $_aChatContext;
 
+    /**
+     * Buttons queued by the `chat_buttons` tool during the current turn. The chat
+     * trigger emits them after the assistant text and pins them to the stored message.
+     */
+    protected $_aPendingChatActions = [];
+
     public static function getInstance()
     {
         if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
@@ -26,6 +32,7 @@ class BxDolAiChat
         $this->_oDb = new BxDolAiQuery();
         $this->_oUi = BxDolAiChatUi::getInstance();
         $this->_aChatContext = null;
+        $this->_aPendingChatActions = [];
     }
 
     public function setChatContext($aAgent, $aParams)
@@ -33,9 +40,34 @@ class BxDolAiChat
         $this->_aChatContext = ['agent' => $aAgent, 'params' => $aParams];
     }
 
+    /**
+     * Optional `?chat=` partition. Not identity — owner stays profile id / session id.
+     * `{owner}#{chat}` so parseThreadId does not treat the nonce as a context pid.
+     */
+    public static function sanitizeChatQueryId($s)
+    {
+        $s = is_string($s) ? $s : '';
+        $s = preg_replace('/[^A-Za-z0-9_-]/', '', $s);
+        $s = substr($s, 0, 64);
+        return strlen($s) >= 8 ? $s : '';
+    }
+
+    /**
+     * Owner (profile id or guest session id) of a `{owner}` / `{owner}#{chat}` subindex.
+     */
+    public static function chatHistoryOwnerFromSubindex($sSub)
+    {
+        $sSub = (string)$sSub;
+        if ($sSub === '')
+            return '';
+        $i = strpos($sSub, '#');
+        return $i === false ? $sSub : substr($sSub, 0, $i);
+    }
+
     /**
      * Guests: UNA session id. Members: profile id.
      * Guest chats remember agent ids in session so login can adopt those threads.
+     * `?chat=` appends a conversation id without changing the owner.
      */
     public function resolveChatHistoryParams($iAgentId = 0)
     {
@@ -43,6 +75,7 @@ class BxDolAiChat
         $iProfileId = (int)bx_get_logged_profile_id();
         $oSession = BxDolSession::getInstance();
         $sSessionKey = 'ai_chat_agent_ids';
+        $sChat = self::sanitizeChatQueryId(bx_get('chat'));
 
         if ($iProfileId) {
             $aIds = $oSession->getValue($sSessionKey);
@@ -60,9 +93,10 @@ class BxDolAiChat
                 $oSession->unsetValue($sSessionKey);
             }
 
+            $sOwner = (string)$iProfileId;
             return [
                 'sender_profile_id' => $iProfileId,
-                'chat_history_subindex' => (string)$iProfileId,
+                'chat_history_subindex' => $sChat !== '' ? ($sOwner . '#' . $sChat) : $sOwner,
             ];
         }
 
@@ -75,16 +109,17 @@ class BxDolAiChat
             }
         }
 
+        $sOwner = (string)$oSession->getId();
         return [
             'sender_profile_id' => 0,
-            'chat_history_subindex' => (string)$oSession->getId(),
+            'chat_history_subindex' => $sChat !== '' ? ($sOwner . '#' . $sChat) : $sOwner,
         ];
     }
 
     /**
      * `{trigger}:{agentId}:{contextPid}:{userSubindex}`.
      * Context is omitted when 0 so existing site-wide threads still load.
-     * User suffix (session id or profile id) stays last so adopt can replace it.
+     * User suffix (session id or profile id, optionally `#{chat}`) stays last so adopt can replace it.
      */
     public static function threadId($aAgent, $aParams = [])
     {
@@ -145,6 +180,101 @@ class BxDolAiChat
         return $this->getChatHistoryRowId($aAgent, is_array($aParams) ? $aParams : []);
     }
 
+    /**
+     * No `?chat=` in the request means "the conversation I am in", not "the base
+     * thread": the newest open thread for this owner+context (a `?chat=` partition
+     * or the base thread). Otherwise a block without `allow_new` would keep talking
+     * to a base thread the agent closed days ago, while Studio shows a newer chat.
+     *
+     * Explicit `?chat=` (subindex with a #) is left alone. Falls back to the base
+     * thread when nothing is open.
+     */
+    public function resolveCurrentChatThread($aAgent, $aParams)
+    {
+        $sSub = (string)($aParams['chat_history_subindex'] ?? '');
+        if ($sSub === '' || strpos($sSub, '#') !== false)
+            return $aParams;
+
+        $sCurrent = $this->_oDb->getCurrentOpenChatThreadId($aAgent, $sSub, (int)($aParams['chat_history_context_pid'] ?? 0));
+        if ($sCurrent === '')
+            return $aParams;
+
+        $aParsed = self::parseThreadId($aAgent, $sCurrent);
+        if (!$aParsed || (string)$aParsed['chat_history_subindex'] === '')
+            return $aParams;
+
+        $aParams['chat_history_subindex'] = (string)$aParsed['chat_history_subindex'];
+        return $aParams;
+    }
+
+    /**
+     * Create the `?chat=` history row immediately, so the thread exists (and lists)
+     * before its first message. The owner's other threads are left as they are: a
+     * "New chat" is one more conversation, not the end of the previous one — people
+     * switch back to an earlier open thread from the history panel and go on.
+     * Threads close only when the agent or the turn limit closes them.
+     *
+     * @return int history row id, 0 when this is not a `?chat=` thread
+     */
+    public function openPartitionedChatThread($aAgent, $aParams)
+    {
+        $sSub = (string)($aParams['chat_history_subindex'] ?? '');
+        if (strpos($sSub, '#') === false)
+            return 0;
+
+        $sThreadId = self::threadId($aAgent, $aParams);
+        if ($sThreadId === '')
+            return 0;
+
+        return (int)$this->_oDb->ensureChatHistoryRow($sThreadId);
+    }
+
+    /**
+     * `chat_buttons` tool: queue buttons for the reply being generated.
+     *
+     * @return int number of accepted buttons
+     */
+    public function setPendingChatActions($mixed)
+    {
+        $a = $this->_oUi->sanitizeChatActions($this->normalizeChatButtonsInput($mixed));
+        if (!$a)
+            return 0;
+
+        $this->_aPendingChatActions = $this->_oUi->mergeChatActions($this->_aPendingChatActions, $a);
+        return count($a);
+    }
+
+    public function getPendingChatActions()
+    {
+        return $this->_oUi->sanitizeChatActions($this->_aPendingChatActions);
+    }
+
+    public function takePendingChatActions()
+    {
+        $a = $this->getPendingChatActions();
+        $this->_aPendingChatActions = [];
+        return $a;
+    }
+
+    public function resetPendingChatActions()
+    {
+        $this->_aPendingChatActions = [];
+    }
+
+    protected function normalizeChatButtonsInput($mixed)
+    {
+        if (!is_array($mixed))
+            return [];
+
+        if (isset($mixed['buttons']) && is_array($mixed['buttons']))
+            $mixed = $mixed['buttons'];
+
+        if (isset($mixed['type']) && (isset($mixed['label']) || isset($mixed['url'])))
+            return [$mixed];
+
+        return $mixed;
+    }
+
     public function emitConversationClosed($sReason, $sSummary = '', $aAgent = null, $aParams = null)
     {
         if (!is_array($aAgent) && is_array($this->_aChatContext))
@@ -170,6 +300,9 @@ class BxDolAiChat
         return true;
     }
 
+    /**
+     * Thread belongs to the current profile / guest session (any `?chat=` partition of theirs).
+     */
     public function isOwnAgentChatThread($aAgent, $sThreadId)
     {
         $aParsed = self::parseThreadId($aAgent, $sThreadId);
@@ -177,8 +310,9 @@ class BxDolAiChat
             return false;
 
         $aMine = $this->resolveChatHistoryParams((int)$aAgent['id']);
-        $sMine = (string)($aMine['chat_history_subindex'] ?? '');
-        return $sMine !== '' && $aParsed['chat_history_subindex'] === $sMine;
+        $sMineOwner = self::chatHistoryOwnerFromSubindex($aMine['chat_history_subindex'] ?? '');
+        $sRowOwner = self::chatHistoryOwnerFromSubindex($aParsed['chat_history_subindex']);
+        return $sMineOwner !== '' && $sMineOwner === $sRowOwner;
     }
 
     public function getChatHistoryUiMessagesByThread($aAgent, $sThreadId)
@@ -199,10 +333,22 @@ class BxDolAiChat
         return $this->_oDb->getChatArtifactsByThreadId((string)$sThreadId);
     }
 
+    public function getChatHistoryClosedReasonByThread($aAgent, $sThreadId)
+    {
+        if (!self::parseThreadId($aAgent, $sThreadId))
+            return '';
+
+        return $this->_oDb->getChatHistoryClosedReasonById($this->_oDb->getChatHistoryIdByThreadId((string)$sThreadId));
+    }
+
+    /**
+     * Every thread of this agent (Studio chat popup): all owners, all contexts.
+     */
     public function listAgentChatThreads($aAgent)
     {
         $aMineParams = $this->resolveChatHistoryParams((int)$aAgent['id']);
         $sMySub = (string)($aMineParams['chat_history_subindex'] ?? '');
+        $sMyOwner = self::chatHistoryOwnerFromSubindex($sMySub);
         $sMyDefault = self::threadId($aAgent, $aMineParams);
 
         $aOut = [];
@@ -225,10 +371,12 @@ class BxDolAiChat
                 continue;
 
             $aUi = $this->_oUi->storedChatJsonToUiMessages($aRow['messages'] ?? '');
-            if (!$aUi && $sThreadId !== $sMyDefault)
+            // A `?chat=` thread is listed even before its first message (it was opened on purpose).
+            $bPartition = strpos((string)$aParsed['chat_history_subindex'], '#') !== false;
+            if (!$aUi && $sThreadId !== $sMyDefault && !$bPartition)
                 continue;
 
-            $bMine = ($sMySub !== '' && $aParsed['chat_history_subindex'] === $sMySub);
+            $bMine = ($sMyOwner !== '' && $sMyOwner === self::chatHistoryOwnerFromSubindex($aParsed['chat_history_subindex']));
             if ($sThreadId === $sMyDefault)
                 $bHaveMineDefault = true;
 
@@ -250,6 +398,78 @@ class BxDolAiChat
         return $aOut;
     }
 
+    /**
+     * The caller's own conversations with this agent, for the chat widget's history
+     * panel (`system/get_ai_chat_threads`). Unlike listAgentChatThreads (Studio, every
+     * owner) this only reads the current profile's / guest session's rows: any
+     * context, base thread and `?chat=` partitions, newest first. Closed threads
+     * with nothing in them are skipped; the open one is listed even when empty so
+     * the panel can mark it as current.
+     *
+     * @return array<int, array<string, mixed>> see formatOwnAgentChatThread
+     */
+    public function listOwnAgentChatThreads($aAgent)
+    {
+        $aMine = $this->resolveChatHistoryParams((int)$aAgent['id']);
+        $sOwner = self::chatHistoryOwnerFromSubindex($aMine['chat_history_subindex'] ?? '');
+        if ($sOwner === '')
+            return [];
+
+        $aRows = $this->_oDb->getOwnerAgentChatHistoryRows($aAgent, $sOwner);
+        if (!is_array($aRows))
+            return [];
+
+        $aOut = [];
+        foreach ($aRows as $aRow) {
+            $aParsed = self::parseThreadId($aAgent, (string)($aRow['thread_id'] ?? ''));
+            // LIKE matched the owner loosely; the parsed id is the authority.
+            if (!$aParsed || self::chatHistoryOwnerFromSubindex($aParsed['chat_history_subindex']) !== $sOwner)
+                continue;
+
+            $aUi = $this->_oUi->storedChatJsonToUiMessages($aRow['messages'] ?? '');
+            $sReason = trim((string)($aRow['closed_reason'] ?? ''));
+            if (!$aUi && $sReason !== '')
+                continue;
+
+            $aOut[] = $this->formatOwnAgentChatThread($aParsed, $aRow, $aUi);
+        }
+
+        return $aOut;
+    }
+
+    /**
+     * `chat_title` tool: store the title the agent chose for the conversation it is
+     * in right now (`_aChatContext`, set by the chat trigger). Set once —
+     * a thread that already has a title keeps it.
+     *
+     * @return int 1 saved, 0 already titled, -1 no current thread or no `title` column yet
+     */
+    public function setCurrentChatThreadTitle($sTitle)
+    {
+        $sTitle = trim((string)$sTitle);
+        if ($sTitle === '' || !is_array($this->_aChatContext))
+            return -1;
+        if (!$this->_oDb->isFieldExists('sys_agents_chat_history', 'title'))
+            return -1;
+
+        $aAgent = $this->_aChatContext['agent'] ?? null;
+        $aParams = $this->_aChatContext['params'] ?? [];
+        if (!is_array($aAgent))
+            return -1;
+
+        $sThreadId = self::threadId($aAgent, is_array($aParams) ? $aParams : []);
+        if ($sThreadId === '' || $sThreadId === ':')
+            return -1;
+
+        // The row may not be there yet on the very first turn: the history is
+        // persisted after the tools ran. Create it, so the title has somewhere to go.
+        $this->_oDb->ensureChatHistoryRow($sThreadId);
+        if ($this->_oDb->getChatHistoryTitle($sThreadId) !== '')
+            return 0;
+
+        return $this->_oDb->setChatHistoryTitle($sThreadId, $sTitle) ? 1 : -1;
+    }
+
     /**
      * 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.
@@ -373,11 +593,15 @@ class BxDolAiChat
         }
 
         $sTitle = _t('_sys_agents_agents_txt_guest_chat');
-        $oProfile = (ctype_digit($sSub) && (int)$sSub > 0) ? BxDolProfile::getInstance((int)$sSub) : false;
+        $sOwner = self::chatHistoryOwnerFromSubindex($sSub);
+        $oProfile = (ctype_digit($sOwner) && (int)$sOwner > 0) ? BxDolProfile::getInstance((int)$sOwner) : false;
         if ($oProfile)
             $sTitle = $oProfile->getDisplayName();
         if ($sContextName !== '')
             $sTitle .= ' · ' . $sContextName;
+        $sThreadTitle = trim((string)($aRow['title'] ?? ''));
+        if ($sThreadTitle !== '')
+            $sTitle .= ' · ' . $sThreadTitle;
 
         $sReason = trim((string)($aRow['closed_reason'] ?? ''));
         $sStatus = $sReason !== '' ? 'closed' : 'opened';
@@ -412,11 +636,78 @@ class BxDolAiChat
             'created_at' => $sCreated,
             'updated_at' => $sUpdated,
             'artifacts' => is_array($aArtifacts) ? $aArtifacts : [],
+            'messages' => is_array($aUi) ? $aUi : [],
             'updated_sort' => $sUpdatedRaw,
             'class_mine' => $bMine ? ' bx-agents-popup-chat-thread-mine' : '',
         ];
     }
 
+    /**
+     * One history-panel item. `chat` is the `?chat=` nonce ('' for the base thread),
+     * so the client can tell which row its live widget is talking to. `title` is the
+     * one the agent gave (`chat_title` tool) or the first thing the person typed (the
+     * hidden "[page opened…" bootstrap is skipped), `preview` the last line said; both plain text.
+     */
+    protected function formatOwnAgentChatThread($aParsed, $aRow, $aUi)
+    {
+        $iContext = (int)$aParsed['chat_history_context_pid'];
+        $sContextName = '';
+        if ($iContext > 0) {
+            $oContext = BxDolProfile::getInstance($iContext);
+            $sContextName = $oContext ? $oContext->getDisplayName() : ('#' . $iContext);
+        }
+
+        $sSub = (string)$aParsed['chat_history_subindex'];
+        $iDot = strpos($sSub, '#');
+        $sChat = $iDot === false ? '' : substr($sSub, $iDot + 1);
+
+        $sTitle = trim((string)($aRow['title'] ?? ''));
+        $sPreview = '';
+        foreach ($aUi as $aMessage) {
+            $sText = $this->_oUi->uiChatMessagePlainText($aMessage);
+            if ($sText === '')
+                continue;
+            if ($sTitle === '' && ($aMessage['role'] ?? '') === 'user' && strpos($sText, '[page opened') !== 0)
+                $sTitle = $this->clipChatThreadText($sText, 80);
+            $sPreview = $sText;
+        }
+        if ($sPreview !== '' && strpos($sPreview, '[page opened') === 0)
+            $sPreview = '';
+
+        $sReason = trim((string)($aRow['closed_reason'] ?? ''));
+        return [
+            'thread_id' => (string)$aParsed['thread_id'],
+            'chat' => $sChat,
+            'context_pid' => $iContext,
+            'context_name' => $sContextName,
+            'status' => $sReason !== '' ? 'closed' : 'opened',
+            'closed_reason' => $sReason,
+            'title' => $sTitle,
+            'preview' => $this->clipChatThreadText($sPreview, 120),
+            'messages_count' => count($aUi),
+            'created_ts' => $this->chatThreadTimestamp($aRow['created_at'] ?? ''),
+            'updated_ts' => $this->chatThreadTimestamp($aRow['updated_at'] ?? ''),
+        ];
+    }
+
+    protected function clipChatThreadText($s, $iMax)
+    {
+        $s = (string)$s;
+        if (get_mb_len($s) <= $iMax)
+            return $s;
+        return rtrim(get_mb_substr($s, 0, $iMax - 1)) . '…';
+    }
+
+    /** Unix timestamp for a `created_at` / `updated_at` cell, 0 when unset. */
+    protected function chatThreadTimestamp($mixed)
+    {
+        $s = trim((string)$mixed);
+        if ($s === '' || $s === '0')
+            return 0;
+        $i = ctype_digit($s) ? (int)$s : (int)strtotime($s);
+        return $i > 86400 ? $i : 0;
+    }
+
     protected function formatAgentChatThreadTime($mixed): string
     {
         $s = trim((string)$mixed);

+ 353 - 0
inc/classes/BxDolAiChatImages.php

@@ -0,0 +1,353 @@
+<?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
+ * @{
+ */
+
+/**
+ * Images in agent chats: composer uploads (`sys_agents_chat_images` storage),
+ * image parts of an AG-UI request, and NeuronAI content blocks built from them.
+ */
+class BxDolAiChatImages
+{
+    const STORAGE = 'sys_agents_chat_images';
+    const MAX_PER_TURN = 4;
+
+    public static function getInstance()
+    {
+        if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
+            $GLOBALS['bxDolClasses'][__CLASS__] = new self();
+
+        return $GLOBALS['bxDolClasses'][__CLASS__];
+    }
+
+    /**
+     * Store one composer upload (`$_FILES` entry) into the chat images storage.
+     *
+     * @return array{file_id:int,url:string,mime:string}|array{error:string}
+     */
+    public function storeUpload($aFile, $iProfileId = 0)
+    {
+        $oStorage = BxDolStorage::getObjectInstance(self::STORAGE);
+        if (!$oStorage)
+            return ['error' => 'Storage is not available'];
+
+        $sName = (string)($aFile['name'] ?? '');
+        $sTmp = (string)($aFile['tmp_name'] ?? '');
+        $iSize = (int)($aFile['size'] ?? 0);
+        if ($sTmp === '' || !is_uploaded_file($sTmp))
+            return ['error' => 'No file'];
+        $iMax = $this->maxBytes();
+        if ($iSize <= 0 || ($iMax > 0 && $iSize > $iMax))
+            return ['error' => 'File is too large'];
+
+        $sMime = strtolower((string)($aFile['type'] ?? ''));
+        $sExt = strtolower(pathinfo($sName, PATHINFO_EXTENSION));
+        $aMime = [
+            'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
+            'gif' => 'image/gif', 'webp' => 'image/webp',
+        ];
+        if (!isset($aMime[$sExt]))
+            return ['error' => 'Only JPEG, PNG, GIF, WebP'];
+        if ($sMime && strncmp($sMime, 'image/', 6) !== 0)
+            return ['error' => 'Only images'];
+
+        $iId = (int)$oStorage->storeFileFromForm($aFile, false, (int)$iProfileId);
+        if (!$iId)
+            return ['error' => $oStorage->getErrorString() ?: 'Upload failed'];
+
+        if (method_exists($oStorage, 'afterUploadCleanup'))
+            $oStorage->afterUploadCleanup($iId, (int)$iProfileId);
+
+        $sUrl = $oStorage->getFileUrlById($iId);
+        if (!$sUrl)
+            return ['error' => 'Upload failed'];
+
+        $aInfo = $oStorage->getFile($iId) ?: [];
+        return [
+            'file_id' => $iId,
+            'url' => $sUrl,
+            'mime' => (string)($aInfo['mime_type'] ?? $aMime[$sExt]),
+        ];
+    }
+
+    /**
+     * Image part of an AG-UI / stored message: `{type:image, source:{type:url|data,...}}`,
+     * `{type:image_url, image_url:{url}}`, `{url}`, or a `file_id` from storeUpload.
+     *
+     * @return array{url:string,mime:string,file_id:int}|null
+     */
+    public function parsePart($aPart)
+    {
+        if (!is_array($aPart))
+            return null;
+
+        $sType = strtolower((string)($aPart['type'] ?? ''));
+        $sUrl = '';
+        $sMime = 'image/jpeg';
+        $aSource = is_array($aPart['source'] ?? null) ? $aPart['source'] : [];
+        $iFileId = (int)($aPart['file_id'] ?? $aSource['file_id'] ?? 0);
+
+        if ($iFileId > 0) {
+            $oStorage = BxDolStorage::getObjectInstance(self::STORAGE);
+            $sStored = $oStorage ? (string)$oStorage->getFileUrlById($iFileId) : '';
+            if ($sStored !== '')
+                $sUrl = $sStored;
+        }
+
+        if ($sType === 'image' || $sType === 'image_url' || $sUrl !== '') {
+            if ($sUrl === '' && ($aSource['type'] ?? '') === 'url')
+                $sUrl = (string)($aSource['value'] ?? '');
+            elseif ($sUrl === '' && !empty($aPart['url']))
+                $sUrl = (string)$aPart['url'];
+            elseif ($sUrl === '' && !empty($aPart['image_url']['url']))
+                $sUrl = (string)$aPart['image_url']['url'];
+            elseif ($sUrl === '' && ($sType === 'image' || $sType === 'image_url')) {
+                $sContent = (string)($aPart['content'] ?? '');
+                if ($sContent !== '' && (strpos($sContent, 'http://') === 0 || strpos($sContent, 'https://') === 0 || (strpos($sContent, '/') === 0 && strpos($sContent, '//') !== 0)))
+                    $sUrl = $sContent;
+            }
+            $sMime = (string)($aSource['mimeType'] ?? $aPart['media_type'] ?? $aPart['mediaType'] ?? $aPart['mime'] ?? $aPart['mimeType'] ?? $sMime);
+        }
+
+        $sUrl = $this->sanitizeUrl($sUrl);
+        if ($sUrl === '')
+            return null;
+
+        return ['url' => $sUrl, 'mime' => $this->sanitizeMime($sMime), 'file_id' => $iFileId];
+    }
+
+    public function sanitizeMime($sMime)
+    {
+        $sMime = strtolower(trim((string)$sMime));
+        return preg_match('#^image/(jpeg|png|gif|webp)$#', $sMime) ? $sMime : 'image/jpeg';
+    }
+
+    /**
+     * http(s) URLs on this site or on the storage host. Site-relative paths are made absolute.
+     */
+    public function sanitizeUrl($sUrl)
+    {
+        $sUrl = trim((string)$sUrl);
+        if ($sUrl === '' || preg_match('#^(javascript|data|file):#i', $sUrl))
+            return '';
+
+        if (strpos($sUrl, '/') === 0 && strpos($sUrl, '//') !== 0)
+            $sUrl = rtrim(BX_DOL_URL_ROOT, '/') . $sUrl;
+
+        $aUrl = parse_url($sUrl);
+        if (empty($aUrl['scheme']) || empty($aUrl['host']) || !empty($aUrl['user']) || !empty($aUrl['pass']))
+            return '';
+        if (!in_array(strtolower($aUrl['scheme']), ['http', 'https'], true))
+            return '';
+
+        $sHost = strtolower(preg_replace('/^www\./', '', $aUrl['host']));
+        return in_array($sHost, $this->allowedHosts(), true) ? $sUrl : '';
+    }
+
+    /**
+     * This site, the storage domain, the storage endpoint, and the bucket host.
+     */
+    protected function allowedHosts()
+    {
+        $aHosts = [];
+        $aRoot = parse_url(BX_DOL_URL_ROOT);
+        if (!empty($aRoot['host']))
+            $aHosts[] = strtolower(preg_replace('/^www\./', '', $aRoot['host']));
+
+        $sDomain = trim((string)getParam('sys_storage_s3_domain'));
+        $sEndpoint = trim((string)getParam('sys_storage_s3_endpoint'));
+        $sBucket = strtolower(trim((string)getParam('sys_storage_s3_bucket')));
+        $sEndpointHost = '';
+
+        foreach ([$sDomain, $sEndpoint] as $sValue) {
+            if ($sValue === '')
+                continue;
+            $aValue = parse_url(preg_match('#://#', $sValue) ? $sValue : 'https://' . $sValue);
+            if (empty($aValue['host']))
+                continue;
+            $aHosts[] = strtolower($aValue['host']);
+            if ($sValue === $sEndpoint)
+                $sEndpointHost = strtolower($aValue['host']);
+        }
+
+        if (preg_match('/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/', $sBucket)) {
+            if ($sEndpointHost !== '')
+                $aHosts[] = $sBucket . '.' . $sEndpointHost;
+            elseif ($sDomain === '')
+                $aHosts[] = $sBucket . '.s3.amazonaws.com';
+        }
+
+        return $aHosts;
+    }
+
+    /**
+     * NeuronAI user message: text block plus one image block per image.
+     * Images that cannot be turned into a block are passed as their URL in text.
+     *
+     * @param array<int, array{url:string,mime?:string}> $aImages
+     * @return NeuronAI\Chat\Messages\UserMessage|null null when there is nothing to send
+     */
+    public function makeUserMessage($sText, $aImages = [])
+    {
+        $aBlocks = [];
+        $sText = (string)$sText;
+        if ($sText !== '')
+            $aBlocks[] = new NeuronAI\Chat\Messages\ContentBlocks\TextContent($sText);
+
+        foreach ((array)$aImages as $aImage) {
+            $sUrl = trim((string)($aImage['url'] ?? ''));
+            $sMime = trim((string)($aImage['mime'] ?? 'image/jpeg'));
+            $oImage = $this->makeNeuronImageContent($sUrl, $sMime);
+            if ($oImage)
+                $aBlocks[] = $oImage;
+            elseif ($sUrl !== '')
+                $aBlocks[] = new NeuronAI\Chat\Messages\ContentBlocks\TextContent($sUrl);
+        }
+
+        if (!$aBlocks)
+            return null;
+
+        try {
+            return new NeuronAI\Chat\Messages\UserMessage($aBlocks);
+        } catch (Throwable $oException) {
+            $oMessage = new NeuronAI\Chat\Messages\UserMessage($sText !== '' ? $sText : ' ');
+            foreach ($aBlocks as $oBlock) {
+                if ($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\TextContent)
+                    continue;
+                if (method_exists($oMessage, 'addContent'))
+                    $oMessage->addContent($oBlock);
+            }
+            return $oMessage;
+        }
+    }
+
+    /**
+     * Prefer base64 (the model does not have to fetch our URL); fall back to a URL block.
+     */
+    public function makeNeuronImageContent($sUrl, $sMime = 'image/jpeg')
+    {
+        $sUrl = $this->sanitizeUrl($sUrl);
+        if ($sUrl === '')
+            return null;
+
+        $sClass = 'NeuronAI\\Chat\\Messages\\ContentBlocks\\ImageContent';
+        if (!class_exists($sClass))
+            return null;
+
+        $sData = $this->urlToBase64($sUrl);
+        if ($sData !== '') {
+            if (method_exists($sClass, 'fromBase64'))
+                return $sClass::fromBase64($sData, $sMime);
+            if (class_exists('NeuronAI\\Chat\\Enums\\SourceType')) {
+                try {
+                    return new $sClass($sData, NeuronAI\Chat\Enums\SourceType::BASE64, $sMime);
+                } catch (Throwable $oException) {
+                }
+            }
+        }
+
+        if (method_exists($sClass, 'fromUrl'))
+            return $sClass::fromUrl($sUrl);
+
+        try {
+            if (class_exists('NeuronAI\\Chat\\Enums\\SourceType'))
+                return new $sClass($sUrl, NeuronAI\Chat\Enums\SourceType::URL, $sMime);
+            return new $sClass($sUrl);
+        } catch (Throwable $oException) {
+            return null;
+        }
+    }
+
+    /**
+     * NeuronAI ImageContent block → UI part (`{type:image, source:{type:url|data,...}}`).
+     */
+    public function neuronBlockToUiPart($oBlock)
+    {
+        if (!($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\ImageContent))
+            return null;
+
+        $sContent = (string)($oBlock->content ?? '');
+        if ($sContent === '')
+            return null;
+
+        $sMime = (string)($oBlock->mediaType ?? 'image/jpeg');
+        $sSource = '';
+        if (isset($oBlock->sourceType)) {
+            $sSource = $oBlock->sourceType instanceof \BackedEnum
+                ? (string)$oBlock->sourceType->value
+                : (string)$oBlock->sourceType;
+        }
+
+        if ($sSource === 'base64')
+            return ['type' => 'image', 'source' => ['type' => 'data', 'value' => $sContent, 'mimeType' => $sMime]];
+
+        $sUrl = $this->sanitizeUrl($sContent);
+        if ($sUrl === '')
+            return null;
+        return ['type' => 'image', 'source' => ['type' => 'url', 'value' => $sUrl, 'mimeType' => $sMime]];
+    }
+
+    protected function urlToBase64($sUrl)
+    {
+        $sUrl = $this->sanitizeUrl($sUrl);
+        if ($sUrl === '')
+            return '';
+
+        $sLocal = $this->urlToLocalPath($sUrl);
+        $sBin = ($sLocal !== '' && is_readable($sLocal)) ? @file_get_contents($sLocal) : false;
+        if (!is_string($sBin) || $sBin === '') {
+            $sCode = null;
+            $sBin = bx_file_get_contents($sUrl, [], 'get', [], $sCode, [], 10, [CURLOPT_FOLLOWLOCATION => false]);
+        }
+        $iMax = $this->maxBytes();
+        if (!is_string($sBin) || $sBin === '' || ($iMax > 0 && strlen($sBin) > $iMax))
+            return '';
+
+        return base64_encode($sBin);
+    }
+
+    /**
+     * `max_file_size` of the chat images storage, in bytes. 0 means that object sets no limit.
+     */
+    public function maxBytes()
+    {
+        $oStorage = BxDolStorage::getObjectInstance(self::STORAGE);
+        if (!$oStorage)
+            return 0;
+
+        return (int)($oStorage->getObjectData()['max_file_size'] ?? 0);
+    }
+
+    /**
+     * Local path of a file in the chat images storage, '' when the URL is not ours.
+     */
+    protected function urlToLocalPath($sUrl)
+    {
+        if (!defined('BX_DIRECTORY_STORAGE'))
+            return '';
+
+        $aUrl = parse_url((string)$sUrl);
+        $sPath = urldecode((string)($aUrl['path'] ?? ''));
+        $aQuery = [];
+        if (!empty($aUrl['query']))
+            parse_str((string)$aUrl['query'], $aQuery);
+
+        $sFile = '';
+        if (!empty($aQuery['f']))
+            $sFile = basename((string)$aQuery['f']);
+        elseif (preg_match('#/' . preg_quote(self::STORAGE, '#') . '/([^/?]+)$#', $sPath, $aMatch))
+            $sFile = basename((string)$aMatch[1]);
+
+        if ($sFile === '' || strpos($sFile, '..') !== false)
+            return '';
+
+        $sLocal = BX_DIRECTORY_STORAGE . self::STORAGE . '/' . $sFile;
+        return is_file($sLocal) ? $sLocal : '';
+    }
+}
+
+/** @} */

+ 195 - 24
inc/classes/BxDolAiChatUi.php

@@ -9,6 +9,8 @@
 
 class BxDolAiChatUi
 {
+    protected $_oImages;
+
     public static function getInstance()
     {
         if (!isset($GLOBALS['bxDolClasses'][__CLASS__]))
@@ -17,11 +19,22 @@ class BxDolAiChatUi
         return $GLOBALS['bxDolClasses'][__CLASS__];
     }
 
+    public function __construct()
+    {
+        $this->_oImages = BxDolAiChatImages::getInstance();
+    }
+
     public function storedChatJsonToUiMessages($sJson)
     {
         $aStored = json_decode((string)$sJson, true);
+        if (is_string($aStored))
+            $aStored = json_decode($aStored, true);
         if (!is_array($aStored) || $aStored === [])
             return [];
+        if (isset($aStored['messages']) && is_array($aStored['messages']))
+            $aStored = $aStored['messages'];
+        if (!isset($aStored[0]) && isset($aStored['role']))
+            $aStored = [$aStored];
 
         $aResult = [];
         $i = 0;
@@ -29,30 +42,38 @@ class BxDolAiChatUi
             if (!is_array($aMessage))
                 continue;
 
-            $sType = (string)($aMessage['type'] ?? '');
-            if ($sType === 'tool_call' || $sType === 'tool_call_result')
+            // A tool call turn keeps the assistant text said before the call
+            // (then chat_buttons, etc.): show it, only the tool result is internal.
+            $sType = $this->storedEnumString($aMessage['type'] ?? '');
+            if ($sType === 'tool_call_result')
                 continue;
 
-            $sRole = (string)($aMessage['role'] ?? '');
-            if ($sRole === 'model')
+            $sRole = $this->storedEnumString($aMessage['role'] ?? $aMessage['author'] ?? '');
+            if ($sRole === 'model' || $sType === 'tool_call')
                 $sRole = 'assistant';
+            if ($sRole === 'human')
+                $sRole = 'user';
             if ($sRole !== 'user' && $sRole !== 'assistant')
                 continue;
 
             $aParts = [];
-            $mixedContent = $aMessage['content'] ?? '';
+            if (!empty($aMessage['parts']) && is_array($aMessage['parts'])) {
+                foreach ($aMessage['parts'] as $aBlock) {
+                    $aPart = $this->storedContentBlockToUiPart($aBlock);
+                    if ($aPart)
+                        $aParts[] = $aPart;
+                }
+            }
+            $mixedContent = $aMessage['content'] ?? $aMessage['contents'] ?? '';
             if (is_string($mixedContent) && $mixedContent !== '') {
                 $aParts[] = ['type' => 'text', 'content' => $mixedContent];
             } elseif (is_array($mixedContent)) {
+                if (isset($mixedContent['type']) && !isset($mixedContent[0]))
+                    $mixedContent = [$mixedContent];
                 foreach ($mixedContent as $aBlock) {
-                    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];
+                    $aPart = $this->storedContentBlockToUiPart($aBlock);
+                    if ($aPart)
+                        $aParts[] = $aPart;
                 }
             }
             $sId = '';
@@ -70,7 +91,7 @@ class BxDolAiChatUi
             $i++;
         }
 
-        return $aResult;
+        return $this->foldUiChatMessages($aResult);
     }
 
     /**
@@ -83,15 +104,13 @@ class BxDolAiChatUi
         $i = 0;
 
         foreach ($aMessages as $oMessage) {
-            if (
-                $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage
-                || $oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage
-            ) {
+            // ToolCallMessage is an assistant message holding the text said before
+            // the call; only the tool result is skipped (see storedChatJsonToUiMessages).
+            if ($oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage)
                 continue;
-            }
 
             $sRole = $oMessage->getRole();
-            if ($sRole === 'model')
+            if ($sRole === 'model' || $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage)
                 $sRole = 'assistant';
             if ($sRole !== 'user' && $sRole !== 'assistant')
                 continue;
@@ -106,6 +125,10 @@ class BxDolAiChatUi
 
                 if ($oBlock instanceof NeuronAI\Chat\Messages\ContentBlocks\TextContent && $oBlock->content !== '')
                     $aParts[] = ['type' => 'text', 'content' => $oBlock->content];
+
+                $aImage = $this->_oImages->neuronBlockToUiPart($oBlock);
+                if ($aImage)
+                    $aParts[] = $aImage;
             }
 
             $sId = $oMessage->getMetadata('__id');
@@ -122,7 +145,7 @@ class BxDolAiChatUi
             $i++;
         }
 
-        return $aResult;
+        return $this->foldUiChatMessages($aResult);
     }
 
     /**
@@ -152,8 +175,7 @@ class BxDolAiChatUi
                 if ($aParsed['content'] !== '')
                     $aKept[] = ['type' => 'text', 'content' => $aParsed['content']];
                 $aParts = $aKept;
-                if (!$aActions)
-                    $aActions = $aParsed['actions'];
+                $aActions = $this->mergeChatActions($aActions, $aParsed['actions']);
             }
         }
 
@@ -174,6 +196,54 @@ class BxDolAiChatUi
         return $aOut;
     }
 
+    /**
+     * One model turn can be stored as several assistant messages: the text before a
+     * tool call (ToolCallMessage), then the reply after the tool result — often empty
+     * and carrying only `actions`. Show them as one bubble, like the live stream does.
+     * The first message's id is kept; text is joined, actions are merged.
+     *
+     * @param array<int, array<string, mixed>> $aMessages
+     * @return array<int, array<string, mixed>>
+     */
+    public function foldUiChatMessages($aMessages)
+    {
+        $aOut = [];
+        foreach ($aMessages as $aMessage) {
+            $iLast = count($aOut) - 1;
+            $aPrev = $iLast >= 0 ? $aOut[$iLast] : null;
+            if (($aMessage['role'] ?? '') !== 'assistant' || ($aPrev['role'] ?? '') !== 'assistant') {
+                $aOut[] = $aMessage;
+                continue;
+            }
+
+            $sText = '';
+            $aMedia = [];
+            foreach (array_merge($aPrev['parts'] ?? [], $aMessage['parts'] ?? []) as $aPart) {
+                if (($aPart['type'] ?? '') === 'text') {
+                    $sChunk = (string)($aPart['content'] ?? '');
+                    if ($sChunk !== '')
+                        $sText .= ($sText !== '' ? "\n\n" : '') . $sChunk;
+                    continue;
+                }
+                $aMedia[] = $aPart;
+            }
+
+            $aParts = $sText !== '' ? [['type' => 'text', 'content' => $sText]] : [];
+            $aActions = $this->mergeChatActions($aPrev['actions'] ?? [], $aMessage['actions'] ?? []);
+            $aMerged = array_merge($aPrev, [
+                'parts' => array_merge($aParts, $aMedia),
+                'actions' => $aActions,
+            ]);
+            if ($aActions)
+                $aMerged['metadata'] = ['actions' => $aActions];
+            else
+                unset($aMerged['metadata']);
+            $aOut[$iLast] = $aMerged;
+        }
+
+        return $aOut;
+    }
+
     /**
      * @return array{content: string, actions: array}|null
      */
@@ -206,10 +276,33 @@ class BxDolAiChatUi
         ];
     }
 
+    /**
+     * Union of two action lists, duplicates (same type + label + url) dropped, at most 8.
+     *
+     * @return array<int, array<string, mixed>>
+     */
+    public function mergeChatActions($a, $b)
+    {
+        $aAll = array_merge($this->sanitizeChatActions($a), $this->sanitizeChatActions($b));
+        $aOut = [];
+        $aSeen = [];
+        foreach ($aAll as $aItem) {
+            $sKey = ($aItem['type'] ?? '') . '|' . ($aItem['label'] ?? '') . '|' . ($aItem['url'] ?? '');
+            if (isset($aSeen[$sKey]))
+                continue;
+            $aSeen[$sKey] = true;
+            $aOut[] = $aItem;
+            if (count($aOut) >= 8)
+                break;
+        }
+
+        return $aOut;
+    }
+
     /**
      * @return array<int, array<string, mixed>>
      */
-    protected function sanitizeChatActions($mixed)
+    public function sanitizeChatActions($mixed)
     {
         if (!is_array($mixed))
             return [];
@@ -244,6 +337,84 @@ class BxDolAiChatUi
         return $aOut;
     }
 
+    /** Text parts of a UI message joined, tags stripped, whitespace collapsed. */
+    public function uiChatMessagePlainText($aMessage)
+    {
+        $aTexts = [];
+        foreach ((array)($aMessage['parts'] ?? []) as $aPart) {
+            if (is_array($aPart) && ($aPart['type'] ?? '') === 'text' && is_string($aPart['content'] ?? null))
+                $aTexts[] = $aPart['content'];
+        }
+        $s = strip_tags(html_entity_decode(implode(' ', $aTexts), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
+        return trim(preg_replace('/\s+/u', ' ', $s));
+    }
+
+    /**
+     * Neuron / UI content block from stored chat JSON → UI part.
+     *
+     * @param mixed $mixedBlock
+     * @return array<string, mixed>|null
+     */
+    protected function storedContentBlockToUiPart($mixedBlock)
+    {
+        if (is_string($mixedBlock) && $mixedBlock !== '')
+            return ['type' => 'text', 'content' => $mixedBlock];
+        if (!is_array($mixedBlock))
+            return null;
+
+        $sType = $this->storedEnumString($mixedBlock['type'] ?? '');
+        if (strpos($sType, 'textcontent') !== false)
+            $sType = 'text';
+        if (strpos($sType, 'imagecontent') !== false)
+            $sType = 'image';
+        $sText = (string)($mixedBlock['content'] ?? $mixedBlock['text'] ?? '');
+        if ($sType === 'text' || $sType === '' || $sType === 'array')
+            return $sText !== '' ? ['type' => 'text', 'content' => $sText] : null;
+        if ($sType === 'reasoning' || $sType === 'thinking')
+            return $sText !== '' ? ['type' => 'thinking', 'content' => $sText] : null;
+
+        $aImage = $this->storedImageBlockToUiPart($mixedBlock);
+        return $aImage ?: null;
+    }
+
+    /**
+     * @param array<string, mixed> $aBlock
+     * @return array<string, mixed>|null
+     */
+    protected function storedImageBlockToUiPart($aBlock)
+    {
+        $aSource = is_array($aBlock['source'] ?? null) ? $aBlock['source'] : [];
+        $sSourceKind = strtolower((string)($aSource['type'] ?? ''));
+        $sNeuronSource = $this->storedEnumString($aBlock['source_type'] ?? $aBlock['sourceType'] ?? '');
+        $sMime = $this->_oImages->sanitizeMime($aSource['mimeType'] ?? $aBlock['media_type'] ?? $aBlock['mediaType'] ?? $aBlock['mime'] ?? $aBlock['mimeType'] ?? 'image/jpeg');
+
+        if ($sSourceKind === 'data' && (string)($aSource['value'] ?? '') !== '')
+            return ['type' => 'image', 'source' => ['type' => 'data', 'value' => (string)$aSource['value'], 'mimeType' => $sMime]];
+
+        if (($sNeuronSource === 'base64' || $sNeuronSource === 'data') && (string)($aBlock['content'] ?? '') !== '')
+            return ['type' => 'image', 'source' => ['type' => 'data', 'value' => (string)$aBlock['content'], 'mimeType' => $sMime]];
+
+        $aParsed = $this->_oImages->parsePart($aBlock);
+        if ($aParsed)
+            return ['type' => 'image', 'source' => ['type' => 'url', 'value' => $aParsed['url'], 'mimeType' => $aParsed['mime']]];
+
+        return null;
+    }
+
+    /**
+     * Enum (object, `{value}` array or string) from stored JSON → lower-case string.
+     *
+     * @param mixed $mixed
+     */
+    protected function storedEnumString($mixed)
+    {
+        if (is_object($mixed) && $mixed instanceof \BackedEnum)
+            return strtolower((string)$mixed->value);
+        if (is_array($mixed))
+            return strtolower((string)($mixed['value'] ?? $mixed['name'] ?? ''));
+        return strtolower((string)$mixed);
+    }
+
     protected function isAllowedChatActionUrl($sUrl)
     {
         $a = parse_url((string)$sUrl);

+ 168 - 34
inc/classes/BxDolAiQuery.php

@@ -267,11 +267,8 @@ class BxDolAiQuery extends BxDolDb
     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
+            SELECT " . $this->getChatHistoryRowFields() . "
             FROM `sys_agents_chat_history`
             WHERE `thread_id` = :exact OR `thread_id` LIKE :prefix
             ORDER BY `updated_at` DESC
@@ -282,6 +279,125 @@ class BxDolAiQuery extends BxDolDb
         ]);
     }
 
+    /**
+     * Every thread one owner (profile id, or guest session id) has with this agent:
+     * site-wide and in any context, base and `?chat=` partitions. Newest first.
+     * Owner is matched by LIKE on the thread id, so callers still re-check it via
+     * BxDolAiChat::parseThreadId.
+     */
+    public function getOwnerAgentChatHistoryRows($aAgent, $sOwner, $iLimit = 100)
+    {
+        $sPrefix = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        $sOwner = (string)$sOwner;
+        if ($sPrefix === ':' || $sOwner === '')
+            return [];
+
+        $sOwnerLike = str_replace(['\\', '%', '_'], ['\\\\', '\\%', '\\_'], $sOwner);
+        return $this->getAll("
+            SELECT " . $this->getChatHistoryRowFields() . "
+            FROM `sys_agents_chat_history`
+            WHERE `thread_id` = :site
+               OR `thread_id` LIKE :site_chat
+               OR `thread_id` LIKE :ctx
+               OR `thread_id` LIKE :ctx_chat
+            ORDER BY `updated_at` DESC, `id` DESC
+            LIMIT " . (int)$iLimit, [
+            'site' => $sPrefix . ':' . $sOwner,
+            'site_chat' => $sPrefix . ':' . $sOwnerLike . '#%',
+            'ctx' => $sPrefix . ':%:' . $sOwnerLike,
+            'ctx_chat' => $sPrefix . ':%:' . $sOwnerLike . '#%',
+        ]);
+    }
+
+    /**
+     * The owner's current thread for this agent+context: the newest one that is not
+     * closed — a `?chat=` partition or the base thread. '' when there is none.
+     */
+    public function getCurrentOpenChatThreadId($aAgent, $sOwner, $iContext)
+    {
+        if (!$this->isFieldExists('sys_agents_chat_history', 'closed_reason'))
+            return '';
+
+        $sPrefix = ($aAgent['trigger'] ?? '') . ':' . ($aAgent['id'] ?? '');
+        $sOwner = (string)$sOwner;
+        if ($sPrefix === ':' || $sOwner === '')
+            return '';
+
+        $sBase = ((int)$iContext > 0)
+            ? ($sPrefix . ':' . (int)$iContext . ':' . $sOwner)
+            : ($sPrefix . ':' . $sOwner);
+
+        return (string)$this->getOne("
+            SELECT `thread_id`
+            FROM `sys_agents_chat_history`
+            WHERE (`thread_id` = :exact OR `thread_id` LIKE :hash)
+              AND (`closed_reason` = '' OR `closed_reason` IS NULL)
+            ORDER BY `updated_at` DESC, `id` DESC
+            LIMIT 1
+        ", [
+            'exact' => $sBase,
+            'hash' => $sBase . '#%',
+        ]);
+    }
+
+    /**
+     * Stored thread title; '' when unset, or when the column is not there yet —
+     * callers fall back to deriving one from the messages.
+     */
+    public function getChatHistoryTitle($sThreadId)
+    {
+        if (!$this->isFieldExists('sys_agents_chat_history', 'title'))
+            return '';
+
+        return (string)$this->getOne("SELECT `title` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+            't' => (string)$sThreadId,
+        ]);
+    }
+
+    public function setChatHistoryTitle($sThreadId, $sTitle)
+    {
+        if (!$this->isFieldExists('sys_agents_chat_history', 'title'))
+            return false;
+
+        return (bool)$this->query("UPDATE `sys_agents_chat_history` SET `title` = :s WHERE `thread_id` = :t", [
+            's' => get_mb_substr((string)$sTitle, 0, 255),
+            't' => (string)$sThreadId,
+        ]);
+    }
+
+    /**
+     * History row for a thread, created empty when missing.
+     *
+     * @return int row id, 0 for an invalid thread id
+     */
+    public function ensureChatHistoryRow($sThreadId)
+    {
+        $sThreadId = (string)$sThreadId;
+        if ($sThreadId === '' || $sThreadId === ':')
+            return 0;
+
+        $iId = $this->getChatHistoryIdByThreadId($sThreadId);
+        if ($iId)
+            return $iId;
+
+        $this->query("INSERT IGNORE INTO `sys_agents_chat_history` (`thread_id`, `messages`) VALUES (:t, :m)", [
+            't' => $sThreadId,
+            'm' => '[]',
+        ]);
+
+        return $this->getChatHistoryIdByThreadId($sThreadId);
+    }
+
+    protected function getChatHistoryRowFields()
+    {
+        $sFields = "`id`, `thread_id`, `messages`, `created_at`, `updated_at`";
+        if ($this->isFieldExists('sys_agents_chat_history', 'closed_reason'))
+            $sFields .= ", `closed_reason`";
+        if ($this->isFieldExists('sys_agents_chat_history', 'title'))
+            $sFields .= ", `title`";
+        return $sFields;
+    }
+
     public function getChatHistoryIdByThreadId($sThreadId)
     {
         $sThreadId = (string)$sThreadId;
@@ -447,7 +563,7 @@ class BxDolAiQuery extends BxDolDb
     }
 
     /**
-     * Rename this agent's guest threads (`…:{sessionId}`) onto a profile id.
+     * Rename this agent's guest threads (`…:{sessionId}` or `…:{sessionId}#{chat}`) onto a profile id.
      * If the member thread already has messages, drop the guest copy.
      */
     public function adoptGuestChatHistory($aAgent, $sSessionId, $iProfileId)
@@ -458,36 +574,54 @@ class BxDolAiQuery extends BxDolDb
             return 0;
 
         $sPrefix = $aAgent['trigger'] . ':' . $aAgent['id'] . ':';
-        $sOldSuffix = ':' . $sSessionId;
-        $sNewSuffix = ':' . $iProfileId;
-        $iLen = strlen($sOldSuffix);
-        $aBindings = [
+        $aRows = $this->getAll("SELECT `id`, `thread_id`, `messages` FROM `sys_agents_chat_history` WHERE `thread_id` LIKE :p", [
             'p' => $sPrefix . '%',
-            'o' => $sOldSuffix,
-            'n' => $sNewSuffix,
-        ];
-
-        $this->query("
-            DELETE `g` FROM `sys_agents_chat_history` AS `g`
-            INNER JOIN `sys_agents_chat_history` AS `m`
-                ON `m`.`thread_id` = CONCAT(LEFT(`g`.`thread_id`, CHAR_LENGTH(`g`.`thread_id`) - $iLen), :n)
-            WHERE `g`.`thread_id` LIKE :p AND RIGHT(`g`.`thread_id`, $iLen) = :o
-              AND `m`.`messages` IS NOT NULL AND `m`.`messages` != '' AND `m`.`messages` != '[]'
-        ", $aBindings);
-
-        $this->query("
-            DELETE `m` FROM `sys_agents_chat_history` AS `m`
-            INNER JOIN `sys_agents_chat_history` AS `g`
-                ON `g`.`thread_id` LIKE :p AND RIGHT(`g`.`thread_id`, $iLen) = :o
-               AND `m`.`thread_id` = CONCAT(LEFT(`g`.`thread_id`, CHAR_LENGTH(`g`.`thread_id`) - $iLen), :n)
-            WHERE `m`.`messages` = '' OR `m`.`messages` = '[]' OR `m`.`messages` IS NULL
-        ", $aBindings);
-
-        $iAffected = (int)$this->query("
-            UPDATE `sys_agents_chat_history`
-            SET `thread_id` = CONCAT(LEFT(`thread_id`, CHAR_LENGTH(`thread_id`) - $iLen), :n)
-            WHERE `thread_id` LIKE :p AND RIGHT(`thread_id`, $iLen) = :o
-        ", $aBindings);
+        ]);
+        if (!is_array($aRows) || !$aRows)
+            return 0;
+
+        $iAffected = 0;
+        foreach ($aRows as $aRow) {
+            $sThreadId = (string)($aRow['thread_id'] ?? '');
+            $aParsed = BxDolAiChat::parseThreadId($aAgent, $sThreadId);
+            if (!$aParsed)
+                continue;
+
+            $sSub = (string)$aParsed['chat_history_subindex'];
+            $sOwner = BxDolAiChat::chatHistoryOwnerFromSubindex($sSub);
+            if ($sOwner !== $sSessionId)
+                continue;
+
+            $sConv = strlen($sSub) > strlen($sOwner) ? substr($sSub, strlen($sOwner)) : '';
+            $sNewThread = BxDolAiChat::threadId($aAgent, [
+                'chat_history_context_pid' => (int)$aParsed['chat_history_context_pid'],
+                'chat_history_subindex' => $iProfileId . $sConv,
+            ]);
+            if ($sNewThread === '' || $sNewThread === $sThreadId)
+                continue;
+
+            $aMember = $this->getRow("SELECT `id`, `messages` FROM `sys_agents_chat_history` WHERE `thread_id` = :t", [
+                't' => $sNewThread,
+            ]);
+            $sGuestMsg = trim((string)($aRow['messages'] ?? ''));
+            $bGuestHas = $sGuestMsg !== '' && $sGuestMsg !== '[]';
+            $sMemberMsg = is_array($aMember) ? trim((string)($aMember['messages'] ?? '')) : '';
+            $bMemberHas = $sMemberMsg !== '' && $sMemberMsg !== '[]';
+
+            if ($bMemberHas) {
+                $this->query("DELETE FROM `sys_agents_chat_history` WHERE `id` = :id", ['id' => (int)$aRow['id']]);
+                continue;
+            }
+            if (is_array($aMember) && (int)($aMember['id'] ?? 0) > 0)
+                $this->query("DELETE FROM `sys_agents_chat_history` WHERE `id` = :id", ['id' => (int)$aMember['id']]);
+            if (!$bGuestHas && is_array($aMember))
+                continue;
+
+            $iAffected += (int)$this->query("UPDATE `sys_agents_chat_history` SET `thread_id` = :n WHERE `id` = :id", [
+                'n' => $sNewThread,
+                'id' => (int)$aRow['id'],
+            ]);
+        }
 
         return $iAffected;
     }

+ 351 - 76
inc/classes/BxDolAiTriggerChat.php

@@ -45,8 +45,10 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
         if (json_last_error() !== JSON_ERROR_NONE)
             return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
 
-        $sPrompt = $this->extractPromptFromRequest($aData);
-        if (!$sPrompt)
+        $aTurn = $this->extractUserTurnFromRequest($aData);
+        $sPrompt = BxDolAiChatLimits::getInstance()->applyChatInputLimit($aTurn['text'], $aAgent);
+        $oMessage = BxDolAiChatImages::getInstance()->makeUserMessage($sPrompt, $aTurn['images']);
+        if (!$oMessage)
             return echoJson(['code' => 400, 'msg' => _t('_sys_agents_json_field_err')]);
 
         $aParams = $this->historyParams($aAgent);
@@ -58,56 +60,172 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
         $sThreadId = !empty($aData['threadId']) ? $aData['threadId'] : null;
 
         if (class_exists('NeuronAI\Chat\Messages\Stream\Adapters\AGUIAdapter')) {
-            $this->stream($aAgent['id'], $sPrompt, $aParams, $sThreadId);
+            $this->stream($aAgent['id'], $oMessage, $aParams, $sThreadId);
             return;
         }
 
         try {
-            $sReply = $this->call($aAgent, $sPrompt);
+            $sReply = $this->call($aAgent, $sPrompt !== '' ? $sPrompt : ' ');
         } catch (Throwable $o) {
             return echoJson(['code' => 500, 'msg' => $o->getMessage(), 'messages' => []]);
         }
         return echoJson(['code' => 200, 'msg' => $sReply, 'text' => $sReply]);
     }
 
+    /**
+     * Composer image upload (`system/ai_chat_upload`): one `$_FILES['file']` into the chat images storage.
+     *
+     * @return array{file_id:int,url:string,mime:string}|array{error:string,code?:int}
+     */
+    public function upload($iAgentId)
+    {
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => (int)$iAgentId ? 404 : 400];
+
+        $oAi = $this->getAi();
+        if (!$oAi || !$oAi->canChatDirectly($aAgent))
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $aParams = $this->historyParams($aAgent);
+        if (!$aParams)
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $aFile = $_FILES['file'] ?? null;
+        if (!is_array($aFile))
+            return ['error' => 'No file'];
+
+        $aStored = BxDolAiChatImages::getInstance()->storeUpload($aFile, (int)bx_get_logged_profile_id());
+        if (!empty($aStored['error']))
+            return ['error' => $aStored['error']];
+
+        return $aStored;
+    }
+
+    /**
+     * The caller's own conversations with an agent (`system/get_ai_chat_threads`).
+     *
+     * @return array{threads:array}|array{error:string,code:int}
+     */
+    public function listThreads($iAgentId)
+    {
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => (int)$iAgentId ? 404 : 400];
+
+        $oAi = $this->getAi();
+        if (!$oAi || !$oAi->canChatDirectly($aAgent))
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        return ['threads' => BxDolAiChat::getInstance()->listOwnAgentChatThreads($aAgent)];
+    }
+
+    /**
+     * One of the caller's earlier conversations, read-only (`system/get_ai_chat_thread`):
+     * the transcript in the same UI message shape hydrate returns. Does not open or
+     * close anything — which thread is current is left as it was.
+     *
+     * @return array{messages:array,status:string,closed_reason:string,artifacts:array}|array{error:string,code:int}
+     */
+    public function getThread($iAgentId, $sThreadId = '')
+    {
+        $aAgent = $this->loadAgent($iAgentId);
+        if (!$aAgent)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => (int)$iAgentId ? 404 : 400];
+
+        $oAi = $this->getAi();
+        if (!$oAi || !$oAi->canChatDirectly($aAgent))
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $oChat = BxDolAiChat::getInstance();
+        $sThreadId = trim((string)$sThreadId);
+        if ($sThreadId === '' || !$oChat->isOwnAgentChatThread($aAgent, $sThreadId))
+            return ['error' => _t('_sys_agents_unauthorized'), 'code' => 403];
+
+        $aMessages = $oChat->getChatHistoryUiMessagesByThread($aAgent, $sThreadId);
+        if ($aMessages === false)
+            return ['error' => _t('_sys_agents_agent_not_found'), 'code' => 404];
+
+        $sReason = trim((string)$oChat->getChatHistoryClosedReasonByThread($aAgent, $sThreadId));
+        return [
+            'messages' => is_array($aMessages) ? $aMessages : [],
+            'status' => $sReason !== '' ? 'closed' : 'opened',
+            'closed_reason' => $sReason,
+            'artifacts' => $oChat->getChatHistoryArtifactsByThread($aAgent, $sThreadId),
+        ];
+    }
+
     public function extractPromptFromRequest($aData)
     {
+        $aTurn = $this->extractUserTurnFromRequest($aData);
+        return $aTurn['text'];
+    }
+
+    /**
+     * Last user turn: text + image URLs from AG-UI `content` / `parts`.
+     *
+     * @return array{text:string,images:array<int,array{url:string,mime:string}>}
+     */
+    public function extractUserTurnFromRequest($aData)
+    {
+        $aEmpty = ['text' => '', 'images' => []];
         if (!is_array($aData))
-            return '';
+            return $aEmpty;
 
-        if (!empty($aData['prompt']) && is_string($aData['prompt']))
-            return trim($aData['prompt']);
+        $sPrompt = (!empty($aData['prompt']) && is_string($aData['prompt'])) ? trim($aData['prompt']) : '';
 
         if (empty($aData['messages']) || !is_array($aData['messages']))
-            return '';
+            return $sPrompt !== '' ? ['text' => $sPrompt, 'images' => []] : $aEmpty;
 
+        $oImages = BxDolAiChatImages::getInstance();
         for ($i = count($aData['messages']) - 1; $i >= 0; $i--) {
             $aMessage = $aData['messages'][$i];
-            if (!is_array($aMessage))
+            if (!is_array($aMessage) || ($aMessage['role'] ?? '') !== 'user')
                 continue;
 
-            if (($aMessage['role'] ?? '') !== 'user')
-                continue;
-
-            if (isset($aMessage['content']) && is_string($aMessage['content']))
-                return trim($aMessage['content']);
-
-            if (!empty($aMessage['parts']) && is_array($aMessage['parts'])) {
-                $aText = [];
-                foreach ($aMessage['parts'] as $aPart) {
-                    if (is_array($aPart) && ($aPart['type'] ?? '') === 'text' && isset($aPart['content']))
-                        $aText[] = $aPart['content'];
+            $aParts = [];
+            if (!empty($aMessage['parts']) && is_array($aMessage['parts']))
+                $aParts = array_merge($aParts, $aMessage['parts']);
+            if (isset($aMessage['content']) && is_array($aMessage['content']))
+                $aParts = array_merge($aParts, $aMessage['content']);
+
+            $aText = [];
+            $aImages = [];
+            foreach ($aParts as $aPart) {
+                if (!is_array($aPart))
+                    continue;
+                $sType = strtolower((string)($aPart['type'] ?? ''));
+                if ($sType === 'text') {
+                    $s = trim((string)($aPart['content'] ?? $aPart['text'] ?? ''));
+                    if ($s !== '')
+                        $aText[] = $s;
+                    continue;
                 }
-                $s = trim(implode("\n", $aText));
-                if ($s !== '')
-                    return $s;
+                $aImage = $oImages->parsePart($aPart);
+                if ($aImage)
+                    $aImages[] = $aImage;
             }
+
+            if (!$aParts && isset($aMessage['content']) && is_string($aMessage['content']))
+                $aText[] = trim($aMessage['content']);
+
+            $sText = trim(implode("\n", $aText));
+            if ($sText === '' && $sPrompt !== '')
+                $sText = $sPrompt;
+
+            return [
+                'text' => $sText,
+                'images' => array_slice($aImages, 0, BxDolAiChatImages::MAX_PER_TURN),
+            ];
         }
 
-        return '';
+        return $aEmpty;
     }
 
-    public function stream($iAgentId, $sPrompt, $aParams = [], $sThreadId = null)
+    /**
+     * @param string|NeuronAI\Chat\Messages\UserMessage $mixedPrompt plain text, or a ready user message (text + images)
+     */
+    public function stream($iAgentId, $mixedPrompt, $aParams = [], $sThreadId = null)
     {
         $oChat = BxDolAiChat::getInstance();
         $oLimits = BxDolAiChatLimits::getInstance();
@@ -151,6 +269,9 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
             exit;
         }
 
+        if ($aAgent)
+            $oChat->openPartitionedChatThread($aAgent, $aParams);
+
         $iRequestTurns = (int)($aParams['request_user_turns'] ?? 0);
         if ($aAgent && $oLimits->isChatTurnLimitReached($aAgent, $oLimits->getChatUserTurnCount((int)$iAgentId, $aParams), $iRequestTurns)) {
             $oChat->emitConversationClosed('limit', '', $aAgent, $aParams);
@@ -160,22 +281,36 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
             exit;
         }
 
-        $sPrompt = $oLimits->applyChatInputLimit((string)$sPrompt, $aAgent ?: []);
+        $oUserMessage = $mixedPrompt instanceof NeuronAI\Chat\Messages\UserMessage
+            ? $mixedPrompt
+            : BxDolAiChatImages::getInstance()->makeUserMessage($oLimits->applyChatInputLimit((string)$mixedPrompt, $aAgent ?: []), []);
+        $sPrompt = $oUserMessage ? (string)$oUserMessage->getContent() : '';
 
+        $oChat->resetPendingChatActions();
         $bStarted = false;
         $aSseState = $this->resetActionsSseState();
         $o = null;
         try {
+            if (!$oUserMessage)
+                throw new Exception('Empty chat message');
             $o = BxDolAi::getAgentInstance((int)$iAgentId, $aParams);
-            $oHandler = $o->stream(new NeuronAI\Chat\Messages\UserMessage($sPrompt));
+            $oHandler = $o->stream($oUserMessage);
 
-            foreach ($oHandler->events($oAdapter) as $sEvent) {
+            foreach ($oHandler->events($oAdapter) as $sChunk) {
                 $bStarted = true;
-                $this->processActionsSseEvent($sEvent, $aSseState, $fEmit);
+                foreach ($this->splitSseEvents($sChunk) as $sEvent) {
+                    $aPayload = $this->parseSseEvent($sEvent);
+                    $sType = is_array($aPayload) ? (string)($aPayload['type'] ?? '') : '';
+                    // Buttons queued by tools must reach the client before RUN_FINISHED.
+                    if ($sType === 'RUN_FINISHED')
+                        $this->finishActionsSse($o, $aSseState, $fEmit);
+                    $this->processActionsSseEvent($sEvent, $aSseState, $fEmit);
+                }
             }
 
             $this->flushActionsSseState($aSseState, $fEmit);
-            $this->persistAssistantActions($o);
+            if (empty($aSseState['customEmitted']))
+                $this->finishActionsSse($o, $aSseState, $fEmit);
 
             if ($aAgent && $oLimits->isChatTurnLimitReached($aAgent, $oLimits->getChatUserTurnCount((int)$iAgentId, $aParams)))
                 $oChat->emitConversationClosed('limit', '', $aAgent, $aParams);
@@ -217,8 +352,11 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
         if (!$aParams)
             return echoJson(['code' => 403, 'msg' => _t('_sys_agents_unauthorized'), 'messages' => [], 'activeRun' => null, 'interrupts' => null]);
 
+        $oChat = BxDolAiChat::getInstance();
+        $oChat->openPartitionedChatThread($aAgent, $aParams);
+
         try {
-            $aMessages = BxDolAiChat::getInstance()->getChatHistoryUiMessages($aAgent['id'], $aParams);
+            $aMessages = $oChat->getChatHistoryUiMessages($aAgent['id'], $aParams);
         } catch (Throwable $o) {
             $aMessages = [];
         }
@@ -236,6 +374,7 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
     /**
      * Member/guest subindex + optional `?context=` (group/org profile id).
      * Invalid context is 403 — never fall back to the site-wide thread.
+     * Without `?chat=`, follow the owner's current open thread (see BxDolAiChat::resolveCurrentChatThread).
      */
     protected function historyParams($aAgent)
     {
@@ -246,7 +385,7 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
 
         $aParams = $oChat->resolveChatHistoryParams((int)$aAgent['id']);
         $aParams['chat_history_context_pid'] = (int)$mixedContext;
-        return $aParams;
+        return $oChat->resolveCurrentChatThread($aAgent, $aParams);
     }
 
     protected function loadAgent($iAgentId)
@@ -267,24 +406,70 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
         return 'data: ' . json_encode($aPayload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
     }
 
-    protected function parseSseEvent($sEvent)
+    /**
+     * One adapter chunk may carry several SSE frames (`data: …\n\n` ×N). Split so each is processed on its own.
+     *
+     * @return string[]
+     */
+    protected function splitSseEvents($sChunk)
     {
-        $s = trim((string)$sEvent);
-        if (!preg_match('/^data:\s*(.+)$/s', $s, $aM))
-            return null;
+        $s = trim(str_replace(["\r\n", "\r"], "\n", (string)$sChunk));
+        if ($s === '')
+            return [$sChunk];
 
-        $a = json_decode($aM[1], true);
-        return is_array($a) ? $a : null;
+        $aOut = [];
+        foreach (preg_split('/\n\s*\n/', $s) as $sFrame) {
+            $sFrame = trim($sFrame);
+            if ($sFrame !== '')
+                $aOut[] = $sFrame . "\n\n";
+        }
+        return $aOut ?: [$sChunk];
     }
 
-    protected function resetActionsSseState()
+    protected function parseSseEvent($sEvent)
     {
-        return [
+        $s = str_replace(["\r\n", "\r"], "\n", (string)$sEvent);
+        $aLast = null;
+        foreach (preg_split('/\n\s*\n/', trim($s)) as $sFrame) {
+            $aData = [];
+            foreach (explode("\n", $sFrame) as $sLine) {
+                if (stripos($sLine, 'data:') === 0)
+                    $aData[] = ltrim(substr($sLine, 5));
+            }
+            if (!$aData)
+                continue;
+            $a = json_decode(implode("\n", $aData), true);
+            if (is_array($a))
+                $aLast = $a;
+        }
+        return $aLast;
+    }
+
+    /**
+     * `lastMessageId` / `lastText` remember the assistant message just finished (so
+     * buttons queued by a tool can be attached to it); `customEmitted` — the
+     * `chat_actions` event went out for this turn already.
+     */
+    protected function resetActionsSseState($aKeep = [])
+    {
+        return array_merge([
             'mode' => 'undecided',
             'buf' => '',
             'messageId' => '',
             'held' => [],
-        ];
+            'lastMessageId' => '',
+            'lastText' => '',
+            'customEmitted' => false,
+        ], $aKeep);
+    }
+
+    protected function rememberActionsSseTurn(&$aState, $sMessageId = '', $sText = '', $bCustom = null)
+    {
+        $aState = $this->resetActionsSseState([
+            'lastMessageId' => $sMessageId !== '' ? $sMessageId : (string)($aState['lastMessageId'] ?? ''),
+            'lastText' => $sText !== '' ? $sText : (string)($aState['lastText'] ?? ''),
+            'customEmitted' => $bCustom === null ? !empty($aState['customEmitted']) : (bool)$bCustom,
+        ]);
     }
 
     protected function emitHeldActionsSse(&$aState, $fEmit)
@@ -313,17 +498,73 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
             'type' => 'TEXT_MESSAGE_END',
             'messageId' => $sId,
         ]));
-        if (!empty($aParsed['actions'])) {
-            $fEmit($this->sseEvent([
-                'type' => 'CUSTOM',
-                'name' => 'chat_actions',
-                'value' => [
-                    'messageId' => $sId,
-                    'actions' => $aParsed['actions'],
-                ],
-            ]));
-        }
-        $aState = $this->resetActionsSseState();
+        $bCustom = false;
+        if (!empty($aParsed['actions']))
+            $bCustom = $this->emitActionsCustomSse($sId, $aParsed['actions'], $fEmit);
+
+        $aState = $this->resetActionsSseState([
+            'lastMessageId' => $sId,
+            'lastText' => (string)($aParsed['content'] ?? ''),
+            'customEmitted' => !empty($aState['customEmitted']) || $bCustom,
+        ]);
+        if (empty($aState['customEmitted']))
+            $this->emitPendingActionsSse($aState, $fEmit, $aParsed['actions'] ?? []);
+    }
+
+    /**
+     * `chat_actions` custom event: `messageId` + `value` (the buttons) — what the App chat widget reads.
+     */
+    protected function emitActionsCustomSse($sMessageId, $aActions, $fEmit)
+    {
+        $aActions = BxDolAiChatUi::getInstance()->sanitizeChatActions($aActions);
+        if (!$aActions)
+            return false;
+
+        $fEmit($this->sseEvent([
+            'type' => 'CUSTOM',
+            'name' => 'chat_actions',
+            'messageId' => (string)$sMessageId,
+            'value' => $aActions,
+        ]));
+        return true;
+    }
+
+    /**
+     * Buttons queued by the `chat_buttons` tool (plus `$aExtra`), once per turn,
+     * pinned to the last assistant message.
+     */
+    protected function emitPendingActionsSse(&$aState, $fEmit, $aExtra = [])
+    {
+        if (!empty($aState['customEmitted']))
+            return false;
+
+        $oChat = BxDolAiChat::getInstance();
+        $aActions = BxDolAiChatUi::getInstance()->mergeChatActions($oChat->getPendingChatActions(), $aExtra);
+        if (!$aActions)
+            return false;
+
+        $sMid = (string)($aState['lastMessageId'] ?? '');
+        if ($sMid === '')
+            $sMid = (string)($aState['messageId'] ?? '');
+
+        if (!$this->emitActionsCustomSse($sMid, $aActions, $fEmit))
+            return false;
+
+        $aState['customEmitted'] = true;
+        return true;
+    }
+
+    /**
+     * End of the model turn: flush held text, pin actions to the stored assistant
+     * message, emit whatever buttons are still pending.
+     */
+    protected function finishActionsSse($oAgent, &$aState, $fEmit)
+    {
+        $this->flushActionsSseState($aState, $fEmit);
+        $aDone = $this->persistAssistantActions($oAgent, (string)($aState['lastText'] ?? ''));
+        if (($aState['lastMessageId'] ?? '') === '' && !empty($aDone['messageId']))
+            $aState['lastMessageId'] = (string)$aDone['messageId'];
+        $this->emitPendingActionsSse($aState, $fEmit, $aDone['actions'] ?? []);
     }
 
     protected function processActionsSseEvent($sEvent, &$aState, $fEmit)
@@ -368,9 +609,12 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
                 $this->emitParsedActionsSse($aState, $fEmit, $aParsed);
                 return;
             }
+            $sId = (string)($aState['messageId'] ?? '');
+            $sText = (string)($aState['buf'] ?? '');
             $this->emitHeldActionsSse($aState, $fEmit);
             $fEmit($sEvent);
-            $aState = $this->resetActionsSseState();
+            $this->rememberActionsSseTurn($aState, $sId, $sText);
+            $this->emitPendingActionsSse($aState, $fEmit);
             return;
         }
 
@@ -380,6 +624,13 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
             return;
         }
 
+        // Tool call frames are internal: not forwarded, but they end the text so far.
+        if ($sType === 'TOOL_CALL_START' || $sType === 'TOOL_CALL_ARGS' || $sType === 'TOOL_CALL_END' || $sType === 'TOOL_CALL_RESULT') {
+            if ($aState['mode'] === 'json' || $aState['held'] || $aState['buf'] !== '')
+                $this->flushActionsSseState($aState, $fEmit);
+            return;
+        }
+
         if ($aState['mode'] === 'json' || $aState['held'])
             $this->flushActionsSseState($aState, $fEmit);
 
@@ -399,55 +650,79 @@ class BxDolAiTriggerChat extends BxDolAiTrigger
 
     protected function flushActionsSseState(&$aState, $fEmit)
     {
-        $oUi = BxDolAiChatUi::getInstance();
         if ($aState['buf'] === '' && !$aState['held'])
             return;
 
-        $aParsed = $oUi->parseAssistantChatPayload($aState['buf']);
+        $aParsed = BxDolAiChatUi::getInstance()->parseAssistantChatPayload($aState['buf']);
         if ($aParsed && $aState['mode'] !== 'text') {
             $this->emitParsedActionsSse($aState, $fEmit, $aParsed);
             return;
         }
 
+        $sId = (string)($aState['messageId'] ?? '');
+        $sText = (string)($aState['buf'] ?? '');
         $this->emitHeldActionsSse($aState, $fEmit);
-        $aState = $this->resetActionsSseState();
+        $this->rememberActionsSseTurn($aState, $sId, $sText);
     }
 
-    protected function persistAssistantActions($oAgent)
+    /**
+     * Pin this turn's actions (JSON payload and/or `chat_buttons` tool) to the last
+     * assistant message in history, so hydrate shows them again.
+     *
+     * @return array{messageId: string, actions: array}
+     */
+    protected function persistAssistantActions($oAgent, $sThisTurnText = '')
     {
         $oUi = BxDolAiChatUi::getInstance();
+        $aEmpty = ['messageId' => '', 'actions' => []];
+        $aPending = BxDolAiChat::getInstance()->takePendingChatActions();
+
         if (!is_object($oAgent) || !method_exists($oAgent, 'getChatHistory'))
-            return;
+            return ['messageId' => '', 'actions' => $aPending];
 
         $oHistory = $oAgent->getChatHistory();
-        if (!($oHistory instanceof BxDolAiChatHistory) || !method_exists($oHistory, 'persistMessages'))
-            return;
+        if (!is_object($oHistory) || !method_exists($oHistory, 'getMessages'))
+            return ['messageId' => '', 'actions' => $aPending];
 
         $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
-            ) {
+            if ($oMessage instanceof NeuronAI\Chat\Messages\ToolResultMessage)
                 continue;
-            }
 
+            // When the model obeys "do not write another message after chat_buttons"
+            // the turn ends on the ToolCallMessage: pin the buttons to it, or they
+            // would be lost from history.
             $sRole = $oMessage->getRole();
-            if ($sRole === 'model')
+            if ($sRole === 'model' || $oMessage instanceof NeuronAI\Chat\Messages\ToolCallMessage)
                 $sRole = 'assistant';
             if ($sRole !== 'assistant')
-                return;
-
-            $aParsed = $oUi->parseAssistantChatPayload((string)$oMessage->getContent());
-            if (!$aParsed)
-                return;
-
-            $oMessage->setContents($aParsed['content']);
-            $oMessage->addMetadata('actions', $aParsed['actions']);
-            $oHistory->persistMessages();
-            return;
+                return ['messageId' => '', 'actions' => $aPending];
+
+            $sStored = (string)$oMessage->getContent();
+            $aParsed = $oUi->parseAssistantChatPayload($sStored);
+            if (!$aParsed && $sThisTurnText !== '')
+                $aParsed = $oUi->parseAssistantChatPayload($sThisTurnText);
+
+            $aActions = $oUi->mergeChatActions($aPending, is_array($aParsed) ? ($aParsed['actions'] ?? []) : []);
+            if (!$aActions)
+                return $aEmpty;
+
+            if ($aParsed && method_exists($oMessage, 'setContents'))
+                $oMessage->setContents($aParsed['content']);
+            if (method_exists($oMessage, 'addMetadata'))
+                $oMessage->addMetadata('actions', $aActions);
+            if (method_exists($oHistory, 'persistMessages'))
+                $oHistory->persistMessages();
+
+            $sId = method_exists($oMessage, 'getMetadata') ? $oMessage->getMetadata('__id') : '';
+            return [
+                'messageId' => is_string($sId) ? $sId : '',
+                'actions' => $aActions,
+            ];
         }
+
+        return ['messageId' => '', 'actions' => $aPending];
     }
 
     /**

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
install/sql/system.sql


+ 4 - 0
modules/boonex/english/data/langs/system/en.xml

@@ -1978,6 +1978,8 @@
     <string name="_adm_stg_cpt_option_sys_api_url_root_push"><![CDATA[Site main URL in push notifications]]></string>
     <string name="_adm_stg_cpt_option_sys_agents_inspector_key"><![CDATA[Inspector key]]></string>
     <string name="_adm_stg_cpt_option_sys_agents_inspector_key_info"><![CDATA[Get key at https://app.inspector.dev/]]></string>
+    <string name="_adm_stg_cpt_option_sys_agents_operator_agent"><![CDATA[Operator agent]]></string>
+    <string name="_adm_stg_cpt_option_sys_agents_operator_agent_info"><![CDATA[Agent shown to site operators: the Dashboard block and the App floating chat bubble. Block settings are fixed in get_block_ai_agent_operator.]]></string>
     <string name="_adm_stg_cpt_option_sys_agents_asst_chats_trans_del"><![CDATA[Auto delete transient chats everyday]]></string>
     <string name="_adm_stg_cpt_option_sys_viewport_meta_tag"><![CDATA[Viewport meta tag]]></string>
 
@@ -2403,6 +2405,8 @@
     <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_txt_search"><![CDATA[Search chats...]]></string>
+    <string name="_sys_agents_agents_txt_search_empty"><![CDATA[No chats found]]></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>

+ 4 - 0
modules/boonex/russian/data/langs/system/ru.xml

@@ -1949,6 +1949,8 @@
     <string name="_adm_stg_cpt_option_sys_api_url_root_push"><![CDATA[Основной URL-адрес сайта в push-уведомлениях]]></string>
     <string name="_adm_stg_cpt_option_sys_agents_inspector_key"><![CDATA[Ключ Inspector]]></string>
     <string name="_adm_stg_cpt_option_sys_agents_inspector_key_info"><![CDATA[Получить ключ на https://app.inspector.dev/]]></string>
+    <string name="_adm_stg_cpt_option_sys_agents_operator_agent"><![CDATA[Агент оператора]]></string>
+    <string name="_adm_stg_cpt_option_sys_agents_operator_agent_info"><![CDATA[Агент для операторов сайта: блок на Dashboard и плавающий чат в App. Настройки блока зашиты в get_block_ai_agent_operator.]]></string>
     <string name="_adm_stg_cpt_option_sys_agents_asst_chats_trans_del"><![CDATA[Автоматическое удаление временных чатов каждый день]]></string>
     <string name="_adm_stg_cpt_option_sys_viewport_meta_tag"><![CDATA[Метатег области просмотра]]></string>
 
@@ -2374,6 +2376,8 @@
         <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_txt_search"><![CDATA[Поиск по чатам...]]></string>
+        <string name="_sys_agents_agents_txt_search_empty"><![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>

+ 144 - 47
studio/js/agents.js

@@ -66,6 +66,7 @@ BxDolStudioPageAgents.prototype.initAgentChat = function(sSelector, iAgentId, aT
         e.preventDefault();
         $this._openAgentChatThread($(this));
     });
+    this._bindAgentChatSearch(oBox);
 
     var oFirst = oBox.find('.bx-agents-popup-chat-thread').first();
     if (oFirst.length)
@@ -109,9 +110,11 @@ BxDolStudioPageAgents.prototype._fillAgentChatThreads = function(oBox, aThreads)
         oBtn.attr({
             'data-thread-id': t.thread_id || '',
             'data-writable': bMine ? 1 : 0,
-            'data-context': parseInt(t.context_pid, 10) || 0
+            'data-context': parseInt(t.context_pid, 10) || 0,
+            'data-status': t.status === 'closed' ? 'closed' : 'opened'
         });
         oBtn.data('artifacts', Array.isArray(t.artifacts) ? t.artifacts : []);
+        oBtn.data('messages', Array.isArray(t.messages) ? t.messages : null);
         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>')
@@ -122,16 +125,39 @@ BxDolStudioPageAgents.prototype._fillAgentChatThreads = function(oBox, aThreads)
     }
 };
 
-BxDolStudioPageAgents.prototype._ensureAgentChatStyles = function() {
-    if (document.getElementById('bx-agents-popup-chat-inline-css'))
+BxDolStudioPageAgents.prototype._bindAgentChatSearch = function(oBox) {
+    var oInput = oBox.find('.bx-agents-popup-chat-list-search-input');
+    if (!oInput.length)
         return;
-    var oStyle = document.createElement('style');
-    oStyle.id = 'bx-agents-popup-chat-inline-css';
+
+    oInput.val('').off('input.agentChat').on('input.agentChat', function() {
+        var sQuery = $.trim($(this).val()).toLowerCase();
+        var iShown = 0;
+        oBox.find('.bx-agents-popup-chat-thread').each(function() {
+            var bMatch = !sQuery || $(this).text().toLowerCase().indexOf(sQuery) !== -1;
+            $(this).toggle(bMatch);
+            if (bMatch)
+                iShown++;
+        });
+        oBox.find('.bx-agents-popup-chat-list-empty').toggle(!iShown);
+    });
+};
+
+BxDolStudioPageAgents.prototype._ensureAgentChatStyles = function() {
+    var oStyle = document.getElementById('bx-agents-popup-chat-inline-css');
+    if (!oStyle) {
+        oStyle = document.createElement('style');
+        oStyle.id = 'bx-agents-popup-chat-inline-css';
+        document.head.appendChild(oStyle);
+    }
     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-search{margin:0 0 0.5rem;}' +
+        '.bx-agents-popup-chat-list-search-input{width:100%;box-sizing:border-box;padding:0.35rem 0.5rem;border:1px solid rgba(127,127,127,.35);border-radius:0.375rem;background:transparent;color:inherit;font-size:0.85rem;}' +
+        '.bx-agents-popup-chat-list-empty{opacity:.6;font-size:0.85rem;padding:0.5rem 0;}' +
         '.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);}' +
@@ -147,9 +173,16 @@ BxDolStudioPageAgents.prototype._ensureAgentChatStyles = function() {
         '.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);
+        '.bx-agents-popup-chat{height:auto;flex:1 1 auto;min-height:16rem;max-height:100%;display:flex;flex-direction:column;}' +
+        '.bx-agents-popup-chat-note{margin-top:0.5rem;font-size:0.8rem;opacity:.7;}' +
+        '.bx-ai-chat-message-inner{display:flex;flex-direction:column;gap:0.4rem;}' +
+        '.bx-ai-chat-image{display:block;max-width:12rem;max-height:12rem;border-radius:0.5rem;object-fit:cover;}' +
+        '.bx-ai-chat-actions{display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.15rem;}' +
+        '.bx-ai-chat-action{display:inline-flex;align-items:center;padding:0.2rem 0.65rem;border-radius:999px;font-size:0.8rem;line-height:1.3;border:1px solid rgba(127,127,127,.35);background:rgba(127,127,127,.12);color:inherit;text-decoration:none;}' +
+        '.bx-ai-chat-action-link:hover{opacity:.85;}' +
+        '.bx-agents-popup-thread{display:flex;flex-direction:column;flex:1 1 auto;min-height:0;height:100%;}' +
+        '.bx-agents-popup-thread-messages{flex:1;overflow-y:auto;min-height:16rem;}' +
+        '.bx-agents-popup-thread-empty{opacity:.6;font-size:0.85rem;padding:0.5rem;}';
 };
 
 BxDolStudioPageAgents.prototype._openAgentChatThread = function(oBtn) {
@@ -167,31 +200,40 @@ BxDolStudioPageAgents.prototype._openAgentChatThread = function(oBtn) {
     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') || '';
+    var bClosed = oBtn.attr('data-status') === 'closed';
 
     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);
+        oBox.find('.bx-agents-popup-chat-note').toggle(!bWritable || bClosed);
+    this._loadReadonlyAgentChat(sSelector, iAgentId, sThreadId, oBtn.data('messages'));
 };
 
-BxDolStudioPageAgents.prototype._initWritableAgentChat = function(sSelector, iAgentId, iContext) {
+BxDolStudioPageAgents.prototype._chatQueryFromThreadId = function(sThreadId) {
+    var s = String(sThreadId || '');
+    var i = s.lastIndexOf('.');
+    if (i === -1)
+        return '';
+    var sChat = s.slice(i + 1);
+    return /^[A-Za-z0-9_-]{8,64}$/.test(sChat) ? sChat : '';
+};
+
+BxDolStudioPageAgents.prototype._initWritableAgentChat = function(sSelector, iAgentId, iContext, sThreadId) {
     if (typeof una === 'undefined' || !una.Chat)
         return;
 
     var sRoot = (typeof sUrlRoot !== 'undefined') ? sUrlRoot : '/';
     var sEndpoint = sRoot + 'sys-ai-chat/' + encodeURIComponent(iAgentId);
+    var aQuery = [];
     iContext = parseInt(iContext, 10) || 0;
     if (iContext > 0)
-        sEndpoint += '&context=' + encodeURIComponent(iContext);
+        aQuery.push('context=' + encodeURIComponent(iContext));
+    var sChat = this._chatQueryFromThreadId(sThreadId);
+    if (sChat)
+        aQuery.push('chat=' + encodeURIComponent(sChat));
+    if (aQuery.length)
+        sEndpoint += '?' + aQuery.join('&');
 
     this._oAgentChat = una.Chat.init(sSelector, {
         agentId: iAgentId,
@@ -201,7 +243,7 @@ BxDolStudioPageAgents.prototype._initWritableAgentChat = function(sSelector, iAg
     });
 };
 
-BxDolStudioPageAgents.prototype._loadReadonlyAgentChat = function(sSelector, iAgentId, sThreadId) {
+BxDolStudioPageAgents.prototype._loadReadonlyAgentChat = function(sSelector, iAgentId, sThreadId, aCached) {
     var $this = this;
     var el = typeof sSelector === 'string' ? document.querySelector(sSelector) : sSelector;
     if (!el)
@@ -211,6 +253,11 @@ BxDolStudioPageAgents.prototype._loadReadonlyAgentChat = function(sSelector, iAg
     var iLoad = this._iAgentChatLoad;
     el.innerHTML = '';
 
+    if (Array.isArray(aCached)) {
+        this._renderReadonlyAgentChat(el, aCached);
+        return;
+    }
+
     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);
@@ -225,10 +272,6 @@ BxDolStudioPageAgents.prototype._loadReadonlyAgentChat = function(sSelector, iAg
             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() {
@@ -236,7 +279,7 @@ BxDolStudioPageAgents.prototype._loadReadonlyAgentChat = function(sSelector, iAg
             return;
         el.innerHTML = '';
     });
-    };
+};
 
 BxDolStudioPageAgents.prototype._ensureAgentChatArtifactsPanel = function(oBox) {
     if (!oBox || !oBox.length)
@@ -277,52 +320,106 @@ BxDolStudioPageAgents.prototype._renderAgentChatArtifacts = function(oBox, aArti
 };
 
 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;">';
+    if (aMessages && !Array.isArray(aMessages))
+        aMessages = Object.keys(aMessages).map(function(k) { return aMessages[k]; });
+    aMessages = Array.isArray(aMessages) ? aMessages : [];
+    var html = '<div class="bx-agents-popup-thread">' +
+        '<div class="bx-agents-popup-thread-messages bx-def-border bx-def-round-corners bx-def-padding">';
+    var n = 0;
     for (var i = 0; i < aMessages.length; i++) {
         var m = aMessages[i] || {};
         var isUser = m.role === 'user';
+        var aParts = Array.isArray(m.parts) ? m.parts : [];
+        var body = '';
         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;
+        for (var j = 0; j < aParts.length; j++) {
+            var p = aParts[j] || {};
+            if (p.type === 'image' || p.type === 'image_url') {
+                var src = this._agentChatImageSrc(p);
+                if (src)
+                    body += '<img class="bx-ai-chat-image" src="' + this._escAgentChat(src) + '" alt="" />';
+                continue;
             }
+            if ((p.type === 'text' || !p.type) && p.content)
+                text += (text ? '\n' : '') + p.content;
         }
+        if (text)
+            body += '<div class="bx-ai-chat-text">' + this._escAgentChat(text).replace(/\n/g, '<br />') + '</div>';
+        if (!isUser)
+            body += this._renderAgentChatActions(m.actions || (m.metadata && m.metadata.actions) || []);
+        if (!body)
+            continue;
+        n++;
         html += '<div class="bx-ai-chat-message" style="display:flex;justify-content:' + (isUser ? 'flex-end' : 'flex-start') + ';margin:6px 0;">' +
-            '<div class="bx-ai-chat-message-inner bx-def-padding-sec bx-def-round-corners" style="max-width:85%;background:' + (isUser ? '#2563eb' : '#fff') + ';color:' + (isUser ? '#fff' : 'inherit') + ';">' +
-            this._escAgentChat(text).replace(/\n/g, '<br />') +
-            this._renderReadonlyAgentChatActions(m.actions || (m.metadata && m.metadata.actions) || []) +
+            '<div class="bx-ai-chat-message-inner bx-def-padding-sec bx-def-round-corners" style="max-width:85%;background:' + (isUser ? '#2563eb' : 'rgba(127,127,127,.18)') + ';color:' + (isUser ? '#fff' : 'inherit') + ';">' +
+            body +
             '</div></div>';
     }
+    if (!n)
+        html += '<div class="bx-agents-popup-thread-empty">No messages</div>';
     html += '</div></div>';
     el.innerHTML = html;
-    var list = el.querySelector('.bx-ai-chat-messages');
+    var list = el.querySelector('.bx-agents-popup-thread-messages');
     if (list)
         list.scrollTop = list.scrollHeight;
 };
 
-BxDolStudioPageAgents.prototype._renderReadonlyAgentChatActions = function(aActions) {
-    if (!aActions || !aActions.length)
+BxDolStudioPageAgents.prototype._agentChatImageSrc = function(p) {
+    if (!p)
         return '';
+    if (p.url)
+        return this._agentChatSafeUrl(p.url);
+    var src = p.source || {};
+    var kind = String(src.type || '').toLowerCase();
+    if (kind === 'url')
+        return this._agentChatSafeUrl(src.value || src.url || '');
+    if (kind === 'data' && src.value) {
+        var mime = String(src.mimeType || src.mediaType || 'image/jpeg').toLowerCase();
+        if (!/^image\/(jpeg|png|gif|webp)$/.test(mime))
+            mime = 'image/jpeg';
+        var v = String(src.value).replace(/\s+/g, '');
+        if (/^data:image\/(jpeg|png|gif|webp);base64,/i.test(v))
+            return v;
+        if (!/^[A-Za-z0-9+/=]+$/.test(v))
+            return '';
+        return 'data:' + mime + ';base64,' + v;
+    }
+    return this._agentChatSafeUrl(p.content || '');
+};
+
+BxDolStudioPageAgents.prototype._agentChatSafeUrl = function(s) {
+    s = String(s == null ? '' : s);
+    if (/^https?:\/\//i.test(s))
+        return s;
+    if (s.charAt(0) === '/' && s.charAt(1) !== '/')
+        return s;
+    return '';
+};
 
-    var html = '<div class="bx-ai-chat-actions" style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-top:0.5rem;">';
+BxDolStudioPageAgents.prototype._renderAgentChatActions = function(aActions) {
+    if (!aActions || !aActions.length)
+        return '';
+    var html = '<div class="bx-ai-chat-actions">';
+    var n = 0;
     for (var i = 0; i < aActions.length; i++) {
         var a = aActions[i] || {};
-        var sLabel = this._escAgentChat(a.label || '');
-        if (!sLabel)
+        var label = this._escAgentChat(a.label || '');
+        if (!label)
             continue;
-        if (a.type === 'link' && a.url) {
-            html += '<a class="bx-btn bx-btn-small underline" href="' + this._escAgentChat(a.url) + '" target="_blank" rel="noopener noreferrer">' + sLabel + '</a>';
-        }
-        else {
-            html += '<span class="bx-btn bx-btn-small bx-btn-disabled">' + sLabel + '</span>';
+        var type = String(a.type || '').toLowerCase();
+        if (type === 'link') {
+            var url = this._agentChatSafeUrl(a.url || '');
+            if (!url)
+                continue;
+            html += '<a class="bx-ai-chat-action bx-ai-chat-action-link" href="' + this._escAgentChat(url) + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
+        } else {
+            html += '<span class="bx-ai-chat-action bx-ai-chat-action-reply">' + label + '</span>';
         }
+        n++;
     }
-    html += '</div>';
-    return html;
+    return n ? html + '</div>' : '';
 };
 
 BxDolStudioPageAgents.prototype._escAgentChat = function(s) {

+ 5 - 1
studio/template/agents_popup_chat.html

@@ -1,6 +1,10 @@
 <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-search">
+            <input type="search" id="__chat_id__-search" class="bx-agents-popup-chat-list-search-input" placeholder="__txt_search__" aria-label="__txt_search__" autocomplete="off" />
+        </div>
+        <div class="bx-agents-popup-chat-list-empty" style="display:none;">__txt_search_empty__</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__">
@@ -15,4 +19,4 @@
         <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>
+</div>

+ 3 - 0
studio/template/scripts/BxBaseStudioAgentsAgents.php

@@ -290,6 +290,8 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
             'agent_id' => (int)$iId,
             'txt_readonly' => _t('_sys_agents_agents_txt_readonly'),
             'txt_chats' => _t('_sys_agents_agents_txt_chats'),
+            'txt_search' => bx_html_attribute(_t('_sys_agents_agents_txt_search')),
+            'txt_search_empty' => _t('_sys_agents_agents_txt_search_empty'),
             'bx_repeat:threads' => $aThreadsTpl,
         ]));
 
@@ -378,6 +380,7 @@ class BxBaseStudioAgentsAgents extends BxDolStudioAgentsAgents
             'created_at' => (string)($aThread['created_at'] ?? ''),
             'updated_at' => (string)($aThread['updated_at'] ?? ''),
             'artifacts' => $aArtifacts,
+            'messages' => (!empty($aThread['messages']) && is_array($aThread['messages'])) ? array_values($aThread['messages']) : [],
         ];
     }
 

+ 199 - 3
template/scripts/BxBaseServices.php

@@ -130,7 +130,13 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
             'GetForm' => 'BxBaseSearchExtendedServices',
             'GetResults' => 'BxBaseSearchExtendedServices',
 
-            'WikiAction' => 'BxBaseServiceWiki'
+            'WikiAction' => 'BxBaseServiceWiki',
+
+            'AiChat' => 'BxBaseServices',
+            'AiChatUpload' => 'BxBaseServices',
+            'GetBlockAiAgentOperator' => 'BxBaseServices',
+            'GetAiChatThreads' => 'BxBaseServices',
+            'GetAiChatThread' => 'BxBaseServices',
         );
     }
 
@@ -864,7 +870,34 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
         ]]);
     }
 
-    public function serviceGetBlockAiAgent($iId = 0, $mixedContextPid = false, $bRequirePost = false)
+    /**
+     * AI agent chat block.
+     *
+     * Studio / `bx_srv('system', 'get_block_ai_agent', [...], 'TemplServices')`:
+     *
+     * array (
+     *   'module' => 'system',
+     *   'method' => 'get_block_ai_agent',
+     *   'params' =>
+     *   array (
+     *     0 => 6, // agent id
+     *     1 => array (
+     *       'context' => false,       // profile id, or false = resolve from the page
+     *       'require_post' => false,  // only accept a page/post as context
+     *       'allow_images' => true,   // composer image attachments
+     *       'allow_new' => false,     // "Start new" extra threads (`?chat=`)
+     *       'show_history' => false,  // "Chats" panel with the viewer's earlier threads (App only; operator block turns it on)
+     *     ),
+     *   ),
+     *   'class' => 'TemplServices',
+     * )
+     *
+     * Legacy positional params still work: (id, context, require_post, allow_images, allow_new, show_history).
+     *
+     * @param int $iId Agent id
+     * @param array|int|false $aParams Named options (preferred) or legacy context pid
+     */
+    public function serviceGetBlockAiAgent($iId = 0, $aParams = [], ...$aLegacy)
     {
         $bIsApi = bx_is_api();
 
@@ -872,9 +905,12 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
         if(!$aAgent || !is_array($aAgent))
             return $bIsApi ? [] : '';
 
+        $aParams = $this->_normalizeAiAgentBlockParams($aParams, $aLegacy);
+        $mixedContextPid = $aParams['context'];
+        $bRequirePost = $aParams['require_post'];
+
         $oAi = BxDolAi::getInstance();
         $oChat = BxDolAiChat::getInstance();
-        $bRequirePost = (bool)$bRequirePost;
         if ($mixedContextPid === false || $mixedContextPid === null || $mixedContextPid === '')
             $iContextPid = $oAi ? (int)$oChat->resolveChatHistoryContextPidFromPage($bRequirePost) : 0;
         else
@@ -886,6 +922,11 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
             'agent_profile' => BxDolProfile::getData($aAgent['profile_id']),
             'context_profile_id' => $iContextPid,
             'hidden_first_message' => (string)($aAgent['hidden_first_message'] ?? ''),
+            'allow_images' => $aParams['allow_images'] ? 1 : 0,
+            'allow_new' => $aParams['allow_new'] ? 1 : 0,
+            'show_history' => $aParams['show_history'] ? 1 : 0,
+            'max_images' => BxDolAiChatImages::MAX_PER_TURN,
+            'max_input_chars' => (int)($aAgent['max_input_chars'] ?? 0),
             ])];
 
         $sEndpoint = BX_DOL_URL_ROOT . 'sys-ai-chat/' . (int)$iId;
@@ -903,6 +944,125 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
         ]);
     }
 
+    /**
+     * Operator AI agent block: the single definition of "which agent, with which
+     * settings" for site operators. The Dashboard page block and the App floating
+     * bubble both call this service with no params, so they can never diverge.
+     *
+     * Agent id comes from Studio > Settings > Agents (`sys_agents_operator_agent`).
+     * Block params are fixed here — change them here, and only here. This is the one
+     * block with the "Chats" history panel (`show_history`): operators come back to
+     * the same agent again and again, and need to find what it did last time.
+     *
+     * Studio: `{"module":"system","method":"get_block_ai_agent_operator","class":"TemplServices"}`
+     * API:    `api.php?r=system/get_block_ai_agent_operator/TemplServices`
+     */
+    public function serviceGetBlockAiAgentOperator()
+    {
+        $bIsApi = bx_is_api();
+
+        $iId = (int)getParam('sys_agents_operator_agent');
+        if ($iId <= 0)
+            return $bIsApi ? [] : '';
+
+        $aAgent = BxDolAiQuery::getAgentObject($iId);
+        $oAi = BxDolAi::getInstance();
+        if (!$aAgent || !is_array($aAgent) || !$oAi || !$oAi->canChatDirectly($aAgent))
+            return $bIsApi ? [] : '';
+
+        return $this->serviceGetBlockAiAgent($iId, [
+            'context' => false,
+            'require_post' => false,
+            'allow_images' => true,
+            'allow_new' => true,
+            'show_history' => true,
+        ]);
+    }
+
+    /**
+     * Studio > Settings select for `sys_agents_operator_agent`: active agents
+     * that can be chatted with directly (see BxDolAi::canChatDirectly).
+     */
+    public function serviceGetOptionsOperatorAgent()
+    {
+        $aResult = [
+            ['key' => '', 'value' => _t('_Select_one')],
+        ];
+
+        $aAgents = BxDolDb::getInstance()->getAll("SELECT `id`, `name` FROM `sys_agents_agents` WHERE `active` = 1 AND `trigger` IN ('manual', 'message') ORDER BY `name` ASC");
+        foreach ($aAgents as $aAgent) {
+            $aResult[] = [
+                'key' => (int)$aAgent['id'],
+                'value' => $aAgent['name'] . ' (#' . (int)$aAgent['id'] . ')',
+            ];
+        }
+
+        return $aResult;
+    }
+
+    /**
+     * Named options for {@see serviceGetBlockAiAgent}, plus legacy positional args.
+     *
+     * @return array{context:mixed,require_post:bool,allow_images:bool,allow_new:bool,show_history:bool}
+     */
+    protected function _normalizeAiAgentBlockParams($mixedParams, array $aLegacy = [])
+    {
+        $isOn = function ($v) {
+            return $v === true || $v === 1 || $v === '1' || $v === 'true' || $v === 'on' || $v === 'yes';
+        };
+
+        $aOut = [
+            'context' => false,
+            'require_post' => false,
+            'allow_images' => false,
+            'allow_new' => false,
+            'show_history' => false,
+        ];
+
+        $aNamedKeys = ['context', 'context_profile_id', 'require_post', 'allow_images', 'allow_new', 'show_history'];
+        $bNamed = is_array($mixedParams);
+        if ($bNamed && $mixedParams) {
+            $bNamed = false;
+            foreach ($aNamedKeys as $sKey) {
+                if (array_key_exists($sKey, $mixedParams)) {
+                    $bNamed = true;
+                    break;
+                }
+            }
+        }
+
+        if ($bNamed) {
+            if (array_key_exists('context', $mixedParams))
+                $aOut['context'] = $mixedParams['context'];
+            elseif (array_key_exists('context_profile_id', $mixedParams))
+                $aOut['context'] = $mixedParams['context_profile_id'];
+            if (array_key_exists('require_post', $mixedParams))
+                $aOut['require_post'] = $mixedParams['require_post'];
+            if (array_key_exists('allow_images', $mixedParams))
+                $aOut['allow_images'] = $mixedParams['allow_images'];
+            if (array_key_exists('allow_new', $mixedParams))
+                $aOut['allow_new'] = $mixedParams['allow_new'];
+            if (array_key_exists('show_history', $mixedParams))
+                $aOut['show_history'] = $mixedParams['show_history'];
+        } else {
+            $aOut['context'] = $mixedParams;
+            if (array_key_exists(0, $aLegacy))
+                $aOut['require_post'] = $aLegacy[0];
+            if (array_key_exists(1, $aLegacy))
+                $aOut['allow_images'] = $aLegacy[1];
+            if (array_key_exists(2, $aLegacy))
+                $aOut['allow_new'] = $aLegacy[2];
+            if (array_key_exists(3, $aLegacy))
+                $aOut['show_history'] = $aLegacy[3];
+        }
+
+        $aOut['require_post'] = $isOn($aOut['require_post']);
+        $aOut['allow_images'] = $isOn($aOut['allow_images']);
+        $aOut['allow_new'] = $isOn($aOut['allow_new']);
+        $aOut['show_history'] = $isOn($aOut['show_history']);
+        return $aOut;
+    }
+
     public function serviceGetMenu($aParams)
     {
         $bIsApi = bx_is_api();
@@ -1790,6 +1950,42 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
     {
         return BxDolAiTrigger::getInstance('chat')->handle($iAgentId);
     }
+
+    /**
+     * Composer image upload for the chat widget (`$_FILES['file']`).
+     * → `{file_id, url, mime}` or `{error}`; the client sends the URL back as an image part of its next message.
+     */
+    public function serviceAiChatUpload($iAgentId)
+    {
+        return BxDolAiTrigger::getInstance('chat')->upload($iAgentId);
+    }
+
+    /**
+     * The caller's own conversations with an agent — the "Chats" panel of the App
+     * chat widget. Any context, base thread and `?chat=` partitions, newest first.
+     *
+     * API: `api.php?r=system/get_ai_chat_threads/TemplServices&params[]=<agent id>`
+     * → `{threads: [{thread_id, chat, context_pid, context_name, status, closed_reason,
+     *    title, preview, messages_count, created_ts, updated_ts}]}`.
+     * The row whose `chat` / context matches the widget (or the open one, when the
+     * widget has no `?chat=`) is the live conversation; the rest are read-only —
+     * see serviceGetAiChatThread.
+     */
+    public function serviceGetAiChatThreads($iAgentId)
+    {
+        return BxDolAiTrigger::getInstance('chat')->listThreads($iAgentId);
+    }
+
+    /**
+     * One of the caller's earlier conversations, read-only.
+     *
+     * API: `api.php?r=system/get_ai_chat_thread/TemplServices&params[]=<agent id>&params[]=<thread id>`
+     * → `{messages, status, closed_reason, artifacts}`; 403 for a thread of someone else.
+     */
+    public function serviceGetAiChatThread($iAgentId, $sThreadId = '')
+    {
+        return BxDolAiTrigger::getInstance('chat')->getThread($iAgentId, $sThreadId);
+    }
 }
 
 /** @} */

+ 58 - 0
tests/units/inc/classes/BxDolAiChatImagesTest.php

@@ -0,0 +1,58 @@
+<?php
+
+/**
+ * Chat image URLs: this site and the storage host only.
+ */
+class BxDolAiChatImagesTest extends \PHPUnit\Framework\TestCase
+{
+    public function testRejectsOtherHosts()
+    {
+        $o = new BxDolAiChatImages();
+        $this->assertSame('', $o->sanitizeUrl('http://169.254.169.254/latest/meta-data/iam/?x=sys_agents_chat_images'));
+        $this->assertSame('', $o->sanitizeUrl('http://10.0.0.5:8080/admin/storage.php'));
+        $this->assertSame('', $o->sanitizeUrl('https://evil.test/storage.php?o=sys_agents_chat_images'));
+        $this->assertSame('', $o->sanitizeUrl('javascript:alert(1)'));
+        $this->assertSame('', $o->sanitizeUrl('file:///etc/passwd'));
+    }
+
+    public function testAllowsThisSite()
+    {
+        $o = new BxDolAiChatImages();
+        $aRoot = parse_url(BX_DOL_URL_ROOT);
+        $sHost = $aRoot['host'];
+        $sPath = '/storage.php?o=sys_agents_chat_images&f=a.jpg';
+
+        $this->assertNotSame('', $o->sanitizeUrl(rtrim(BX_DOL_URL_ROOT, '/') . $sPath));
+        $this->assertStringContainsString($sHost, $o->sanitizeUrl($sPath));
+
+        if (!filter_var($sHost, FILTER_VALIDATE_IP)) {
+            $sWww = strncmp($sHost, 'www.', 4) === 0 ? substr($sHost, 4) : 'www.' . $sHost;
+            $this->assertNotSame('', $o->sanitizeUrl($aRoot['scheme'] . '://' . $sWww . $sPath));
+        }
+
+        $this->assertSame('', $o->sanitizeUrl('https://' . $sHost . '.evil.test/storage.php'));
+        $this->assertSame('', $o->sanitizeUrl('http://' . $sHost . '@169.254.169.254/storage.php'));
+    }
+
+    public function testMaxBytesFollowsStorage()
+    {
+        $oStorage = BxDolStorage::getObjectInstance('sys_agents_chat_images');
+        if (!$oStorage)
+            $this->markTestSkipped('Chat images storage is not installed.');
+
+        $iMax = (int)($oStorage->getObjectData()['max_file_size'] ?? 0);
+        $this->assertSame($iMax, (new BxDolAiChatImages())->maxBytes());
+    }
+
+    public function testAllowsStorageDomain()
+    {
+        $sDomain = trim((string)getParam('sys_storage_s3_domain'));
+        if ($sDomain === '')
+            $this->markTestSkipped('Storage domain is not configured.');
+
+        $aDomain = parse_url(preg_match('#://#', $sDomain) ? $sDomain : 'https://' . $sDomain);
+        $o = new BxDolAiChatImages();
+        $this->assertNotSame('', $o->sanitizeUrl('https://' . $aDomain['host'] . '/sys_agents_chat_images/a.jpg'));
+        $this->assertSame('', $o->sanitizeUrl('https://' . $aDomain['host'] . '.evil.test/sys_agents_chat_images/a.jpg'));
+    }
+}

+ 1 - 0
tests/units/inc/classes/BxDolServiceMacroTest.php

@@ -10,6 +10,7 @@ class BxDolServiceMacroTest extends \PHPUnit\Framework\TestCase
     #[DataProvider('providerForMalformedMacro')]
     public function testCallMacroMalformed($s)
     {
+        bx_import('BxDolLanguages');
         $this->assertSame(_t('_sys_macros_malformed'), BxDolService::callMacro($s));
     }
 

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