This commit is contained in:
Zylan
2025-02-04 20:52:02 +08:00
parent 75d6ff2c40
commit 65830eaea3
11 changed files with 2368 additions and 25 deletions

72
app.py
View File

@@ -83,7 +83,14 @@ def stream_model_response(response_generator, sid):
# Stream responses # Stream responses
for response in response_generator: for response in response_generator:
socketio.emit('claude_response', response, room=sid) # For Mathpix responses, use text_extracted event
if isinstance(response.get('content', ''), str) and 'mathpix' in response.get('model', ''):
socketio.emit('text_extracted', {
'content': response['content']
}, room=sid)
else:
# For AI model responses, use claude_response event
socketio.emit('claude_response', response, room=sid)
except Exception as e: except Exception as e:
error_msg = f"Streaming error: {str(e)}" error_msg = f"Streaming error: {str(e)}"
@@ -119,26 +126,59 @@ def handle_screenshot_request():
def handle_text_extraction(data): def handle_text_extraction(data):
try: try:
print("Starting text extraction...") print("Starting text extraction...")
image_data = data['image'] # Base64 encoded image
# Convert base64 to PIL Image # Validate input data
image_bytes = base64.b64decode(image_data) if not data or not isinstance(data, dict):
image = Image.open(BytesIO(image_bytes)) raise ValueError("Invalid request data")
if 'image' not in data:
raise ValueError("No image data provided")
image_data = data['image']
if not isinstance(image_data, str):
raise ValueError("Invalid image data format")
settings = data.get('settings', {})
if not isinstance(settings, dict):
raise ValueError("Invalid settings format")
# Temporarily disabled text extraction mathpix_key = settings.get('mathpixApiKey')
extracted_text = "Text extraction is currently disabled" if not mathpix_key:
raise ValueError("Mathpix API key is required")
# Send the extracted text back to the client try:
socketio.emit('text_extraction_response', { app_id, app_key = mathpix_key.split(':')
'success': True, if not app_id.strip() or not app_key.strip():
'text': extracted_text raise ValueError()
except ValueError:
raise ValueError("Invalid Mathpix API key format. Expected format: 'app_id:app_key'")
print("Creating Mathpix model instance...")
model = ModelFactory.create_model(
model_name='mathpix',
api_key=mathpix_key
)
print("Starting text extraction thread...")
extraction_thread = Thread(
target=stream_model_response,
args=(model.analyze_image(image_data), request.sid)
)
extraction_thread.daemon = True # Make thread daemon so it doesn't block shutdown
extraction_thread.start()
except ValueError as e:
error_msg = str(e)
print(f"Validation error: {error_msg}")
socketio.emit('text_extracted', {
'error': error_msg
}, room=request.sid) }, room=request.sid)
except Exception as e: except Exception as e:
print(f"Text extraction error: {str(e)}") error_msg = f"Text extraction error: {str(e)}"
socketio.emit('text_extraction_response', { print(f"Unexpected error: {error_msg}")
'success': False, print(f"Error details: {type(e).__name__}")
'error': f'Text extraction error: {str(e)}' socketio.emit('text_extracted', {
'error': error_msg
}, room=request.sid) }, room=request.sid)
@socketio.on('analyze_text') @socketio.on('analyze_text')

View File

@@ -3,12 +3,14 @@ from .base import BaseModel
from .claude import ClaudeModel from .claude import ClaudeModel
from .gpt4o import GPT4oModel from .gpt4o import GPT4oModel
from .deepseek import DeepSeekModel from .deepseek import DeepSeekModel
from .mathpix import MathpixModel
class ModelFactory: class ModelFactory:
_models: Dict[str, Type[BaseModel]] = { _models: Dict[str, Type[BaseModel]] = {
'claude-3-5-sonnet-20241022': ClaudeModel, 'claude-3-5-sonnet-20241022': ClaudeModel,
'gpt-4o-2024-11-20': GPT4oModel, 'gpt-4o-2024-11-20': GPT4oModel,
'deepseek-reasoner': DeepSeekModel 'deepseek-reasoner': DeepSeekModel,
'mathpix': MathpixModel
} }
@classmethod @classmethod

283
models/mathpix.py Normal file
View File

