From a72d42c6d8781a768a152edc940372684cf164a1 Mon Sep 17 00:00:00 2001 From: desartstudio95 Date: Tue, 12 May 2026 07:56:18 +0200 Subject: [PATCH] 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. --- firestore.rules | 4 +- src/components/AnalysisView.tsx | 146 +++++++++++++++++++++++++++++-- src/components/ImageUploader.tsx | 10 ++- src/lib/imageUtils.ts | 65 ++++++++++++++ src/services/geminiService.ts | 23 ++++- test-gemini.ts | 18 ++++ workspace/test-gemini.ts | 18 ++++ 7 files changed, 268 insertions(+), 16 deletions(-) create mode 100644 src/lib/imageUtils.ts create mode 100644 test-gemini.ts create mode 100644 workspace/test-gemini.ts diff --git a/firestore.rules b/firestore.rules index 9a1aa63..61207a5 100644 --- a/firestore.rules +++ b/firestore.rules @@ -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 diff --git a/src/components/AnalysisView.tsx b/src/components/AnalysisView.tsx index f050886..b819f6e 100644 --- a/src/components/AnalysisView.tsx +++ b/src/components/AnalysisView.tsx @@ -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(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(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 Decisão QuantScan + {mockPrice !== null && ( +
+ Preço de Mercado (Simulado) + {mockPrice.toFixed(5)} +
+ )} +
ENTRADA
- {result.entry} +
+ {result.entry} + +
STOP LOSS
- {result.stopLoss} +
+ {result.stopLoss} + +
TAKE PROFIT
- {result.takeProfit} +
+ {result.takeProfit} + +
diff --git a/src/components/ImageUploader.tsx b/src/components/ImageUploader.tsx index 2205d9e..2e70b5c 100644 --- a/src/components/ImageUploader.tsx +++ b/src/components/ImageUploader.tsx @@ -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 = ({ 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); diff --git a/src/lib/imageUtils.ts b/src/lib/imageUtils.ts new file mode 100644 index 0000000..1a84cbc --- /dev/null +++ b/src/lib/imageUtils.ts @@ -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 => { + 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); + } +}; diff --git a/src/services/geminiService.ts b/src/services/geminiService.ts index 8f446f6..fe04a22 100644 --- a/src/services/geminiService.ts +++ b/src/services/geminiService.ts @@ -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 => { - 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; }; diff --git a/test-gemini.ts b/test-gemini.ts new file mode 100644 index 0000000..619d01d --- /dev/null +++ b/test-gemini.ts @@ -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(); diff --git a/workspace/test-gemini.ts b/workspace/test-gemini.ts new file mode 100644 index 0000000..619d01d --- /dev/null +++ b/workspace/test-gemini.ts @@ -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();