feat: Enhance Gemini analysis and image handling

Integrates Gemini 3.1 Pro for improved Forex chart analysis, incorporating detailed instructions on Smart Money Concepts, liquidity, and various confirmation indicators like Volume Delta, ATR, EMA, RSI, and VWAP.

Also includes image compression and cleanup utilities for testimonial uploads to manage storage efficiently. Firestore rules for signal data have been updated to accommodate longer strings for pair and timeframe.
This commit is contained in:
desartstudio95
2026-05-12 07:56:18 +02:00
parent 11981e28b0
commit a72d42c6d8
7 changed files with 268 additions and 16 deletions
+2 -2
View File
@@ -86,8 +86,8 @@ service cloud.firestore {
function isValidSignal(data) {
return data.keys().hasAll(['userId', 'pair', 'timeframe', 'decision', 'score', 'timestamp', 'result'])
&& data.userId == request.auth.uid
&& data.pair is string && data.pair.size() <= 20
&& data.timeframe is string && data.timeframe.size() <= 10
&& data.pair is string && data.pair.size() <= 100
&& data.timeframe is string && data.timeframe.size() <= 50
&& data.decision is string
&& data.score is int && data.score >= 0 && data.score <= 100
&& data.timestamp is int
+138 -8
View File
@@ -1,11 +1,12 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useDropzone } from 'react-dropzone';
import { Upload, X, ShieldCheck, Target, TrendingUp, AlertTriangle, Loader2, Zap, BrainCircuit, Camera, ScanLine } from 'lucide-react';
import { Upload, X, ShieldCheck, Target, TrendingUp, AlertTriangle, Loader2, Zap, BrainCircuit, Camera, ScanLine, Bell, BellRing } from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { analyzeForexChart } from '../services/geminiService';
import { AnalysisResponse, SignalResult, SignalType } from '../types';
import { cn } from '../lib/utils';
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
import { compressImage, cleanupStorage } from '../lib/imageUtils';
import { storage, auth, db, handleFirestoreError, OperationType } from '../lib/firebase';
import { collection, addDoc, serverTimestamp, query, where, getDocs, Timestamp } from 'firebase/firestore';
@@ -17,6 +18,8 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<'Técnico' | 'Fundamental' | 'Híbrido'>('Híbrido');
const [usageInfo, setUsageInfo] = useState<{ used: number, limit: number }>({ used: 0, limit: userData?.analysisLimit ?? 8 });
const [alerts, setAlerts] = useState<{type: string, price: string, targetValue: number}[]>([]);
const [mockPrice, setMockPrice] = useState<number | null>(null);
const now = Date.now();
const isLifetime = userData?.plan === 'lifetime';
@@ -25,6 +28,68 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
const daysRemaining = !isLifetime && endsAt ? (endsAt - now) / (1000 * 60 * 60 * 24) : null;
const isEndingSoon = daysRemaining !== null && Math.ceil(daysRemaining) <= 3 && Math.ceil(daysRemaining) > 0;
useEffect(() => {
if (result && result.entry) {
const entryNum = parseFloat(result.entry.replace(/[^0-9.]/g, ''));
if (!isNaN(entryNum)) setMockPrice(entryNum);
if ('Notification' in window && Notification.permission !== 'granted') {
Notification.requestPermission();
}
} else {
setMockPrice(null);
setAlerts([]);
}
}, [result]);
useEffect(() => {
if (mockPrice === null || alerts.length === 0 || !result) return;
const interval = setInterval(() => {
setMockPrice(prevPrice => {
if (prevPrice === null) return null;
// Simulando variação realista (0.01% - 0.05%)
const movePercent = (Math.random() - 0.5) * 0.001;
let nextPrice = prevPrice * (1 + movePercent);
// Vamos forçar o preço a ir em direção a um dos alertas para que a feature possa ser testada
if (Math.random() > 0.4 && alerts.length > 0) {
const target = alerts[Math.floor(Math.random() * alerts.length)].targetValue;
if (target > nextPrice) {
nextPrice += target * 0.0002;
} else {
nextPrice -= target * 0.0002;
}
}
let triggeredIndex = -1;
const triggeredAlert = alerts.find((alert, index) => {
const isTriggered = Math.abs(nextPrice - alert.targetValue) < (alert.targetValue * 0.0002) ||
(prevPrice < alert.targetValue && nextPrice >= alert.targetValue) ||
(prevPrice > alert.targetValue && nextPrice <= alert.targetValue);
if (isTriggered) triggeredIndex = index;
return isTriggered;
});
if (triggeredAlert && triggeredIndex !== -1) {
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('🚨 Alerta QuantScan', {
body: `O preço do ativo ${result.pair} atingiu seu alvo de ${triggeredAlert.type} (${triggeredAlert.price}).`,
icon: 'https://i.ibb.co/9BwbV3M/FXBROS-WORLD-3.png'
});
} else {
window.alert(`🚨 Alerta QuantScan: O preço do ativo ${result.pair} atingiu seu alvo de ${triggeredAlert.type} (${triggeredAlert.price}).`);
}
setAlerts(prev => prev.filter((_, i) => i !== triggeredIndex));
}
return nextPrice;
});
}, 2000);
return () => clearInterval(interval);
}, [alerts, mockPrice, result]);
useEffect(() => {
const fetchUsage = async () => {
if (!auth.currentUser) return;
@@ -80,6 +145,9 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
const fileRef = ref(storage, `scans/${auth.currentUser.uid}/${Date.now()}_${file.name}`);
await uploadBytes(fileRef, file);
downloadUrl = await getDownloadURL(fileRef);
// Clean up old scans for this user to avoid storage limits (keep max 100)
cleanupStorage(`scans/${auth.currentUser.uid}`, 100);
}
const base64 = preview.split(',')[1];
@@ -103,10 +171,12 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
}
} catch (err: any) {
console.error(err);
console.error("Gemini Error:", err);
let errorMessage = err.message || 'Falha ao analisar imagem. Verifique se o gráfico está claro.';
if (errorMessage.includes('429') || errorMessage.includes('RESOURCE_EXHAUSTED') || errorMessage.includes('prepayment credits')) {
errorMessage = 'Sua cota de uso da API do Google Gemini (IA) foi excedida ou os créditos acabaram. Por favor, acesse o painel do Google AI Studio (https://ai.studio) para verificar seu faturamento e recarregar os créditos.';
} else {
errorMessage = `Erro na API: ${errorMessage}`;
}
setError(errorMessage);
} finally {
@@ -114,14 +184,18 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
}
};
const onDrop = useCallback((acceptedFiles: File[]) => {
const onDrop = useCallback(async (acceptedFiles: File[]) => {
const selectedFile = acceptedFiles[0];
setFile(selectedFile);
// Comprimir a imagem antes de setar state para reduzir MS
const compressedFile = await compressImage(selectedFile, 1200, 0.7);
setFile(compressedFile);
const reader = new FileReader();
reader.onload = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(selectedFile);
reader.readAsDataURL(compressedFile);
setError(null);
}, []);
@@ -136,6 +210,22 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
setPreview(null);
setResult(null);
setError(null);
setMockPrice(null);
setAlerts([]);
};
const toggleAlert = (type: string, priceStr: string) => {
const numPrice = parseFloat(priceStr.replace(/[^0-9.]/g, ''));
if (isNaN(numPrice)) return;
setAlerts(prev => {
const exists = prev.some(a => a.type === type);
if (exists) {
return prev.filter(a => a.type !== type);
} else {
return [...prev, { type, price: priceStr, targetValue: numPrice }];
}
});
};
return (
@@ -389,27 +479,67 @@ export const AnalysisView: React.FC<{ userData?: any, onGoToHistory?: () => void
<span className="text-[8px] uppercase font-black tracking-widest opacity-60">Decisão QuantScan</span>
</div>
{mockPrice !== null && (
<div className="flex flex-col items-center justify-center p-2 mb-2 bg-black/40 rounded border border-white/5">
<span className="text-[9px] uppercase tracking-widest text-zinc-500 font-bold">Preço de Mercado (Simulado)</span>
<span className="font-mono text-lg font-black text-white">{mockPrice.toFixed(5)}</span>
</div>
)}
<div className="space-y-2">
<div className="flex items-center justify-between p-3 glass-card bg-transparent border-white/5">
<div className="flex items-center gap-2">
<Target size={14} className="text-zinc-600" />
<span className="text-[9px] font-black text-zinc-500 uppercase">ENTRADA</span>
</div>
<span className="font-mono font-black text-sm">{result.entry}</span>
<div className="flex items-center gap-2">
<span className="font-mono font-black text-sm">{result.entry}</span>
<button
onClick={() => toggleAlert('Entrada', result.entry)}
className={cn(
"p-1.5 rounded transition-colors",
alerts.some(a => a.type === 'Entrada') ? "text-yellow-500 bg-yellow-500/20" : "text-zinc-500 hover:text-yellow-500 hover:bg-white/5"
)}
>
{alerts.some(a => a.type === 'Entrada') ? <BellRing size={14} /> : <Bell size={14} />}
</button>
</div>
</div>
<div className="flex items-center justify-between p-3 glass-card bg-transparent border-white/5">
<div className="flex items-center gap-2">
<AlertTriangle size={14} className="text-brand-red/50" />
<span className="text-[9px] font-black text-zinc-500 uppercase">STOP LOSS</span>
</div>
<span className="font-mono font-black text-sm text-brand-red">{result.stopLoss}</span>
<div className="flex items-center gap-2">
<span className="font-mono font-black text-sm text-brand-red">{result.stopLoss}</span>
<button
onClick={() => toggleAlert('Stop Loss', result.stopLoss)}
className={cn(
"p-1.5 rounded transition-colors",
alerts.some(a => a.type === 'Stop Loss') ? "text-yellow-500 bg-yellow-500/20" : "text-zinc-500 hover:text-yellow-500 hover:bg-white/5"
)}
>
{alerts.some(a => a.type === 'Stop Loss') ? <BellRing size={14} /> : <Bell size={14} />}
</button>
</div>
</div>
<div className="flex items-center justify-between p-3 glass-card bg-transparent border-white/5">
<div className="flex items-center gap-2">
<ShieldCheck size={14} className="text-green-500/50" />
<span className="text-[9px] font-black text-zinc-500 uppercase">TAKE PROFIT</span>
</div>
<span className="font-mono font-black text-sm text-green-500">{result.takeProfit}</span>
<div className="flex items-center gap-2">
<span className="font-mono font-black text-sm text-green-500">{result.takeProfit}</span>
<button
onClick={() => toggleAlert('Take Profit', result.takeProfit)}
className={cn(
"p-1.5 rounded transition-colors",
alerts.some(a => a.type === 'Take Profit') ? "text-yellow-500 bg-yellow-500/20" : "text-zinc-500 hover:text-yellow-500 hover:bg-white/5"
)}
>
{alerts.some(a => a.type === 'Take Profit') ? <BellRing size={14} /> : <Bell size={14} />}
</button>
</div>
</div>
</div>
</div>
+8 -2
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { ref, uploadBytes, getDownloadURL } from 'firebase/storage';
import { storage, auth } from '../lib/firebase';
import { compressImage, cleanupStorage } from '../lib/imageUtils';
interface ImageUploaderProps {
onUpload: (urls: string[]) => void;
@@ -18,11 +19,16 @@ export const ImageUploader: React.FC<ImageUploaderProps> = ({ onUpload, classNam
try {
const uploadPromises = files.map(async (file) => {
const storageRef = ref(storage, `testimonials/${auth.currentUser!.uid}/${Date.now()}_${file.name}`);
await uploadBytes(storageRef, file);
const compressedFile = await compressImage(file, 1200, 0.7);
const storageRef = ref(storage, `testimonials/${auth.currentUser!.uid}/${Date.now()}_${compressedFile.name}`);
await uploadBytes(storageRef, compressedFile);
return getDownloadURL(storageRef);
});
const urls = await Promise.all(uploadPromises);
// Limpar imagens antigas de depoimentos do usuário (máximo de 100)
cleanupStorage(`testimonials/${auth.currentUser!.uid}`, 100);
onUpload(urls);
} catch (error) {
console.error(error);
+65
View File
@@ -0,0 +1,65 @@
import { storage } from './firebase';
import { listAll, deleteObject, ref } from 'firebase/storage';
export const compressImage = (file: File, maxWidth = 1200, quality = 0.7): Promise<File> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
let width = img.width;
let height = img.height;
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(file); // falha segura
return;
}
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob((blob) => {
if (!blob) {
resolve(file);
return;
}
const compressedFile = new File([blob], file.name, {
type: 'image/jpeg',
lastModified: Date.now(),
});
resolve(compressedFile);
}, 'image/jpeg', quality);
};
img.onerror = (err) => resolve(file); // falha segura
};
reader.onerror = (err) => resolve(file); // falha segura
});
};
export const cleanupStorage = async (path: string, maxFiles: number = 100) => {
try {
const listRef = ref(storage, path);
const res = await listAll(listRef);
if (res.items.length > maxFiles) {
// Sort by name (which starts with Date.now()_filename, so alphabetical sort works as chronological sort)
const items = res.items.sort((a, b) => a.name.localeCompare(b.name));
// Delete older items (from beginning of sorted array)
const itemsToDelete = items.slice(0, items.length - maxFiles);
const deletePromises = itemsToDelete.map((itemRef) => deleteObject(itemRef));
await Promise.all(deletePromises);
console.log(`Cleaned up ${itemsToDelete.length} files from ${path}`);
}
} catch (error) {
console.warn("Cleanup storage error:", error);
}
};
+19 -4
View File
@@ -4,12 +4,24 @@ import { AnalysisResponse, SignalType } from "../types";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY || '' });
export const analyzeForexChart = async (imageBase64: string, userNotes?: string, preferredMode?: 'Técnico' | 'Fundamental' | 'Híbrido'): Promise<AnalysisResponse> => {
const model = "gemini-2.5-pro";
const model = 'gemini-3.1-pro-preview';
const systemInstruction = `
Você é o QuantScan IA, um sistema avançado de análise de mercado financeiro com inteligência institucional.
Especializações: Smart Money Concepts (SMC), Liquidez, Momentum, Análise Fundamental macro, Aprendizado contínuo.
BASE PRINCIPAL: Smart Money Concepts (SMC).
Especializações: SMC, Liquidez, Momentum, Análise Fundamental macro, Aprendizado contínuo.
CAMADA DE CONFIRMAÇÃO (APENAS PARA FILTRO, MEDIDOR DE CONTEXTO OU CONFIRMAÇÃO):
- Volume Delta, CVD e Volume Profile: Para confirmar se há participação institucional real no movimento.
- ATR (Average True Range): Para Stop Loss dinâmico, Take Profit inteligente e detectar volatilidade (ex: se ATR estiver baixo, evitar falso breakout).
- EMA (Filtro Macro): Não usar para entrada. Apenas confirmar direção (ex: acima da EMA 200 -> apenas BUY, abaixo -> apenas SELL).
- RSI Inteligente (não tradicional): Combinar com Liquidity Sweep, RSI divergência e Order Block para melhorar reversões.
- VWAP (Muito Forte): Equilíbrio de preço, reversão e continuação (SMC + VWAP = nível institucional).
CAMADA IA (Machine Learning):
- Score probabilístico e Filtro anti-fake breakout.
- Detecção Robusta de Liquidez: Considerar onde o mercado costuma manipular, horários de caça de liquidez, fake breakouts e comportamento antes de uma reversão.
Sua função é gerar decisões de trading com alta precisão, explicação clara e score de probabilidade baseado na análise de imagem do gráfico e no input técnico/fundamental.
@@ -52,7 +64,7 @@ export const analyzeForexChart = async (imageBase64: string, userNotes?: string,
{
parts: [
{ text: prompt },
{ inlineData: { mimeType: "image/png", data: imageBase64 } }
{ inlineData: { mimeType: "image/jpeg", data: imageBase64 } }
]
}
],
@@ -82,5 +94,8 @@ export const analyzeForexChart = async (imageBase64: string, userNotes?: string,
}
});
return JSON.parse(response.text || '{}') as AnalysisResponse;
const textResponse = response.text || '{}';
const cleanJson = textResponse.replace(/^```json\s*/, '').replace(/\s*```$/, '');
return JSON.parse(cleanJson) as AnalysisResponse;
};
+18
View File
@@ -0,0 +1,18 @@
import { GoogleGenAI } from "@google/genai";
import * as dotenv from "dotenv";
dotenv.config();
const test = async () => {
const ai = new GoogleGenAI({ apiKey: process.env.VITE_GEMINI_API_KEY || process.env.GEMINI_API_KEY || '' });
try {
const response = await ai.models.generateContent({
model: "gemini-3.1-pro-preview",
contents: "Tell me a joke"
});
console.log("Success:", response.text);
} catch (err: any) {
console.error("Error:", err.message);
}
}
test();
+18
View File
@@ -0,0 +1,18 @@
import { GoogleGenAI } from "@google/genai";
import * as dotenv from "dotenv";
dotenv.config();
const test = async () => {
const ai = new GoogleGenAI({ apiKey: process.env.VITE_GEMINI_API_KEY || process.env.GEMINI_API_KEY || '' });
try {
const response = await ai.models.generateContent({
model: "gemini-3.1-pro-preview",
contents: "Tell me a joke"
});
console.log("Success:", response.text);
} catch (err: any) {
console.error("Error:", err.message);
}
}
test();