@@ -0,0 +1,283 @@
from typing import Generator, Dict, Any
import json
import requests
from .base import BaseModel
class MathpixModel(BaseModel):
"""
Mathpix OCR model for processing images containing mathematical formulas,
text, and tables.
"""
def __init__(self, api_key: str, temperature: float = 0.7, system_prompt: str = None):
"""
Initialize the Mathpix model.
Args:
api_key: Mathpix API key in format "app_id:app_key"
temperature: Not used for Mathpix but kept for BaseModel compatibility
system_prompt: Not used for Mathpix but kept for BaseModel compatibility
Raises:
ValueError: If the API key format is invalid
"""
super().__init__(api_key, temperature, system_prompt)
try:
self.app_id, self.app_key = api_key.split(':')
except ValueError:
raise ValueError("Mathpix API key must be in format 'app_id:app_key'")
self.api_url = "https://api.mathpix.com/v3/text"
self.headers = {
"app_id": self.app_id,
"app_key": self.app_key,
"Content-Type": "application/json"
}
# Content type presets
self.presets = {
"math": {
"formats": ["latex_normal", "latex_styled", "asciimath"],
"data_options": {
"include_asciimath": True,
"include_latex": True,
"include_mathml": True
},
"ocr_options": {
"detect_formulas": True,
"enable_math_ocr": True,
"enable_handwritten": True,
"rm_spaces": True
}
},
"text": {
"formats": ["text"],
"data_options": {
"include_latex": False,
"include_asciimath": False
},
"ocr_options": {
"enable_spell_check": True,
"enable_handwritten": True,
"rm_spaces": False
}
},
"table": {
"formats": ["text", "data"],
"data_options": {
"include_latex": True
},
"ocr_options": {
"detect_tables": True,
"enable_spell_check": True,
"rm_spaces": True
}
}
}
# Default to math preset
self.current_preset = "math"
def analyze_image(self, image_data: str, proxies: dict = None, content_type: str = None,
confidence_threshold: float = 0.8, max_retries: int = 3) -> Generator[dict, None, None]:
"""
Analyze an image using Mathpix OCR API.
Args:
image_data: Base64 encoded image data
proxies: Optional proxy configuration
content_type: Type of content to analyze ('math', 'text', or 'table')
confidence_threshold: Minimum confidence score to accept (0.0 to 1.0)
max_retries: Maximum number of retry attempts for failed requests
Yields:
dict: Response chunks with status and content
"""
if content_type and content_type in self.presets:
self.current_preset = content_type
preset = self.presets[self.current_preset]
try:
# Prepare request payload
payload = {
"src": f"data:image/jpeg;base64,{image_data}",
"formats": preset["formats"],
"data_options": preset["data_options"],
"ocr_options": preset["ocr_options"]
}
# Initialize retry counter
retry_count = 0
while retry_count < max_retries:
try:
# Send request to Mathpix API with timeout
response = requests.post(
self.api_url,
headers=self.headers,
json=payload,
proxies=proxies,
timeout=25 # 25 second timeout
)
# Handle specific API error codes
if response.status_code == 429: # Rate limit exceeded
if retry_count < max_retries - 1:
retry_count += 1
continue
else:
raise requests.exceptions.RequestException("Rate limit exceeded")
response.raise_for_status()
result = response.json()
# Check confidence threshold
if 'confidence' in result and result['confidence'] < confidence_threshold:
yield {
"status": "warning",
"content": f"Low confidence score: {result['confidence']:.2%}"
}
break # Success, exit retry loop
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
if retry_count < max_retries - 1:
retry_count += 1
continue
raise
# Format the response
formatted_response = self._format_response(result)
# Yield initial status
yield {
"status": "started",
"content": ""
}
# Yield the formatted response
yield {
"status": "completed",
"content": formatted_response,
"model": self.get_model_identifier()
}
except requests.exceptions.RequestException as e:
yield {
"status": "error",
"error": f"Mathpix API error: {str(e)}"
}
except Exception as e:
yield {
"status": "error",
"error": f"Error processing image: {str(e)}"
}
def analyze_text(self, text: str, proxies: dict = None) -> Generator[dict, None, None]:
"""
Not implemented for Mathpix model as it only processes images.
"""
yield {
"status": "error",
"error": "Text analysis is not supported by Mathpix model"
}
def get_default_system_prompt(self) -> str:
"""
Not used for Mathpix model.
"""
return ""
def get_model_identifier(self) -> str:
"""
Return the model identifier.
"""
return "mathpix"
def validate_api_key(self) -> bool:
"""
Validate if the API key is in the correct format (app_id:app_key).
"""
try:
app_id, app_key = self.api_key.split(':')
return bool(app_id.strip() and app_key.strip())
except ValueError:
return False
def _format_response(self, result: Dict[str, Any]) -> str:
"""
Format the Mathpix API response into a readable string.
Args:
result: Raw API response from Mathpix
Returns:
str: Formatted response string with all available formats
"""
formatted_parts = []
# Add confidence score if available
if 'confidence' in result:
formatted_parts.append(f"Confidence: {result['confidence']:.2%}\n")
# Add text content
if 'text' in result:
formatted_parts.append("Text Content:")
formatted_parts.append(result['text'])
formatted_parts.append("")
# Add LaTeX content
if 'latex_normal' in result:
formatted_parts.append("LaTeX (Normal):")
formatted_parts.append(result['latex_normal'])
formatted_parts.append("")
if 'latex_styled' in result:
formatted_parts.append("LaTeX (Styled):")
formatted_parts.append(result['latex_styled'])
formatted_parts.append("")
# Add data formats (ASCII math, MathML)
if 'data' in result and isinstance(result['data'], list):
for item in result['data']:
item_type = item.get('type', '')
if item_type and 'value' in item:
formatted_parts.append(f"{item_type.upper()}:")
formatted_parts.append(item['value'])
formatted_parts.append("")
# Add table data if present
if 'tables' in result and result['tables']:
formatted_parts.append("Tables Detected:")
for i, table in enumerate(result['tables'], 1):
formatted_parts.append(f"Table {i}:")
if 'cells' in table:
# Format table as a grid
cells = table['cells']
if cells:
max_col = max(cell.get('col', 0) for cell in cells) + 1
max_row = max(cell.get('row', 0) for cell in cells) + 1
grid = [['' for _ in range(max_col)] for _ in range(max_row)]
for cell in cells:
row = cell.get('row', 0)
col = cell.get('col', 0)
text = cell.get('text', '')
grid[row][col] = text
# Format grid as table
col_widths = [max(len(str(grid[r][c])) for r in range(max_row)) for c in range(max_col)]
for row in grid:
row_str = ' | '.join(f"{str(cell):<{width}}" for cell, width in zip(row, col_widths))
formatted_parts.append(f"| {row_str} |")
formatted_parts.append("")
# Add error message if present
if 'error' in result:
error_msg = result['error']
if isinstance(error_msg, dict):
error_msg = error_msg.get('message', str(error_msg))
formatted_parts.append(f"Error: {error_msg}")
return "\n".join(formatted_parts).strip()

146
static/base.css Normal file
View File

