leonardo 1 vecka sedan
förälder
incheckning
30f8c0b2d3
5 ändrade filer med 106 tillägg och 36 borttagningar
  1. 6 1
      src/api/auth.js
  2. 2 2
      src/api/conversas.js
  3. 57 11
      src/composables/useAuth.js
  4. 22 14
      src/composables/useChat.js
  5. 19 8
      src/composables/useConversas.js

+ 6 - 1
src/api/auth.js

@@ -1,5 +1,7 @@
 import { apiBaseUrl, extractErrorMessage, ApiError, categorizeHttpStatus } from "./client.js";
 
+const AUTH_TIMEOUT_MS = 20_000;
+
 async function parseResponse(res) {
   const contentType = res.headers.get("content-type") ?? "";
   if (contentType.includes("application/json")) {
@@ -25,7 +27,10 @@ async function postAuth(caminho, corpo, comApiError = false) {
     res = await fetch(`${apiBaseUrl}${caminho}`, {
       method: "POST",
       headers: { "content-type": "application/json" },
-      body: JSON.stringify(corpo)
+      body: JSON.stringify(corpo),
+      // o refresh roda sob um lock compartilhado entre abas (useAuth): sem timeout, um
+      // refresh pendurado numa aba travaria a renovação de todas as outras
+      signal: AbortSignal.timeout(AUTH_TIMEOUT_MS)
     });
   } catch {
     throw new ApiError("network", "network_error");

+ 2 - 2
src/api/conversas.js

@@ -4,8 +4,8 @@ export function listarConversas() {
   return apiFetch("/api/conversas");
 }
 
-export function listarMensagensConversa(id, after) {
-  const query = after ? `?after=${encodeURIComponent(after)}` : "";
+export function listarMensagensConversa(id, afterId) {
+  const query = afterId ? `?afterId=${encodeURIComponent(afterId)}` : "";
   return apiFetch(`/api/conversas/${id}/mensagens${query}`);
 }
 

+ 57 - 11
src/composables/useAuth.js

@@ -55,27 +55,73 @@ let _proactiveRefreshTimer = null;
 let _refreshInFlight = null;
 let _authEpoch = 0;
 
+// Abas com a mesma sessão (duas abas com "lembrar-me", ou uma aba duplicada) compartilham o
+// refresh token, que é rotativo. Sem coordenação, a aba que perde a corrida fica com o token
+// antigo e, ao reusá-lo depois da janela de tolerância do backend, dispara a detecção de reuso
+// — que revoga todas as sessões do usuário. Por isso: a renovação é serializada entre abas
+// (Web Locks) e a aba que renova avisa as outras do token novo (BroadcastChannel).
+const REFRESH_LOCK_NAME = "auth.refresh";
+// tempo para o aviso de outra aba chegar quando a nossa renovação perdeu a corrida
+const ESPERA_AVISO_OUTRA_ABA_MS = 500;
+
+const _canalSessao = typeof BroadcastChannel !== "undefined" ? new BroadcastChannel("auth.session") : null;
+
+function aplicarTokens({ accessToken, refreshToken }) {
+  session.value = {
+    ...session.value,
+    accessToken,
+    refreshToken: refreshToken ?? session.value?.refreshToken ?? null
+  };
+  persistSession(session.value);
+  setAccessToken(accessToken);
+  scheduleProactiveRefresh(accessToken);
+}
+
+_canalSessao?.addEventListener("message", (event) => {
+  const msg = event.data;
+  if (msg?.tipo !== "renovada" || !msg.de) return;
+  // só adota quem ainda está com o token que a outra aba acabou de trocar
+  if (session.value?.refreshToken !== msg.de) return;
+  aplicarTokens(msg);
+});
+
+function comLockEntreAbas(fn) {
+  if (typeof navigator !== "undefined" && navigator.locks?.request) {
+    return navigator.locks.request(REFRESH_LOCK_NAME, fn);
+  }
+  return fn();
+}
 
 function doRefresh() {
   if (_refreshInFlight) return _refreshInFlight;
   const epoch = _authEpoch;
-  _refreshInFlight = (async () => {
+  const rtInicial = session.value?.refreshToken;
+  _refreshInFlight = comLockEntreAbas(async () => {
+    // enquanto esperávamos o lock, outra aba pode ter renovado e avisado — usa o token dela
+    if (rtInicial && session.value?.refreshToken !== rtInicial && session.value?.accessToken) {
+      return session.value.accessToken;
+    }
     const rt = session.value?.refreshToken;
     if (!rt) throw new Error("no_refresh_token");
-    const result = await refreshTokenRequest({ refreshToken: rt });
+    let result;
+    try {
+      result = await refreshTokenRequest({ refreshToken: rt });
+    } catch (err) {
+      // perdemos a corrida para outra aba (o backend recusa o token recém-rotacionado sem
+      // revogar nada, dentro da janela de tolerância): espera o aviso dela antes de desistir
+      await new Promise((resolve) => setTimeout(resolve, ESPERA_AVISO_OUTRA_ABA_MS));
+      if (epoch === _authEpoch && session.value?.refreshToken !== rt && session.value?.accessToken) {
+        return session.value.accessToken;
+      }
+      throw err;
+    }
     // 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) throw new Error("auth_epoch_stale");
-    session.value = {
-      ...session.value,
-      accessToken: result.accessToken,
-      refreshToken: result.refreshToken ?? session.value?.refreshToken ?? null
-    };
-    persistSession(session.value);
-    setAccessToken(result.accessToken);
-    scheduleProactiveRefresh(result.accessToken);
+    aplicarTokens(result);
+    _canalSessao?.postMessage({ tipo: "renovada", de: rt, accessToken: result.accessToken, refreshToken: result.refreshToken ?? null });
     return result.accessToken;
-  })().finally(() => {
+  }).finally(() => {
     _refreshInFlight = null;
   });
   return _refreshInFlight;

+ 22 - 14
src/composables/useChat.js

@@ -227,12 +227,24 @@ export function useChat() {
     error.value = "";
   }
 
-  function cancelCurrentStream() {
+  // Abortado, sendChatStream só retorna (não chama onDone/onError), então quem aborta precisa
+  // fechar a mensagem em streaming — senão ela fica com streaming: true para sempre.
+  // `motivo` fica no próprio controller (não em abort(reason): com reason o fetch rejeita com o
+  // valor cru em vez de AbortError, e sendChatStream trataria o cancelamento como erro)
+  function abortarStreamAtivo(motivo) {
     _flushStream?.();
-    if (_streamAbort) {
-      _streamAbort.abort();
-      _streamAbort = null;
-    }
+    _flushStream = null;
+    if (!_streamAbort) return;
+    _streamAbort.motivo = motivo;
+    _streamAbort.abort();
+    _streamAbort = null;
+    messages.value = messages.value
+      .filter((m) => !(m.role === "assistant" && m.streaming && !m.content))
+      .map((m) => (m.role === "assistant" && m.streaming ? { ...m, streaming: false, statusMsg: "" } : m));
+  }
+
+  function cancelCurrentStream() {
+    abortarStreamAtivo("cancelado");
     loading.value = false;
   }
 
@@ -241,14 +253,7 @@ export function useChat() {
     const trimmed = String(content ?? "").trim();
     if (!trimmed) return;
 
-    if (_streamAbort) {
-      _flushStream?.();
-      _streamAbort.abort();
-
-      messages.value = messages.value
-        .filter((m) => !(m.role === "assistant" && m.streaming && !m.content))
-        .map((m) => (m.role === "assistant" && m.streaming ? { ...m, streaming: false } : m));
-    }
+    abortarStreamAtivo("nova_pergunta");
     const abortController = new AbortController();
     _streamAbort = abortController;
      
@@ -261,7 +266,10 @@ export function useChat() {
         const title = trimmed.slice(0, 60);
         const conv = await createConversation(title);
         if (_streamAbort !== abortController) {
-          toast.erro("Você enviou outra pergunta antes desta ser processada; esta pergunta não foi enviada.");
+          // sair da tela ou trocar de conversa também cancela — aí o aviso não se aplica
+          if (abortController.motivo === "nova_pergunta") {
+            toast.erro("Você enviou outra pergunta antes desta ser processada; esta pergunta não foi enviada.");
+          }
           return;
         }
         convId = conv.id;

+ 19 - 8
src/composables/useConversas.js

@@ -6,6 +6,14 @@ let singleton;
 
 const POLL_INTERVAL_MS = 3000;
 
+function compararMensagens(a, b) {
+  return new Date(a.Timestamp) - new Date(b.Timestamp) || a.Id - b.Id;
+}
+
+function maiorId(mensagens, atual) {
+  return mensagens.reduce((max, m) => (max == null || m.Id > max ? m.Id : max), atual);
+}
+
 export function resetConversasSingleton() {
   if (singleton) {
     singleton.fecharConversa();
@@ -33,7 +41,8 @@ export function useConversas() {
   const sugestaoError =  ref("");
 
   let pollTimer = null;
-  let ultimoTimestamp = null;
+  // cursor do polling: maior Id já recebido do servidor (ver listarMensagens no backend)
+  let ultimoId = null;
   let buscandoNovasMensagens = false;
   let conversasEpoch = 0;
   // duplo clique na mesma conversa passa pelo guard de id; o epoch distingue as aberturas
@@ -121,13 +130,14 @@ export function useConversas() {
     const idConsultado = conversaAtivaId.value;
     buscandoNovasMensagens = true;
     try {
-      const { results } = await listarMensagensConversa(idConsultado, ultimoTimestamp);
+      const { results } = await listarMensagensConversa(idConsultado, ultimoId);
       if (conversaAtivaId.value !== idConsultado) return;
       if (results.length > 0) {
         const idsExistentes = new Set(mensagens.value.map((m) => m.Id));
         const novas = results.filter((m) => !idsExistentes.has(m.Id));
-        mensagens.value = [...mensagens.value, ...novas];
-        ultimoTimestamp = results[results.length - 1].Timestamp;
+        // uma entrada atrasada pelo webhook pode ter Timestamp anterior às já exibidas
+        mensagens.value = [...mensagens.value, ...novas].sort(compararMensagens);
+        ultimoId = maiorId(results, ultimoId);
         if (novas.some((m) => m.Direcao === "entrada")) buscarSugestao();
       }
     } catch {
@@ -150,7 +160,7 @@ export function useConversas() {
     conversaAtivaId.value = id;
     mensagens.value = [];
     limparMidiaCache();
-    ultimoTimestamp = null;
+    ultimoId = null;
     invalidarSugestao();
     mensagensLoading.value = true;
     mensagensError.value = "";
@@ -159,7 +169,7 @@ export function useConversas() {
       const { results } = await listarMensagensConversa(id);
       if (abertura !== aberturaEpoch) return;
       mensagens.value = results;
-      ultimoTimestamp = results.length > 0 ? results[results.length - 1].Timestamp : null;
+      ultimoId = maiorId(results, null);
       if (results.length && results[results.length - 1].Direcao === "entrada") buscarSugestao();
     } catch (err) {
       if (abertura !== aberturaEpoch) return;
@@ -184,7 +194,7 @@ export function useConversas() {
     mensagens.value = [];
     mensagensLoading.value = false;
     limparMidiaCache();
-    ultimoTimestamp = null;
+    ultimoId = null;
     invalidarSugestao();
   }
 
@@ -195,8 +205,9 @@ export function useConversas() {
     try {
       const mensagem = await responderConversa(idAlvo, texto.trim());
       if (conversaAtivaId.value !== idAlvo) return;
+      // não avança o cursor: uma entrada gravada antes desta resposta (Id menor) pode ainda
+      // não ter vindo no polling; o próximo ciclo a traz e a dedup por Id ignora esta
       mensagens.value.push(mensagem);
-      ultimoTimestamp = mensagem.Timestamp;
       sugestao.value = null;
       sugestaoError.value = "";
       await carregarConversas();