feat: Implement Performance Monitor, Settings Manager, UI Manager, Voice Manager, and Utility Functions
- Added PerformanceMonitor class for tracking application performance metrics including FPS, memory usage, and response times. - Introduced SettingsManager class to handle application settings, configuration, and persistence with local storage. - Created UIManager class to manage UI state, updates, and user interactions, including theme toggling and loading indicators. - Developed VoiceManager class for voice recognition and speech synthesis functionalities, including voice input and output controls. - Added utility functions for common operations such as debouncing, throttling, UUID generation, data formatting, and local storage management.
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import EnhancedVisionChatApp from './modules/EnhancedVisionChatApp.js';
|
||||
|
||||
// Wait for DOM to be fully loaded
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Create global app instance
|
||||
window.app = new EnhancedVisionChatApp();
|
||||
|
||||
// Log initialization
|
||||
console.log('🎉 Enhanced Vision Chat Pro loaded successfully!');
|
||||
});
|
||||
|
||||
// Handle service worker registration for PWA support
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/service-worker.js')
|
||||
.then(registration => {
|
||||
console.log('ServiceWorker registration successful');
|
||||
})
|
||||
.catch(error => {
|
||||
console.log('ServiceWorker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Handle unhandled errors
|
||||
window.addEventListener('unhandledrejection', event => {
|
||||
console.error('Unhandled promise rejection:', event.reason);
|
||||
if (window.app) {
|
||||
window.app.notifications.show('❌ An unexpected error occurred', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle runtime errors
|
||||
window.addEventListener('error', event => {
|
||||
console.error('Runtime error:', event.error);
|
||||
if (window.app) {
|
||||
window.app.notifications.show('❌ An unexpected error occurred', 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle visibility changes
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (window.app) {
|
||||
if (document.hidden) {
|
||||
window.app.camera.pauseCapture();
|
||||
} else {
|
||||
window.app.camera.resumeCapture();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Handle online/offline status
|
||||
window.addEventListener('online', () => {
|
||||
if (window.app) {
|
||||
window.app.notifications.show('🌐 Connection restored', 'success');
|
||||
window.app.checkConnection();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
if (window.app) {
|
||||
window.app.notifications.show('📡 Connection lost', 'warning');
|
||||
window.app.setConnectionStatus('disconnected');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle before unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window.app) {
|
||||
window.app.settings.saveSettings();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* Camera Manager Module
|
||||
* Handles camera initialization, video capture, and related functionality
|
||||
*/
|
||||
|
||||
export default class CameraManager {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.stream = null;
|
||||
this.captureInterval = null;
|
||||
this.lastMotionDetection = 0;
|
||||
this.motionThreshold = 30;
|
||||
this.isCapturePaused = false;
|
||||
|
||||
// Camera constraints
|
||||
this.constraints = {
|
||||
video: {
|
||||
width: { ideal: 1920 },
|
||||
height: { ideal: 1080 },
|
||||
frameRate: { ideal: 30 }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
// Set initial camera source
|
||||
this.constraints.video.facingMode = this.app.getConfig('cameraSource');
|
||||
|
||||
// Request camera access
|
||||
const stream = await navigator.mediaDevices.getUserMedia(this.constraints);
|
||||
this.handleStreamSuccess(stream);
|
||||
|
||||
// Add camera switch capability check
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const hasMultipleCameras = devices.filter(device => device.kind === 'videoinput').length > 1;
|
||||
|
||||
if (hasMultipleCameras) {
|
||||
this.app.setState('hasMultipleCameras', true);
|
||||
}
|
||||
|
||||
this.app.notifications.show('📹 Camera connected successfully', 'success');
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Camera initialization failed:', error);
|
||||
this.app.notifications.show('❌ Camera access failed: ' + this.getErrorMessage(error), 'error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
handleStreamSuccess(stream) {
|
||||
this.stream = stream;
|
||||
this.app.video.srcObject = stream;
|
||||
|
||||
// Setup video metadata handling
|
||||
this.app.video.addEventListener('loadedmetadata', () => {
|
||||
this.setupCanvas();
|
||||
console.log(`Camera initialized: ${this.app.video.videoWidth}x${this.app.video.videoHeight}`);
|
||||
});
|
||||
}
|
||||
|
||||
setupCanvas() {
|
||||
// Set canvas dimensions to match video
|
||||
this.app.canvas.width = this.app.video.videoWidth;
|
||||
this.app.canvas.height = this.app.video.videoHeight;
|
||||
}
|
||||
|
||||
captureFrame() {
|
||||
if (!this.isVideoReady()) return null;
|
||||
|
||||
try {
|
||||
// Draw current video frame to canvas
|
||||
this.app.ctx.drawImage(
|
||||
this.app.video,
|
||||
0, 0,
|
||||
this.app.canvas.width,
|
||||
this.app.canvas.height
|
||||
);
|
||||
|
||||
// Convert to JPEG with configured quality
|
||||
const imageData = this.app.canvas.toDataURL(
|
||||
'image/jpeg',
|
||||
this.app.getConfig('imageQuality')
|
||||
);
|
||||
|
||||
// Update state
|
||||
this.app.setState('currentImageData', imageData.split(',')[1]);
|
||||
this.app.setState('lastCaptureTime', Date.now());
|
||||
this.app.setState('imageCount', this.app.getState('imageCount') + 1);
|
||||
|
||||
return imageData.split(',')[1];
|
||||
|
||||
} catch (error) {
|
||||
console.error('Frame capture failed:', error);
|
||||
this.app.notifications.show('❌ Frame capture failed', 'error');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
startAutoCapture() {
|
||||
if (this.captureInterval) {
|
||||
this.stopAutoCapture();
|
||||
}
|
||||
|
||||
const interval = parseInt(this.app.settings.get('captureInterval')) * 1000;
|
||||
|
||||
this.captureInterval = setInterval(() => {
|
||||
if (!this.isCapturePaused && this.shouldCapture()) {
|
||||
this.captureFrame();
|
||||
}
|
||||
}, interval);
|
||||
|
||||
// Capture first frame immediately
|
||||
this.captureFrame();
|
||||
|
||||
// Update UI
|
||||
this.app.ui.updateCaptureButton(true);
|
||||
this.app.notifications.show('▶️ Auto-capture started');
|
||||
}
|
||||
|
||||
stopAutoCapture() {
|
||||
if (this.captureInterval) {
|
||||
clearInterval(this.captureInterval);
|
||||
this.captureInterval = null;
|
||||
|
||||
// Update UI
|
||||
this.app.ui.updateCaptureButton(false);
|
||||
this.app.notifications.show('⏸️ Auto-capture stopped');
|
||||
}
|
||||
}
|
||||
|
||||
pauseCapture() {
|
||||
this.isCapturePaused = true;
|
||||
}
|
||||
|
||||
resumeCapture() {
|
||||
this.isCapturePaused = false;
|
||||
if (this.app.settings.get('autoCapture')) {
|
||||
this.captureFrame(); // Capture immediate frame on resume
|
||||
}
|
||||
}
|
||||
|
||||
async switchCamera() {
|
||||
if (!this.stream) return;
|
||||
|
||||
// Toggle facing mode
|
||||
const currentMode = this.app.getConfig('cameraSource');
|
||||
const newMode = currentMode === 'user' ? 'environment' : 'user';
|
||||
|
||||
// Stop current stream
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
|
||||
// Update constraints
|
||||
this.constraints.video.facingMode = newMode;
|
||||
|
||||
try {
|
||||
// Get new stream
|
||||
const newStream = await navigator.mediaDevices.getUserMedia(this.constraints);
|
||||
this.handleStreamSuccess(newStream);
|
||||
|
||||
// Update config
|
||||
this.app.setConfig('cameraSource', newMode);
|
||||
|
||||
this.app.notifications.show('🔄 Camera switched successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Camera switch failed:', error);
|
||||
this.app.notifications.show('❌ Camera switch failed', 'error');
|
||||
|
||||
// Try to revert to previous camera
|
||||
this.constraints.video.facingMode = currentMode;
|
||||
const revertStream = await navigator.mediaDevices.getUserMedia(this.constraints);
|
||||
this.handleStreamSuccess(revertStream);
|
||||
}
|
||||
}
|
||||
|
||||
showImagePreview() {
|
||||
const imageData = this.app.getState('currentImageData');
|
||||
if (!imageData) return;
|
||||
|
||||
const preview = document.getElementById('imagePreview');
|
||||
const img = document.getElementById('previewImg');
|
||||
|
||||
img.src = 'data:image/jpeg;base64,' + imageData;
|
||||
preview.classList.add('show');
|
||||
|
||||
// Hide preview after delay
|
||||
setTimeout(() => {
|
||||
preview.classList.remove('show');
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
shouldCapture() {
|
||||
if (!this.app.settings.get('smartDetection')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Implement motion detection logic
|
||||
const motionDetected = this.detectMotion();
|
||||
const timeSinceLastDetection = Date.now() - this.lastMotionDetection;
|
||||
|
||||
return motionDetected || timeSinceLastDetection > 5000;
|
||||
}
|
||||
|
||||
detectMotion() {
|
||||
if (!this.isVideoReady()) return false;
|
||||
|
||||
try {
|
||||
const currentFrame = this.app.ctx.getImageData(
|
||||
0, 0,
|
||||
this.app.canvas.width,
|
||||
this.app.canvas.height
|
||||
);
|
||||
|
||||
if (!this.previousFrame) {
|
||||
this.previousFrame = currentFrame;
|
||||
return false;
|
||||
}
|
||||
|
||||
const diff = this.calculateFrameDifference(currentFrame, this.previousFrame);
|
||||
this.previousFrame = currentFrame;
|
||||
|
||||
if (diff > this.motionThreshold) {
|
||||
this.lastMotionDetection = Date.now();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Motion detection failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
calculateFrameDifference(frame1, frame2) {
|
||||
const data1 = frame1.data;
|
||||
const data2 = frame2.data;
|
||||
let diff = 0;
|
||||
|
||||
// Sample pixels for performance
|
||||
for (let i = 0; i < data1.length; i += 40) {
|
||||
diff += Math.abs(data1[i] - data2[i]);
|
||||
}
|
||||
|
||||
return diff / data1.length;
|
||||
}
|
||||
|
||||
isVideoReady() {
|
||||
return this.app.video &&
|
||||
this.app.video.videoWidth &&
|
||||
this.app.video.videoHeight &&
|
||||
!this.app.video.paused;
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.stopAutoCapture();
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach(track => track.stop());
|
||||
}
|
||||
}
|
||||
|
||||
getErrorMessage(error) {
|
||||
switch(error.name) {
|
||||
case 'NotAllowedError':
|
||||
return 'Camera permission denied. Please allow camera access.';
|
||||
case 'NotFoundError':
|
||||
return 'No camera found. Please connect a camera and try again.';
|
||||
case 'NotReadableError':
|
||||
return 'Camera is in use by another application.';
|
||||
default:
|
||||
return error.message || 'Unknown camera error occurred.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// Import managers and utilities
|
||||
import CameraManager from './CameraManager.js';
|
||||
import MessageHandler from './MessageHandler.js';
|
||||
import SettingsManager from './SettingsManager.js';
|
||||
import UIManager from './UIManager.js';
|
||||
import VoiceManager from './VoiceManager.js';
|
||||
import PerformanceMonitor from './PerformanceMonitor.js';
|
||||
import NotificationManager from './NotificationManager.js';
|
||||
import { debounce, generateUUID } from './utils.js';
|
||||
|
||||
export default class EnhancedVisionChatApp {
|
||||
constructor() {
|
||||
// Core Elements
|
||||
this.video = document.getElementById('videoElement');
|
||||
this.canvas = document.getElementById('captureCanvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.messagesContainer = document.getElementById('messagesContainer');
|
||||
this.messageInput = document.getElementById('messageInput');
|
||||
this.sendBtn = document.getElementById('sendBtn');
|
||||
this.statusDot = document.getElementById('statusDot');
|
||||
this.statusText = document.getElementById('statusText');
|
||||
this.typingIndicator = document.getElementById('typingIndicator');
|
||||
|
||||
// Feature Managers
|
||||
this.ui = new UIManager(this);
|
||||
this.camera = new CameraManager(this);
|
||||
this.messages = new MessageHandler(this);
|
||||
this.settings = new SettingsManager(this);
|
||||
this.voice = new VoiceManager(this);
|
||||
this.performance = new PerformanceMonitor(this);
|
||||
this.notifications = new NotificationManager(this);
|
||||
|
||||
// Application State
|
||||
this.state = {
|
||||
connected: false,
|
||||
recording: false,
|
||||
darkTheme: false,
|
||||
voiceMode: false,
|
||||
captureIntervalId: null,
|
||||
lastCaptureTime: 0,
|
||||
currentImageData: null,
|
||||
messageHistory: [],
|
||||
availableModels: [],
|
||||
lastResponseTime: 0,
|
||||
imageCount: 0,
|
||||
responseCache: new Map()
|
||||
};
|
||||
|
||||
// Configuration
|
||||
this.config = {
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
modelName: 'llava',
|
||||
systemPrompt: 'You are a helpful AI assistant with advanced vision capabilities.',
|
||||
contextMemory: 10,
|
||||
imageQuality: 0.8,
|
||||
timeout: 30000,
|
||||
responseStyle: 'casual',
|
||||
cameraSource: 'user'
|
||||
};
|
||||
|
||||
// Initialize the application
|
||||
this.init();
|
||||
}
|
||||
|
||||
async init() {
|
||||
console.log('🚀 Initializing Enhanced Vision Chat Pro...');
|
||||
|
||||
try {
|
||||
// Initialize all managers
|
||||
await this.initializeManagers();
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Load saved settings
|
||||
this.settings.loadSettings();
|
||||
|
||||
// Initialize camera
|
||||
await this.camera.initialize();
|
||||
|
||||
// Check Ollama connection
|
||||
await this.checkConnection();
|
||||
|
||||
// Start auto-capture if enabled
|
||||
if (this.settings.get('autoCapture')) {
|
||||
this.camera.startAutoCapture();
|
||||
}
|
||||
|
||||
// Initialize theme
|
||||
this.ui.initializeTheme();
|
||||
|
||||
this.notifications.show('✅ Enhanced Vision Chat Pro initialized successfully!', 'success');
|
||||
console.log('✅ Initialization complete!');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Initialization failed:', error);
|
||||
this.notifications.show('❌ Initialization failed: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async initializeManagers() {
|
||||
await Promise.all([
|
||||
this.ui.initialize(),
|
||||
this.messages.initialize(),
|
||||
this.settings.initialize(),
|
||||
this.voice.initialize(),
|
||||
this.performance.initialize(),
|
||||
this.notifications.initialize()
|
||||
]);
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Core functionality
|
||||
this.sendBtn.addEventListener('click', () => this.messages.sendMessage());
|
||||
this.messageInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.messages.sendMessage();
|
||||
}
|
||||
});
|
||||
|
||||
// Message input handling
|
||||
this.messageInput.addEventListener('input', () => {
|
||||
this.ui.adjustTextareaHeight(this.messageInput);
|
||||
});
|
||||
|
||||
// Global keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key) {
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
this.messages.clearChat();
|
||||
break;
|
||||
case 'e':
|
||||
e.preventDefault();
|
||||
this.messages.exportChat();
|
||||
break;
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
this.camera.captureFrame();
|
||||
this.camera.showImagePreview();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
this.ui.hideSettings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async checkConnection() {
|
||||
try {
|
||||
this.setConnectionStatus('connecting');
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), this.config.timeout);
|
||||
|
||||
const response = await fetch(`${this.config.ollamaUrl}/api/tags`, {
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.state.availableModels = data.models || [];
|
||||
this.setConnectionStatus('connected');
|
||||
this.settings.updateAvailableModels();
|
||||
|
||||
if (!this.state.availableModels.some(model =>
|
||||
model.name.includes(this.config.modelName)
|
||||
)) {
|
||||
this.messages.addSystemMessage(`⚠️ Model "${this.config.modelName}" not found.`);
|
||||
this.setConnectionStatus('warning');
|
||||
}
|
||||
|
||||
this.notifications.show('✅ Connected to Ollama successfully!');
|
||||
} else {
|
||||
throw new Error('Connection failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Ollama connection failed:', error);
|
||||
this.setConnectionStatus('disconnected');
|
||||
this.handleConnectionError(error);
|
||||
}
|
||||
}
|
||||
|
||||
setConnectionStatus(status) {
|
||||
this.state.connected = status === 'connected' || status === 'warning';
|
||||
this.ui.updateConnectionStatus(status);
|
||||
}
|
||||
|
||||
handleConnectionError(error) {
|
||||
if (error.name === 'AbortError') {
|
||||
this.messages.addErrorMessage(
|
||||
`Connection timeout after ${this.config.timeout/1000}s. Please check if Ollama is running.`
|
||||
);
|
||||
} else {
|
||||
this.messages.addErrorMessage(
|
||||
`Failed to connect to Ollama at ${this.config.ollamaUrl}. Make sure Ollama is running.`
|
||||
);
|
||||
}
|
||||
this.notifications.show('❌ Failed to connect to Ollama', 'error');
|
||||
}
|
||||
|
||||
// Utility methods
|
||||
getConfig(key) {
|
||||
return this.config[key];
|
||||
}
|
||||
|
||||
setConfig(key, value) {
|
||||
this.config[key] = value;
|
||||
this.settings.saveSettings();
|
||||
}
|
||||
|
||||
getState(key) {
|
||||
return this.state[key];
|
||||
}
|
||||
|
||||
setState(key, value) {
|
||||
this.state[key] = value;
|
||||
// Trigger any necessary UI updates
|
||||
this.ui.handleStateChange(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* Message Handler Module
|
||||
* Manages chat messages, history, and interaction with Ollama API
|
||||
*/
|
||||
|
||||
export default class MessageHandler {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.messageQueue = [];
|
||||
this.isProcessing = false;
|
||||
this.maxRetries = 3;
|
||||
this.maxHistoryLength = 100;
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Load any saved chat history
|
||||
this.loadChatHistory();
|
||||
|
||||
// Initialize quick actions
|
||||
this.setupQuickActions();
|
||||
}
|
||||
|
||||
setupQuickActions() {
|
||||
document.querySelectorAll('.quick-action').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const prompt = btn.dataset.prompt;
|
||||
if (prompt) {
|
||||
this.app.messageInput.value = prompt;
|
||||
this.app.messageInput.focus();
|
||||
this.app.ui.adjustTextareaHeight(this.app.messageInput);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const message = this.app.messageInput.value.trim();
|
||||
if (!message) return;
|
||||
|
||||
if (!this.app.getState('connected')) {
|
||||
this.addErrorMessage('❌ Not connected to Ollama. Please check the connection.');
|
||||
this.app.notifications.show('❌ Connection required', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure we have recent image data
|
||||
if (!this.app.getState('currentImageData') ||
|
||||
(Date.now() - this.app.getState('lastCaptureTime') > 30000)) {
|
||||
this.app.camera.captureFrame();
|
||||
}
|
||||
|
||||
// Check cache for similar messages
|
||||
const cacheKey = this.generateCacheKey(message);
|
||||
if (this.app.settings.get('cacheResponses') && this.app.state.responseCache.has(cacheKey)) {
|
||||
const cachedResponse = this.app.state.responseCache.get(cacheKey);
|
||||
this.addUserMessage(message);
|
||||
this.addAssistantMessage(cachedResponse + ' (cached)');
|
||||
this.app.notifications.show('⚡ Response served from cache');
|
||||
return;
|
||||
}
|
||||
|
||||
// Process the message
|
||||
this.addUserMessage(message);
|
||||
this.app.messageInput.value = '';
|
||||
this.app.messageInput.style.height = 'auto';
|
||||
this.app.ui.setSendingState(true);
|
||||
this.showTypingIndicator();
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await this.sendToOllama(message);
|
||||
|
||||
this.app.setState('lastResponseTime', Date.now() - startTime);
|
||||
this.addAssistantMessage(response);
|
||||
this.updateMessageHistory(message, response);
|
||||
|
||||
// Cache the response
|
||||
if (this.app.settings.get('cacheResponses')) {
|
||||
this.app.state.responseCache.set(cacheKey, response);
|
||||
}
|
||||
|
||||
// Voice synthesis if enabled
|
||||
if (this.app.getState('voiceMode')) {
|
||||
this.app.voice.speakResponse(response);
|
||||
}
|
||||
|
||||
this.app.notifications.show('✅ Response received');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Message sending failed:', error);
|
||||
this.addErrorMessage('❌ Error: ' + error.message);
|
||||
this.app.notifications.show('❌ ' + error.message, 'error');
|
||||
|
||||
} finally {
|
||||
this.app.ui.setSendingState(false);
|
||||
this.hideTypingIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
async sendToOllama(message) {
|
||||
const payload = {
|
||||
model: this.app.getConfig('modelName'),
|
||||
prompt: this.buildContextualPrompt(message),
|
||||
stream: false,
|
||||
options: {
|
||||
temperature: this.getTemperatureForStyle(),
|
||||
top_p: 0.9,
|
||||
num_ctx: 4096
|
||||
}
|
||||
};
|
||||
|
||||
// Add image data if available
|
||||
const imageData = this.app.getState('currentImageData');
|
||||
if (imageData) {
|
||||
payload.images = [imageData];
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), this.app.getConfig('timeout'));
|
||||
|
||||
const response = await fetch(`${this.app.getConfig('ollamaUrl')}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.response || 'No response received';
|
||||
}
|
||||
|
||||
buildContextualPrompt(currentMessage) {
|
||||
let prompt = this.app.getConfig('systemPrompt');
|
||||
|
||||
// Add style-specific instructions
|
||||
switch (this.app.getConfig('responseStyle')) {
|
||||
case 'professional':
|
||||
prompt += ' Respond in a professional, formal manner.';
|
||||
break;
|
||||
case 'technical':
|
||||
prompt += ' Provide detailed technical explanations and analysis.';
|
||||
break;
|
||||
case 'creative':
|
||||
prompt += ' Be creative, imaginative, and engaging in your responses.';
|
||||
break;
|
||||
case 'casual':
|
||||
prompt += ' Be casual, friendly, and conversational.';
|
||||
break;
|
||||
}
|
||||
|
||||
prompt += '\n\n';
|
||||
|
||||
// Add recent conversation history
|
||||
const recentHistory = this.getRecentHistory();
|
||||
if (recentHistory.length > 0) {
|
||||
prompt += 'Recent conversation:\n';
|
||||
recentHistory.forEach(({ user, assistant }) => {
|
||||
prompt += `Human: ${user}\nAssistant: ${assistant}\n\n`;
|
||||
});
|
||||
}
|
||||
|
||||
prompt += `Human: ${currentMessage}\nAssistant:`;
|
||||
return prompt;
|
||||
}
|
||||
|
||||
getTemperatureForStyle() {
|
||||
switch (this.app.getConfig('responseStyle')) {
|
||||
case 'professional': return 0.3;
|
||||
case 'technical': return 0.2;
|
||||
case 'creative': return 0.9;
|
||||
case 'casual':
|
||||
default: return 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
addUserMessage(content) {
|
||||
this.addMessage('user', content);
|
||||
}
|
||||
|
||||
addAssistantMessage(content) {
|
||||
this.addMessage('assistant', content);
|
||||
}
|
||||
|
||||
addSystemMessage(content) {
|
||||
this.addMessage('system', content);
|
||||
}
|
||||
|
||||
addErrorMessage(content) {
|
||||
this.addMessage('error', content);
|
||||
}
|
||||
|
||||
addSuccessMessage(content) {
|
||||
this.addMessage('success', content);
|
||||
}
|
||||
|
||||
addMessage(type, content) {
|
||||
const messageDiv = document.createElement('div');
|
||||
messageDiv.className = `message ${type}`;
|
||||
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.innerHTML = this.formatMessageContent(content);
|
||||
messageDiv.appendChild(contentDiv);
|
||||
|
||||
// Add message actions for assistant messages
|
||||
if (type === 'assistant') {
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'message-actions';
|
||||
actionsDiv.innerHTML = this.getMessageActionsHTML();
|
||||
messageDiv.appendChild(actionsDiv);
|
||||
}
|
||||
|
||||
if (type !== 'system' && type !== 'error' && type !== 'success') {
|
||||
const timeDiv = document.createElement('div');
|
||||
timeDiv.className = 'message-time';
|
||||
timeDiv.textContent = new Date().toLocaleTimeString();
|
||||
messageDiv.appendChild(timeDiv);
|
||||
}
|
||||
|
||||
this.app.messagesContainer.appendChild(messageDiv);
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
getMessageActionsHTML() {
|
||||
return `
|
||||
<button class="message-action-btn" onclick="app.messages.copyMessage(this)">📋</button>
|
||||
<button class="message-action-btn" onclick="app.voice.speakMessage(this)">🔊</button>
|
||||
<button class="message-action-btn" onclick="app.messages.regenerateResponse(this)">🔄</button>
|
||||
`;
|
||||
}
|
||||
|
||||
formatMessageContent(content) {
|
||||
return content
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/`(.*?)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/(\bhttps?:\/\/[^\s]+)/g, '<a href="$1" target="_blank">$1</a>');
|
||||
}
|
||||
|
||||
showTypingIndicator() {
|
||||
this.app.typingIndicator.classList.add('show');
|
||||
this.scrollToBottom();
|
||||
}
|
||||
|
||||
hideTypingIndicator() {
|
||||
this.app.typingIndicator.classList.remove('show');
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
this.app.messagesContainer.scrollTop = this.app.messagesContainer.scrollHeight;
|
||||
}
|
||||
|
||||
clearChat() {
|
||||
if (confirm('Are you sure you want to clear the chat history?')) {
|
||||
this.app.messagesContainer.innerHTML = `
|
||||
<div class="message system">
|
||||
🔄 Chat cleared! Enhanced Vision Chat Pro is ready to help with your visual tasks.
|
||||
</div>
|
||||
`;
|
||||
this.app.state.messageHistory = [];
|
||||
this.app.state.responseCache.clear();
|
||||
localStorage.removeItem('chatHistory');
|
||||
this.app.notifications.show('🗑️ Chat cleared successfully');
|
||||
}
|
||||
}
|
||||
|
||||
exportChat() {
|
||||
const chatData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
settings: {
|
||||
model: this.app.getConfig('modelName'),
|
||||
ollamaUrl: this.app.getConfig('ollamaUrl'),
|
||||
responseStyle: this.app.getConfig('responseStyle')
|
||||
},
|
||||
messages: Array.from(this.app.messagesContainer.children).map(msg => ({
|
||||
type: msg.className.replace('message ', ''),
|
||||
content: msg.querySelector('div').textContent,
|
||||
time: msg.querySelector('.message-time')?.textContent || null
|
||||
})),
|
||||
statistics: {
|
||||
totalMessages: this.app.state.messageHistory.length,
|
||||
imagesProcessed: this.app.getState('imageCount'),
|
||||
averageResponseTime: this.app.getState('lastResponseTime')
|
||||
}
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(chatData, null, 2)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `enhanced-vision-chat-${new Date().toISOString().slice(0, 19).replace(/:/g, '-')}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
this.addSuccessMessage('💾 Chat export completed successfully!');
|
||||
this.app.notifications.show('💾 Chat exported with full metadata');
|
||||
}
|
||||
|
||||
async copyMessage(button) {
|
||||
const message = button.closest('.message').querySelector('div').textContent;
|
||||
await navigator.clipboard.writeText(message);
|
||||
this.app.notifications.show('📋 Message copied to clipboard');
|
||||
}
|
||||
|
||||
regenerateResponse(button) {
|
||||
const messageElement = button.closest('.message');
|
||||
const userMessage = messageElement.previousElementSibling;
|
||||
|
||||
if (userMessage && userMessage.classList.contains('user')) {
|
||||
const userText = userMessage.querySelector('div').textContent;
|
||||
this.app.messageInput.value = userText;
|
||||
this.sendMessage();
|
||||
this.app.notifications.show('🔄 Regenerating response...');
|
||||
}
|
||||
}
|
||||
|
||||
updateMessageHistory(userMessage, assistantResponse) {
|
||||
this.app.state.messageHistory.push({
|
||||
user: userMessage,
|
||||
assistant: assistantResponse,
|
||||
timestamp: Date.now(),
|
||||
imageUsed: !!this.app.getState('currentImageData')
|
||||
});
|
||||
|
||||
// Trim history if it exceeds maximum length
|
||||
if (this.app.state.messageHistory.length > this.maxHistoryLength) {
|
||||
this.app.state.messageHistory = this.app.state.messageHistory.slice(-this.maxHistoryLength);
|
||||
}
|
||||
|
||||
// Save to local storage
|
||||
this.saveChatHistory();
|
||||
}
|
||||
|
||||
getRecentHistory() {
|
||||
const contextMemory = this.app.getConfig('contextMemory');
|
||||
return this.app.state.messageHistory.slice(-contextMemory);
|
||||
}
|
||||
|
||||
generateCacheKey(message) {
|
||||
const imageData = this.app.getState('currentImageData');
|
||||
return `${message}_${imageData?.substring(0, 100) || ''}`;
|
||||
}
|
||||
|
||||
saveChatHistory() {
|
||||
localStorage.setItem('chatHistory', JSON.stringify({
|
||||
messages: this.app.state.messageHistory,
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
|
||||
loadChatHistory() {
|
||||
const saved = localStorage.getItem('chatHistory');
|
||||
if (saved) {
|
||||
const { messages, timestamp } = JSON.parse(saved);
|
||||
|
||||
// Only load history if it's less than 24 hours old
|
||||
if (Date.now() - timestamp < 24 * 60 * 60 * 1000) {
|
||||
this.app.state.messageHistory = messages;
|
||||
|
||||
// Rebuild message UI
|
||||
messages.forEach(msg => {
|
||||
this.addUserMessage(msg.user);
|
||||
this.addAssistantMessage(msg.assistant);
|
||||
});
|
||||
} else {
|
||||
localStorage.removeItem('chatHistory');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* Performance Monitor Module
|
||||
* Handles performance tracking, metrics, and monitoring
|
||||
*/
|
||||
|
||||
export default class PerformanceMonitor {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.isMonitoring = false;
|
||||
this.monitoringInterval = null;
|
||||
this.metrics = {
|
||||
fps: 0,
|
||||
memory: 0,
|
||||
cpu: 0,
|
||||
lastFrameTime: 0,
|
||||
frameCount: 0,
|
||||
frameHistory: [],
|
||||
memoryHistory: [],
|
||||
responseTimeHistory: [],
|
||||
imageProcessingTimes: []
|
||||
};
|
||||
|
||||
// Configuration
|
||||
this.config = {
|
||||
updateInterval: 1000, // Update interval in ms
|
||||
historyLength: 60, // Number of data points to keep
|
||||
fpsTarget: 30, // Target FPS
|
||||
memoryThreshold: 90, // Memory usage warning threshold (%)
|
||||
responseTimeThreshold: 2000 // Response time warning threshold (ms)
|
||||
};
|
||||
|
||||
// Performance marks
|
||||
this.marks = new Map();
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.setupEventListeners();
|
||||
this.checkBrowserSupport();
|
||||
|
||||
// Initialize performance buffer if available
|
||||
if ('performance' in window) {
|
||||
performance.clearMarks();
|
||||
performance.clearMeasures();
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Performance monitor toggle button
|
||||
const perfBtn = document.getElementById('perfBtn');
|
||||
if (perfBtn) {
|
||||
perfBtn.addEventListener('click', () => this.toggleMonitoring());
|
||||
}
|
||||
|
||||
// Monitor specific events
|
||||
window.addEventListener('blur', () => this.pauseMonitoring());
|
||||
window.addEventListener('focus', () => this.resumeMonitoring());
|
||||
}
|
||||
|
||||
checkBrowserSupport() {
|
||||
this.hasPerformanceAPI = 'performance' in window;
|
||||
this.hasMemoryAPI = 'memory' in performance;
|
||||
this.hasResourceTiming = 'resourceTimingBufferSize' in performance;
|
||||
|
||||
if (!this.hasPerformanceAPI) {
|
||||
console.warn('Performance API not supported');
|
||||
}
|
||||
}
|
||||
|
||||
startMonitoring() {
|
||||
if (this.isMonitoring) return;
|
||||
|
||||
this.isMonitoring = true;
|
||||
this.monitoringInterval = setInterval(() => {
|
||||
this.updateMetrics();
|
||||
this.updateUI();
|
||||
}, this.config.updateInterval);
|
||||
|
||||
// Start frame counting
|
||||
this.startFrameTracking();
|
||||
|
||||
// Show performance monitor
|
||||
document.getElementById('performanceMonitor').classList.add('show');
|
||||
|
||||
this.app.notifications.show('📊 Performance monitoring started');
|
||||
}
|
||||
|
||||
stopMonitoring() {
|
||||
if (!this.isMonitoring) return;
|
||||
|
||||
this.isMonitoring = false;
|
||||
clearInterval(this.monitoringInterval);
|
||||
this.monitoringInterval = null;
|
||||
|
||||
// Stop frame tracking
|
||||
this.stopFrameTracking();
|
||||
|
||||
// Hide performance monitor
|
||||
document.getElementById('performanceMonitor').classList.remove('show');
|
||||
|
||||
this.app.notifications.show('📊 Performance monitoring stopped');
|
||||
}
|
||||
|
||||
toggleMonitoring() {
|
||||
if (this.isMonitoring) {
|
||||
this.stopMonitoring();
|
||||
} else {
|
||||
this.startMonitoring();
|
||||
}
|
||||
}
|
||||
|
||||
pauseMonitoring() {
|
||||
if (this.isMonitoring) {
|
||||
clearInterval(this.monitoringInterval);
|
||||
this.monitoringInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
resumeMonitoring() {
|
||||
if (this.isMonitoring && !this.monitoringInterval) {
|
||||
this.monitoringInterval = setInterval(() => {
|
||||
this.updateMetrics();
|
||||
this.updateUI();
|
||||
}, this.config.updateInterval);
|
||||
}
|
||||
}
|
||||
|
||||
startFrameTracking() {
|
||||
let lastTime = performance.now();
|
||||
|
||||
const trackFrame = () => {
|
||||
const now = performance.now();
|
||||
const delta = now - lastTime;
|
||||
|
||||
this.metrics.frameCount++;
|
||||
this.metrics.fps = Math.round(1000 / delta);
|
||||
|
||||
// Keep track of frame times
|
||||
this.metrics.frameHistory.push({
|
||||
timestamp: now,
|
||||
fps: this.metrics.fps
|
||||
});
|
||||
|
||||
// Limit history length
|
||||
if (this.metrics.frameHistory.length > this.config.historyLength) {
|
||||
this.metrics.frameHistory.shift();
|
||||
}
|
||||
|
||||
lastTime = now;
|
||||
|
||||
if (this.isMonitoring) {
|
||||
requestAnimationFrame(trackFrame);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(trackFrame);
|
||||
}
|
||||
|
||||
stopFrameTracking() {
|
||||
this.metrics.frameCount = 0;
|
||||
this.metrics.fps = 0;
|
||||
this.metrics.frameHistory = [];
|
||||
}
|
||||
|
||||
updateMetrics() {
|
||||
// Update memory usage if available
|
||||
if (this.hasMemoryAPI) {
|
||||
const memory = performance.memory;
|
||||
this.metrics.memory = Math.round(
|
||||
(memory.usedJSHeapSize / memory.jsHeapSizeLimit) * 100
|
||||
);
|
||||
|
||||
this.metrics.memoryHistory.push({
|
||||
timestamp: Date.now(),
|
||||
value: this.metrics.memory
|
||||
});
|
||||
|
||||
if (this.metrics.memoryHistory.length > this.config.historyLength) {
|
||||
this.metrics.memoryHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Check for performance issues
|
||||
this.checkPerformanceIssues();
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
if (!this.isMonitoring) return;
|
||||
|
||||
// Update FPS counter
|
||||
const fpsCounter = document.getElementById('fpsCounter');
|
||||
if (fpsCounter) {
|
||||
fpsCounter.textContent = this.metrics.fps;
|
||||
fpsCounter.style.color = this.metrics.fps < this.config.fpsTarget
|
||||
? 'var(--error-color)'
|
||||
: 'inherit';
|
||||
}
|
||||
|
||||
// Update memory usage
|
||||
const memoryUsage = document.getElementById('memoryUsage');
|
||||
if (memoryUsage && this.hasMemoryAPI) {
|
||||
const usedMB = Math.round(performance.memory.usedJSHeapSize / 1048576);
|
||||
memoryUsage.textContent = usedMB;
|
||||
memoryUsage.style.color = this.metrics.memory > this.config.memoryThreshold
|
||||
? 'var(--error-color)'
|
||||
: 'inherit';
|
||||
}
|
||||
}
|
||||
|
||||
checkPerformanceIssues() {
|
||||
// Check FPS
|
||||
if (this.metrics.fps < this.config.fpsTarget) {
|
||||
console.warn(`Low FPS detected: ${this.metrics.fps}`);
|
||||
}
|
||||
|
||||
// Check memory usage
|
||||
if (this.metrics.memory > this.config.memoryThreshold) {
|
||||
console.warn(`High memory usage: ${this.metrics.memory}%`);
|
||||
}
|
||||
|
||||
// Check response times
|
||||
const avgResponseTime = this.getAverageResponseTime();
|
||||
if (avgResponseTime > this.config.responseTimeThreshold) {
|
||||
console.warn(`High average response time: ${avgResponseTime}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
// Performance marking and measuring
|
||||
mark(name) {
|
||||
if (this.hasPerformanceAPI) {
|
||||
const mark = `${name}_${Date.now()}`;
|
||||
performance.mark(mark);
|
||||
this.marks.set(name, mark);
|
||||
}
|
||||
}
|
||||
|
||||
measure(name) {
|
||||
if (this.hasPerformanceAPI && this.marks.has(name)) {
|
||||
const startMark = this.marks.get(name);
|
||||
const measureName = `${name}_measure`;
|
||||
performance.measure(measureName, startMark);
|
||||
|
||||
const duration = performance.getEntriesByName(measureName)[0].duration;
|
||||
|
||||
// Clean up
|
||||
performance.clearMarks(startMark);
|
||||
performance.clearMeasures(measureName);
|
||||
|
||||
return duration;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
recordResponseTime(duration) {
|
||||
this.metrics.responseTimeHistory.push({
|
||||
timestamp: Date.now(),
|
||||
value: duration
|
||||
});
|
||||
|
||||
if (this.metrics.responseTimeHistory.length > this.config.historyLength) {
|
||||
this.metrics.responseTimeHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
recordImageProcessingTime(duration) {
|
||||
this.metrics.imageProcessingTimes.push({
|
||||
timestamp: Date.now(),
|
||||
value: duration
|
||||
});
|
||||
|
||||
if (this.metrics.imageProcessingTimes.length > this.config.historyLength) {
|
||||
this.metrics.imageProcessingTimes.shift();
|
||||
}
|
||||
}
|
||||
|
||||
getAverageResponseTime() {
|
||||
if (this.metrics.responseTimeHistory.length === 0) return 0;
|
||||
|
||||
const sum = this.metrics.responseTimeHistory.reduce(
|
||||
(acc, item) => acc + item.value,
|
||||
0
|
||||
);
|
||||
return Math.round(sum / this.metrics.responseTimeHistory.length);
|
||||
}
|
||||
|
||||
getAverageImageProcessingTime() {
|
||||
if (this.metrics.imageProcessingTimes.length === 0) return 0;
|
||||
|
||||
const sum = this.metrics.imageProcessingTimes.reduce(
|
||||
(acc, item) => acc + item.value,
|
||||
0
|
||||
);
|
||||
return Math.round(sum / this.metrics.imageProcessingTimes.length);
|
||||
}
|
||||
|
||||
getPerformanceReport() {
|
||||
return {
|
||||
fps: {
|
||||
current: this.metrics.fps,
|
||||
average: this.getAverageFPS(),
|
||||
history: this.metrics.frameHistory
|
||||
},
|
||||
memory: {
|
||||
current: this.metrics.memory,
|
||||
history: this.metrics.memoryHistory
|
||||
},
|
||||
responseTimes: {
|
||||
average: this.getAverageResponseTime(),
|
||||
history: this.metrics.responseTimeHistory
|
||||
},
|
||||
imageProcessing: {
|
||||
average: this.getAverageImageProcessingTime(),
|
||||
history: this.metrics.imageProcessingTimes
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getAverageFPS() {
|
||||
if (this.metrics.frameHistory.length === 0) return 0;
|
||||
|
||||
const sum = this.metrics.frameHistory.reduce(
|
||||
(acc, item) => acc + item.fps,
|
||||
0
|
||||
);
|
||||
return Math.round(sum / this.metrics.frameHistory.length);
|
||||
}
|
||||
|
||||
clearMetrics() {
|
||||
this.metrics = {
|
||||
fps: 0,
|
||||
memory: 0,
|
||||
cpu: 0,
|
||||
lastFrameTime: 0,
|
||||
frameCount: 0,
|
||||
frameHistory: [],
|
||||
memoryHistory: [],
|
||||
responseTimeHistory: [],
|
||||
imageProcessingTimes: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Settings Manager Module
|
||||
* Handles application settings, configuration, and their persistence
|
||||
*/
|
||||
|
||||
export default class SettingsManager {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.storageKey = 'enhancedVisionChatSettings';
|
||||
this.settingsPanel = document.getElementById('settingsPanel');
|
||||
|
||||
// Default settings
|
||||
this.defaults = {
|
||||
ollamaUrl: 'http://localhost:11434',
|
||||
modelName: 'llava',
|
||||
systemPrompt: 'You are a helpful AI assistant with advanced vision capabilities.',
|
||||
contextMemory: 10,
|
||||
imageQuality: 0.8,
|
||||
timeout: 30000,
|
||||
responseStyle: 'casual',
|
||||
cameraSource: 'user',
|
||||
autoCapture: true,
|
||||
captureInterval: 10,
|
||||
smartDetection: false,
|
||||
enablePerformance: false,
|
||||
cacheResponses: true,
|
||||
theme: 'light',
|
||||
voiceMode: false
|
||||
};
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
this.setupSettingsPanel();
|
||||
this.setupEventListeners();
|
||||
await this.loadSettings();
|
||||
}
|
||||
|
||||
setupSettingsPanel() {
|
||||
// Connection settings
|
||||
this.bindInput('ollamaUrl', 'text');
|
||||
this.bindInput('modelName', 'select');
|
||||
this.bindInput('timeout', 'range', value => {
|
||||
document.getElementById('timeoutValue').textContent = value;
|
||||
return value * 1000;
|
||||
});
|
||||
|
||||
// Camera settings
|
||||
this.bindInput('autoCapture', 'checkbox');
|
||||
this.bindInput('captureInterval', 'range', value => {
|
||||
document.getElementById('intervalValue').textContent = value;
|
||||
return parseInt(value);
|
||||
});
|
||||
this.bindInput('imageQuality', 'select', value => parseFloat(value));
|
||||
this.bindInput('cameraSource', 'select');
|
||||
this.bindInput('smartDetection', 'checkbox');
|
||||
|
||||
// AI settings
|
||||
this.bindInput('systemPrompt', 'textarea');
|
||||
this.bindInput('contextMemory', 'select', value => parseInt(value));
|
||||
this.bindInput('responseStyle', 'select');
|
||||
this.bindInput('autoDescribe', 'checkbox');
|
||||
|
||||
// Performance settings
|
||||
this.bindInput('enablePerformance', 'checkbox');
|
||||
this.bindInput('cacheResponses', 'checkbox');
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Settings panel toggle
|
||||
document.getElementById('settingsBtn').addEventListener('click', () => {
|
||||
this.toggleSettings();
|
||||
});
|
||||
|
||||
document.getElementById('closeSettings').addEventListener('click', () => {
|
||||
this.hideSettings();
|
||||
});
|
||||
|
||||
// Model selection handling
|
||||
document.getElementById('modelName').addEventListener('change', (e) => {
|
||||
const customModelItem = document.getElementById('customModelItem');
|
||||
if (e.target.value === 'custom') {
|
||||
customModelItem.style.display = 'block';
|
||||
} else {
|
||||
customModelItem.style.display = 'none';
|
||||
this.set('modelName', e.target.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Custom model input
|
||||
document.getElementById('customModel').addEventListener('change', (e) => {
|
||||
this.set('modelName', e.target.value);
|
||||
});
|
||||
|
||||
// Handle clicks outside settings panel
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.settingsPanel.contains(e.target) &&
|
||||
!e.target.matches('#settingsBtn') &&
|
||||
this.settingsPanel.classList.contains('show')) {
|
||||
this.hideSettings();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
bindInput(settingKey, type, transformer = null) {
|
||||
const element = document.getElementById(settingKey);
|
||||
if (!element) return;
|
||||
|
||||
// Set initial value
|
||||
if (type === 'checkbox') {
|
||||
element.checked = this.get(settingKey);
|
||||
} else {
|
||||
element.value = this.get(settingKey);
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
element.addEventListener(type === 'range' ? 'input' : 'change', (e) => {
|
||||
let value = type === 'checkbox' ? e.target.checked : e.target.value;
|
||||
if (transformer) {
|
||||
value = transformer(value);
|
||||
}
|
||||
this.set(settingKey, value);
|
||||
});
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
try {
|
||||
const saved = localStorage.getItem(this.storageKey);
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved);
|
||||
|
||||
// Apply saved settings
|
||||
Object.entries(settings).forEach(([key, value]) => {
|
||||
this.app.setConfig(key, value);
|
||||
|
||||
// Update UI elements
|
||||
const element = document.getElementById(key);
|
||||
if (element) {
|
||||
if (element.type === 'checkbox') {
|
||||
element.checked = value;
|
||||
} else {
|
||||
element.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Special handling for theme
|
||||
if (settings.theme === 'dark') {
|
||||
this.app.ui.toggleTheme();
|
||||
}
|
||||
|
||||
// Special handling for voice mode
|
||||
if (settings.voiceMode) {
|
||||
this.app.voice.toggleVoiceMode();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
this.app.notifications.show('⚠️ Failed to load settings', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
const settings = {
|
||||
ollamaUrl: this.app.getConfig('ollamaUrl'),
|
||||
modelName: this.app.getConfig('modelName'),
|
||||
systemPrompt: this.app.getConfig('systemPrompt'),
|
||||
contextMemory: this.app.getConfig('contextMemory'),
|
||||
imageQuality: this.app.getConfig('imageQuality'),
|
||||
timeout: this.app.getConfig('timeout'),
|
||||
responseStyle: this.app.getConfig('responseStyle'),
|
||||
cameraSource: this.app.getConfig('cameraSource'),
|
||||
autoCapture: this.get('autoCapture'),
|
||||
captureInterval: this.get('captureInterval'),
|
||||
enablePerformance: this.get('enablePerformance'),
|
||||
cacheResponses: this.get('cacheResponses'),
|
||||
theme: this.app.getState('darkTheme') ? 'dark' : 'light',
|
||||
voiceMode: this.app.getState('voiceMode')
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(settings));
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error);
|
||||
this.app.notifications.show('⚠️ Failed to save settings', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
get(key) {
|
||||
const element = document.getElementById(key);
|
||||
if (!element) return this.defaults[key];
|
||||
|
||||
if (element.type === 'checkbox') {
|
||||
return element.checked;
|
||||
} else if (element.type === 'range' || element.tagName === 'SELECT') {
|
||||
return element.value;
|
||||
}
|
||||
return element.value || this.defaults[key];
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
const element = document.getElementById(key);
|
||||
if (element) {
|
||||
if (element.type === 'checkbox') {
|
||||
element.checked = value;
|
||||
} else {
|
||||
element.value = value;
|
||||
}
|
||||
}
|
||||
this.app.setConfig(key, value);
|
||||
this.saveSettings();
|
||||
this.handleSettingChange(key, value);
|
||||
}
|
||||
|
||||
handleSettingChange(key, value) {
|
||||
switch (key) {
|
||||
case 'autoCapture':
|
||||
if (value) {
|
||||
this.app.camera.startAutoCapture();
|
||||
} else {
|
||||
this.app.camera.stopAutoCapture();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'enablePerformance':
|
||||
if (value) {
|
||||
this.app.performance.startMonitoring();
|
||||
} else {
|
||||
this.app.performance.stopMonitoring();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'cameraSource':
|
||||
this.app.camera.initialize();
|
||||
break;
|
||||
|
||||
case 'cacheResponses':
|
||||
if (!value) {
|
||||
this.app.state.responseCache.clear();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
toggleSettings() {
|
||||
this.settingsPanel.classList.toggle('show');
|
||||
}
|
||||
|
||||
hideSettings() {
|
||||
this.settingsPanel.classList.remove('show');
|
||||
}
|
||||
|
||||
updateAvailableModels() {
|
||||
const select = document.getElementById('modelName');
|
||||
const customOption = select.querySelector('option[value="custom"]');
|
||||
|
||||
// Clear existing model options (except predefined ones)
|
||||
const predefinedValues = ['llava', 'llava:7b', 'llava:13b', 'llava:34b', 'bakllava', 'moondream', 'custom'];
|
||||
Array.from(select.options).forEach(option => {
|
||||
if (!predefinedValues.includes(option.value)) {
|
||||
option.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Add available models
|
||||
this.app.state.availableModels.forEach(model => {
|
||||
if (!predefinedValues.includes(model.name)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = model.name;
|
||||
option.textContent = `${model.name} (${this.formatBytes(model.size)})`;
|
||||
select.insertBefore(option, customOption);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
resetToDefaults() {
|
||||
if (confirm('Are you sure you want to reset all settings to defaults?')) {
|
||||
Object.entries(this.defaults).forEach(([key, value]) => {
|
||||
this.set(key, value);
|
||||
});
|
||||
this.app.notifications.show('🔄 Settings reset to defaults');
|
||||
}
|
||||
}
|
||||
|
||||
exportSettings() {
|
||||
const settings = {
|
||||
timestamp: new Date().toISOString(),
|
||||
settings: Object.fromEntries(
|
||||
Object.keys(this.defaults).map(key => [key, this.get(key)])
|
||||
)
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(settings, null, 2)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'vision-chat-settings.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
this.app.notifications.show('💾 Settings exported successfully');
|
||||
}
|
||||
|
||||
async importSettings(file) {
|
||||
try {
|
||||
const content = await file.text();
|
||||
const { settings } = JSON.parse(content);
|
||||
|
||||
Object.entries(settings).forEach(([key, value]) => {
|
||||
this.set(key, value);
|
||||
});
|
||||
|
||||
this.app.notifications.show('✅ Settings imported successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to import settings:', error);
|
||||
this.app.notifications.show('❌ Failed to import settings', 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* UI Manager Module
|
||||
* Handles UI state, updates, animations, and user interactions
|
||||
*/
|
||||
|
||||
export default class UIManager {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
||||
// Cache DOM elements
|
||||
this.elements = {
|
||||
statusDot: document.getElementById('statusDot'),
|
||||
statusText: document.getElementById('statusText'),
|
||||
currentModel: document.getElementById('currentModel'),
|
||||
responseTime: document.getElementById('responseTime'),
|
||||
imageCount: document.getElementById('imageCount'),
|
||||
sendBtn: document.getElementById('sendBtn'),
|
||||
themeToggle: document.getElementById('themeToggle'),
|
||||
performanceMonitor: document.getElementById('performanceMonitor'),
|
||||
messageInput: document.getElementById('messageInput'),
|
||||
captureBtn: document.getElementById('pauseBtn'),
|
||||
settingsPanel: document.getElementById('settingsPanel')
|
||||
};
|
||||
|
||||
// UI state
|
||||
this.state = {
|
||||
isSending: false,
|
||||
isDarkTheme: false,
|
||||
isSettingsOpen: false,
|
||||
isPerformanceVisible: false,
|
||||
lastNotification: null
|
||||
};
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
// Initialize UI components
|
||||
this.initializeTheme();
|
||||
this.setupResizeHandlers();
|
||||
this.setupScrollHandlers();
|
||||
this.setupAnimationHandlers();
|
||||
}
|
||||
|
||||
initializeTheme() {
|
||||
const savedTheme = localStorage.getItem('enhancedVisionChatTheme');
|
||||
if (savedTheme === 'dark') {
|
||||
this.toggleTheme();
|
||||
}
|
||||
}
|
||||
|
||||
setupResizeHandlers() {
|
||||
// Auto-resize textarea
|
||||
this.elements.messageInput.addEventListener('input', () => {
|
||||
this.adjustTextareaHeight(this.elements.messageInput);
|
||||
});
|
||||
|
||||
// Handle window resize
|
||||
window.addEventListener('resize', () => {
|
||||
this.handleResize();
|
||||
});
|
||||
}
|
||||
|
||||
setupScrollHandlers() {
|
||||
// Smooth scroll for messages container
|
||||
const messagesContainer = document.getElementById('messagesContainer');
|
||||
messagesContainer.addEventListener('scroll', () => {
|
||||
this.handleScroll(messagesContainer);
|
||||
});
|
||||
}
|
||||
|
||||
setupAnimationHandlers() {
|
||||
// Handle animation end events
|
||||
document.addEventListener('animationend', (e) => {
|
||||
if (e.target.classList.contains('notification')) {
|
||||
e.target.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
adjustTextareaHeight(textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 140) + 'px';
|
||||
}
|
||||
|
||||
handleResize() {
|
||||
// Update UI elements based on window size
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
document.body.classList.toggle('mobile', isMobile);
|
||||
|
||||
// Adjust video container aspect ratio
|
||||
const videoContainer = document.querySelector('.video-container');
|
||||
if (videoContainer) {
|
||||
const aspectRatio = isMobile ? 9/16 : 16/9;
|
||||
videoContainer.style.paddingBottom = `${(1/aspectRatio) * 100}%`;
|
||||
}
|
||||
}
|
||||
|
||||
handleScroll(container) {
|
||||
// Show/hide scroll indicator
|
||||
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 100;
|
||||
container.classList.toggle('show-scroll-indicator', !isNearBottom);
|
||||
}
|
||||
|
||||
updateConnectionStatus(status) {
|
||||
this.elements.statusDot.className = 'status-dot';
|
||||
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
this.elements.statusDot.classList.add('connected');
|
||||
this.elements.statusText.textContent = 'Connected & Ready';
|
||||
break;
|
||||
case 'connecting':
|
||||
this.elements.statusText.textContent = 'Connecting...';
|
||||
break;
|
||||
case 'warning':
|
||||
this.elements.statusDot.classList.add('warning');
|
||||
this.elements.statusText.textContent = 'Model Warning';
|
||||
break;
|
||||
default:
|
||||
this.elements.statusText.textContent = 'Disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
updateModelInfo(model) {
|
||||
this.elements.currentModel.textContent = model;
|
||||
}
|
||||
|
||||
updatePerformanceStats(fps, memory) {
|
||||
if (this.state.isPerformanceVisible) {
|
||||
document.getElementById('fpsCounter').textContent = fps;
|
||||
document.getElementById('memoryUsage').textContent = memory;
|
||||
}
|
||||
}
|
||||
|
||||
setSendingState(sending) {
|
||||
this.state.isSending = sending;
|
||||
this.elements.sendBtn.disabled = sending;
|
||||
const btnText = this.elements.sendBtn.querySelector('span');
|
||||
const btnIcon = this.elements.sendBtn.querySelector('span:last-child');
|
||||
|
||||
if (sending) {
|
||||
btnText.textContent = 'Sending...';
|
||||
btnIcon.innerHTML = '<div class="loading-spinner"></div>';
|
||||
} else {
|
||||
btnText.textContent = 'Send';
|
||||
btnIcon.textContent = '🚀';
|
||||
}
|
||||
|
||||
this.elements.messageInput.disabled = sending;
|
||||
}
|
||||
|
||||
updateCaptureButton(isCapturing) {
|
||||
const btn = this.elements.captureBtn;
|
||||
btn.classList.toggle('active', isCapturing);
|
||||
btn.innerHTML = isCapturing ? '⏸️' : '▶️';
|
||||
btn.title = isCapturing ? 'Pause Auto-capture' : 'Resume Auto-capture';
|
||||
}
|
||||
|
||||
toggleTheme() {
|
||||
this.state.isDarkTheme = !this.state.isDarkTheme;
|
||||
document.documentElement.setAttribute(
|
||||
'data-theme',
|
||||
this.state.isDarkTheme ? 'dark' : 'light'
|
||||
);
|
||||
this.elements.themeToggle.textContent = this.state.isDarkTheme ? '☀️' : '🌙';
|
||||
localStorage.setItem(
|
||||
'enhancedVisionChatTheme',
|
||||
this.state.isDarkTheme ? 'dark' : 'light'
|
||||
);
|
||||
}
|
||||
|
||||
togglePerformanceMonitor() {
|
||||
this.state.isPerformanceVisible = !this.state.isPerformanceVisible;
|
||||
this.elements.performanceMonitor.classList.toggle('show', this.state.isPerformanceVisible);
|
||||
}
|
||||
|
||||
showSettingsPanel() {
|
||||
this.state.isSettingsOpen = true;
|
||||
this.elements.settingsPanel.classList.add('show');
|
||||
}
|
||||
|
||||
hideSettingsPanel() {
|
||||
this.state.isSettingsOpen = false;
|
||||
this.elements.settingsPanel.classList.remove('show');
|
||||
}
|
||||
|
||||
handleStateChange(key, value) {
|
||||
switch (key) {
|
||||
case 'darkTheme':
|
||||
if (value !== this.state.isDarkTheme) {
|
||||
this.toggleTheme();
|
||||
}
|
||||
break;
|
||||
case 'isPerformanceVisible':
|
||||
this.togglePerformanceMonitor();
|
||||
break;
|
||||
case 'currentModel':
|
||||
this.updateModelInfo(value);
|
||||
break;
|
||||
case 'imageCount':
|
||||
this.elements.imageCount.textContent = value;
|
||||
break;
|
||||
case 'lastResponseTime':
|
||||
this.elements.responseTime.textContent = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
showLoadingIndicator(message = 'Loading...') {
|
||||
const loadingDiv = document.createElement('div');
|
||||
loadingDiv.className = 'loading-indicator';
|
||||
loadingDiv.innerHTML = `
|
||||
<div class="loading-spinner"></div>
|
||||
<span>${message}</span>
|
||||
`;
|
||||
document.body.appendChild(loadingDiv);
|
||||
return loadingDiv;
|
||||
}
|
||||
|
||||
hideLoadingIndicator(indicator) {
|
||||
if (indicator && indicator.parentNode) {
|
||||
indicator.classList.add('fade-out');
|
||||
setTimeout(() => indicator.remove(), 300);
|
||||
}
|
||||
}
|
||||
|
||||
showTooltip(element, message) {
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'tooltip';
|
||||
tooltip.textContent = message;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
tooltip.style.top = `${rect.bottom + 5}px`;
|
||||
tooltip.style.left = `${rect.left + (rect.width/2)}px`;
|
||||
|
||||
document.body.appendChild(tooltip);
|
||||
setTimeout(() => tooltip.remove(), 2000);
|
||||
}
|
||||
|
||||
shake(element) {
|
||||
element.classList.add('shake');
|
||||
element.addEventListener('animationend', () => {
|
||||
element.classList.remove('shake');
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
pulse(element) {
|
||||
element.classList.add('pulse');
|
||||
element.addEventListener('animationend', () => {
|
||||
element.classList.remove('pulse');
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
fadeIn(element) {
|
||||
element.classList.add('fade-in');
|
||||
element.addEventListener('animationend', () => {
|
||||
element.classList.remove('fade-in');
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
fadeOut(element, remove = false) {
|
||||
element.classList.add('fade-out');
|
||||
element.addEventListener('animationend', () => {
|
||||
element.classList.remove('fade-out');
|
||||
if (remove) element.remove();
|
||||
}, { once: true });
|
||||
}
|
||||
|
||||
disableInteraction() {
|
||||
document.body.classList.add('no-interaction');
|
||||
}
|
||||
|
||||
enableInteraction() {
|
||||
document.body.classList.remove('no-interaction');
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
if (window.getSelection) {
|
||||
window.getSelection().removeAllRanges();
|
||||
}
|
||||
}
|
||||
|
||||
requestFullscreen(element = document.documentElement) {
|
||||
if (element.requestFullscreen) {
|
||||
element.requestFullscreen();
|
||||
} else if (element.webkitRequestFullscreen) {
|
||||
element.webkitRequestFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
exitFullscreen() {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
isFullscreen() {
|
||||
return document.fullscreenElement !== null ||
|
||||
document.webkitFullscreenElement !== null;
|
||||
}
|
||||
|
||||
toggleFullscreen() {
|
||||
if (this.isFullscreen()) {
|
||||
this.exitFullscreen();
|
||||
} else {
|
||||
this.requestFullscreen();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Voice Manager Module
|
||||
* Handles voice recognition and speech synthesis functionality
|
||||
*/
|
||||
|
||||
export default class VoiceManager {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
this.recognition = null;
|
||||
this.synthesis = null;
|
||||
this.isListening = false;
|
||||
this.isSpeaking = false;
|
||||
this.voices = [];
|
||||
|
||||
// Voice settings
|
||||
this.settings = {
|
||||
language: 'en-US',
|
||||
continuous: false,
|
||||
interimResults: false,
|
||||
maxAlternatives: 1,
|
||||
pitch: 1,
|
||||
rate: 0.9,
|
||||
volume: 0.8,
|
||||
voiceName: null // Will be set during initialization
|
||||
};
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
await this.initializeRecognition();
|
||||
await this.initializeSynthesis();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
async initializeRecognition() {
|
||||
if (!('webkitSpeechRecognition' in window) && !('SpeechRecognition' in window)) {
|
||||
console.warn('Speech recognition not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
this.recognition = new SpeechRecognition();
|
||||
|
||||
// Configure recognition
|
||||
this.recognition.lang = this.settings.language;
|
||||
this.recognition.continuous = this.settings.continuous;
|
||||
this.recognition.interimResults = this.settings.interimResults;
|
||||
this.recognition.maxAlternatives = this.settings.maxAlternatives;
|
||||
|
||||
// Setup recognition event handlers
|
||||
this.recognition.onstart = () => this.handleRecognitionStart();
|
||||
this.recognition.onend = () => this.handleRecognitionEnd();
|
||||
this.recognition.onresult = (event) => this.handleRecognitionResult(event);
|
||||
this.recognition.onerror = (event) => this.handleRecognitionError(event);
|
||||
}
|
||||
|
||||
async initializeSynthesis() {
|
||||
if (!('speechSynthesis' in window)) {
|
||||
console.warn('Speech synthesis not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
this.synthesis = window.speechSynthesis;
|
||||
|
||||
// Wait for voices to be loaded
|
||||
if (this.synthesis.getVoices().length === 0) {
|
||||
await new Promise(resolve => {
|
||||
this.synthesis.addEventListener('voiceschanged', () => {
|
||||
this.voices = this.synthesis.getVoices();
|
||||
resolve();
|
||||
}, { once: true });
|
||||
});
|
||||
} else {
|
||||
this.voices = this.synthesis.getVoices();
|
||||
}
|
||||
|
||||
// Set default voice (prefer en-US)
|
||||
this.settings.voiceName = this.selectDefaultVoice();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Voice toggle button
|
||||
const voiceToggle = document.getElementById('voiceToggle');
|
||||
if (voiceToggle) {
|
||||
voiceToggle.addEventListener('click', () => this.toggleVoiceMode());
|
||||
}
|
||||
|
||||
// Mic button
|
||||
const micBtn = document.getElementById('micBtn');
|
||||
if (micBtn) {
|
||||
micBtn.addEventListener('click', () => this.startVoiceInput());
|
||||
}
|
||||
}
|
||||
|
||||
startVoiceInput() {
|
||||
if (!this.recognition) {
|
||||
this.app.notifications.show('❌ Voice recognition not supported', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.isListening) {
|
||||
this.stopVoiceInput();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.recognition.start();
|
||||
this.updateMicButton(true);
|
||||
this.app.notifications.show('🎤 Listening... Speak now!');
|
||||
} catch (error) {
|
||||
console.error('Failed to start voice recognition:', error);
|
||||
this.app.notifications.show('❌ Failed to start voice input', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
stopVoiceInput() {
|
||||
if (this.recognition && this.isListening) {
|
||||
this.recognition.stop();
|
||||
this.updateMicButton(false);
|
||||
}
|
||||
}
|
||||
|
||||
handleRecognitionStart() {
|
||||
this.isListening = true;
|
||||
this.updateMicButton(true);
|
||||
}
|
||||
|
||||
handleRecognitionEnd() {
|
||||
this.isListening = false;
|
||||
this.updateMicButton(false);
|
||||
}
|
||||
|
||||
handleRecognitionResult(event) {
|
||||
const transcript = event.results[0][0].transcript;
|
||||
this.app.messageInput.value = transcript;
|
||||
this.app.messageInput.focus();
|
||||
this.app.ui.adjustTextareaHeight(this.app.messageInput);
|
||||
|
||||
// Auto-send if confidence is high
|
||||
if (event.results[0][0].confidence > 0.8) {
|
||||
this.app.messages.sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
handleRecognitionError(event) {
|
||||
console.error('Speech recognition error:', event.error);
|
||||
this.app.notifications.show('❌ Voice recognition error: ' + event.error, 'error');
|
||||
this.updateMicButton(false);
|
||||
}
|
||||
|
||||
updateMicButton(isActive) {
|
||||
const micBtn = document.getElementById('micBtn');
|
||||
if (micBtn) {
|
||||
micBtn.style.color = isActive ? 'var(--error-color)' : '';
|
||||
micBtn.innerHTML = isActive ? '🎙️' : '🎤';
|
||||
micBtn.classList.toggle('active', isActive);
|
||||
}
|
||||
}
|
||||
|
||||
speakText(text, options = {}) {
|
||||
if (!this.synthesis) {
|
||||
console.warn('Speech synthesis not supported');
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any ongoing speech
|
||||
this.synthesis.cancel();
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text);
|
||||
|
||||
// Apply settings and custom options
|
||||
utterance.voice = this.getVoice(options.voiceName || this.settings.voiceName);
|
||||
utterance.pitch = options.pitch || this.settings.pitch;
|
||||
utterance.rate = options.rate || this.settings.rate;
|
||||
utterance.volume = options.volume || this.settings.volume;
|
||||
|
||||
// Handle events
|
||||
utterance.onstart = () => {
|
||||
this.isSpeaking = true;
|
||||
this.app.notifications.show('🔊 Speaking...', 'info');
|
||||
};
|
||||
|
||||
utterance.onend = () => {
|
||||
this.isSpeaking = false;
|
||||
};
|
||||
|
||||
utterance.onerror = (event) => {
|
||||
console.error('Speech synthesis error:', event);
|
||||
this.app.notifications.show('❌ Speech synthesis error', 'error');
|
||||
this.isSpeaking = false;
|
||||
};
|
||||
|
||||
this.synthesis.speak(utterance);
|
||||
}
|
||||
|
||||
stopSpeaking() {
|
||||
if (this.synthesis && this.isSpeaking) {
|
||||
this.synthesis.cancel();
|
||||
this.isSpeaking = false;
|
||||
}
|
||||
}
|
||||
|
||||
toggleVoiceMode() {
|
||||
this.app.setState('voiceMode', !this.app.getState('voiceMode'));
|
||||
const btn = document.getElementById('voiceToggle');
|
||||
|
||||
if (this.app.getState('voiceMode')) {
|
||||
btn.innerHTML = '🔊 Voice ON';
|
||||
btn.style.background = 'var(--success-color)';
|
||||
btn.style.color = 'white';
|
||||
this.app.notifications.show('🎤 Voice mode enabled');
|
||||
} else {
|
||||
btn.innerHTML = '🎤 Voice';
|
||||
btn.style.background = '';
|
||||
btn.style.color = '';
|
||||
this.app.notifications.show('🔇 Voice mode disabled');
|
||||
this.stopSpeaking();
|
||||
}
|
||||
}
|
||||
|
||||
getVoice(name) {
|
||||
return this.voices.find(voice => voice.name === name) ||
|
||||
this.voices.find(voice => voice.lang.startsWith('en')) ||
|
||||
this.voices[0];
|
||||
}
|
||||
|
||||
selectDefaultVoice() {
|
||||
// Prefer English voices, with priority for specific ones
|
||||
const preferredVoices = [
|
||||
'Google US English',
|
||||
'Alex',
|
||||
'Samantha',
|
||||
'Microsoft David'
|
||||
];
|
||||
|
||||
for (const name of preferredVoices) {
|
||||
const voice = this.voices.find(v => v.name === name);
|
||||
if (voice) return voice.name;
|
||||
}
|
||||
|
||||
// Fall back to any English voice
|
||||
const englishVoice = this.voices.find(voice => voice.lang.startsWith('en'));
|
||||
return englishVoice ? englishVoice.name : this.voices[0]?.name;
|
||||
}
|
||||
|
||||
speakMessage(button) {
|
||||
if (!this.synthesis) return;
|
||||
|
||||
const message = button.closest('.message').querySelector('div').textContent;
|
||||
this.speakText(message);
|
||||
}
|
||||
|
||||
isVoiceSupported() {
|
||||
return ('webkitSpeechRecognition' in window) ||
|
||||
('SpeechRecognition' in window) ||
|
||||
('speechSynthesis' in window);
|
||||
}
|
||||
|
||||
setVoiceSettings(settings) {
|
||||
Object.assign(this.settings, settings);
|
||||
|
||||
if (this.recognition) {
|
||||
this.recognition.lang = this.settings.language;
|
||||
this.recognition.continuous = this.settings.continuous;
|
||||
this.recognition.interimResults = this.settings.interimResults;
|
||||
this.recognition.maxAlternatives = this.settings.maxAlternatives;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Utility Functions Module
|
||||
* Provides common utility functions used throughout the application
|
||||
*/
|
||||
|
||||
// Timing Functions
|
||||
export const debounce = (func, wait = 300) => {
|
||||
let timeout;
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout);
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, wait);
|
||||
};
|
||||
};
|
||||
|
||||
export const throttle = (func, limit = 300) => {
|
||||
let inThrottle;
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// UUID Generation
|
||||
export const generateUUID = () => {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = Math.random() * 16 | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||||
return v.toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
// Data Formatting
|
||||
export const formatBytes = (bytes, decimals = 2) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export const formatDuration = (ms) => {
|
||||
if (ms < 1000) return ms + 'ms';
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
};
|
||||
|
||||
export const formatDate = (date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
// Data Validation
|
||||
export const isValidUrl = (string) => {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isValidJSON = (str) => {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const isBase64Image = (str) => {
|
||||
if (!str?.startsWith('data:image/')) return false;
|
||||
try {
|
||||
return btoa(atob(str.split(',')[1])) === str.split(',')[1];
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// DOM Utilities
|
||||
export const createElement = (tag, attributes = {}, children = []) => {
|
||||
const element = document.createElement(tag);
|
||||
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
if (key === 'style' && typeof value === 'object') {
|
||||
Object.assign(element.style, value);
|
||||
} else if (key.startsWith('on') && typeof value === 'function') {
|
||||
element.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
} else {
|
||||
element.setAttribute(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
children.forEach(child => {
|
||||
if (typeof child === 'string') {
|
||||
element.appendChild(document.createTextNode(child));
|
||||
} else {
|
||||
element.appendChild(child);
|
||||
}
|
||||
});
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
export const removeElement = (element) => {
|
||||
if (element && element.parentNode) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
};
|
||||
|
||||
// Array and Object Utilities
|
||||
export const deepClone = (obj) => {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
if (obj instanceof Date) return new Date(obj);
|
||||
if (obj instanceof Array) return obj.map(item => deepClone(item));
|
||||
if (obj instanceof Object) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([key, value]) => [key, deepClone(value)])
|
||||
);
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
export const deepMerge = (target, ...sources) => {
|
||||
if (!sources.length) return target;
|
||||
const source = sources.shift();
|
||||
|
||||
if (isObject(target) && isObject(source)) {
|
||||
for (const key in source) {
|
||||
if (isObject(source[key])) {
|
||||
if (!target[key]) Object.assign(target, { [key]: {} });
|
||||
deepMerge(target[key], source[key]);
|
||||
} else {
|
||||
Object.assign(target, { [key]: source[key] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return deepMerge(target, ...sources);
|
||||
};
|
||||
|
||||
// Type Checking
|
||||
export const isObject = (item) => {
|
||||
return item && typeof item === 'object' && !Array.isArray(item);
|
||||
};
|
||||
|
||||
// Browser and Device Detection
|
||||
export const getBrowserInfo = () => {
|
||||
const ua = navigator.userAgent;
|
||||
let browser = 'Unknown';
|
||||
let version = 'Unknown';
|
||||
|
||||
// Browser detection
|
||||
if (ua.includes('Firefox/')) {
|
||||
browser = 'Firefox';
|
||||
version = ua.split('Firefox/')[1];
|
||||
} else if (ua.includes('Chrome/')) {
|
||||
browser = 'Chrome';
|
||||
version = ua.split('Chrome/')[1].split(' ')[0];
|
||||
} else if (ua.includes('Safari/')) {
|
||||
browser = 'Safari';
|
||||
version = ua.split('Version/')[1].split(' ')[0];
|
||||
} else if (ua.includes('Edge/')) {
|
||||
browser = 'Edge';
|
||||
version = ua.split('Edge/')[1];
|
||||
}
|
||||
|
||||
return { browser, version };
|
||||
};
|
||||
|
||||
export const getDeviceType = () => {
|
||||
const ua = navigator.userAgent;
|
||||
if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) {
|
||||
return 'tablet';
|
||||
}
|
||||
if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) {
|
||||
return 'mobile';
|
||||
}
|
||||
return 'desktop';
|
||||
};
|
||||
|
||||
// Error Handling
|
||||
export const handleError = (error, context = '') => {
|
||||
console.error(`Error in ${context}:`, error);
|
||||
|
||||
if (error instanceof TypeError) {
|
||||
return 'A type error occurred. Please check your input.';
|
||||
}
|
||||
if (error instanceof ReferenceError) {
|
||||
return 'A reference error occurred. Please try again.';
|
||||
}
|
||||
if (error instanceof NetworkError) {
|
||||
return 'A network error occurred. Please check your connection.';
|
||||
}
|
||||
|
||||
return error.message || 'An unknown error occurred.';
|
||||
};
|
||||
|
||||
// Local Storage Utilities
|
||||
export const storage = {
|
||||
set: (key, value) => {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error saving to localStorage:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
get: (key, defaultValue = null) => {
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : defaultValue;
|
||||
} catch (error) {
|
||||
console.error('Error reading from localStorage:', error);
|
||||
return defaultValue;
|
||||
}
|
||||
},
|
||||
|
||||
remove: (key) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error removing from localStorage:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
clear: () => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error clearing localStorage:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Color Utilities
|
||||
export const hexToRgb = (hex) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result ? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16)
|
||||
} : null;
|
||||
};
|
||||
|
||||
export const rgbToHex = (r, g, b) => {
|
||||
return '#' + [r, g, b].map(x => {
|
||||
const hex = x.toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
// Performance Utilities
|
||||
export const measureExecutionTime = async (callback) => {
|
||||
const start = performance.now();
|
||||
const result = await callback();
|
||||
const end = performance.now();
|
||||
return {
|
||||
result,
|
||||
duration: end - start
|
||||
};
|
||||
};
|
||||
|
||||
// String Utilities
|
||||
export const truncate = (str, length, suffix = '...') => {
|
||||
if (str.length <= length) return str;
|
||||
return str.substring(0, length - suffix.length) + suffix;
|
||||
};
|
||||
|
||||
export const capitalize = (str) => {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
};
|
||||
|
||||
export const slugify = (str) => {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_-]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user