LayoutSistema.vue 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248
  1. <script setup>
  2. import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
  3. import { useRoute, useRouter } from "vue-router";
  4. import BotaoAlterarTema from "../components/BotaoAlterarTema.vue";
  5. import DocumentList from "../components/DocumentList.vue";
  6. import DocumentUpload from "../components/DocumentUpload.vue";
  7. import SearchBox from "../components/SearchBox.vue";
  8. import { ingestDocuments, ingestFile, listDocuments } from "../api/chat.js";
  9. import { useChat } from "../composables/useChat.js";
  10. import { useSearch } from "../composables/useSearch.js";
  11. const router = useRouter();
  12. const route = useRoute();
  13. const { newConversation } = useChat();
  14. const { results: searchResults, loading: searchLoading, error: searchError, run: runSearch } = useSearch();
  15. const docs = reactive({
  16. items: [],
  17. loading: false,
  18. error: ""
  19. });
  20. const painelAberto = ref("");
  21. const rotaAtiva = computed(() => String(route.name ?? ""));
  22. const classeBotaoSidebar =
  23. "w-full rounded-lg border px-3 py-2.5 text-center text-sm font-medium transition-colors !border-primary !bg-none !bg-primary !text-primary-foreground hover:!bg-[var(--primary-600)] hover:!border-[var(--primary-600)]";
  24. const tituloPagina = computed(() => {
  25. const nome = rotaAtiva.value;
  26. if (nome === "home") return "Nova conversa";
  27. if (nome === "conversas-pesquisar") return "Pesquisar conversas";
  28. if (nome === "conversas-historico") return "Histórico";
  29. return "Oráculo";
  30. });
  31. const tituloModal = computed(() => {
  32. if (painelAberto.value === "update") return "Update";
  33. if (painelAberto.value === "documentos") return "Documentos";
  34. return "";
  35. });
  36. function irPara(nome) {
  37. router.push({ name: nome });
  38. }
  39. function alternarPainel(nome) {
  40. const n = String(nome ?? "");
  41. painelAberto.value = painelAberto.value === n ? "" : n;
  42. }
  43. function fecharModal() {
  44. painelAberto.value = "";
  45. }
  46. async function novaConversa() {
  47. newConversation();
  48. await router.push({ name: "home" });
  49. }
  50. async function loadDocs() {
  51. docs.loading = true;
  52. docs.error = "";
  53. try {
  54. const data = await listDocuments({ limit: 50, offset: 0 });
  55. docs.items = data.items ?? [];
  56. } catch (e) {
  57. docs.error = e?.message || "erro";
  58. docs.items = [];
  59. } finally {
  60. docs.loading = false;
  61. }
  62. }
  63. async function onIngested({ text, source, file } = {}) {
  64. try {
  65. if (file) {
  66. await ingestFile(file, { source });
  67. } else {
  68. await ingestDocuments([{ text, source }]);
  69. }
  70. await loadDocs();
  71. } catch (e) {
  72. docs.error = e?.message || "erro";
  73. }
  74. }
  75. onMounted(async () => {
  76. await loadDocs();
  77. });
  78. function onKeyDown(e) {
  79. if (!painelAberto.value) return;
  80. if (e?.key === "Escape") fecharModal();
  81. }
  82. onMounted(() => {
  83. window.addEventListener("keydown", onKeyDown);
  84. });
  85. onBeforeUnmount(() => {
  86. window.removeEventListener("keydown", onKeyDown);
  87. });
  88. let overflowAntes = "";
  89. watch(
  90. () => painelAberto.value,
  91. (v) => {
  92. const aberto = Boolean(v);
  93. if (aberto) {
  94. overflowAntes = document.body.style.overflow || "";
  95. document.body.style.overflow = "hidden";
  96. } else {
  97. document.body.style.overflow = overflowAntes;
  98. }
  99. }
  100. );
  101. </script>
  102. <template>
  103. <div class="h-screen overflow-hidden bg-background text-foreground">
  104. <div
  105. class="[--layout-header-height:5rem] relative grid h-full min-h-0 grid-cols-[340px_1fr] grid-rows-[var(--layout-header-height)_1fr] overflow-hidden max-[980px]:grid-cols-1 max-[980px]:grid-rows-[var(--layout-header-height)_1fr_auto]"
  106. >
  107. <div
  108. aria-hidden="true"
  109. class="pointer-events-none absolute inset-0 z-0 bg-[radial-gradient(900px_520px_at_20%_10%,rgba(37,99,235,0.12),transparent_60%),radial-gradient(700px_520px_at_85%_35%,rgba(162,102,255,0.10),transparent_60%)] dark:hidden"
  110. />
  111. <div
  112. aria-hidden="true"
  113. class="pointer-events-none absolute inset-0 z-0 hidden bg-[radial-gradient(900px_520px_at_20%_10%,rgba(91,140,255,0.18),transparent_60%),radial-gradient(700px_520px_at_85%_35%,rgba(162,102,255,0.14),transparent_60%)] dark:block"
  114. />
  115. <header
  116. class="col-span-full row-start-1 relative z-10 grid grid-cols-[340px_1fr] items-center gap-[18px] border-b border-gray-200 bg-primary px-[18px] py-[14px] text-primary-foreground dark:border-gray-700 dark:bg-primary max-[980px]:flex"
  117. >
  118. <div class="min-w-0">
  119. <div class="text-[16px] font-extrabold tracking-[0.14em]">ORÁCULO</div>
  120. <div class="mt-1.5 text-xs text-primary-foreground/80">Assistente interno com base de conhecimento</div>
  121. </div>
  122. <div class="min-w-0 flex items-center justify-between gap-3 max-[980px]:flex-1">
  123. <div class="min-w-0">
  124. <div class="truncate text-base font-medium">{{ tituloPagina }}</div>
  125. </div>
  126. <BotaoAlterarTema />
  127. </div>
  128. </header>
  129. <aside
  130. class="col-start-1 row-start-2 relative z-10 flex min-h-0 flex-col gap-[14px] overflow-hidden border-r border-gray-200 bg-gray-100/50 p-[18px] shadow-sm backdrop-blur-md dark:border-gray-700 dark:bg-secondary/50 max-[980px]:row-start-3 max-[980px]:border-r-0 max-[980px]:border-b-0 max-[980px]:border-t"
  131. >
  132. <div class="grid flex-1 min-h-0 gap-[14px] overflow-auto pr-0.5">
  133. <div class="rounded-xl border border-gray-200 bg-background/60 p-4 text-sm text-gray-500 dark:border-gray-700 dark:bg-background/10 dark:text-gray-400">
  134. </div>
  135. </div>
  136. <div class="rounded-xl border border-gray-200 bg-background/60 p-2 shadow-sm backdrop-blur-md dark:border-gray-700">
  137. <div class="grid content-end gap-1.5">
  138. <button
  139. type="button"
  140. :class="[classeBotaoSidebar, rotaAtiva === 'home' ? '!border-ring' : '']"
  141. @click="novaConversa"
  142. >
  143. Nova conversa
  144. </button>
  145. <button
  146. type="button"
  147. :class="[classeBotaoSidebar, rotaAtiva === 'conversas-pesquisar' ? '!border-ring' : '']"
  148. @click="irPara('conversas-pesquisar')"
  149. >
  150. Pesquisar conversas
  151. </button>
  152. <button
  153. type="button"
  154. :class="[classeBotaoSidebar, rotaAtiva === 'conversas-historico' ? '!border-ring' : '']"
  155. @click="irPara('conversas-historico')"
  156. >
  157. Histórico
  158. </button>
  159. <div class="my-1 h-px bg-gray-200 dark:bg-gray-700" />
  160. <button
  161. type="button"
  162. :class="[classeBotaoSidebar, painelAberto === 'update' ? '!border-ring' : '']"
  163. @click="alternarPainel('update')"
  164. >
  165. Update
  166. </button>
  167. <button
  168. type="button"
  169. :class="[classeBotaoSidebar, painelAberto === 'documentos' ? '!border-ring' : '']"
  170. @click="alternarPainel('documentos')"
  171. >
  172. Documentos
  173. </button>
  174. </div>
  175. </div>
  176. <div class="pr-8 text-[11px] leading-snug text-gray-400 dark:text-gray-500">
  177. Backend: http://localhost:3001 · Qdrant: http://localhost:6333 · Ollama: http://localhost:11434
  178. </div>
  179. </aside>
  180. <Teleport to="body">
  181. <div v-if="painelAberto" class="fixed inset-0 z-[9999]">
  182. <div class="absolute inset-0 bg-black/40 dark:bg-black/60" @click="fecharModal" />
  183. <div class="absolute inset-0 flex items-center justify-center p-4">
  184. <div
  185. class="w-full max-w-[980px] overflow-hidden rounded-2xl border border-gray-200 bg-background text-foreground shadow-lg dark:border-gray-700"
  186. @click.stop
  187. >
  188. <div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-700">
  189. <div class="text-sm font-semibold text-gray-900 dark:text-gray-100">{{ tituloModal }}</div>
  190. <button
  191. type="button"
  192. class="rounded-lg border border-gray-200 bg-background/60 px-3 py-1.5 text-sm text-gray-500 transition-colors hover:border-gray-500 dark:border-gray-700 dark:bg-background/10 dark:text-gray-400 dark:hover:border-gray-400"
  193. @click="fecharModal"
  194. >
  195. Fechar
  196. </button>
  197. </div>
  198. <div class="max-h-[calc(100vh-8rem)] overflow-auto p-4">
  199. <DocumentUpload v-if="painelAberto === 'update'" @ingested="onIngested" />
  200. <template v-else-if="painelAberto === 'documentos'">
  201. <SearchBox :results="searchResults" :loading="searchLoading" :error="searchError" @search="runSearch" />
  202. <div class="h-3" />
  203. <DocumentList :items="docs.items" :loading="docs.loading" :error="docs.error" @refresh="loadDocs" />
  204. </template>
  205. </div>
  206. </div>
  207. </div>
  208. </div>
  209. </Teleport>
  210. <main class="col-start-2 row-start-2 relative z-10 flex min-h-0 flex-col overflow-hidden p-0 max-[980px]:col-start-1 max-[980px]:row-start-2">
  211. <div
  212. class="flex-1 min-h-0 overflow-auto p-4 md:p-6"
  213. :class="rotaAtiva === 'home' ? 'bg-black/10' : ''"
  214. >
  215. <slot />
  216. </div>
  217. </main>
  218. </div>
  219. </div>
  220. </template>