1 2 // Quem assinou o laudo. O produtor recebia o link, via uma assinatura 3 // rabiscada e não tinha como saber de quem era nem como responder â o que 4 // derruba a credibilidade do documento e mata o canal de contato. 5 function _blocoConsultor(c, art) { 6 if (!c || !c.name) return ''; 7 const registro = (c.council && c.council_number) 8 ? `${sanitize(c.council)}-${sanitize(c.council_uf || '')} ${sanitize(c.council_number)}`.replace('- ', ' ') 9 : ''; 10 const zap = (c.whatsapp || '').replace(/\D/g, ''); 11 const linhas = [c.title, registro].filter(Boolean).map(sanitize).join(' · '); 12 13 return `<div class="bg-gray-800 rounded-2xl p-4 mb-4"> 14 <p class="text-xs font-semibold text-gray-400 mb-2 uppercase tracking-wide">Responsável Técnico</p> 15 <p class="text-white font-bold">${sanitize(c.name)}</p> 16 ${linhas ? `<p class="text-gray-400 text-sm mt-0.5">${linhas}</p>` : ''} 17 ${art ? `<p class="text-gray-500 text-xs mt-1">ART nº ${sanitize(art)}</p>` : ''} 18 ${zap ? `<a href="https://wa.me/55${zap}" target="_blank" rel="noopener" 19 class="portal-print-hide" style="display:flex;align-items:center;justify-content:center;gap:8px;margin-top:12px;background:#065f46;color:#6ee7b7;padding:11px;border-radius:14px;font-size:13px;font-weight:700;text-decoration:none;"> 20 Falar com o responsável técnico 21 </a>` : ''} 22 </div>`; 23 } 24 25 async function showPublicReport(token) { 26 const container = document.getElementById('publicReport'); 27 try { 28 // RPC em vez de leitura direta: a RLS por organização fecha `visits` 29 // para anon, e um select('*') público devolveria user_id e custos. 30 const { data, error } = await supabaseClient.rpc('portal_report', { p_token: token }); 31 if (error || !data) throw new Error('not found'); 32 33 if (window.CF && CF.fotos) { 34 await CF.fotos.assinarPortal(token, 'laudo', CF.fotos.caminhosProfundos(data)); 35 } 36 37 const d = new Date(data.scheduled_date); 38 container.innerHTML = ` 39 <div class="min-h-screen bg-gray-900 p-4"> 40 <div class="max-w-lg mx-auto"> 41 <div class="text-center mb-5"> 42 <div class="w-16 h-16 bg-gradient-to-br from-green-500 to-emerald-600 rounded-2xl flex items-center justify-center mx-auto mb-3 shadow-lg"> 43 <span class="text-3xl">${_icon('leaf',15)}</span> 44 </div> 45 <h1 class="text-2xl font-bold text-white">Relatório de Visita Técnica</h1> 46 <p class="text-gray-500 text-xs mt-1">CropFlow â Gestão AgrÃcola Inteligente</p> 47 </div> 48 49 <div class="rounded-2xl mb-4 overflow-hidden" style="background:#064e3b;border:1px solid rgba(16,185,129,0.3);"> 50 <div class="p-4"> 51 ${data.clients ? `<p class="text-white font-bold text-xl mb-1">${sanitize(data.clients.name)}</p>` : '<p class="text-white font-bold text-xl mb-1">Produtor</p>'} 52 ${data.clients?.farm_name ? `<p class="text-green-400 text-sm font-medium mb-1">${sanitize(data.clients.farm_name)}</p>` : ''} 53 ${data.clients?.city ? `<p class="text-gray-400 text-sm mb-3">${sanitize(data.clients.city)}${data.clients.state ? ' / '+sanitize(data.clients.state) : ''}</p>` : '<div class="mb-3"></div>'} 54 <div class="flex flex-wrap gap-2"> 55 ${data.clients?.crop ? `<span style="background:rgba(16,185,129,0.18);color:#6ee7b7;padding:3px 10px;border-radius:99px;font-size:12px;font-weight:600;">${sanitize(data.clients.crop)}</span>` : ''} 56 ${data.safra ? `<span style="background:rgba(251,191,36,0.15);color:#fcd34d;padding:3px 10px;border-radius:99px;font-size:12px;font-weight:600;">${sanitize(data.safra)}</span>` : ''} 57 ${data.stage ? `<span style="background:rgba(99,102,241,0.18);color:#a5b4fc;padding:3px 10px;border-radius:99px;font-size:12px;font-weight:600;">${sanitize(data.stage)}</span>` : ''} 58 </div> 59 </div> 60 <div style="background:rgba(0,0,0,0.2);border-top:1px solid rgba(16,185,129,0.2);" class="px-4 py-3 flex items-center justify-between"> 61 <p class="text-gray-400 text-sm">${_icon('calendar',15)} ${d.toLocaleDateString('pt-BR', {weekday:'long', day:'numeric', month:'long', year:'numeric'})}</p> 62 <p class="text-gray-500 text-xs">${d.toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'})}</p> 63 </div> 64 </div> 65 66 ${data.objective ? `<div class="bg-gray-800 rounded-2xl p-4 mb-4"><p class="text-xs font-semibold text-green-400 mb-2 uppercase tracking-wide">Objetivo da Visita</p><p class="text-gray-200 leading-relaxed">${sanitize(data.objective)}</p></div>` : ''} 67 ${data.notes ? `<div class="bg-gray-800 rounded-2xl p-4 mb-4"><p class="text-xs font-semibold text-green-400 mb-2 uppercase tracking-wide">Diagnóstico Técnico</p><p class="text-gray-200 leading-relaxed whitespace-pre-line">${sanitize(data.notes)}</p></div>` : ''} 68 ${data.next_steps ? `<div class="bg-emerald-900/40 border border-emerald-700/50 rounded-2xl p-4 mb-4"><p class="text-xs font-semibold text-emerald-400 mb-2 uppercase tracking-wide">Próximos Passos</p><p class="text-gray-200 leading-relaxed whitespace-pre-line">${sanitize(data.next_steps)}</p></div>` : ''} 69 ${data.satisfaction ? `<div class="bg-gray-800 rounded-2xl p-4 mb-4 text-center"><p class="text-xs font-semibold text-yellow-400 mb-2 uppercase tracking-wide">Satisfação do Produtor</p><p style="margin:0;">${_estrelas(data.satisfaction, 5, 26)}</p><p class="text-gray-400 text-sm mt-2">${['','Ruim','Regular','Bom','Muito Bom','Excelente'][data.satisfaction]||''}</p></div>` : ''} 70 ${data.attachments?.length ? `<div class="bg-gray-800 rounded-2xl p-4 mb-4"> 71 <p class="text-xs font-semibold text-gray-400 mb-3 uppercase tracking-wide">Fotos da Visita (${data.attachments.length})</p> 72 <div class="space-y-5">${data.attachments.map(p => { 73 const src = getPhotoSrc(p); 74 if (!src) return ''; 75 // sanitize() aqui não era aplicad
75o: comment e aiComment entravam 76 // crus no innerHTML de uma página pública sem login â XSS armazenado. 77 const comment = typeof p === 'object' && p.comment ? sanitize(p.comment) : ''; 78 const aiComment = typeof p === 'object' && p.aiComment ? sanitize(p.aiComment) : ''; 79 // O escape anterior era `src.replace(/'/g, "\'")`, que em JS é uma 80 // troca de aspa simples por aspa simples â não fazia nada. 81 const safeSrc = sanitize(src); 82 return `<div class="flex flex-col"> 83 <img src="${safeSrc}" class="rounded-2xl cursor-pointer hover:opacity-90 transition shadow-lg w-full" style="max-height:340px;object-fit:contain;" onclick="openLightbox('${escAttrJs(safeSrc)}')" loading="lazy"> 84 ${comment ? `<p class="text-sm text-gray-300 mt-2 px-1"><strong>Nota:</strong> ${comment}</p>` : ''} 85 ${aiComment ? `<div class="mt-2 rounded-xl p-3" style="background:rgba(124,58,237,0.12);border:1px solid rgba(124,58,237,0.25);"><p class="text-xs font-semibold text-violet-400 mb-1">Complemento</p><p class="text-sm text-gray-300 leading-relaxed">${aiComment}</p></div>` : ''} 86 </div>`; 87 }).filter(Boolean).join('')}</div> 88 </div>` : ''} 89 ${data.consultor_signature ? `<div class="bg-gray-800 rounded-2xl p-4 mb-4"><p class="text-xs font-semibold text-blue-400 mb-3 uppercase tracking-wide">Assinatura do Responsável Técnico</p><div class="bg-gray-900 rounded-xl p-4 flex items-center justify-center border border-blue-900/40"><img src="${sanitize(data.consultor_signature)}" style="max-width:100%;max-height:100px;filter:invert(1) hue-rotate(180deg);" alt="Assinatura do consultor"></div></div>` : ''} 90 ${data.producer_signature ? `<div class="bg-gray-800 rounded-2xl p-4 mb-4"><p class="text-xs font-semibold text-gray-400 mb-3 uppercase tracking-wide">Assinatura do Produtor</p><div class="bg-gray-900 rounded-xl p-4 flex items-center justify-center border border-gray-700"><img src="${sanitize(data.producer_signature)}" style="max-width:100%;max-height:100px;filter:invert(1) hue-rotate(90deg);" alt="Assinatura do produtor"></div></div>` : ''} 91 ${typeof cfBlocoAssinatura === 'function' && data.assinado_em 92 ? `<div class="mb-4">${cfBlocoAssinatura(data)}</div>` : ''} 93 ${_blocoConsultor(data.consultor, data.art_number)} 94 <div class="text-center mt-8 pt-6 border-t border-gray-700/50 portal-print-hide"> 95 <p class="text-gray-500 text-sm font-semibold">CropFlow</p> 96 <p class="text-gray-600 text-xs">Gestão AgrÃcola Inteligente</p> 97 </div> 98 </div> 99 </div>`; 100 } catch(e) { 101 container.innerHTML = '<div class="min-h-screen bg-gray-900 flex items-center justify-center"><p class="text-gray-400">Relatório não encontrado ou link expirado.</p></div>'; 102 } 103 } 104 105 let chatHistory = []; 106 let _recognition = null; 107 let _isListening = false; 108 let _lastAiCall = 0; 109 const AI_DEBOUNCE_MS = 3000; 110 111 let assistantPhotos = []; 112 113 function addAssistantPhoto(event) { 114 const files = Array.from(event.target.files); 115 files.forEach(file => { 116 const reader = new FileReader(); 117 reader.onload = ev => { 118 const img = new Image(); 119 img.onload = () => { 120 const canvas = document.createElement('canvas'); 121 const MAX = 800;
122 let w = img.width, h = img.height; 123 if (w > MAX || h > MAX) { 124 if (w > h) { h = Math.round(h * MAX / w); w = MAX; } 125 else { w = Math.round(w * MAX / h); h = MAX; } 126 } 127 canvas.width = w; canvas.height = h; 128 canvas.getContext('2d').drawImage(img, 0, 0, w, h); 129 assistantPhotos.push({ data: canvas.toDataURL('image/jpeg', 0.8) }); 130 renderAssistantPhotos(); 131 }; 132 img.src = ev.target.result; 133 }; 134 reader.readAsDataURL(file); 135 }); 136 event.target.value = ''; 137 } 138 139 function renderAssistantPhotos() { 140 const container = document.getElementById('assistantImagePreview'); 141 if (assistantPhotos.length === 0) { 142 container.style.display = 'none'; 143 container.innerHTML = ''; 144 return; 145 } 146 container.style.display = 'flex'; 147 container.innerHTML = assistantPhotos.map((p, i) => ` 148 <div class="relative"> 149 <img src="${p.data}" class="w-16 h-16 object-cover rounded-xl border border-gray-600"> 150 <button onclick="assistantPhotos.splice(${i},1); renderAssistantPhotos();" class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs font-bold shadow-md">${_icon('x',15)}</button> 151 </div> 152 `).join(''); 153 } 154 155 function toggleVoiceInput() { 156 if (!('SpeechRecognition' in window) && !('webkitSpeechRecognition' in window)) { 157 appendAIBubble('Seu navegador não suporta reconhecimento de voz. Use Chrome ou Safari.'); 158 return; 159 } 160 if (_isListening) { 161 stopVoice(); 162 } else { 163 startVoice(); 164 } 165 } 166 167 async function startVoice() { 168 const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; 169 if (!SpeechRecognition) { showAlert('Ditado por voz nao disponivel neste navegador. Use Chrome ou Edge.'); return; } 170 if (typeof _garantirMicrofone === 'function' && !await _garantirMicrofone()) return; 171 _recognition = new SpeechRecognition(); 172 _recognition.lang = 'pt-BR'; 173 _recognition.continuous = true; 174 _recognition.interimResults = true; 175 _recognition.maxAlternatives = 1; 176 177 const btn = document.getElementById('voiceBtn'); 178 const input = document.getElementById('assistantInput'); 179 let _silenceTimer = null; 180 let _lastTranscript = ''; 181 182 _recognition.onstart = () => { 183 _isListening = true; 184 btn.classList.add('voice-active'); 185 btn.title = 'Ouvindo... (toque para parar)'; 186 input.placeholder = 'Ouvindo â fale à vontade...'; 187 }; 188 189 _recognition.onresult = (event) => { 190 if (_silenceTimer) clearTimeout(_silenceTimer); 191 192 const transcript = Array.from(event.results) 193 .map(r => r[0].transcript).join(''); 194 input.value = transcript; 195 _lastTranscript = transcript; 196 197 const lastResult = event.results[event.results.length - 1]; 198 if (lastResult.isFinal) { 199 _silenceTimer = setTimeout(() => { 200 if (_lastTranscript.trim()) { 201 stopVoice(); 202 setTimeout(() => sendToAssistant(), 200); 203 } else { 204 stopVoice(); 205 } 206 }, 2000); 207 } 208 }; 209 210 _recognition.onerror = (event) => { 211 if (_silenceTimer) clearTimeout(_silenceTimer); 212 stopVoice(); 213 if (event.error === 'not-allowed') { 214 appendAIBubble('Permissão de microfone negada. Habilite nas configurações do navegador.'); 215 } else if (event.error !== 'no-speech') { 216 appendAIBubble('Erro no reconhecimento de voz: ' + event.error); 217 } 218 }; 219 220 _recognition.onend = () => { 221 if (_isListening) { 222 try { _recognition.start(); } catch(e) { stopVoice(); } 223 } 224 }; 225 try { _recognition.start(); } catch(e) { stopVoice(); } 226 } 227 228 function stopVoice() { 229 _isListening = false;
230 const btn = document.getElementById('voiceBtn'); 231 const input = document.getElementById('assistantInput'); 232 if (btn) { btn.classList.remove('voice-active'); btn.title = 'Falar por voz'; } 233 if (input) { input.placeholder = 'Ex: Agendar visita João sexta 14h...'; } 234 if (_recognition) { try { _recognition.stop(); } catch(e) {} _recognition = null; } 235 } 236 237 /* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 238 FASE 6 â IA CONTEXTUAL 239 240 Antes, toda pergunta carregava a carteira inteira: nome, fazenda, 241 cidade, cultura, TELEFONE e trecho das notas de cada cliente. Com 50 242 produtores isso é dezenas de milhares de caracteres por pergunta â 243 pagos toda vez, inclusive em "que horas é minha próxima visita" â e 244 empurra dado pessoal de terceiros para o modelo sem necessidade. 245 246 Agora o contexto vai em camadas: núcleo sempre; Ãndice enxuto da 247 carteira; e o dossiê fundo só da fazenda em escopo. O escopo vem de 248 abrir a IA a partir de uma fazenda, ou de citar o nome dela na 249 pergunta. Telefone saiu â a IA nunca precisou dele para responder. 250 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 251 let _escopoIA = null; // client_id em foco, ou null 252 253 function definirEscopoIA(clientId) { 254 _escopoIA = clientId || null; 255 renderEscopoIA(); 256 } 257 258 function limparEscopoIA() { 259 _escopoIA = null; 260 renderEscopoIA(); 261 } 262 263 function renderEscopoIA() { 264 const el = document.getElementById('iaEscopoChip'); 265 if (!el) return; 266 const c = _escopoIA && (clients || []).find(x => x.id === _escopoIA); 267 if (!c) { el.style.display = 'none'; el.innerHTML = ''; return; } 268 el.style.display = 'flex'; 269 el.innerHTML = `<span style="color:#a5b4fc;font-size:11.5px;font-weight:700;">Falando sobre: ${sanitize(c.farm_name || c.name)}</span> 270 <button onclick="limparEscopoIA()" style="background:none;border:none;color:#6b7280;font-size:11px;font-weight:700;cursor:pointer;padding:0 2px;">trocar</button>`; 271 } 272 273 // A pergunta manda mais que o estado: citar outra fazenda troca o foco. 274 function _escopoDaPergunta(question) { 275 const txt = String(question || ''); 276 if (txt.length < 3) return null; 277 const alvo = txt.toLowerCase(); 278 let achado = null; 279 for (const c of (clients || [])) { 280 for (const nome of [c.farm_name, c.name]) { 281 const n = String(nome || '').trim().toLowerCase(); 282 if (n.length < 4) continue; // "Zé", "Sol" dariam falso positivo 283 if (!alvo.includes(n)) continue; 284 if (!achado || n.length > achado.tam) achado = { id: c.id, tam: n.length }; 285 } 286 } 287 return achado ? achado.id : null; 288 } 289 290 function quickAction(text) { 291 document.getElementById('assistantInput').value = text; 292 sendToAssistant(); 293 } 294 295 function quickClimaAction() { 296 const city = document.getElementById('weatherLocation')?.textContent || ''; 297 const validCity = city && city !== 'Carregando...' && city !== 'Erro ao carregar' && city !== '--'; 298 const question = validCity 299 ? `Como está o clima em ${city} hoje? Me dê a temperatura, umidade e previsão.` 300 : 'Como está o clima na minha localização hoje?'; 301 document.getElementById('assistantInput').value = question; 302 sendToAssistant(); 303 } 304 305 function clearChat() { 306 chatHistory = []; 307 limparEscopoIA(); 308 document.getElementById('chatMessages').innerHTML = ` 309 <div class="flex items-start space-x-2"> 310 <div class="w-7 h-7 bg-violet-700 rounded-full flex items-center justify-center text-xs flex-shrink-0">${_icon('bot',15)}</div> 311 <div class="bg-gray-700/80 p-3 rounded-2xl rounded-tl-none text-sm text-gray-200 max-w-xs"> 312 Conversa reiniciada! Como posso ajudar? 313 </div> 314 </div>`; 315 } 316 317 function appendUserBubble(text, images = []) { 318 const c = document.getElementById('chatMessages'); 319 let imgHtml = ''; 320 if (images && images.length > 0) { 321 imgHtml = `<div class="flex flex-wrap gap-1 mt-2 justify-end">${images.map(img => `<img src="${img.data}" class="w-12 h-12 object-cover rounded-md">`).join('')}</div>`; 322 } 323 c.innerHTML += `<div class="flex justify-end"><div class="chat-bubble-user">${sanitize(text)}${imgHtml}</div></div>`; 324 c.scrollTop = c.scrollHeight; 325 } 326 327 function appendAIBubble(html_content, actionCard) { 328 const c = document.getElementById('chatMessages'); 329 c.innerHTML += `<div class="flex items-start space-x-2"> 330 <div class="w-7 h-7 bg-violet-700 rounded-full flex items-center justify-center text-xs flex-shrink-0">${_icon('bot',15)}</div> 331 <div> 332 <div class="chat-bubble-ai">${html_content}</div> 333 ${actionCard ? `<div class="chat-action-card">${actionCard}</div>` : ''} 334 </div> 335 </div>`; 336 c.scrollTop = c.scrollHeight; 337 } 338 339 function appendLoadingBubble() { 340 const c = document.getElementById('chatMessages'); 341 c.innerHTML += `<div class="flex items-start space-x-2" id="aiLoading"> 342 <div class="w-7 h-7 bg-violet-700 rounded-full flex items-center justify-center text-xs flex-shrink-0">${_icon('bot',15)}</div> 343 <div class="chat-bubble-ai flex items-center space-x-2"> 344 <div class="animate-spin h-3 w-3 border-2 border-violet-400 border-t-transparent roun
344ded-full"></div> 345 <span class="text-xs text-gray-400">Processando...</span> 346 </div> 347 </div>`; 348 c.scrollTop = c.scrollHeight; 349 } 350 351 async function sendToAssistant() { 352 if (!localStorage.getItem(_ob('cropflow_ia_used'))) saveOnboardingProgress(_ob('cropflow_ia_used')); 353 updateOnboardingChecklist(); 354 const input = document.getElementById('assistantInput'); 355 const question = input.value.trim(); 356 357 if (!question && assistantPhotos.length === 0) return; 358 359 if (question.length > 2000) { await showAlert('Mensagem muito longa. Máximo 2000 caracteres.'); return; } 360 if (!await checkTrialLimitAsync('ai', true)) return; 361 362 const now_ms = Date.now(); 363 if (now_ms - _lastAiCall < AI_DEBOUNCE_MS) { 364 const wait = Math.ceil((AI_DEBOUNCE_MS - (now_ms - _lastAiCall)) / 1000); 365 await showAlert(`Aguarde ${wait} segundo${wait !== 1 ? 's' : ''} antes de enviar outra mensagem.`); 366 return; 367 } 368 _lastAiCall = now_ms; 369 370 const imagesToSend = [...assistantPhotos]; 371 assistantPhotos = []; 372 renderAssistantPhotos(); 373 374 input.value = ''; 375 input.disabled = true; 376 document.getElementById('assistantSendBtn').disabled = true; 377 378 appendUserBubble(question, imagesToSend); 379 appendLoadingBubble(); 380 381 try { 382 const now = new Date(); 383 const todayVisits = visits.filter(v => { 384 const d = new Date(v.scheduledDate); 385 return d.toDateString() === now.toDateString(); 386 }); 387 const weekVisits = visits.filter(v => { 388 const d = new Date(v.scheduledDate); 389 const diff = (d - now) / (1000*60*60*24); 390 return diff >= -1 && diff <= 7; 391 }); 392 const pendingVisits = visits.filter(v => !v.completed && v.scheduledDate > Date.now()) 393 .sort((a,b) => a.scheduledDate - b.scheduledDate).slice(0,10); 394 395 // Escopo: o que a pergunta cita vence o que estava em foco. 396 const _escopoCitado = _escopoDaPergunta(question); 397 if (_escopoCitado) definirEscopoIA(_escopoCitado); 398 const _foco = _escopoIA && clients.find(c => c.id === _escopoIA); 399 400 // Clima é o bloco mais caro do contexto (previsão de 7 dias) e 401 // só serve para pergunta de clima/aplicação. Vai sob demanda. 402 const _querClima = /clima|chuv|chov|vento|tempo|temperatura|umidade|pulveriz|aplica|geada|seca/i.test(question || ''); 403 404 const context = { 405 dataHoje: now.toLocaleDateString('pt-BR', {weekday:'long', day:'numeric', month:'long', year:'numeric'}), 406 horaAtual: now.toLocaleTimeString('pt-BR', {hour:'2-digit', minute:'2-digit'}), 407 focoDaConversa: _foco ? (_foco.farm_name || _foco.name) : null, 408 visitasHoje: todayVisits.map(v => ({ 409 id: v.id, 410 cliente: clients.find(c=>c.id===v.client_id)?.name || 'Pessoal', 411 hora: new Date(v.scheduledDate).toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'}), 412 objetivo: v.objective, 413 concluida: v.completed 414 })), 415 proximasVisitas: pendingVisits.map(v => ({ 416 id: v.id, 417 cliente: clients.find(c=>c.id===v.client_id)?.name || 'Pessoal', 418 data: new Date(v.scheduledDate).toLocaleDateString('pt-BR',{weekday:'short',day:'numeric',month:'short'}), 419 hora: new Date(v.scheduledDate).toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'}), 420 objetivo: v.objective 421 })), 422 // Ãndice da carteira: o suficiente para a IA saber quem 423 // existe e o que está esquecido. Sem telefone, sem notas â 424 // o fundo vem no dossiê, e só de quem está em foco. 425 // Com uma fazenda em foco, as outras só precisam existir pelo 426 // nome â a IA tem que saber quem está na carteira para agendar 427 // ou trocar de assunto, não o estado de cada uma. 428 clientes: clients.map(c => { 429 if (_foco) return { id: c.id, nome: c.name, fazenda: c.farm_name }; 430 const cVisits = visits.filter(v => v.client_id === c.id && v.completed) 431 .sort((a,b) => b.scheduledDate - a.scheduledDate); 432 const lastV = cVisits[0]; 433 return { 434 id: c.id, 435 nome: c.name, 436 fazenda: c.farm_name, 437 cultura: c.crop, 438 totalVisitas: cVisits.length, 439 diasSemVisita: lastV ? Math.floor((Date.now() - lastV.scheduledDate) / 86400000) : null, 440 }; 441 }), 442 // Dossiê: só da fazenda em foco. à o que faz a resposta ser 443 // agronômica em vez de genérica. 444 fazendaEmFoco: !_foco ? null : (() => { 445 const _fv = visits.filter(v => v.client_id === _foco.id) 446 .sort((a,b) => b.scheduledDate - a.scheduledDate); 447 const _fh = (farmHistory || []).filter(h => h.client_id === _foco.id) 448 .sort((a,b) => new Date(b.created_at) - new Date(a.created_at)); 449 return { 450 nome: _foco.name, 451 fazenda: _foco.farm_name, 452 cidade: _foco.city, 453 cultura: _foco.crop, 454 area: _foco.area_ha, 455 visitas: _fv.slice(0, 8).map(v => ({ 456 data: new Date(v.scheduledDate).toLocaleDateString('pt-BR'), 457 concluida: v.completed, 458 objetivo: v.objective, 459 estadio: v.stage, 460 safra: v.safra, 461 diagnostico: v.notes ? String(v.notes).slice(0, 600) : null, 462 proximosPassos: v.next_steps ? String(v.next_steps).slice(0, 300) : null, 463 proximaVisita: v.proxima_visita || null 464 })), 465 lavoura: _fh.slice(0, 15).map(h => ({ 466 data: h.created_at ? new Date(h.created_at).toLocaleDateString('pt-BR') : null, 467 cultura: h.culture, 468 estagio: h.stage, 469 problema: h.problem,
470 severidade: h.severity, 471 produto: h.products_applied, 472 dose: h.dose, 473 status: h.status, 474 reentrada: h.reentry_date, 475 observacoes: h.observations ? String(h.observations).slice(0, 300) : null 476 })) 477 }; 478 })(), 479 /* Quem está sem visita â incluindo quem NUNCA foi visitado. 480 O filtro anterior fazia `if (!cV.length) return false`, ou 481 seja, descartava exatamente o caso mais grave: o produtor 482 cadastrado que nunca recebeu uma visita não aparecia na 483 lista de abandonados. A IA respondia "não há clientes sem 484 visita há mais de 30 dias" com um cliente nessa situação 485 na carteira. */ 486 clientesSemVisita: clients.map(c => { 487 const cV = visits.filter(v => v.client_id === c.id && v.completed); 488 if (!cV.length) { 489 return { nome: c.name, fazenda: c.farm_name || null, situacao: 'nunca visitado', dias: null }; 490 } 491 const ultima = Math.max(...cV.map(v => v.scheduledDate)); 492 const dias = Math.floor((Date.now() - ultima) / 86400000); 493 if (dias <= 30) return null; 494 return { nome: c.name, fazenda: c.farm_name || null, situacao: 'sem visita', dias }; 495 }).filter(Boolean), 496 497 /* As visitas que Jà ACONTECERAM. Faltava por completo: o 498 contexto tinha as de hoje, as futuras e a CONTAGEM das 499 concluÃdas â nunca a lista. A IA sabia que existiam três 500 visitas e não sabia de quem, quando, nem o que foi 501 diagnosticado. Por isso respondia "não há visitas 502 registradas nesta semana" no dia seguinte a uma visita. */ 503 visitasRealizadas: visits 504 .filter(v => v.completed) 505 .sort((a, b) => b.scheduledDate - a.scheduledDate) 506 .slice(0, 12) 507 .map(v => ({ 508 data: new Date(v.scheduledDate).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' }), 509 diasAtras: Math.floor((Date.now() - v.scheduledDate) / 86400000), 510 cliente: clients.find(c => c.id === v.client_id)?.name || 'Pessoal', 511 objetivo: v.objective || null, 512 estadio: v.stage || null, 513 diagnostico: v.notes ? String(v.notes).slice(0, 220) : null, 514 proximosPassos: v.next_steps ? String(v.next_steps).slice(0, 160) : null, 515 retornoMarcado: v.proxima_visita || null, 516 laudoEnviado: !!v.laudo_enviado_em, 517 confirmadoPeloProdutor: !!v.client_confirmed 518 })), 519 // Sem foco, uma amostra geral da lavoura; com foco, o dossiê 520 // acima já cobre â repetir seria pagar duas vezes. 521 historicoRecente: _foco ? [] : (farmHistory || []).slice(0, 10).map(h => ({ 522 cliente: clients.find(c => c.id === h.client_id)?.name || '?', 523 cultura: h.culture, 524 problema: h.problem, 525 produto: h.products_applied, 526 severidade: h.severity, 527 data: h.created_at ? new Date(h.created_at).toLocaleDateString('pt-BR') : null 528 })), 529 totalVisitas: visits.length, 530 visitasConcluidas: visits.filter(v=>v.completed).length, 531 totalClientes: clients.length, 532 plano: isPro() ? 'PRO' : 'Trial', 533 climaAtual: (weatherDataCache && _querClima) ? { 534 temperatura: weatherDataCache.current?.temperature_2m ? Math.round(weatherDataCache.current.temperature_2m) + '°C' : null, 535 umidade: weatherDataCache.current?.relative_humidity_2m ? weatherDataCache.current.relative_humidity_2m + '%' : null, 536 vento: weatherDataCache.current?.wind_speed_10m ? Math.round(weatherDataCache.current.wind_speed_10m) + ' km/h' : null, 537 descricao: weatherDataCache.current?.weather_code !== undefined ? getWeatherDesc(weatherDataCache.current.weather_code) : null, 538 cidade: document.getElementById('weatherLocation')?.textContent || 'Desconhecida', 539 previsao7dias: weatherDataCache.daily ? weatherDataCache.daily.time.slice(0,7).map((d,i) => ({ 540 data: new Date(d+'T12:00:00').toLocaleDateString('pt-BR',{weekday:'short',day:'numeric',month:'short'}), 541 max: Math.round(weatherDataCache.daily.temperature_2m_max[i]) + '°C', 542 min: Math.round(weatherDataCache.daily.temperature_2m_min[i]) + '°C', 543 condicao: getWeatherDesc(weatherDataCache.daily.weather_code[i]) 544 })) : [] 545 } : null 546 }; 547 548 // Blindagem (Fase 4): instruções/regras/ações + recusa + anti-injeção vivem no 549 // SERVIDOR (modo:'chat'). Aqui mandamos só os DADOS como contexto + a pergunta. 550 const contexto = [ 551 'DADOS COMPLETOS DO USUÃRIO (JSON):', 552 JSON.stringify(context, null, 2), 553 '', 554 'HISTÃRICO DA CONVERSA: ' + chatHistory.slice(-8).map(m => m.role+': '+m.content).join(' | '), 555 '', 556 'Data atual: ' + now.toISOString() + '. Plano: ' + (isPro() ? 'PRO' : 'Trial') + '. Idioma: Português brasileiro.', 557 ].join('\n'); 558 559 const response = await consultarAssistenteComImagens(question, imagesToSend, { modo: 'chat', contexto }); 560 document.getElementById('aiLoading')?.remove(); 561 562 if (!response || response === '__SERVER_ERROR__') { 563 input.disabled = false;
564 document.getElementById('assistantSendBtn').disabled = false; 565 input.focus(); 566 appendAIBubble('Erro desconhecido ao contatar a IA. Tente novamente.'); 567 return; 568 } 569 if (ehErroIA(response)) { 570 input.disabled = false; 571 document.getElementById('assistantSendBtn').disabled = false; 572 input.focus(); 573 appendAIBubble(textoErroIA(response)); 574 return; 575 } 576 577 let parsed = null; 578 try { 579 const trimmed = response.trim(); 580 if (trimmed.startsWith('{')) { 581 parsed = JSON.parse(trimmed); 582 } 583 } catch(e1) { 584 try { 585 const jsonMatch = response.match(/\{[\s\S]*\}/); 586 if (jsonMatch) parsed = JSON.parse(jsonMatch[0]); 587 } catch(e2) { 588 } 589 } 590 591 if (parsed && parsed.acao && parsed.mensagem) { 592 chatHistory.push({role:'user', content: question}); 593 chatHistory.push({role:'assistant', content: parsed.mensagem}); 594 await executeAssistantAction(parsed); 595 } else if (parsed && parsed.mensagem) { 596 chatHistory.push({role:'user', content: question}); 597 chatHistory.push({role:'assistant', content: parsed.mensagem}); 598 appendAIBubble(sanitizeAI(parsed.mensagem)); 599 } else { 600 let cleanResponse = response; 601 const msgMatch = response.match(/"mensagem"\s*:\s*"((?:[^"\\]|\\.)*)"/); 602 if (msgMatch) { 603 try { 604 cleanResponse = JSON.parse('"' + msgMatch[1] + '"'); 605 } catch(_) { 606 cleanResponse = msgMatch[1].replace(/\\n
606/g, '\n').replace(/\\"/g, '"'); 607 } 608 } 609 chatHistory.push({role:'user', content: question}); 610 chatHistory.push({role:'assistant', content: cleanResponse}); 611 appendAIBubble(sanitizeAI(cleanResponse)); 612 } 613 } catch(e) { 614 document.getElementById('aiLoading')?.remove(); 615 appendAIBubble('Erro ao processar. Tente novamente.'); 616 } 617 618 input.disabled = false; 619 document.getElementById('assistantSendBtn').disabled = false; 620 input.focus(); 621 if (typeof renderTrialUI === 'function') renderTrialUI(); 622 } 623 624 /* Linha de informação nos cards da IA. O emoji de prefixo mudava de 625 desenho em cada aparelho e não combinava com o resto do app, que já 626 tem seus próprios Ãcones. */ 627 function _linhaCard(icone, conteudo) { 628 return '<span style="display:inline-flex;align-items:center;gap:6px;">' 629 + '<span style="color:#9ca3af;display:inline-flex;">' + _icon(icone, 14) + '</span>' 630 + conteudo + '</span>'; 631 } 632 633 async function executeAssistantAction(parsed) { 634 const { acao, params, mensagem } = parsed; 635 636 if (acao === 'responder') { 637 appendAIBubble(sanitizeAI(mensagem)); 638 return; 639 } 640 641 if (acao === 'listar_agenda' || acao === 'resumo_dia') { 642 appendAIBubble(sanitizeAI(mensagem)); 643 return; 644 } 645 646 if (acao === 'info_cliente') { 647 const nome = params.nome?.toLowerCase() || ''; 648 const found = clients.find(c => c.name.toLowerCase().includes(nome)); 649 let card = ''; 650 if (found) { 651 card = `${_linhaCard('user', `<strong>${sanitize(found.name)}</strong>`)}<br> 652 ${_linhaCard('leaf', sanitize(found.farm_name||'â'))}<br> 653 ${_linhaCard('mapPin', sanitize(found.city||'â') + (found.state?', '+found.state:''))}<br> 654 ${_linhaCard('phone', `<a href="tel:${found.phone}" style="color:#34d399">${sanitize(found.phone||'â')}</a>`)}`; 655 } 656 appendAIBubble(sanitizeAI(mensagem), card); 657 return; 658 } 659 660 if (acao === 'criar_visita') { 661 const clienteNome = params.cliente_nome || ''; 662 const client = clients.find(c => c.name.toLowerCase().includes(clienteNome.toLowerCase())); 663 664 if (!params.data || !params.hora) { 665 appendAIBubble(sanitizeAI(mensagem)); 666 return; 667 } 668 669 const confirmCard = `${_linhaCard('checkCircle', '<strong>Criar esta visita?</strong>')}<br> 670 ${_linhaCard('user', sanitize(client?.name || clienteNome || 'Sem cliente'))}<br> 671 ${_linhaCard('calendar', '')}${new Date(params.data+'T'+params.hora).toLocaleDateString('pt-BR',{weekday:'long',day:'numeric',month:'long'})} à s ${params.hora}<br> 672 ${_linhaCard('target', sanitize(params.objetivo||'Visita técnica'))}<br><br> 673 <button onclick="this.disabled=true; this.parentElement.parentElement.remove(); confirmarCriarVisitaIA(${JSON.stringify(params).replace(/'/g,'\x27')})" 674 style="background:#10b981;color:white;border:none;padding:8px 16px;border-radius:10px;cursor:pointer;font-size:13px;font-weight:600;"> 675 Confirmar e criar 676 </button> 677 <button onclick="this.parentElement.parentElement.remove()" 678 style="background:#1C2942;color:#9ca3af;border:none;padding:8px 12px;border-radius:10px;cursor:pointer;font-size:13px;margin-left:8px;"> 679 Cancelar 680 </button>`; 681 appendAIBubble(sanitizeAI(mensagem), confirmCard); 682 return; 683 } 684 685 if (acao === 'adicionar_cliente') { 686 const confirmCard = `${_linhaCard('user', '<strong>Adicionar este cliente?</strong>')}<br> 687 Nome: ${sanitize(params.nome||'')}<br> 688 Tel: ${sanitize(params.telefone||'â')}<br> 689 Cidade: ${sanitize(params.cidade||'â')}<br><br> 690 <button onclick="confirmarAdicionarClienteIA(${JSON.stringify(params).replace(/'/g,'\x27')})" 691 style="background:#10b981;color:white;border:none;padding:8px 16px;border-radius:10px;cursor:pointer;font-size:13px;font-weight:600;"> 692 Confirmar e adicionar 693 </button> 694 <button onclick="this.parentElement.parentElement.remove()" 695 style="background:#1C2942;color:#9ca3af;border:none;padding:8px 12px;border-radius:10px;cursor:pointer;font-size:13px;margin-left:8px;"> 696 Cancelar 697 </button>`; 698 appendAIBubble(sanitizeAI(mensagem), confirmCard); 699 return; 700 } 701 702 if (acao === 'finalizar_visita') { 703 if (!params.visita_id) { 704 const hoje = visits.filter(v => { 705 const d = new Date(v.scheduledDate); 706 return d.toDateString() === new Date().toDateString() && !v.completed; 707 }); 708 if (hoje.length === 0) { 709 appendAIBubble('Não encontrei visitas de hoje para finalizar. Qual visita deseja finalizar?'); 710 return; 711 } 712 const btns = hoje.map(v => { 713 const c = clients.find(x=>x.id===v.client_id); 714 return `<button onclick="openFinalizeModal('${v.
714id}')" style="background:#1C2942;color:white;border:none;padding:6px 12px;border-radius:10px;cursor:pointer;font-size:12px;margin:2px;">${sanitize(c?.name||'Pessoal')}</button>`; 715 }).join(''); 716 appendAIBubble(sanitizeAI(mensagem), `Escolha qual visita finalizar:<br>${btns}`); 717 } else { 718 openFinalizeModal(params.visita_id); 719 appendAIBubble(sanitizeAI(mensagem)); 720 } 721 return; 722 } 723 724 appendAIBubble(sanitizeAI(mensagem)); 725 } 726 727 /* Casa o nome que a IA devolveu com um produtor da carteira. 728 O código anterior era `clients.find(c => c.name.toLowerCase().includes(nome))`, 729 com dois defeitos graves: 730 1. nome VAZIO â que é literalmente o valor do template no prompt 731 ("cliente_nome":"") â faz includes('') casar com o PRIMEIRO da lista, 732 e a visita ia para o produtor errado sem ninguém perceber; 733 2. `c.name` nulo derrubava tudo com TypeError. 734 Agora exige nome, prioriza correspondência exata e, em caso de dúvida 735 entre vários, devolve ambÃguo em vez de escolher por conta própria. */ 736 function _acharClientePorNome(nome) { 737 const alvo = String(nome || '').trim().toLowerCase(); 738 if (alvo.length < 2) return { status: 'sem_nome' }; 739 740 const lista = (clients || []).filter(c => c && c.name); 741 const exato = lista.filter(c => c.name.trim().toLowerCase() === alvo); 742 if (exato.length === 1) return { status: 'ok', client: exato[0] }; 743 744 const comeca = lista.filter(c => c.name.trim().toLowerCase().startsWith(alvo)); 745 if (comeca.length === 1) return { status: 'ok', client: comeca[0] }; 746 747 const contem = lista.filter(c => c.name.toLowerCase().includes(alvo)); 748 if (contem.length === 1) return { status: 'ok', client: contem[0] }; 749 if (contem.length > 1) return { status: 'ambiguo', opcoes: contem.slice(0, 5) }; 750 return { status: 'nao_encontrado' }; 751 } 752 753 async function confirmarCriarVisitaIA(params) { 754 const achado = _acharClientePorNome(params.cliente_nome); 755 756 if (achado.status === 'ambiguo') { 757 appendAIBubble('Encontrei mais de um produtor com esse nome: <strong>' + 758 achado.opcoes.map(c => sanitize(c.name)).join(', ') + 759 '</strong>. Qual deles?'); 760 return; 761 } 762 763 // Sem nome reconhecido, a visita vira compromisso pessoal â o que é 764 // honesto. Antes ela era atribuÃda ao primeiro cliente da lista. 765 const client = achado.status === 'ok' ? achado.client : null; 766 if (achado.status === 'nao_encontrado' && params.cliente_nome) { 767 appendAIBubble('Não achei nenhum produtor chamado <strong>' + 768 sanitize(params.cliente_nome) + '</strong>. Vou criar como compromisso pessoal â ' + 769 'se for um cliente novo, cadastre antes.'); 770 } 771 772 // Data malformada vinda do modelo fazia .toISOString() lançar 773 // RangeError e derrubar a criação inteira, sem mensagem ao usuário. 774 const bruta = new Date(String(params.data || '') + 'T' + (params.hora || '08:00')); 775 if (isNaN(bruta.getTime())) { 776 appendAIBubble('Não consegui entender a data. Pode repetir no formato dia/mês e hora? Ex.: <strong>05/08 à s 8h</strong>.'); 777 return; 778 } 779 const scheduled_date = bruta.toISOString(); 780 const tempId = 'temp_' + Date.now(); 781 const newVisit = { 782 id: tempId, 783 user_id: currentUser.id, 784 client_id: client?.id || null, 785 scheduled_date, 786 scheduledDate: new Date(scheduled_date).getTime(), 787 objective: params.objetivo || 'Visita técnica', 788 notes: '', 789 is_personal: !client, 790 completed: false 791 }; 792 visits.push(newVisit); 793 updateDashboard(); 794 saveCache();
795 generateCalendar(); 796 797 appendAIBubble(`${_linhaCard('checkCircle', '<strong>Visita criada!</strong>')}<br> 798 ${_linhaCard('user', sanitize(client?.name || params.cliente_nome || 'Pessoal'))}<br> 799 ${_linhaCard('calendar', '')}${new Date(scheduled_date).toLocaleDateString('pt-BR',{weekday:'long',day:'numeric',month:'long'})} à s ${params.hora}`); 800 801 const insertData = { 802 user_id: currentUser.id, 803 client_id: client?.id || null, 804 scheduled_date, 805 objective: params.objetivo || 'Visita técnica', 806 notes: '', 807 is_personal: !client, 808 completed: false 809 }; 810 811 if (isOnline) { 812 const { data, error } = await supabaseClient.from('visits').insert(insertData).select().single(); 813 if (!error && data) { 814 const idx = visits.findIndex(v => v.id === tempId); 815 if (idx !== -1) visits[idx] = { ...data, scheduledDate: new Date(data.scheduled_date).getTime() }; 816 saveCache(); 817 updateDashboard(); 818 const calView = document.getElementById('calendarView'); 819 if (calView && calView.style.display !== 'none') generateCalendar(); 820 scheduleVisitAlarms(); 821 } else if (error) { 822 visits = visits.filter(v => v.id !== tempId); 823 updateDashboard(); 824 saveCache(); 825 appendAIBubble(`<strong>Erro ao salvar a visita no servidor.</strong><br>Detalhe: ${error.message || 'Erro desconhecido'}<br><br>Tente novamente ou use o botão + para agendar manualmente.`); 826 } 827 } else { 828 offlineQueue.push({ type: 'insert_visit', data: insertData, tempId }); 829 saveQueue(); 830 } 831 } 832 833 async function confirmarAdicionarClienteIA(params) { 834 const clientData = { 835 user_id: currentUser.id, 836 name: params.nome || '', 837 phone: params.telefone || '', 838 city: params.cidade || '', 839 farm_name: params.fazenda || '', 840 state: '', 841 notes: '' 842 }; 843 const tempId = 'temp_' + Date.now(); 844 clients.push({ ...clientData, id: tempId }); 845 renderClients(); 846 saveCache(); 847 848 appendAIBubble(`${_linhaCard('checkCircle', '<strong>Cliente adicionado!</strong>')}<br>${_icon('user',15)} ${sanitize(params.nome)}`); 849 850 if (isOnline) { 851 const { data, error } = await supabaseClient.from('clients').insert(clientData).select().single(); 852 if (!error && data) { 853 const idx = clients.findIndex(c => c.id === tempId); 854 if (idx !== -1) clients[idx] = data; 855 saveCache(); 856 } 857 } else { 858 offlineQueue.push({ type: 'insert_client', data: clientData, tempId }); 859 saveQueue(); 860 } 861 } 862 863 // Versão do termo. Mudou o texto, muda a constante: quem aceitou a versão 864 // anterior é perguntado de novo, e fica registrado qual texto cada um aceitou. 865 const AI_TERMS_VERSION = 'v1-2026-07'; 866 867 const AI_TERMS_TEXTO = 868 'Antes de usar a IA, confirme que você entende:\n\n' + 869 '1. A IA redige MINUTA. Quem assina, revisa e responde tecnicamente pelo documento é você.\n\n' + 870 '2. A IA não emite receituário agronômico e não define dose por conta própria. Dose, carência e alvo saem da bula e do registro do produto no Agrofit/MAPA.\n\n' + 871 '3. Todo texto sugerido deve ser conferido antes de virar laudo assinado ou ir para o produtor.\n\n' + 872 '4. A responsabilidade técnica do documento é do profissional habilitado que o assina.'; 873 874 // Ponto único por onde toda chamada de IA passa â é aqui que o aceite é 875 // exigido, e não em cada tela que usa o assistente. 876 async function ensureAiTermsAccepted() { 877 if (currentProfile?.ai_terms_accepted_at && currentProfile?.ai_terms_version === AI_TERMS_VERSION) return true; 878 879 const aceitou = await showConfirm(AI_TERMS_TEXTO, 'Li e assumo a responsabilidade', false); 880 if (!aceitou) return false;
881 882 const agora = new Date().toISOString(); 883 if (currentProfile) { 884 currentProfile.ai_terms_accepted_at = agora; 885 currentProfile.ai_terms_version = AI_TERMS_VERSION; 886 } 887 try { 888 await supabaseClient.from('profiles') 889 .update({ ai_terms_accepted_at: agora, ai_terms_version: AI_TERMS_VERSION }) 890 .eq('user_id', currentUser.id); 891 } catch (_) { 892 // Falha de rede não pode travar o uso: o aceite fica na sessão e sobe 893 // no próximo salvamento de perfil. 894 } 895 return true; 896 } 897 898 /* Aviso de cota acabando. 899 900 O teto mensal do PRO é novo â antes o assinante não tinha limite 901 nenhum. Criar um limite e deixar que ele descubra quando a IA parar 902 de responder, no meio de uma visita, é puxar o tapete de quem paga. 903 O aviso aparece a partir de 15% restante, uma vez por dia, e diz o 904 que fazer. Uma vez por dia porque avisar a cada pergunta vira ruÃdo, 905 e ruÃdo a gente aprende a ignorar. */ 906 function _avisarCotaBaixa(data) { 907 try { 908 const rest = data?.monthly_remaining; 909 const teto = data?.monthly_limit; 910 if (typeof rest !== 'number' || typeof teto !== 'number' || teto <= 0) return; 911 if (rest > teto * 0.15) return; 912 913 const chave = 'cf_aviso_cota_' + new Date().toISOString().slice(0, 10); 914 if (localStorage.getItem(chave)) return; 915 localStorage.setItem(chave, '1'); 916 917 const msg = rest <= 0 918 ? 'Seu uso de IA do mês acabou. Ele volta conforme os dias antigos saem da contagem â ou fale com o suporte se seu volume é esse mesmo.' 919 : 'Resta pouco uso de IA neste mês. Se seu volume de campo é esse mesmo, fale com o suporte que a gente ajusta.'; 920 if (typeof showToast === 'function') showToast(msg); 921 } catch (_) { /* aviso nunca pode quebrar a resposta da IA */ } 922 } 923 924 /* Marca de erro da IA. 925 926 A sinalização era o EMOJI no começo da string: quem chamava testava 927 `ehErroIA(resp)`. Isso quebra de um jeito silencioso â o prompt 928 do WhatsApp permite emoji na resposta, então um resumo legÃtimo que 929 comece com "Percevejo acima do nÃvel" seria tratado como erro e 930 jogado fora. Também prendia o texto: mudar a mensagem quebrava a 931 detecção. 932 933 Agora a marca é um caractere de controle invisÃvel no inÃcio. O 934 usuário continua lendo a mesma frase; o código para de adivinhar. */ 935 const CF_ERRO_IA = '\u0001'; 936 function _erroIA(msg) { return CF_ERRO_IA + msg; } 937 window.ehErroIA = function (t) { return typeof t === 'string' && t.charCodeAt(0) === 1; }; 938 window.textoErroIA = function (t) { return window.ehErroIA(t) ? t.slice(1) : t; }; 939 940 async function consultarAssistenteComImagens(pergunta, imagens, opts = {}) { 941 if (!await ensureAiTermsAccepted()) { 942 return 'Para usar o assistente é preciso aceitar o termo de responsabilidade técnica.'; 943 } 944 try { 945 let { data: sessionData, error: sessionError } = await supabaseClient.auth.getSession(); 946 let session = sessionData?.session; 947 948 if (!session || sessionError) { 949 const { data: refreshData } = await supabaseClient.auth.refreshSession(); 950 session = refreshData?.session; 951 } 952 953 if (!session) { 954 return _erroIA('Sua sessão expirou. Saia da conta e entre novamente para usar a IA.'); 955 } 956 957 const imagensFormatadas = (imagens || []).filter(img => 958 img && img.data && typeof img.data === 'string' && img.data.includes('base64,') 959 ).map(img => { 960 const match = img.data.match(/^data:(.+);base64,(.+)/); 961 if (!match) return null; 962 return { type: 'image', source: { type: 'base64', media_type: match[1], data: match[2] } }; 963 }
963).filter(Boolean); 964 965 const controller = new AbortController(); 966 const timeout = setTimeout(() => controller.abort(), 45000); 967 968 let response; 969 try { 970 response = await fetch('https://ephqjphpmyaajqjhjupe.supabase.co/functions/v1/chamar-assistente', { 971 method: 'POST', 972 headers: { 973 'Authorization': 'Bearer ' + session.access_token, 974 'Content-Type': 'application/json', 975 'apikey': SUPABASE_ANON_KEY 976 }, 977 body: JSON.stringify({ pergunta, imagens: imagensFormatadas, modo: opts.modo, contexto: opts.contexto }), 978 signal: controller.signal 979 }); 980 } finally { 981 clearTimeout(timeout); 982 } 983 984 let data; 985 try { 986 data = await response.json(); 987 } catch(jsonErr) { 988 return _erroIA('Resposta inválida do servidor (HTTP ' + response.status + '). Tente novamente.'); 989 } 990 991 if (!response.ok) { 992 const errorCode = data?.error || ''; 993 const errorMsg = data?.message || 'Erro ' + response.status; 994 995 if (errorCode === 'trial_expired') { 996 setTimeout(() => showUpgradeModal(errorMsg), 100); 997 return _erroIA('' + errorMsg); 998 } 999 if (errorCode === 'rate_limit_daily') { 1000 if (errorMsg.includes('upgrade')) setTimeout(() => showUpgradeModal(errorMsg), 100); 1001 return _erroIA('' + errorMsg); 1002 } 1003 if (errorCode === 'rate_limit_minute') { 1004 return _erroIA('' + errorMsg); 1005 } 1006 if (response.status === 401) { 1007 return _erroIA('Sessão inválida. Saia e entre novamente.'); 1008 } 1009 if (response.status === 403) { 1010 return _erroIA('Sem permissão para usar a IA (erro 403). Contate o suporte.'); 1011 } 1012 if (response.status === 500 || response.status === 503) { 1013 return _erroIA('Servidor de IA com instabilidade (erro ' + response.status + '). Tente em instantes.'); 1014 } 1015 return _erroIA('Erro ' + response.status + ': ' + errorMsg); 1016 } 1017 1018 if (data.daily_remaining !== undefined && data.daily_remaining <= 1 && !isPro()) { 1019 setTimeout(() => renderTrialUI(), 500); 1020 } 1021 _avisarCotaBaixa(data); 1022 1023 // Guarda qual modelo respondeu. O banco tem `visits.ai_model` e 1024 // `ai_generated` desde julho â o rastro de IA que fica no 1025 // registro, invisÃvel ao produtor, para o dia em que alguém 1026 // perguntar como o laudo foi redigido. Estavam VAZIOS: as 1027 // colunas existiam e ninguém gravava. Agora que há mais de um 1028 // modelo em uso, dizer "foi IA" sem dizer qual não serve. 1029 if (data.modelo) window._cfUltimoModeloIA = data.modelo; 1030 return data.resposta || 'Resposta vazia do servidor.'; 1031 1032 } catch(e) { 1033 if (e.name === 'AbortError') { 1034 return _erroIA('A IA demorou mais de 45s para responder. Conexão lenta ou servidor ocupado. Tente novamente.'); 1035 } 1036 if (e.name === 'TypeError' || e.message?.includes('fetch') || e.message?.includes('network') || e.message?.includes('Network')) { 1037 return _erroIA('Sem conexão com o servidor de IA. Verifique sua internet e tente novamente.'); 1038 } 1039 return _erroIA('Erro inesperado: ' + (e.message || 'desconhecido')); 1040 } 1041 } 1042 1043 function syncVisitsToSW() { 1044 if (!('serviceWorker' in navigator) || !navigator.serviceWorker.controller) return; 1045 try { 1046 const visitsForSW = visits 1047 .filter(v => !v.completed && v.scheduledDate > Date.now()) 1048 .map(v => { 1049 const client = clients.find(c => c.id === v.client_id); 1050 return { 1051 id: v.id, 1052 scheduledDate: v.scheduledDate, 1053 completed: v.completed, 1054 clientName: client?.name || (v.is_personal ? 'Compromisso Pessoal' : 'Visita'), 1055 farmName: client?.farm_name || '' 1056 }; 1057 }); 1058 navigator.serviceWorker.controller.postMessage({ 1059 type: 'SYNC_VISITS_DATA', 1060 visits: visitsForSW 1061 }); 1062 } catch(e) { 1063 } 1064 }
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.