PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / trunk
MxChat – AI Chatbot & Content Generation for WordPress vtrunk
3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | js/mxchat_transcripts.js +213 -19 3.2.2trunk View file →
@@ -216,15 +216,64 @@
216 216 $('#mxch-select-all').prop('checked', totalItems > 0 && checkedItems === totalItems);
217 217 $('#mxch-select-all').prop('indeterminate', checkedItems > 0 && checkedItems < totalItems);
218 218 }
219 219
220 - // Sort button
221 - $('#mxch-sort-btn').on('click', function() {
222 - currentSortOrder = currentSortOrder === 'desc' ? 'asc' : 'desc';
223 - $(this).find('svg').css('transform', currentSortOrder === 'asc' ? 'rotate(180deg)' : 'rotate(0deg)');
224 - loadChatList(currentPage, $('#mxch-search-transcripts').val());
220 + // Sort button — opens a small menu with 4 sort modes (plan-a5b006 adds rating sorts).
221 + $('#mxch-sort-btn').on('click', function(e) {
222 + e.stopPropagation();
223 + const $btn = $(this);
224 + let $menu = $('#mxch-sort-menu');
225 + if (!$menu.length) {
226 + $menu = $(
227 + '<div id="mxch-sort-menu" class="mxch-sort-menu" role="menu">' +
228 + ' <button type="button" class="mxch-sort-option" data-sort="desc" role="menuitem">Newest first</button>' +
229 + ' <button type="button" class="mxch-sort-option" data-sort="asc" role="menuitem">Oldest first</button>' +
230 + ' <button type="button" class="mxch-sort-option" data-sort="rating_positive" role="menuitem"><span class="mxch-sort-option-icon mxch-rating-thumb-up"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H7"/><path d="M3 10h4"/></svg></span>Positive ratings first</button>' +
231 + ' <button type="button" class="mxch-sort-option" data-sort="rating_negative" role="menuitem"><span class="mxch-sort-option-icon mxch-rating-thumb-down"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H17"/><path d="M21 14h-4"/></svg></span>Negative ratings first</button>' +
232 + '</div>'
233 + );
234 + $('body').append($menu);
235 + $menu.on('click', '.mxch-sort-option', function() {
236 + currentSortOrder = $(this).data('sort');
237 + $menu.find('.mxch-sort-option').removeClass('active');
238 + $(this).addClass('active');
239 + $menu.hide();
240 + loadChatList(1, $('#mxch-search-transcripts').val());
241 + });
242 + $(document).on('click.mxchSortMenu', function(ev) {
243 + if (!$(ev.target).closest('#mxch-sort-menu, #mxch-sort-btn').length) {
244 + $menu.hide();
245 + }
246 + });
247 + }
248 + $menu.find('.mxch-sort-option').removeClass('active');
249 + $menu.find('[data-sort="' + currentSortOrder + '"]').addClass('active');
250 + const offset = $btn.offset();
251 + const btnHeight = $btn.outerHeight();
252 + $menu.css({
253 + position: 'absolute',
254 + top: (offset.top + btnHeight + 4) + 'px',
255 + left: offset.left + 'px'
256 + }).toggle();
225 257 });
226 258
259 + // Render a rating badge for a session row. Uses inline SVG so the glyph
260 + // renders the same across OSes (emoji fonts vary). Tooltip surfaces the
261 + // optional feedback text.
262 + function renderRatingBadge(session) {
263 + const value = (session && typeof session.rating_value === 'number') ? session.rating_value : null;
264 + const feedback = (session && session.rating_feedback) ? session.rating_feedback : '';
265 + if (value === 1) {
266 + const title = feedback ? ('Visitor: ' + feedback) : 'Visitor rated this chat positively';
267 + return '<span class="mxch-chat-rating mxch-chat-rating-up" title="' + escapeHtml(title) + '" aria-label="' + escapeHtml(title) + '"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H7"/><path d="M3 10h4"/></svg></span>';
268 + }
269 + if (value === -1) {
270 + const title = feedback ? ('Visitor: ' + feedback) : 'Visitor rated this chat negatively';
271 + return '<span class="mxch-chat-rating mxch-chat-rating-down" title="' + escapeHtml(title) + '" aria-label="' + escapeHtml(title) + '"><svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H17"/><path d="M21 14h-4"/></svg></span>';
272 + }
273 + return '<span class="mxch-chat-rating mxch-chat-rating-empty" title="No rating yet" aria-label="No rating yet">—</span>';
274 + }
275 +
227 276 // Delete selected button — opens the shared confirm modal with the "also delete lead" checkbox.
228 277 $('#mxch-delete-selected').on('click', function() {
229 278 const count = selectedSessions.size;
230 279 if (count === 0) return;
@@ -366,8 +415,9 @@
366 415 <div class="mxch-chat-name">${escapeHtml(session.display_name)}</div>
367 416 <div class="mxch-chat-preview">${escapeHtml(session.preview)}</div>
368 417 </div>
369 418 <div class="mxch-chat-meta">
419 + ${renderRatingBadge(session)}
370 420 <span class="mxch-chat-time">${escapeHtml(session.time_display)}</span>
371 421 <span class="mxch-chat-count">${session.message_count}</span>
372 422 </div>
373 423 </div>
@@ -520,8 +570,18 @@
520 570 } else {
521 571 $('#mxch-detail-email-row').hide();
522 572 }
523 573
574 + // Feedback row — .text() (not .html()) for XSS-safe display of user-submitted text.
575 + // rating_feedback is already sanitize_text_field()'d + mb_substr(200) on save.
576 + var feedback = (data && data.rating_feedback) ? String(data.rating_feedback).trim() : '';
577 + if (feedback) {
578 + $('#mxch-detail-feedback').text(feedback);
579 + $('#mxch-detail-feedback-row').show();
580 + } else {
581 + $('#mxch-detail-feedback-row').hide();
582 + }
583 +
524 584 // Clicked links
525 585 if (data.clicked_urls && data.clicked_urls.length > 0) {
526 586 let linksHtml = '';
527 587 data.clicked_urls.forEach(function(url) {
@@ -547,13 +607,19 @@
547 607 <div class="mxch-message-time">${escapeHtml(msg.timestamp)}</div>
548 608 </div>
549 609 `;
550 610 } else {
551 - const ragLink = msg.has_rag ? `<a href="#" class="mxch-rag-link" data-message-id="${msg.id}">Sources</a>` : '';
611 + // Live-agent replies (role 'agent') get their own label and never
612 + // carry a Sources link; bot/assistant rows render exactly as before.
613 + const isAgent = !!msg.is_agent;
614 + const ragLink = (!isAgent && msg.has_rag) ? `<a href="#" class="mxch-rag-link" data-message-id="${msg.id}">Sources</a>` : '';
615 + const senderLabel = isAgent
616 + ? '<span class="mxch-agent-label">Live Agent</span>'
617 + : '<span class="mxch-bot-label">AI Assistant</span>';
552 618 messagesHtml += `
553 - <div class="mxch-message mxch-message-bot" data-message-id="${msg.id}">
619 + <div class="mxch-message mxch-message-bot${isAgent ? ' mxch-message-agent' : ''}" data-message-id="${msg.id}">
554 620 <div class="mxch-message-header">
555 - <span class="mxch-bot-label">AI Assistant</span>
621 + ${senderLabel}
556 622 ${ragLink}
557 623 </div>
558 624 <div class="mxch-message-row">
559 625 <div class="mxch-message-bubble">
@@ -877,9 +943,12 @@
877 943 renderActionsContext(response.data, $actionsContent);
878 944
879 945 // Update badge counts
880 946 const sourcesCount = response.data.top_matches ? response.data.top_matches.length : 0;
881 - const actionsCount = response.data.action_analysis ? response.data.action_analysis.length : 0;
947 + // 470f68: the Actions tab now carries two mechanisms — tools
948 + // that ran and trigger phrases that scored. Badge counts both.
949 + const toolCallsCount = response.data.tool_calls ? response.data.tool_calls.length : 0;
950 + const actionsCount = (response.data.action_analysis ? response.data.action_analysis.length : 0) + toolCallsCount;
882 951
883 952 if (sourcesCount > 0) {
884 953 $('#mxch-sources-count').text(sourcesCount).show();
885 954 }
@@ -915,21 +984,43 @@
915 984
916 985 function renderRagContext(data, $container) {
917 986 let html = '';
918 987
919 - // Check if we have any source data
920 - if (!data.top_matches || data.top_matches.length === 0) {
921 - html += '<div class="mxch-no-results"><p>No document matches found for this response.</p></div>';
988 + // 67fc92: "the KB was searched and nothing matched" and "nothing was
989 + // recorded for this row" are different facts. Retrieval keys present
990 + // means the search ran and was recorded (top_matches may be empty);
991 + // no retrieval keys means there is simply no record (older rows, or
992 + // turns that never consulted the knowledge base).
993 + const retrievalRecorded = typeof data.total_documents_checked !== 'undefined'
994 + || (data.top_matches && data.top_matches.length > 0);
995 +
996 + if (!retrievalRecorded) {
997 + html += '<div class="mxch-no-results"><p>No retrieval data was recorded for this response.</p></div>';
922 998 $container.html(html);
923 999 return;
924 1000 }
925 1001
1002 + // Hybrid keyword boost (38ffa1): rows carry matched_via + fused_rank
1003 + // when hybrid retrieval was on for this response. Cosine % stays the
1004 + // anchor; the chip explains WHY a low-% row ranked high.
1005 + const topMatches = data.top_matches || [];
1006 + const hybridOn = topMatches.some(function(m) { return m.matched_via; });
1007 +
926 1008 html += '<div class="mxch-rag-summary">';
927 1009 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Knowledge Base:</span> <span class="mxch-rag-value">' + escapeHtml(data.knowledge_base_type || 'WordPress Database') + '</span></div>';
928 1010 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Similarity Threshold:</span> <span class="mxch-rag-value">' + Math.round((data.similarity_threshold || 0.35) * 100) + '%</span></div>';
929 1011 html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Documents Checked:</span> <span class="mxch-rag-value">' + (data.total_documents_checked || 0) + '</span></div>';
1012 + if (hybridOn) {
1013 + html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Retrieval:</span> <span class="mxch-rag-value">Hybrid</span></div>';
1014 + }
930 1015 html += '</div>';
931 1016
1017 + if (topMatches.length === 0) {
1018 + html += '<div class="mxch-no-results"><p>The knowledge base was searched &mdash; no documents matched this response.</p></div>';
1019 + $container.html(html);
1020 + return;
1021 + }
1022 +
932 1023 const groupedByUrl = {};
933 1024
934 1025 data.top_matches.forEach(function(match) {
935 1026 const url = match.source_display || 'Unknown';
@@ -938,9 +1029,11 @@
938 1029 url: url,
939 1030 isUrl: url.startsWith('http'),
940 1031 bestScore: 0,
941 1032 usedForContext: false,
942 - matchedChunks: []
1033 + matchedChunks: [],
1034 + bestFusedRank: Infinity,
1035 + viaSet: {}
943 1036 };
944 1037 }
945 1038
946 1039 if (match.similarity_percentage > groupedByUrl[url].bestScore) {
@@ -950,8 +1043,15 @@
950 1043 if (match.used_for_context) {
951 1044 groupedByUrl[url].usedForContext = true;
952 1045 }
953 1046
1047 + if (match.fused_rank && match.fused_rank < groupedByUrl[url].bestFusedRank) {
1048 + groupedByUrl[url].bestFusedRank = match.fused_rank;
1049 + }
1050 + if (match.matched_via) {
1051 + groupedByUrl[url].viaSet[match.matched_via] = true;
1052 + }
1053 +
954 1054 groupedByUrl[url].matchedChunks.push({
955 1055 chunkIndex: match.chunk_index,
956 1056 score: match.similarity_percentage,
957 1057 usedForContext: match.used_for_context
@@ -957,9 +1057,16 @@
957 1057 usedForContext: match.used_for_context
958 1058 });
959 1059 });
960 1060
961 - const urlGroups = Object.values(groupedByUrl).sort((a, b) => b.bestScore - a.bestScore);
1061 + // Hybrid on: order by fused rank (rows without one sort last);
1062 + // otherwise by cosine, exactly as before.
1063 + const urlGroups = Object.values(groupedByUrl).sort(function(a, b) {
1064 + if (hybridOn && a.bestFusedRank !== b.bestFusedRank) {
1065 + return a.bestFusedRank - b.bestFusedRank;
1066 + }
1067 + return b.bestScore - a.bestScore;
1068 + });
962 1069 const usedUrlCount = data.sources_used > 0 ? data.sources_used : urlGroups.filter(g => g.usedForContext).length;
963 1070 const chunksInfo = data.total_chunks_used > 0 ? data.total_chunks_used + ' chunks sent to AI' : '';
964 1071
965 1072 html += '<div class="mxch-rag-matches">';
@@ -974,8 +1081,17 @@
974 1081 html += '<div class="mxch-rag-match-card ' + cardClass + '">';
975 1082 html += '<div class="mxch-rag-match-header">';
976 1083 html += '<span class="mxch-rag-match-score">' + group.bestScore + '%</span>';
977 1084
1085 + if (hybridOn) {
1086 + const vias = Object.keys(group.viaSet);
1087 + if (vias.length) {
1088 + const viaLabel = (vias.length > 1 || vias[0] === 'both') ? 'Both'
1089 + : (vias[0] === 'keyword' ? 'Keyword' : 'Vector');
1090 + html += '<span class="mxch-rag-via-chip mxch-rag-via-' + viaLabel.toLowerCase() + '">' + viaLabel + '</span>';
1091 + }
1092 + }
1093 +
978 1094 if (group.matchedChunks.length > 1) {
979 1095 html += '<span class="mxch-rag-chunk-badge">' + group.matchedChunks.length + ' chunks</span>';
980 1096 }
981 1097
@@ -995,19 +1111,86 @@
995 1111 html += '</div>';
996 1112 $container.html(html);
997 1113 }
998 1114
1115 + // AI Tools trace (470f68): one card per tool EXECUTION for this message.
1116 + // Rendered ABOVE the trigger-phrase scores — a tool that actually ran
1117 + // outranks a phrase that merely scored.
1118 + function renderToolCalls(toolCalls) {
1119 + const failed = toolCalls.filter(function(t) { return !t.ok; }).length;
1120 +
1121 + let html = '<div class="mxch-rag-summary">';
1122 + html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Tools Used:</span> <span class="mxch-rag-value">' + toolCalls.length + '</span></div>';
1123 + if (failed > 0) {
1124 + html += '<div class="mxch-rag-summary-item"><span class="mxch-rag-label">Failed:</span> <span class="mxch-rag-value" style="color: #ef4444; font-weight: 600;">' + failed + '</span></div>';
1125 + }
1126 + html += '</div>';
1127 +
1128 + html += '<div class="mxch-rag-matches">';
1129 + html += '<h3>AI Tools</h3>';
1130 + html += '<p style="color: var(--mxch-text-secondary); font-size: 13px; margin-bottom: 16px;">Tools the assistant chose and ran for this message, in order.</p>';
1131 +
1132 + toolCalls.forEach(function(tool) {
1133 + const ok = !!tool.ok;
1134 + const cardClass = ok ? 'mxch-rag-match-used' : 'mxch-tool-failed';
1135 + const statusClass = ok ? 'status-used' : 'status-failed';
1136 + const statusIcon = ok ? '&#10003;' : '&#10007;';
1137 + const statusLabel = ok ? 'Ran' : 'Failed';
1138 +
1139 + html += '<div class="mxch-rag-match-card ' + cardClass + '">';
1140 + html += '<div class="mxch-rag-match-header">';
1141 + html += '<span class="mxch-rag-match-score">&#9889;</span>';
1142 + if (typeof tool.ms === 'number') {
1143 + html += '<span class="mxch-tool-duration">' + tool.ms + ' ms</span>';
1144 + }
1145 + html += '<span class="mxch-rag-match-status ' + statusClass + '">' + statusIcon + ' ' + statusLabel + '</span>';
1146 + html += '</div>';
1147 +
1148 + html += '<div class="mxch-action-details">';
1149 + html += '<div class="mxch-action-label">' + escapeHtml(tool.label || tool.name) + '</div>';
1150 + html += '<div class="mxch-action-callback"><span class="mxch-action-callback-label">Tool:</span> ' + escapeHtml(tool.name) + '</div>';
1151 + html += '</div>';
1152 +
1153 + if (!ok && tool.error) {
1154 + html += '<div class="mxch-tool-error">' + escapeHtml(tool.error) + '</div>';
1155 + }
1156 +
1157 + if (tool.args_redacted === 'sensitive') {
1158 + html += '<div class="mxch-tool-redacted">Arguments not recorded &mdash; this tool is marked sensitive.</div>';
1159 + } else if (tool.args_excerpt) {
1160 + html += '<details class="mxch-tool-args"><summary>Arguments</summary>';
1161 + html += '<div class="mxch-tool-args-body">' + escapeHtml(tool.args_excerpt) + '</div></details>';
1162 + }
1163 +
1164 + html += '</div>';
1165 + });
1166 +
1167 + html += '</div>';
1168 + return html;
1169 + }
1170 +
999 1171 function renderActionsContext(data, $container) {
1000 1172 let html = '';
1001 1173
1002 - // Check if we have action analysis data
1003 - if (!data.action_analysis || data.action_analysis.length === 0) {
1004 - html += '<div class="mxch-no-results"><p>No action analysis available for this message.</p><p style="color: var(--mxch-text-secondary); font-size: 13px; margin-top: 8px;">Actions are only evaluated when enabled in your bot configuration.</p></div>';
1174 + const toolCalls = (data.tool_calls && data.tool_calls.length) ? data.tool_calls : [];
1175 + const actions = (data.action_analysis && data.action_analysis.length) ? data.action_analysis : [];
1176 +
1177 + // 95d79d's empty state now shows only when NEITHER mechanism produced
1178 + // anything — the string itself is unchanged.
1179 + if (toolCalls.length === 0 && actions.length === 0) {
1180 + html += '<div class="mxch-no-results"><p>No trigger-phrase analysis for this message.</p><p style="color: var(--mxch-text-secondary); font-size: 13px; margin-top: 8px;">This panel shows how your <strong>Trigger Phrases</strong> scored &mdash; it stays empty if you have none enabled for this bot, or if the answer came from <strong>AI Tools</strong>, which don\'t produce similarity scores. It\'s recorded when the answer is generated, so it won\'t appear on older conversations.</p></div>';
1005 1181 $container.html(html);
1006 1182 return;
1007 1183 }
1008 1184
1009 - const actions = data.action_analysis;
1185 + if (toolCalls.length > 0) {
1186 + html += renderToolCalls(toolCalls);
1187 + }
1188 +
1189 + if (actions.length === 0) {
1190 + $container.html(html);
1191 + return;
1192 + }
1010 1193 const triggeredAction = actions.find(a => a.triggered);
1011 1194 const actionsAboveThreshold = actions.filter(a => a.above_threshold).length;
1012 1195
1013 1196 // Summary section
@@ -1459,9 +1642,9 @@
1459 1642 function renderLeadsTable(rows) {
1460 1643 const $tbody = $('#mxch-leads-tbody');
1461 1644 if (!rows || rows.length === 0) {
1462 1645 $tbody.html(`
1463 - <tr><td colspan="6" class="mxch-leads-empty">
1646 + <tr><td colspan="7" class="mxch-leads-empty">
1464 1647 <div class="mxch-leads-empty-wrap">
1465 1648 <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="8" y1="12" x2="16" y2="12"/></svg>
1466 1649 <p>No leads match the current filters.</p>
1467 1650 </div>
@@ -1497,8 +1680,18 @@
1497 1680 const lastCell = escapeHtmlLeads(r.last_seen_display || (isOrphan ? 'No conversation yet' : ''));
1498 1681 const pageCell = r.top_page_url
1499 1682 ? `<a href="${escapeHtmlLeads(r.top_page_url)}" target="_blank" rel="noopener" class="mxch-leads-page-link" title="${escapeHtmlLeads(r.top_page_url)}">${escapeHtmlLeads(r.top_page_title || r.top_page_url)}</a>`
1500 1683 : '<span class="mxch-leads-muted">—</span>';
1684 + // Consent (b062c4): 'yes'/'no' are recorded decisions; '' means the
1685 + // capture predates the checkbox — "Not recorded", never "No".
1686 + let consentCell;
1687 + if (r.consent === 'yes' || r.consent === 'no') {
1688 + const consentTitle = (r.consent_at ? 'Recorded ' + r.consent_at : '') +
1689 + (r.consent_label ? (r.consent_at ? ' — ' : '') + '"' + r.consent_label + '"' : '');
1690 + consentCell = `<span class="mxch-leads-pill${r.consent === 'yes' ? ' mxch-leads-pill-consent-yes' : ' mxch-leads-pill-consent-no'}" title="${escapeHtmlLeads(consentTitle)}">${r.consent === 'yes' ? 'Yes' : 'No'}</span>`;
1691 + } else {
1692 + consentCell = '<span class="mxch-leads-muted" title="Captured before the consent checkbox was enabled">Not recorded</span>';
1693 + }
1501 1694 // View Convo only for active leads (orphans and chat_deleted have no viewable session).
1502 1695 const viewBtn = (status === 'active' && r.latest_session_id)
1503 1696 ? `<button type="button" class="mxch-btn mxch-btn-ghost mxch-btn-sm mxch-leads-view" data-session-id="${escapeHtmlLeads(r.latest_session_id)}" title="View latest conversation">
1504 1697 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
@@ -1516,8 +1709,9 @@
1516 1709 <td class="mxch-leads-col-lead">${leadCell}</td>
1517 1710 <td class="mxch-leads-col-count">${countCell}</td>
1518 1711 <td class="mxch-leads-col-last">${lastCell}</td>
1519 1712 <td class="mxch-leads-col-page">${pageCell}</td>
1713 + <td class="mxch-leads-col-consent">${consentCell}</td>
1520 1714 <td class="mxch-leads-col-actions">${viewBtn}${deleteBtn}</td>
1521 1715 </tr>
1522 1716 `;
1523 1717 });