Add UI narrative for LLM action guidance and unify render modes
- Create UiNarrativeBuilder class for structured, story-like UI descriptions - screen: 50-80 words describing current state - actions: 80-120 words with exact tool invocation examples - navigation: 30-40 words for navigating to related screens - notes: 30-50 words for truncation, permissions, constraints - Enhance mcp.ftl macros to capture semantic metadata - form-single: Track field names, types, validation rules - form-list: Capture totalItems, displayedItems, truncated flags - Store metadata in mcpSemanticData for narrative generation - Deprecate DefaultScreenMacros.json.ftl - Update MoquiConf.xml to map both mcp and json to mcp.ftl - Remove redundant 218-line template with no semantic capture - Integrate uiNarrative into BrowseScreens service - Generate narrative using UiNarrativeBuilder - Include uiNarrative in result map - Provide screenDef for context - Remove redundant semantic state extraction - Delete fallback logic that extracted forms/lists from screen definition - Rely exclusively on mcpSemanticData captured by macros - Improve smart truncation in serializeMoquiObject - terse mode: 10 items, 200 char strings - non-terse mode: 50 items, no string truncation (fixed bug) - Add _hasMore flag to truncated data metadata - Fix CustomScreenTestImpl postRenderContext capture - Capture context before pop to preserve mcpSemanticData
Showing
9 changed files
with
733 additions
and
369 deletions
No preview for this file type
| ... | @@ -2,7 +2,7 @@ arguments=--init-script /home/ean/.local/share/opencode/bin/jdtls/config_linux/o | ... | @@ -2,7 +2,7 @@ arguments=--init-script /home/ean/.local/share/opencode/bin/jdtls/config_linux/o |
| 2 | auto.sync=false | 2 | auto.sync=false |
| 3 | build.scans.enabled=false | 3 | build.scans.enabled=false |
| 4 | connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9)) | 4 | connection.gradle.distribution=GRADLE_DISTRIBUTION(VERSION(8.9)) |
| 5 | connection.project.dir= | 5 | connection.project.dir=../../../framework |
| 6 | eclipse.preferences.version=1 | 6 | eclipse.preferences.version=1 |
| 7 | gradle.user.home= | 7 | gradle.user.home= |
| 8 | java.home=/usr/lib/jvm/java-21-openjdk-amd64 | 8 | java.home=/usr/lib/jvm/java-21-openjdk-amd64 | ... | ... |
| ... | @@ -28,10 +28,11 @@ | ... | @@ -28,10 +28,11 @@ |
| 28 | </webapp-list> | 28 | </webapp-list> |
| 29 | 29 | ||
| 30 | <screen-facade> | 30 | <screen-facade> |
| 31 | <!-- DEPRECATED: json mode now uses mcp template for unified semantic capture --> | ||
| 31 | <screen-text-output type="mcp" mime-type="text/markdown" always-standalone="true" | 32 | <screen-text-output type="mcp" mime-type="text/markdown" always-standalone="true" |
| 32 | macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/> | 33 | macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/> |
| 33 | <screen-text-output type="json" mime-type="application/json" always-standalone="true" | 34 | <screen-text-output type="json" mime-type="application/json" always-standalone="true" |
| 34 | macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.json.ftl"/> | 35 | macro-template-location="component://moqui-mcp-2/screen/macro/DefaultScreenMacros.mcp.ftl"/> |
| 35 | <widget-render-mode type="mcp" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/> | 36 | <widget-render-mode type="mcp" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/> |
| 36 | <widget-render-mode type="json" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/> | 37 | <widget-render-mode type="json" widget-render-class="org.moqui.impl.screen.ScreenWidgetRenderFtl"/> |
| 37 | </screen-facade> | 38 | </screen-facade> | ... | ... |
| 1 | <#-- | ||
| 2 | Moqui MCP JSON Macros | ||
| 3 | Renders screens in structured JSON format for LLM consumption. | ||
| 4 | --> | ||
| 5 | |||
| 6 | <#include "DefaultScreenMacros.any.ftl"/> | ||
| 7 | |||
| 8 | <#macro @element></#macro> | ||
| 9 | |||
| 10 | <#macro screen>{"screen": {<#recurse>}}</#macro> | ||
| 11 | |||
| 12 | <#macro widgets> | ||
| 13 | "widgets": [<#recurse>] | ||
| 14 | </#macro> | ||
| 15 | |||
| 16 | <#macro "fail-widgets"><#recurse></#macro> | ||
| 17 | |||
| 18 | <#-- ================ Subscreens ================ --> | ||
| 19 | <#macro "subscreens-menu"></#macro> | ||
| 20 | <#macro "subscreens-active">{"type": "subscreens-active", "content": ${sri.renderSubscreen()}}</#macro> | ||
| 21 | <#macro "subscreens-panel">{"type": "subscreens-panel", "content": ${sri.renderSubscreen()}}</#macro> | ||
| 22 | |||
| 23 | <#-- ================ Section ================ --> | ||
| 24 | <#macro section>{"type": "section", "name": ${(.node["@name"]!"")?json_string}, "content": ${sri.renderSection(.node["@name"])}}</#macro> | ||
| 25 | <#macro "section-iterate">${sri.renderSection(.node["@name"])}</#macro> | ||
| 26 | <#macro "section-include">${sri.renderSectionInclude(.node)}</#macro> | ||
| 27 | |||
| 28 | <#-- ================ Containers ================ --> | ||
| 29 | <#macro container> | ||
| 30 | <#assign children = []> | ||
| 31 | <#list .node?children as child> | ||
| 32 | <#assign rendered><#recurse child></#assign> | ||
| 33 | <#if rendered?has_content && !(rendered?starts_with("{\"widgets\""))> | ||
| 34 | <#assign children = children + [rendered]> | ||
| 35 | </#if> | ||
| 36 | </#list> | ||
| 37 | {"type": "container", "children": [${children?join(",")}]} | ||
| 38 | </#macro> | ||
| 39 | |||
| 40 | <#macro "container-box"> | ||
| 41 | {"type": "container-box"<#if .node["box-header"]?has_content>, "header": ${.node["box-header"][0]["@label"]!?json_string}</#if><#if .node["box-body"]?has_content>, "body": ${.node["box-body"][0]["@label"]!?json_string}</#if>} | ||
| 42 | </#macro> | ||
| 43 | |||
| 44 | <#macro "container-row"><#list .node["row-col"] as rowColNode><#recurse rowColNode></#list></#macro> | ||
| 45 | |||
| 46 | <#macro "container-panel"> | ||
| 47 | {"type": "container-panel"<#if .node["panel-header"]?has_content>, "header": ${.node["panel-header"][0]["@label"]!?json_string}</#if>} | ||
| 48 | </#macro> | ||
| 49 | |||
| 50 | <#macro "container-dialog"> | ||
| 51 | {"type": "container-dialog", "buttonText": ${ec.resource.expand(.node["@button-text"], "")?json_string}} | ||
| 52 | </#macro> | ||
| 53 | |||
| 54 | <#-- ================== Standalone Fields ==================== --> | ||
| 55 | <#macro link> | ||
| 56 | <#assign linkNode = .node> | ||
| 57 | <#if linkNode["@condition"]?has_content><#assign conditionResult = ec.getResource().condition(linkNode["@condition"], "")><#else><#assign conditionResult = true></#if> | ||
| 58 | <#if conditionResult> | ||
| 59 | <#assign urlInstance = sri.makeUrlByType(linkNode["@url"]!"", linkNode["@url-type"]!"transition", linkNode, "true")> | ||
| 60 | <#assign linkText = ""> | ||
| 61 | <#if linkNode["@text"]?has_content> | ||
| 62 | <#assign linkText = ec.getResource().expand(linkNode["@text"], "")> | ||
| 63 | <#elseif linkNode["@entity-name"]?has_content> | ||
| 64 | <#assign linkText = sri.getFieldEntityValue(linkNode)!""?string> | ||
| 65 | </#if> | ||
| 66 | <#if !(linkText?has_content) && .node?parent?node_name?ends_with("-field")> | ||
| 67 | <#assign linkText = sri.getFieldValueString(.node?parent?parent)!> | ||
| 68 | </#if> | ||
| 69 | |||
| 70 | <#-- Convert path to dot notation for moqui_render_screen --> | ||
| 71 | <#assign fullPath = urlInstance.sui.fullPathNameList![]> | ||
| 72 | <#assign dotPath = ""> | ||
| 73 | <#list fullPath as pathPart><#assign dotPath = dotPath + (dotPath?has_content)?then(".", "") + pathPart></#list> | ||
| 74 | |||
| 75 | <#assign paramStr = urlInstance.getParameterString()!""> | ||
| 76 | <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if> | ||
| 77 | |||
| 78 | {"type": "link", "text": ${linkText?json_string}, "path": ${dotPath?json_string}} | ||
| 79 | </#if> | ||
| 80 | </#macro> | ||
| 81 | |||
| 82 | <#macro image>{"type": "image", "alt": ${(.node["@alt"]!"")?json_string}, "url": ${(.node["@url"]!"")?json_string}}</#macro> | ||
| 83 | |||
| 84 | <#macro label> | ||
| 85 | <#assign text = ec.resource.expand(.node["@text"], "")> | ||
| 86 | <#assign type = .node["@type"]!"span"> | ||
| 87 | {"type": "label", "text": ${text?json_string}, "labelType": ${type?json_string}} | ||
| 88 | </#macro> | ||
| 89 | |||
| 90 | <#-- ======================= Form ========================= --> | ||
| 91 | <#macro "form-single"> | ||
| 92 | <#assign formNode = sri.getFormNode(.node["@name"])> | ||
| 93 | <#assign mapName = formNode["@map"]!"fieldValues"> | ||
| 94 | |||
| 95 | <#assign fields = []> | ||
| 96 | <#t>${sri.pushSingleFormMapContext(mapName)} | ||
| 97 | <#list formNode["field"] as fieldNode> | ||
| 98 | <#assign fieldSubNode = ""> | ||
| 99 | <#list fieldNode["conditional-field"] as csf><#if ec.resource.condition(csf["@condition"], "")><#assign fieldSubNode = csf><#break></#if></#list> | ||
| 100 | <#if !(fieldSubNode?has_content)><#assign fieldSubNode = fieldNode["default-field"][0]!></#if> | ||
| 101 | <#if fieldSubNode?has_content && !(fieldSubNode["ignored"]?has_content) && !(fieldSubNode["hidden"]?has_content) && !(fieldSubNode["submit"]?has_content) && fieldSubNode?parent["@hide"]! != "true"> | ||
| 102 | <#assign fieldValue = ec.context.get(fieldSubNode?parent["@name"])!""> | ||
| 103 | <#if fieldValue?has_content> | ||
| 104 | <#assign fieldInfo = {"name": (fieldSubNode?parent["@name"]!"")?json_string, "value": (fieldValue!?json_string)}> | ||
| 105 | <#assign fields = fields + [fieldInfo]> | ||
| 106 | </#if> | ||
| 107 | </#if> | ||
| 108 | </#list> | ||
| 109 | <#t>${sri.popContext()} | ||
| 110 | {"type": "form-single", "name": ${formNode["@name"]?json_string}, "map": ${mapName?json_string}, "fields": [${fields?join(",")}]} | ||
| 111 | </#macro> | ||
| 112 | |||
| 113 | <#macro "form-list"> | ||
| 114 | <#assign formInstance = sri.getFormInstance(.node["@name"])> | ||
| 115 | <#assign formListInfo = formInstance.makeFormListRenderInfo()> | ||
| 116 | <#assign formNode = formListInfo.getFormNode()> | ||
| 117 | <#assign formListColumnList = formListInfo.getAllColInfo()> | ||
| 118 | <#assign listObject = formListInfo.getListObject(false)!> | ||
| 119 | |||
| 120 | {"type": "form-list", "name": ${.node["@name"]?json_string}} | ||
| 121 | </#macro> | ||
| 122 | |||
| 123 | <#macro formListSubField fieldNode> | ||
| 124 | <#list fieldNode["conditional-field"] as fieldSubNode> | ||
| 125 | <#if ec.resource.condition(fieldSubNode["@condition"], "")> | ||
| 126 | {"type": "field", "name": ${fieldSubNode["@name"]?json_string}} | ||
| 127 | <#return> | ||
| 128 | </#if> | ||
| 129 | </#list> | ||
| 130 | </#macro> | ||
| 131 | |||
| 132 | <#macro formListWidget fieldSubNode> | ||
| 133 | <#if fieldSubNode["ignored"]?has_content || fieldSubNode["hidden"]?has_content || fieldSubNode?parent["@hide"]! == "true"><#return></#if> | ||
| 134 | <#if fieldSubNode["submit"]?has_content> | ||
| 135 | <#assign submitText = sri.getFieldValueString(fieldSubNode)!""?json_string> | ||
| 136 | <#assign screenName = sri.getEffectiveScreen().name!""?string> | ||
| 137 | <#assign formNodeObj = sri.getFormNode(.node["@name"])!""> | ||
| 138 | <#assign formName = formNodeObj["@name"]!?string> | ||
| 139 | <#assign fieldName = fieldSubNode["@name"]!""?string> | ||
| 140 | {"type": "submit", "text": ${submitText}, "action": "${screenName}.${formName}.${fieldName}"} | ||
| 141 | </#if> | ||
| 142 | <#recurse fieldSubNode> | ||
| 143 | </#macro> | ||
| 144 | |||
| 145 | <#macro fieldTitle fieldSubNode> | ||
| 146 | <#assign titleValue><#if fieldSubNode["@title"]?has_content>${fieldSubNode["@title"]}<#else><#list fieldSubNode?parent["@name"]?split("(?=[A-Z])", "r") as nameWord>${nameWord?cap_first?replace("Id", "ID")}<#if nameWord_has_next> </#if></#list></#if></#assign> | ||
| 147 | ${ec.l10n.localize(titleValue)?json_string} | ||
| 148 | </#macro> | ||
| 149 | |||
| 150 | <#-- ================== Form Field Widgets ==================== --> | ||
| 151 | <#macro "check"> | ||
| 152 | <#assign options = sri.getFieldOptions(.node)!> | ||
| 153 | <#assign currentValue = sri.getFieldValueString(.node)!""> | ||
| 154 | {"type": "check", "value": ${(options.get(currentValue)!currentValue)?json_string}} | ||
| 155 | </#macro> | ||
| 156 | |||
| 157 | <#macro "date-find"></#macro> | ||
| 158 | |||
| 159 | <#macro "date-time"> | ||
| 160 | <#assign javaFormat = .node["@format"]!""> | ||
| 161 | <#if !(javaFormat?has_content)> | ||
| 162 | <#if .node["@type"]! == "time"><#assign javaFormat="HH:mm"> | ||
| 163 | <#elseif .node["@type"]! == "date"><#assign javaFormat="yyyy-MM-dd"> | ||
| 164 | <#else><#assign javaFormat="yyyy-MM-dd HH:mm"></#if> | ||
| 165 | </#if> | ||
| 166 | <#assign fieldValue = sri.getFieldValueString(.node?parent?parent, .node["@default-value"]!"", javaFormat)!""> | ||
| 167 | {"type": "date-time", "name": ${(.node["@name"]!"")?json_string}, "format": ${javaFormat?json_string}, "value": ${fieldValue?json_string!"null"}} | ||
| 168 | </#macro> | ||
| 169 | |||
| 170 | <#macro "display"> | ||
| 171 | <#assign fieldValue = ""> | ||
| 172 | <#assign dispFieldNode = .node?parent?parent> | ||
| 173 | <#if .node["@text"]?has_content> | ||
| 174 | <#assign textMap = {}> | ||
| 175 | <#if .node["@text-map"]?has_content><#assign textMap = ec.getResource().expression(.node["@text-map"], {})!></#if> | ||
| 176 | <#assign fieldValue = ec.getResource().expand(.node["@text"], "", textMap, false)!> | ||
| 177 | <#if .node["@currency-unit-field"]?has_content> | ||
| 178 | <#assign fieldValue = ec.getL10n().formatCurrency(fieldValue, ec.getResource().expression(.node["@currency-unit-field"], ""))!""> | ||
| 179 | </#if> | ||
| 180 | <#else> | ||
| 181 | <#assign fieldValue = sri.getFieldValueString(.node)!""> | ||
| 182 | </#if> | ||
| 183 | {"type": "display", "value": ${fieldValue?json_string}} | ||
| 184 | </#macro> | ||
| 185 | |||
| 186 | <#macro "display-entity"> | ||
| 187 | <#assign entityValue = sri.getFieldEntityValue(.node)!""> | ||
| 188 | {"type": "display-entity", "value": ${entityValue?json_string}} | ||
| 189 | </#macro> | ||
| 190 | |||
| 191 | <#macro "drop-down"> | ||
| 192 | <#assign options = sri.getFieldOptions(.node)!> | ||
| 193 | <#assign currentValue = sri.getFieldValueString(.node)!""> | ||
| 194 | {"type": "drop-down", "value": ${(options.get(currentValue)!currentValue)?json_string}} | ||
| 195 | </#macro> | ||
| 196 | |||
| 197 | <#macro "text-area"> | ||
| 198 | <#assign fieldValue = sri.getFieldValueString(.node)!""> | ||
| 199 | {"type": "text-area", "value": ${fieldValue?json_string}} | ||
| 200 | </#macro> | ||
| 201 | |||
| 202 | <#macro "text-line"> | ||
| 203 | <#assign fieldValue = sri.getFieldValueString(.node)!""> | ||
| 204 | {"type": "text-line", "value": ${fieldValue?json_string}} | ||
| 205 | </#macro> | ||
| 206 | |||
| 207 | <#macro "text-find"> | ||
| 208 | <#assign fieldValue = sri.getFieldValueString(.node)!""> | ||
| 209 | {"type": "text-find", "value": ${fieldValue?json_string}} | ||
| 210 | </#macro> | ||
| 211 | |||
| 212 | <#macro "submit"> | ||
| 213 | <#assign text = ec.resource.expand(.node["@text"], "")!""> | ||
| 214 | {"type": "submit", "text": ${text?json_string}} | ||
| 215 | </#macro> | ||
| 216 | |||
| 217 | <#macro "password"></#macro> | ||
| 218 | <#macro "hidden"></#macro> |
| ... | @@ -75,6 +75,11 @@ | ... | @@ -75,6 +75,11 @@ |
| 75 | <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if> | 75 | <#if paramStr?has_content><#assign dotPath = dotPath + "?" + paramStr></#if> |
| 76 | 76 | ||
| 77 | [${linkText}](${dotPath})<#t> | 77 | [${linkText}](${dotPath})<#t> |
| 78 | <#if mcpSemanticData??> | ||
| 79 | <#if !mcpSemanticData.links??><#assign dummy = mcpSemanticData.put("links", [])></#if> | ||
| 80 | <#assign linkInfo = {"text": linkText, "path": dotPath, "type": "navigation"}> | ||
| 81 | <#assign dummy = mcpSemanticData.links.add(linkInfo)> | ||
| 82 | </#if> | ||
| 78 | </#if> | 83 | </#if> |
| 79 | </#macro> | 84 | </#macro> |
| 80 | 85 | ||
| ... | @@ -94,6 +99,19 @@ | ... | @@ -94,6 +99,19 @@ |
| 94 | <#macro "form-single"> | 99 | <#macro "form-single"> |
| 95 | <#assign formNode = sri.getFormNode(.node["@name"])> | 100 | <#assign formNode = sri.getFormNode(.node["@name"])> |
| 96 | <#assign mapName = formNode["@map"]!"fieldValues"> | 101 | <#assign mapName = formNode["@map"]!"fieldValues"> |
| 102 | <#assign formMap = ec.resource.expression(mapName, "")!> | ||
| 103 | |||
| 104 | <#if mcpSemanticData??> | ||
| 105 | <#if !mcpSemanticData.formMetadata??><#assign dummy = mcpSemanticData.put("formMetadata", {})</#if> | ||
| 106 | |||
| 107 | <#assign formMeta = {}> | ||
| 108 | <#assign formMeta = formMeta + {"name": .node["@name"]!"", "map": mapName}> | ||
| 109 | <#assign fieldMetaList = []> | ||
| 110 | |||
| 111 | <#assign dummy = mcpSemanticData.formMeta.put(.node["@name"], formMeta)> | ||
| 112 | </#if> | ||
| 113 | |||
| 114 | <#if mcpSemanticData?? && formMap?has_content><#assign dummy = mcpSemanticData.put(.node["@name"], formMap)></#if> | ||
| 97 | <#t>${sri.pushSingleFormMapContext(mapName)} | 115 | <#t>${sri.pushSingleFormMapContext(mapName)} |
| 98 | <#list formNode["field"] as fieldNode> | 116 | <#list formNode["field"] as fieldNode> |
| 99 | <#assign fieldSubNode = ""> | 117 | <#assign fieldSubNode = ""> |
| ... | @@ -101,9 +119,28 @@ | ... | @@ -101,9 +119,28 @@ |
| 101 | <#if !fieldSubNode?has_content><#assign fieldSubNode = fieldNode["default-field"][0]!></#if> | 119 | <#if !fieldSubNode?has_content><#assign fieldSubNode = fieldNode["default-field"][0]!></#if> |
| 102 | <#if fieldSubNode?has_content && !fieldSubNode["ignored"]?has_content && !fieldSubNode["hidden"]?has_content && !fieldSubNode["submit"]?has_content && fieldSubNode?parent["@hide"]! != "true"> | 120 | <#if fieldSubNode?has_content && !fieldSubNode["ignored"]?has_content && !fieldSubNode["hidden"]?has_content && !fieldSubNode["submit"]?has_content && fieldSubNode?parent["@hide"]! != "true"> |
| 103 | <#assign title><@fieldTitle fieldSubNode/></#assign> | 121 | <#assign title><@fieldTitle fieldSubNode/></#assign> |
| 122 | |||
| 123 | <#if mcpSemanticData??> | ||
| 124 | <#assign fieldMeta = {}> | ||
| 125 | <#assign fieldMeta = fieldMeta + {"name": fieldNode["@name"]!"", "title": title!"", "required": (fieldNode["@required"]! == "true")}> | ||
| 126 | |||
| 127 | <#if fieldSubNode["text-line"]?has_content><#assign dummy = fieldMeta.put("type", "text")></#if> | ||
| 128 | <#if fieldSubNode["text-area"]?has_content><#assign dummy = fieldMeta.put("type", "textarea")></#if> | ||
| 129 | <#if fieldSubNode["drop-down"]?has_content><#assign dummy = fieldMeta.put("type", "dropdown")></#if> | ||
| 130 | <#if fieldSubNode["check"]?has_content><#assign dummy = fieldMeta.put("type", "checkbox")></#if> | ||
| 131 | <#if fieldSubNode["date-find"]?has_content><#assign dummy = fieldMeta.put("type", "date")></#if> | ||
| 132 | |||
| 133 | <#assign dummy = fieldMetaList.add(fieldMeta)> | ||
| 134 | </#if> | ||
| 135 | |||
| 104 | * **${title}**: <#recurse fieldSubNode> | 136 | * **${title}**: <#recurse fieldSubNode> |
| 105 | </#if> | 137 | </#if> |
| 106 | </#list> | 138 | </#list> |
| 139 | |||
| 140 | <#if mcpSemanticData?? && fieldMetaList?has_content> | ||
| 141 | <#assign dummy = mcpSemanticData.formMeta[.node["@name"]!].put("fields", fieldMetaList)> | ||
| 142 | </#if> | ||
| 143 | |||
| 107 | <#t>${sri.popContext()} | 144 | <#t>${sri.popContext()} |
| 108 | </#macro> | 145 | </#macro> |
| 109 | 146 | ||
| ... | @@ -113,6 +150,31 @@ | ... | @@ -113,6 +150,31 @@ |
| 113 | <#assign formNode = formListInfo.getFormNode()> | 150 | <#assign formNode = formListInfo.getFormNode()> |
| 114 | <#assign formListColumnList = formListInfo.getAllColInfo()> | 151 | <#assign formListColumnList = formListInfo.getAllColInfo()> |
| 115 | <#assign listObject = formListInfo.getListObject(false)!> | 152 | <#assign listObject = formListInfo.getListObject(false)!> |
| 153 | <#assign totalItems = listObject?size> | ||
| 154 | |||
| 155 | <#if mcpSemanticData?? && listObject?has_content> | ||
| 156 | <#assign truncatedList = listObject> | ||
| 157 | <#if listObject?size > 50> | ||
| 158 | <#assign truncatedList = listObject?take(50)> | ||
| 159 | </#if> | ||
| 160 | <#assign dummy = mcpSemanticData.put(.node["@name"], truncatedList)> | ||
| 161 | |||
| 162 | <#if !mcpSemanticData.listMetadata??><#assign dummy = mcpSemanticData.put("listMetadata", {})</#if> | ||
| 163 | |||
| 164 | <#assign columnNames = []> | ||
| 165 | <#list formListColumnList as columnFieldList> | ||
| 166 | <#assign fieldNode = columnFieldList[0]> | ||
| 167 | <#assign dummy = columnNames.add(fieldNode["@name"]!"")> | ||
| 168 | </#list> | ||
| 169 | |||
| 170 | <#assign dummy = mcpSemanticData.listMeta.put(.node["@name"]!"", { | ||
| 171 | "name": .node["@name"]!"", | ||
| 172 | "totalItems": totalItems, | ||
| 173 | "displayedItems": truncatedList?size, | ||
| 174 | "truncated": (listObject?size > 50), | ||
| 175 | "columns": columnNames | ||
| 176 | })> | ||
| 177 | </#if> | ||
| 116 | 178 | ||
| 117 | <#-- Header Row --> | 179 | <#-- Header Row --> |
| 118 | <#list formListColumnList as columnFieldList> | 180 | <#list formListColumnList as columnFieldList> |
| ... | @@ -123,7 +185,7 @@ | ... | @@ -123,7 +185,7 @@ |
| 123 | | | 185 | | |
| 124 | <#list formListColumnList as columnFieldList>| --- </#list>| | 186 | <#list formListColumnList as columnFieldList>| --- </#list>| |
| 125 | <#-- Data Rows --> | 187 | <#-- Data Rows --> |
| 126 | <#list listObject as listEntry> | 188 | <#list (truncatedList?? && truncatedList?size > 0)!listObject as listEntry> |
| 127 | <#t>${sri.startFormListRow(formListInfo, listEntry, listEntry_index, listEntry_has_next)} | 189 | <#t>${sri.startFormListRow(formListInfo, listEntry, listEntry_index, listEntry_has_next)} |
| 128 | <#list formListColumnList as columnFieldList> | 190 | <#list formListColumnList as columnFieldList> |
| 129 | <#t>| <#list columnFieldList as fieldNode><@formListSubField fieldNode/><#if fieldNode_has_next> </#if></#list><#t> | 191 | <#t>| <#list columnFieldList as fieldNode><@formListSubField fieldNode/><#if fieldNode_has_next> </#if></#list><#t> | ... | ... |
| ... | @@ -174,16 +174,20 @@ | ... | @@ -174,16 +174,20 @@ |
| 174 | 174 | ||
| 175 | try { | 175 | try { |
| 176 | // Consolidated Tool Dispatching | 176 | // Consolidated Tool Dispatching |
| 177 | 177 | ||
| 178 | ec.logger.info("MCP ToolsCall: Dispatching tool name=${name}, arguments=${arguments}") | ||
| 179 | ec.logger.info("MCP ToolsCall: CODE VERSION: 2025-01-09 - FIXED NULL CHECK") | ||
| 180 | |||
| 178 | if (name == "moqui_render_screen") { | 181 | if (name == "moqui_render_screen") { |
| 179 | def screenPath = arguments?.path | 182 | def screenPath = arguments?.path |
| 180 | def parameters = arguments?.parameters ?: [:] | 183 | def parameters = arguments?.parameters ?: [:] |
| 181 | def renderMode = arguments?.renderMode ?: "mcp" | 184 | def renderMode = arguments?.renderMode ?: "mcp" |
| 182 | def subscreenName = arguments?.subscreenName | 185 | def subscreenName = arguments?.subscreenName |
| 186 | def terse = arguments?.terse ?: false | ||
| 183 | 187 | ||
| 184 | if (!screenPath) throw new Exception("moqui_render_screen requires 'path' parameter") | 188 | if (!screenPath) throw new Exception("moqui_render_screen requires 'path' parameter") |
| 185 | 189 | ||
| 186 | ec.logger.info("MCP ToolsCall: Rendering screen path=${screenPath}, subscreen=${subscreenName}") | 190 | ec.logger.info("MCP ToolsCall: Rendering screen path=${screenPath}, subscreen=${subscreenName}, terse=${terse}") |
| 187 | 191 | ||
| 188 | // Strip query parameters from path if present | 192 | // Strip query parameters from path if present |
| 189 | if (screenPath.contains("?")) { | 193 | if (screenPath.contains("?")) { |
| ... | @@ -193,8 +197,10 @@ | ... | @@ -193,8 +197,10 @@ |
| 193 | // Handle component:// or simple dot notation path | 197 | // Handle component:// or simple dot notation path |
| 194 | def resolvedPath = screenPath | 198 | def resolvedPath = screenPath |
| 195 | def resolvedSubscreen = subscreenName | 199 | def resolvedSubscreen = subscreenName |
| 196 | 200 | ||
| 197 | if (!resolvedPath.startsWith("component://")) { | 201 | ec.logger.info("MCP ToolsCall: Starting path resolution, screenPath=${screenPath}, resolvedPath=${resolvedPath}") |
| 202 | |||
| 203 | if (resolvedPath && !resolvedPath.startsWith("component://")) { | ||
| 198 | // Simple dot notation or path conversion | 204 | // Simple dot notation or path conversion |
| 199 | // Longest prefix match for XML screen files | 205 | // Longest prefix match for XML screen files |
| 200 | def pathParts = resolvedPath.split('\\.') | 206 | def pathParts = resolvedPath.split('\\.') |
| ... | @@ -229,10 +235,11 @@ | ... | @@ -229,10 +235,11 @@ |
| 229 | } | 235 | } |
| 230 | 236 | ||
| 231 | def screenCallParams = [ | 237 | def screenCallParams = [ |
| 232 | screenPath: resolvedPath, | 238 | path: resolvedPath, |
| 233 | parameters: parameters, | 239 | parameters: parameters, |
| 234 | renderMode: renderMode, | 240 | renderMode: renderMode, |
| 235 | sessionId: sessionId | 241 | sessionId: sessionId, |
| 242 | terse: terse | ||
| 236 | ] | 243 | ] |
| 237 | if (resolvedSubscreen) screenCallParams.subscreenName = resolvedSubscreen | 244 | if (resolvedSubscreen) screenCallParams.subscreenName = resolvedSubscreen |
| 238 | 245 | ||
| ... | @@ -706,12 +713,13 @@ | ... | @@ -706,12 +713,13 @@ |
| 706 | <service verb="execute" noun="ScreenAsMcpTool" authenticate="true" allow-remote="true" transaction-timeout="120"> | 713 | <service verb="execute" noun="ScreenAsMcpTool" authenticate="true" allow-remote="true" transaction-timeout="120"> |
| 707 | <description>Execute a screen as an MCP tool</description> | 714 | <description>Execute a screen as an MCP tool</description> |
| 708 | <in-parameters> | 715 | <in-parameters> |
| 709 | <parameter name="screenPath" required="true"/> | 716 | <parameter name="path" required="true"/> |
| 710 | <parameter name="parameters" type="Map"><description>Parameters to pass to screen</description></parameter> | 717 | <parameter name="parameters" type="Map"><description>Parameters to pass to screen</description></parameter> |
| 711 | <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter> | 718 | <parameter name="action"><description>Action being processed: if not null, use real screen rendering instead of test mock</description></parameter> |
| 712 | <parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter> | 719 | <parameter name="renderMode" default="mcp"><description>Render mode: mcp, text, html, xml, vuet, qvt</description></parameter> |
| 713 | <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> | 720 | <parameter name="sessionId"><description>Session ID for user context restoration</description></parameter> |
| 714 | <parameter name="subscreenName"><description>Optional subscreen name for dot notation paths</description></parameter> | 721 | <parameter name="subscreenName"><description>Optional subscreen name for dot notation paths</description></parameter> |
| 722 | <parameter name="terse" type="Boolean" default="false"><description>If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items, no truncation).</description></parameter> | ||
| 715 | </in-parameters> | 723 | </in-parameters> |
| 716 | <out-parameters> | 724 | <out-parameters> |
| 717 | <parameter name="result" type="Map"/> | 725 | <parameter name="result" type="Map"/> |
| ... | @@ -778,6 +786,94 @@ def getWikiInstructions = { screenPath -> | ... | @@ -778,6 +786,94 @@ def getWikiInstructions = { screenPath -> |
| 778 | return null | 786 | return null |
| 779 | } | 787 | } |
| 780 | 788 | ||
| 789 | // Optimized recursive serializer for Moqui/Java objects to JSON-friendly Map/List | ||
| 790 | // When terse=true, returns minimal data with truncation metadata for easy access to full version | ||
| 791 | def serializeMoquiObject | ||
| 792 | serializeMoquiObject = { obj, depth = 0, isTerse = false -> | ||
| 793 | if (depth > 5) return "..." // Prevent deep recursion | ||
| 794 | |||
| 795 | if (obj == null) return null | ||
| 796 | if (obj instanceof Map) { | ||
| 797 | def newMap = [:] | ||
| 798 | obj.each { k, v -> | ||
| 799 | def keyStr = k.toString() | ||
| 800 | // Skip internal framework keys and metadata fields | ||
| 801 | if (keyStr.startsWith("_") || keyStr == "ec" || keyStr == "sri") return | ||
| 802 | // Skip audit fields to reduce payload | ||
| 803 | if (keyStr in ["lastUpdatedStamp", "lastUpdatedTxStamp", "createdDate", "createdTxStamp", "createdByUserLogin"]) return | ||
| 804 | def value = serializeMoquiObject(v, depth + 1, isTerse) | ||
| 805 | if (value != null) newMap[keyStr] = value | ||
| 806 | } | ||
| 807 | return newMap | ||
| 808 | } | ||
| 809 | if (obj instanceof Iterable) { | ||
| 810 | def list = obj.collect() | ||
| 811 | // Apply truncation only if terse mode is enabled | ||
| 812 | if (isTerse && list.size() > 10) { | ||
| 813 | ec.logger.info("serializeMoquiObject: Terse mode - truncating list from ${list.size()} to 10 items") | ||
| 814 | def truncated = list.take(10) | ||
| 815 | def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) } | ||
| 816 | return [ | ||
| 817 | _items: resultList, | ||
| 818 | _totalCount: list.size(), | ||
| 819 | _truncated: true, | ||
| 820 | _hasMore: true, | ||
| 821 | _message: "Terse mode: showing first 10 of ${list.size()} items. Set terse=false to get full data." | ||
| 822 | ] | ||
| 823 | } | ||
| 824 | // Full data in non-terse mode (but still apply depth protection) | ||
| 825 | def maxItems = isTerse ? 10 : 50 | ||
| 826 | def truncated = list.take(maxItems) | ||
| 827 | def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) } | ||
| 828 | if (!isTerse && list.size() > 50) { | ||
| 829 | ec.logger.info("serializeMoquiObject: Non-terse mode - truncating large list from ${list.size()} to 50 items for size limits") | ||
| 830 | return [ | ||
| 831 | _items: resultList, | ||
| 832 | _totalCount: list.size(), | ||
| 833 | _truncated: true, | ||
| 834 | _hasMore: true, | ||
| 835 | _message: "Non-terse mode: showing first 50 of ${list.size()} items (size limit reached)" | ||
| 836 | ] | ||
| 837 | } | ||
| 838 | return resultList | ||
| 839 | } | ||
| 840 | if (obj instanceof org.moqui.entity.EntityValue) { | ||
| 841 | return serializeMoquiObject(obj.getMap(), depth + 1, isTerse) | ||
| 842 | } | ||
| 843 | if (obj instanceof java.sql.Timestamp || obj instanceof java.util.Date) { | ||
| 844 | return obj.toString() | ||
| 845 | } | ||
| 846 | if (obj instanceof Number || obj instanceof Boolean) { | ||
| 847 | return obj | ||
| 848 | } | ||
| 849 | if (obj instanceof String) { | ||
| 850 | // Apply truncation only if terse mode is enabled | ||
| 851 | if (isTerse && obj.length() > 200) { | ||
| 852 | return [ | ||
| 853 | _value: obj.substring(0, 200) + "...", | ||
| 854 | _fullLength: obj.length(), | ||
| 855 | _truncated: true, | ||
| 856 | _message: "Terse mode: truncated to 200 chars. Set terse=false to get full data." | ||
| 857 | ] | ||
| 858 | } | ||
| 859 | // Full data in non-terse mode (no string truncation) | ||
| 860 | return obj | ||
| 861 | } | ||
| 862 | if (obj.getClass().getName().startsWith("org.moqui.impl.screen.ScreenDefinition")) { | ||
| 863 | return [location: obj.location] | ||
| 864 | } | ||
| 865 | // Fallback for unknown types - truncate if too long | ||
| 866 | def str = obj.toString() | ||
| 867 | if (str.length() > 200) { | ||
| 868 | return [ | ||
| 869 | _value: str.substring(0, 200) + "...", | ||
| 870 | _fullLength: str.length(), | ||
| 871 | _truncated: true | ||
| 872 | ] | ||
| 873 | } | ||
| 874 | return str | ||
| 875 | } | ||
| 876 | |||
| 781 | // Resolve input screen path to simple path for lookup | 877 | // Resolve input screen path to simple path for lookup |
| 782 | def inputScreenPath = screenPath | 878 | def inputScreenPath = screenPath |
| 783 | if (screenPath.startsWith("component://")) { | 879 | if (screenPath.startsWith("component://")) { |
| ... | @@ -788,16 +884,13 @@ ec.logger.info("MCP Screen Execution: Looking up wiki docs for ${inputScreenPath | ... | @@ -788,16 +884,13 @@ ec.logger.info("MCP Screen Execution: Looking up wiki docs for ${inputScreenPath |
| 788 | // Try to get wiki instructions | 884 | // Try to get wiki instructions |
| 789 | def wikiInstructions = getWikiInstructions(inputScreenPath) | 885 | def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 790 | 886 | ||
| 791 | // Check if action is being processed - use real screen rendering if so | ||
| 792 | def isActionExecution = parameters?.action != null | ||
| 793 | |||
| 794 | // Try to render screen content for LLM consumption | 887 | // Try to render screen content for LLM consumption |
| 795 | def output = null | 888 | def output = null |
| 796 | def screenUrl = "http://localhost:8080/${screenPath}" | 889 | def screenUrl = "http://localhost:8080/${screenPath}" |
| 797 | def isError = false | 890 | def isError = false |
| 798 | 891 | ||
| 799 | try { | 892 | try { |
| 800 | ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath} using ScreenTest with proper root screen, action=${parameters?.action}") | 893 | ec.logger.info("MCP Screen Execution: Attempting to render screen ${screenPath} using ScreenTest with proper root screen") |
| 801 | 894 | ||
| 802 | def testScreenPath = screenPath | 895 | def testScreenPath = screenPath |
| 803 | def rootScreen = "component://webroot/screen/webroot.xml" | 896 | def rootScreen = "component://webroot/screen/webroot.xml" |
| ... | @@ -806,10 +899,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -806,10 +899,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 806 | def isStandalone = false | 899 | def isStandalone = false |
| 807 | 900 | ||
| 808 | if (screenPath.startsWith("component://")) { | 901 | if (screenPath.startsWith("component://")) { |
| 809 | def pathAfterComponent = screenPath.substring(12).replace('.xml','') // Remove "component://" | 902 | def pathAfterComponent = screenPath.substring(12).replace('.xml','') |
| 810 | def pathParts = pathAfterComponent.split("/") | ||
| 811 | 903 | ||
| 812 | // Check if target screen itself is standalone | ||
| 813 | try { | 904 | try { |
| 814 | targetScreenDef = ec.screen.getScreenDefinition(screenPath) | 905 | targetScreenDef = ec.screen.getScreenDefinition(screenPath) |
| 815 | if (targetScreenDef?.screenNode) { | 906 | if (targetScreenDef?.screenNode) { |
| ... | @@ -826,13 +917,11 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -826,13 +917,11 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 826 | } | 917 | } |
| 827 | 918 | ||
| 828 | if (!isStandalone) { | 919 | if (!isStandalone) { |
| 829 | // Check if screen path itself is a valid screen definition | ||
| 830 | try { | 920 | try { |
| 831 | if (ec.screen.getScreenDefinition(screenPath)) { | 921 | if (ec.screen.getScreenDefinition(screenPath)) { |
| 832 | rootScreen = screenPath | 922 | rootScreen = screenPath |
| 833 | testScreenPath = "" | 923 | testScreenPath = "" |
| 834 | } else { | 924 | } else { |
| 835 | // Original component root logic | ||
| 836 | if (pathAfterComponent.startsWith("webroot/screen/")) { | 925 | if (pathAfterComponent.startsWith("webroot/screen/")) { |
| 837 | rootScreen = "component://webroot/screen/webroot.xml" | 926 | rootScreen = "component://webroot/screen/webroot.xml" |
| 838 | testScreenPath = pathAfterComponent.substring("webroot/screen/".length()) | 927 | testScreenPath = pathAfterComponent.substring("webroot/screen/".length()) |
| ... | @@ -852,10 +941,9 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -852,10 +941,9 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 852 | testScreenPath = "" | 941 | testScreenPath = "" |
| 853 | } | 942 | } |
| 854 | 943 | ||
| 855 | // Get final screen definition for MCP data extraction | 944 | // Get final screen definition for data extraction |
| 856 | def finalScreenDef = rootScreen ? ec.screen.getScreenDefinition(rootScreen) : null | 945 | def finalScreenDef = rootScreen ? ec.screen.getScreenDefinition(rootScreen) : null |
| 857 | if (finalScreenDef && testScreenPath) { | 946 | if (finalScreenDef && testScreenPath) { |
| 858 | // Navigate to subscreen | ||
| 859 | def pathSegments = testScreenPath.split('/') | 947 | def pathSegments = testScreenPath.split('/') |
| 860 | for (segment in pathSegments) { | 948 | for (segment in pathSegments) { |
| 861 | if (finalScreenDef) { | 949 | if (finalScreenDef) { |
| ... | @@ -868,134 +956,124 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -868,134 +956,124 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 868 | } | 956 | } |
| 869 | } | 957 | } |
| 870 | } | 958 | } |
| 959 | |||
| 960 | // Regular screen rendering with current user context - use our custom ScreenTestImpl | ||
| 961 | def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) | ||
| 962 | .rootScreen(rootScreen) | ||
| 963 | .renderMode(renderMode ? renderMode : "mcp") | ||
| 964 | .auth(ec.user.username) | ||
| 871 | 965 | ||
| 872 | // Extract MCP-specific data when renderMode is "mcp" or "json" | 966 | def renderParams = parameters ?: [:] |
| 873 | def mcpData = [:] | 967 | renderParams.userId = ec.user.userId |
| 874 | if ((renderMode == "mcp" || renderMode == "json") && finalScreenDef) { | 968 | renderParams.username = ec.user.username |
| 875 | ec.logger.info("MCP Screen Execution: Extracting MCP data for ${screenPath}") | 969 | |
| 876 | 970 | def relativePath = subscreenName ? subscreenName.replaceAll('_','/') : testScreenPath | |
| 877 | // Extract parameters | 971 | ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}") |
| 878 | if (finalScreenDef.parameterByName) { | 972 | |
| 879 | mcpData.parameters = [:] | 973 | def testRender = screenTest.render(relativePath, renderParams, "POST") |
| 880 | finalScreenDef.parameterByName.each { name, param -> | 974 | output = testRender.getOutput() |
| 881 | def value = ec.context.get(name) ?: parameters?.get(name) | 975 | |
| 882 | mcpData.parameters[name] = [name: name, value: value, type: "parameter"] | 976 | // --- NEW: Semantic State Extraction --- |
| 977 | def postContext = testRender.getPostRenderContext() | ||
| 978 | def semanticState = [:] | ||
| 979 | def isTerse = context.terse == true | ||
| 980 | |||
| 981 | if (finalScreenDef && postContext) { | ||
| 982 | semanticState.screenPath = inputScreenPath | ||
| 983 | semanticState.terse = isTerse | ||
| 984 | semanticState.data = [:] | ||
| 985 | |||
| 986 | // Use the explicit semantic data captured by macros if available | ||
| 987 | def explicitData = postContext.get("mcpSemanticData") | ||
| 988 | if (explicitData instanceof Map) { | ||
| 989 | explicitData.each { k, v -> | ||
| 990 | semanticState.data[k] = serializeMoquiObject(v, 0, isTerse) | ||
| 883 | } | 991 | } |
| 884 | } | 992 | } |
| 885 | 993 | ||
| 886 | // Extract forms and their fields | 994 | // Extract transitions (Actions) with metadata (from screen definition, not macros) |
| 887 | if (finalScreenDef.formByName) { | 995 | semanticState.actions = [] |
| 888 | mcpData.forms = [] | 996 | finalScreenDef.getAllTransitions().each { trans -> |
| 889 | finalScreenDef.formByName.each { formName, form -> | 997 | def actionInfo = [ |
| 890 | def formInfo = [name: formName, fields: []] | 998 | name: trans.getName(), |
| 891 | def formNode = form.internalFormNode | 999 | service: trans.getSingleServiceName() |
| 892 | if (formNode) { | 1000 | ] |
| 893 | // Extract field elements | 1001 | semanticState.actions << actionInfo |
| 894 | def fields = formNode.children('field') | ||
| 895 | fields.each { field -> | ||
| 896 | def fieldName = field.attribute('name') | ||
| 897 | if (fieldName && field) { | ||
| 898 | def fieldInfo = [name: fieldName] | ||
| 899 | if (field.getName()) fieldInfo.type = field.getName() | ||
| 900 | def value = ec.context.get(fieldName) ?: parameters?.get(fieldName) | ||
| 901 | if (value) fieldInfo.value = value | ||
| 902 | |||
| 903 | // Check if it's a widget with options | ||
| 904 | if (field.'drop-down' || field.'check' || field.'radio') { | ||
| 905 | fieldInfo.widgetType = "selection" | ||
| 906 | } | ||
| 907 | formInfo.fields << fieldInfo | ||
| 908 | } | ||
| 909 | } | ||
| 910 | } | ||
| 911 | if (formInfo.fields) mcpData.forms << formInfo | ||
| 912 | } | ||
| 913 | } | 1002 | } |
| 914 | 1003 | ||
| 915 | // Extract transitions (actions like "Update" buttons) | 1004 | // 3. Extract parameters that are currently set |
| 916 | ec.logger.info("MCP Data Extraction: renderMode=${renderMode}, hasScreenDef=${finalScreenDef != null}, hasTransition=${finalScreenDef?.hasTransition(null)}") | 1005 | semanticState.parameters = [:] |
| 917 | if (finalScreenDef.hasTransition(null)) { | 1006 | if (finalScreenDef.parameterByName) { |
| 918 | mcpData.actions = [] | 1007 | finalScreenDef.parameterByName.each { name, param -> |
| 919 | finalScreenDef.getTransitionList().each { trans -> | 1008 | def value = postContext.get(name) ?: parameters?.get(name) |
| 920 | mcpData.actions << [ | 1009 | if (value != null) semanticState.parameters[name] = serializeMoquiObject(value, 0, isTerse) |
| 921 | name: trans.name, | ||
| 922 | service: trans.xmlTransition ? trans.xmlTransition.attribute('service') : null, | ||
| 923 | description: trans.description | ||
| 924 | ] | ||
| 925 | } | 1010 | } |
| 926 | } | 1011 | } |
| 1012 | |||
| 1013 | // Log semantic state size for optimization tracking | ||
| 1014 | def semanticStateJson = new groovy.json.JsonBuilder(semanticState).toString() | ||
| 1015 | def semanticStateSize = semanticStateJson.length() | ||
| 1016 | ec.logger.info("MCP Screen Execution: Semantic state size: ${semanticStateSize} bytes, data keys: ${semanticState.data.keySet()}, actions count: ${semanticState.actions.size()}, terse=${isTerse}") | ||
| 927 | } | 1017 | } |
| 928 | 1018 | ||
| 929 | // Regular screen rendering with current user context - use our custom ScreenTestImpl | 1019 | ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}") |
| 930 | def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) | ||
| 931 | .rootScreen(rootScreen) | ||
| 932 | .renderMode(renderMode ? renderMode : "mcp") | ||
| 933 | .auth(ec.user.username) | ||
| 934 | |||
| 935 | def renderParams = parameters ?: [:] | ||
| 936 | renderParams.userId = ec.user.userId | ||
| 937 | renderParams.username = ec.user.username | ||
| 938 | |||
| 939 | def relativePath = subscreenName ? subscreenName.replaceAll('_','/') : testScreenPath | ||
| 940 | ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}") | ||
| 941 | |||
| 942 | def testRender = screenTest.render(relativePath, renderParams, "POST") | ||
| 943 | output = testRender.getOutput() | ||
| 944 | ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}") | ||
| 945 | 1020 | ||
| 946 | } catch (Exception e) { | 1021 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 |
| 947 | isError = true | 1022 | |
| 948 | ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e) | ||
| 949 | output = "SCREEN RENDERING ERROR: ${e.message}\n\nTroubleshooting Suggestions:\n1. Check if the screen path is correct\n2. Verify user permissions\n3. Check server logs" | ||
| 950 | } | ||
| 951 | |||
| 952 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | ||
| 953 | |||
| 954 | // Build result based on renderMode | 1023 | // Build result based on renderMode |
| 955 | def content = [] | 1024 | def content = [] |
| 956 | if ((renderMode == "mcp" || renderMode == "json") && mcpData) { | 1025 | if ((renderMode == "mcp" || renderMode == "json") && semanticState) { |
| 957 | // Return structured MCP data | 1026 | // Return structured MCP data |
| 958 | def mcpResult = [ | 1027 | def mcpResult = [ |
| 959 | screenPath: screenPath, | 1028 | screenPath: screenPath, |
| 960 | screenUrl: screenUrl, | 1029 | screenUrl: screenUrl, |
| 961 | executionTime: executionTime, | 1030 | executionTime: executionTime, |
| 962 | isError: isError | 1031 | isError: isError, |
| 963 | ] | 1032 | semanticState: semanticState |
| 964 | if (mcpData.parameters) mcpResult.parameters = mcpData.parameters | 1033 | ] |
| 965 | if (mcpData.forms) mcpResult.forms = mcpData.forms | 1034 | |
| 966 | if (mcpData.actions) mcpResult.actions = mcpData.actions | 1035 | // Truncate text preview to 500 chars to save tokens, since we have structured data |
| 967 | if (output) mcpResult.htmlPreview = output.take(2000) + (output.length() > 2000 ? "..." : "") | 1036 | if (output) mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "") |
| 968 | if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions | 1037 | if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions |
| 969 | 1038 | ||
| 970 | content << [ | 1039 | content << [ |
| 971 | type: "text", | 1040 | type: "text", |
| 972 | text: new groovy.json.JsonBuilder(mcpResult).toString() | 1041 | text: new groovy.json.JsonBuilder(mcpResult).toString() |
| 973 | ] | 1042 | ] |
| 974 | } else { | 1043 | } else { |
| 975 | // Return raw output for other modes | 1044 | // Return raw output for other modes (text, html, etc) |
| 976 | def textOutput = output | 1045 | def textOutput = output |
| 977 | if (wikiInstructions) { | 1046 | if (wikiInstructions) { |
| 978 | textOutput = "--- Wiki Instructions ---\n\n${wikiInstructions}\n\n--- Screen Output ---\n\n${output}" | 1047 | textOutput = "--- Wiki Instructions ---\n\n${wikiInstructions}\n\n--- Screen Output ---\n\n${output}" |
| 1048 | } | ||
| 1049 | content << [ | ||
| 1050 | type: "text", | ||
| 1051 | text: textOutput, | ||
| 1052 | screenPath: screenPath, | ||
| 1053 | screenUrl: screenUrl, | ||
| 1054 | executionTime: executionTime, | ||
| 1055 | isError: isError | ||
| 1056 | ] | ||
| 1057 | } | ||
| 1058 | |||
| 1059 | result = [ | ||
| 1060 | content: content, | ||
| 1061 | isError: false | ||
| 1062 | ] | ||
| 1063 | return // Success! | ||
| 1064 | |||
| 1065 | } catch (Exception e) { | ||
| 1066 | isError = true | ||
| 1067 | ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e) | ||
| 1068 | output = "SCREEN RENDERING ERROR: ${e.message}" | ||
| 1069 | result = [ | ||
| 1070 | isError: true, | ||
| 1071 | content: [[type: "text", text: output]] | ||
| 1072 | ] | ||
| 979 | } | 1073 | } |
| 980 | content << [ | 1074 | ]]></script> |
| 981 | type: "text", | 1075 | </actions> |
| 982 | text: textOutput, | 1076 | </service> |
| 983 | screenPath: screenPath, | ||
| 984 | screenUrl: screenUrl, | ||
| 985 | executionTime: executionTime, | ||
| 986 | isError: isError | ||
| 987 | ] | ||
| 988 | } | ||
| 989 | |||
| 990 | result = [ | ||
| 991 | content: content, | ||
| 992 | isError: false | ||
| 993 | ] | ||
| 994 | |||
| 995 | ec.logger.info("MCP Screen Execution: Returned result for screen ${screenPath} in ${executionTime}s") | ||
| 996 | ]]></script> | ||
| 997 | </actions> | ||
| 998 | </service> | ||
| 999 | 1077 | ||
| 1000 | <service verb="mcp" noun="ResourcesTemplatesList" authenticate="false" allow-remote="true" transaction-timeout="30"> | 1078 | <service verb="mcp" noun="ResourcesTemplatesList" authenticate="false" allow-remote="true" transaction-timeout="30"> |
| 1001 | <description>Handle MCP resources/templates/list request</description> | 1079 | <description>Handle MCP resources/templates/list request</description> |
| ... | @@ -1274,6 +1352,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1274,6 +1352,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1274 | <parameter name="renderMode" default="mcp"><description>Render mode: mcp (default), text, html, xml, vuet, qvt</description></parameter> | 1352 | <parameter name="renderMode" default="mcp"><description>Render mode: mcp (default), text, html, xml, vuet, qvt</description></parameter> |
| 1275 | <parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter> | 1353 | <parameter name="parameters" type="Map"><description>Parameters to pass to screen during rendering or action</description></parameter> |
| 1276 | <parameter name="sessionId"/> | 1354 | <parameter name="sessionId"/> |
| 1355 | <parameter name="terse" type="Boolean" default="false"><description>If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items, no truncation).</description></parameter> | ||
| 1277 | </in-parameters> | 1356 | </in-parameters> |
| 1278 | <out-parameters> | 1357 | <out-parameters> |
| 1279 | <parameter name="result" type="Map"/> | 1358 | <parameter name="result" type="Map"/> |
| ... | @@ -1667,7 +1746,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1667,7 +1746,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1667 | screenPath: screenPath, | 1746 | screenPath: screenPath, |
| 1668 | parameters: renderParams, | 1747 | parameters: renderParams, |
| 1669 | renderMode: actualRenderMode, | 1748 | renderMode: actualRenderMode, |
| 1670 | sessionId: sessionId | 1749 | sessionId: sessionId, |
| 1750 | terse: context.terse == true | ||
| 1671 | ] | 1751 | ] |
| 1672 | if (subscreenName) screenCallParams.subscreenName = subscreenName | 1752 | if (subscreenName) screenCallParams.subscreenName = subscreenName |
| 1673 | 1753 | ||
| ... | @@ -1676,18 +1756,51 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1676,18 +1756,51 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1676 | .parameters(screenCallParams) | 1756 | .parameters(screenCallParams) |
| 1677 | .call() | 1757 | .call() |
| 1678 | 1758 | ||
| 1679 | // Extract rendered content from result | 1759 | // Extract rendered content and semantic state from result |
| 1680 | // ScreenAsMcpTool returns {content: [{type: "text", text: "...", ...}]} | ||
| 1681 | ec.logger.info("BrowseScreens: serviceResult keys: ${serviceResult?.keySet()}, has content: ${serviceResult?.containsKey('content')}, has result: ${serviceResult?.containsKey('result')}") | ||
| 1682 | if (serviceResult) { | 1760 | if (serviceResult) { |
| 1761 | def resultObj = null | ||
| 1683 | if (serviceResult.containsKey('content') && serviceResult.content && serviceResult.content.size() > 0) { | 1762 | if (serviceResult.containsKey('content') && serviceResult.content && serviceResult.content.size() > 0) { |
| 1684 | renderedContent = serviceResult.content[0].text | 1763 | def rawText = serviceResult.content[0].text |
| 1685 | ec.logger.info("BrowseScreens: Extracted content from serviceResult.content[0].text") | 1764 | if (rawText && rawText.startsWith("{")) { |
| 1765 | try { resultObj = new groovy.json.JsonSlurper().parseText(rawText) } catch(e) {} | ||
| 1766 | } | ||
| 1767 | renderedContent = rawText | ||
| 1686 | } else if (serviceResult.containsKey('result') && serviceResult.result && serviceResult.result.content && serviceResult.result.content.size() > 0) { | 1768 | } else if (serviceResult.containsKey('result') && serviceResult.result && serviceResult.result.content && serviceResult.result.content.size() > 0) { |
| 1687 | renderedContent = serviceResult.result.content[0].text | 1769 | def rawText = serviceResult.result.content[0].text |
| 1688 | ec.logger.info("BrowseScreens: Extracted content from serviceResult.result.content[0].text") | 1770 | if (rawText && rawText.startsWith("{")) { |
| 1689 | } else { | 1771 | try { resultObj = new groovy.json.JsonSlurper().parseText(rawText) } catch(e) {} |
| 1690 | ec.logger.info("BrowseScreens: serviceResult structure: ${serviceResult}, result content size: ${serviceResult?.result?.content?.size()}") | 1772 | } |
| 1773 | renderedContent = rawText | ||
| 1774 | } | ||
| 1775 | |||
| 1776 | if (resultObj && resultObj.semanticState) { | ||
| 1777 | resultMap.semanticState = resultObj.semanticState | ||
| 1778 | |||
| 1779 | // Build UI narrative for LLM guidance | ||
| 1780 | try { | ||
| 1781 | def narrativeBuilder = new org.moqui.mcp.UiNarrativeBuilder() | ||
| 1782 | // Get screen definition for narrative building | ||
| 1783 | def screenDefForNarrative = null | ||
| 1784 | if (screenPath) { | ||
| 1785 | screenDefForNarrative = ec.screen.getScreenDefinition(screenPath) | ||
| 1786 | } | ||
| 1787 | |||
| 1788 | def uiNarrative = narrativeBuilder.buildNarrative( | ||
| 1789 | screenDefForNarrative, | ||
| 1790 | resultObj.semanticState, | ||
| 1791 | currentPath, | ||
| 1792 | context.terse == true | ||
| 1793 | ) | ||
| 1794 | resultMap.uiNarrative = uiNarrative | ||
| 1795 | ec.logger.info("BrowseScreens: Generated UI narrative for ${currentPath}") | ||
| 1796 | } catch (Exception e) { | ||
| 1797 | ec.logger.warn("BrowseScreens: Failed to generate UI narrative: ${e.message}") | ||
| 1798 | } | ||
| 1799 | |||
| 1800 | // If we have semantic state, we can truncate the rendered content to save tokens | ||
| 1801 | if (renderedContent && renderedContent.length() > 500) { | ||
| 1802 | renderedContent = renderedContent.take(500) + "... (truncated, see uiNarrative for actions)" | ||
| 1803 | } | ||
| 1691 | } | 1804 | } |
| 1692 | } | 1805 | } |
| 1693 | 1806 | ||
| ... | @@ -1947,7 +2060,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1947,7 +2060,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1947 | properties: [ | 2060 | properties: [ |
| 1948 | "path": [type: "string", description: "Screen path (e.g. 'PopCommerce.Catalog.Product')"], | 2061 | "path": [type: "string", description: "Screen path (e.g. 'PopCommerce.Catalog.Product')"], |
| 1949 | "parameters": [type: "object", description: "Parameters for the screen"], | 2062 | "parameters": [type: "object", description: "Parameters for the screen"], |
| 1950 | "renderMode": [type: "string", description: "mcp, text, html, xml, vuet, qvt", default: "mcp"] | 2063 | "renderMode": [type: "string", description: "mcp, text, html, xml, vuet, qvt", default: "mcp"], |
| 2064 | "terse": [type: "boolean", description: "If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items). Default: false"] | ||
| 1951 | ], | 2065 | ], |
| 1952 | required: ["path"] | 2066 | required: ["path"] |
| 1953 | ] | 2067 | ] |
| ... | @@ -1962,7 +2076,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1962,7 +2076,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1962 | "path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"], | 2076 | "path": [type: "string", description: "Path to browse (e.g. 'PopCommerce')"], |
| 1963 | "action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"], | 2077 | "action": [type: "string", description: "Action to process before rendering: null (browse), 'submit' (form), 'create', 'update', or transition name"], |
| 1964 | "renderMode": [type: "string", description: "Render mode: mcp (default), text, html, xml, vuet, qvt"], | 2078 | "renderMode": [type: "string", description: "Render mode: mcp (default), text, html, xml, vuet, qvt"], |
| 1965 | "parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"] | 2079 | "parameters": [type: "object", description: "Parameters to pass to screen during rendering or action"], |
| 2080 | "terse": [type: "boolean", description: "If true, return minimal data (10 items, 200 chars strings). If false, include full data (50 items). Default: false"] | ||
| 1966 | ] | 2081 | ] |
| 1967 | ] | 2082 | ] |
| 1968 | ], | 2083 | ], | ... | ... |
| ... | @@ -291,6 +291,11 @@ class CustomScreenTestImpl implements McpScreenTest { | ... | @@ -291,6 +291,11 @@ class CustomScreenTestImpl implements McpScreenTest { |
| 291 | // push the context | 291 | // push the context |
| 292 | ContextStack cs = eci.getContext() | 292 | ContextStack cs = eci.getContext() |
| 293 | cs.push() | 293 | cs.push() |
| 294 | |||
| 295 | // Create a persistent map for semantic data that survives nested pops | ||
| 296 | Map<String, Object> mcpSemanticData = new HashMap<>() | ||
| 297 | cs.put("mcpSemanticData", mcpSemanticData) | ||
| 298 | |||
| 294 | // create the WebFacadeStub using our custom method | 299 | // create the WebFacadeStub using our custom method |
| 295 | org.moqui.mcp.WebFacadeStub wfs = (org.moqui.mcp.WebFacadeStub) csti.createWebFacade(csti.ecfi, stri.parameters, csti.sessionAttributes, stri.requestMethod, stri.screenPath) | 300 | org.moqui.mcp.WebFacadeStub wfs = (org.moqui.mcp.WebFacadeStub) csti.createWebFacade(csti.ecfi, stri.parameters, csti.sessionAttributes, stri.requestMethod, stri.screenPath) |
| 296 | // set stub on eci, will also put parameters in the context | 301 | // set stub on eci, will also put parameters in the context |
| ... | @@ -336,8 +341,10 @@ class CustomScreenTestImpl implements McpScreenTest { | ... | @@ -336,8 +341,10 @@ class CustomScreenTestImpl implements McpScreenTest { |
| 336 | // calc renderTime | 341 | // calc renderTime |
| 337 | stri.renderTime = System.currentTimeMillis() - startTime | 342 | stri.renderTime = System.currentTimeMillis() - startTime |
| 338 | 343 | ||
| 344 | // capture everything currently in the context stack before popping | ||
| 345 | stri.postRenderContext = new HashMap<>(cs) | ||
| 339 | // pop the context stack, get rid of var space | 346 | // pop the context stack, get rid of var space |
| 340 | stri.postRenderContext = cs.pop() | 347 | cs.pop() |
| 341 | 348 | ||
| 342 | // check, pass through, error messages | 349 | // check, pass through, error messages |
| 343 | if (eci.message.hasError()) { | 350 | if (eci.message.hasError()) { | ... | ... |
| 1 | /* | ||
| 2 | * This software is in public domain under CC0 1.0 Universal plus a | ||
| 3 | * Grant of Patent License. | ||
| 4 | * | ||
| 5 | * To the extent possible under law, author(s) have dedicated all | ||
| 6 | * copyright and related and neighboring rights to this software to the | ||
| 7 | * public domain worldwide. This software is distributed without any | ||
| 8 | * warranty. | ||
| 9 | * | ||
| 10 | * You should have received a copy of the CC0 Public Domain Dedication | ||
| 11 | * along with this software (see the LICENSE.md file). If not, see | ||
| 12 | * <http://creativecommons.org/publicdomain/zero/1.0/>. | ||
| 13 | */ | ||
| 14 | package org.moqui.mcp | ||
| 15 | |||
| 16 | import org.moqui.impl.screen.ScreenDefinition | ||
| 17 | import org.slf4j.Logger | ||
| 18 | import org.slf4j.LoggerFactory | ||
| 19 | |||
| 20 | /** | ||
| 21 | * Builds UI narrative for MCP screen responses. | ||
| 22 | * Creates structured, story-like descriptions that guide LLM on how to invoke actions. | ||
| 23 | */ | ||
| 24 | class UiNarrativeBuilder { | ||
| 25 | protected final static Logger logger = LoggerFactory.getLogger(UiNarrativeBuilder.class) | ||
| 26 | |||
| 27 | private int countForms(Map semanticState) { | ||
| 28 | if (!semanticState?.data) return 0 | ||
| 29 | int count = 0 | ||
| 30 | semanticState.data.keySet().each { k -> | ||
| 31 | if (k.toString().toLowerCase().contains('form')) { | ||
| 32 | count++ | ||
| 33 | } | ||
| 34 | } | ||
| 35 | return count | ||
| 36 | } | ||
| 37 | |||
| 38 | private int countLists(Map semanticState) { | ||
| 39 | if (!semanticState?.data) return 0 | ||
| 40 | int count = 0 | ||
| 41 | semanticState.data.keySet().each { k -> | ||
| 42 | if (k.toString().toLowerCase().contains('list')) { | ||
| 43 | count++ | ||
| 44 | } | ||
| 45 | } | ||
| 46 | return count | ||
| 47 | } | ||
| 48 | |||
| 49 | Map<String, Object> buildNarrative(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath, boolean isTerse) { | ||
| 50 | def narrative = [:] | ||
| 51 | |||
| 52 | narrative.screen = describeScreen(screenDef, semanticState, isTerse) | ||
| 53 | narrative.actions = describeActions(screenDef, semanticState, currentPath, isTerse) | ||
| 54 | narrative.navigation = describeLinks(semanticState, currentPath, isTerse) | ||
| 55 | narrative.notes = describeNotes(semanticState, isTerse) | ||
| 56 | |||
| 57 | return narrative | ||
| 58 | } | ||
| 59 | |||
| 60 | String describeScreen(ScreenDefinition screenDef, Map<String, Object> semanticState, boolean isTerse) { | ||
| 61 | def screenName = screenDef?.name ?: "Screen" | ||
| 62 | def sb = new StringBuilder() | ||
| 63 | |||
| 64 | sb.append("${screenName} displays ") | ||
| 65 | |||
| 66 | def formCount = countForms(semanticState) | ||
| 67 | def listCount = countLists(semanticState) | ||
| 68 | def itemCount = countItems(semanticState) | ||
| 69 | |||
| 70 | if (listCount > 0 && itemCount > 0) { | ||
| 71 | sb.append("${itemCount} item${itemCount > 1 ? 's' : ''} in ${listCount} list${listCount > 1 ? 's' : ''}") | ||
| 72 | if (formCount > 0) sb.append(" with a search form") | ||
| 73 | } else if (formCount > 0) { | ||
| 74 | sb.append("a form with ${formCount} field${formCount > 1 ? 's' : ''}") | ||
| 75 | } else { | ||
| 76 | sb.append("information") | ||
| 77 | } | ||
| 78 | |||
| 79 | sb.append(". ") | ||
| 80 | |||
| 81 | def forms = semanticState?.data | ||
| 82 | if (forms) { | ||
| 83 | def formNames = forms.keySet().findAll { k -> k.contains('Form') || k.contains('form') }[0..2] | ||
| 84 | if (formNames) { | ||
| 85 | def fields = getFormFieldNames(forms, formNames[0]) | ||
| 86 | if (fields) { | ||
| 87 | sb.append("Form contains: ${fields.join(', ')}. ") | ||
| 88 | } | ||
| 89 | } | ||
| 90 | } | ||
| 91 | |||
| 92 | def links = semanticState?.data?.links | ||
| 93 | if (links && links.size() > 0) { | ||
| 94 | def linkTypes = links.collect { l -> l.type?.toString() ?: 'navigation' }.unique() | ||
| 95 | if (linkTypes) { | ||
| 96 | sb.append("Available links: ${linkTypes.take(3).join(', ')}. ") | ||
| 97 | } | ||
| 98 | } | ||
| 99 | |||
| 100 | return sb.toString() | ||
| 101 | } | ||
| 102 | |||
| 103 | List<String> describeActions(ScreenDefinition screenDef, Map<String, Object> semanticState, String currentPath, boolean isTerse) { | ||
| 104 | def actions = [] | ||
| 105 | |||
| 106 | def transitions = semanticState?.actions | ||
| 107 | if (transitions) { | ||
| 108 | transitions.each { trans -> | ||
| 109 | def transName = trans.name?.toString() | ||
| 110 | def service = trans.service?.toString() | ||
| 111 | |||
| 112 | if (transName) { | ||
| 113 | if (service) { | ||
| 114 | actions << buildServiceActionNarrative(transName, service, currentPath, semanticState) | ||
| 115 | } else if (transName.toLowerCase().startsWith('create') || transName.toLowerCase().startsWith('update')) { | ||
| 116 | actions << buildTransitionActionNarrative(transName, currentPath, semanticState) | ||
| 117 | } | ||
| 118 | } | ||
| 119 | } | ||
| 120 | } | ||
| 121 | |||
| 122 | def forms = semanticState?.data | ||
| 123 | if (forms) { | ||
| 124 | def formNames = forms.keySet().findAll { k -> k.contains('Form') || k.contains('form') } | ||
| 125 | formNames.each { formName -> | ||
| 126 | actions << buildFormSubmitNarrative(formName, currentPath, semanticState) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | if (actions.isEmpty()) { | ||
| 131 | actions << "No explicit actions available on this screen. Use navigation links to explore." | ||
| 132 | } | ||
| 133 | |||
| 134 | return actions | ||
| 135 | } | ||
| 136 | |||
| 137 | List<String> describeLinks(Map<String, Object> semanticState, String currentPath, boolean isTerse) { | ||
| 138 | def navigation = [] | ||
| 139 | |||
| 140 | def links = semanticState?.data?.links | ||
| 141 | if (links && links.size() > 0) { | ||
| 142 | def sortedLinks = links.sort { a, b -> (a.text <=> b.text) } | ||
| 143 | |||
| 144 | sortedLinks.take(5).each { link -> | ||
| 145 | def linkText = link.text?.toString() | ||
| 146 | def linkPath = link.path?.toString() | ||
| 147 | def linkType = link.type?.toString() ?: 'navigation' | ||
| 148 | |||
| 149 | if (linkPath) { | ||
| 150 | if (linkPath.startsWith('#')) { | ||
| 151 | def actionName = linkPath.substring(1) | ||
| 152 | navigation << "To ${linkText.toLowerCase()}, use the '${actionName}' action (see actions section)." | ||
| 153 | } else { | ||
| 154 | navigation << "To ${linkText.toLowerCase()}, call moqui_render_screen(path='${linkPath}')." | ||
| 155 | } | ||
| 156 | } | ||
| 157 | } | ||
| 158 | } | ||
| 159 | |||
| 160 | if (navigation.isEmpty()) { | ||
| 161 | def parentPath = getParentPath(currentPath) | ||
| 162 | if (parentPath) { | ||
| 163 | navigation << "To go back, call moqui_browse_screens(path='${parentPath}')." | ||
| 164 | } | ||
| 165 | } | ||
| 166 | |||
| 167 | return navigation | ||
| 168 | } | ||
| 169 | |||
| 170 | List<String> describeNotes(Map<String, Object> semanticState, boolean isTerse) { | ||
| 171 | def notes = [] | ||
| 172 | |||
| 173 | def data = semanticState?.data | ||
| 174 | if (data) { | ||
| 175 | data.each { key, value -> | ||
| 176 | if (value instanceof Map && value.containsKey('_truncated') && value._truncated == true) { | ||
| 177 | def total = value._totalCount ?: 0 | ||
| 178 | def shown = value._items?.size() ?: 0 | ||
| 179 | notes << "List truncated: showing ${shown} of ${total} item${total > 1 ? 's' : ''}. Set terse=false to view all." | ||
| 180 | } | ||
| 181 | } | ||
| 182 | } | ||
| 183 | |||
| 184 | def actions = semanticState?.actions | ||
| 185 | if (actions && actions.size() > 5) { | ||
| 186 | notes << "This screen has ${actions.size()} actions. Use semanticState.actions for complete list." | ||
| 187 | } | ||
| 188 | |||
| 189 | def parameters = semanticState?.parameters | ||
| 190 | if (parameters && parameters.size() > 0) { | ||
| 191 | def requiredParams = parameters.findAll { k, v -> k.toString().toLowerCase().contains('id') } | ||
| 192 | if (requiredParams.size() > 0) { | ||
| 193 | notes << "Required parameters: ${requiredParams.keySet().join(', ')}." | ||
| 194 | } | ||
| 195 | } | ||
| 196 | |||
| 197 | return notes | ||
| 198 | } | ||
| 199 | |||
| 200 | private String buildServiceActionNarrative(String actionName, String service, String currentPath, Map semanticState) { | ||
| 201 | def actionLower = actionName.toLowerCase() | ||
| 202 | def verb = actionLower.startsWith('create') ? 'create' : actionLower.startsWith('update') ? 'update' : actionLower.startsWith('delete') ? 'delete' : 'execute' | ||
| 203 | |||
| 204 | def params = extractServiceParameters(service, semanticState) | ||
| 205 | |||
| 206 | def sb = new StringBuilder() | ||
| 207 | sb.append("To ${verb} ") | ||
| 208 | |||
| 209 | def object = extractObjectFromAction(actionName) | ||
| 210 | sb.append(object.toLowerCase()) | ||
| 211 | sb.append(", call moqui_render_screen(path='${currentPath}', action='${actionName}'") | ||
| 212 | |||
| 213 | if (params) { | ||
| 214 | sb.append(", parameters={${params}}") | ||
| 215 | } | ||
| 216 | |||
| 217 | sb.append("). ") | ||
| 218 | sb.append("This invokes service '${service}' via transition.") | ||
| 219 | |||
| 220 | return sb.toString() | ||
| 221 | } | ||
| 222 | |||
| 223 | private String buildTransitionActionNarrative(String actionName, String currentPath, Map semanticState) { | ||
| 224 | def actionLower = actionName.toLowerCase() | ||
| 225 | def verb = actionLower.startsWith('create') ? 'create' : actionLower.startsWith('update') ? 'update' : actionLower.startsWith('delete') ? 'delete' : 'process' | ||
| 226 | |||
| 227 | def params = extractTransitionParameters(actionName, semanticState) | ||
| 228 | |||
| 229 | def sb = new StringBuilder() | ||
| 230 | sb.append("To ${verb} ") | ||
| 231 | |||
| 232 | def object = extractObjectFromAction(actionName) | ||
| 233 | sb.append(object.toLowerCase()) | ||
| 234 | sb.append(", call moqui_render_screen(path='${currentPath}', action='${actionName}'") | ||
| 235 | |||
| 236 | if (params) { | ||
| 237 | sb.append(", parameters={${params}}") | ||
| 238 | } | ||
| 239 | |||
| 240 | sb.append("). ") | ||
| 241 | sb.append("This triggers the '${actionName}' transition on this screen.") | ||
| 242 | |||
| 243 | return sb.toString() | ||
| 244 | } | ||
| 245 | |||
| 246 | private String buildFormSubmitNarrative(String formName, String currentPath, Map semanticState) { | ||
| 247 | def formFriendly = formFriendlyName(formName) | ||
| 248 | def params = extractFormParameters(formName, semanticState) | ||
| 249 | |||
| 250 | def sb = new StringBuilder() | ||
| 251 | sb.append("To submit ${formFriendly.toLowerCase()}, call moqui_render_screen(path='${currentPath}', parameters={${params}}). ") | ||
| 252 | sb.append("This filters or processes the ${formFriendly.toLowerCase()} form.") | ||
| 253 | |||
| 254 | return sb.toString() | ||
| 255 | } | ||
| 256 | |||
| 257 | |||
| 258 | private int countItems(Map semanticState) { | ||
| 259 | if (!semanticState?.data) return 0 | ||
| 260 | def total = 0 | ||
| 261 | semanticState.data.each { k, v -> | ||
| 262 | if (v instanceof Map && v.containsKey('_totalCount')) { | ||
| 263 | total += v._totalCount as Integer | ||
| 264 | } else if (v instanceof List) { | ||
| 265 | total += v.size() | ||
| 266 | } | ||
| 267 | } | ||
| 268 | return total | ||
| 269 | } | ||
| 270 | |||
| 271 | private List<String> getFormFieldNames(Map forms, String formName) { | ||
| 272 | def form = forms[formName] | ||
| 273 | if (!form) return [] | ||
| 274 | |||
| 275 | if (form instanceof Map) { | ||
| 276 | def result = [] | ||
| 277 | form.keySet().each { k -> | ||
| 278 | if (!k.toString().startsWith('_') && result.size() < 5) { | ||
| 279 | result.add(k.toString()) | ||
| 280 | } | ||
| 281 | } | ||
| 282 | return result | ||
| 283 | } | ||
| 284 | |||
| 285 | return [] | ||
| 286 | } | ||
| 287 | |||
| 288 | private String extractServiceParameters(String service, Map semanticState) { | ||
| 289 | def params = [] | ||
| 290 | def allParams = semanticState?.parameters | ||
| 291 | |||
| 292 | if (allParams) { | ||
| 293 | def paramKeys = [] | ||
| 294 | allParams.keySet().each { k -> | ||
| 295 | if (paramKeys.size() < 3) { | ||
| 296 | paramKeys.add(k.toString()) | ||
| 297 | } | ||
| 298 | } | ||
| 299 | paramKeys.each { key -> | ||
| 300 | def value = allParams[key] | ||
| 301 | if (value != null) { | ||
| 302 | def valStr = value instanceof String ? "'${value}'" : value.toString() | ||
| 303 | params << "${key}: ${valStr}" | ||
| 304 | } | ||
| 305 | } | ||
| 306 | } | ||
| 307 | |||
| 308 | return params.join(', ') | ||
| 309 | } | ||
| 310 | |||
| 311 | private String extractTransitionParameters(String actionName, Map semanticState) { | ||
| 312 | def params = [] | ||
| 313 | def allParams = semanticState?.parameters | ||
| 314 | |||
| 315 | if (allParams) { | ||
| 316 | def paramKeys = allParams.keySet().take(3) | ||
| 317 | paramKeys.each { key -> | ||
| 318 | def value = allParams[key] | ||
| 319 | if (value != null) { | ||
| 320 | def valStr = value instanceof String ? "'${value}'" : value.toString() | ||
| 321 | params << "${key}: ${valStr}" | ||
| 322 | } | ||
| 323 | } | ||
| 324 | } | ||
| 325 | |||
| 326 | return params.join(', ') | ||
| 327 | } | ||
| 328 | |||
| 329 | private String extractFormParameters(String formName, Map semanticState) { | ||
| 330 | def form = semanticState?.data?.get(formName) | ||
| 331 | if (!form) return '...' | ||
| 332 | |||
| 333 | def params = [] | ||
| 334 | if (form instanceof Map) { | ||
| 335 | def fieldNames = [] | ||
| 336 | form.keySet().each { k -> | ||
| 337 | if (!k.toString().startsWith('_') && fieldNames.size() < 3) { | ||
| 338 | fieldNames.add(k.toString()) | ||
| 339 | } | ||
| 340 | } | ||
| 341 | fieldNames.each { key -> | ||
| 342 | def value = form[key] | ||
| 343 | if (value != null) { | ||
| 344 | def valStr = value instanceof String ? "'${value}'" : value.toString() | ||
| 345 | params << "${key}: ${valStr}" | ||
| 346 | } | ||
| 347 | } | ||
| 348 | } | ||
| 349 | |||
| 350 | if (params.isEmpty()) params << '...' | ||
| 351 | |||
| 352 | return params.join(', ') | ||
| 353 | } | ||
| 354 | |||
| 355 | private String extractObjectFromAction(String actionName) { | ||
| 356 | def actionLower = actionName.toLowerCase() | ||
| 357 | |||
| 358 | def patterns = [ | ||
| 359 | /create(.+)/, | ||
| 360 | /update(.+)/, | ||
| 361 | /delete(.+)/, | ||
| 362 | /find(.+)/, | ||
| 363 | /search(.+)/ | ||
| 364 | ] | ||
| 365 | |||
| 366 | for (pattern in patterns) { | ||
| 367 | def m = actionLower =~ pattern | ||
| 368 | if (m.find()) { | ||
| 369 | def object = m.group(1) | ||
| 370 | if (object) { | ||
| 371 | def words = object.split('(?=[A-Z])') | ||
| 372 | def cleaned = words.findAll { w -> w.length() > 0 }.join(' ') | ||
| 373 | return cleaned ?: 'item' | ||
| 374 | } | ||
| 375 | } | ||
| 376 | } | ||
| 377 | |||
| 378 | return 'item' | ||
| 379 | } | ||
| 380 | |||
| 381 | private String formFriendlyName(String formName) { | ||
| 382 | def name = formName.replace('Form', '').replace('form', '') | ||
| 383 | def words = name.split('(?=[A-Z])') | ||
| 384 | return words.findAll { w -> w.length() > 0 }.join(' ') ?: 'Form' | ||
| 385 | } | ||
| 386 | |||
| 387 | private String getParentPath(String path) { | ||
| 388 | if (!path || path == 'root') return null | ||
| 389 | |||
| 390 | def parts = path.split('\\.') | ||
| 391 | if (parts.length > 1) { | ||
| 392 | return parts[0..-2].join('.') | ||
| 393 | } | ||
| 394 | |||
| 395 | return 'root' | ||
| 396 | } | ||
| 397 | } |
-
Please register or sign in to post a comment