PaginaInicialView.vue 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179
  1. <script setup>
  2. import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
  3. import ChatWindow from "../../components/ChatWindow.vue";
  4. import LayoutSistema from "../../layout/LayoutSistema.vue";
  5. import { useChat } from "../../composables/useChat.js";
  6. import BaseButton from "../../components/base/BaseButton.vue";
  7. const {
  8. messages: chatMessages,
  9. loading: chatLoading,
  10. error: chatError,
  11. send: sendMessage,
  12. clearError,
  13. cancelCurrentStream,
  14. activeConversationId,
  15. exportConversation
  16. } = useChat();
  17. onUnmounted(() => cancelCurrentStream());
  18. const exportando = ref(false);
  19. async function onExport() {
  20. if (!activeConversationId.value || exportando.value) return;
  21. exportando.value = true;
  22. try {
  23. await exportConversation(activeConversationId.value);
  24. } finally {
  25. exportando.value = false;
  26. }
  27. }
  28. const landingDraft = ref("");
  29. const isLanding = computed(() => {
  30. const ms = chatMessages.value ?? [];
  31. if (ms.length === 0) return true;
  32. if (ms.length === 1 && ms[0]?.role === "assistant") return true;
  33. return false;
  34. });
  35. async function onLandingSend() {
  36. const text = landingDraft.value;
  37. if (!text.trim()) return;
  38. landingDraft.value = "";
  39. await sendMessage(text);
  40. }
  41. const FRASES_EXEMPLO = [
  42. "Qual é a política de férias da empresa?",
  43. "Resuma o manual de integração de novos funcionários",
  44. "Como solicito reembolso de despesas de viagem?",
  45. "Quais são os benefícios disponíveis para colaboradores?",
  46. ];
  47. const PLACEHOLDER_PADRAO = "Digite sua pergunta...";
  48. const placeholderTexto = ref(PLACEHOLDER_PADRAO);
  49. let typingTimer = null;
  50. let fraseIdx = 0;
  51. const prefereMenosMovimento =
  52. typeof window !== "undefined" &&
  53. window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches === true;
  54. function digitar(i) {
  55. const frase = FRASES_EXEMPLO[fraseIdx];
  56. placeholderTexto.value = frase.slice(0, i) + (i < frase.length ? "▍" : "");
  57. if (i < frase.length) typingTimer = setTimeout(() => digitar(i + 1), 40 + Math.random() * 45);
  58. else typingTimer = setTimeout(() => apagar(frase.length), 2400);
  59. }
  60. function apagar(i) {
  61. const frase = FRASES_EXEMPLO[fraseIdx];
  62. placeholderTexto.value = frase.slice(0, i) + (i > 0 ? "▍" : "");
  63. if (i > 0) typingTimer = setTimeout(() => apagar(i - 1), 18);
  64. else {
  65. fraseIdx = (fraseIdx + 1) % FRASES_EXEMPLO.length;
  66. typingTimer = setTimeout(() => digitar(0), 400);
  67. }
  68. }
  69. function iniciarTypewriter() {
  70. if (prefereMenosMovimento || typingTimer) return;
  71. digitar(0);
  72. }
  73. function pararTypewriter() {
  74. clearTimeout(typingTimer);
  75. typingTimer = null;
  76. placeholderTexto.value = PLACEHOLDER_PADRAO;
  77. }
  78. watch(
  79. [isLanding, landingDraft],
  80. ([landing, draft]) => {
  81. if (landing && !draft) iniciarTypewriter();
  82. else pararTypewriter();
  83. },
  84. { immediate: true }
  85. );
  86. onUnmounted(pararTypewriter);
  87. const textareaEl = ref(null);
  88. function ajustarAltura() {
  89. const el = textareaEl.value;
  90. if (!el) return;
  91. el.style.height = "auto";
  92. el.style.height = `${Math.min(el.scrollHeight, 220)}px`;
  93. }
  94. watch(landingDraft, () => nextTick(ajustarAltura));
  95. onMounted(() => {
  96. textareaEl.value?.focus();
  97. });
  98. </script>
  99. <template>
  100. <LayoutSistema>
  101. <div v-if="isLanding" class="min-h-[calc(100vh-var(--layout-header-height))] flex items-center justify-center pb-12 md:pb-16">
  102. <div class="w-full max-w-[720px]">
  103. <div class="text-3xl font-extrabold tracking-tight">Como posso ajudar?</div>
  104. <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">
  105. Digite sua pergunta abaixo. Você também pode alimentar a base pela barra lateral.
  106. </div>
  107. <div class="mt-6 rounded-2xl border border-gray-300 bg-white/70 p-4 shadow-sm backdrop-blur-md transition-[border-color,box-shadow] duration-200 focus-within:border-ring/60 focus-within:ring-2 focus-within:ring-ring/15 dark:border-white/10 dark:bg-white/[0.04] dark:focus-within:border-ring/60">
  108. <textarea
  109. ref="textareaEl"
  110. v-model="landingDraft"
  111. rows="3"
  112. :placeholder="placeholderTexto"
  113. aria-label="Digite sua pergunta"
  114. class="min-h-20 max-h-[220px] w-full resize-none border-0 bg-transparent shadow-none focus:ring-0 !outline-none"
  115. @keydown.enter.exact.prevent="onLandingSend"
  116. />
  117. <div class="mt-3 flex items-center justify-between gap-3 ">
  118. <span class="text-xs text-gray-400 dark:text-gray-500">Enter para enviar · Shift+Enter para nova linha</span>
  119. <BaseButton :disabled="chatLoading || !landingDraft.trim()" :loading="chatLoading" @click="onLandingSend">
  120. <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  121. <path stroke-linecap="round" stroke-linejoin="round" d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5" />
  122. </svg>
  123. Enviar
  124. </BaseButton>
  125. </div>
  126. </div>
  127. <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">
  128. <span>Erro: {{ chatError }}</span>
  129. <button type="button" class="shrink-0 rounded p-0.5 opacity-60 hover:opacity-100 transition-opacity" aria-label="Fechar erro" @click="clearError">
  130. <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
  131. <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
  132. </svg>
  133. </button>
  134. </div>
  135. </div>
  136. </div>
  137. <div v-else class="relative h-full w-full">
  138. <div class="relative flex h-full w-full flex-col p-4 md:p-6">
  139. <div class="mb-2 flex items-center justify-end">
  140. <button
  141. v-if="activeConversationId"
  142. type="button"
  143. class="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white/60 px-2.5 py-1.5 text-xs font-medium text-gray-500 transition-colors hover:bg-white hover:text-gray-700 disabled:opacity-50 dark:border-white/10 dark:bg-white/5 dark:text-white/50 dark:hover:bg-white/10 dark:hover:text-white/80"
  144. :disabled="exportando || chatLoading"
  145. @click="onExport"
  146. >
  147. <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  148. <path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
  149. </svg>
  150. {{ exportando ? "Exportando..." : "Exportar .md" }}
  151. </button>
  152. </div>
  153. <ChatWindow class="flex-1 min-h-1" :messages="chatMessages" :loading="chatLoading" :error="chatError" @send="sendMessage" @clearError="clearError" />
  154. </div>
  155. </div>
  156. </LayoutSistema>
  157. </template>