PageSourceSearch

https://cropflow.com.br/js/ai.js

js cropflow.com.br collected 2026-10-01 03:52:55 UTC 62,137 bytes, 1,064 lines download raw bytes

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.