feat(chatbot): integrate OpenRouter API for AI assistance in dashboard
- Added a Chatbot component to the dashboard for user interaction and support. - Created a README for the Chatbot detailing setup, features, and usage instructions. - Introduced environment variables for secure API key management. - Updated app.js to include the Chatbot component. - Implemented a configuration server to serve API keys securely. - Enhanced styles for the Chatbot interface to improve user experience.
This commit is contained in:
343
dashboard/Chatbot.js
Normal file
343
dashboard/Chatbot.js
Normal file
@@ -0,0 +1,343 @@
|
||||
const { useState, useRef, useEffect } = React;
|
||||
|
||||
// Chatbot component that integrates with OpenRouter API
|
||||
function Chatbot() {
|
||||
console.log('Chatbot component loaded successfully');
|
||||
const [config, setConfig] = useState(null);
|
||||
const [messages, setMessages] = useState([
|
||||
{
|
||||
id: 1,
|
||||
type: 'bot',
|
||||
content: 'Hello! I\'m your CityPulse assistant. I can help you with questions about city reports, dashboard features, or general inquiries. How can I assist you today?',
|
||||
timestamp: new Date()
|
||||
}
|
||||
]);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const messagesEndRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
// Auto-scroll to bottom when new messages are added
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [messages]);
|
||||
|
||||
// Load configuration from environment variables
|
||||
useEffect(() => {
|
||||
// For security, API keys should never be hardcoded in frontend code
|
||||
// In production, use a backend service or build-time replacement
|
||||
const loadConfig = () => {
|
||||
// Check if we're in development mode (localhost)
|
||||
const isDevelopment = window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1';
|
||||
|
||||
if (isDevelopment) {
|
||||
// In development, try to load from environment or show setup message
|
||||
console.log('Development mode detected');
|
||||
console.log('Please ensure your .env file is properly configured');
|
||||
console.log('For security, consider using a backend service in production');
|
||||
|
||||
// For now, we'll use a placeholder that should be replaced
|
||||
// In a real app, this would be handled by build tools
|
||||
setConfig({
|
||||
OPENROUTER_API_KEY: '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',
|
||||
OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1',
|
||||
OPENROUTER_MODEL: 'x-ai/grok-4-fast:free'
|
||||
});
|
||||
} else {
|
||||
// In production, this should come from a secure backend endpoint
|
||||
console.log('Production mode - configuration should come from backend');
|
||||
setConfig({
|
||||
OPENROUTER_API_KEY: 'CONFIGURE_BACKEND_ENDPOINT',
|
||||
OPENROUTER_BASE_URL: 'https://openrouter.ai/api/v1',
|
||||
OPENROUTER_MODEL: 'x-ai/grok-4-fast:free'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
loadConfig();
|
||||
console.log('Config loading initiated...');
|
||||
}, []);
|
||||
|
||||
// Debug: Monitor config changes
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
console.log('Config loaded successfully:', {
|
||||
hasKey: !!config.OPENROUTER_API_KEY,
|
||||
baseURL: config.OPENROUTER_BASE_URL,
|
||||
model: config.OPENROUTER_MODEL
|
||||
});
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// Function to clean up markdown formatting from AI responses
|
||||
const cleanMarkdown = (text) => {
|
||||
return text
|
||||
// Remove headers (### text)
|
||||
.replace(/^###\s+/gm, '')
|
||||
.replace(/^##\s+/gm, '')
|
||||
.replace(/^#\s+/gm, '')
|
||||
// Convert bold/italic (*text*) to readable format
|
||||
.replace(/\*([^*]+)\*/g, '$1')
|
||||
// Remove extra asterisks
|
||||
.replace(/\*{2,}/g, '')
|
||||
// Convert bullet points (-) to readable format
|
||||
.replace(/^- /gm, '• ')
|
||||
// Clean up multiple spaces but preserve line breaks
|
||||
.replace(/ {2,}/g, ' ')
|
||||
// Trim each line while preserving line breaks
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.join('\n')
|
||||
.trim();
|
||||
};
|
||||
|
||||
// Send message to OpenRouter API
|
||||
const sendMessage = async (userMessage) => {
|
||||
if (!userMessage.trim() || isLoading) return;
|
||||
|
||||
// Wait for config to be loaded
|
||||
if (!config) {
|
||||
console.log('Config not loaded yet, waiting...');
|
||||
setTimeout(() => sendMessage(userMessage), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Sending message with config:', {
|
||||
baseURL: config.OPENROUTER_BASE_URL,
|
||||
model: config.OPENROUTER_MODEL,
|
||||
hasKey: !!config.OPENROUTER_API_KEY
|
||||
});
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const requestHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.OPENROUTER_API_KEY}`,
|
||||
'HTTP-Referer': window.location.href,
|
||||
'X-Title': 'CityPulse Dashboard'
|
||||
};
|
||||
|
||||
console.log('Making API request to:', `${config.OPENROUTER_BASE_URL}/chat/completions`);
|
||||
console.log('Request headers:', {
|
||||
'Content-Type': requestHeaders['Content-Type'],
|
||||
'Authorization': `Bearer ${config.OPENROUTER_API_KEY ? '[API_KEY_PRESENT]' : '[NO_KEY]'}`,
|
||||
'HTTP-Referer': requestHeaders['HTTP-Referer'],
|
||||
'X-Title': requestHeaders['X-Title']
|
||||
});
|
||||
|
||||
const response = await fetch(`${config.OPENROUTER_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify({
|
||||
model: config.OPENROUTER_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a helpful assistant for the CityPulse Dashboard - a city reporting system. You help users understand dashboard features, city reports, and provide general assistance. Keep responses concise, helpful, and use plain text without markdown formatting, headers, or special characters.`
|
||||
},
|
||||
...messages.filter(msg => msg.type !== 'system').map(msg => ({
|
||||
role: msg.type === 'user' ? 'user' : 'assistant',
|
||||
content: msg.content
|
||||
})),
|
||||
{
|
||||
role: 'user',
|
||||
content: userMessage
|
||||
}
|
||||
],
|
||||
max_tokens: 500,
|
||||
temperature: 0.7
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('OpenRouter API error:', response.status, errorText);
|
||||
console.error('Response headers:', Object.fromEntries(response.headers.entries()));
|
||||
throw new Error(`API request failed: ${response.status} - ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('OpenRouter API response:', data);
|
||||
|
||||
if (data.choices && data.choices[0] && data.choices[0].message) {
|
||||
const botResponse = cleanMarkdown(data.choices[0].message.content);
|
||||
|
||||
setMessages(prev => [...prev, {
|
||||
id: Date.now() + 1,
|
||||
type: 'bot',
|
||||
content: botResponse,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
} else {
|
||||
console.error('Invalid API response format:', data);
|
||||
throw new Error('Invalid response format from API');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error calling OpenRouter API:', error);
|
||||
setMessages(prev => [...prev, {
|
||||
id: Date.now() + 1,
|
||||
type: 'bot',
|
||||
content: `Sorry, I encountered an error while processing your request: ${error.message}. Please try again later.`,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle form submission
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
if (!inputValue.trim() || isLoading) return;
|
||||
|
||||
const userMessage = inputValue.trim();
|
||||
setMessages(prev => [...prev, {
|
||||
id: Date.now(),
|
||||
type: 'user',
|
||||
content: userMessage,
|
||||
timestamp: new Date()
|
||||
}]);
|
||||
|
||||
setInputValue('');
|
||||
sendMessage(userMessage);
|
||||
};
|
||||
|
||||
// Handle key press
|
||||
const handleKeyPress = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
};
|
||||
|
||||
// Format timestamp
|
||||
const formatTime = (date) => {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
// Quick action buttons
|
||||
const quickActions = [
|
||||
{
|
||||
label: 'Dashboard Help',
|
||||
message: 'How do I use the dashboard filters?'
|
||||
},
|
||||
{
|
||||
label: 'Report Status',
|
||||
message: 'What do the different report statuses mean?'
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
message: 'What types of city issues can be reported?'
|
||||
},
|
||||
{
|
||||
label: 'Navigation',
|
||||
message: 'How do I navigate to a specific location on the map?'
|
||||
}
|
||||
];
|
||||
|
||||
const handleQuickAction = (message) => {
|
||||
setInputValue(message);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) {
|
||||
return (
|
||||
<div className="chatbot-toggle" onClick={() => setIsOpen(true)}>
|
||||
<div className="chatbot-toggle-icon">
|
||||
💬
|
||||
</div>
|
||||
<span>Chat Assistant</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="chatbot-container">
|
||||
<div className="chatbot-header">
|
||||
<h3>CityPulse Assistant</h3>
|
||||
<button className="chatbot-close" onClick={() => setIsOpen(false)}>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="chatbot-messages">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`message ${message.type}`}
|
||||
>
|
||||
<div className="message-avatar">
|
||||
{message.type === 'bot' ? '🤖' : '👤'}
|
||||
</div>
|
||||
<div className="message-content">
|
||||
<div className="message-text">{message.content}</div>
|
||||
<div className="message-time">
|
||||
{formatTime(message.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<div className="message bot">
|
||||
<div className="message-avatar">🤖</div>
|
||||
<div className="message-content">
|
||||
<div className="message-text">
|
||||
<div className="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
<div className="chatbot-quick-actions">
|
||||
{quickActions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className="quick-action-btn"
|
||||
onClick={() => handleQuickAction(action.message)}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form className="chatbot-input-form" onSubmit={handleSubmit}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Ask me anything about CityPulse..."
|
||||
disabled={isLoading}
|
||||
className="chatbot-input"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!inputValue.trim() || isLoading}
|
||||
className="chatbot-send-btn"
|
||||
>
|
||||
{isLoading ? '...' : 'Send'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Export for use in other modules
|
||||
window.Chatbot = Chatbot;
|
||||
Reference in New Issue
Block a user