Quellcode durchsuchen

AI agents: mockup_upsert tool and get_mockup_block service block for … (#5700)

* AI agents: mockup_upsert tool and get_mockup_block service block for NEO mockups

- BxDolAIToolMockup (`mockup_upsert`): agent saves a normalized JSON tree
  (view/row/text/image/icon/button) into sys_pages_blocks_data for an existing
  `system / get_mockup_block / TemplServices` service block; refuses other blocks,
  drops unknown/empty nodes, limits button href to relative, http(s), mailto.
- system/get_mockup_block returns the tree as a `mockup` API block (placeholder
  when nothing is saved yet).
- BxBasePage::_getBlockService keys get_mockup_block calls by block id so several
  mockup blocks on one page do not share BxDolService's memory cache.
- install/sql/system.sql: mockup_upsert tool row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore: retrigger UNA CI / Railway preview for #5700

Co-authored-by: Aleksandr Trofimov <AlexTr@users.noreply.github.com>

* add permission check to serviceSetPageBlockData SAFE block

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Aleksandr Trofimov <AlexTr@users.noreply.github.com>
Co-authored-by: Aleksandr Trofimov <at@unacms.com>
RomanL vor 3 Wochen
Ursprung
Commit
be2f2b5cc6

+ 272 - 0
inc/classes/BxDolAIToolMockup.php

@@ -0,0 +1,272 @@
+<?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
+ * @{
+ */
+
+/**
+ * `mockup_upsert` tool: the agent saves a NEO mockup JSON tree onto an existing
+ * `get_mockup_block` service page block. The tree lives in `sys_pages_blocks_data`
+ * (no new table) and is served by system/get_mockup_block/TemplServices as a
+ * `mockup` API block, which NEO renders natively (view/row/text/image/icon/button).
+ */
+
+use NeuronAI\Tools\PropertyType;
+use NeuronAI\Tools\ToolProperty;
+
+class BxDolAIToolMockup extends BxDolAITool
+{
+    const MAX_DEPTH = 24;
+    const MAX_JSON = 200000;
+
+    public function __construct()
+    {
+        parent::__construct(
+            'mockup_upsert',
+            'Save a NEO mockup JSON tree onto a get_mockup_block page block (sys_pages_blocks_data). Do not use mysql_write, HTML, or React Native JSX for mockups. Do not touch get_mockup_test (hardcoded demo). '
+            . 'Root: {"type":"view","className":"...","children":[...]}. Types: view (column), row (horizontal; already flex-row), text, image, icon, button. Empty view/row/text are dropped. '
+            . 'Columns: parent type=row className="gap-4 w-full"; children view with flex-1 or w-1/2 / w-1/3 / w-1/4. NEVER w-full on row children — that stacks cards full-width. 2 cols → w-1/2 or flex-1; 3 → w-1/3 or flex-1; 4 → w-1/4. w-full only on vertical view sections. '
+            . 'className: Tailwind already in NEO (gap-2 gap-4 gap-8 w-full w-1/2 w-1/3 w-1/4 flex-1 min-w-0 p-4 rounded-xl border border-border bg-card text-foreground text-muted-foreground text-sm text-lg text-4xl font-semibold font-bold items-center justify-between h-48). No arbitrary-[]. Text color/size/font-* only on text nodes (RN does not cascade). '
+            . 'text: field text; one feature line = one text node, not \\n. icon: Lucide name, size default 24. image: display src only (src/medium/small/thumb), never src_orig. button: label required, optional href, style primary|secondary|outline|plain|link|glass. '
+            . 'Match screenshot hierarchy/spacing/CTAs; skip video/map/forms as text+button placeholders.'
+        );
+    }
+
+    protected function properties(): array
+    {
+        return [
+            new ToolProperty('block_id', PropertyType::INTEGER, 'sys_pages_blocks.id of the get_mockup_block service block', true),
+            new ToolProperty('tree_json', PropertyType::STRING, 'JSON object, root type view. Row children: flex-1 or w-1/3, never w-full. Example: {"type":"view","className":"gap-8 w-full","children":[{"type":"row","className":"gap-4 w-full","children":[{"type":"view","className":"w-1/3 p-4 rounded-xl border border-border bg-card gap-4","children":[{"type":"text","className":"text-4xl font-bold text-foreground","text":"Pro"}]}]}]}', true),
+            new ToolProperty('dry_run', PropertyType::BOOLEAN, 'If true, validate only and do not save', false),
+        ];
+    }
+
+    public function __invoke($block_id, $tree_json, $dry_run = false): string
+    {
+        $iBlockId = (int)$block_id;
+        if ($iBlockId <= 0)
+            return 'error: block_id is required (sys_pages_blocks.id of the get_mockup_block block)';
+
+        $sJson = is_string($tree_json) ? trim($tree_json) : '';
+        if ($sJson === '')
+            return 'error: tree_json is required';
+        if (strlen($sJson) > self::MAX_JSON)
+            return 'error: tree_json is too large';
+
+        $aRaw = json_decode($sJson, true);
+        if (!is_array($aRaw))
+            return 'error: tree_json is not valid JSON object';
+
+        $aTree = self::normalizeNode($aRaw, 0);
+        if (!$aTree)
+            return 'error: empty or invalid tree. Root type view with children. Types: view,row,text,image,icon,button.';
+
+        if (!self::isMockupBlock($iBlockId))
+            return 'error: block_id ' . $iBlockId . ' is not a get_mockup_block service block';
+
+        $bDry = ($dry_run === true || $dry_run === 1 || $dry_run === '1' || $dry_run === 'true');
+        if ($bDry)
+            return 'dry_run ok. block_id=' . $iBlockId . ' nodes=' . self::countNodes($aTree);
+
+        $sErr = self::saveTree($iBlockId, $aTree);
+        if ($sErr !== '')
+            return $sErr;
+
+        return 'saved. block_id=' . $iBlockId . ' nodes=' . self::countNodes($aTree);
+    }
+
+    public static function currentBlockId()
+    {
+        if (!class_exists('BxDolPage'))
+            return 0;
+        $aProc = BxDolPage::getBlockProcessing();
+        return (!empty($aProc['id'])) ? (int)$aProc['id'] : 0;
+    }
+
+    public static function isMockupBlock($iBlockId)
+    {
+        $iBlockId = (int)$iBlockId;
+        if ($iBlockId <= 0)
+            return false;
+
+        $sContent = BxDolDb::getInstance()->getOne("SELECT `content` FROM `sys_pages_blocks` WHERE `id` = :id AND `type` = 'service' LIMIT 1", [
+            'id' => $iBlockId,
+        ]);
+        return is_string($sContent) && strpos($sContent, 'get_mockup_block') !== false;
+    }
+
+    public static function getTree($iBlockId = 0)
+    {
+        $iBlockId = (int)$iBlockId;
+        if ($iBlockId <= 0)
+            $iBlockId = self::currentBlockId();
+        if ($iBlockId <= 0)
+            return null;
+
+        $oDb = new BxDolPageQuery([]);
+        $sData = $oDb->getPageBlockData($iBlockId);
+        if (!$sData)
+            return null;
+
+        $aRaw = json_decode($sData, true);
+        if (!is_array($aRaw))
+            return null;
+
+        return self::normalizeNode($aRaw, 0);
+    }
+
+    public static function saveTree($iBlockId, array $aTree)
+    {
+        $iBlockId = (int)$iBlockId;
+        if ($iBlockId <= 0)
+            return 'error: block_id missing';
+
+        $oDb = new BxDolPageQuery([]);
+        $b = $oDb->setPageBlockData($iBlockId, 0, '', $aTree);
+        if (!$b)
+            return 'error: failed to write sys_pages_blocks_data';
+
+        return '';
+    }
+
+    public static function normalizeNode($raw, $iDepth)
+    {
+        $aTypes = ['view' => 1, 'row' => 1, 'text' => 1, 'image' => 1, 'icon' => 1, 'button' => 1];
+        $aBtn = [
+            'primary' => 'borderedProminent',
+            'secondary' => 'bordered',
+            'outline' => 'bordered',
+            'bordered' => 'bordered',
+            'borderedProminent' => 'borderedProminent',
+            'plain' => 'plain',
+            'link' => 'link',
+            'glass' => 'glass',
+            'glassProminent' => 'glassProminent',
+            'borderless' => 'borderless',
+        ];
+
+        if ($iDepth > self::MAX_DEPTH || $raw === null)
+            return null;
+
+        if (is_string($raw)) {
+            $s = trim($raw);
+            if ($s !== '' && ($s[0] === '{' || $s[0] === '[')) {
+                $decoded = json_decode($s, true);
+                if (is_array($decoded))
+                    return self::normalizeNode($decoded, $iDepth);
+            }
+            return $s !== '' ? ['type' => 'text', 'className' => '', 'text' => $s] : null;
+        }
+        if (!is_array($raw))
+            return null;
+
+        if (isset($raw[0]) && !isset($raw['type'])) {
+            $aChildren = [];
+            foreach ($raw as $child) {
+                $n = self::normalizeNode($child, $iDepth + 1);
+                if ($n)
+                    $aChildren[] = $n;
+            }
+            return $aChildren ? ['type' => 'view', 'className' => '', 'children' => $aChildren] : null;
+        }
+
+        $sType = isset($raw['type']) ? (string)$raw['type'] : '';
+        if ($sType === 'mockup')
+            return self::normalizeNode($raw['data'] ?? $raw['content'] ?? $raw['tree'] ?? $raw['node'] ?? null, $iDepth);
+
+        if ($sType === '')
+            $sType = (isset($raw['text']) && !isset($raw['children'])) ? 'text' : 'view';
+        if (empty($aTypes[$sType]))
+            return null;
+
+        $sClass = (isset($raw['className']) && is_string($raw['className'])) ? $raw['className'] : '';
+        $aChildrenIn = [];
+        if (isset($raw['children']))
+            $aChildrenIn = is_array($raw['children']) ? $raw['children'] : [$raw['children']];
+        $aChildren = [];
+        foreach ($aChildrenIn as $child) {
+            $n = self::normalizeNode($child, $iDepth + 1);
+            if ($n)
+                $aChildren[] = $n;
+        }
+
+        if ($sType === 'text') {
+            $sText = isset($raw['text']) ? (string)$raw['text'] : '';
+            return $sText === '' ? null : ['type' => 'text', 'className' => $sClass, 'text' => $sText];
+        }
+        if ($sType === 'icon') {
+            $sIcon = isset($raw['icon']) ? (string)$raw['icon'] : '';
+            if ($sIcon === '')
+                return null;
+            $iSize = isset($raw['size']) ? (int)$raw['size'] : 24;
+            return ['type' => 'icon', 'className' => $sClass, 'icon' => $sIcon, 'size' => $iSize > 0 ? $iSize : 24];
+        }
+        if ($sType === 'image') {
+            $sSrc = self::pickImageSrc($raw);
+            if ($sSrc === '')
+                return null;
+            return ['type' => 'image', 'className' => $sClass, 'src' => $sSrc, 'alt' => isset($raw['alt']) ? (string)$raw['alt'] : ''];
+        }
+        if ($sType === 'button') {
+            $sLabel = (string)($raw['label'] ?? $raw['text'] ?? '');
+            if ($sLabel === '')
+                return null;
+            $sStyleKey = (string)($raw['style'] ?? $raw['variant'] ?? 'primary');
+            return [
+                'type' => 'button',
+                'className' => $sClass,
+                'label' => $sLabel,
+                'href' => self::safeHref($raw['href'] ?? ''),
+                'style' => $aBtn[$sStyleKey] ?? 'borderedProminent',
+            ];
+        }
+        if (!$aChildren)
+            return null;
+
+        return ['type' => $sType, 'className' => $sClass, 'children' => $aChildren];
+    }
+
+    /**
+     * Only relative paths, anchors, http(s) and mailto links; javascript:/data: and the like are dropped.
+     */
+    protected static function safeHref($mixed)
+    {
+        $s = is_string($mixed) ? trim($mixed) : '';
+        if ($s === '')
+            return '';
+        if ($s[0] === '/' || $s[0] === '#' || $s[0] === '?')
+            return $s;
+        if (preg_match('#^(https?://|mailto:)#i', $s))
+            return $s;
+        return '';
+    }
+
+    /**
+     * Display src for image node: string src, or UNA image object (src/medium/small/thumb) either
+     * directly in the node or nested under `src`. Never src_orig.
+     */
+    protected static function pickImageSrc(array $raw)
+    {
+        $aSrc = (isset($raw['src']) && is_array($raw['src'])) ? $raw['src'] : $raw;
+        foreach (['src', 'medium', 'small', 'thumb'] as $k) {
+            if (!empty($aSrc[$k]) && is_string($aSrc[$k]))
+                return $aSrc[$k];
+        }
+        return '';
+    }
+
+    protected static function countNodes(array $aNode)
+    {
+        $i = 1;
+        if (!empty($aNode['children']) && is_array($aNode['children'])) {
+            foreach ($aNode['children'] as $child) {
+                if (is_array($child))
+                    $i += self::countNodes($child);
+            }
+        }
+        return $i;
+    }
+}

+ 1 - 0
install/sql/system.sql

@@ -7061,6 +7061,7 @@ INSERT INTO `sys_agents_tools` (`type`, `title`, `docs`, `params`, `params_user`
 ('mysql_write_safe', 'MySQL Write (safe)', 'INSERT or UPDATE one row by primary key. DELETE/DROP/TRUNCATE/ALTER are blocked. Each change is snapshotted into sys_agents_sql_log (before/after JSON). Roll back with mysql_undo. Use together with MySQL Schema and MySQL Select. Do not enable the stock MySQL Write tool on the same agent.', '{}', NULL, 0, 0, 1, 'BxDolAIToolMysqlWrite', ''),
 ('mysql_undo', 'MySQL Undo', 'Roll back the last mysql_write snapshot (scope=last) or all snapshots in this chat (scope=session), newest first. UPDATE restores before_json. INSERT deletes that primary key internally. Already-undone rows are skipped. Attach to the same agent as MySQL Write (safe).', '{}', NULL, 0, 0, 1, 'BxDolAIToolMysqlUndo', ''),
 
+('mockup_upsert', 'Mockup upsert', 'Save a NEO mockup JSON tree (view/row/text/image/icon/button) onto a get_mockup_block service page block. The tree is stored in sys_pages_blocks_data and rendered by the NEO app as a mockup block. Add a "get_mockup_block" service block (system / get_mockup_block / TemplServices) to the page first. Do not use MySQL Write for mockups.', '{}', NULL, 0, 0, 1, 'BxDolAIToolMockup', ''),
 ('prompt_feedback', 'Prompt feedback', 'Call when the user says the previous reply was wrong, corrects you, or you notice you broke a rule. Writes one paste-ready markdown case into sys_agents_prompt_feedback.markdown: question, wrong answer, correction, why, expected behavior, suggested prompt rule, excerpt, and the prompt that was active. Do not mention the log to the visitor.', '{}', NULL, 0, 0, 1, 'BxDolAIToolPromptFeedback', '');
 
 CREATE TABLE IF NOT EXISTS `sys_agents_chat_history` (

+ 10 - 1
template/scripts/BxBasePage.php

@@ -1411,7 +1411,16 @@ class BxBasePage extends BxDolPage
     {
         self::setBlockProcessing($aBlock);
 
-        $aResult = BxDolService::callSerialized($aBlock['content'], array_merge($this->_aMarkers, [
+        $sContent = $aBlock['content'];
+        // get_mockup_block blocks are stored with empty params, so BxDolService's memory cache would
+        // hand every such block on the page the same tree; key the call by block id instead.
+        $aCall = is_string($sContent) ? @unserialize($sContent) : false;
+        if (is_array($aCall) && !empty($aCall['method']) && $aCall['method'] === 'get_mockup_block') {
+            $aCall['params'] = [(int)$aBlock['id']];
+            $sContent = serialize($aCall);
+        }
+
+        $aResult = BxDolService::callSerialized($sContent, array_merge($this->_aMarkers, [
             'block_id' => $aBlock['id']
         ]));
 

+ 3 - 0
template/scripts/BxBaseServicePages.php

@@ -134,6 +134,9 @@ class BxBaseServicePages extends BxDol
 
     public function serviceSetPageBlockData($iBlockId, $iContentId = 0, $sContentModule = '')
     {
+        if (!isAdmin())
+            return false;
+
         $sData = @file_get_contents("php://input");
         $aData = json_decode($sData, true);
         if($aData === null)

+ 29 - 0
template/scripts/BxBaseServices.php

@@ -138,6 +138,7 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
             'GetBlockAiAgentOperator' => 'BxBaseServices',
             'GetAiChatThreads' => 'BxBaseServices',
             'GetAiChatThread' => 'BxBaseServices',
+            'GetMockupBlock' => 'BxBaseServices',
         );
     }
 
@@ -1993,6 +1994,34 @@ class BxBaseServices extends BxDol implements iBxDolProfileService
     {
         return BxDolAiTrigger::getInstance('chat')->getThread($iAgentId, $sThreadId);
     }
+
+    /**
+     * Agent-written NEO mockup block (Studio: system / get_mockup_block / TemplServices).
+     * The tree is written by the `mockup_upsert` tool into sys_pages_blocks_data for this block;
+     * BxBasePage::_getBlockService passes the block id as the only param.
+     * @param int $iBlockId sys_pages_blocks.id
+     * @return array API block of type `mockup`
+     */
+    public function serviceGetMockupBlock($iBlockId = 0)
+    {
+        $aTree = BxDolAIToolMockup::getTree((int)$iBlockId);
+
+        if (!$aTree) {
+            $aTree = [
+                'type' => 'view',
+                'className' => 'gap-4 w-full',
+                'children' => [
+                    [
+                        'type' => 'text',
+                        'className' => 'text-muted-foreground',
+                        'text' => 'No mockup yet. Ask the mockup agent to generate one for this page.',
+                    ],
+                ],
+            ];
+        }
+
+        return [bx_api_get_block('mockup', $aTree)];
+    }
 }
 
 /** @} */