leonardo преди 1 месец
родител
ревизия
d521db44ef
променени са 6 файла, в които са добавени 39 реда и са изтрити 10 реда
  1. 17 1
      src/api/chat.js
  2. 1 1
      src/composables/useAuth.js
  3. 4 2
      src/composables/useConversas.js
  4. 9 4
      src/composables/useDocumentos.js
  5. 1 2
      src/styles/tailwind.css
  6. 7 0
      src/views/atendimentos/AtendimentoView.vue

+ 17 - 1
src/api/chat.js

@@ -55,9 +55,20 @@ export async function sendChatStream(message, { conversationId, mode, onChunk, o
   const decoder = new TextDecoder();
   let buffer = "";
 
+  const STREAM_INACTIVITY_TIMEOUT_MS = 45_000;
+  function readWithTimeout() {
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => reject(new Error("stream_inactivity_timeout")), STREAM_INACTIVITY_TIMEOUT_MS);
+      reader.read().then(
+        (result) => { clearTimeout(timer); resolve(result); },
+        (err) => { clearTimeout(timer); reject(err); }
+      );
+    });
+  }
+
   try {
     while (true) {
-      const { done, value } = await reader.read();
+      const { done, value } = await readWithTimeout();
       if (done) break;
       buffer += decoder.decode(value, { stream: true });
       const lines = buffer.split("\n");
@@ -77,6 +88,11 @@ export async function sendChatStream(message, { conversationId, mode, onChunk, o
     }
   } catch (e) {
     if (e?.name === "AbortError") return;
+    if (e?.message === "stream_inactivity_timeout") {
+      reader.cancel().catch(() => {});
+      onError?.(new Error("A resposta demorou demais e foi interrompida."));
+      return;
+    }
     onError?.(e);
   }
 

+ 1 - 1
src/composables/useAuth.js

@@ -61,7 +61,7 @@ function doRefresh() {
     const result = await refreshTokenRequest({ refreshToken: rt });
     // logout()/authErrorCallback podem ter rodado enquanto o refresh estava em
     // voo — não reaplicar sessão/token de um ciclo de auth que já terminou
-    if (epoch !== _authEpoch) return result.accessToken;
+    if (epoch !== _authEpoch) throw new Error("auth_epoch_stale");
     session.value = {
       ...session.value,
       accessToken: result.accessToken,

+ 4 - 2
src/composables/useConversas.js

@@ -97,7 +97,7 @@ export function useConversas() {
       sugestao.value = null;
       sugestaoError.value = err?.message ?? "Falha ao gerar sugestão";
     } finally {
-      if (conversaAtivaId.value === idAlvo) sugestaoLoading.value = false;
+      sugestaoLoading.value = false;
     }
   }
 
@@ -167,9 +167,11 @@ export function useConversas() {
 
   async function enviarResposta(texto) {
     if (!conversaAtivaId.value || !texto.trim()) return;
+    const idAlvo = conversaAtivaId.value;
     enviando.value = true;
     try {
-      const mensagem = await responderConversa(conversaAtivaId.value, texto.trim());
+      const mensagem = await responderConversa(idAlvo, texto.trim());
+      if (conversaAtivaId.value !== idAlvo) return;
       mensagens.value.push(mensagem);
       ultimoTimestamp = mensagem.Timestamp;
       sugestao.value = null;

+ 9 - 4
src/composables/useDocumentos.js

@@ -8,28 +8,33 @@ export function useDocumentos() {
     error: "",
   });
 
-  const PAGE_SIZE = 200; 
-  const MAX_PAGES = 25; 
+  const PAGE_SIZE = 200;
+  const MAX_PAGES = 25;
+  let docsEpoch = 0;
 
   async function loadDocs() {
+    const epoch = ++docsEpoch;
     docs.loading = true;
     docs.error = "";
     try {
-      
+
       const all = [];
       let offset = 0;
       for (let page = 0; page < MAX_PAGES; page += 1) {
         const data = await listDocuments({ limit: PAGE_SIZE, offset });
+        if (epoch !== docsEpoch) return;
         all.push(...(data.items ?? []));
         if (data.nextOffset == null) break;
         offset = data.nextOffset;
       }
+      if (epoch !== docsEpoch) return;
       docs.items = all;
     } catch (e) {
+      if (epoch !== docsEpoch) return;
       docs.error = e?.message || "erro";
       docs.items = [];
     } finally {
-      docs.loading = false;
+      if (epoch === docsEpoch) docs.loading = false;
     }
   }
 

+ 1 - 2
src/styles/tailwind.css

@@ -273,8 +273,7 @@ body {
     box-shadow: 0 0 0 3px color-mix(in oklab, var(--ring) 18%, transparent);
   }
 
-  /* fallback global de foco por teclado — cobre qualquer elemento focável que não
-     defina seu próprio focus-visible:ring explícito (ex.: itens de lista com role="button") */
+  
   a:focus-visible,
   button:focus-visible,
   summary:focus-visible,

+ 7 - 0
src/views/atendimentos/AtendimentoView.vue

@@ -84,6 +84,12 @@ function montarPayloadGoldenLabel() {
   };
 }
 
+function aoMudarHorarioVisitaInformado() {
+  if (form.value.HorarioVisitaInformado !== "sim") {
+    form.value.HorarioVisita = "";
+  }
+}
+
 async function salvarRotulo() {
   if (salvando.value || !atendimento.value) return;
   salvando.value = true;
@@ -511,6 +517,7 @@ watch(() => route.params.id, carregar, { immediate: true });
               <label class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Horário de visita informado</label>
               <select
                 v-model="form.HorarioVisitaInformado"
+                @change="aoMudarHorarioVisitaInformado"
                 class="mt-1 w-full rounded-lg border border-gray-200 bg-background px-3 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-primary/30 dark:border-gray-700 dark:text-gray-200"
               >
                 <option value="">-- não rotulado --</option>