@@ -0,0 +1,146 @@
/* CSS Variables */
:root {
--primary-color: #007bff;
--primary-color-rgb: 0, 123, 255;
--bg-primary: #ffffff;
--bg-secondary: #f8f9fa;
--bg-tertiary: #ffffff;
--bg-input: #ffffff;
--bg-button-secondary: #e9ecef;
--bg-toast: #333333;
--bg-toast-error: #dc3545;
--text-primary: #212529;
--text-secondary: #6c757d;
--text-toast: #ffffff;
--border-color: #dee2e6;
--status-idle: #6c757d;
--status-thinking: #ffc107;
--status-done: #28a745;
--error-color: #dc3545;
--highlight-color: rgba(0, 123, 255, 0.1);
--tooltip-bg: rgba(0, 0, 0, 0.8);
--tooltip-text: #ffffff;
}
/* Dark Theme */
[data-theme="dark"] {
--bg-primary: #212529;
--bg-secondary: #343a40;
--bg-tertiary: #2b3035;
--bg-input: #1a1d20;
--bg-button-secondary: #495057;
--text-primary: #f8f9fa;
--text-secondary: #adb5bd;
--border-color: #495057;
--tooltip-bg: rgba(255, 255, 255, 0.9);
--tooltip-text: #000000;
}
/* App Container */
.app-container {
display: flex;
flex-direction: column;
height: 100vh;
margin: 0;
background-color: var(--bg-primary);
color: var(--text-primary);
}
/* Header Styles */
.app-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.header-right {
display: flex;
gap: 0.5rem;
}
/* Main Content */
.app-main {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.content-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Utility Classes */
.hidden {
display: none !important;
}
/* Animations */
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@keyframes highlight {
0% { background-color: var(--highlight-color); }
100% { background-color: transparent; }
}
/* Touch Interactions */
@media (hover: none) {
.btn-icon:active {
background-color: var(--bg-button-secondary);
}
.section-header:active {
background-color: var(--bg-tertiary);
}
}
/* Responsive Design */
@media (max-width: 768px) {
html {
font-size: 16px;
height: 100%;
overflow: hidden;
}
body {
height: 100%;
overflow: hidden;
}
.app-header {
padding: 0.5rem;
}
.app-header h1 {
font-size: 1.25rem;
}
.header-right {
gap: 0.25rem;
}
.btn-icon {
padding: 0.75rem;
}
}

717
static/components.css Normal file
View File

@@ -0,0 +1,717 @@
/* Capture Section */
.capture-section {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem;
gap: 1rem;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
}
.toolbar-buttons {
display: flex;
gap: 1rem;
}
/* Image Preview */
.image-preview {
flex: 1;
display: flex;
flex-direction: column;
gap: 1rem;
overflow: hidden;
}
.image-container {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
background-color: var(--bg-secondary);
border-radius: 8px;
position: relative;
min-height: 200px;
max-height: 60vh;
}
.image-container img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
touch-action: pinch-zoom;
-webkit-user-select: none;
user-select: none;
}
/* Analysis Section */
.analysis-button {
display: flex;
flex-direction: column;
gap: 1rem;
}
.text-editor {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.text-editor textarea {
width: 100%;
resize: vertical;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-secondary);
color: var(--text-primary);
}
.text-actions {
display: flex;
justify-content: space-between;
align-items: center;
}
/* Claude Panel */
.claude-panel {
position: absolute;
top: 0;
right: 0;
width: 50%;
height: 100%;
background-color: var(--bg-secondary);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.header-title {
display: flex;
align-items: center;
gap: 0.5rem;
}
.analysis-status {
display: flex;
align-items: center;
}
.status-light {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--status-idle);
}
.status-light.thinking {
background-color: var(--status-thinking);
}
.status-light.done {
background-color: var(--status-done);
}
.response-content {
flex: 1;
padding: 1rem;
overflow-y: auto;
}
/* Settings Panel */
.settings-panel {
position: absolute;
top: 0;
right: 0;
width: 400px;
height: 100%;
background-color: var(--bg-secondary);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
}
.settings-content {
flex: 1;
padding: 1rem;
overflow-y: auto;
}
.settings-section {
margin-bottom: 1.5rem;
padding: 1rem;
border-radius: 8px;
background: var(--bg-tertiary);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
margin: -0.75rem -0.75rem 0 -0.75rem;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
position: relative;
z-index: 1;
}
.section-header i {
font-size: 0.8em;
transition: transform 0.3s ease;
margin-left: 0.5rem;
}
.settings-section:not(.expanded) .section-header i {
transform: rotate(-90deg);
}
.section-content {
transition: all 0.3s ease;
overflow: hidden;
padding: 0 1rem;
}
.settings-section:not(.expanded) .section-content {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
}
.settings-section.expanded .section-content {
max-height: 2000px;
padding-top: 1rem;
padding-bottom: 1rem;
opacity: 1;
}
/* Form Elements */
.setting-group {
margin-bottom: 1rem;
}
.setting-group label {
display: block;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.setting-group label i {
color: var(--text-secondary);
cursor: help;
}
.setting-group input[type="text"],
.setting-group input[type="password"],
.setting-group input[type="number"],
.setting-group select,
.setting-group textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border-color);
border-radius: 4px;
background-color: var(--bg-input);
color: var(--text-primary);
}
.setting-group textarea {
resize: vertical;
min-height: 100px;
}
.input-group {
position: relative;
display: flex;
align-items: center;
}
.input-group.highlight {
animation: highlight 0.2s ease-in-out;
}
.input-group input {
flex: 1;
padding-right: 2.5rem;
}
.input-group .btn-icon {
position: absolute;
right: 0.5rem;
}
/* History Panel */
.history-panel {
position: absolute;
top: 0;
right: 0;
width: 300px;
height: 100%;
background-color: var(--bg-secondary);
border-left: 1px solid var(--border-color);
display: flex;
flex-direction: column;
transition: transform 0.3s ease;
}
.history-content {
flex: 1;
padding: 1rem;
overflow-y: auto;
}
.history-item {
margin-bottom: 1rem;
background: var(--bg-tertiary);
border-radius: 8px;
overflow: hidden;
transition: all 0.3s ease;
border: 1px solid var(--border-color);
}
.history-item-header {
padding: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
position: relative;
background: var(--bg-secondary);
}
.history-image {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
}
.history-image img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.history-empty {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1rem;
color: var(--text-secondary);
padding: 2rem;
}
.history-empty i {
font-size: 3rem;
opacity: 0.5;
}
/* Crop Container */
.crop-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--bg-primary);
display: flex;
flex-direction: column;
z-index: 1100;
}
.crop-wrapper {
flex: 1;
position: relative;
overflow: hidden;
background-color: var(--bg-secondary);
display: flex;
justify-content: center;
align-items: center;
}
.crop-area {
position: relative;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: var(--bg-secondary);
}
.crop-area img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* Cropper.js Customization */
.cropper-container {
direction: ltr;
font-size: 0;
line-height: 0;
position: relative;
touch-action: none;
user-select: none;
}
.cropper-wrap-box,
.cropper-canvas,
.cropper-drag-box,
.cropper-crop-box,
.cropper-modal {
bottom: 0;
left: 0;
position: absolute;
right: 0;
top: 0;
}
.cropper-wrap-box {
overflow: hidden;
}
.cropper-drag-box {
background-color: #fff;
opacity: 0;
}
.cropper-modal {
background-color: var(--bg-primary);
opacity: 0.5;
}
.cropper-view-box {
display: block;
height: 100%;
outline: 1px solid var(--primary-color);
outline-color: var(--primary-color);
overflow: hidden;
width: 100%;
}
.cropper-dashed {
border: 0 dashed #eee;
display: block;
opacity: 0.5;
position: absolute;
}
.cropper-center {
display: block;
height: 0;
left: 50%;
opacity: 0.75;
position: absolute;
top: 50%;
width: 0;
}
.cropper-center::before,
.cropper-center::after {
background-color: #eee;
content: " ";
display: block;
position: absolute;
}
.cropper-face {
background-color: #fff;
left: 0;
opacity: 0.1;
position: absolute;
top: 0;
}
.cropper-line {
background-color: var(--primary-color);
display: block;
height: 100%;
opacity: 0.1;
position: absolute;
width: 100%;
}
.cropper-point {
background-color: var(--primary-color);
height: 5px;
opacity: 0.75;
position: absolute;
width: 5px;
}
.crop-actions {
height: 80px;
padding: 1rem;
display: flex;
justify-content: center;
gap: 1rem;
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
}
.crop-actions button {
flex: 1;
max-width: 160px;
padding: 0.75rem;
font-size: 1rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
/* Toast Container */
.toast-container {
position: fixed;
bottom: 1rem;
right: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 2000;
}
.toast {
padding: 0.75rem 1rem;
border-radius: 4px;
background-color: var(--bg-toast);
color: var(--text-toast);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
animation: slideIn 0.3s ease;
}
.toast.error {
background-color: var(--bg-toast-error);
}
/* Button Styles */
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-secondary {
background-color: var(--bg-button-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-icon {
padding: 0.5rem;
background: none;
border: none;
color: var(--text-primary);
cursor: pointer;
transition: color 0.2s;
}
.btn-icon:hover {
color: var(--primary-color);
}
.button-group {
display: flex;
gap: 0.5rem;
}
/* Tooltip styles */
[title] {
position: relative;
}
[title]:hover::after {
content: attr(title);
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
padding: 0.5rem;
background: var(--tooltip-bg);
color: var(--tooltip-text);
border-radius: 4px;
font-size: 0.8em;
white-space: nowrap;
z-index: 1000;
}
/* Responsive Design for Components */
@media (max-width: 768px) {
.settings-content,
.history-content,
.response-content {
-webkit-overflow-scrolling: touch;
}
.capture-section {
padding: 0.5rem;
}
.toolbar {
flex-wrap: wrap;
}
.button-group {
width: 100%;
}
.button-group button {
flex: 1;
padding: 0.75rem;
font-size: 0.9rem;
}
.image-container {
margin: 0 -0.5rem;
border-radius: 0;
background-color: var(--bg-primary);
}
.image-container img {
width: 100%;
height: auto;
object-fit: contain;
}
.settings-panel,
.history-panel,
.claude-panel {
width: 100%;
position: fixed;
bottom: 0;
right: 0;
height: 90vh;
transform: translateY(100%);
border-top-left-radius: 16px;
border-top-right-radius: 16px;
border-left: none;
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
}
.settings-panel:not(.hidden),
.history-panel:not(.hidden),
.claude-panel:not(.hidden) {
transform: translateY(0);
}
.settings-content,
.history-content {
padding: 0.75rem;
}
.settings-section {
margin-bottom: 1rem;
padding: 0.75rem;
}
.setting-group {
margin-bottom: 0.75rem;
}
.setting-group input[type="text"],
.setting-group input[type="password"],
.setting-group input[type="number"],
.setting-group select,
.setting-group textarea {
padding: 0.75rem;
font-size: 1rem;
}
.crop-container {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--bg-primary);
z-index: 1100;
}
.crop-wrapper {
height: calc(100% - 80px);
overflow: hidden;
background-color: var(--bg-secondary);
}
.crop-area {
max-width: 100%;
max-height: 100%;
}
.crop-actions {
height: 80px;
padding: 1rem;
background-color: var(--bg-secondary);
border-top: 1px solid var(--border-color);
}
.crop-actions button {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.text-editor textarea {
padding: 0.75rem;
font-size: 1rem;
min-height: 120px;
}
.text-actions {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.text-actions button {
width: 100%;
padding: 0.75rem;
}
.toast-container {
left: 1rem;
right: 1rem;
bottom: 1rem;
}
.toast {
width: 100%;
padding: 1rem;
font-size: 0.9rem;
text-align: center;
}
}

603
static/js/core.js Normal file
View File

@@ -0,0 +1,603 @@
class SnapSolver {
constructor() {
// Initialize managers first
window.uiManager = new UIManager();
window.settingsManager = new SettingsManager();
this.initializeElements();
this.initializeState();
this.initializeConnection();
this.setupAutoScroll();
this.setupEventListeners();
}
initializeElements() {
// Capture elements
this.captureBtn = document.getElementById('captureBtn');
this.cropBtn = document.getElementById('cropBtn');
this.connectionStatus = document.getElementById('connectionStatus');
this.screenshotImg = document.getElementById('screenshotImg');
this.cropContainer = document.getElementById('cropContainer');
this.imagePreview = document.getElementById('imagePreview');
this.sendToClaudeBtn = document.getElementById('sendToClaude');
this.extractTextBtn = document.getElementById('extractText');
this.textEditor = document.getElementById('textEditor');
this.extractedText = document.getElementById('extractedText');
this.sendExtractedTextBtn = document.getElementById('sendExtractedText');
this.responseContent = document.getElementById('responseContent');
this.claudePanel = document.getElementById('claudePanel');
this.statusLight = document.querySelector('.status-light');
// Verify all elements are found
const elements = [
this.captureBtn, this.cropBtn, this.connectionStatus, this.screenshotImg,
this.cropContainer, this.imagePreview, this.sendToClaudeBtn, this.extractTextBtn,
this.textEditor, this.extractedText, this.sendExtractedTextBtn, this.responseContent,
this.claudePanel, this.statusLight
];
elements.forEach((element, index) => {
if (!element) {
console.error(`Failed to initialize element at index ${index}`);
}
});
}
initializeState() {
this.socket = null;
this.cropper = null;
this.croppedImage = null;
this.history = JSON.parse(localStorage.getItem('snapHistory') || '[]');
this.heartbeatInterval = null;
this.connectionCheckInterval = null;
this.isReconnecting = false;
this.lastConnectionAttempt = 0;
}
resetConnection() {
const now = Date.now();
const timeSinceLastAttempt = now - this.lastConnectionAttempt;
// Prevent multiple reset attempts within 2 seconds
if (timeSinceLastAttempt < 2000) {
console.log('Skipping reset - too soon since last attempt');
return;
}
console.log('Resetting connection...');
this.isReconnecting = true;
this.lastConnectionAttempt = now;
// Clear existing intervals
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (this.connectionCheckInterval) {
clearInterval(this.connectionCheckInterval);
this.connectionCheckInterval = null;
}
// Clean up existing socket
if (this.socket) {
this.socket.removeAllListeners();
this.socket.disconnect();
this.socket = null;
}
// Small delay before reconnecting
setTimeout(() => {
this.initializeConnection();
this.isReconnecting = false;
}, 100);
}
startConnectionCheck() {
// Clear any existing interval
if (this.connectionCheckInterval) {
clearInterval(this.connectionCheckInterval);
}
// Check connection status every 5 seconds
this.connectionCheckInterval = setInterval(() => {
if (!this.isReconnecting && (!this.socket || !this.socket.connected)) {
console.log('Connection check failed, attempting reset...');
this.resetConnection();
}
}, 5000);
}
initializeCropper() {
try {
// Clean up existing cropper if any
if (this.cropper) {
this.cropper.destroy();
this.cropper = null;
}
// Show crop container and prepare crop area
this.cropContainer.classList.remove('hidden');
const cropArea = document.querySelector('.crop-area');
cropArea.innerHTML = '';
// Create a new image element for cropping
const cropImage = document.createElement('img');
cropImage.src = this.screenshotImg.src;
cropArea.appendChild(cropImage);
// Initialize Cropper.js
this.cropper = new Cropper(cropImage, {
aspectRatio: NaN,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.8,
restore: false,
modal: true,
guides: true,
highlight: true,
cropBoxMovable: true,
cropBoxResizable: true,
toggleDragModeOnDblclick: false,
ready: () => {
console.log('Cropper initialized successfully');
},
error: (error) => {
console.error('Cropper initialization error:', error);
window.showToast('Failed to initialize image cropper', 'error');
}
});
} catch (error) {
console.error('Error initializing cropper:', error);
window.showToast('Failed to initialize image cropper', 'error');
}
}
setupAutoScroll() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'characterData' || mutation.type === 'childList') {
this.responseContent.scrollTo({
top: this.responseContent.scrollHeight,
behavior: 'smooth'
});
}
});
});
observer.observe(this.responseContent, {
childList: true,
characterData: true,
subtree: true
});
}
updateConnectionStatus(connected) {
if (!this.connectionStatus || !this.captureBtn) {
console.error('Required elements not initialized');
return;
}
this.connectionStatus.textContent = connected ? 'Connected' : 'Disconnected';
this.connectionStatus.className = `status ${connected ? 'connected' : 'disconnected'}`;
// Enable/disable capture button
if (this.captureBtn) {
this.captureBtn.disabled = !connected;
}
if (!connected) {
// Hide UI elements when disconnected
const elements = [
this.imagePreview,
this.cropBtn,
this.sendToClaudeBtn,
this.extractTextBtn,
this.textEditor
];
elements.forEach(element => {
if (element) {
element.classList.add('hidden');
}
});
}
}
updateStatusLight(status) {
if (!this.statusLight) return;
this.statusLight.className = 'status-light';
switch (status) {
case 'started':
case 'streaming':
this.statusLight.classList.add('processing');
break;
case 'completed':
this.statusLight.classList.add('completed');
break;
case 'error':
this.statusLight.classList.add('error');
break;
default:
break;
}
}
initializeConnection() {
// Clear any existing heartbeat interval
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
try {
// Clean up existing socket if any
if (this.socket) {
this.socket.disconnect();
this.socket = null;
}
console.log('Initializing socket connection...');
this.socket = io(window.location.origin, {
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 100, // Very fast initial reconnection
reconnectionDelayMax: 1000, // Shorter max delay
timeout: 120000,
autoConnect: true, // Enable auto-connect
transports: ['websocket'],
forceNew: true, // Force a new connection on refresh
closeOnBeforeunload: false, // Prevent auto-close on page refresh
reconnectionAttempts: Infinity, // Never stop trying to reconnect
extraHeaders: {
'X-Client-Version': '1.0'
}
});
// Setup heartbeat with monitoring
this.heartbeatInterval = setInterval(() => {
if (this.socket && this.socket.connected) {
const heartbeatTimeout = setTimeout(() => {
console.log('Heartbeat timeout, resetting connection...');
if (!this.isReconnecting) {
this.resetConnection();
}
}, 5000); // Wait 5 seconds for heartbeat response
this.socket.emit('heartbeat');
// Clear timeout when heartbeat is acknowledged
this.socket.once('heartbeat_response', () => {
clearTimeout(heartbeatTimeout);
});
}
}, 10000);
this.socket.on('connect', () => {
console.log('Connected to server');
this.updateConnectionStatus(true);
// Re-enable capture button on reconnection
if (this.captureBtn) {
this.captureBtn.disabled = false;
}
// Start connection check after successful connection
this.startConnectionCheck();
});
this.socket.on('disconnect', (reason) => {
console.log('Disconnected from server:', reason);
this.updateConnectionStatus(false);
// Always attempt to reconnect regardless of reason
console.log('Attempting reconnection...');
if (!this.socket.connected && !this.isReconnecting) {
this.resetConnection();
}
// Clean up resources but maintain reconnection ability
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
});
// Add reconnecting event handler
this.socket.on('reconnecting', (attemptNumber) => {
console.log(`Reconnection attempt ${attemptNumber}...`);
if (!this.isReconnecting) {
this.resetConnection();
}
});
// Add reconnect_failed event handler
this.socket.on('reconnect_failed', () => {
console.log('Reconnection failed, trying again...');
if (!this.isReconnecting) {
this.resetConnection();
}
});
this.socket.on('connect_error', (error) => {
console.error('Connection error:', error);
this.updateConnectionStatus(false);
window.showToast('Connection error: ' + error.message, 'error');
// Enhanced exponential backoff with jitter
const attempts = this.socket.io.backoff?.attempts || 0;
const baseDelay = 1000;
const maxDelay = 10000;
const jitter = Math.random() * 1000;
const delay = Math.min(baseDelay * Math.pow(1.5, attempts) + jitter, maxDelay);
console.log(`Scheduling reconnection attempt in ${Math.round(delay)}ms...`);
setTimeout(() => {
if (!this.socket.connected) {
console.log(`Attempting to reconnect (attempt ${attempts + 1})...`);
this.socket.connect();
}
}, delay);
});
this.socket.on('heartbeat_response', () => {
console.debug('Heartbeat acknowledged');
// Reset connection if we were in a disconnected state
if (this.connectionStatus && this.connectionStatus.textContent === 'Disconnected' && !this.isReconnecting) {
this.resetConnection();
}
});
this.socket.on('error', (error) => {
console.error('Socket error:', error);
window.showToast('Socket error occurred', 'error');
});
this.setupSocketEventHandlers();
} catch (error) {
console.error('Connection initialization error:', error);
this.updateConnectionStatus(false);
}
}
setupSocketEventHandlers() {
if (!this.socket) {
console.error('Socket not initialized');
return;
}
// Screenshot response handler
this.socket.on('screenshot_response', (data) => {
if (data.success) {
this.screenshotImg.src = `data:image/png;base64,${data.image}`;
this.imagePreview.classList.remove('hidden');
this.cropBtn.classList.remove('hidden');
this.captureBtn.disabled = false;
this.captureBtn.innerHTML = '<i class="fas fa-camera"></i><span>Capture</span>';
this.sendToClaudeBtn.classList.add('hidden');
this.extractTextBtn.classList.add('hidden');
this.textEditor.classList.add('hidden');
window.showToast('Screenshot captured successfully');
} else {
window.showToast('Failed to capture screenshot: ' + data.error, 'error');
this.captureBtn.disabled = false;
this.captureBtn.innerHTML = '<i class="fas fa-camera"></i><span>Capture</span>';
}
});
// Mathpix text extraction response handler
this.socket.on('mathpix_response', (data) => {
console.log('Received mathpix_response:', data);
this.updateStatusLight(data.status);
switch (data.status) {
case 'started':
console.log('Text extraction started');
this.extractedText.value = '';
this.extractTextBtn.disabled = true;
break;
case 'completed':
if (data.content) {
console.log('Received extracted text:', data.content);
const confidenceMatch = data.content.match(/Confidence: (\d+\.\d+)%/);
if (confidenceMatch) {
const confidence = confidenceMatch[1];
document.getElementById('confidenceDisplay').textContent = confidence + '%';
this.extractedText.value = data.content.replace(/Confidence: \d+\.\d+%\n\n/, '');
} else {
this.extractedText.value = data.content;
document.getElementById('confidenceDisplay').textContent = '';
}
this.textEditor.classList.remove('hidden');
}
this.extractTextBtn.disabled = false;
this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>Extract Text</span>';
window.showToast('Text extracted successfully');
break;
case 'error':
console.error('Text extraction error:', data.error);
const errorMessage = data.error || 'Unknown error occurred';
window.showToast('Failed to extract text: ' + errorMessage, 'error');
this.extractTextBtn.disabled = false;
this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>Extract Text</span>';
break;
default:
console.warn('Unknown mathpix response status:', data.status);
if (data.error) {
window.showToast('Text extraction failed: ' + data.error, 'error');
this.extractTextBtn.disabled = false;
this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>Extract Text</span>';
}
}
});
// AI analysis response handler
this.socket.on('claude_response', (data) => {
console.log('Received claude_response:', data);
this.updateStatusLight(data.status);
switch (data.status) {
case 'started':
console.log('AI analysis started');
this.responseContent.textContent = '';
this.sendToClaudeBtn.disabled = true;
this.sendExtractedTextBtn.disabled = true;
break;
case 'streaming':
if (data.content) {
console.log('Received AI content:', data.content);
this.responseContent.textContent += data.content;
}
break;
case 'completed':
if (data.content) {
console.log('Received final AI content:', data.content);
this.responseContent.textContent += data.content;
}
this.sendToClaudeBtn.disabled = false;
this.sendExtractedTextBtn.disabled = false;
this.addToHistory(this.croppedImage, this.responseContent.textContent);
window.showToast('Analysis completed successfully');
break;
case 'error':
console.error('AI analysis error:', data.error);
const errorMessage = data.error || 'Unknown error occurred';
this.responseContent.textContent += '\nError: ' + errorMessage;
this.sendToClaudeBtn.disabled = false;
this.sendExtractedTextBtn.disabled = false;
window.showToast('Analysis failed: ' + errorMessage, 'error');
break;
default:
console.warn('Unknown claude response status:', data.status);
if (data.error) {
this.responseContent.textContent += '\nError: ' + data.error;
this.sendToClaudeBtn.disabled = false;
this.sendExtractedTextBtn.disabled = false;
window.showToast('Unknown error occurred', 'error');
}
}
});
}
setupEventListeners() {
// Add click handler for app title
const appTitle = document.getElementById('appTitle');
if (appTitle) {
appTitle.addEventListener('click', () => {
this.resetInterface();
});
}
// Handle page visibility changes
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
console.log('Page became visible, checking connection...');
// Check connection status and reset if needed
if (this.socket && !this.socket.connected && !this.isReconnecting) {
console.log('Connection lost while page was hidden, resetting...');
this.resetConnection();
}
}
});
// Handle before unload to clean up properly
window.addEventListener('beforeunload', () => {
if (this.socket) {
console.log('Page unloading, cleaning up socket...');
// Store connection state in sessionStorage
sessionStorage.setItem('wasConnected', 'true');
this.socket.disconnect();
}
});
// Check if we need to reconnect after a page reload
if (sessionStorage.getItem('wasConnected') === 'true') {
console.log('Page reloaded, initiating immediate reconnection...');
sessionStorage.removeItem('wasConnected');
// Force an immediate connection attempt
setTimeout(() => {
if (!this.socket?.connected && !this.isReconnecting) {
this.resetConnection();
}
}, 500);
}
this.setupCaptureEvents();
this.setupCropEvents();
this.setupAnalysisEvents();
this.setupKeyboardShortcuts();
}
resetInterface() {
if (!this.captureBtn) return;
// Clear all intervals
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
if (this.connectionCheckInterval) {
clearInterval(this.connectionCheckInterval);
this.connectionCheckInterval = null;
}
// Clean up cropper if it exists
if (this.cropper) {
this.cropper.destroy();
this.cropper = null;
}
// Clean up socket if it exists
if (this.socket) {
this.socket.removeAllListeners(); // Remove all event listeners
this.socket.disconnect();
this.socket = null;
}
// Show capture button
this.captureBtn.classList.remove('hidden');
// Hide all panels
const panels = ['historyPanel', 'settingsPanel'];
panels.forEach(panelId => {
const panel = document.getElementById(panelId);
if (panel) panel.classList.add('hidden');
});
// Reset image preview and related buttons
const elements = [
this.imagePreview,
this.cropBtn,
this.sendToClaudeBtn,
this.extractTextBtn,
this.textEditor
];
elements.forEach(element => {
if (element) element.classList.add('hidden');
});
// Clear text areas
if (this.extractedText) this.extractedText.value = '';
if (this.responseContent) this.responseContent.textContent = '';
const confidenceDisplay = document.getElementById('confidenceDisplay');
if (confidenceDisplay) confidenceDisplay.textContent = '';
// Hide Claude panel
if (this.claudePanel) this.claudePanel.classList.add('hidden');
}
}
// Initialize the application
window.addEventListener('DOMContentLoaded', () => {
window.app = new SnapSolver();
});

