Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
12 changes: 11 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 All @@ -319,3 +324,8 @@
.rich-content > p:only-child {
margin: 0;
}

/* Remove bottom margin from single list item */
.rich-content li:only-child {
margin-bottom: 0;
}
193 changes: 162 additions & 31 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,50 @@ 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 preContainer = preElement?.parentElement;
const downChevron = flowSection.querySelector('.down-arrow');
const upChevron = flowSection.querySelector('.up-arrow');
if (preElement && preContainer) {
preElement.classList.toggle('hidden');
preContainer.classList.toggle('mt-3');
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 +344,8 @@ document.addEventListener('DOMContentLoaded', function() {
transferPayload = []
lastTransactionId = null; // Clear transaction ID
}
flowsStore = {}
document.removeEventListener('click', flowInteractionHandler)

}

Expand Down Expand Up @@ -330,7 +377,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 +399,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 +444,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 +547,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 +564,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.flowfuse.cloud/v4/website-chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Expand Down Expand Up @@ -669,7 +718,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 +731,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 +799,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 Down Expand Up @@ -815,19 +869,23 @@ 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"
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>
<a href="${nodeUrlWithUTM}" target="_blank" rel="noopener noreferrer"
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-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>
</div>
</a>`;
});
Expand All @@ -843,17 +901,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 +923,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 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 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 +1212,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