ChatWindow.vue 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. <template>
  2. <div class="flex h-full min-h-[520px] w-full flex-col gap-4">
  3. <div
  4. ref="chatEl"
  5. class="min-h-0 flex-1 overflow-auto rounded-2xl border border-gray-200 bg-background/60 p-4 dark:border-gray-700 dark:bg-black/20"
  6. >
  7. <div v-if="!messages.length" class="grid h-full place-items-center">
  8. <div class="text-center">
  9. <div class="text-xl font-semibold tracking-tight">Nova conversa</div>
  10. <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Digite sua pergunta abaixo para começar.</div>
  11. </div>
  12. </div>
  13. <div v-else class="grid gap-4">
  14. <div v-for="(m, idx) in messages" :key="idx" class="group flex flex-col" :class="m.role === 'user' ? 'items-end' : 'items-start'">
  15. <div class="mb-0.5 flex items-center gap-1.5" :class="m.role === 'user' ? 'flex-row-reverse' : ''">
  16. <div
  17. class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-bold text-white"
  18. :class="m.role === 'user' ? 'bg-primary' : 'bg-gray-400 dark:bg-gray-600'"
  19. >
  20. {{ m.role === 'user' ? 'V' : 'O' }}
  21. </div>
  22. <span class="text-xs font-medium text-gray-400 dark:text-gray-500">
  23. {{ m.role === 'user' ? 'Você' : 'Oráculo' }}
  24. </span>
  25. <span v-if="m.sentAt" class="text-[10px] text-gray-300 dark:text-gray-600">
  26. {{ formatarHora(m.sentAt) }}
  27. </span>
  28. </div>
  29. <div class="relative max-w-[78%]">
  30. <!-- Botão copiar mensagem -->
  31. <button
  32. type="button"
  33. class="copy-btn absolute -top-1 z-10 flex items-center gap-1 rounded-md border border-gray-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-gray-500 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 hover:text-gray-700 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
  34. :class="m.role === 'user' ? 'right-0' : 'left-0'"
  35. :aria-label="`Copiar mensagem ${m.role === 'user' ? 'sua' : 'do Oráculo'}`"
  36. @click="copiarMensagem(m.content, idx)"
  37. >
  38. <svg v-if="copiado !== idx" class="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  39. <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
  40. <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
  41. </svg>
  42. <svg v-else class="h-3 w-3 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
  43. <polyline points="20 6 9 17 4 12" />
  44. </svg>
  45. {{ copiado === idx ? 'Copiado!' : 'Copiar' }}
  46. </button>
  47. <div
  48. class="rounded-2xl border px-3 py-2 text-sm leading-relaxed"
  49. :class="
  50. m.role === 'user'
  51. ? 'border-primary/25 bg-primary/10 text-foreground'
  52. : 'prose-chat border-gray-200 bg-white/50 text-foreground dark:border-gray-700 dark:bg-gray-800/60'
  53. "
  54. >
  55. <!-- Usuário: texto puro -->
  56. <span v-if="m.role === 'user'" class="whitespace-pre-wrap">{{ m.content }}</span>
  57. <!-- Assistente: Markdown renderizado -->
  58. <!-- eslint-disable-next-line vue/no-v-html -->
  59. <div v-else class="prose-content" v-html="renderMarkdown(m.content)" />
  60. </div>
  61. </div>
  62. </div>
  63. <div v-if="loading" class="flex flex-col items-start gap-0.5">
  64. <div class="mb-0.5 flex items-center gap-1.5">
  65. <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>
  66. <span class="text-xs font-medium text-gray-400 dark:text-gray-500">Oráculo</span>
  67. </div>
  68. <div class="rounded-2xl border border-gray-200 bg-white/50 px-4 py-3 dark:border-gray-700 dark:bg-gray-800/60">
  69. <span class="inline-flex items-center gap-1">
  70. <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 0ms" />
  71. <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 150ms" />
  72. <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 300ms" />
  73. </span>
  74. </div>
  75. </div>
  76. </div>
  77. </div>
  78. <div class="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
  79. <textarea
  80. v-model="draft"
  81. rows="2"
  82. placeholder="Digite sua pergunta..."
  83. class="resize-none"
  84. style="resize: none"
  85. @keydown.enter.exact.prevent="onSend"
  86. />
  87. <BaseButton
  88. class="py-2.5"
  89. :disabled="loading || !draft.trim()"
  90. @click="onSend"
  91. >
  92. <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
  93. <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" />
  94. </svg>
  95. Enviar
  96. </BaseButton>
  97. </div>
  98. <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">
  99. <span>Erro: {{ error }}</span>
  100. <button
  101. type="button"
  102. class="shrink-0 rounded p-0.5 opacity-60 transition-opacity hover:opacity-100"
  103. aria-label="Fechar erro"
  104. @click="$emit('clearError')"
  105. >
  106. <svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
  107. <path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
  108. </svg>
  109. </button>
  110. </div>
  111. </div>
  112. </template>
  113. <script setup>
  114. import { ref, nextTick, onMounted, onUnmounted, watch } from "vue";
  115. import { marked, Renderer } from "marked";
  116. import DOMPurify from "dompurify";
  117. import hljs from "highlight.js";
  118. import { useChat } from "../composables/useChat.js";
  119. import BaseButton from "./base/BaseButton.vue";
  120. const props = defineProps({
  121. messages: { type: Array, required: true },
  122. loading: { type: Boolean, required: true },
  123. error: { type: String, required: true }
  124. });
  125. const emit = defineEmits(["send", "clearError"]);
  126. const { cancelCurrentStream } = useChat();
  127. onUnmounted(() => cancelCurrentStream());
  128. const draft = ref("");
  129. const chatEl = ref(null);
  130. const copiado = ref(null);
  131. let copiadoTimeout = null;
  132. // Configurar marked com highlight.js
  133. const renderer = new Renderer();
  134. renderer.code = ({ text, lang }) => {
  135. const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
  136. const highlighted = hljs.highlight(text, { language }).value;
  137. const escapedCode = text.replace(/`/g, "&#96;");
  138. return `<div class="code-block-wrapper">
  139. <div class="code-block-header">
  140. <span class="code-lang">${language}</span>
  141. <button class="code-copy-btn" data-code="${escapedCode}" aria-label="Copiar código">
  142. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
  143. Copiar
  144. </button>
  145. </div>
  146. <pre><code class="hljs language-${language}">${highlighted}</code></pre>
  147. </div>`;
  148. };
  149. marked.use({
  150. renderer,
  151. breaks: true,
  152. gfm: true,
  153. });
  154. function renderMarkdown(content) {
  155. if (!content) return "";
  156. const html = marked.parse(content);
  157. return DOMPurify.sanitize(html, {
  158. ADD_ATTR: ["data-code"],
  159. ADD_TAGS: ["button"],
  160. });
  161. }
  162. // Delegação de eventos para botões de copiar dentro do HTML renderizado
  163. function handleCodeCopy(e) {
  164. const btn = e.target.closest(".code-copy-btn");
  165. if (!btn) return;
  166. const code = btn.dataset.code?.replace(/&#96;/g, "`") ?? "";
  167. navigator.clipboard.writeText(code).then(() => {
  168. btn.classList.add("copied");
  169. btn.querySelector("svg").style.display = "none";
  170. const original = btn.innerHTML;
  171. btn.innerHTML = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg> Copiado!`;
  172. setTimeout(() => {
  173. btn.innerHTML = original;
  174. btn.classList.remove("copied");
  175. }, 2000);
  176. });
  177. }
  178. onMounted(() => chatEl.value?.addEventListener("click", handleCodeCopy));
  179. onUnmounted(() => chatEl.value?.removeEventListener("click", handleCodeCopy));
  180. function copiarMensagem(content, idx) {
  181. navigator.clipboard.writeText(content).then(() => {
  182. clearTimeout(copiadoTimeout);
  183. copiado.value = idx;
  184. copiadoTimeout = setTimeout(() => { copiado.value = null; }, 2000);
  185. });
  186. }
  187. function formatarHora(ts) {
  188. if (!ts) return "";
  189. try {
  190. return new Date(ts).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
  191. } catch {
  192. return "";
  193. }
  194. }
  195. watch(
  196. () => [props.messages.length, props.loading],
  197. async () => {
  198. await nextTick();
  199. const el = chatEl.value;
  200. if (!el) return;
  201. el.scrollTop = el.scrollHeight;
  202. },
  203. { immediate: true }
  204. );
  205. async function onSend() {
  206. const text = draft.value;
  207. draft.value = "";
  208. emit("send", text);
  209. await nextTick();
  210. }
  211. </script>