464 lines
17 KiB
JavaScript
464 lines
17 KiB
JavaScript
|
|
import { useState, useEffect } from 'react';
|
||
|
|
import { aiCardOCR, ollamaCardOCR, geminiCardOCR } from '../lib/ai-ocr';
|
||
|
|
|
||
|
|
export default function OCRSettings({ isOpen, onClose }) {
|
||
|
|
const [settings, setSettings] = useState({
|
||
|
|
service: 'gemini', // Default to free Gemini
|
||
|
|
openaiApiKey: '',
|
||
|
|
geminiApiKey: '',
|
||
|
|
ollamaUrl: 'http://localhost:11434'
|
||
|
|
});
|
||
|
|
const [isTesting, setIsTesting] = useState(false);
|
||
|
|
const [testResult, setTestResult] = useState(null);
|
||
|
|
|
||
|
|
// Load settings from localStorage on mount
|
||
|
|
useEffect(() => {
|
||
|
|
const loadSettings = async () => {
|
||
|
|
const savedSettings = localStorage.getItem('ocrSettings');
|
||
|
|
let currentSettings = {
|
||
|
|
service: 'gemini',
|
||
|
|
openaiApiKey: '',
|
||
|
|
geminiApiKey: '',
|
||
|
|
ollamaUrl: 'http://localhost:11434'
|
||
|
|
};
|
||
|
|
|
||
|
|
if (savedSettings) {
|
||
|
|
try {
|
||
|
|
const parsed = JSON.parse(savedSettings);
|
||
|
|
currentSettings = { ...currentSettings, ...parsed };
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error loading OCR settings:', error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Try to auto-load Gemini API key from environment if not already set
|
||
|
|
if (!currentSettings.geminiApiKey) {
|
||
|
|
try {
|
||
|
|
const response = await fetch('/api/config/gemini');
|
||
|
|
if (response.ok) {
|
||
|
|
const data = await response.json();
|
||
|
|
if (data.hasKey && data.apiKey) {
|
||
|
|
currentSettings.geminiApiKey = data.apiKey;
|
||
|
|
currentSettings.service = 'gemini'; // Default to Gemini if key is available
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.log('Could not auto-load Gemini API key:', error);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setSettings(currentSettings);
|
||
|
|
};
|
||
|
|
|
||
|
|
loadSettings();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const saveSettings = () => {
|
||
|
|
try {
|
||
|
|
localStorage.setItem('ocrSettings', JSON.stringify(settings));
|
||
|
|
|
||
|
|
// Configure AI services
|
||
|
|
if (settings.openaiApiKey) {
|
||
|
|
aiCardOCR.setApiKey(settings.openaiApiKey);
|
||
|
|
}
|
||
|
|
if (settings.geminiApiKey) {
|
||
|
|
geminiCardOCR.setApiKey(settings.geminiApiKey);
|
||
|
|
}
|
||
|
|
if (settings.ollamaUrl) {
|
||
|
|
ollamaCardOCR.setBaseUrl(settings.ollamaUrl);
|
||
|
|
}
|
||
|
|
|
||
|
|
setTestResult({ type: 'success', message: 'Settings saved successfully!' });
|
||
|
|
setTimeout(() => setTestResult(null), 3000);
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Error saving OCR settings:', error);
|
||
|
|
setTestResult({ type: 'error', message: 'Failed to save settings' });
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const testConnection = async () => {
|
||
|
|
setIsTesting(true);
|
||
|
|
setTestResult(null);
|
||
|
|
|
||
|
|
try {
|
||
|
|
if (settings.service === 'puter') {
|
||
|
|
// Test Puter.js connection
|
||
|
|
try {
|
||
|
|
// Try to load Puter.js if not already loaded
|
||
|
|
if (typeof window !== 'undefined' && !window.puter) {
|
||
|
|
const script = document.createElement('script');
|
||
|
|
script.src = 'https://js.puter.com/v2/';
|
||
|
|
await new Promise((resolve, reject) => {
|
||
|
|
script.onload = resolve;
|
||
|
|
script.onerror = reject;
|
||
|
|
document.head.appendChild(script);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
if (window.puter && window.puter.ai) {
|
||
|
|
setTestResult({ type: 'success', message: 'Puter.js loaded successfully! Ready for free AI vision.' });
|
||
|
|
} else {
|
||
|
|
setTestResult({ type: 'error', message: 'Failed to load Puter.js AI capabilities' });
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
setTestResult({ type: 'error', message: 'Could not connect to Puter.js service' });
|
||
|
|
}
|
||
|
|
} else if (settings.service === 'openai') {
|
||
|
|
if (!settings.openaiApiKey) {
|
||
|
|
setTestResult({ type: 'error', message: 'Please enter your OpenAI API key' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Test with a simple request
|
||
|
|
const response = await fetch('https://api.openai.com/v1/models', {
|
||
|
|
headers: {
|
||
|
|
'Authorization': `Bearer ${settings.openaiApiKey}`,
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.ok) {
|
||
|
|
setTestResult({ type: 'success', message: 'OpenAI API connection successful!' });
|
||
|
|
} else {
|
||
|
|
const error = await response.json().catch(() => ({}));
|
||
|
|
setTestResult({
|
||
|
|
type: 'error',
|
||
|
|
message: `OpenAI API error: ${error.error?.message || response.statusText}`
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else if (settings.service === 'gemini') {
|
||
|
|
if (!settings.geminiApiKey) {
|
||
|
|
setTestResult({ type: 'error', message: 'Please enter your Gemini API key' });
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Test with a simple request
|
||
|
|
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models', {
|
||
|
|
headers: {
|
||
|
|
'x-goog-api-key': settings.geminiApiKey,
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
if (response.ok) {
|
||
|
|
const data = await response.json();
|
||
|
|
const hasVisionModel = data.models?.some(model =>
|
||
|
|
model.name.includes('gemini') && model.supportedGenerationMethods?.includes('generateContent')
|
||
|
|
);
|
||
|
|
|
||
|
|
if (hasVisionModel) {
|
||
|
|
setTestResult({ type: 'success', message: 'Gemini API connection successful with vision support!' });
|
||
|
|
} else {
|
||
|
|
setTestResult({
|
||
|
|
type: 'warning',
|
||
|
|
message: 'Gemini connected but vision capabilities unclear.'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
const error = await response.json().catch(() => ({}));
|
||
|
|
setTestResult({
|
||
|
|
type: 'error',
|
||
|
|
message: `Gemini API error: ${error.error?.message || response.statusText}`
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else if (settings.service === 'ollama') {
|
||
|
|
// Test Ollama connection
|
||
|
|
const response = await fetch(`${settings.ollamaUrl}/api/tags`);
|
||
|
|
|
||
|
|
if (response.ok) {
|
||
|
|
const data = await response.json();
|
||
|
|
const hasVisionModel = data.models?.some(model =>
|
||
|
|
model.name.includes('llava') || model.name.includes('vision')
|
||
|
|
);
|
||
|
|
|
||
|
|
if (hasVisionModel) {
|
||
|
|
setTestResult({ type: 'success', message: 'Ollama connection successful with vision models!' });
|
||
|
|
} else {
|
||
|
|
setTestResult({
|
||
|
|
type: 'warning',
|
||
|
|
message: 'Ollama connected but no vision models found. Install llava:latest for card scanning.'
|
||
|
|
});
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
setTestResult({ type: 'error', message: 'Could not connect to Ollama server' });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('Connection test error:', error);
|
||
|
|
setTestResult({
|
||
|
|
type: 'error',
|
||
|
|
message: `Connection failed: ${error.message}`
|
||
|
|
});
|
||
|
|
} finally {
|
||
|
|
setIsTesting(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleInputChange = (field, value) => {
|
||
|
|
setSettings(prev => ({
|
||
|
|
...prev,
|
||
|
|
[field]: value
|
||
|
|
}));
|
||
|
|
setTestResult(null);
|
||
|
|
};
|
||
|
|
|
||
|
|
if (!isOpen) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||
|
|
<div className="rounded-xl border max-w-md w-full max-h-[90vh] overflow-y-auto" style={{ backgroundColor: 'var(--bg-secondary)', borderColor: 'var(--border)' }}>
|
||
|
|
{/* Header */}
|
||
|
|
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: 'var(--border)' }}>
|
||
|
|
<h2 className="text-xl font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
🤖 OCR Settings
|
||
|
|
</h2>
|
||
|
|
<button
|
||
|
|
onClick={onClose}
|
||
|
|
className="p-2 rounded-lg hover:opacity-70 transition-opacity"
|
||
|
|
style={{ color: 'var(--text-secondary)' }}
|
||
|
|
>
|
||
|
|
✕
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Content */}
|
||
|
|
<div className="p-6 space-y-6">
|
||
|
|
{/* Service Selection */}
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-3" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
OCR Service
|
||
|
|
</label>
|
||
|
|
<div className="space-y-2">
|
||
|
|
<label className="flex items-center">
|
||
|
|
<input
|
||
|
|
type="radio"
|
||
|
|
name="service"
|
||
|
|
value="puter"
|
||
|
|
checked={settings.service === 'puter'}
|
||
|
|
onChange={(e) => handleInputChange('service', e.target.value)}
|
||
|
|
className="mr-3"
|
||
|
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||
|
|
/>
|
||
|
|
<div>
|
||
|
|
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Puter.js</div>
|
||
|
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
Free GPT-4o vision - requires signing in to puter.com first
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
|
||
|
|
<label className="flex items-center">
|
||
|
|
<input
|
||
|
|
type="radio"
|
||
|
|
name="service"
|
||
|
|
value="openai"
|
||
|
|
checked={settings.service === 'openai'}
|
||
|
|
onChange={(e) => handleInputChange('service', e.target.value)}
|
||
|
|
className="mr-3"
|
||
|
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||
|
|
/>
|
||
|
|
<div>
|
||
|
|
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>OpenAI Vision API</div>
|
||
|
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
High accuracy, requires API key (~$0.01 per scan)
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
|
||
|
|
<label className="flex items-center">
|
||
|
|
<input
|
||
|
|
type="radio"
|
||
|
|
name="service"
|
||
|
|
value="gemini"
|
||
|
|
checked={settings.service === 'gemini'}
|
||
|
|
onChange={(e) => handleInputChange('service', e.target.value)}
|
||
|
|
className="mr-3"
|
||
|
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||
|
|
/>
|
||
|
|
<div>
|
||
|
|
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Google Gemini (Recommended)</div>
|
||
|
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
Free tier available, high accuracy, requires API key
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
|
||
|
|
<label className="flex items-center">
|
||
|
|
<input
|
||
|
|
type="radio"
|
||
|
|
name="service"
|
||
|
|
value="ollama"
|
||
|
|
checked={settings.service === 'ollama'}
|
||
|
|
onChange={(e) => handleInputChange('service', e.target.value)}
|
||
|
|
className="mr-3"
|
||
|
|
style={{ accentColor: 'var(--accent-ember)' }}
|
||
|
|
/>
|
||
|
|
<div>
|
||
|
|
<div className="font-medium" style={{ color: 'var(--text-primary)' }}>Ollama Local</div>
|
||
|
|
<div className="text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
Private & free, requires local Ollama + LLaVA model
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</label>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* OpenAI Settings */}
|
||
|
|
{settings.service === 'openai' && (
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
OpenAI API Key
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="password"
|
||
|
|
placeholder="sk-..."
|
||
|
|
value={settings.openaiApiKey}
|
||
|
|
onChange={(e) => handleInputChange('openaiApiKey', e.target.value)}
|
||
|
|
className="w-full px-3 py-2 rounded-lg border"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--bg-primary)',
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
color: 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<div className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
Get your API key from{' '}
|
||
|
|
<a
|
||
|
|
href="https://platform.openai.com/api-keys"
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="underline hover:opacity-70"
|
||
|
|
style={{ color: 'var(--accent-ember)' }}
|
||
|
|
>
|
||
|
|
OpenAI Platform
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Gemini Settings */}
|
||
|
|
{settings.service === 'gemini' && (
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
Gemini API Key
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="password"
|
||
|
|
placeholder="AIza..."
|
||
|
|
value={settings.geminiApiKey}
|
||
|
|
onChange={(e) => handleInputChange('geminiApiKey', e.target.value)}
|
||
|
|
className="w-full px-3 py-2 rounded-lg border"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--bg-primary)',
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
color: 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<div className="mt-2 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
Get your free API key from{' '}
|
||
|
|
<a
|
||
|
|
href="https://ai.google.dev/gemini-api/docs/api-key"
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="underline hover:opacity-70"
|
||
|
|
style={{ color: 'var(--accent-ember)' }}
|
||
|
|
>
|
||
|
|
Google AI Studio
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Ollama Settings */}
|
||
|
|
{settings.service === 'ollama' && (
|
||
|
|
<div>
|
||
|
|
<label className="block text-sm font-medium mb-2" style={{ color: 'var(--text-primary)' }}>
|
||
|
|
Ollama Server URL
|
||
|
|
</label>
|
||
|
|
<input
|
||
|
|
type="url"
|
||
|
|
placeholder="http://localhost:11434"
|
||
|
|
value={settings.ollamaUrl}
|
||
|
|
onChange={(e) => handleInputChange('ollamaUrl', e.target.value)}
|
||
|
|
className="w-full px-3 py-2 rounded-lg border"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--bg-primary)',
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
color: 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<div className="mt-2 text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
<div>Install Ollama and run: <code className="px-1 py-0.5 rounded text-xs" style={{ backgroundColor: 'var(--bg-tertiary)' }}>ollama pull llava:latest</code></div>
|
||
|
|
<div>
|
||
|
|
Setup guide:{' '}
|
||
|
|
<a
|
||
|
|
href="https://ollama.ai"
|
||
|
|
target="_blank"
|
||
|
|
rel="noopener noreferrer"
|
||
|
|
className="underline hover:opacity-70"
|
||
|
|
style={{ color: 'var(--accent-ember)' }}
|
||
|
|
>
|
||
|
|
ollama.ai
|
||
|
|
</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Test Result */}
|
||
|
|
{testResult && (
|
||
|
|
<div className={`p-3 rounded-lg border ${
|
||
|
|
testResult.type === 'success' ? 'border-green-500 bg-green-50 dark:bg-green-900/20' :
|
||
|
|
testResult.type === 'warning' ? 'border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20' :
|
||
|
|
'border-red-500 bg-red-50 dark:bg-red-900/20'
|
||
|
|
}`}>
|
||
|
|
<div className={`text-sm ${
|
||
|
|
testResult.type === 'success' ? 'text-green-700 dark:text-green-300' :
|
||
|
|
testResult.type === 'warning' ? 'text-yellow-700 dark:text-yellow-300' :
|
||
|
|
'text-red-700 dark:text-red-300'
|
||
|
|
}`}>
|
||
|
|
{testResult.message}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Actions */}
|
||
|
|
<div className="flex gap-3">
|
||
|
|
<button
|
||
|
|
onClick={testConnection}
|
||
|
|
disabled={isTesting}
|
||
|
|
className="flex-1 px-4 py-2 rounded-lg border font-medium transition-all duration-200 hover:opacity-80 disabled:opacity-50"
|
||
|
|
style={{
|
||
|
|
borderColor: 'var(--border)',
|
||
|
|
color: 'var(--text-primary)'
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{isTesting ? 'Testing...' : 'Test Connection'}
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<button
|
||
|
|
onClick={saveSettings}
|
||
|
|
className="flex-1 px-4 py-2 rounded-lg font-medium transition-all duration-200 hover:opacity-90"
|
||
|
|
style={{
|
||
|
|
backgroundColor: 'var(--accent-ember)',
|
||
|
|
color: 'white'
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
Save Settings
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Usage Tips */}
|
||
|
|
<div className="rounded-lg p-4" style={{ backgroundColor: 'var(--bg-tertiary)' }}>
|
||
|
|
<div className="font-medium mb-2" style={{ color: 'var(--text-primary)' }}>💡 Tips</div>
|
||
|
|
<ul className="text-sm space-y-1" style={{ color: 'var(--text-secondary)' }}>
|
||
|
|
<li>• Puter.js offers free GPT-4o vision with no setup required</li>
|
||
|
|
<li>• OpenAI Vision API offers highest accuracy for card recognition</li>
|
||
|
|
<li>• Ollama is free and private but requires local setup</li>
|
||
|
|
<li>• Test your connection before scanning cards</li>
|
||
|
|
<li>• Settings are saved locally in your browser</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|