| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232 |
- <template>
- <div class="flex h-full min-h-[520px] w-full flex-col gap-4">
- <div
- ref="chatEl"
- 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"
- >
- <div v-if="!messages.length" class="grid h-full place-items-center">
- <div class="text-center">
- <div class="text-xl font-semibold tracking-tight">Nova conversa</div>
- <div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Digite sua pergunta abaixo para começar.</div>
- </div>
- </div>
- <div v-else class="grid gap-4">
- <div v-for="(m, idx) in messages" :key="idx" class="group flex flex-col" :class="m.role === 'user' ? 'items-end' : 'items-start'">
- <div class="mb-0.5 flex items-center gap-1.5" :class="m.role === 'user' ? 'flex-row-reverse' : ''">
- <div
- class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[9px] font-bold text-white"
- :class="m.role === 'user' ? 'bg-primary' : 'bg-gray-400 dark:bg-gray-600'"
- >
- {{ m.role === 'user' ? 'V' : 'O' }}
- </div>
- <span class="text-xs font-medium text-gray-400 dark:text-gray-500">
- {{ m.role === 'user' ? 'Você' : 'Oráculo' }}
- </span>
- <span v-if="m.sentAt" class="text-[10px] text-gray-300 dark:text-gray-600">
- {{ formatarHora(m.sentAt) }}
- </span>
- </div>
- <div class="relative max-w-[78%]">
- <!-- Botão copiar mensagem -->
- <button
- type="button"
- 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"
- :class="m.role === 'user' ? 'right-0' : 'left-0'"
- :aria-label="`Copiar mensagem ${m.role === 'user' ? 'sua' : 'do Oráculo'}`"
- @click="copiarMensagem(m.content, idx)"
- >
- <svg v-if="copiado !== idx" class="h-3 w-3" 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>
- <svg v-else class="h-3 w-3 text-green-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
- <polyline points="20 6 9 17 4 12" />
- </svg>
- {{ copiado === idx ? 'Copiado!' : 'Copiar' }}
- </button>
- <div
- class="rounded-2xl border px-3 py-2 text-sm leading-relaxed"
- :class="
- m.role === 'user'
- ? 'border-primary/25 bg-primary/10 text-foreground'
- : 'prose-chat border-gray-200 bg-white/50 text-foreground dark:border-gray-700 dark:bg-gray-800/60'
- "
- >
- <!-- Usuário: texto puro -->
- <span v-if="m.role === 'user'" class="whitespace-pre-wrap">{{ m.content }}</span>
- <!-- Assistente: Markdown renderizado -->
- <!-- eslint-disable-next-line vue/no-v-html -->
- <div v-else class="prose-content" v-html="renderMarkdown(m.content)" />
- </div>
- </div>
- </div>
- <div v-if="loading" class="flex flex-col items-start gap-0.5">
- <div class="mb-0.5 flex items-center gap-1.5">
- <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-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" />
- <span class="h-1.5 w-1.5 animate-bounce rounded-full bg-gray-400 dark:bg-gray-500" style="animation-delay: 300ms" />
- </span>
- </div>
- </div>
- </div>
- </div>
- <div class="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
- <textarea
- v-model="draft"
- rows="2"
- placeholder="Digite sua pergunta..."
- class="resize-none"
- style="resize: none"
- @keydown.enter.exact.prevent="onSend"
- />
- <BaseButton
- class="py-2.5"
- :disabled="loading || !draft.trim()"
- @click="onSend"
- >
- <svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
- <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" />
- </svg>
- Enviar
- </BaseButton>
- </div>
- <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, 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({
- messages: { type: Array, required: true },
- loading: { type: Boolean, required: true },
- error: { type: String, required: true }
- });
- const emit = defineEmits(["send", "clearError"]);
- const { cancelCurrentStream } = useChat();
- onUnmounted(() => cancelCurrentStream());
- const draft = ref("");
- const chatEl = ref(null);
- const copiado = ref(null);
- let copiadoTimeout = null;
- // Configurar marked com highlight.js
- const renderer = new Renderer();
- renderer.code = ({ text, lang }) => {
- const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
- const highlighted = hljs.highlight(text, { language }).value;
- const escapedCode = text.replace(/`/g, "`");
- return `<div class="code-block-wrapper">
- <div class="code-block-header">
- <span class="code-lang">${language}</span>
- <button class="code-copy-btn" data-code="${escapedCode}" aria-label="Copiar código">
- <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>
- Copiar
- </button>
- </div>
- <pre><code class="hljs language-${language}">${highlighted}</code></pre>
- </div>`;
- };
- marked.use({
- renderer,
- breaks: true,
- gfm: true,
- });
- function renderMarkdown(content) {
- if (!content) return "";
- const html = marked.parse(content);
- return DOMPurify.sanitize(html, {
- ADD_ATTR: ["data-code"],
- ADD_TAGS: ["button"],
- });
- }
- // Delegação de eventos para botões de copiar dentro do HTML renderizado
- function handleCodeCopy(e) {
- const btn = e.target.closest(".code-copy-btn");
- if (!btn) return;
- const code = btn.dataset.code?.replace(/`/g, "`") ?? "";
- navigator.clipboard.writeText(code).then(() => {
- btn.classList.add("copied");
- btn.querySelector("svg").style.display = "none";
- const original = btn.innerHTML;
- 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!`;
- setTimeout(() => {
- btn.innerHTML = original;
- btn.classList.remove("copied");
- }, 2000);
- });
- }
- onMounted(() => chatEl.value?.addEventListener("click", handleCodeCopy));
- onUnmounted(() => chatEl.value?.removeEventListener("click", handleCodeCopy));
- function copiarMensagem(content, idx) {
- navigator.clipboard.writeText(content).then(() => {
- clearTimeout(copiadoTimeout);
- copiado.value = idx;
- copiadoTimeout = setTimeout(() => { copiado.value = null; }, 2000);
- });
- }
- function formatarHora(ts) {
- if (!ts) return "";
- try {
- return new Date(ts).toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
- } catch {
- return "";
- }
- }
- watch(
- () => [props.messages.length, props.loading],
- async () => {
- await nextTick();
- const el = chatEl.value;
- if (!el) return;
- el.scrollTop = el.scrollHeight;
- },
- { immediate: true }
- );
- async function onSend() {
- const text = draft.value;
- draft.value = "";
- emit("send", text);
- await nextTick();
- }
- </script>
|