385
static/js/events.js Normal file
View File

@@ -0,0 +1,385 @@
// Events handling extension for SnapSolver class
Object.assign(SnapSolver.prototype, {
setupCaptureEvents() {
if (!this.captureBtn) {
console.error('Capture button not initialized');
return;
}
// Capture button
this.captureBtn.addEventListener('click', async () => {
if (!this.socket) {
console.error('Socket not initialized');
window.showToast('Connection not initialized. Please refresh the page.', 'error');
return;
}
if (!this.socket.connected) {
console.error('Socket not connected');
window.showToast('Server connection lost. Attempting to reconnect...', 'error');
this.socket.connect();
return;
}
try {
this.captureBtn.disabled = true;
this.captureBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i><span>Capturing...</span>';
// Set a timeout to re-enable the button if no response is received
const timeout = setTimeout(() => {
if (this.captureBtn.disabled) {
this.captureBtn.disabled = false;
this.captureBtn.innerHTML = '<i class="fas fa-camera"></i><span>Capture</span>';
window.showToast('Screenshot capture timed out. Please try again.', 'error');
}
}, 10000);
this.socket.emit('request_screenshot', null, (error) => {
if (error) {
clearTimeout(timeout);
console.error('Screenshot error:', error);
window.showToast('Error capturing screenshot: ' + error, 'error');
this.captureBtn.disabled = false;
this.captureBtn.innerHTML = '<i class="fas fa-camera"></i><span>Capture</span>';
}
});
} catch (error) {
console.error('Capture error:', error);
window.showToast('Error requesting screenshot: ' + error.message, 'error');
this.captureBtn.disabled = false;
this.captureBtn.innerHTML = '<i class="fas fa-camera"></i><span>Capture</span>';
}
});
},
setupCropEvents() {
if (!this.cropBtn || !this.screenshotImg) {
console.error('Required elements not initialized');
return;
}
// Crop button
this.cropBtn.addEventListener('click', () => {
if (this.screenshotImg.src) {
this.initializeCropper();
}
});
// Crop confirm button
const cropConfirm = document.getElementById('cropConfirm');
if (cropConfirm) {
cropConfirm.addEventListener('click', () => {
if (this.cropper) {
try {
console.log('Starting crop operation...');
if (!this.cropper) {
throw new Error('Cropper not initialized');
}
const cropBoxData = this.cropper.getCropBoxData();
console.log('Crop box data:', cropBoxData);
if (!cropBoxData || typeof cropBoxData.width !== 'number' || typeof cropBoxData.height !== 'number') {
throw new Error('Invalid crop box data');
}
if (cropBoxData.width < 10 || cropBoxData.height < 10) {
throw new Error('Crop area is too small. Please select a larger area (minimum 10x10 pixels).');
}
console.log('Getting cropped canvas...');
const canvas = this.cropper.getCroppedCanvas({
maxWidth: 2560,
maxHeight: 1440,
fillColor: '#fff',
imageSmoothingEnabled: true,
imageSmoothingQuality: 'high',
});
if (!canvas) {
throw new Error('Failed to create cropped canvas');
}
console.log('Canvas created successfully');
console.log('Converting to data URL...');
try {
this.croppedImage = canvas.toDataURL('image/png');
console.log('Data URL conversion successful');
} catch (dataUrlError) {
console.error('Data URL conversion error:', dataUrlError);
throw new Error('Failed to process cropped image. The image might be too large or memory insufficient.');
}
if (this.cropper) {
this.cropper.destroy();
this.cropper = null;
}
this.cropContainer.classList.add('hidden');
const cropArea = document.querySelector('.crop-area');
if (cropArea) cropArea.innerHTML = '';
this.screenshotImg.src = this.croppedImage;
this.imagePreview.classList.remove('hidden');
this.cropBtn.classList.remove('hidden');
this.sendToClaudeBtn.classList.remove('hidden');
this.extractTextBtn.classList.remove('hidden');
window.showToast('Image cropped successfully');
} catch (error) {
console.error('Cropping error details:', {
message: error.message,
stack: error.stack,
cropperState: this.cropper ? 'initialized' : 'not initialized'
});
window.showToast(error.message || 'Error while cropping image', 'error');
} finally {
if (this.cropper) {
this.cropper.destroy();
this.cropper = null;
}
}
}
});
}
// Crop cancel button
const cropCancel = document.getElementById('cropCancel');
if (cropCancel) {
cropCancel.addEventListener('click', () => {
if (this.cropper) {
this.cropper.destroy();
this.cropper = null;
}
this.cropContainer.classList.add('hidden');
this.sendToClaudeBtn.classList.add('hidden');
this.extractTextBtn.classList.add('hidden');
const cropArea = document.querySelector('.crop-area');
if (cropArea) cropArea.innerHTML = '';
});
}
},
setupAnalysisEvents() {
// Set up text extraction socket event listener once
this.socket.on('text_extracted', (data) => {
if (data.error) {
console.error('Text extraction error:', data.error);
window.showToast('Failed to extract text: ' + data.error, 'error');
if (this.extractedText) {
this.extractedText.value = '';
this.extractedText.disabled = false;
}
} else if (data.content) {
if (this.extractedText) {
this.extractedText.value = data.content;
this.extractedText.disabled = false;
// Scroll to make text editor visible
this.extractedText.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
window.showToast('Text extracted successfully');
}
if (this.extractTextBtn) {
this.extractTextBtn.disabled = false;
this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>Extract Text</span>';
}
});
// Extract Text button
if (this.extractTextBtn) {
this.extractTextBtn.addEventListener('click', () => {
if (!this.croppedImage) {
window.showToast('Please crop the image first', 'error');
return;
}
const settings = window.settingsManager.getSettings();
const mathpixAppId = settings.mathpixAppId;
const mathpixAppKey = settings.mathpixAppKey;
if (!mathpixAppId || !mathpixAppKey) {
window.showToast('Please enter Mathpix credentials in settings', 'error');
const settingsPanel = document.getElementById('settingsPanel');
if (settingsPanel) settingsPanel.classList.remove('hidden');
return;
}
this.extractTextBtn.disabled = true;
this.extractTextBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i><span>Extracting...</span>';
try {
// Show text editor and prepare UI
const textEditor = document.getElementById('textEditor');
if (textEditor) {
textEditor.classList.remove('hidden');
// Scroll to make text editor visible
textEditor.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// Clear any previous text and show loading indicator
if (this.extractedText) {
this.extractedText.value = 'Extracting text...';
this.extractedText.disabled = true;
}
// Set up timeout to re-enable button if no response
const timeout = setTimeout(() => {
if (this.extractTextBtn && this.extractTextBtn.disabled) {
this.extractTextBtn.disabled = false;
this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>Extract Text</span>';
if (this.extractedText) {
this.extractedText.value = '';
this.extractedText.disabled = false;
}
window.showToast('Text extraction timed out. Please try again.', 'error');
}
}, 30000); // 30 second timeout
this.socket.emit('extract_text', {
image: this.croppedImage.split(',')[1],
settings: {
mathpixApiKey: `${mathpixAppId}:${mathpixAppKey}`
}
}, (error) => {
// Clear timeout on acknowledgement
clearTimeout(timeout);
if (error) {
console.error('Text extraction error:', error);
window.showToast('Failed to start text extraction: ' + error, 'error');
this.extractTextBtn.disabled = false;
this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>Extract Text</span>';
if (this.extractedText) {
this.extractedText.value = '';
this.extractedText.disabled = false;
}
}
});
} catch (error) {
console.error('Text extraction error:', error);
window.showToast('Failed to extract text: ' + error.message, 'error');
this.extractTextBtn.disabled = false;
this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>Extract Text</span>';
if (this.extractedText) {
this.extractedText.value = '';
this.extractedText.disabled = false;
}
}
});
}
// Send Extracted Text button
if (this.sendExtractedTextBtn && this.extractedText) {
this.sendExtractedTextBtn.addEventListener('click', () => {
const text = this.extractedText.value.trim();
if (!text) {
window.showToast('Please enter some text', 'error');
return;
}
const settings = window.settingsManager.getSettings();
const apiKey = window.settingsManager.getApiKey();
if (!apiKey) {
const settingsPanel = document.getElementById('settingsPanel');
if (settingsPanel) settingsPanel.classList.remove('hidden');
window.showToast('Please configure API key in settings', 'error');
return;
}
if (this.claudePanel) this.claudePanel.classList.remove('hidden');
if (this.responseContent) this.responseContent.textContent = '';
this.sendExtractedTextBtn.disabled = true;
try {
this.socket.emit('analyze_text', {
text: text,
settings: {
apiKey: apiKey,
model: settings.model || 'claude-3-5-sonnet-20241022',
temperature: parseFloat(settings.temperature) || 0.7,
systemPrompt: settings.systemPrompt || 'You are an expert at analyzing questions and providing detailed solutions.',
proxyEnabled: settings.proxyEnabled || false,
proxyHost: settings.proxyHost || '127.0.0.1',
proxyPort: settings.proxyPort || '4780'
}
});
} catch (error) {
console.error('Text analysis error:', error);
if (this.responseContent) {
this.responseContent.textContent = 'Error: Failed to send text for analysis - ' + error.message;
}
this.sendExtractedTextBtn.disabled = false;
window.showToast('Failed to send text for analysis', 'error');
}
});
}
// Send to Claude button
if (this.sendToClaudeBtn) {
this.sendToClaudeBtn.addEventListener('click', () => {
if (!this.croppedImage) {
window.showToast('Please crop the image first', 'error');
return;
}
const settings = window.settingsManager.getSettings();
const apiKey = window.settingsManager.getApiKey();
if (!apiKey) {
const settingsPanel = document.getElementById('settingsPanel');
if (settingsPanel) settingsPanel.classList.remove('hidden');
window.showToast('Please configure API key in settings', 'error');
return;
}
if (this.claudePanel) this.claudePanel.classList.remove('hidden');
if (this.responseContent) this.responseContent.textContent = '';
this.sendToClaudeBtn.disabled = true;
try {
this.socket.emit('analyze_image', {
image: this.croppedImage.split(',')[1],
settings: {
apiKey: apiKey,
model: settings.model || 'claude-3-5-sonnet-20241022',
temperature: parseFloat(settings.temperature) || 0.7,
systemPrompt: settings.systemPrompt || 'You are an expert at analyzing questions and providing detailed solutions.',
proxyEnabled: settings.proxyEnabled || false,
proxyHost: settings.proxyHost || '127.0.0.1',
proxyPort: settings.proxyPort || '4780'
}
});
} catch (error) {
console.error('Image analysis error:', error);
if (this.responseContent) {
this.responseContent.textContent = 'Error: Failed to send image for analysis - ' + error.message;
}
this.sendToClaudeBtn.disabled = false;
window.showToast('Failed to send image for analysis', 'error');
}
});
}
},
setupKeyboardShortcuts() {
// Keyboard shortcuts for capture and crop
document.addEventListener('keydown', (e) => {
if (e.ctrlKey || e.metaKey) {
switch(e.key) {
case 'c':
if (this.captureBtn && !this.captureBtn.disabled) {
this.captureBtn.click();
}
break;
case 'x':
if (this.cropBtn && !this.cropBtn.disabled) {
this.cropBtn.click();
}
break;
}
}
});
}
});

93
static/js/history.js Normal file
View File

@@ -0,0 +1,93 @@
// History management extension for SnapSolver class
Object.assign(SnapSolver.prototype, {
addToHistory(imageData, response) {
const historyItem = {
id: Date.now(),
timestamp: new Date().toISOString(),
image: imageData,
extractedText: this.extractedText.value || null,
response: response
};
this.history.unshift(historyItem);
if (this.history.length > 10) this.history.pop();
localStorage.setItem('snapHistory', JSON.stringify(this.history));
window.renderHistory();
}
});
// Global function for history rendering
window.renderHistory = function() {
const content = document.querySelector('.history-content');
const history = JSON.parse(localStorage.getItem('snapHistory') || '[]');
if (history.length === 0) {
content.innerHTML = `
<div class="history-empty">
<i class="fas fa-history"></i>
<p>No history yet</p>
</div>
`;
return;
}
content.innerHTML = history.map(item => `
<div class="history-item" data-id="${item.id}">
<div class="history-item-header">
<span>${new Date(item.timestamp).toLocaleString()}</span>
<button class="btn-icon delete-history" data-id="${item.id}">
<i class="fas fa-trash"></i>
</button>
</div>
<img src="${item.image}" alt="Historical screenshot" class="history-image">
</div>
`).join('');
// Add click handlers for history items
content.querySelectorAll('.delete-history').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const id = parseInt(btn.dataset.id);
const updatedHistory = history.filter(item => item.id !== id);
localStorage.setItem('snapHistory', JSON.stringify(updatedHistory));
window.renderHistory();
window.showToast('History item deleted');
});
});
content.querySelectorAll('.history-item').forEach(item => {
item.addEventListener('click', () => {
const historyItem = history.find(h => h.id === parseInt(item.dataset.id));
if (historyItem) {
// Display the image
window.app.screenshotImg.src = historyItem.image;
window.app.imagePreview.classList.remove('hidden');
document.getElementById('historyPanel').classList.add('hidden');
// Hide all action buttons in history view
window.app.cropBtn.classList.add('hidden');
window.app.captureBtn.classList.add('hidden');
window.app.sendToClaudeBtn.classList.add('hidden');
window.app.extractTextBtn.classList.add('hidden');
window.app.sendExtractedTextBtn.classList.add('hidden');
// Reset confidence display
document.getElementById('confidenceDisplay').textContent = '';
// Only show text editor if there was extracted text
if (historyItem.extractedText) {
window.app.textEditor.classList.remove('hidden');
window.app.extractedText.value = historyItem.extractedText;
} else {
window.app.textEditor.classList.add('hidden');
window.app.extractedText.value = '';
}
// Show response if it exists
if (historyItem.response) {
window.app.claudePanel.classList.remove('hidden');
window.app.responseContent.textContent = historyItem.response;
}
}
});
});
};

