leonardo пре 2 месеци
родитељ
комит
e464a9aeb1

+ 1 - 0
.gitignore

@@ -7,3 +7,4 @@ vue.js
 vue.js.map
 *.map
 .DS_Store
+.env

+ 5 - 1
package.json

@@ -9,8 +9,12 @@
     "lint": "node -c vite.config.js"
   },
   "dependencies": {
+    "dompurify": "^3.4.11",
+    "highlight.js": "^11.11.1",
+    "marked": "^18.0.5",
     "vue": "^3.4.27",
-    "vue-router": "^4.4.5"
+    "vue-router": "^4.4.5",
+    "vue-toastification": "^2.0.0-rc.5"
   },
   "devDependencies": {
     "@tailwindcss/cli": "^4.3.0",

+ 1 - 4
src/App.vue

@@ -1,10 +1,7 @@
 <script setup>
 import { RouterView } from "vue-router";
-import PaginaInicialView from "./views/pagina-inicial/PaginaInicialView.vue";
 </script>
 
 <template>
-  <RouterView v-slot="{ Component }">
-    <component :is="Component || PaginaInicialView" />
-  </RouterView>
+  <RouterView />
 </template>

+ 0 - 4
src/components/ChatWindow.vue

@@ -128,7 +128,6 @@ import { ref, computed, nextTick, onMounted, onUnmounted, watch } from "vue";
 import { marked, Renderer } from "marked";
 import DOMPurify from "dompurify";
 import hljs from "highlight.js";
-import { useChat } from "../composables/useChat.js";
 import BaseButton from "./base/BaseButton.vue";
 
 const props = defineProps({
@@ -139,9 +138,6 @@ const props = defineProps({
 
 const emit = defineEmits(["send", "clearError"]);
 
-const { cancelCurrentStream } = useChat();
-onUnmounted(() => cancelCurrentStream());
-
 const draft = ref("");
 const chatEl = ref(null);
 const copiado = ref(null);

+ 3 - 6
src/components/SearchBox.vue

@@ -3,13 +3,9 @@
     <h2 class="text-lg font-semibold text-gray-900 dark:text-gray-100">Busca</h2>
     <div class="mt-3 grid grid-cols-1 gap-2 min-[520px]:grid-cols-[1fr_120px]">
       <input v-model="query" placeholder="Buscar na base..." @keydown.enter="onSearch" />
-      <button
-        class="w-full rounded-lg border border-primary bg-primary px-3 py-2.5 text-sm font-medium text-primary-foreground transition-colors hover:border-[var(--primary-600)] hover:bg-[var(--primary-600)] disabled:cursor-not-allowed disabled:opacity-50"
-        :disabled="loading || !query.trim()"
-        @click="onSearch"
-      >
+      <BaseButton :disabled="loading || !query.trim()" @click="onSearch">
         Buscar
-      </button>
+      </BaseButton>
     </div>
     <div v-if="error" class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">Erro: {{ error }}</div>
 
@@ -31,6 +27,7 @@
 
 <script setup>
 import { ref } from "vue";
+import BaseButton from "./base/BaseButton.vue";
 
 const props = defineProps({
   results: { type: Array, required: true },

+ 9 - 19
src/components/base/BaseInput.vue

@@ -2,28 +2,21 @@
   <div class="grid gap-1.5">
     <label v-if="label" :for="inputId" class="text-sm font-medium text-gray-700 dark:text-gray-300">
       {{ label }}
-      <span v-if="required" class="ml-0.5 text-red-500" aria-hidden="true">*</span>
     </label>
-    <component
-      :is="tag"
+    <input
       :id="inputId"
       v-bind="$attrs"
       :value="modelValue"
-      :disabled="disabled"
-      :required="required"
-      :placeholder="placeholder"
-      class="w-full rounded-xl border bg-background px-3 text-sm text-foreground placeholder-gray-400 outline-none transition-colors focus:border-primary dark:placeholder-gray-500"
       :class="[
-        tag === 'textarea' ? 'py-2.5 resize-none' : 'py-2',
+        'w-full rounded-xl border bg-background px-3 py-2 text-sm text-foreground placeholder:text-gray-400 outline-none transition-colors focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-50 dark:placeholder:text-gray-500',
         error
-          ? 'border-red-300 dark:border-red-700'
-          : 'border-gray-200 dark:border-gray-700',
-        disabled ? 'opacity-60 cursor-not-allowed' : '',
+          ? 'border-red-400 focus:border-red-400 dark:border-red-500/60'
+          : 'border-gray-200 focus:border-primary dark:border-gray-700'
       ]"
       @input="$emit('update:modelValue', $event.target.value)"
     />
     <p v-if="error" class="text-xs text-red-600 dark:text-red-400">{{ error }}</p>
-    <p v-else-if="hint" class="text-xs text-gray-400 dark:text-gray-500">{{ hint }}</p>
+    <p v-else-if="hint" class="text-xs text-gray-500 dark:text-gray-400">{{ hint }}</p>
   </div>
 </template>
 
@@ -35,16 +28,13 @@ defineOptions({ inheritAttrs: false });
 const props = defineProps({
   modelValue: { type: String, default: "" },
   label: { type: String, default: "" },
-  placeholder: { type: String, default: "" },
-  error: { type: String, default: "" },
   hint: { type: String, default: "" },
-  tag: { type: String, default: "input" },
-  disabled: { type: Boolean, default: false },
-  required: { type: Boolean, default: false },
-  id: { type: String, default: "" },
+  error: { type: String, default: "" },
+  id: { type: String, default: "" }
 });
 
 defineEmits(["update:modelValue"]);
 
-const inputId = computed(() => props.id || `base-input-${Math.random().toString(36).slice(2, 7)}`);
+let _counter = 0;
+const inputId = computed(() => props.id || `base-input-${++_counter}`);
 </script>

+ 0 - 43
src/components/skeletons/SkeletonMessage.vue

@@ -1,43 +0,0 @@
-<template>
-  <div class="flex flex-col gap-3">
-    <!-- Mensagem do usuário (direita) -->
-    <div class="flex flex-col items-end">
-      <div class="mb-0.5 flex items-center gap-1.5 flex-row-reverse">
-        <div class="h-5 w-5 rounded-full skeleton" />
-        <div class="h-3 w-10 rounded skeleton" />
-      </div>
-      <div class="h-10 w-52 rounded-2xl skeleton" />
-    </div>
-
-    <!-- Mensagem do assistente (esquerda) -->
-    <div class="flex flex-col items-start">
-      <div class="mb-0.5 flex items-center gap-1.5">
-        <div class="h-5 w-5 rounded-full skeleton" />
-        <div class="h-3 w-12 rounded skeleton" />
-      </div>
-      <div class="flex flex-col gap-1.5 w-[72%]">
-        <div class="h-4 rounded skeleton w-full" />
-        <div class="h-4 rounded skeleton w-[90%]" />
-        <div class="h-4 rounded skeleton w-[75%]" />
-      </div>
-    </div>
-  </div>
-</template>
-
-<style scoped>
-.skeleton {
-  background: linear-gradient(
-    90deg,
-    rgba(156, 163, 175, 0.18) 25%,
-    rgba(156, 163, 175, 0.32) 50%,
-    rgba(156, 163, 175, 0.18) 75%
-  );
-  background-size: 200% 100%;
-  animation: shimmer 1.5s infinite;
-}
-
-@keyframes shimmer {
-  0% { background-position: 200% 0; }
-  100% { background-position: -200% 0; }
-}
-</style>

+ 1 - 10
src/composables/useAuth.js

@@ -134,21 +134,12 @@ async function logout() {
   }
 }
 
-function clearSession() {
-  session.value = null;
-  persistSession(null);
-  setAccessToken(null);
-  setRefreshCallback(null);
-  setAuthErrorCallback(null);
-}
-
 export function useAuth() {
   return {
     session: readonly(session),
     user,
     isAuthenticated,
     login,
-    logout,
-    clearSession
+    logout
   };
 }

+ 25 - 19
src/composables/useChat.js

@@ -47,16 +47,22 @@ export function useChat() {
   const conversationsOffset = ref(0);
 
   function normalizeApiMessages(items) {
-    return (items ?? []).map((m) => ({
-      role: m.Role === "user" ? "user" : "assistant",
-      content: String(m.Content ?? ""),
-      sources: m.Sources
-        ? typeof m.Sources === "string"
-          ? JSON.parse(m.Sources)
-          : m.Sources
-        : undefined,
-      sentAt: m.SentAt ? new Date(m.SentAt).getTime() : 0
-    }));
+    return (items ?? []).map((m) => {
+      let sources;
+      if (m.Sources) {
+        if (typeof m.Sources === "string") {
+          try { sources = JSON.parse(m.Sources); } catch { sources = undefined; }
+        } else {
+          sources = m.Sources;
+        }
+      }
+      return {
+        role: m.Role === "user" ? "user" : "assistant",
+        content: String(m.Content ?? ""),
+        sources,
+        sentAt: m.SentAt ? new Date(m.SentAt).getTime() : 0
+      };
+    });
   }
 
   function normalizeConversation(c) {
@@ -134,12 +140,12 @@ export function useChat() {
     if (!numId || !title?.trim()) return;
     try {
       await updateConversationTitleAPI(numId, title.trim());
+      conversations.value = conversations.value.map((c) =>
+        c.id === strId ? { ...c, title: title.trim() } : c
+      );
     } catch {
       toast.erro("Não foi possível renomear a conversa.");
     }
-    conversations.value = conversations.value.map((c) =>
-      c.id === strId ? { ...c, title: title.trim() } : c
-    );
   }
 
   async function deleteConversation(id) {
@@ -148,15 +154,15 @@ export function useChat() {
     if (!numId) return;
     try {
       await deleteConversationAPI(numId);
+      conversations.value = conversations.value.filter((c) => c.id !== strId);
+      if (String(activeConversationId.value) === strId) {
+        const next = conversations.value[0];
+        if (next) await setActiveConversation(next.id);
+        else newConversation();
+      }
     } catch {
       toast.erro("Não foi possível excluir a conversa.");
     }
-    conversations.value = conversations.value.filter((c) => c.id !== strId);
-    if (String(activeConversationId.value) === strId) {
-      const next = conversations.value[0];
-      if (next) await setActiveConversation(next.id);
-      else newConversation();
-    }
   }
 
   async function exportConversation(id) {

+ 37 - 0
src/layout/LayoutSistema.vue

@@ -503,6 +503,43 @@ watch(
               </svg>
               <span :class="classeRotulo">Nova conversa</span>
             </button>
+
+            <button
+              type="button"
+              :class="classeItemSidebar(rotaAtiva === 'conversas-historico')"
+              :title="sidebarRecolhida ? 'Histórico' : ''"
+              @click="irPara('conversas-historico')"
+            >
+              <svg
+                :class="classeIcon(rotaAtiva === 'conversas-historico')"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="1.8"
+              >
+                <path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6l4 2" />
+                <path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-3.2-6.9" />
+              </svg>
+              <span :class="classeRotulo">Histórico</span>
+            </button>
+
+            <button
+              type="button"
+              :class="classeItemSidebar(rotaAtiva === 'conversas-pesquisar')"
+              :title="sidebarRecolhida ? 'Pesquisar' : ''"
+              @click="irPara('conversas-pesquisar')"
+            >
+              <svg
+                :class="classeIcon(rotaAtiva === 'conversas-pesquisar')"
+                viewBox="0 0 24 24"
+                fill="none"
+                stroke="currentColor"
+                stroke-width="1.8"
+              >
+                <path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
+              </svg>
+              <span :class="classeRotulo">Pesquisar</span>
+            </button>
           </nav>
 
           <!-- Lista de conversas recentes (apenas sidebar expandida) -->

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

@@ -56,7 +56,7 @@ async function onSubmit() {
 
     <header class="relative z-10 flex items-center justify-between px-6 py-5">
       <div>
-        <div class="text-[16px] font-extrabold tracking-[0.14em]">ORACULO</div>
+        <div class="text-[16px] font-extrabold tracking-[0.14em]">ORÁCULO</div>
         <div class="mt-1 text-sm text-gray-500 dark:text-gray-400">
           Acesso ao assistente interno
         </div>

+ 28 - 4
src/views/conversas/ConversasPesquisarView.vue

@@ -5,7 +5,13 @@ import LayoutSistema from "../../layout/LayoutSistema.vue";
 import { useChat } from "../../composables/useChat.js";
 
 const router = useRouter();
-const { searchConversations, setActiveConversation } = useChat();
+const {
+  searchConversations,
+  setActiveConversation,
+  conversationsHasMore,
+  conversationsLoading,
+  loadMoreConversations
+} = useChat();
 
 const draftQuery = ref("");
 const query = ref("");
@@ -29,14 +35,25 @@ function abrirConversa(id) {
       <div class="mt-3 grid grid-cols-1 gap-2 min-[520px]:grid-cols-[1fr_120px]">
         <input
           v-model="draftQuery"
-          placeholder="Buscar por título ou conteúdo..."
+          placeholder="Buscar por título..."
           @keydown.enter.prevent="buscar"
         />
         <button type="button" :disabled="!draftQuery.trim()" @click="buscar">Buscar</button>
       </div>
 
-      <div class="mt-2.5 text-sm text-gray-500 dark:text-gray-400">
-        {{ query.trim() ? `${results.length} resultados` : "Digite para filtrar o histórico." }}
+      <div class="mt-2.5 flex items-center justify-between gap-3">
+        <span class="text-sm text-gray-500 dark:text-gray-400">
+          {{ query.trim() ? `${results.length} resultados` : "Digite para filtrar o histórico." }}
+        </span>
+        <button
+          v-if="conversationsHasMore"
+          type="button"
+          class="shrink-0 text-xs text-primary underline-offset-2 hover:underline disabled:opacity-50"
+          :disabled="conversationsLoading"
+          @click="loadMoreConversations"
+        >
+          {{ conversationsLoading ? "Carregando..." : "Carregar mais conversas" }}
+        </button>
       </div>
 
       <div class="mt-3 grid gap-2">
@@ -50,6 +67,13 @@ function abrirConversa(id) {
           <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ c.title || "Nova conversa" }}</div>
           <div class="mt-1 text-xs text-gray-500 dark:text-gray-400">{{ c.messageCount ?? 0 }} msgs</div>
         </button>
+
+        <div
+          v-if="query.trim() && !results.length"
+          class="py-6 text-center text-sm text-gray-400 dark:text-gray-500"
+        >
+          Nenhuma conversa encontrada.
+        </div>
       </div>
     </section>
   </LayoutSistema>

+ 2 - 2
src/views/pagina-inicial/PaginaInicialView.vue

@@ -54,7 +54,7 @@ async function onLandingSend() {
           Digite sua pergunta abaixo. Você também pode alimentar a base pela barra lateral.
         </div>
 
-        <div class="mt-6 rounded-2xl border border-gray-200 bg-white/70 p-4 shadow-sm backdrop-blur-md dark:border-white/10 dark:bg-white/[0.04]">
+        <div class="mt-6 rounded-2xl border border-gray-300 bg-white/70 p-4 shadow-sm backdrop-blur-md dark:border-white/10 dark:bg-white/[0.04]">
           <textarea
             v-model="landingDraft"
             rows="3"
@@ -63,7 +63,7 @@ async function onLandingSend() {
             style="resize: none"
             @keydown.enter.exact.prevent="onLandingSend"
           />
-          <div class="mt-3 flex items-center justify-between gap-3">
+          <div class="mt-3 flex items-center justify-between gap-3 ">
             <span class="text-xs text-gray-400 dark:text-gray-500">Enter para enviar · Shift+Enter para nova linha</span>
             <BaseButton :disabled="chatLoading || !landingDraft.trim()" @click="onLandingSend">
               <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">