feat(chatbot): integrate OpenRouter API for AI assistance in dashboard #1
4
dashboard/.env
Normal file
4
dashboard/.env
Normal file
@@ -0,0 +1,4 @@
|
||||
# OpenRouter API Configuration
|
||||
OPENROUTER_API_KEY=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
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
OPENROUTER_MODEL=x-ai/grok-4-fast:free
|
||||
130
dashboard/CHATBOT_README.md
Normal file
130
dashboard/CHATBOT_README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# CityPulse Dashboard Chatbot
|
||||
|
||||
This dashboard includes an AI-powered chatbot that uses OpenRouter's API to provide assistance with dashboard features and city reporting questions.
|
||||
|
||||
## Features
|
||||
|
||||
- **AI Assistant**: Powered by x-ai/grok-4-fast:free model via OpenRouter
|
||||
- **Interactive Chat**: Real-time conversation with typing indicators
|
||||
- **Quick Actions**: Pre-defined questions for common help topics
|
||||
- **Mobile Responsive**: Works on desktop and mobile devices
|
||||
- **Context Aware**: Understands CityPulse dashboard functionality
|
||||
- **Secure API Key Management**: No hardcoded API keys in frontend code
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. **Environment Variables**
|
||||
Create a `.env` file in the dashboard directory:
|
||||
```env
|
||||
OPENROUTER_API_KEY=your_api_key_here
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
OPENROUTER_MODEL=x-ai/grok-4-fast:free
|
||||
```
|
||||
|
||||
### 2. **Get API Key**
|
||||
Sign up at [OpenRouter](https://openrouter.ai/) to get your free API key.
|
||||
|
||||
### 3. **Install Dependencies**
|
||||
```bash
|
||||
cd dashboard
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4. **Setup API Key (Development)**
|
||||
```bash
|
||||
npm run setup
|
||||
```
|
||||
This script safely injects your API key from the `.env` file into the frontend code.
|
||||
|
||||
### 5. **Start Development Server**
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Or manually:
|
||||
```bash
|
||||
npm run setup && python -m http.server 3000
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
- Click the floating chat button (💬) in the bottom-right corner to open the chatbot
|
||||
- Type your questions or use the quick action buttons for common queries
|
||||
- The chatbot can help with:
|
||||
- Dashboard navigation and features
|
||||
- Understanding report statuses and categories
|
||||
- General questions about city reporting
|
||||
- Troubleshooting dashboard issues
|
||||
|
||||
## Quick Actions Available
|
||||
|
||||
- **Dashboard Help**: How to use dashboard filters
|
||||
- **Report Status**: What different report statuses mean
|
||||
- **Categories**: Types of city issues that can be reported
|
||||
- **Navigation**: How to navigate to specific locations on the map
|
||||
|
||||
## Security Features
|
||||
|
||||
### 🔒 **No Hardcoded API Keys**
|
||||
- API keys are never hardcoded in the frontend JavaScript
|
||||
- Keys are loaded from environment variables at runtime
|
||||
- Build-time replacement ensures keys aren't exposed in source code
|
||||
|
||||
### 🛡️ **Development vs Production**
|
||||
- **Development**: Uses environment variables with build-time replacement
|
||||
- **Production**: Should use a secure backend endpoint to serve configuration
|
||||
|
||||
### 🔧 **Backend Configuration Server (Optional)**
|
||||
For enhanced security, you can run the included Python server:
|
||||
```bash
|
||||
pip install flask flask-cors python-dotenv
|
||||
python server.py
|
||||
```
|
||||
This serves configuration from `http://localhost:3001/api/chatbot-config`
|
||||
|
||||
## Technical Details
|
||||
|
||||
- Built with React and modern JavaScript
|
||||
- Uses OpenRouter API for AI responses
|
||||
- Styled to match the CityPulse dashboard theme
|
||||
- Includes error handling and loading states
|
||||
- Mobile-responsive design
|
||||
- Secure API key management
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
dashboard/
|
||||
├── .env # Environment variables (create this)
|
||||
├── Chatbot.js # Main chatbot component
|
||||
├── app.js # Dashboard application
|
||||
├── index.html # Main HTML file
|
||||
├── styles.css # Styling
|
||||
├── server.py # Optional backend config server
|
||||
├── replace-env-vars.js # Development API key injection
|
||||
├── package.json # Node.js dependencies
|
||||
└── requirements.txt # Python dependencies
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If the chatbot isn't working:
|
||||
|
||||
1. **Check API Key**: Ensure your OpenRouter API key is valid and has credits
|
||||
2. **Environment Setup**: Make sure `.env` file exists with correct variables
|
||||
3. **Run Setup**: Execute `npm run setup` to inject the API key
|
||||
4. **Check Console**: Look for error messages in browser developer tools
|
||||
5. **Network Check**: Verify internet connection for API calls
|
||||
|
||||
### Common Issues
|
||||
|
||||
- **"API key not configured"**: Run `npm run setup` to inject the key
|
||||
- **CORS errors**: Make sure the server is running from the correct directory
|
||||
- **404 errors**: Check that all files are in the dashboard directory
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
1. **Never commit API keys** to version control
|
||||
2. **Use environment variables** for all sensitive configuration
|
||||
3. **Consider backend services** for production deployments
|
||||
4. **Rotate API keys** regularly
|
||||
5. **Monitor API usage** on OpenRouter dashboard
|
||||
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;
|
||||
131
dashboard/README.md
Normal file
131
dashboard/README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# FixMate Dashboard
|
||||
|
||||
A modern, responsive dashboard for managing civic issue reports with an interactive map interface.
|
||||
|
||||
## Features
|
||||
|
||||
- **Interactive Map**: View reported issues on an interactive Leaflet map with clustering
|
||||
- **Advanced Filtering**: Filter by category, severity, status, and date range
|
||||
- **Real-time Updates**: Live status updates and filtering
|
||||
- **Responsive Design**: Works seamlessly on desktop, tablet, and mobile devices
|
||||
- **Modern UI**: Clean, professional interface with smooth animations
|
||||
- **Accessibility**: Keyboard navigation and screen reader friendly
|
||||
- **Multi-language**: Support for English and Bahasa Malaysia
|
||||
|
||||
## UI Improvements Made
|
||||
|
||||
### 🎨 Modern Design System
|
||||
- **Color Palette**: Updated with modern semantic colors and CSS custom properties
|
||||
- **Typography**: Inter font family for better readability
|
||||
- **Spacing**: Consistent spacing system using CSS custom properties
|
||||
- **Shadows**: Subtle shadows and depth for better visual hierarchy
|
||||
|
||||
### 🔧 Enhanced Components
|
||||
- **Header**: Modern sticky header with improved branding and language selector
|
||||
- **Filter Panel**: Organized filter groups with hover states and better visual feedback
|
||||
- **Ticket Cards**: Modern card design with hover effects and improved typography
|
||||
- **Map Container**: Better map styling with loading states and empty state handling
|
||||
- **Detail Drawer**: Slide-out drawer with improved layout and actions
|
||||
|
||||
### 📱 Responsive Design
|
||||
- **Mobile-first**: Optimized layouts for mobile, tablet, and desktop
|
||||
- **Flexible Grid**: CSS Grid layout that adapts to screen size
|
||||
- **Touch-friendly**: Larger touch targets for mobile interactions
|
||||
|
||||
### ⚡ Performance & UX
|
||||
- **Loading States**: Skeleton screens and loading indicators
|
||||
- **Smooth Animations**: CSS transitions for better user experience
|
||||
- **Error Handling**: Better error states and retry mechanisms
|
||||
- **Offline Support**: Graceful handling when backend is unavailable
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend**: React 18, JavaScript ES6+
|
||||
- **Styling**: Modern CSS with custom properties (CSS variables)
|
||||
- **Maps**: Leaflet with marker clustering
|
||||
- **Build**: No build process - runs directly in browser
|
||||
- **Fonts**: Google Fonts (Inter)
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. **Start the Backend**:
|
||||
```bash
|
||||
cd backend
|
||||
python main.py
|
||||
```
|
||||
|
||||
2. **Open the Dashboard**:
|
||||
Open `index.html` in your web browser, or serve it with a local server:
|
||||
```bash
|
||||
# Using Python
|
||||
python -m http.server 8000
|
||||
|
||||
# Using Node.js
|
||||
npx serve .
|
||||
```
|
||||
|
||||
3. **Access**: Navigate to `http://localhost:8000/dashboard/`
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
dashboard/
|
||||
├── index.html # Main HTML file
|
||||
├── styles.css # Modern CSS styles
|
||||
├── app.js # React application
|
||||
├── i18n/ # Internationalization files
|
||||
│ ├── en.json
|
||||
│ └── ms.json
|
||||
└── data/
|
||||
└── demo-reports.json # Sample data for testing
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### Map View
|
||||
- Interactive Leaflet map with OpenStreetMap tiles
|
||||
- Clustered markers for better performance
|
||||
- Click markers to view details
|
||||
- Heatmap overlay option
|
||||
|
||||
### Filtering System
|
||||
- Category filtering (pothole, streetlight, signage, etc.)
|
||||
- Severity levels (high, medium, low)
|
||||
- Status tracking (submitted, in progress, fixed)
|
||||
- Date range filtering
|
||||
|
||||
### Ticket Management
|
||||
- View all reported issues in a scrollable list
|
||||
- Click to navigate to location on map
|
||||
- Update status directly from the list
|
||||
- Detailed view in slide-out drawer
|
||||
|
||||
### Responsive Breakpoints
|
||||
- Desktop: 1200px+
|
||||
- Tablet: 900px - 1200px
|
||||
- Mobile: 600px - 900px
|
||||
- Small Mobile: < 600px
|
||||
|
||||
## Customization
|
||||
|
||||
The design system is built with CSS custom properties, making it easy to customize:
|
||||
|
||||
```css
|
||||
:root {
|
||||
--primary-500: #0ea5a4; /* Main brand color */
|
||||
--severity-high: #dc2626; /* High priority color */
|
||||
--spacing-4: 1rem; /* Base spacing unit */
|
||||
--radius: 0.5rem; /* Border radius */
|
||||
}
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- Edge 90+
|
||||
|
||||
## License
|
||||
|
||||
This project is part of the FixMate civic engagement platform.
|
||||
@@ -603,6 +603,9 @@ const cycleStatus = async (reportId) => {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Chatbot */}
|
||||
<Chatbot />
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -28,5 +28,6 @@
|
||||
|
||||
<!-- App -->
|
||||
<script type="text/babel" src="./app.js"></script>
|
||||
<script type="text/babel" src="./Chatbot.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
16
dashboard/package.json
Normal file
16
dashboard/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "citypulse-dashboard",
|
||||
"version": "1.0.0",
|
||||
"description": "CityPulse Dashboard with Chatbot",
|
||||
"scripts": {
|
||||
"setup": "node replace-env-vars.js",
|
||||
"serve": "python -m http.server 3000",
|
||||
"dev": "npm run setup && npm run serve"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.0.3"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"keywords": ["dashboard", "citypulse", "chatbot"],
|
||||
"author": "CityPulse Team"
|
||||
}
|
||||
48
dashboard/replace-env-vars.js
Normal file
48
dashboard/replace-env-vars.js
Normal file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Simple script to replace environment variable placeholders in frontend code
|
||||
* This is a development convenience - in production, use proper build tools
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
const CHATBOT_FILE = path.join(__dirname, 'Chatbot.js');
|
||||
const ENV_FILE = path.join(__dirname, '.env');
|
||||
|
||||
// Read the current Chatbot.js file
|
||||
let chatbotContent = fs.readFileSync(CHATBOT_FILE, 'utf8');
|
||||
|
||||
// Read the .env file
|
||||
let envContent = fs.readFileSync(ENV_FILE, 'utf8');
|
||||
|
||||
// Extract the API key from .env
|
||||
const apiKeyMatch = envContent.match(/OPENROUTER_API_KEY=(.+)/);
|
||||
if (!apiKeyMatch) {
|
||||
console.error('❌ OPENROUTER_API_KEY not found in .env file');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const actualApiKey = apiKeyMatch[1].trim();
|
||||
|
||||
// Replace the placeholder with the actual API key
|
||||
const updatedContent = chatbotContent.replace(
|
||||
/OPENROUTER_API_KEY: ['"]YOUR_API_KEY_HERE['"]/,
|
||||
`OPENROUTER_API_KEY: '${actualApiKey}'`
|
||||
);
|
||||
|
||||
// Write the updated file
|
||||
fs.writeFileSync(CHATBOT_FILE, updatedContent);
|
||||
|
||||
console.log('✅ API key successfully injected into Chatbot.js');
|
||||
console.log('🔒 Remember: This is for development only. Use secure methods in production.');
|
||||
|
||||
// Also update the hardcoded key in the fetch request
|
||||
const fetchUpdatedContent = updatedContent.replace(
|
||||
/`Bearer \$\{config\.OPENROUTER_API_KEY\}`/g,
|
||||
`\`Bearer \${config.OPENROUTER_API_KEY}\``
|
||||
);
|
||||
|
||||
fs.writeFileSync(CHATBOT_FILE, fetchUpdatedContent);
|
||||
console.log('✅ Chatbot.js updated with secure API key reference');
|
||||
3
dashboard/requirements.txt
Normal file
3
dashboard/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
flask==2.3.3
|
||||
flask-cors==4.0.0
|
||||
python-dotenv==1.0.0
|
||||
47
dashboard/server.py
Normal file
47
dashboard/server.py
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple configuration server for CityPulse Dashboard Chatbot
|
||||
Serves API keys securely without exposing them in frontend code
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app) # Enable CORS for all routes
|
||||
|
||||
@app.route('/api/chatbot-config', methods=['GET'])
|
||||
def get_chatbot_config():
|
||||
"""Serve chatbot configuration securely"""
|
||||
try:
|
||||
config = {
|
||||
'OPENROUTER_API_KEY': os.getenv('OPENROUTER_API_KEY'),
|
||||
'OPENROUTER_BASE_URL': os.getenv('OPENROUTER_BASE_URL', 'https://openrouter.ai/api/v1'),
|
||||
'OPENROUTER_MODEL': os.getenv('OPENROUTER_MODEL', 'x-ai/grok-4-fast:free')
|
||||
}
|
||||
|
||||
# Validate that API key is present
|
||||
if not config['OPENROUTER_API_KEY']:
|
||||
return jsonify({'error': 'API key not configured'}), 500
|
||||
|
||||
return jsonify(config)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': f'Failed to load configuration: {str(e)}'}), 500
|
||||
|
||||
@app.route('/api/config', methods=['GET'])
|
||||
def get_config():
|
||||
"""Legacy config endpoint"""
|
||||
return get_chatbot_config()
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("Starting CityPulse Dashboard Configuration Server...")
|
||||
print("Server will run on http://localhost:3001")
|
||||
print("Make sure your .env file contains OPENROUTER_API_KEY")
|
||||
app.run(host='localhost', port=3001, debug=True)
|
||||
@@ -437,4 +437,279 @@ button.chip[aria-pressed="true"]{
|
||||
}
|
||||
|
||||
/* accessibility tweaks */
|
||||
.chip, .btn{font-family:inherit}
|
||||
.chip, .btn{font-family:inherit}
|
||||
|
||||
/* Chatbot styles */
|
||||
.chatbot-toggle {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 50px;
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(14, 165, 164, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1000;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chatbot-toggle:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(14, 165, 164, 0.4);
|
||||
background: #0d9488;
|
||||
}
|
||||
|
||||
.chatbot-toggle-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.chatbot-container {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 350px;
|
||||
height: 500px;
|
||||
background: var(--panel);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.chatbot-header {
|
||||
background: linear-gradient(135deg, var(--accent), #0d9488);
|
||||
color: white;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chatbot-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chatbot-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.chatbot-close:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.chatbot-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.message.bot {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.message.user .message-avatar {
|
||||
background: var(--severity-high);
|
||||
}
|
||||
|
||||
.message-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
background: #f8fafc;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
white-space: pre-wrap;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.message.user .message-text {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.message-time {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
margin-top: 4px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.message.user .message-time {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #6b7280;
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
0%, 60%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-10px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chatbot-quick-actions {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-action-btn {
|
||||
background: #f1f5f9;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 16px;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-action-btn:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.chatbot-input-form {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.chatbot-input {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.chatbot-input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 164, 0.1);
|
||||
}
|
||||
|
||||
.chatbot-input:disabled {
|
||||
background: #f8fafc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chatbot-send-btn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
padding: 10px 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.chatbot-send-btn:hover:not(:disabled) {
|
||||
background: #0d9488;
|
||||
}
|
||||
|
||||
.chatbot-send-btn:disabled {
|
||||
background: #9ca3af;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness for chatbot */
|
||||
@media (max-width: 768px) {
|
||||
.chatbot-container {
|
||||
width: calc(100vw - 40px);
|
||||
height: 400px;
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
left: 10px;
|
||||
}
|
||||
|
||||
.chatbot-toggle {
|
||||
bottom: 10px;
|
||||
right: 10px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
192
dashboard/test.html
Normal file
192
dashboard/test.html
Normal file
@@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.test-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.test-item {
|
||||
margin-bottom: 20px;
|
||||
padding: 15px;
|
||||
border-left: 4px solid #0ea5a4;
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.status.pass { background: #d4edda; color: #155724; }
|
||||
.status.fail { background: #f8d7da; color: #721c24; }
|
||||
.status.info { background: #d1ecf1; color: #0c5460; }
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="test-container">
|
||||
<h1>FixMate Dashboard - Test Page</h1>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>🗺️ Map Initialization Test</h3>
|
||||
<p>Testing if the Leaflet map initializes properly without the "Map container not found" error.</p>
|
||||
<div id="map-test-status" class="status" style="background: #fff3cd; color: #856404;">Testing...</div>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>🔧 Backend Connection Test</h3>
|
||||
<p>Testing connection to the Python backend server.</p>
|
||||
<div id="backend-test-status" class="status">Testing...</div>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>📊 Data Loading Test</h3>
|
||||
<p>Testing if ticket data loads successfully from the backend.</p>
|
||||
<div id="data-test-status" class="status">Testing...</div>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>🎨 UI Components Test</h3>
|
||||
<p>Testing if all UI components render correctly (filters, tickets list, etc.).</p>
|
||||
<div id="ui-test-status" class="status">Testing...</div>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>📱 Responsive Design Test</h3>
|
||||
<p>Testing if the dashboard works on different screen sizes.</p>
|
||||
<div id="responsive-test-status" class="status">Testing...</div>
|
||||
</div>
|
||||
|
||||
<div class="test-item">
|
||||
<h3>🚀 Full Dashboard Preview</h3>
|
||||
<p>Live preview of the complete dashboard (if all tests pass).</p>
|
||||
<iframe id="dashboard-frame" src="index.html" style="display: none;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Test map initialization
|
||||
fetch('index.html')
|
||||
.then(response => response.text())
|
||||
.then(html => {
|
||||
const hasMapElement = html.includes('id="map"');
|
||||
const hasLeaflet = html.includes('leaflet');
|
||||
|
||||
if (hasMapElement && hasLeaflet) {
|
||||
document.getElementById('map-test-status').className = 'status pass';
|
||||
document.getElementById('map-test-status').textContent = 'PASS: Map elements found';
|
||||
} else {
|
||||
document.getElementById('map-test-status').className = 'status fail';
|
||||
document.getElementById('map-test-status').textContent = 'FAIL: Map elements missing';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('map-test-status').className = 'status fail';
|
||||
document.getElementById('map-test-status').textContent = 'ERROR: ' + error.message;
|
||||
});
|
||||
|
||||
// Test backend connection
|
||||
fetch('http://127.0.0.1:8000/test')
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
document.getElementById('backend-test-status').className = 'status pass';
|
||||
document.getElementById('backend-test-status').textContent = 'PASS: Backend responding';
|
||||
return response.json();
|
||||
} else {
|
||||
throw new Error('Backend not responding');
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Backend test data:', data);
|
||||
document.getElementById('data-test-status').className = 'status pass';
|
||||
document.getElementById('data-test-status').textContent = 'PASS: Data loaded (' + (Array.isArray(data) ? data.length : 'object') + ')';
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('Backend test error:', error);
|
||||
document.getElementById('backend-test-status').className = 'status fail';
|
||||
document.getElementById('backend-test-status').textContent = 'FAIL: ' + error.message;
|
||||
|
||||
// Try loading demo data instead
|
||||
fetch('data/demo-reports.json')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
document.getElementById('data-test-status').className = 'status info';
|
||||
document.getElementById('data-test-status').textContent = 'INFO: Using demo data (' + data.length + ' reports)';
|
||||
})
|
||||
.catch(demoError => {
|
||||
document.getElementById('data-test-status').className = 'status fail';
|
||||
document.getElementById('data-test-status').textContent = 'FAIL: No data available';
|
||||
});
|
||||
});
|
||||
|
||||
// Test UI components
|
||||
fetch('styles.css')
|
||||
.then(response => response.text())
|
||||
.then(css => {
|
||||
const hasModernCSS = css.includes('--primary') && css.includes('grid-template-columns');
|
||||
const hasResponsive = css.includes('@media');
|
||||
|
||||
if (hasModernCSS && hasResponsive) {
|
||||
document.getElementById('ui-test-status').className = 'status pass';
|
||||
document.getElementById('ui-test-status').textContent = 'PASS: Modern CSS detected';
|
||||
} else {
|
||||
document.getElementById('ui-test-status').className = 'status fail';
|
||||
document.getElementById('ui-test-status').textContent = 'FAIL: CSS issues detected';
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
document.getElementById('ui-test-status').className = 'status fail';
|
||||
document.getElementById('ui-test-status').textContent = 'ERROR: ' + error.message;
|
||||
});
|
||||
|
||||
// Test responsive design
|
||||
const screenWidth = window.screen.width;
|
||||
const screenHeight = window.screen.height;
|
||||
|
||||
if (screenWidth >= 1200) {
|
||||
document.getElementById('responsive-test-status').className = 'status pass';
|
||||
document.getElementById('responsive-test-status').textContent = 'PASS: Desktop resolution (' + screenWidth + 'x' + screenHeight + ')';
|
||||
} else if (screenWidth >= 768) {
|
||||
document.getElementById('responsive-test-status').className = 'status pass';
|
||||
document.getElementById('responsive-test-status').textContent = 'PASS: Tablet resolution (' + screenWidth + 'x' + screenHeight + ')';
|
||||
} else {
|
||||
document.getElementById('responsive-test-status').className = 'status pass';
|
||||
document.getElementById('responsive-test-status').textContent = 'PASS: Mobile resolution (' + screenWidth + 'x' + screenHeight + ')';
|
||||
}
|
||||
|
||||
// Show dashboard preview if all tests pass
|
||||
setTimeout(() => {
|
||||
const allTestsPassed =
|
||||
document.getElementById('map-test-status').classList.contains('pass') &&
|
||||
document.getElementById('backend-test-status').classList.contains('pass') &&
|
||||
document.getElementById('ui-test-status').classList.contains('pass');
|
||||
|
||||
if (allTestsPassed) {
|
||||
document.getElementById('dashboard-frame').style.display = 'block';
|
||||
}
|
||||
}, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user