View File

@@ -136,14 +136,24 @@ class SnapSolver {
}); });
// Text extraction response handler // Text extraction response handler
this.socket.on('text_extraction_response', (data) => { this.socket.on('text_extracted', (data) => {
if (data.success) { if (data.error) {
this.extractedText.value = data.text; console.error('Text extraction error:', data.error);
this.textEditor.classList.remove('hidden');
window.showToast('Text extracted successfully');
} else {
window.showToast('Failed to extract text: ' + data.error, 'error'); window.showToast('Failed to extract text: ' + data.error, 'error');
if (this.extractedText) {
this.extractedText.value = '';
this.extractedText.disabled = false;
}
} else if (data.content) {
if (this.extractedText) {
this.extractedText.value = data.content;
this.extractedText.disabled = false;
// Scroll to make text editor visible
this.extractedText.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
window.showToast('Text extracted successfully');
} }
this.extractTextBtn.disabled = false; this.extractTextBtn.disabled = false;
this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>Extract Text</span>'; this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>Extract Text</span>';
}); });
@@ -405,9 +415,31 @@ class SnapSolver {
this.extractTextBtn.disabled = true; this.extractTextBtn.disabled = true;
this.extractTextBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i><span>Extracting...</span>'; this.extractTextBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i><span>Extracting...</span>';
const settings = window.settingsManager.getSettings();
const mathpixAppId = settings.mathpixAppId;
const mathpixAppKey = settings.mathpixAppKey;
if (!mathpixAppId || !mathpixAppKey) {
window.showToast('Please enter Mathpix credentials in settings', 'error');
document.getElementById('settingsPanel').classList.remove('hidden');
this.extractTextBtn.disabled = false;
this.extractTextBtn.innerHTML = '<i class="fas fa-font"></i><span>Extract Text</span>';
return;
}
// Show text editor and prepare UI
this.textEditor.classList.remove('hidden');
if (this.extractedText) {
this.extractedText.value = 'Extracting text...';
this.extractedText.disabled = true;
}
try { try {
this.socket.emit('extract_text', { this.socket.emit('extract_text', {
image: this.croppedImage.split(',')[1] image: this.croppedImage.split(',')[1],
settings: {
mathpixApiKey: `${mathpixAppId}:${mathpixAppKey}`
}
}); });
} catch (error) { } catch (error) {
window.showToast('Failed to extract text: ' + error.message, 'error'); window.showToast('Failed to extract text: ' + error.message, 'error');

View File

@@ -18,6 +18,10 @@ class SettingsManager {
this.proxyPortInput = document.getElementById('proxyPort'); this.proxyPortInput = document.getElementById('proxyPort');
this.proxySettings = document.getElementById('proxySettings'); this.proxySettings = document.getElementById('proxySettings');
// Initialize Mathpix inputs
this.mathpixAppIdInput = document.getElementById('mathpixAppId');
this.mathpixAppKeyInput = document.getElementById('mathpixAppKey');
// API Key elements // API Key elements
this.apiKeyInputs = { this.apiKeyInputs = {
'claude-3-5-sonnet-20241022': document.getElementById('claudeApiKey'), 'claude-3-5-sonnet-20241022': document.getElementById('claudeApiKey'),
@@ -47,6 +51,14 @@ class SettingsManager {
loadSettings() { loadSettings() {
const settings = JSON.parse(localStorage.getItem('aiSettings') || '{}'); const settings = JSON.parse(localStorage.getItem('aiSettings') || '{}');
// Load Mathpix credentials
if (settings.mathpixAppId) {
this.mathpixAppIdInput.value = settings.mathpixAppId;
}
if (settings.mathpixAppKey) {
this.mathpixAppKeyInput.value = settings.mathpixAppKey;
}
// Load API keys // Load API keys
if (settings.apiKeys) { if (settings.apiKeys) {
Object.entries(this.apiKeyInputs).forEach(([model, input]) => { Object.entries(this.apiKeyInputs).forEach(([model, input]) => {
@@ -89,6 +101,8 @@ class SettingsManager {
saveSettings() { saveSettings() {
const settings = { const settings = {
apiKeys: {}, apiKeys: {},
mathpixAppId: this.mathpixAppIdInput.value,
mathpixAppKey: this.mathpixAppKeyInput.value,
model: this.modelSelect.value, model: this.modelSelect.value,
temperature: this.temperatureInput.value, temperature: this.temperatureInput.value,
language: this.languageInput.value, language: this.languageInput.value,
@@ -129,7 +143,9 @@ class SettingsManager {
systemPrompt: this.systemPromptInput.value + ` Please respond in ${this.languageInput.value}.`, systemPrompt: this.systemPromptInput.value + ` Please respond in ${this.languageInput.value}.`,
proxyEnabled: this.proxyEnabledInput.checked, proxyEnabled: this.proxyEnabledInput.checked,
proxyHost: this.proxyHostInput.value, proxyHost: this.proxyHostInput.value,
proxyPort: this.proxyPortInput.value proxyPort: this.proxyPortInput.value,
mathpixAppId: this.mathpixAppIdInput.value,
mathpixAppKey: this.mathpixAppKeyInput.value
}; };
} }
@@ -139,6 +155,10 @@ class SettingsManager {
input.addEventListener('change', () => this.saveSettings()); input.addEventListener('change', () => this.saveSettings());
}); });
// Save Mathpix settings on change
this.mathpixAppIdInput.addEventListener('change', () => this.saveSettings());
this.mathpixAppKeyInput.addEventListener('change', () => this.saveSettings());
this.modelSelect.addEventListener('change', (e) => { this.modelSelect.addEventListener('change', (e) => {
this.updateVisibleApiKey(e.target.value); this.updateVisibleApiKey(e.target.value);
this.saveSettings(); this.saveSettings();

View File

@@ -98,6 +98,28 @@
</button> </button>
</div> </div>
<div class="settings-content"> <div class="settings-content">
<div class="settings-section">
<h3>OCR Configuration</h3>
<div class="setting-group">
<label for="mathpixAppId">Mathpix App ID</label>
<div class="input-group">
<input type="password" id="mathpixAppId" placeholder="Enter Mathpix App ID">
<button class="btn-icon toggle-api-key">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<div class="setting-group">
<label for="mathpixAppKey">Mathpix App Key</label>
<div class="input-group">
<input type="password" id="mathpixAppKey" placeholder="Enter Mathpix App Key">
<button class="btn-icon toggle-api-key">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
</div>
<div class="settings-section"> <div class="settings-section">
<h3>AI Configuration</h3> <h3>AI Configuration</h3>
<div class="setting-group api-key-group" data-model="claude-3-5-sonnet-20241022"> <div class="setting-group api-key-group" data-model="claude-3-5-sonnet-20241022">