BxDolAIToolMockup.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. <?php defined('BX_DOL') or die('hack attempt');
  2. /**
  3. * Copyright (c) UNA, Inc - https://una.io
  4. * MIT License - https://opensource.org/licenses/MIT
  5. *
  6. * @defgroup UnaCore UNA Core
  7. * @{
  8. */
  9. /**
  10. * `mockup_upsert` tool: the agent saves a NEO mockup JSON tree onto an existing
  11. * `get_mockup_block` service page block. The tree lives in `sys_pages_blocks_data`
  12. * (no new table) and is served by system/get_mockup_block/TemplServices as a
  13. * `mockup` API block, which NEO renders natively (view/row/text/image/icon/button).
  14. */
  15. use NeuronAI\Tools\PropertyType;
  16. use NeuronAI\Tools\ToolProperty;
  17. class BxDolAIToolMockup extends BxDolAITool
  18. {
  19. const MAX_DEPTH = 24;
  20. const MAX_JSON = 200000;
  21. /** sys_pages_blocks_data.data is MEDIUMTEXT: a longer tree is cut off and no longer parses. */
  22. const MAX_STORED = 16777215;
  23. public function __construct()
  24. {
  25. parent::__construct(
  26. 'mockup_upsert',
  27. '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). '
  28. . 'Root: {"type":"view","className":"...","children":[...]}. Types: view (column), row (horizontal; already flex-row), text, image, icon, button. Empty view/row/text are dropped. '
  29. . '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. '
  30. . '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). '
  31. . '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. '
  32. . 'Match screenshot hierarchy/spacing/CTAs; skip video/map/forms as text+button placeholders.'
  33. );
  34. }
  35. protected function properties(): array
  36. {
  37. return [
  38. new ToolProperty('block_id', PropertyType::INTEGER, 'sys_pages_blocks.id of the get_mockup_block service block', true),
  39. 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),
  40. new ToolProperty('dry_run', PropertyType::BOOLEAN, 'If true, validate only and do not save', false),
  41. ];
  42. }
  43. public function __invoke($block_id, $tree_json, $dry_run = false): string
  44. {
  45. $iBlockId = (int)$block_id;
  46. if ($iBlockId <= 0)
  47. return 'error: block_id is required (sys_pages_blocks.id of the get_mockup_block block)';
  48. $sJson = is_string($tree_json) ? trim($tree_json) : '';
  49. if ($sJson === '')
  50. return 'error: tree_json is required';
  51. if (strlen($sJson) > self::MAX_JSON)
  52. return 'error: tree_json is too large';
  53. $aRaw = json_decode($sJson, true);
  54. if (!is_array($aRaw))
  55. return 'error: tree_json is not valid JSON object';
  56. $aTree = self::normalizeNode($aRaw, 0);
  57. if (!$aTree)
  58. return 'error: empty or invalid tree. Root type view with children. Types: view,row,text,image,icon,button.';
  59. if (!self::isMockupBlock($iBlockId))
  60. return 'error: block_id ' . $iBlockId . ' is not a get_mockup_block service block';
  61. $bDry = ($dry_run === true || $dry_run === 1 || $dry_run === '1' || $dry_run === 'true');
  62. if ($bDry)
  63. return 'dry_run ok. block_id=' . $iBlockId . ' nodes=' . self::countNodes($aTree);
  64. $sErr = self::saveTree($iBlockId, $aTree);
  65. if ($sErr !== '')
  66. return $sErr;
  67. return 'saved. block_id=' . $iBlockId . ' nodes=' . self::countNodes($aTree);
  68. }
  69. public static function currentBlockId()
  70. {
  71. $aProc = BxDolPage::getBlockProcessing();
  72. return (!empty($aProc['id'])) ? (int)$aProc['id'] : 0;
  73. }
  74. public static function isMockupBlock($iBlockId)
  75. {
  76. $iBlockId = (int)$iBlockId;
  77. if ($iBlockId <= 0)
  78. return false;
  79. $sContent = BxDolDb::getInstance()->getOne("SELECT `content` FROM `sys_pages_blocks` WHERE `id` = :id AND `type` = 'service' LIMIT 1", [
  80. 'id' => $iBlockId,
  81. ]);
  82. $aCall = BxDolService::decodeServiceCall($sContent);
  83. return is_array($aCall) && ($aCall['module'] ?? '') === 'system' && ($aCall['method'] ?? '') === 'get_mockup_block';
  84. }
  85. public static function getTree($iBlockId = 0)
  86. {
  87. $iBlockId = (int)$iBlockId;
  88. if ($iBlockId <= 0)
  89. $iBlockId = self::currentBlockId();
  90. if ($iBlockId <= 0)
  91. return null;
  92. $oDb = new BxDolPageQuery([]);
  93. $sData = $oDb->getPageBlockData($iBlockId);
  94. if (!$sData)
  95. return null;
  96. $aRaw = json_decode($sData, true);
  97. if (!is_array($aRaw))
  98. return null;
  99. return self::normalizeNode($aRaw, 0);
  100. }
  101. public static function saveTree($iBlockId, array $aTree)
  102. {
  103. $iBlockId = (int)$iBlockId;
  104. if ($iBlockId <= 0)
  105. return 'error: block_id missing';
  106. // Unescaped: a non-Latin character costs 2-4 bytes here instead of 6 as \uXXXX.
  107. $sData = json_encode($aTree, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
  108. if (!is_string($sData))
  109. return 'error: the tree cannot be encoded as JSON';
  110. if (strlen($sData) > self::MAX_STORED)
  111. return 'error: the tree is too large to save (' . strlen($sData) . ' bytes, the limit is ' . self::MAX_STORED . '). Simplify it: fewer nodes, shorter texts and class names.';
  112. $oDb = new BxDolPageQuery([]);
  113. $b = $oDb->setPageBlockData($iBlockId, 0, '', $sData);
  114. if ($b === false)
  115. return 'error: failed to write sys_pages_blocks_data';
  116. return '';
  117. }
  118. public static function normalizeNode($raw, $iDepth)
  119. {
  120. $aTypes = ['view' => 1, 'row' => 1, 'text' => 1, 'image' => 1, 'icon' => 1, 'button' => 1];
  121. $aBtn = [
  122. 'primary' => 'borderedProminent',
  123. 'secondary' => 'bordered',
  124. 'outline' => 'bordered',
  125. 'bordered' => 'bordered',
  126. 'borderedProminent' => 'borderedProminent',
  127. 'plain' => 'plain',
  128. 'link' => 'link',
  129. 'glass' => 'glass',
  130. 'glassProminent' => 'glassProminent',
  131. 'borderless' => 'borderless',
  132. ];
  133. if ($iDepth > self::MAX_DEPTH || $raw === null)
  134. return null;
  135. if (is_string($raw)) {
  136. $s = trim($raw);
  137. if ($s !== '' && ($s[0] === '{' || $s[0] === '[')) {
  138. $decoded = json_decode($s, true);
  139. if (is_array($decoded))
  140. return self::normalizeNode($decoded, $iDepth);
  141. }
  142. return $s !== '' ? ['type' => 'text', 'className' => '', 'text' => $s] : null;
  143. }
  144. if (!is_array($raw))
  145. return null;
  146. if (isset($raw[0]) && !isset($raw['type'])) {
  147. $aChildren = [];
  148. foreach ($raw as $child) {
  149. $n = self::normalizeNode($child, $iDepth + 1);
  150. if ($n)
  151. $aChildren[] = $n;
  152. }
  153. return $aChildren ? ['type' => 'view', 'className' => '', 'children' => $aChildren] : null;
  154. }
  155. $sType = isset($raw['type']) ? (string)$raw['type'] : '';
  156. if ($sType === 'mockup')
  157. return self::normalizeNode($raw['data'] ?? $raw['content'] ?? $raw['tree'] ?? $raw['node'] ?? null, $iDepth);
  158. if ($sType === '')
  159. $sType = (isset($raw['text']) && !isset($raw['children'])) ? 'text' : 'view';
  160. if (empty($aTypes[$sType]))
  161. return null;
  162. $sClass = (isset($raw['className']) && is_string($raw['className'])) ? $raw['className'] : '';
  163. $aChildrenIn = [];
  164. if (isset($raw['children']))
  165. $aChildrenIn = is_array($raw['children']) ? $raw['children'] : [$raw['children']];
  166. $aChildren = [];
  167. foreach ($aChildrenIn as $child) {
  168. $n = self::normalizeNode($child, $iDepth + 1);
  169. if ($n)
  170. $aChildren[] = $n;
  171. }
  172. if ($sType === 'text') {
  173. $sText = isset($raw['text']) ? (string)$raw['text'] : '';
  174. return $sText === '' ? null : ['type' => 'text', 'className' => $sClass, 'text' => $sText];
  175. }
  176. if ($sType === 'icon') {
  177. $sIcon = isset($raw['icon']) ? (string)$raw['icon'] : '';
  178. if ($sIcon === '')
  179. return null;
  180. $iSize = isset($raw['size']) ? (int)$raw['size'] : 24;
  181. return ['type' => 'icon', 'className' => $sClass, 'icon' => $sIcon, 'size' => $iSize > 0 ? $iSize : 24];
  182. }
  183. if ($sType === 'image') {
  184. $sSrc = self::pickImageSrc($raw);
  185. if ($sSrc === '')
  186. return null;
  187. return ['type' => 'image', 'className' => $sClass, 'src' => $sSrc, 'alt' => isset($raw['alt']) ? (string)$raw['alt'] : ''];
  188. }
  189. if ($sType === 'button') {
  190. $sLabel = (string)($raw['label'] ?? $raw['text'] ?? '');
  191. if ($sLabel === '')
  192. return null;
  193. $sStyleKey = (string)($raw['style'] ?? $raw['variant'] ?? 'primary');
  194. return [
  195. 'type' => 'button',
  196. 'className' => $sClass,
  197. 'label' => $sLabel,
  198. 'href' => self::safeHref($raw['href'] ?? ''),
  199. 'style' => $aBtn[$sStyleKey] ?? 'borderedProminent',
  200. ];
  201. }
  202. if (!$aChildren)
  203. return null;
  204. return ['type' => $sType, 'className' => $sClass, 'children' => $aChildren];
  205. }
  206. /**
  207. * Only relative paths, anchors, http(s) and mailto links; javascript:/data: and the like are dropped.
  208. */
  209. protected static function safeHref($mixed)
  210. {
  211. $s = is_string($mixed) ? trim($mixed) : '';
  212. if ($s === '')
  213. return '';
  214. if ($s[0] === '/' || $s[0] === '#' || $s[0] === '?')
  215. return $s;
  216. if (preg_match('#^(https?://|mailto:)#i', $s))
  217. return $s;
  218. return '';
  219. }
  220. /**
  221. * Display src for image node: string src, or UNA image object (src/medium/small/thumb) either
  222. * directly in the node or nested under `src`. Never src_orig.
  223. */
  224. protected static function pickImageSrc(array $raw)
  225. {
  226. $aSrc = (isset($raw['src']) && is_array($raw['src'])) ? $raw['src'] : $raw;
  227. foreach (['src', 'medium', 'small', 'thumb'] as $k) {
  228. if (!empty($aSrc[$k]) && is_string($aSrc[$k]))
  229. return $aSrc[$k];
  230. }
  231. return '';
  232. }
  233. protected static function countNodes(array $aNode)
  234. {
  235. $i = 1;
  236. if (!empty($aNode['children']) && is_array($aNode['children'])) {
  237. foreach ($aNode['children'] as $child) {
  238. if (is_array($child))
  239. $i += self::countNodes($child);
  240. }
  241. }
  242. return $i;
  243. }
  244. }