Fix FTL macro errors and stabilize semantic state extraction.
- Resolve NonHashException in macros by avoiding .add() on sequences. - Fix 'Maps with null keys' JSON error by ensuring string keys. - Stabilize form macros with null-safe checks. - Update McpServices and CustomScreenTestImpl for better semantic data handling.
Showing
4 changed files
with
127 additions
and
251 deletions
| ... | @@ -79,9 +79,7 @@ | ... | @@ -79,9 +79,7 @@ |
| 79 | 79 | ||
| 80 | [${linkText}](${slashPath})<#t> | 80 | [${linkText}](${slashPath})<#t> |
| 81 | <#if mcpSemanticData??> | 81 | <#if mcpSemanticData??> |
| 82 | <#if !mcpSemanticData.links??><#assign dummy = mcpSemanticData.put("links", [])></#if> | 82 | <#assign dummy = ec.resource.expression("mcpSemanticData.links.add([text: '" + (linkText!"")?js_string + "', path: '" + (slashPath!"")?js_string + "', type: 'navigation'])", "")!> |
| 83 | <#assign linkInfo = {"text": linkText, "path": slashPath, "type": "navigation"}> | ||
| 84 | <#assign dummy = mcpSemanticData.links.add(linkInfo)> | ||
| 85 | </#if> | 83 | </#if> |
| 86 | </#if> | 84 | </#if> |
| 87 | </#macro> | 85 | </#macro> |
| ... | @@ -105,15 +103,10 @@ | ... | @@ -105,15 +103,10 @@ |
| 105 | <#assign formMap = ec.resource.expression(mapName, "")!> | 103 | <#assign formMap = ec.resource.expression(mapName, "")!> |
| 106 | 104 | ||
| 107 | <#if mcpSemanticData??> | 105 | <#if mcpSemanticData??> |
| 108 | <#if !mcpSemanticData.formMetadata??><#assign dummy = mcpSemanticData.put("formMetadata", {})></#if> | 106 | <#assign formName = (.node["@name"]!"")?string> |
| 109 | |||
| 110 | <#assign formMeta = {"name": (.node["@name"]!""), "map": mapName}> | ||
| 111 | <#assign fieldMetaList = []> | 107 | <#assign fieldMetaList = []> |
| 112 | 108 | <#assign dummy = ec.resource.expression("if (mcpSemanticData.formMetadata == null) mcpSemanticData.formMetadata = [:]; mcpSemanticData.formMetadata.put('" + formName?js_string + "', [name: '" + formName?js_string + "', map: '" + (mapName!"")?js_string + "'])", "")!> | |
| 113 | <#assign dummy = mcpSemanticData.formMetadata.put(.node["@name"], formMeta)> | ||
| 114 | </#if> | 109 | </#if> |
| 115 | |||
| 116 | <#if mcpSemanticData?? && formMap?has_content><#assign dummy = mcpSemanticData.put(.node["@name"], formMap)></#if> | ||
| 117 | <#t>${sri.pushSingleFormMapContext(mapName)} | 110 | <#t>${sri.pushSingleFormMapContext(mapName)} |
| 118 | <#list formNode["field"] as fieldNode> | 111 | <#list formNode["field"] as fieldNode> |
| 119 | <#assign fieldSubNode = ""> | 112 | <#assign fieldSubNode = ""> |
| ... | @@ -125,13 +118,13 @@ | ... | @@ -125,13 +118,13 @@ |
| 125 | <#if mcpSemanticData??> | 118 | <#if mcpSemanticData??> |
| 126 | <#assign fieldMeta = {"name": (fieldNode["@name"]!""), "title": (title!), "required": (fieldNode["@required"]! == "true")}> | 119 | <#assign fieldMeta = {"name": (fieldNode["@name"]!""), "title": (title!), "required": (fieldNode["@required"]! == "true")}> |
| 127 | 120 | ||
| 128 | <#if fieldSubNode["text-line"]?has_content><#assign dummy = fieldMeta.put("type", "text")></#if> | 121 | <#if fieldSubNode["text-line"]?has_content><#assign fieldMeta = fieldMeta + {"type": "text"}></#if> |
| 129 | <#if fieldSubNode["text-area"]?has_content><#assign dummy = fieldMeta.put("type", "textarea")></#if> | 122 | <#if fieldSubNode["text-area"]?has_content><#assign fieldMeta = fieldMeta + {"type": "textarea"}></#if> |
| 130 | <#if fieldSubNode["drop-down"]?has_content><#assign dummy = fieldMeta.put("type", "dropdown")></#if> | 123 | <#if fieldSubNode["drop-down"]?has_content><#assign fieldMeta = fieldMeta + {"type": "dropdown"}></#if> |
| 131 | <#if fieldSubNode["check"]?has_content><#assign dummy = fieldMeta.put("type", "checkbox")></#if> | 124 | <#if fieldSubNode["check"]?has_content><#assign fieldMeta = fieldMeta + {"type": "checkbox"}></#if> |
| 132 | <#if fieldSubNode["date-find"]?has_content><#assign dummy = fieldMeta.put("type", "date")></#if> | 125 | <#if fieldSubNode["date-find"]?has_content><#assign fieldMeta = fieldMeta + {"type": "date"}></#if> |
| 133 | 126 | ||
| 134 | <#assign dummy = fieldMetaList.add(fieldMeta)> | 127 | <#assign fieldMetaList = fieldMetaList + [fieldMeta]> |
| 135 | </#if> | 128 | </#if> |
| 136 | 129 | ||
| 137 | * **${title}**: <#recurse fieldSubNode> | 130 | * **${title}**: <#recurse fieldSubNode> |
| ... | @@ -139,7 +132,9 @@ | ... | @@ -139,7 +132,9 @@ |
| 139 | </#list> | 132 | </#list> |
| 140 | 133 | ||
| 141 | <#if mcpSemanticData?? && fieldMetaList?has_content> | 134 | <#if mcpSemanticData?? && fieldMetaList?has_content> |
| 142 | <#assign dummy = mcpSemanticData.formMetadata[.node["@name"]!].put("fields", fieldMetaList)> | 135 | <#assign formName = (.node["@name"]!"")?string> |
| 136 | <#assign dummy = ec.context.put("tempFieldMetaList", fieldMetaList)!> | ||
| 137 | <#assign dummy = ec.resource.expression("def formMeta = mcpSemanticData.formMetadata?.get('" + formName?js_string + "'); if (formMeta != null) formMeta.put('fields', tempFieldMetaList)", "")!> | ||
| 143 | </#if> | 138 | </#if> |
| 144 | 139 | ||
| 145 | <#t>${sri.popContext()} | 140 | <#t>${sri.popContext()} |
| ... | @@ -154,24 +149,17 @@ | ... | @@ -154,24 +149,17 @@ |
| 154 | <#assign totalItems = listObject?size> | 149 | <#assign totalItems = listObject?size> |
| 155 | 150 | ||
| 156 | <#if mcpSemanticData?? && listObject?has_content> | 151 | <#if mcpSemanticData?? && listObject?has_content> |
| 157 | <#assign truncatedList = listObject> | 152 | <#assign formName = (.node["@name"]!"")?string> |
| 158 | <#assign dummy = mcpSemanticData.put(.node["@name"], truncatedList)> | 153 | <#assign displayedItems = (totalItems > 50)?then(50, totalItems)> |
| 159 | 154 | <#assign isTruncated = (totalItems > 50)> | |
| 160 | <#if !mcpSemanticData.listMetadata??><#assign dummy = mcpSemanticData.put("listMetadata", {})></#if> | ||
| 161 | |||
| 162 | <#assign columnNames = []> | 155 | <#assign columnNames = []> |
| 163 | <#list formListColumnList as columnFieldList> | 156 | <#list formListColumnList as columnFieldList> |
| 164 | <#assign fieldNode = columnFieldList[0]> | 157 | <#assign fieldNode = columnFieldList[0]> |
| 165 | <#assign dummy = columnNames.add(fieldNode["@name"]!"")> | 158 | <#assign columnNames = columnNames + [fieldNode["@name"]!""]> |
| 166 | </#list> | 159 | </#list> |
| 167 | 160 | <#assign dummy = ec.context.put("tempListObject", listObject)!> | |
| 168 | <#assign dummy = mcpSemanticData.listMetadata.put(.node["@name"]!"", { | 161 | <#assign dummy = ec.context.put("tempColumnNames", columnNames)!> |
| 169 | "name": .node["@name"]!"", | 162 | <#assign dummy = ec.resource.expression("mcpSemanticData.put('" + formName?js_string + "', tempListObject); if (mcpSemanticData.listMetadata == null) mcpSemanticData.listMetadata = [:]; mcpSemanticData.listMetadata.put('" + formName?js_string + "', [name: '" + formName?js_string + "', totalItems: " + totalItems + ", displayedItems: " + displayedItems + ", truncated: " + isTruncated?string + ", columns: tempColumnNames])", "")!> |
| 170 | "totalItems": totalItems, | ||
| 171 | "displayedItems": (totalItems > 50)?then(50, totalItems), | ||
| 172 | "truncated": (totalItems > 50), | ||
| 173 | "columns": columnNames | ||
| 174 | })> | ||
| 175 | </#if> | 163 | </#if> |
| 176 | 164 | ||
| 177 | <#-- Header Row --> | 165 | <#-- Header Row --> |
| ... | @@ -227,8 +215,8 @@ | ... | @@ -227,8 +215,8 @@ |
| 227 | <#-- ================== Form Field Widgets ==================== --> | 215 | <#-- ================== Form Field Widgets ==================== --> |
| 228 | <#macro "check"> | 216 | <#macro "check"> |
| 229 | <#assign options = sri.getFieldOptions(.node)!> | 217 | <#assign options = sri.getFieldOptions(.node)!> |
| 230 | <#assign currentValue = sri.getFieldValueString(.node)> | 218 | <#assign currentValue = sri.getFieldValueString(.node)!> |
| 231 | <#t>${(options.get(currentValue))!(currentValue)} | 219 | <#t>${(options[currentValue])!currentValue} |
| 232 | </#macro> | 220 | </#macro> |
| 233 | 221 | ||
| 234 | <#macro "date-find"></#macro> | 222 | <#macro "date-find"></#macro> |
| ... | @@ -264,9 +252,9 @@ | ... | @@ -264,9 +252,9 @@ |
| 264 | </#macro> | 252 | </#macro> |
| 265 | 253 | ||
| 266 | <#macro "drop-down"> | 254 | <#macro "drop-down"> |
| 267 | <#assign options = sri.getFieldOptions(.node)> | 255 | <#assign options = sri.getFieldOptions(.node)!> |
| 268 | <#assign currentValue = sri.getFieldValueString(.node)> | 256 | <#assign currentValue = sri.getFieldValueString(.node)!> |
| 269 | <#t>${(options.get(currentValue))!(currentValue)} | 257 | <#t>${(options[currentValue])!currentValue} |
| 270 | </#macro> | 258 | </#macro> |
| 271 | 259 | ||
| 272 | <#macro "text-area"><#t>${sri.getFieldValueString(.node)}</#macro> | 260 | <#macro "text-area"><#t>${sri.getFieldValueString(.node)}</#macro> | ... | ... |
| ... | @@ -670,21 +670,21 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false -> | ... | @@ -670,21 +670,21 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false -> |
| 670 | _totalCount: list.size(), | 670 | _totalCount: list.size(), |
| 671 | _truncated: true, | 671 | _truncated: true, |
| 672 | _hasMore: true, | 672 | _hasMore: true, |
| 673 | _message: "Terse mode: showing first 10 of ${list.size()} items. Set terse=false to get full data." | 673 | _message: "Terse mode: showing first 10 of ${list.size()} items. Set terse=false to get more data." |
| 674 | ] | 674 | ] |
| 675 | } | 675 | } |
| 676 | // Full data in non-terse mode (but still apply depth protection) | 676 | // Increased limits for non-terse mode |
| 677 | def maxItems = isTerse ? 10 : 50 | 677 | def maxItems = isTerse ? 10 : 250 |
| 678 | def truncated = list.take(maxItems) | 678 | def truncated = list.take(maxItems) |
| 679 | def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) } | 679 | def resultList = truncated.collect { serializeMoquiObject(it, depth + 1, isTerse) } |
| 680 | if (!isTerse && list.size() > 50) { | 680 | if (!isTerse && list.size() > maxItems) { |
| 681 | ec.logger.info("serializeMoquiObject: Non-terse mode - truncating large list from ${list.size()} to 50 items for size limits") | 681 | ec.logger.info("serializeMoquiObject: Non-terse mode - truncating large list from ${list.size()} to ${maxItems} items for safety") |
| 682 | return [ | 682 | return [ |
| 683 | _items: resultList, | 683 | _items: resultList, |
| 684 | _totalCount: list.size(), | 684 | _totalCount: list.size(), |
| 685 | _truncated: true, | 685 | _truncated: true, |
| 686 | _hasMore: true, | 686 | _hasMore: true, |
| 687 | _message: "Non-terse mode: showing first 50 of ${list.size()} items (size limit reached)" | 687 | _message: "Truncated to ${maxItems} items (size safety limit reached)" |
| 688 | ] | 688 | ] |
| 689 | } | 689 | } |
| 690 | return resultList | 690 | return resultList |
| ... | @@ -705,10 +705,18 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false -> | ... | @@ -705,10 +705,18 @@ serializeMoquiObject = { obj, depth = 0, isTerse = false -> |
| 705 | _value: obj.substring(0, 200) + "...", | 705 | _value: obj.substring(0, 200) + "...", |
| 706 | _fullLength: obj.length(), | 706 | _fullLength: obj.length(), |
| 707 | _truncated: true, | 707 | _truncated: true, |
| 708 | _message: "Terse mode: truncated to 200 chars. Set terse=false to get full data." | 708 | _message: "Terse mode: truncated to 200 chars. Set terse=false to get more data." |
| 709 | ] | ||
| 710 | } | ||
| 711 | // Non-terse limit increased to 2000 for safety but much larger than before | ||
| 712 | if (!isTerse && obj.length() > 2000) { | ||
| 713 | return [ | ||
| 714 | _value: obj.substring(0, 2000) + "...", | ||
| 715 | _fullLength: obj.length(), | ||
| 716 | _truncated: true, | ||
| 717 | _message: "Truncated to 2000 chars for safety." | ||
| 709 | ] | 718 | ] |
| 710 | } | 719 | } |
| 711 | // Full data in non-terse mode (no string truncation) | ||
| 712 | return obj | 720 | return obj |
| 713 | } | 721 | } |
| 714 | if (obj.getClass().getName().startsWith("org.moqui.impl.screen.ScreenDefinition")) { | 722 | if (obj.getClass().getName().startsWith("org.moqui.impl.screen.ScreenDefinition")) { |
| ... | @@ -924,193 +932,14 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -924,193 +932,14 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 924 | semanticState: semanticState | 932 | semanticState: semanticState |
| 925 | ] | 933 | ] |
| 926 | 934 | ||
| 927 | // Truncate text preview to 500 chars to save tokens, since we have structured data | 935 | // Truncate text preview only if terse=true |
| 928 | if (output) mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "") | 936 | if (output) { |
| 929 | if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions | 937 | if (isTerse) { |
| 930 | 938 | mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "") | |
| 931 | content << [ | 939 | } else { |
| 932 | type: "text", | 940 | mcpResult.textPreview = output |
| 933 | text: new groovy.json.JsonBuilder(mcpResult).toString() | ||
| 934 | ] | ||
| 935 | } else { | ||
| 936 | // Return raw output for other modes (text, html, etc) | ||
| 937 | def textOutput = output | ||
| 938 | if (wikiInstructions) { | ||
| 939 | textOutput = "--- Wiki Instructions ---\n\n${wikiInstructions}\n\n--- Screen Output ---\n\n${output}" | ||
| 940 | } | ||
| 941 | content << [ | ||
| 942 | type: "text", | ||
| 943 | text: textOutput, | ||
| 944 | screenPath: screenPath, | ||
| 945 | screenUrl: screenUrl, | ||
| 946 | executionTime: executionTime, | ||
| 947 | isError: isError | ||
| 948 | ] | ||
| 949 | } | ||
| 950 | |||
| 951 | result = [ | ||
| 952 | content: content, | ||
| 953 | isError: false | ||
| 954 | ] | ||
| 955 | return // Success! | ||
| 956 | |||
| 957 | } catch (Exception e) { | ||
| 958 | isError = true | ||
| 959 | ec.logger.error("MCP Screen Execution: Full exception for ${screenPath}", e) | ||
| 960 | output = "SCREEN RENDERING ERROR: ${e.message}" | ||
| 961 | result = [ | ||
| 962 | isError: true, | ||
| 963 | content: [[type: "text", text: output]] | ||
| 964 | ] | ||
| 965 | } | ||
| 966 | } | ||
| 967 | } | ||
| 968 | |||
| 969 | // 2. If literal resolution failed, try Component-based resolution | ||
| 970 | if (reachedIndex == -1 && pathSegments.size() >= 2) { | ||
| 971 | def componentName = pathSegments[0] | ||
| 972 | def rootScreenName = pathSegments[1] | ||
| 973 | def compRootLoc = "component://${componentName}/screen/${rootScreenName}.xml" | ||
| 974 | |||
| 975 | if (ec.resource.getLocationReference(compRootLoc).exists) { | ||
| 976 | ec.logger.info("MCP Path Resolution: Found component root at ${compRootLoc}") | ||
| 977 | rootScreen = compRootLoc | ||
| 978 | testScreenPath = pathSegments.size() > 2 ? pathSegments[2..-1].join('/') : "" | ||
| 979 | resolvedScreenDef = ec.screen.getScreenDefinition(rootScreen) | ||
| 980 | |||
| 981 | // Resolve further if there are remaining segments | ||
| 982 | if (testScreenPath) { | ||
| 983 | def remainingSegments = pathSegments[2..-1] | ||
| 984 | def compPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( | ||
| 985 | resolvedScreenDef, resolvedScreenDef, remainingSegments, testScreenPath, [:], ec.screenFacade | ||
| 986 | ) | ||
| 987 | if (compPathList) { | ||
| 988 | for (String screenName in compPathList) { | ||
| 989 | def ssi = resolvedScreenDef?.getSubscreensItem(screenName) | ||
| 990 | if (ssi && ssi.getLocation()) { | ||
| 991 | resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation()) | ||
| 992 | } else { | ||
| 993 | break | ||
| 994 | } | ||
| 995 | } | ||
| 996 | } | ||
| 997 | } | ||
| 998 | } | ||
| 999 | } | ||
| 1000 | |||
| 1001 | // 3. Fallback to double-slash search if still not found | ||
| 1002 | if (reachedIndex == -1 && !resolvedScreenDef && pathSegments.size() > 0 && !testPath.startsWith("//")) { | ||
| 1003 | def searchPath = "//" + pathSegments.join('/') | ||
| 1004 | ec.logger.info("MCP Path Resolution: Fallback to search path ${searchPath}") | ||
| 1005 | |||
| 1006 | rootScreen = "component://webroot/screen/webroot.xml" | ||
| 1007 | def searchPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( | ||
| 1008 | webrootSd, webrootSd, pathSegments, searchPath, [:], ec.screenFacade | ||
| 1009 | ) | ||
| 1010 | |||
| 1011 | if (searchPathList) { | ||
| 1012 | testScreenPath = searchPath | ||
| 1013 | resolvedScreenDef = webrootSd | ||
| 1014 | for (String screenName in searchPathList) { | ||
| 1015 | def ssi = resolvedScreenDef?.getSubscreensItem(screenName) | ||
| 1016 | if (ssi && ssi.getLocation()) { | ||
| 1017 | resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation()) | ||
| 1018 | } else { | ||
| 1019 | break | ||
| 1020 | } | ||
| 1021 | } | ||
| 1022 | } | ||
| 1023 | } | ||
| 1024 | |||
| 1025 | // If we found a specific target, we're good. | ||
| 1026 | // If not, default to webroot with full path (original behavior, but now we know it failed) | ||
| 1027 | if (!resolvedScreenDef) { | ||
| 1028 | rootScreen = "component://webroot/screen/webroot.xml" | ||
| 1029 | resolvedScreenDef = webrootSd | ||
| 1030 | testScreenPath = testPath | ||
| 1031 | } | ||
| 1032 | } | ||
| 1033 | |||
| 1034 | // Regular screen rendering with current user context - use our custom ScreenTestImpl | ||
| 1035 | def screenTest = new org.moqui.mcp.CustomScreenTestImpl(ec.ecfi) | ||
| 1036 | .rootScreen(rootScreen) | ||
| 1037 | .renderMode(renderMode ?: "mcp") | ||
| 1038 | .auth(ec.user.username) | ||
| 1039 | |||
| 1040 | def renderParams = parameters ?: [:] | ||
| 1041 | renderParams.userId = ec.user.userId | ||
| 1042 | renderParams.username = ec.user.username | ||
| 1043 | |||
| 1044 | def relativePath = testScreenPath | ||
| 1045 | ec.logger.info("TESTRENDER root=${rootScreen} path=${relativePath} params=${renderParams}") | ||
| 1046 | |||
| 1047 | def testRender = screenTest.render(relativePath, renderParams, "POST") | ||
| 1048 | output = testRender.getOutput() | ||
| 1049 | |||
| 1050 | // --- NEW: Semantic State Extraction --- | ||
| 1051 | def postContext = testRender.getPostRenderContext() | ||
| 1052 | def semanticState = [:] | ||
| 1053 | def isTerse = context.terse == true | ||
| 1054 | |||
| 1055 | // Get final screen definition using resolved screen location | ||
| 1056 | def finalScreenDef = resolvedScreenDef | ||
| 1057 | |||
| 1058 | if (finalScreenDef && postContext) { | ||
| 1059 | semanticState.screenPath = inputScreenPath | ||
| 1060 | semanticState.terse = isTerse | ||
| 1061 | semanticState.data = [:] | ||
| 1062 | |||
| 1063 | // Use the explicit semantic data captured by macros if available | ||
| 1064 | def explicitData = postContext.get("mcpSemanticData") | ||
| 1065 | if (explicitData instanceof Map) { | ||
| 1066 | explicitData.each { k, v -> | ||
| 1067 | semanticState.data[k] = serializeMoquiObject(v, 0, isTerse) | ||
| 1068 | } | ||
| 1069 | } | ||
| 1070 | |||
| 1071 | // Extract transitions (Actions) with metadata (from screen definition, not macros) | ||
| 1072 | semanticState.actions = [] | ||
| 1073 | finalScreenDef.getAllTransitions().each { trans -> | ||
| 1074 | def actionInfo = [ | ||
| 1075 | name: trans.getName(), | ||
| 1076 | service: trans.getSingleServiceName() | ||
| 1077 | ] | ||
| 1078 | semanticState.actions << actionInfo | ||
| 1079 | } | ||
| 1080 | |||
| 1081 | // 3. Extract parameters that are currently set | ||
| 1082 | semanticState.parameters = [:] | ||
| 1083 | if (finalScreenDef.parameterByName) { | ||
| 1084 | finalScreenDef.parameterByName.each { name, param -> | ||
| 1085 | def value = postContext.get(name) ?: parameters?.get(name) | ||
| 1086 | if (value != null) semanticState.parameters[name] = serializeMoquiObject(value, 0, isTerse) | ||
| 1087 | } | 941 | } |
| 1088 | } | 942 | } |
| 1089 | |||
| 1090 | // Log semantic state size for optimization tracking | ||
| 1091 | def semanticStateJson = new groovy.json.JsonBuilder(semanticState).toString() | ||
| 1092 | def semanticStateSize = semanticStateJson.length() | ||
| 1093 | ec.logger.info("MCP Screen Execution: Semantic state size: ${semanticStateSize} bytes, data keys: ${semanticState.data.keySet()}, actions count: ${semanticState.actions.size()}, terse=${isTerse}") | ||
| 1094 | } | ||
| 1095 | |||
| 1096 | ec.logger.info("MCP Screen Execution: Successfully rendered screen ${screenPath}, output length: ${output?.length() ?: 0}") | ||
| 1097 | |||
| 1098 | def executionTime = (System.currentTimeMillis() - startTime) / 1000.0 | ||
| 1099 | |||
| 1100 | // Build result based on renderMode | ||
| 1101 | def content = [] | ||
| 1102 | if ((renderMode == "mcp" || renderMode == "json") && semanticState) { | ||
| 1103 | // Return structured MCP data | ||
| 1104 | def mcpResult = [ | ||
| 1105 | screenPath: screenPath, | ||
| 1106 | screenUrl: screenUrl, | ||
| 1107 | executionTime: executionTime, | ||
| 1108 | isError: isError, | ||
| 1109 | semanticState: semanticState | ||
| 1110 | ] | ||
| 1111 | |||
| 1112 | // Truncate text preview to 500 chars to save tokens, since we have structured data | ||
| 1113 | if (output) mcpResult.textPreview = output.take(500) + (output.length() > 500 ? "..." : "") | ||
| 1114 | if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions | 943 | if (wikiInstructions) mcpResult.wikiInstructions = wikiInstructions |
| 1115 | 944 | ||
| 1116 | content << [ | 945 | content << [ |
| ... | @@ -1575,26 +1404,85 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1575,26 +1404,85 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1575 | ] | 1404 | ] |
| 1576 | } | 1405 | } |
| 1577 | } else { | 1406 | } else { |
| 1578 | // Forward slash path resolution using Moqui standard | 1407 | // Forward slash path resolution using Moqui standard with robust component-based fallback |
| 1579 | def webrootSd = ec.screen.getScreenDefinition("component://webroot/screen/webroot.xml") | 1408 | def webrootSd = ec.screen.getScreenDefinition("component://webroot/screen/webroot.xml") |
| 1580 | def pathSegments = [] | 1409 | def pathSegments = [] |
| 1581 | currentPath.split('/').each { if (it && it.trim()) pathSegments.add(it) } | 1410 | currentPath.split('/').each { if (it && it.trim()) pathSegments.add(it) } |
| 1582 | 1411 | ||
| 1412 | // 1. Try literal resolution from webroot | ||
| 1583 | def screenPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( | 1413 | def screenPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( |
| 1584 | webrootSd, webrootSd, pathSegments, currentPath, [:], ec.screenFacade | 1414 | webrootSd, webrootSd, pathSegments, currentPath, [:], ec.screenFacade |
| 1585 | ) | 1415 | ) |
| 1586 | 1416 | ||
| 1417 | def currentSd = webrootSd | ||
| 1418 | def reachedIndex = -1 | ||
| 1587 | if (screenPathList) { | 1419 | if (screenPathList) { |
| 1588 | resolvedScreenDef = webrootSd | 1420 | for (int i = 0; i < screenPathList.size(); i++) { |
| 1589 | for (String screenName in screenPathList) { | 1421 | def screenName = screenPathList[i] |
| 1590 | def ssi = resolvedScreenDef?.getSubscreensItem(screenName) | 1422 | def ssi = currentSd?.getSubscreensItem(screenName) |
| 1591 | if (ssi && ssi.getLocation()) { | 1423 | if (ssi && ssi.getLocation()) { |
| 1592 | resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation()) | 1424 | currentSd = ec.screen.getScreenDefinition(ssi.getLocation()) |
| 1425 | reachedIndex = i | ||
| 1593 | } else { | 1426 | } else { |
| 1594 | break | 1427 | break |
| 1595 | } | 1428 | } |
| 1596 | } | 1429 | } |
| 1597 | } | 1430 | } |
| 1431 | |||
| 1432 | resolvedScreenDef = currentSd | ||
| 1433 | |||
| 1434 | // 2. If literal resolution failed, try Component-based resolution | ||
| 1435 | if (reachedIndex == -1 && pathSegments.size() >= 2) { | ||
| 1436 | def componentName = pathSegments[0] | ||
| 1437 | def rootScreenName = pathSegments[1] | ||
| 1438 | def compRootLoc = "component://${componentName}/screen/${rootScreenName}.xml" | ||
| 1439 | |||
| 1440 | if (ec.resource.getLocationReference(compRootLoc).exists) { | ||
| 1441 | ec.logger.info("BrowseScreens Path Resolution: Found component root at ${compRootLoc}") | ||
| 1442 | resolvedScreenDef = ec.screen.getScreenDefinition(compRootLoc) | ||
| 1443 | def subScreenPath = pathSegments.size() > 2 ? pathSegments[2..-1].join('/') : "" | ||
| 1444 | |||
| 1445 | // Resolve further if there are remaining segments | ||
| 1446 | if (subScreenPath) { | ||
| 1447 | def remainingSegments = pathSegments[2..-1] | ||
| 1448 | def compPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( | ||
| 1449 | resolvedScreenDef, resolvedScreenDef, remainingSegments, subScreenPath, [:], ec.screenFacade | ||
| 1450 | ) | ||
| 1451 | if (compPathList) { | ||
| 1452 | for (String screenName in compPathList) { | ||
| 1453 | def ssi = resolvedScreenDef?.getSubscreensItem(screenName) | ||
| 1454 | if (ssi && ssi.getLocation()) { | ||
| 1455 | resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation()) | ||
| 1456 | } else { | ||
| 1457 | break | ||
| 1458 | } | ||
| 1459 | } | ||
| 1460 | } | ||
| 1461 | } | ||
| 1462 | } | ||
| 1463 | } | ||
| 1464 | |||
| 1465 | // 3. Fallback to double-slash search if still not found | ||
| 1466 | if (reachedIndex == -1 && resolvedScreenDef == webrootSd && pathSegments.size() > 0 && !currentPath.startsWith("//")) { | ||
| 1467 | def searchPath = "//" + pathSegments.join('/') | ||
| 1468 | ec.logger.info("BrowseScreens Path Resolution: Fallback to search path ${searchPath}") | ||
| 1469 | |||
| 1470 | def searchPathList = org.moqui.impl.screen.ScreenUrlInfo.parseSubScreenPath( | ||
| 1471 | webrootSd, webrootSd, pathSegments, searchPath, [:], ec.screenFacade | ||
| 1472 | ) | ||
| 1473 | |||
| 1474 | if (searchPathList) { | ||
| 1475 | resolvedScreenDef = webrootSd | ||
| 1476 | for (String screenName in searchPathList) { | ||
| 1477 | def ssi = resolvedScreenDef?.getSubscreensItem(screenName) | ||
| 1478 | if (ssi && ssi.getLocation()) { | ||
| 1479 | resolvedScreenDef = ec.screen.getScreenDefinition(ssi.getLocation()) | ||
| 1480 | } else { | ||
| 1481 | break | ||
| 1482 | } | ||
| 1483 | } | ||
| 1484 | } | ||
| 1485 | } | ||
| 1598 | 1486 | ||
| 1599 | if (resolvedScreenDef) { | 1487 | if (resolvedScreenDef) { |
| 1600 | resolvedScreenDef.getSubscreensItemsSorted().each { subItem -> | 1488 | resolvedScreenDef.getSubscreensItemsSorted().each { subItem -> |
| ... | @@ -1629,11 +1517,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1629,11 +1517,7 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1629 | def foundTransition = resolvedScreenDef.getAllTransitions().find { it.getName() == action } | 1517 | def foundTransition = resolvedScreenDef.getAllTransitions().find { it.getName() == action } |
| 1630 | 1518 | ||
| 1631 | if (foundTransition) { | 1519 | if (foundTransition) { |
| 1632 | def serviceName = null | 1520 | def serviceName = foundTransition.getSingleServiceName() |
| 1633 | if (foundTransition.xmlTransition) { | ||
| 1634 | def serviceCallNode = foundTransition.xmlTransition.first("service-call") | ||
| 1635 | if (serviceCallNode) serviceName = serviceCallNode.attribute("name") | ||
| 1636 | } | ||
| 1637 | 1521 | ||
| 1638 | if (serviceName) { | 1522 | if (serviceName) { |
| 1639 | ec.logger.info("BrowseScreens: Executing service: ${serviceName}") | 1523 | ec.logger.info("BrowseScreens: Executing service: ${serviceName}") |
| ... | @@ -1725,11 +1609,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1725,11 +1609,8 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1725 | // Build UI narrative for LLM guidance | 1609 | // Build UI narrative for LLM guidance |
| 1726 | try { | 1610 | try { |
| 1727 | def narrativeBuilder = new org.moqui.mcp.UiNarrativeBuilder() | 1611 | def narrativeBuilder = new org.moqui.mcp.UiNarrativeBuilder() |
| 1728 | // Get screen definition for narrative building | 1612 | // Use the screen definition we already resolved |
| 1729 | def screenDefForNarrative = null | 1613 | def screenDefForNarrative = resolvedScreenDef |
| 1730 | if (resultObj.semanticState.screenPath) { | ||
| 1731 | screenDefForNarrative = ec.screen.getScreenDefinition(resultObj.semanticState.screenPath) | ||
| 1732 | } | ||
| 1733 | 1614 | ||
| 1734 | def uiNarrative = narrativeBuilder.buildNarrative( | 1615 | def uiNarrative = narrativeBuilder.buildNarrative( |
| 1735 | screenDefForNarrative, | 1616 | screenDefForNarrative, |
| ... | @@ -1738,13 +1619,13 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) | ... | @@ -1738,13 +1619,13 @@ def wikiInstructions = getWikiInstructions(inputScreenPath) |
| 1738 | context.terse == true | 1619 | context.terse == true |
| 1739 | ) | 1620 | ) |
| 1740 | resultMap.uiNarrative = uiNarrative | 1621 | resultMap.uiNarrative = uiNarrative |
| 1741 | ec.logger.info("BrowseScreens: Generated UI narrative for ${currentPath}") | 1622 | ec.logger.info("BrowseScreens: Generated UI narrative for ${currentPath}: ${uiNarrative?.keySet()}") |
| 1742 | } catch (Exception e) { | 1623 | } catch (Exception e) { |
| 1743 | ec.logger.warn("BrowseScreens: Failed to generate UI narrative: ${e.message}") | 1624 | ec.logger.warn("BrowseScreens: Failed to generate UI narrative: ${e.message}") |
| 1744 | } | 1625 | } |
| 1745 | 1626 | ||
| 1746 | // If we have semantic state, we can truncate rendered content to save tokens | 1627 | // Only truncate if terse=true |
| 1747 | if (renderedContent && renderedContent.length() > 500) { | 1628 | if (renderedContent && context.terse == true && renderedContent.length() > 500) { |
| 1748 | renderedContent = renderedContent.take(500) + "... (truncated, see uiNarrative for actions)" | 1629 | renderedContent = renderedContent.take(500) + "... (truncated, see uiNarrative for actions)" |
| 1749 | } | 1630 | } |
| 1750 | } | 1631 | } | ... | ... |
| ... | @@ -321,6 +321,9 @@ class CustomScreenTestImpl implements McpScreenTest { | ... | @@ -321,6 +321,9 @@ class CustomScreenTestImpl implements McpScreenTest { |
| 321 | 321 | ||
| 322 | // Create a persistent map for semantic data that survives nested pops | 322 | // Create a persistent map for semantic data that survives nested pops |
| 323 | Map<String, Object> mcpSemanticData = new HashMap<>() | 323 | Map<String, Object> mcpSemanticData = new HashMap<>() |
| 324 | mcpSemanticData.put("links", new ArrayList<>()) | ||
| 325 | mcpSemanticData.put("formMetadata", new HashMap<>()) | ||
| 326 | mcpSemanticData.put("listMetadata", new HashMap<>()) | ||
| 324 | cs.put("mcpSemanticData", mcpSemanticData) | 327 | cs.put("mcpSemanticData", mcpSemanticData) |
| 325 | 328 | ||
| 326 | // create the WebFacadeStub using our custom method | 329 | // create the WebFacadeStub using our custom method | ... | ... |
| ... | @@ -80,9 +80,11 @@ class UiNarrativeBuilder { | ... | @@ -80,9 +80,11 @@ class UiNarrativeBuilder { |
| 80 | 80 | ||
| 81 | def forms = semanticState?.data | 81 | def forms = semanticState?.data |
| 82 | if (forms) { | 82 | if (forms) { |
| 83 | def formNames = forms.keySet().findAll { k -> k.contains('Form') || k.contains('form') }[0..2] | 83 | def maxForms = isTerse ? 2 : 10 |
| 84 | def formNames = forms.keySet().findAll { k -> k.contains('Form') || k.contains('form') } | ||
| 84 | if (formNames) { | 85 | if (formNames) { |
| 85 | def fields = getFormFieldNames(forms, formNames[0]) | 86 | def formNamesToDescribe = formNames.take(maxForms + 1) |
| 87 | def fields = getFormFieldNames(forms, formNamesToDescribe[0]) | ||
| 86 | if (fields) { | 88 | if (fields) { |
| 87 | sb.append("Form contains: ${fields.join(', ')}. ") | 89 | sb.append("Form contains: ${fields.join(', ')}. ") |
| 88 | } | 90 | } |
| ... | @@ -93,7 +95,8 @@ class UiNarrativeBuilder { | ... | @@ -93,7 +95,8 @@ class UiNarrativeBuilder { |
| 93 | if (links && links.size() > 0) { | 95 | if (links && links.size() > 0) { |
| 94 | def linkTypes = links.collect { l -> l.type?.toString() ?: 'navigation' }.unique() | 96 | def linkTypes = links.collect { l -> l.type?.toString() ?: 'navigation' }.unique() |
| 95 | if (linkTypes) { | 97 | if (linkTypes) { |
| 96 | sb.append("Available links: ${linkTypes.take(3).join(', ')}. ") | 98 | def maxTypes = isTerse ? 3 : 15 |
| 99 | sb.append("Available links: ${linkTypes.take(maxTypes).join(', ')}. ") | ||
| 97 | } | 100 | } |
| 98 | } | 101 | } |
| 99 | 102 | ||
| ... | @@ -141,7 +144,8 @@ class UiNarrativeBuilder { | ... | @@ -141,7 +144,8 @@ class UiNarrativeBuilder { |
| 141 | if (links && links.size() > 0) { | 144 | if (links && links.size() > 0) { |
| 142 | def sortedLinks = links.sort { a, b -> (a.text <=> b.text) } | 145 | def sortedLinks = links.sort { a, b -> (a.text <=> b.text) } |
| 143 | 146 | ||
| 144 | sortedLinks.take(5).each { link -> | 147 | def linksToTake = isTerse ? 5 : 50 |
| 148 | sortedLinks.take(linksToTake).each { link -> | ||
| 145 | def linkText = link.text?.toString() | 149 | def linkText = link.text?.toString() |
| 146 | def linkPath = link.path?.toString() | 150 | def linkPath = link.path?.toString() |
| 147 | def linkType = link.type?.toString() ?: 'navigation' | 151 | def linkType = link.type?.toString() ?: 'navigation' | ... | ... |
-
Please register or sign in to post a comment