Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/_includes/components/ai-expert-modal.njk
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.3.0/dist/purify.min.js"></script>
<!-- FlowFuse Expert Modal -->

<div id="ai-expert-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center p-0 md:p-4 md:px-8 md:py-8 z-[9999]">
<!-- Modal Content -->
<div class="bg-white rounded-none md:rounded-lg shadow-xl w-full max-w-4xl h-full md:h-full flex flex-col relative">
Expand Down Expand Up @@ -32,7 +34,7 @@
</div>

<!-- Chat Messages Container -->
<div id="chat-messages" class="space-y-4">
<div id="chat-messages" class="space-y-4 overflow-auto">
<!-- Messages will be added here dynamically -->
</div>

Expand Down
7 changes: 6 additions & 1 deletion src/css/style.components.css
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@
box-shadow: inset 0px 2px 5px #00000052;
}

.webinar-tile a.webinar-tile-img img,
.webinar-tile a.webinar-tile-img img,
.webinar-tile a.webinar-tile-img svg {
transition: transform 0.3s;
}
Expand Down Expand Up @@ -305,6 +305,11 @@
transition: var(--tdur, 0.15s) transform, var(--tdur, 0.15s) box-shadow;
}

.rich-content button.plain {
transform: none;
box-shadow: none;
}

/* Remove bottom margin from last element in rich content */
.rich-content > *:last-child {
margin-bottom: 0;
Expand Down
185 changes: 156 additions & 29 deletions src/js/ai-expert-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ document.addEventListener('DOMContentLoaded', function() {
const chatMessages = document.getElementById('chat-messages');
const modalInput = document.getElementById('modal-input');
let transferPayload = []
let flowsStore = {}

// Debug: Check if button was found
if (!tellMeHowBtn) {
Expand Down Expand Up @@ -37,6 +38,48 @@ document.addEventListener('DOMContentLoaded', function() {
let currentLoadingMessageIndex = 0;
let loadingMessageInterval = null;

function copyToClipboard(text) {
navigator.clipboard.writeText(text)
.then(() => {
console.log('copied');
})
.catch(err => {
console.error('Failed to copy text:', err);
});
}

let flowInteractionHandler = function (e) {
if (e.target.closest('.copy')) {
const flowTarget = e.target.closest('[data-flow-id]')?.getAttribute('data-flow-id');
if (flowTarget && flowsStore[flowTarget]) {
copyToClipboard(flowsStore[flowTarget]);
const copyButton = e.target.closest('.copy');
const copySvg = copyButton.querySelector('.copy-svg');
const checkSvg = copyButton.querySelector('.check-svg');
copySvg.classList.add('hidden');
checkSvg.classList.remove('hidden');
setTimeout(() => {
copySvg.classList.remove('hidden');
checkSvg.classList.add('hidden');
}, 2000);
}
}

if (e.target.closest('.expand')) {
const flowSection = e.target.closest('[data-flow-id]');
if (flowSection) {
const preElement = flowSection.querySelector('pre');
const downChevron = flowSection.querySelector('.down-arrow');
const upChevron = flowSection.querySelector('.up-arrow');
if (preElement) {
preElement.classList.toggle('hidden');
downChevron.classList.toggle('hidden');
upChevron.classList.toggle('hidden');
}
}
}
};

// Centralized function to manage input state based on isGenerating
function updateInputState() {
const clearBtn = document.getElementById('clear-conversation');
Expand Down Expand Up @@ -299,6 +342,8 @@ document.addEventListener('DOMContentLoaded', function() {
transferPayload = []
lastTransactionId = null; // Clear transaction ID
}
flowsStore = {}
document.removeEventListener('click', flowInteractionHandler)

}

Expand Down Expand Up @@ -330,7 +375,8 @@ document.addEventListener('DOMContentLoaded', function() {
}

// Handle multi-message vs single message responses
if (response.isMultiMessage && Array.isArray(response.answer)) {
const answers = Array.isArray(response.answer) ? response.answer : [response.answer];
if (answers.length >= 1) {
// Stop loading animation first
const loadingElement = chatMessages.children[aiMessageIndex];
if (loadingElement) {
Expand All @@ -351,8 +397,8 @@ document.addEventListener('DOMContentLoaded', function() {
}

// Now add each message from the response
response.answer.forEach(messageObj => {
addAIMessageFromObject(messageObj);
answers.forEach(answer => {
addAIMessageFromObject(answer);
});
} else {
// Fallback for old single message format - treat as single chat message
Expand Down Expand Up @@ -396,7 +442,7 @@ document.addEventListener('DOMContentLoaded', function() {

// Create DOM element
const messageDiv = document.createElement('div');
messageDiv.className = `flex ${type === 'human' ? 'justify-end' : 'justify-start'} mb-4`;
messageDiv.className = `flex ${type === 'human' ? 'justify-end' : 'justify-start'} mb-4 overflow-auto`;

const messageBubble = document.createElement('div');
messageBubble.className = `max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
Expand Down Expand Up @@ -499,6 +545,7 @@ document.addEventListener('DOMContentLoaded', function() {

// Update input state (clear button will be disabled since messages array is empty)
updateInputState();
flowsStore = {}
}

async function sendChatMessage(query) {
Expand All @@ -515,7 +562,7 @@ document.addEventListener('DOMContentLoaded', function() {

// Note: This API may only work in production (flowfuse.com domain)
// For local development, we'll get simulated responses
const response = await fetch('https://flowfuse-expert-api.flowfuse.dev/v3/website-chat', {
const response = await fetch('https://flowfuse-expert-api-dev.flowfuse.cloud/v4/website-chat', {
Comment thread
cstns marked this conversation as resolved.
Outdated
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -669,7 +716,10 @@ document.addEventListener('DOMContentLoaded', function() {
return messages.length - 1; // Return index of the newly added message
}

function addAIMessageFromObject(messageObj) {
function addAIMessageFromObject(aiMessage) {
if (typeof aiMessage === 'string') {
aiMessage = { kind: 'chat', content: aiMessage };
}
// Add to messages array
const message = { content: '', type: 'ai', isHTML: true };
messages.push(message);
Expand All @@ -679,20 +729,21 @@ document.addEventListener('DOMContentLoaded', function() {
messageDiv.className = 'flex justify-start mb-4';

const messageBubble = document.createElement('div');
let paddingClass = messageObj.kind === 'guide' ? 'py-4' : 'py-2';
messageBubble.className = `max-w-[90%] px-4 ${paddingClass} rounded-lg bg-gray-100 text-gray-800 rounded-bl-sm rich-content`;
let paddingClass = (aiMessage.kind === 'guide' || aiMessage.kind === 'resources') ? 'py-4' : 'py-2';
messageBubble.className = `max-w-[90%] px-4 ${paddingClass} rounded-lg bg-gray-100 text-gray-800 rounded-bl-sm rich-content overflow-auto`;

// Render content based on message kind
let htmlContent = '';
if (messageObj.kind === 'guide' || messageObj.kind === 'resources') {
htmlContent = renderRichContent(messageObj);
} else if (messageObj.kind === 'chat') {
htmlContent = renderChatContent(messageObj);
if (aiMessage.kind === 'guide' || aiMessage.kind === 'resources') {
htmlContent = renderRichContent(aiMessage);
} else if (aiMessage.kind === 'chat') {
htmlContent = renderChatContent(aiMessage);
}

// Update message content and DOM
message.content = htmlContent;
messageBubble.innerHTML = htmlContent;
const sanitizedHtml = DOMPurify.sanitize(htmlContent, { ADD_ATTR: ['target'] });
message.content = sanitizedHtml;
messageBubble.innerHTML = sanitizedHtml;

messageDiv.appendChild(messageBubble);
chatMessages.appendChild(messageDiv);
Expand Down Expand Up @@ -746,7 +797,8 @@ document.addEventListener('DOMContentLoaded', function() {

// Content (no title for chat messages)
if (chatMessage.content) {
html += `<p class="text-gray-700">${chatMessage.content}</p>`;
const sanitizedContent = DOMPurify.sanitize(chatMessage.content, { ADD_ATTR: ['target'] });
html += `<p class="text-gray-700">${sanitizedContent}</p>`;
}

return html;
Expand All @@ -769,7 +821,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Setup Guide label for guide messages
let headerText = 'Result'

if (richAnswer.kind === 'guide') headerText = 'Setup Guide'
if (richAnswer.kind === 'guide') headerText = 'Guide'
if (richAnswer.kind === 'resources') headerText = 'Resources';

html += `<div class="bg-indigo-100 text-indigo-700 text-sm px-3 py-2 rounded-full inline-block mb-3">${headerText}</div>`;
Expand Down Expand Up @@ -815,19 +867,21 @@ document.addEventListener('DOMContentLoaded', function() {
html += '<div class="mb-4">';
html += '<h4 class="text-base font-medium text-gray-900 mb-3">Required Node Packages</h4>';
html += '<div class="grid grid-cols-1 md:grid-cols-2 gap-2">';

richAnswer.nodePackages.forEach(pkg => {
const nodeRedUrl = `https://flows.nodered.org/node/${pkg.name}`;
const nodeRedUrlWithUTM = addUTMParameters(nodeRedUrl);
const faviconUrl = `https://www.google.com/s2/favicons?domain=flows.nodered.org`;
const url = pkg.url || pkg.metadata?.source;
const defLocation = 'https://flows.nodered.org/search?type=node';
const packageName = pkg.name || pkg.id || pkg.metadata?.id;
const nodeUrl = url || (packageName ? `https://flows.nodered.org/node/${packageName}` : defLocation);
const nodeUrlWithUTM = addUTMParameters(nodeUrl);
const faviconUrl = `https://www.google.com/s2/favicons?domain=${new URL(nodeUrl).hostname}`;

html += `
<a href="${nodeRedUrlWithUTM}" target="_blank" rel="noopener noreferrer"
<a href="${nodeUrlWithUTM}" target="_blank" rel="noopener noreferrer"
class="flex items-center gap-2 p-3 bg-white border border-gray-200 rounded-lg hover:border-indigo-300 hover:bg-gray-50 transition-colors h-16">
<img src="${faviconUrl}" class="w-4 h-4 flex-shrink-0" alt="">
<div class="flex-1 min-w-0 overflow-hidden">
<h6 class="font-mono text-gray-900 text-sm truncate">${pkg.name}</h6>
<p class="text-xs text-gray-500 mt-1 mb-0 truncate">${nodeRedUrl}</p>
<h6 class="font-mono text-gray-900 text-sm truncate">${packageName || nodeUrl}</h6>
<p class="text-xs text-gray-500 mt-1 mb-0 truncate">${nodeUrl}</p>
</div>
</a>`;
});
Expand All @@ -843,17 +897,19 @@ document.addEventListener('DOMContentLoaded', function() {
html += '<div class="grid grid-cols-1 md:grid-cols-2 gap-2">';

richAnswer.resources.forEach(resource => {
const faviconUrl = `https://www.google.com/s2/favicons?domain=${new URL(resource.url).hostname}`;
const resourceUrlWithUTM = addUTMParameters(resource.url);
const url = resource.url || resource.metadata?.source;
const title = resource.title || resource.metadata?.title || url;
const faviconUrl = `https://www.google.com/s2/favicons?domain=${new URL(url).hostname}`;
const resourceUrlWithUTM = addUTMParameters(url);

html += `
<a href="${resourceUrlWithUTM}" target="_blank" rel="noopener noreferrer"
class="block p-3 bg-white border border-gray-200 rounded-lg hover:border-indigo-300 hover:bg-gray-50 transition-colors">
class="block p-3 bg-white border border-gray-200 rounded-lg hover:border-indigo-400 hover:bg-indigo-50 active:bg-indigo-100 active:border-indigo-500 transition-all duration-200">
<div class="flex items-start gap-2">
<img src="${faviconUrl}" class="w-4 h-4 flex-shrink-0 mt-0.5" alt="">
<div class="flex-1 min-w-0 overflow-hidden">
<h6 class="font-medium text-gray-900 text-sm truncate">${resource.title}</h6>
<p class="text-xs text-gray-500 mt-1 truncate">${resource.url}</p>
<h6 class="font-medium text-gray-900 text-sm truncate">${title}</h6>
<p class="text-xs text-gray-500 mt-1 truncate">${url}</p>
</div>
</div>
</a>`;
Expand All @@ -863,10 +919,77 @@ document.addEventListener('DOMContentLoaded', function() {
html += '</div>';
}

// Flows
if (richAnswer.flows && richAnswer.flows.length > 0) {
html += '<div class="overflow-auto">';
html += '<h4 class="text-base font-medium text-gray-900 mb-3">Example Flows</h4>';
html += '<ul class="space-y-2 overflow-auto">';

richAnswer.flows.forEach(flow => {
if (!flow || typeof flow !== 'object' || !flow.metadata || !Array.isArray(flow.metadata.flows) || flow.metadata.flows.length === 0) return;
const flowId = `flow-${crypto.randomUUID()}`
let flowsJSON = JSON.stringify(flow.metadata.flows, null, 2);
flowsStore[flowId] = flowsJSON;
html += `
<li class="overflow-auto" data-flow-id="${flowId}">
<div class="flex flex-col p-3 bg-white border border-gray-200 rounded-lg hover:border-indigo-300 hover:bg-gray-50 transition-colors overflow-auto">
<div class="flex items-start gap-2">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="flex-shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24">
<rect width="24" height="24" fill="gray" rx="4"/>
<g clip-path="url(#a)">
<path fill="#fff" d="M0 12v-1.647c5.09 0 5.81-.9 6.44-1.695.72-.9 1.46-1.6 3.88-1.6v1.648c-1.76 0-2.04.354-2.51.948C6.79 10.937 5.5 12 0 12Z"/>
<path fill="#fff" d="M8.6 16.941c-2.9 0-3.47-1.513-3.88-2.614C4.25 13.072 3.85 12 0 12v-1.647c4.67 0 5.67 1.618 6.34 3.419.38 1.015.57 1.522 2.26 1.522v1.647Z"/>
<path fill="#fff" d="M16.78 19H9.9c-.95 0-1.72-.737-1.72-1.647v-2.47c0-.91.77-1.648 1.72-1.648h6.88c.95 0 1.72.738 1.72 1.647v2.47c0 .91-.77 1.648-1.72 1.648Zm0-4.118H9.9v2.47h6.88v-2.47Zm1.5-4.117H11.4c-.95 0-1.72-.738-1.72-1.647v-2.47c0-.91.77-1.648 1.72-1.648h6.88c.95 0 1.72.737 1.72 1.647v2.47c0 .91-.77 1.648-1.72 1.648Zm0-4.118H11.4v2.47h6.88v-2.47Z"/>
</g>
<defs>
<clipPath id="a">
<path fill="#fff" d="M0 5h20v14H0z"/>
</clipPath>
</defs>
</svg>
<div class="flex flex-1 flex-col overflow-auto">
<div class="flex items-start justify-between gap-2">
<h6>${flow.title}</h6>
<div class="actions flex items-start gap-4">
<button class="text-gray-600 hover:text-indigo-600 hover:bg-indigo-50 expand flex items-center plain transition-colors duration-200 rounded px-2 py-1">
<span>JSON</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 down-arrow" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 up-arrow hidden" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293 3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 010 1.414z" clip-rule="evenodd" />
</svg>
</button>
<button class="text-gray-600 hover:text-indigo-600 hover:bg-indigo-50 copy plain transition-colors duration-200 rounded p-1">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" class="h-6 w-6 copy-svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" class="h-6 w-6 hidden check-svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</button>
</div>
</div>
<p class="text-xs" style="margin-bottom: 0;">
<span>Category: </span>
<span>${flow.metadata?.category}</span>
</p>
</div>
</div>
<div class="overflow-auto rounded-md text-gray-300 ml-6" style="max-height: 300px; background-color: #404040;">
<pre class="overflow-auto hidden mt-3 py-2 px-4">${flowsJSON}</pre>
</div>
</div>
</li>`
});

html += '</ul>';
html += '</div>';
}

return html;
}


// Stop generation
const stopGenerationBtn = document.getElementById('stop-generation');
if (stopGenerationBtn) {
Expand Down Expand Up @@ -1085,6 +1208,10 @@ document.addEventListener('DOMContentLoaded', function() {
}
});

// Add click handler for flow copy buttons & code blocks
document.addEventListener('click', flowInteractionHandler);


// Clear conversation event listener
const clearConversationBtn = document.getElementById('clear-conversation');
if (clearConversationBtn) {
Expand Down
Loading