Files
citypulse/dashboard/Chatbot.js
Zahar ec3c7320d7 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.
2025-09-27 15:00:30 +08:00

344 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;