leonardo 3 달 전
부모
커밋
72c46e749a

+ 5 - 2
src/api/chat.js

@@ -7,7 +7,7 @@ export async function sendChat(message, { conversationId } = {}) {
   });
 }
 
-export async function sendChatStream(message, { conversationId, onChunk, onSources, onDone, onError } = {}) {
+export async function sendChatStream(message, { conversationId, onChunk, onSources, onDone, onError, signal } = {}) {
   const token = getAccessToken();
   const headers = { "content-type": "application/json" };
   if (token) headers["Authorization"] = `Bearer ${token}`;
@@ -17,9 +17,11 @@ export async function sendChatStream(message, { conversationId, onChunk, onSourc
     res = await fetch(`${apiBaseUrl}/api/chat/stream`, {
       method: "POST",
       headers,
-      body: JSON.stringify({ message, ...(conversationId ? { conversationId } : {}) })
+      body: JSON.stringify({ message, ...(conversationId ? { conversationId } : {}) }),
+      signal
     });
   } catch (e) {
+    if (e?.name === "AbortError") return;
     onError?.(e);
     return;
   }
@@ -54,6 +56,7 @@ export async function sendChatStream(message, { conversationId, onChunk, onSourc
       }
     }
   } catch (e) {
+    if (e?.name === "AbortError") return;
     onError?.(e);
   }
 

+ 5 - 1
src/api/client.js

@@ -3,6 +3,7 @@ export const apiBaseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:3001
 let _accessToken = null;
 let _refreshFn = null;
 let _authErrorFn = null;
+let _refreshPromise = null;
 
 export function setAccessToken(token) {
   _accessToken = token ?? null;
@@ -32,7 +33,10 @@ export async function apiFetch(path, { method = "GET", body } = {}) {
 
   if (res.status === 401 && _refreshFn) {
     try {
-      const newToken = await _refreshFn();
+      if (!_refreshPromise) {
+        _refreshPromise = _refreshFn().finally(() => { _refreshPromise = null; });
+      }
+      const newToken = await _refreshPromise;
       if (newToken) {
         headers["Authorization"] = `Bearer ${newToken}`;
         res = await fetch(`${apiBaseUrl}${path}`, {

+ 31 - 14
src/components/BotaoAlterarTema.vue

@@ -58,19 +58,36 @@ onMounted(() => {
     aria-label="Alternar tema"
     @click="darkMode = !darkMode"
   >
-    <svg v-if="darkMode" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
-      <path
-        stroke-linecap="round"
-        stroke-linejoin="round"
-        d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
-      />
-    </svg>
-    <svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
-      <path
-        stroke-linecap="round"
-        stroke-linejoin="round"
-        d="M21.752 15.002A9.718 9.718 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"
-      />
-    </svg>  
+    <Transition name="tema-icon" mode="out-in">
+      <svg v-if="darkMode" key="sun" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+        <path
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
+        />
+      </svg>
+      <svg v-else key="moon" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+        <path
+          stroke-linecap="round"
+          stroke-linejoin="round"
+          d="M21.752 15.002A9.718 9.718 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"
+        />
+      </svg>
+    </Transition>
   </button>
 </template>
+
+<style scoped>
+.tema-icon-enter-active,
+.tema-icon-leave-active {
+  transition: opacity 0.15s ease, transform 0.15s ease;
+}
+.tema-icon-enter-from {
+  opacity: 0;
+  transform: rotate(-90deg) scale(0.7);
+}
+.tema-icon-leave-to {
+  opacity: 0;
+  transform: rotate(90deg) scale(0.7);
+}
+</style>

+ 20 - 6
src/components/ChatWindow.vue

@@ -32,7 +32,7 @@
             :class="
               m.role === 'user'
                 ? 'border-primary/25 bg-primary/10 text-foreground'
-                : 'border-gray-200 bg-white/50 text-foreground dark:border-gray-700 dark:bg-white/[0.07]'
+                : 'border-gray-200 bg-white/50 text-foreground dark:border-gray-700 dark:bg-gray-800/60'
             "
           >
             {{ m.content }}
@@ -44,7 +44,7 @@
             <div class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-gray-400 text-[9px] font-bold text-white dark:bg-gray-600">O</div>
             <span class="text-xs font-medium text-gray-400 dark:text-gray-500">Oráculo</span>
           </div>
-          <div class="rounded-2xl border border-gray-200 bg-white/50 px-4 py-3 dark:border-gray-700 dark:bg-white/[0.07]">
+          <div class="rounded-2xl border border-gray-200 bg-white/50 px-4 py-3 dark:border-gray-700 dark:bg-gray-800/60">
             <span class="inline-flex items-center gap-1">
               <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 0ms" />
               <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 150ms" />
@@ -76,14 +76,25 @@
       </button>
     </div>
 
-    <div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300">
-      Erro: {{ error }}
+    <div v-if="error" class="flex items-center justify-between gap-2 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300">
+      <span>Erro: {{ error }}</span>
+      <button
+        type="button"
+        class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100"
+        aria-label="Fechar erro"
+        @click="$emit('clearError')"
+      >
+        <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
+          <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
+        </svg>
+      </button>
     </div>
   </div>
 </template>
 
 <script setup>
-import { ref, nextTick, watch } from "vue";
+import { ref, nextTick, onUnmounted, watch } from "vue";
+import { useChat } from "../composables/useChat.js";
 
 const props = defineProps({
   messages: { type: Array, required: true },
@@ -91,7 +102,10 @@ const props = defineProps({
   error: { type: String, required: true }
 });
 
-const emit = defineEmits(["send"]);
+const emit = defineEmits(["send", "clearError"]);
+
+const { cancelCurrentStream } = useChat();
+onUnmounted(() => cancelCurrentStream());
 
 const draft = ref("");
 const chatEl = ref(null);

+ 13 - 13
src/components/ModalUsuario.vue

@@ -24,37 +24,37 @@
 
         <form class="p-5 grid gap-4" @submit.prevent="onSubmit">
           <div class="grid gap-1.5">
-            <label class="text-sm font-medium text-gray-700 dark:text-gray-300">Nome</label>
-            <input v-model="form.Nome" placeholder="Nome completo" required :disabled="salvando" />
+            <label for="mu-nome" class="text-sm font-medium text-gray-700 dark:text-gray-300">Nome</label>
+            <input id="mu-nome" v-model="form.Nome" placeholder="Nome completo" required :disabled="salvando" />
           </div>
 
           <div v-if="!usuario" class="grid gap-1.5">
-            <label class="text-sm font-medium text-gray-700 dark:text-gray-300">Login</label>
-            <input v-model="form.Login" placeholder="Nome de usuário" required :disabled="salvando" autocomplete="off" />
+            <label for="mu-login" class="text-sm font-medium text-gray-700 dark:text-gray-300">Login</label>
+            <input id="mu-login" v-model="form.Login" placeholder="Nome de usuário" required :disabled="salvando" autocomplete="off" />
           </div>
 
           <div class="grid gap-1.5">
-            <label class="text-sm font-medium text-gray-700 dark:text-gray-300">E-mail</label>
-            <input v-model="form.Email" type="email" placeholder="email@empresa.com" required :disabled="salvando" />
+            <label for="mu-email" class="text-sm font-medium text-gray-700 dark:text-gray-300">E-mail</label>
+            <input id="mu-email" v-model="form.Email" type="email" placeholder="email@empresa.com" required :disabled="salvando" />
           </div>
 
           <div v-if="!usuario" class="grid gap-1.5">
-            <label class="text-sm font-medium text-gray-700 dark:text-gray-300">Senha</label>
-            <input v-model="form.Senha" type="password" placeholder="Mínimo 6 caracteres" required :disabled="salvando" autocomplete="new-password" />
+            <label for="mu-senha" class="text-sm font-medium text-gray-700 dark:text-gray-300">Senha</label>
+            <input id="mu-senha" v-model="form.Senha" type="password" placeholder="Mínimo 6 caracteres" required :disabled="salvando" autocomplete="new-password" />
           </div>
 
-          <div class="grid grid-cols-2 gap-3">
+          <div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
             <div class="grid gap-1.5">
-              <label class="text-sm font-medium text-gray-700 dark:text-gray-300">Nível</label>
-              <select v-model="form.Nivel" :disabled="salvando">
+              <label for="mu-nivel" class="text-sm font-medium text-gray-700 dark:text-gray-300">Nível</label>
+              <select id="mu-nivel" v-model="form.Nivel" :disabled="salvando">
                 <option value="1">1 — Básico</option>
                 <option value="2">2 — Intermediário</option>
                 <option value="3">3 — Administrador</option>
               </select>
             </div>
             <div class="grid gap-1.5">
-              <label class="text-sm font-medium text-gray-700 dark:text-gray-300">Setor</label>
-              <input v-model="form.Setor" placeholder="Ex: TI, RH, Financeiro" required :disabled="salvando" />
+              <label for="mu-setor" class="text-sm font-medium text-gray-700 dark:text-gray-300">Setor</label>
+              <input id="mu-setor" v-model="form.Setor" placeholder="Ex: TI, RH, Financeiro" required :disabled="salvando" />
             </div>
           </div>
 

+ 3 - 0
src/composables/useAuth.js

@@ -1,6 +1,7 @@
 import { computed, readonly, ref } from "vue";
 import { loginRequest, logoutRequest, refreshTokenRequest } from "../api/auth.js";
 import { setAccessToken, setRefreshCallback, setAuthErrorCallback } from "../api/client.js";
+import { resetChatSingleton } from "./useChat.js";
 
 const LOCAL_STORAGE_KEY = "auth.session";
 const SESSION_STORAGE_KEY = "auth.session.temp";
@@ -92,6 +93,7 @@ function setupAuth() {
 
   setAuthErrorCallback(() => {
     clearTimeout(_proactiveRefreshTimer);
+    resetChatSingleton();
     session.value = null;
     persistSession(null);
     setAccessToken(null);
@@ -123,6 +125,7 @@ async function logout() {
   try {
     await logoutRequest({ refreshToken: rt });
   } finally {
+    resetChatSingleton();
     session.value = null;
     persistSession(null);
     setAccessToken(null);

+ 36 - 1
src/composables/useChat.js

@@ -9,6 +9,15 @@ import {
 } from "../api/conversations.js";
 
 let singleton;
+let _streamAbort = null;
+
+export function resetChatSingleton() {
+  if (_streamAbort) {
+    _streamAbort.abort();
+    _streamAbort = null;
+  }
+  singleton = undefined;
+}
 
 export function useChat() {
   if (singleton) return singleton;
@@ -27,6 +36,7 @@ export function useChat() {
   const loading = ref(false);
   const error = ref("");
   const conversationsLoading = ref(false);
+  const conversationsError = ref("");
 
   function normalizeApiMessages(items) {
     return (items ?? []).map((m) => ({
@@ -43,6 +53,7 @@ export function useChat() {
 
   async function loadConversationsList() {
     conversationsLoading.value = true;
+    conversationsError.value = "";
     try {
       const data = await listConversations();
       conversations.value = (data.items ?? []).map((c) => ({
@@ -52,8 +63,9 @@ export function useChat() {
         updatedAt: new Date(c.UpdatedAt).getTime(),
         messageCount: 0
       }));
-    } catch {
+    } catch (err) {
       conversations.value = [];
+      conversationsError.value = err?.message || "Erro ao carregar conversas.";
     } finally {
       conversationsLoading.value = false;
     }
@@ -121,11 +133,28 @@ export function useChat() {
     return sorted.filter((c) => String(c.title ?? "").toLowerCase().includes(q));
   }
 
+  function clearError() {
+    error.value = "";
+  }
+
+  function cancelCurrentStream() {
+    if (_streamAbort) {
+      _streamAbort.abort();
+      _streamAbort = null;
+    }
+    loading.value = false;
+  }
+
   async function send(content) {
     error.value = "";
     const trimmed = String(content ?? "").trim();
     if (!trimmed) return;
 
+    if (_streamAbort) {
+      _streamAbort.abort();
+    }
+    _streamAbort = new AbortController();
+
     let convId = activeConversationId.value ? Number(activeConversationId.value) : null;
 
     if (!convId) {
@@ -154,6 +183,7 @@ export function useChat() {
 
     await sendChatStream(trimmed, {
       conversationId: convId,
+      signal: _streamAbort.signal,
       onChunk: (delta) => {
         messages.value[msgIndex].content += delta;
       },
@@ -161,6 +191,7 @@ export function useChat() {
         messages.value[msgIndex].sources = sources ?? [];
       },
       onDone: () => {
+        _streamAbort = null;
         messages.value[msgIndex].streaming = false;
         loading.value = false;
         if (convId) {
@@ -174,6 +205,7 @@ export function useChat() {
         }
       },
       onError: (e) => {
+        _streamAbort = null;
         error.value = e?.message || "erro";
         if (!messages.value[msgIndex].content) {
           messages.value[msgIndex].content = "Não consegui responder no momento.";
@@ -193,7 +225,10 @@ export function useChat() {
     loading,
     error,
     conversationsLoading,
+    conversationsError,
     send,
+    clearError,
+    cancelCurrentStream,
     newConversation,
     setActiveConversation,
     renameConversation,

+ 37 - 4
src/layout/LayoutSistema.vue

@@ -23,6 +23,7 @@ const {
   conversations,
   activeConversationId,
   conversationsLoading,
+  conversationsError,
   setActiveConversation,
   deleteConversation,
   renameConversation,
@@ -518,10 +519,15 @@ watch(
           <!-- Lista de conversas recentes (apenas sidebar expandida) -->
           <div
             v-if="!sidebarRecolhida"
-            class="mt-0.5 -mx-0.5 px-0.5"
+            class="conv-scroll mt-0.5 -mx-0.5 px-0.5 flex-1 min-h-0 overflow-y-auto"
           >
-            <div v-if="conversationsLoading" class="px-3 py-2 text-xs text-gray-400 dark:text-white/30">
-              Carregando...
+            <div v-if="conversationsLoading" class="grid gap-1 px-1 py-1">
+              <div v-for="i in 4" :key="i" class="flex items-center gap-2 rounded-xl px-3 py-2.5">
+                <div class="h-3 animate-pulse rounded-full bg-gray-200 dark:bg-white/10" :style="`width: ${55 + (i * 11) % 35}%`" />
+              </div>
+            </div>
+            <div v-else-if="conversationsError" class="px-3 py-2 text-xs text-red-400 dark:text-red-400/70">
+              {{ conversationsError }}
             </div>
             <template v-for="grupo in conversacoesAgrupadas" :key="grupo.label">
               <div class="px-3 pt-2 pb-0.5 text-[10px] font-medium text-gray-400 dark:text-white/30 select-none">
@@ -574,7 +580,7 @@ watch(
                   </div>
                 </template>
                 <template v-else>
-                  <span class="flex-1 min-w-0 truncate leading-snug">{{ c.title }}</span>
+                  <span class="flex-1 min-w-0 truncate leading-snug" :title="c.title">{{ c.title }}</span>
                   <div
                     v-show="hoveredConversaId === c.id"
                     class="absolute right-0 top-0 h-full flex items-center gap-0.5 pr-2 pl-16"
@@ -777,6 +783,33 @@ watch(
 </template>
 
 <style scoped>
+.conv-scroll {
+  scrollbar-width: thin;
+  scrollbar-color: rgba(0, 0, 0, 0.15) transparent;
+}
+.conv-scroll::-webkit-scrollbar {
+  width: 3px;
+}
+.conv-scroll::-webkit-scrollbar-track {
+  background: transparent;
+}
+.conv-scroll::-webkit-scrollbar-thumb {
+  background: rgba(0, 0, 0, 0.15);
+  border-radius: 999px;
+}
+.conv-scroll::-webkit-scrollbar-thumb:hover {
+  background: rgba(0, 0, 0, 0.25);
+}
+:is(.dark) .conv-scroll {
+  scrollbar-color: rgba(255, 255, 255, 0.12) transparent;
+}
+:is(.dark) .conv-scroll::-webkit-scrollbar-thumb {
+  background: rgba(255, 255, 255, 0.12);
+}
+:is(.dark) .conv-scroll::-webkit-scrollbar-thumb:hover {
+  background: rgba(255, 255, 255, 0.22);
+}
+
 .modal-enter-active,
 .modal-leave-active {
   transition: opacity 0.2s ease;

+ 2 - 2
src/styles/style.css

@@ -5,7 +5,7 @@
   --bg: #f6f8ff;
   --surface: rgba(255, 255, 255, 0.72);
   --surface-2: rgba(255, 255, 255, 0.6);
-  --border: rgba(15, 23, 42, 0.14);
+  --border: rgba(15, 23, 42, 0.24);
   --text: #0b1224;
   --muted: rgba(11, 18, 36, 0.65);
   --shadow: 0 18px 50px rgba(15, 23, 42, 0.14);
@@ -24,7 +24,7 @@ html.dark {
   --bg: #070b16;
   --surface: rgba(16, 24, 44, 0.72);
   --surface-2: rgba(10, 16, 30, 0.7);
-  --border: rgba(255, 255, 255, 0.12);
+  --border: rgba(255, 255, 255, 0.20);
   --text: #e7ecf6;
   --muted: rgba(231, 236, 246, 0.68);
   --shadow: 0 18px 50px rgba(0, 0, 0, 0.38);

+ 2 - 2
src/views/auth/LoginView.vue

@@ -67,7 +67,7 @@ async function onSubmit() {
 
     <main class="relative z-10 flex min-h-[calc(100vh-88px)] items-center justify-center px-4 pb-8">
       <div
-        class="w-full max-w-md rounded-[28px] border border-gray-200 bg-white/80 p-6 shadow-sm backdrop-blur-md dark:border-white/10 dark:bg-gradient-to-b dark:from-[#101827] dark:to-[#070b16] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
+        class="w-full max-w-md rounded-[28px] border border-gray-300 bg-white/80 p-6 shadow-sm backdrop-blur-md dark:border-white/[0.18] dark:bg-gradient-to-b dark:from-[#101827] dark:to-[#070b16] dark:shadow-[0_18px_50px_rgba(0,0,0,0.35)]"
       >
         <div class="text-2xl font-extrabold tracking-tight">Entrar</div>
         <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
@@ -119,7 +119,7 @@ async function onSubmit() {
 
           <button
             type="submit"
-            class="mt-2 inline-flex items-center justify-center rounded-xl border border-primary bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] disabled:opacity-50 disabled:cursor-not-allowed"
+            class="mt-2 inline-flex items-center justify-center rounded-xl border border-primary bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
             :disabled="!canSubmit || loading"
           >
             {{ loading ? "Entrando..." : "Entrar" }}

+ 11 - 5
src/views/pagina-inicial/PaginaInicialView.vue

@@ -1,10 +1,11 @@
 <script setup>
-import { computed, ref } from "vue";
+import { computed, onUnmounted, ref } from "vue";
 import ChatWindow from "../../components/ChatWindow.vue";
 import LayoutSistema from "../../layout/LayoutSistema.vue";
 import { useChat } from "../../composables/useChat.js";
 
-const { messages: chatMessages, loading: chatLoading, error: chatError, send: sendMessage } = useChat();
+const { messages: chatMessages, loading: chatLoading, error: chatError, send: sendMessage, clearError, cancelCurrentStream } = useChat();
+onUnmounted(() => cancelCurrentStream());
 
 const landingDraft = ref("");
 const isLanding = computed(() => {
@@ -55,15 +56,20 @@ async function onLandingSend() {
           </div>
         </div>
 
-        <div v-if="chatError" class="mt-3 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300">
-          Erro: {{ chatError }}
+        <div v-if="chatError" class="mt-3 flex items-center justify-between gap-2 rounded-xl border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-300">
+          <span>Erro: {{ chatError }}</span>
+          <button type="button" class="shrink-0 rounded p-0.5 opacity-60 hover:opacity-100 transition-opacity" aria-label="Fechar erro" @click="clearError">
+            <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
+              <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
+            </svg>
+          </button>
         </div>
       </div>
     </div>
 
     <div v-else class="relative h-full w-full">
       <div class="relative flex h-full w-full flex-col p-4 md:p-6">
-        <ChatWindow class="flex-1 min-h-1" :messages="chatMessages" :loading="chatLoading" :error="chatError" @send="sendMessage" />
+        <ChatWindow class="flex-1 min-h-1" :messages="chatMessages" :loading="chatLoading" :error="chatError" @send="sendMessage" @clearError="clearError" />
       </div>
     </div>
   </LayoutSistema>