1/** 2 * home-agent-tools.js â WebMCP agent tools for the Razor home page. 3 * 4 * The Angular SPA registers the same four ordering tools on its own document 5 * (AgentToolsService); WebMCP tools are per-document, so this server-rendered 6 * page must register them itself or an AI browsing agent landing here sees 7 * none. The two read tools answer from the AndroWeb API (window.AW_HOME 8 * supplies the tenant API base, as in home-customer.js). The two ordering 9 * tools cannot act here â this page has no basket â so they direct the agent 10 * to the store menu page, where the SPA's full implementations take over. 11 * 12 * Every fetch is memoised and fails soft (resolves null): a failed call makes 13 * the tool answer with a not-available message instead of throwing at the 14 * agent. Decision logic lives in pure helpers on window.AW_AGENT_TOOL_HELPERS 15 * (no DOM access) so a future unit harness can cover it without page 16 * scaffolding. 17 */ 18(function () { 19 'use strict'; 20 21 var cfg = window.AW_HOME || {}; 22 23 // ââ pure helpers âââââââââââââââââââââââââââââââââââââââââââââââââââââ 24 var helpers = { 25 /** "1 High Street, Testville, AB1 2CD" from the wire Address. */ 26 formatAddress: function (address) { 27 var parts = address ? [address.Line1, address.Line2, address.Line3, address.Town, address.Postcode] : []; 28 return parts.filter(function (part) { return part && part.trim().length > 0; }).join(', '); 29 }, 30 31 /** The occasion prices are quoted in: the site's first supported occasion, as in the SPA without a basket. */ 32 resolveOccasion: function (site) { 33 return (site && site.OccasionsSupported && site.OccasionsSupported[0]) || 'Collection'; 34 }, 35 36 /** The variant the SPA would quote: the preferred default, else the first. */ 37 preferredVariant: function (product) { 38 var variants = (product && product.Variants) || []; 39 return variants.filter(function (variant) { return variant.PreferredDefault; })[0] || variants[0] || null; 40 }, 41 42 /** The price for the occasion, falling back to the variant's first price. */ 43 priceForVariant: function (variant, occasion) { 44 var prices = (variant && variant.Prices) || []; 45 var match = prices.filter(function (price) { return price.Occasion === occasion; })[0] || prices[0]; 46 return match ? match.Amount : undefined; 47 }, 48 49 /** {id, name, price} results for products whose name or description contains the query. */ 50 searchMenu: function (menu, query, occasion) { 51 var term = (query || '').trim().toLowerCase(); 52 if (!term || !menu || !menu.Products) { return []; } 53 54 return menu.Products.filter(function (product) { 55 var name = (product.Name || '').toLowerCase(); 56 var description = (product.Description || '').toLowerCase(); 57 return name.indexOf(term) !== -1 || description.indexOf(term) !== -1; 58 }).map(function (product) { 59 var variant = helpers.preferredVariant(product); 60 return variant ? { id: variant.Id, name: product.Name, price: helpers.priceForVariant(variant, occasion) } : null; 61 }).filter(function (result) { return result !== null; }); 62 }, 63 64 /** The site's menu path for its best occasion (delivery first, as the order card does), or null. */ 65 menuPathForSite: function (site, siteMenuPaths) { 66 var paths = (siteMenuPaths || {})[site && site.Id]; 67 if (!paths) { return null; } 68 var supportsDelivery = ((site && site.OccasionsSupported) || []).indexOf('Delivery') !== -1; 69 return supportsDelivery ? paths.delivery : paths.collection; 70 } 71 }; 72 window.AW_AGENT_TOOL_HELPERS = helpers; 73 74 // The API lives at document.modelContext from Chrome 150 (navigator.modelContext before that). 75 var modelContext = document.modelContext || navigator.modelContext; 76 if (!modelContext || !cfg.tenantApiUrl) { return; } 77 78 // ââ memoised, fail-soft API reads (sites and menus are anonymous endpoints) ââ 79 var memo = {}; 80 function getJson(url) { 81 if (!(url in memo)) { 82 memo[url] = fetch(url) 83 .then(function (response) { return response.ok ? response.json() : null; }) 84 .catch(function () { return null; }); 85 } 86 return memo[url]; 87 } 88 89 function getSites() { return getJson(cfg.tenantApiUrl + '/sites'); } 90 function getMenu(siteId) { return getJson(cfg.tenantApiUrl + '/sites/' + encodeURIComponent(siteId) + '/v2/menu'); } 91 92 function menuUrlFor(site) { 93 var path = helpers.menuPathForSite(site, cfg.siteMenuPaths); 94 return path ? window.location.origin + path : null; 95 } 96 97 /** The tenant's only site, or null when there are several (the agent must pick a store on a menu page). */ 98 function getSingleSite() { 99 return getSites().then(function (sites) { return sites && sites.length === 1 ? sites[0] : null; }); 100 } 101 102 function buildStoreDirectory(sites) { 103 return { 104 stores: sites.map(function (site) { 105 return { name: site.Name, address: helpers.formatAddress(site.Address), phone: site.Phone, menuUrl: menuUrlFor(site) }; 106 }), 107 message: 'This brand has several stores. Open a store menu page for store-specific details and ordering.' 108 }; 109 } 110 111 var tools = [ 112 { 113 name: 'search_menu', 114 description: 'Search the current restaurant menu by keyword and return matching items with their id, name and price.', 115 annotations: { readOnlyHint: true }, 116 inputSchema: { 117 type: 'object', 118 properties: { 119 query: { type: 'string', description: 'Words to match against item names and descriptions.' } 120 }, 121 required: ['query'] 122 }, 123 execute: function (input) { 124 return getSingleSite().then(function (site) { 125 if (!site) { 126 return { results: [], total: 0, message: 'Open a store menu page to search that storeâs menu.' }; 127 } 128 return getMenu(site.Id).then(function (menu) { 129 var results = helpers.searchMenu(menu, input && input.query, helpers.resolveOccasion(site)); 130 return { results: results, total: results.length }; 131 }); 132 }); 133 } 134 }, 135 { 136 name: 'get_store_info', 137 description: 'Get the current store name, address, phone number, supported order occasions and opening hours.', 138 annotations: { readOnlyHint: true }, 139 inputSchema: { type: 'object', properties: {} }, 140 execute: function () { 141 return getSites().then(function (sites) { 142 if (!sites || sites.length === 0) { 143 return { message: 'Store information is not available yet.' }; 144 } 145 if (sites.length > 1) { 146 return buildStoreDirectory(sites); 147 } 148 var site = sites[0]; 149 return { 150 name: site.Name, 151 address: helpers.formatAddress(site.Address), 152 phone: site.Phone, 153 occasions: site.OccasionsSupported || [], 154 openingHours: site.OpeningHours || [] 155 }; 156 }); 157 } 158 }, 159 { 160 name: 'add_to_basket', 161 description: 'Add a menu item to the current basket by its id (as returned by search_menu).', 162 inputSchema: { 163 type: 'object', 164 properties: { 165 itemId: { type: 'string', description: 'The item id returned by search_menu.' }, 166 quantity: { type: 'integer', minimum: 1, description: 'How many to add. Defaults to 1.' } 167 }, 168 required: ['itemId'] 169 }, 170 execute: function () { 171 return getSingleSite().then(function (site) { 172 var menuUrl = site ? menuUrlFor(site) : null; 173 return { 174 added: false, 175 message: 'Items are added from the store menu page, not the home page.' + (menuUrl ? ' Open ' + menuUrl + ' and call add_to_basket there.' : ' Open a store menu page and call add_to_basket there.') 176 }; 177 }); 178 } 179 }, 180 { 181 name: 'start_checkout', 182 description: 'Return the checkout URL for the current basket so the customer can review and pay.', 183 inputSchema: { type: 'object', properties: {} }, 184 execute: function () { 185 return getSingleSite().then(function (site) { 186 var menuUrl = site ? menuUrlFor(site) : null; 187 return { 188 started: false, 189 message: 'There is no basket on the home page. Add items from the store menu page first' + (menuUrl ? ': ' + menuUrl : '.') 190 }; 191 }); 192 } 193 } 194 ]; 195 196 // Each registration is isolated: registerTool's shape varies across Chrome versions 197 // (it can throw synchronously or return void instead of a promise), and one bad tool 198 // must not stop the remaining tools from registering. Failures stay silent, matching 199 // this page's fail-soft scripts â the tools are an enhancement, never a page break.
200 tools.forEach(function (tool) { 201 try { 202 Promise.resolve(modelContext.registerTool(tool)).catch(function () { /* fail soft */ }); 203 } catch (e) { /* fail soft */ } 204 }); 205})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.