PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
3.2.25 3.2.24 3.2.23 3.2.22 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 All 156 releases
← All changes | js/mxchat_transcripts.js +183 -22 3.2.8 → 3.2.25 View file →
@@ -607,13 +607,19 @@
607 607 <div class="mxch-message-time">${escapeHtml(msg.timestamp)}</div>
608 608 </div>
609 609 `;
610 610 } else {
611 - 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>';
612 618 messagesHtml += `
613 - <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}">
614 620 <div class="mxch-message-header">
615 - <span class="mxch-bot-label">AI Assistant</span>
621 + ${senderLabel}
616 622 ${ragLink}
617 623 </div>
618 624 <div class="mxch-message-row">
619 625 <div class="mxch-message-bubble">
@@ -937,9 +943,12 @@
937 943 renderActionsContext(response.data, $actionsContent);
938 944
939 945 // Update badge counts
940 946 const sourcesCount = response.data.top_matches ? response.data.top_matches.length : 0;
941 - 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;
942 951
943 952 if (sourcesCount > 0) {
944 953 $('#mxch-sources-count').text(sourcesCount).show();
945 954 }
@@ -975,21 +984,43 @@
975 984
976 985 function renderRagContext(data, $container) {
977 986 let html = '';
978 987
979 - // Check if we have any source data
980 - if (!data.top_matches || data.top_matches.length === 0) {
981 - 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>';
982 998 $container.html(html);
983 999 return;
984 1000 }
985 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 +
986 1008 html += '<div class="mxch-rag-summary">';
987 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>';
988 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>';
989 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 + }
990 1015 html += '</div>';
991 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 +
992 1023 const groupedByUrl = {};
993 1024
994 1025 data.top_matches.forEach(function(match) {
995 1026 const url = match.source_display || 'Unknown';
@@ -998,9 +1029,11 @@
998 1029 url: url,
999 1030 isUrl: url.startsWith('http'),
1000 1031 bestScore: 0,
1001 1032 usedForContext: false,
1002 - matchedChunks: []
1033 + matchedChunks: [],
1034 + bestFusedRank: Infinity,
1035 + viaSet: {}
1003 1036 };
1004 1037 }
1005 1038
1006 1039 if (match.similarity_percentage > groupedByUrl[url].bestScore) {
@@ -1010,8 +1043,15 @@
1010 1043 if (match.used_for_context) {
1011 1044 groupedByUrl[url].usedForContext = true;
1012 1045 }
1013 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 +
1014 1054 groupedByUrl[url].matchedChunks.push({
1015 1055 chunkIndex: match.chunk_index,
1016 1056 score: match.similarity_percentage,
1017 1057 usedForContext: match.used_for_context
@@ -1017,9 +1057,16 @@
1017 1057 usedForContext: match.used_for_context
1018 1058 });
1019 1059 });
1020 1060
1021 - 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 + });
1022 1069 const usedUrlCount = data.sources_used > 0 ? data.sources_used : urlGroups.filter(g => g.usedForContext).length;
1023 1070 const chunksInfo = data.total_chunks_used > 0 ? data.total_chunks_used + ' chunks sent to AI' : '';
1024 1071
1025 1072 html += '<div class="mxch-rag-matches">';
@@ -1034,8 +1081,17 @@
1034 1081 html += '<div class="mxch-rag-match-card ' + cardClass + '">';
1035 1082 html += '<div class="mxch-rag-match-header">';
1036 1083 html += '<span class="mxch-rag-match-score">' + group.bestScore + '%</span>';
1037 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 +
1038 1094 if (group.matchedChunks.length > 1) {
1039 1095 html += '<span class="mxch-rag-chunk-badge">' + group.matchedChunks.length + ' chunks</span>';
1040 1096 }
1041 1097
@@ -1055,19 +1111,86 @@
1055 1111 html += '</div>';
1056 1112 $container.html(html);
1057 1113 }
1058 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 +
1059 1171 function renderActionsContext(data, $container) {
1060 1172 let html = '';
1061 1173
1062 - // Check if we have action analysis data
1063 - if (!data.action_analysis || data.action_analysis.length === 0) {
1064 - 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>';
1065 1181 $container.html(html);
1066 1182 return;
1067 1183 }
1068 1184
1069 - 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 + }
1070 1193 const triggeredAction = actions.find(a => a.triggered);
1071 1194 const actionsAboveThreshold = actions.filter(a => a.above_threshold).length;
1072 1195
1073 1196 // Summary section
@@ -1515,13 +1638,31 @@
1515 1638 });
1516 1639 $wrap.html(html);
1517 1640 }
1518 1641
1642 + // A lead's key is its email address, or "session:<id>" when the capture
1643 + // had no address (c0cfaf). Email keys are lowercased exactly as before;
1644 + // session keys pass through untouched, because the server resolves them
1645 + // back to a real session id and case matters there. Both shapes travel on
1646 + // the same wire field the tab has always used, so the selection set, the
1647 + // delete call and the export call need no new parameter.
1648 + function leadKeyOf(r) {
1649 + const email = (r && r.email ? String(r.email) : '').trim();
1650 + if (email) return email.toLowerCase();
1651 + if (r && r.lead_key) return String(r.lead_key);
1652 + return 'session:' + (r && r.latest_session_id ? r.latest_session_id : '');
1653 + }
1654 +
1655 + function normalizeLeadKey(value) {
1656 + const key = (value === undefined || value === null) ? '' : String(value);
1657 + return key.indexOf('session:') === 0 ? key : key.toLowerCase();
1658 + }
1659 +
1519 1660 function renderLeadsTable(rows) {
1520 1661 const $tbody = $('#mxch-leads-tbody');
1521 1662 if (!rows || rows.length === 0) {
1522 1663 $tbody.html(`
1523 - <tr><td colspan="6" class="mxch-leads-empty">
1664 + <tr><td colspan="7" class="mxch-leads-empty">
1524 1665 <div class="mxch-leads-empty-wrap">
1525 1666 <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>
1526 1667 <p>No leads match the current filters.</p>
1527 1668 </div>
@@ -1531,9 +1672,9 @@
1531 1672 }
1532 1673
1533 1674 let html = '';
1534 1675 rows.forEach(function(r) {
1535 - const emailKey = (r.email || '').toLowerCase();
1676 + const emailKey = leadKeyOf(r);
1536 1677 const isChecked = leadsState.selected.has(emailKey) ? ' checked' : '';
1537 1678 // Status: 'active' (has conversations), 'chat_deleted' (admin removed the chat), 'orphan' (no chat ever).
1538 1679 const status = r.status || (r.is_orphan ? 'orphan' : 'active');
1539 1680 const isOrphan = (status === 'orphan');
@@ -1540,12 +1681,21 @@
1540 1681 const isChatDeleted = (status === 'chat_deleted');
1541 1682 const nameLine = r.name
1542 1683 ? `<span class="mxch-leads-lead-name">${escapeHtmlLeads(r.name)}</span>`
1543 1684 : '';
1685 + // A lead captured with no email address (c0cfaf) still has a
1686 + // name; show that on the primary line rather than an empty cell,
1687 + // and say plainly that there is no address.
1688 + const primaryLine = r.email
1689 + ? `<span class="mxch-leads-lead-email" title="${escapeHtmlLeads(r.email)}">${escapeHtmlLeads(r.email)}</span>`
1690 + : `<span class="mxch-leads-lead-email">${escapeHtmlLeads(r.name || 'Unnamed lead')}</span>`;
1691 + const secondaryLine = r.email
1692 + ? nameLine
1693 + : '<span class="mxch-leads-muted">No email address</span>';
1544 1694 const leadCell = `
1545 1695 <div class="mxch-leads-lead-cell">
1546 - <span class="mxch-leads-lead-email" title="${escapeHtmlLeads(r.email)}">${escapeHtmlLeads(r.email)}</span>
1547 - ${nameLine}
1696 + ${primaryLine}
1697 + ${secondaryLine}
1548 1698 </div>`;
1549 1699 let countCell;
1550 1700 if (isOrphan) {
1551 1701 countCell = `<span class="mxch-leads-pill mxch-leads-pill-orphan">Orphan</span>`;
@@ -1557,8 +1707,18 @@
1557 1707 const lastCell = escapeHtmlLeads(r.last_seen_display || (isOrphan ? 'No conversation yet' : ''));
1558 1708 const pageCell = r.top_page_url
1559 1709 ? `<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>`
1560 1710 : '<span class="mxch-leads-muted">—</span>';
1711 + // Consent (b062c4): 'yes'/'no' are recorded decisions; '' means the
1712 + // capture predates the checkbox — "Not recorded", never "No".
1713 + let consentCell;
1714 + if (r.consent === 'yes' || r.consent === 'no') {
1715 + const consentTitle = (r.consent_at ? 'Recorded ' + r.consent_at : '') +
1716 + (r.consent_label ? (r.consent_at ? ' — ' : '') + '"' + r.consent_label + '"' : '');
1717 + 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>`;
1718 + } else {
1719 + consentCell = '<span class="mxch-leads-muted" title="Captured before the consent checkbox was enabled">Not recorded</span>';
1720 + }
1561 1721 // View Convo only for active leads (orphans and chat_deleted have no viewable session).
1562 1722 const viewBtn = (status === 'active' && r.latest_session_id)
1563 1723 ? `<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">
1564 1724 <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>
@@ -1564,20 +1724,21 @@
1564 1724 <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>
1565 1725 <span>View convo</span>
1566 1726 </button>`
1567 1727 : '';
1568 - const deleteBtn = `<button type="button" class="mxch-btn mxch-btn-ghost mxch-btn-sm mxch-btn-danger-ghost mxch-leads-delete-row" data-email="${escapeHtmlLeads(r.email)}" title="Delete lead">
1728 + const deleteBtn = `<button type="button" class="mxch-btn mxch-btn-ghost mxch-btn-sm mxch-btn-danger-ghost mxch-leads-delete-row" data-email="${escapeHtmlLeads(emailKey)}" title="Delete lead">
1569 1729 <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"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
1570 1730 </button>`;
1571 1731
1572 1732 const rowStateClass = isOrphan ? ' is-orphan' : (isChatDeleted ? ' is-chat-deleted' : '');
1573 1733 html += `
1574 - <tr class="mxch-leads-row${rowStateClass}" data-email="${escapeHtmlLeads(r.email)}">
1734 + <tr class="mxch-leads-row${rowStateClass}" data-email="${escapeHtmlLeads(emailKey)}">
1575 1735 <td class="mxch-leads-col-check"><input type="checkbox" class="mxch-leads-rowcheck"${isChecked}></td>
1576 1736 <td class="mxch-leads-col-lead">${leadCell}</td>
1577 1737 <td class="mxch-leads-col-count">${countCell}</td>
1578 1738 <td class="mxch-leads-col-last">${lastCell}</td>
1579 1739 <td class="mxch-leads-col-page">${pageCell}</td>
1740 + <td class="mxch-leads-col-consent">${consentCell}</td>
1580 1741 <td class="mxch-leads-col-actions">${viewBtn}${deleteBtn}</td>
1581 1742 </tr>
1582 1743 `;
1583 1744 });
@@ -1698,9 +1859,9 @@
1698 1859 $('#mxch-leads-select-all').on('change', function() {
1699 1860 const on = $(this).is(':checked');
1700 1861 $('.mxch-leads-rowcheck').prop('checked', on);
1701 1862 $('.mxch-leads-row').each(function() {
1702 - const email = ($(this).data('email') || '').toString().toLowerCase();
1863 + const email = normalizeLeadKey($(this).data('email'));
1703 1864 if (on) {
1704 1865 leadsState.selected.add(email);
1705 1866 } else {
1706 1867 leadsState.selected.delete(email);
@@ -1710,9 +1871,9 @@
1710 1871 });
1711 1872
1712 1873 // Row checkbox
1713 1874 $leads().on('change', '.mxch-leads-rowcheck', function() {
1714 - const email = ($(this).closest('.mxch-leads-row').data('email') || '').toString().toLowerCase();
1875 + const email = normalizeLeadKey($(this).closest('.mxch-leads-row').data('email'));
1715 1876 if ($(this).is(':checked')) {
1716 1877 leadsState.selected.add(email);
1717 1878 } else {
1718 1879 leadsState.selected.delete(email);
@@ -1778,9 +1939,9 @@
1778 1939 success: function(response) {
1779 1940 $btn.prop('disabled', false).text('Delete permanently');
1780 1941 closeLeadsConfirm();
1781 1942 if (response && response.success) {
1782 - emails.forEach(function(e) { leadsState.selected.delete(e.toLowerCase()); });
1943 + emails.forEach(function(e) { leadsState.selected.delete(normalizeLeadKey(e)); });
1783 1944 loadLeads(leadsState.page);
1784 1945 } else {
1785 1946 alert((response && response.data && response.data.message) || 'Failed to delete leads.');
1786 1947 }