Ver código fonte

alterando para star-oraculo-frontend

GabrielRamison 3 meses atrás
commit
74a333c7e9

BIN
.DS_Store


+ 21 - 0
dist/index.html

@@ -0,0 +1,21 @@
+<!doctype html>
+<html lang="pt-BR">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
+    <link rel="icon" type="image/x-icon" href="/favicon.ico?v=2" />
+    <title>ORACULO</title>
+    <script type="module" crossorigin src="/assets/index-COrFMHKV.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DfgF3Bus.css">
+  </head>
+  <body class="bg-background">
+    <noscript>
+      <strong
+        >We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to
+        continue.</strong>
+
+    </noscript>
+    <div id="app" class="g-sidenav-show">Carregando…</div>
+  </body>
+</html>

BIN
docs/.DS_Store


+ 64 - 0
docs/style.md

@@ -0,0 +1,64 @@
+# Guia de Cores — Light e Dark Mode
+
+## Sistema de cor
+
+- **Background/foreground**: usar as variáveis CSS `bg-background` e `text-foreground` — tratam light/dark automaticamente pelo ThemeLoader.
+- **Demais cores**: Tailwind com `dark:` prefix manual (modo escuro é via classe `.dark` no elemento `<html>`).
+
+## Mapeamento de cores
+
+| Elemento | Light | Dark |
+|---|---|---|
+| Texto primário | `text-gray-900` | `dark:text-gray-100` |
+| Texto secundário | `text-gray-500` | `dark:text-gray-400` |
+| Texto terciário/muted | `text-gray-400` | `dark:text-gray-500` |
+| Separadores finos | `text-gray-300` | `dark:text-gray-600` |
+| Bordas padrão | `border-gray-200` | `dark:border-gray-700` |
+| Bordas de badge/chip | `border-gray-300` | `dark:border-gray-600` |
+| Hover de link | `hover:text-gray-900` | `dark:hover:text-white` |
+| Hover de badge | `hover:border-gray-500` | `dark:hover:border-gray-400` |
+| Hover de texto muted | `hover:text-gray-600` | `dark:hover:text-gray-300` |
+
+## Backgrounds
+
+| Elemento | Light | Dark |
+|---|---|---|
+| Fundo da página | `bg-background` (auto) | (auto) |
+| Barra do topo | `bg-primary` | `dark:bg-black` |
+| Footer | `bg-gray-100/50` | `dark:bg-secondary/50` |
+| Texto no footer | `text-black/50` | `dark:text-white/50` |
+
+## Botão primário (submit)
+
+Usar o componente `Button` do PrimeVue com estilo default — ele já suporta dark mode via tema do projeto, sem necessidade de overrides.
+
+## Padrão de uso
+
+Todo `gray-*` que for texto, borda ou hover deve ganhar a contraparte `dark:` correspondente segundo a tabela acima. Exemplo prático:
+
+```html
+<!-- Títulos -->
+<h2 class="text-gray-900 dark:text-gray-100">Título</h2>
+
+<!-- Texto descritivo -->
+<p class="text-gray-500 dark:text-gray-400">Descrição</p>
+
+<!-- Links -->
+<a class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:underline">Link</a>
+
+<!-- Bordas/separadores -->
+<section class="border-t border-gray-200 dark:border-gray-700">
+```
+
+## Erros
+
+- `text-gray-900`/`dark:text-gray-100` — texto principal
+- `text-gray-500`/`dark:text-gray-400` — texto secundário
+- `text-gray-400`/`dark:text-gray-500` — texto terciário
+- Use o componente `Message` com `severity="error"` do PrimeVue para mensagens de erro nos formulários
+
+## Componentes PrimeVue
+
+- `Button`: usar componente sem overrides de cor — segue o tema do projeto.
+- `InputText`, `Password`, `Message`: seguem o tema via variáveis CSS do PrimeVue, já suportam dark mode.
+- Não aplicar `bg-gray-900!` ou outras overrides manuais diretamente nos componentes PrimeVue.

+ 20 - 0
index.html

@@ -0,0 +1,20 @@
+<!doctype html>
+<html lang="pt-BR">
+  <head>
+    <meta charset="UTF-8" />
+    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
+    <link rel="icon" type="image/x-icon" href="/favicon.ico?v=2" />
+    <title>ORACULO</title>
+  </head>
+  <body class="bg-background">
+    <noscript>
+      <strong
+        >We're sorry but this app doesn't work properly without JavaScript enabled. Please enable it to
+        continue.</strong>
+
+    </noscript>
+    <div id="app" class="g-sidenav-show">Carregando…</div>
+    <script type="module" src="/src/main.js"></script>
+  </body>
+</html>

+ 9 - 0
jsconfig.json

@@ -0,0 +1,9 @@
+{
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  },
+  "include": ["src/**/*.js", "src/**/*.vue"]
+}

+ 15 - 0
node_modules/.vite/deps/_metadata.json

@@ -0,0 +1,15 @@
+{
+  "hash": "e45cc84a",
+  "configHash": "5635861b",
+  "lockfileHash": "77fe16be",
+  "browserHash": "3b4962df",
+  "optimized": {
+    "vue": {
+      "src": "../../../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
+      "file": "vue.js",
+      "fileHash": "0989a87e",
+      "needsInterop": false
+    }
+  },
+  "chunks": {}
+}

+ 3 - 0
node_modules/.vite/deps/package.json

@@ -0,0 +1,3 @@
+{
+  "type": "module"
+}

Diferenças do arquivo suprimidas por serem muito extensas
+ 197 - 0
node_modules/.vite/deps/vue.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 3 - 0
node_modules/.vite/deps/vue.js.map


+ 25 - 0
package.json

@@ -0,0 +1,25 @@
+{
+  "name": "ia-empresa-frontend",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vite build",
+    "preview": "vite preview",
+    "lint": "node -c vite.config.js"
+  },
+  "dependencies": {
+    "vue": "^3.4.27",
+    "vue-router": "^4.4.5"
+  },
+  "devDependencies": {
+    "@tailwindcss/cli": "^4.3.0",
+    "@tailwindcss/postcss": "^4.3.0",
+    "@vitejs/plugin-vue": "^6.0.6",
+    "autoprefixer": "^10.5.0",
+    "postcss": "^8.5.15",
+    "tailwindcss": "^4.3.0",
+    "vite": "^6.0.0",
+    "vite-plugin-vue-devtools": "^8.1.1"
+  }
+}

BIN
public/favicon.ico


+ 8 - 0
scripts/launch-editor-trae.sh

@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+file="${1:?}"
+line="${2:-1}"
+column="${3:-1}"
+
+exec /usr/local/bin/trae -r -g "${file}:${line}:${column}"

+ 10 - 0
src/App.vue

@@ -0,0 +1,10 @@
+<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>
+</template>

+ 47 - 0
src/api/chat.js

@@ -0,0 +1,47 @@
+import { apiBaseUrl, apiFetch } from "./client.js";
+
+export async function sendChat(message) {
+  return apiFetch("/api/chat", {
+    method: "POST",
+    body: { message }
+  });
+}
+
+export async function ingestDocuments(documents) {
+  return apiFetch("/api/ingest", {
+    method: "POST",
+    body: { documents }
+  });
+}
+
+export async function ingestFile(file, { source } = {}) {
+  const form = new FormData();
+  form.append("file", file);
+  if (source) form.append("source", source);
+
+  const res = await fetch(`${apiBaseUrl}/api/ingest/file`, {
+    method: "POST",
+    body: form
+  });
+
+  if (!res.ok) {
+    const text = await res.text().catch(() => "");
+    throw new Error(text || `http_error:${res.status}`);
+  }
+
+  const contentType = res.headers.get("content-type") ?? "";
+  if (contentType.includes("application/json")) return res.json();
+  return res.text();
+}
+
+export async function search(query) {
+  return apiFetch("/api/search", {
+    method: "POST",
+    body: { query }
+  });
+}
+
+export async function listDocuments({ limit = 50, offset = 0 } = {}) {
+  const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }).toString();
+  return apiFetch(`/api/documents?${params}`);
+}

+ 20 - 0
src/api/client.js

@@ -0,0 +1,20 @@
+export const apiBaseUrl = import.meta.env.VITE_API_URL ?? "http://localhost:3001";
+
+export async function apiFetch(path, { method = "GET", body } = {}) {
+  const res = await fetch(`${apiBaseUrl}${path}`, {
+    method,
+    headers: {
+      "content-type": "application/json"
+    },
+    body: body ? JSON.stringify(body) : undefined
+  });
+
+  if (!res.ok) {
+    const text = await res.text().catch(() => "");
+    throw new Error(text || `http_error:${res.status}`);
+  }
+
+  const contentType = res.headers.get("content-type") ?? "";
+  if (contentType.includes("application/json")) return res.json();
+  return res.text();
+}

+ 78 - 0
src/components/BotaoAlterarTema.vue

@@ -0,0 +1,78 @@
+<script setup>
+import { computed, onMounted, ref } from "vue";
+
+const TEMAS_VALIDOS = ["light", "dark"];
+const CHAVE_STORAGE = "theme";
+
+const darkModeInterno = ref(false);
+
+const darkMode = computed({
+  get: () => darkModeInterno.value,
+  set: (valor) => alternarTema(valor)
+});
+
+function alternarTema(forcarEscuro) {
+  const escuro = forcarEscuro !== undefined ? forcarEscuro : !darkMode.value;
+  const tema = escuro ? "dark" : "light";
+
+  if (!TEMAS_VALIDOS.includes(tema)) {
+    try {
+      localStorage.removeItem(CHAVE_STORAGE);
+    } catch {}
+    return;
+  }
+
+  try {
+    localStorage.setItem(CHAVE_STORAGE, tema);
+  } catch {}
+  aplicarTema(escuro);
+}
+
+function aplicarTema(escuro) {
+  document.documentElement.classList.toggle("dark", escuro);
+  darkModeInterno.value = escuro;
+}
+
+function obterPreferenciaInicial() {
+  let salvo = null;
+  try {
+    salvo = localStorage.getItem(CHAVE_STORAGE);
+  } catch {
+    salvo = null;
+  }
+  if (salvo === "dark") return true;
+  if (salvo === "light") return false;
+  return window.matchMedia?.("(prefers-color-scheme: dark)")?.matches ?? false;
+}
+
+onMounted(() => {
+  const classeAtual = document.documentElement.classList.contains("dark");
+  const preferencia = obterPreferenciaInicial();
+  const escuro = classeAtual || preferencia;
+  aplicarTema(escuro);
+});
+</script>
+
+<template>
+  <button
+    type="button"
+    class="themeToggleButton"
+    aria-label="Alternar tema"
+    @click="darkMode = !darkMode"
+  >
+    <svg v-if="darkMode" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+      <path
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"
+      />
+    </svg>
+    <svg v-else class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+      <path
+        stroke-linecap="round"
+        stroke-linejoin="round"
+        d="M21.752 15.002A9.718 9.718 0 0 1 18 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 0 0 3 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 0 0 9.002-5.998Z"
+      />
+    </svg>  
+  </button>
+</template>

+ 94 - 0
src/components/ChatWindow.vue

@@ -0,0 +1,94 @@
+<template>
+  <div class="panel">
+    <h2 class="title">Chat</h2>
+    <div ref="chatEl" class="chat">
+      <div v-for="(m, idx) in messages" :key="idx" class="msg" :class="m.role">
+        <div class="bubble">{{ m.content }}</div>
+      </div>
+    </div>
+    <div class="row">
+      <textarea v-model="draft" rows="2" placeholder="Digite sua pergunta..." @keydown.enter.exact.prevent="onSend" />
+      <button :disabled="loading || !draft.trim()" @click="onSend">Enviar</button>
+    </div>
+    <div v-if="error" class="muted" style="margin-top: 8px">Erro: {{ error }}</div>
+  </div>
+</template>
+
+<script setup>
+import { ref, nextTick, watch } from "vue";
+
+const props = defineProps({
+  messages: { type: Array, required: true },
+  loading: { type: Boolean, required: true },
+  error: { type: String, required: true }
+});
+
+const emit = defineEmits(["send"]);
+
+const draft = ref("");
+const chatEl = ref(null);
+
+watch(
+  () => props.messages.length,
+  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>
+
+<style scoped>
+.chat {
+  height: min(560px, calc(100vh - 320px));
+  overflow: auto;
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  padding: 12px;
+  background: var(--chat-bg);
+}
+
+.msg {
+  display: flex;
+  margin-bottom: 10px;
+}
+
+.msg.user {
+  justify-content: flex-end;
+}
+
+.bubble {
+  max-width: 78%;
+  padding: 10px 12px;
+  border-radius: 12px;
+  white-space: pre-wrap;
+  line-height: 1.35;
+}
+
+.msg.user .bubble {
+  background: var(--bubble-user-bg);
+  border: 1px solid var(--bubble-user-border);
+}
+
+.msg.assistant .bubble {
+  background: var(--bubble-assistant-bg);
+  border: 1px solid var(--bubble-assistant-border);
+}
+
+.row {
+  display: grid;
+  grid-template-columns: 1fr 120px;
+  gap: 10px;
+  margin-top: 12px;
+  align-items: start;
+}
+</style>

+ 55 - 0
src/components/DocumentList.vue

@@ -0,0 +1,55 @@
+<template>
+  <div class="panel">
+    <h2 class="title">Documentos</h2>
+    <div class="row">
+      <button :disabled="loading" @click="$emit('refresh')">Atualizar</button>
+      <div class="muted">{{ loading ? "Carregando..." : `${items.length} itens` }}</div>
+    </div>
+    <div v-if="error" class="muted" style="margin-top: 10px">Erro: {{ error }}</div>
+    <div class="list">
+      <div v-for="it in items" :key="String(it.id)" class="item">
+        <div class="muted">{{ it.source || "sem fonte" }} · chunk {{ it.chunkIndex ?? "-" }}</div>
+        <div class="text">{{ it.text }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+defineProps({
+  items: { type: Array, required: true },
+  loading: { type: Boolean, required: true },
+  error: { type: String, required: true }
+});
+defineEmits(["refresh"]);
+</script>
+
+<style scoped>
+.row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+}
+
+.list {
+  margin-top: 12px;
+  display: grid;
+  gap: 10px;
+  max-height: 520px;
+  overflow: auto;
+}
+
+.item {
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  padding: 10px;
+  background: var(--surface-2);
+}
+
+.text {
+  margin-top: 6px;
+  white-space: pre-wrap;
+  line-height: 1.35;
+}
+</style>

+ 61 - 0
src/components/DocumentUpload.vue

@@ -0,0 +1,61 @@
+<template>
+  <div class="panel">
+    <h2 class="title">Ingestão</h2>
+    <div class="muted" style="margin-bottom: 10px">
+      Envie .txt, .pdf, .docx ou imagens (png/jpg) com manuais/prints para alimentar a base.
+    </div>
+    <input
+      type="file"
+      accept=".txt,.pdf,.docx,.png,.jpg,.jpeg,.webp,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,image/*,text/plain"
+      @change="onFile"
+    />
+    <div style="height: 10px" />
+    <textarea v-model="manualText" rows="6" placeholder="Ou cole um texto aqui para ingestão..." />
+    <div style="height: 10px" />
+    <button :disabled="loading || (!manualText.trim() && !file)" @click="onIngest">
+      Ingerir
+    </button>
+    <div v-if="status" class="muted" style="margin-top: 10px">{{ status }}</div>
+    <div v-if="error" class="muted" style="margin-top: 10px">Erro: {{ error }}</div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+
+const emit = defineEmits(["ingested"]);
+
+const manualText = ref("");
+const file = ref(null);
+const loading = ref(false);
+const status = ref("");
+const error = ref("");
+
+async function onFile(e) {
+  const f = e.target.files?.[0];
+  if (!f) return;
+  file.value = f;
+  status.value = `Arquivo carregado: ${f.name}`;
+}
+
+async function onIngest() {
+  error.value = "";
+  status.value = "";
+  loading.value = true;
+  try {
+    const hasManual = Boolean(manualText.value.trim());
+    if (hasManual) {
+      emit("ingested", { text: manualText.value.trim(), source: "manual" });
+    } else if (file.value) {
+      emit("ingested", { file: file.value, source: "upload" });
+    }
+    manualText.value = "";
+    file.value = null;
+    status.value = "Enviado para ingestão.";
+  } catch (e) {
+    error.value = e?.message || "erro";
+  } finally {
+    loading.value = false;
+  }
+}
+</script>

+ 68 - 0
src/components/SearchBox.vue

@@ -0,0 +1,68 @@
+<template>
+  <div class="panel">
+    <h2 class="title">Busca</h2>
+    <div class="row">
+      <input v-model="query" placeholder="Buscar na base..." @keydown.enter="onSearch" />
+      <button :disabled="loading || !query.trim()" @click="onSearch">Buscar</button>
+    </div>
+    <div v-if="error" class="muted" style="margin-top: 10px">Erro: {{ error }}</div>
+    <div v-if="results.length" class="results">
+      <div v-for="r in results" :key="String(r.id)" class="hit">
+        <div class="hitHeader">
+          <div class="muted">score: {{ r.score?.toFixed?.(3) ?? r.score }}</div>
+          <div class="muted">{{ r.source || "sem fonte" }}</div>
+        </div>
+        <div class="hitText">{{ r.text }}</div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref } from "vue";
+
+const props = defineProps({
+  results: { type: Array, required: true },
+  loading: { type: Boolean, required: true },
+  error: { type: String, required: true }
+});
+const emit = defineEmits(["search"]);
+const query = ref("");
+
+function onSearch() {
+  emit("search", query.value);
+}
+</script>
+
+<style scoped>
+.row {
+  display: grid;
+  grid-template-columns: 1fr 120px;
+  gap: 10px;
+}
+
+.results {
+  margin-top: 12px;
+  display: grid;
+  gap: 10px;
+}
+
+.hit {
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  padding: 10px;
+  background: var(--surface-2);
+}
+
+.hitHeader {
+  display: flex;
+  justify-content: space-between;
+  gap: 10px;
+  margin-bottom: 8px;
+}
+
+.hitText {
+  white-space: pre-wrap;
+  line-height: 1.35;
+}
+</style>

+ 267 - 0
src/composables/useChat.js

@@ -0,0 +1,267 @@
+import { ref, watch } from "vue";
+import { sendChat } from "../api/chat.js";
+
+let singleton;
+
+export function useChat() {
+  const defaultMessages = [
+    {
+      role: "assistant",
+      content: "Posso responder usando a base de conhecimento da empresa. Envie uma pergunta ou faça upload de documentos."
+    }
+  ];
+
+  if (singleton) return singleton;
+
+  const STORAGE_CONVERSATIONS = "oraculo_conversations_v1";
+  const STORAGE_ACTIVE_ID = "oraculo_active_conversation_id_v1";
+  const STORAGE_MESSAGES_PREFIX = "oraculo_conversation_messages_v1:";
+
+  function genId() {
+    const a = Date.now().toString(36);
+    const b = Math.random().toString(36).slice(2, 10);
+    return `${a}_${b}`;
+  }
+
+  function normalizeMessages(next) {
+    return (next ?? [])
+      .filter((m) => m && typeof m === "object")
+      .slice(-200)
+      .map((m) => ({
+        role: m?.role === "user" ? "user" : "assistant",
+        content: String(m?.content ?? ""),
+        sources: Array.isArray(m?.sources) ? m.sources : undefined
+      }));
+  }
+
+  function messagesKey(id) {
+    return `${STORAGE_MESSAGES_PREFIX}${String(id ?? "")}`;
+  }
+
+  function loadConversations() {
+    if (typeof window === "undefined") return [];
+    try {
+      const raw = window.localStorage.getItem(STORAGE_CONVERSATIONS);
+      if (!raw) return [];
+      const parsed = JSON.parse(raw);
+      if (!Array.isArray(parsed)) return [];
+      return parsed
+        .filter((c) => c && typeof c === "object")
+        .map((c) => ({
+          id: String(c.id ?? ""),
+          title: String(c.title ?? "Nova conversa"),
+          createdAt: Number(c.createdAt ?? Date.now()),
+          updatedAt: Number(c.updatedAt ?? Date.now()),
+          messageCount: Number(c.messageCount ?? 0)
+        }))
+        .filter((c) => c.id);
+    } catch {
+      return [];
+    }
+  }
+
+  function saveConversations(items) {
+    if (typeof window === "undefined") return;
+    try {
+      window.localStorage.setItem(STORAGE_CONVERSATIONS, JSON.stringify(items));
+    } catch {}
+  }
+
+  function loadActiveConversationId() {
+    if (typeof window === "undefined") return "";
+    try {
+      return String(window.localStorage.getItem(STORAGE_ACTIVE_ID) ?? "");
+    } catch {
+      return "";
+    }
+  }
+
+  function saveActiveConversationId(id) {
+    if (typeof window === "undefined") return;
+    try {
+      window.localStorage.setItem(STORAGE_ACTIVE_ID, String(id ?? ""));
+    } catch {}
+  }
+
+  function loadMessagesForConversation(id) {
+    if (typeof window === "undefined") return defaultMessages;
+    const key = messagesKey(id);
+    try {
+      const raw = window.localStorage.getItem(key);
+      if (!raw) return defaultMessages;
+      const parsed = JSON.parse(raw);
+      if (!Array.isArray(parsed) || parsed.length === 0) return defaultMessages;
+      return normalizeMessages(parsed);
+    } catch {
+      return defaultMessages;
+    }
+  }
+
+  function saveMessagesForConversation(id, next) {
+    if (typeof window === "undefined") return;
+    const key = messagesKey(id);
+    try {
+      window.localStorage.setItem(key, JSON.stringify(normalizeMessages(next)));
+    } catch {}
+  }
+
+  function removeMessagesForConversation(id) {
+    if (typeof window === "undefined") return;
+    try {
+      window.localStorage.removeItem(messagesKey(id));
+    } catch {}
+  }
+
+  const conversations = ref(loadConversations());
+  const activeConversationId = ref(loadActiveConversationId());
+  const messages = ref([]);
+  const loading = ref(false);
+  const error = ref("");
+
+  function ensureConversationExists(id) {
+    const exists = conversations.value.some((c) => c.id === id);
+    if (exists) return;
+    const now = Date.now();
+    conversations.value = [{ id, title: "Nova conversa", createdAt: now, updatedAt: now, messageCount: 0 }, ...conversations.value];
+    saveConversations(conversations.value);
+  }
+
+  function upsertConversationMetaFromMessages(id, nextMessages) {
+    const normalized = normalizeMessages(nextMessages);
+    const firstUser = normalized.find((m) => m.role === "user" && String(m.content ?? "").trim());
+    const title = firstUser ? String(firstUser.content).trim().slice(0, 60) : "Nova conversa";
+    const now = Date.now();
+
+    const existing = conversations.value.find((c) => c.id === id);
+    if (!existing) {
+      conversations.value = [
+        { id, title, createdAt: now, updatedAt: now, messageCount: normalized.length },
+        ...conversations.value
+      ];
+    } else {
+      const next = conversations.value.map((c) =>
+        c.id === id
+          ? { ...c, title: c.title && c.title !== "Nova conversa" ? c.title : title, updatedAt: now, messageCount: normalized.length }
+          : c
+      );
+      next.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
+      conversations.value = next;
+    }
+    saveConversations(conversations.value);
+  }
+
+  function setActiveConversation(id) {
+    const nextId = String(id ?? "").trim();
+    if (!nextId) return;
+    ensureConversationExists(nextId);
+    activeConversationId.value = nextId;
+    saveActiveConversationId(nextId);
+    messages.value = loadMessagesForConversation(nextId);
+  }
+
+  function newConversation() {
+    const id = genId();
+    const now = Date.now();
+    conversations.value = [{ id, title: "Nova conversa", createdAt: now, updatedAt: now, messageCount: defaultMessages.length }, ...conversations.value];
+    saveConversations(conversations.value);
+    activeConversationId.value = id;
+    saveActiveConversationId(id);
+    messages.value = [...defaultMessages];
+    saveMessagesForConversation(id, messages.value);
+    return id;
+  }
+
+  function deleteConversation(id) {
+    const targetId = String(id ?? "").trim();
+    if (!targetId) return;
+    conversations.value = conversations.value.filter((c) => c.id !== targetId);
+    saveConversations(conversations.value);
+    removeMessagesForConversation(targetId);
+
+    if (activeConversationId.value === targetId) {
+      const next = conversations.value[0]?.id;
+      if (next) setActiveConversation(next);
+      else newConversation();
+    }
+  }
+
+  function searchConversations(query) {
+    const q = String(query ?? "").trim().toLowerCase();
+    if (!q) return conversations.value.slice().sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
+
+    const items = conversations.value.slice().sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
+    return items.filter((c) => {
+      if (String(c.title ?? "").toLowerCase().includes(q)) return true;
+      const ms = loadMessagesForConversation(c.id);
+      return ms.some((m) => String(m.content ?? "").toLowerCase().includes(q));
+    });
+  }
+
+  function ensureInitialState() {
+    const existingActive = String(activeConversationId.value ?? "").trim();
+    const hasActive = existingActive && conversations.value.some((c) => c.id === existingActive);
+    if (hasActive) {
+      messages.value = loadMessagesForConversation(existingActive);
+      return;
+    }
+
+    const first = conversations.value[0]?.id;
+    if (first) {
+      setActiveConversation(first);
+      return;
+    }
+
+    newConversation();
+  }
+
+  ensureInitialState();
+
+  watch(
+    messages,
+    (next) => {
+      const id = String(activeConversationId.value ?? "").trim();
+      if (!id) return;
+      saveMessagesForConversation(id, next);
+      upsertConversationMetaFromMessages(id, next);
+    },
+    { deep: true }
+  );
+
+  async function send(content) {
+    error.value = "";
+    const trimmed = String(content ?? "").trim();
+    if (!trimmed) return;
+
+    messages.value.push({ role: "user", content: trimmed });
+    loading.value = true;
+
+    try {
+      const data = await sendChat(trimmed);
+      messages.value.push({
+        role: "assistant",
+        content: data.answer || "(sem resposta)",
+        sources: data.sources ?? []
+      });
+    } catch (e) {
+      error.value = e?.message || "erro";
+      messages.value.push({ role: "assistant", content: "Não consegui responder no momento." });
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  singleton = {
+    conversations,
+    activeConversationId,
+    messages,
+    loading,
+    error,
+    send,
+    newConversation,
+    setActiveConversation,
+    deleteConversation,
+    searchConversations
+  };
+
+  return singleton;
+}

+ 26 - 0
src/composables/useSearch.js

@@ -0,0 +1,26 @@
+import { ref } from "vue";
+import { search } from "../api/chat.js";
+
+export function useSearch() {
+  const results = ref([]);
+  const loading = ref(false);
+  const error = ref("");
+
+  async function run(query) {
+    const q = String(query ?? "").trim();
+    if (!q) return;
+    loading.value = true;
+    error.value = "";
+    try {
+      const data = await search(q);
+      results.value = data.results ?? [];
+    } catch (e) {
+      error.value = e?.message || "erro";
+      results.value = [];
+    } finally {
+      loading.value = false;
+    }
+  }
+
+  return { results, loading, error, run };
+}

+ 174 - 0
src/layout/LayoutSistema.vue

@@ -0,0 +1,174 @@
+<script setup>
+import { computed, onMounted, reactive, ref } from "vue";
+import { useRoute, useRouter } from "vue-router";
+import BotaoAlterarTema from "../components/BotaoAlterarTema.vue";
+import DocumentList from "../components/DocumentList.vue";
+import DocumentUpload from "../components/DocumentUpload.vue";
+import SearchBox from "../components/SearchBox.vue";
+import { ingestDocuments, ingestFile, listDocuments } from "../api/chat.js";
+import { useChat } from "../composables/useChat.js";
+import { useSearch } from "../composables/useSearch.js";
+
+const router = useRouter();
+const route = useRoute();
+const { newConversation } = useChat();
+
+const { results: searchResults, loading: searchLoading, error: searchError, run: runSearch } = useSearch();
+
+const docs = reactive({
+  items: [],
+  loading: false,
+  error: ""
+});
+
+const painelAberto = ref("");
+const rotaAtiva = computed(() => String(route.name ?? ""));
+
+function irPara(nome) {
+  router.push({ name: nome });
+}
+
+function alternarPainel(nome) {
+  const n = String(nome ?? "");
+  painelAberto.value = painelAberto.value === n ? "" : n;
+}
+
+async function novaConversa() {
+  newConversation();
+  await router.push({ name: "home" });
+}
+
+async function loadDocs() {
+  docs.loading = true;
+  docs.error = "";
+  try {
+    const data = await listDocuments({ limit: 50, offset: 0 });
+    docs.items = data.items ?? [];
+  } catch (e) {
+    docs.error = e?.message || "erro";
+    docs.items = [];
+  } finally {
+    docs.loading = false;
+  }
+}
+
+async function onIngested({ text, source, file } = {}) {
+  try {
+    if (file) {
+      await ingestFile(file, { source });
+    } else {
+      await ingestDocuments([{ text, source }]);
+    }
+    await loadDocs();
+  } catch (e) {
+    docs.error = e?.message || "erro";
+  }
+}
+
+onMounted(async () => {
+  await loadDocs();
+});
+</script>
+
+<template>
+  <div class="app">
+    <div class="themeToggle">
+      <BotaoAlterarTema />
+    </div>
+    <div class="appShell">
+      <aside class="sidebar">
+        <div class="sidebarHeader">
+          <div class="brandTitle">ORÁCULO</div>
+          <div class="brandSub">Assistente interno com base de conhecimento</div>
+        </div>
+
+        <div class="sidebarScroll">
+          <div class="panel sidebarNav">
+            <button
+              type="button"
+              class="sidebarNavButton"
+              :class="{ active: rotaAtiva === 'home' }"
+              @click="novaConversa"
+            >
+              Nova conversa
+            </button>
+            <button
+              type="button"
+              class="sidebarNavButton"
+              :class="{ active: rotaAtiva === 'conversas-pesquisar' }"
+              @click="irPara('conversas-pesquisar')"
+            >
+              Pesquisar conversas
+            </button>
+            <button
+              type="button"
+              class="sidebarNavButton"
+              :class="{ active: rotaAtiva === 'conversas-historico' }"
+              @click="irPara('conversas-historico')"
+            >
+              Histórico
+            </button>
+
+            <div class="sidebarNavDivider" />
+
+            <button
+              type="button"
+              class="sidebarNavButton"
+              :class="{ active: painelAberto === 'ingestao' }"
+              @click="alternarPainel('ingestao')"
+            >
+              Ingestão
+            </button>
+            <button
+              type="button"
+              class="sidebarNavButton"
+              :class="{ active: painelAberto === 'documentos' }"
+              @click="alternarPainel('documentos')"
+            >
+              Documentos
+            </button>
+          </div>
+
+          <DocumentUpload v-if="painelAberto === 'ingestao'" @ingested="onIngested" />
+          <template v-if="painelAberto === 'documentos'">
+            <SearchBox :results="searchResults" :loading="searchLoading" :error="searchError" @search="runSearch" />
+            <DocumentList :items="docs.items" :loading="docs.loading" :error="docs.error" @refresh="loadDocs" />
+          </template>
+        </div>
+
+        <div class="sidebarFooter muted">
+          Backend: http://localhost:3001 · Qdrant: http://localhost:6333 · Ollama: http://localhost:11434
+        </div>
+      </aside>
+
+      <main class="main">
+        <slot />
+      </main>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.sidebarNav {
+  display: grid;
+  align-content: end;
+  gap: 6px;
+  padding: 6px;
+}
+
+.sidebarNavButton {
+  width: 100%;
+  height: 95%;
+  text-align: center;
+}
+
+.sidebarNavButton.active {
+  border-color: var(--ring);
+}
+
+.sidebarNavDivider {
+  height: 1px;
+  background: var(--border);
+  margin: 4px 0;
+}
+</style>

+ 0 - 0
src/layout/configuracao-sidebar.js


+ 40 - 0
src/main.js

@@ -0,0 +1,40 @@
+import { createApp } from "vue";
+import App from "./App.vue";
+import "@/styles/style.css";
+import { router } from "./router/index.js";
+
+const appEl = document.getElementById("app");
+
+function mostrarErroInicial(err) {
+  if (!appEl) return;
+  if (appEl.dataset.mounted === "1") return;
+  const msg = typeof err === "string" ? err : err?.message || String(err || "erro");
+  appEl.textContent = `Erro ao iniciar: ${msg}`;
+}
+
+window.addEventListener("error", (e) => mostrarErroInicial(e?.error || e?.message));
+window.addEventListener("unhandledrejection", (e) => mostrarErroInicial(e?.reason));
+
+function aplicarTemaInicial() {
+  let salvo = null;
+  try {
+    salvo = localStorage.getItem("theme");
+  } catch {
+    salvo = null;
+  }
+  const tema =
+    salvo === "dark" || salvo === "light"
+      ? salvo
+      : window.matchMedia?.("(prefers-color-scheme: dark)")?.matches
+        ? "dark"
+        : "light";
+
+  document.documentElement.classList.toggle("dark", tema === "dark");
+}
+
+aplicarTemaInicial();
+
+const app = createApp(App);
+app.use(router);
+app.mount("#app");
+if (appEl) appEl.dataset.mounted = "1";

+ 14 - 0
src/router/index.js

@@ -0,0 +1,14 @@
+import { createRouter, createWebHistory } from "vue-router";
+import PaginaInicialView from "../views/pagina-inicial/PaginaInicialView.vue";
+import ConversasHistoricoView from "../views/conversas/ConversasHistoricoView.vue";
+import ConversasPesquisarView from "../views/conversas/ConversasPesquisarView.vue";
+
+export const router = createRouter({
+  history: createWebHistory(import.meta.env.BASE_URL),
+  routes: [
+    { path: "/", name: "home", component: PaginaInicialView },
+    { path: "/conversas/historico", name: "conversas-historico", component: ConversasHistoricoView },
+    { path: "/conversas/pesquisar", name: "conversas-pesquisar", component: ConversasPesquisarView },
+    { path: "/:pathMatch(.*)*", redirect: "/" }
+  ]
+});

+ 9 - 0
src/services/toast.js

@@ -0,0 +1,9 @@
+let toast = null;
+
+export function setToastInstance(intance){
+    _toast = instance;
+}
+
+export function showToast(options){
+    _toast?.add(options);
+}

+ 12 - 0
src/stores/preferences.js

@@ -0,0 +1,12 @@
+import { defineStore } from 'pinia'
+
+export const usePreferencesStore = defineStore("PreferencesStore", {
+    state: () => ({
+        darkMode: false
+    }),
+    actions: {
+        setDarkMode(value) {
+            this.darkMode = value;
+        }
+    }
+});

+ 300 - 0
src/styles/style.css

@@ -0,0 +1,300 @@
+@import "tailwindcss";
+
+:root {
+  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, "Apple Color Emoji",
+    "Segoe UI Emoji";
+  color-scheme: light;
+  --bg: #f6f8ff;
+  --surface: rgba(255, 255, 255, 0.72);
+  --surface-2: rgba(255, 255, 255, 0.6);
+  --border: rgba(15, 23, 42, 0.14);
+  --text: #0b1224;
+  --muted: rgba(11, 18, 36, 0.65);
+  --shadow: 0 18px 50px rgba(15, 23, 42, 0.14);
+  --ring: rgba(37, 99, 235, 0.45);
+  --btn-border: rgba(37, 99, 235, 0.3);
+  --btn-bg-1: rgba(37, 99, 235, 0.12);
+  --btn-bg-2: rgba(255, 255, 255, 0.92);
+  --chat-bg: rgba(255, 255, 255, 0.55);
+  --bubble-user-bg: rgba(37, 99, 235, 0.12);
+  --bubble-user-border: rgba(37, 99, 235, 0.25);
+  --bubble-assistant-bg: rgba(255, 255, 255, 0.7);
+  --bubble-assistant-border: rgba(15, 23, 42, 0.14);
+}
+
+html.dark {
+  color-scheme: dark;
+  --bg: #070b16;
+  --surface: rgba(16, 24, 44, 0.72);
+  --surface-2: rgba(10, 16, 30, 0.7);
+  --border: rgba(255, 255, 255, 0.12);
+  --text: #e7ecf6;
+  --muted: rgba(231, 236, 246, 0.68);
+  --shadow: 0 18px 50px rgba(0, 0, 0, 0.38);
+  --ring: rgba(91, 140, 255, 0.55);
+  --btn-border: rgba(91, 140, 255, 0.28);
+  --btn-bg-1: rgba(91, 140, 255, 0.18);
+  --btn-bg-2: rgba(27, 42, 78, 0.92);
+  --chat-bg: rgba(7, 11, 22, 0.6);
+  --bubble-user-bg: rgba(27, 42, 78, 0.92);
+  --bubble-user-border: rgba(42, 58, 99, 0.9);
+  --bubble-assistant-bg: rgba(15, 26, 48, 0.9);
+  --bubble-assistant-border: rgba(34, 48, 82, 0.9);
+}
+
+body {
+  margin: 0;
+  background: radial-gradient(1200px 700px at 15% 15%, rgba(91, 140, 255, 0.16), transparent 60%),
+    radial-gradient(900px 700px at 85% 30%, rgba(162, 102, 255, 0.14), transparent 55%),
+    radial-gradient(900px 700px at 60% 85%, rgba(0, 198, 255, 0.08), transparent 60%), var(--bg);
+  color: var(--text);
+}
+
+* {
+  box-sizing: border-box;
+}
+
+.container {
+  max-width: 1100px;
+  margin: 0 auto;
+  padding: 24px;
+}
+
+.app {
+  height: 100vh;
+  overflow: hidden;
+}
+
+.panel {
+  background: var(--surface);
+  border: 1px solid var(--border);
+  border-radius: 12px;
+  padding: 16px;
+  box-shadow: var(--shadow);
+  backdrop-filter: blur(10px);
+}
+
+.title {
+  font-size: 18px;
+  font-weight: 650;
+  margin: 0 0 10px 0;
+}
+
+.muted {
+  color: var(--muted);
+  font-size: 12px;
+}
+
+button {
+  border: 1px solid var(--btn-border);
+  background: linear-gradient(180deg, var(--btn-bg-1), var(--btn-bg-2));
+  color: var(--text);
+  padding: 10px 12px;
+  border-radius: 10px;
+  cursor: pointer;
+  transition: transform 120ms ease, border-color 120ms ease;
+}
+
+button:hover:not(:disabled) {
+  transform: translateY(-1px);
+  border-color: var(--ring);
+}
+
+button:disabled {
+  opacity: 0.5;
+  cursor: not-allowed;
+}
+
+input,
+textarea {
+  width: 100%;
+  border: 1px solid var(--border);
+  background: var(--surface-2);
+  color: var(--text);
+  padding: 10px 12px;
+  border-radius: 10px;
+  outline: none;
+}
+
+select {
+  width: 100%;
+  border: 1px solid var(--border);
+  background: var(--surface-2);
+  color: var(--text);
+  padding: 10px 12px;
+  border-radius: 10px;
+  outline: none;
+  appearance: none;
+}
+
+textarea {
+  resize: vertical;
+}
+
+input:focus,
+textarea:focus,
+select:focus {
+  border-color: var(--ring);
+  box-shadow: 0 0 0 3px rgba(91, 140, 255, 0.18);
+}
+
+.themeToggle {
+  position: fixed;
+  top: 14px;
+  right: 14px;
+  z-index: 1000;
+}
+
+.themeToggleButton {
+  padding: 10px;
+  border-radius: 999px;
+  line-height: 0;
+}
+
+.themeToggleButton svg {
+  width: 20px;
+  height: 20px;
+  display: block;
+}
+
+.appShell {
+  height: 100%;
+  overflow: hidden;
+  display: grid;
+  grid-template-columns: 340px 1fr;
+}
+
+.sidebar {
+  border-right: 1px solid var(--border);
+  background: var(--surface);
+  box-shadow: var(--shadow);
+  backdrop-filter: blur(10px);
+  display: grid;
+  grid-template-rows: auto 1fr auto;
+  min-height: 0;
+  gap: 14px;
+  overflow: hidden;
+  padding: 18px;
+}
+
+.sidebarHeader {
+  padding-right: 34px;
+}
+
+.sidebarScroll {
+  overflow: auto;
+  display: grid;
+  gap: 14px;
+  min-height: 0;
+  padding-right: 2px;
+}
+
+.sidebarFooter {
+  font-size: 11px;
+  line-height: 1.35;
+  padding-right: 34px;
+}
+
+.main {
+  padding: 22px;
+  display: grid;
+  align-items: stretch;
+  min-height: 0;
+  overflow: auto;
+}
+
+.landing {
+  display: grid;
+  place-items: center;
+}
+
+.landingInner {
+  width: min(760px, 100%);
+}
+
+.landingTitle {
+  font-weight: 760;
+  font-size: 28px;
+  letter-spacing: -0.02em;
+}
+
+.landingSub {
+  margin-top: 8px;
+  color: var(--muted);
+}
+
+.landingComposer {
+  margin-top: 18px;
+  padding: 14px;
+}
+
+.landingComposer textarea {
+  min-height: 96px;
+}
+
+.landingActions {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 10px;
+}
+
+.mainChat {
+  width: min(980px, 100%);
+  justify-self: center;
+  align-self: start;
+}
+
+.topbar {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-end;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+
+.brandTitle {
+  letter-spacing: 0.14em;
+  font-weight: 760;
+  font-size: 16px;
+}
+
+.brandSub {
+  margin-top: 6px;
+  color: var(--muted);
+  font-size: 12px;
+}
+
+.layout {
+  display: grid;
+  grid-template-columns: 1.35fr 0.65fr;
+  gap: 16px;
+}
+
+.stack {
+  display: grid;
+  gap: 16px;
+}
+
+.footer {
+  margin-top: 14px;
+}
+
+@media (max-width: 980px) {
+  .appShell {
+    grid-template-columns: 1fr;
+  }
+  .sidebar {
+    border-right: 0;
+    border-bottom: 1px solid var(--border);
+  }
+  .main {
+    padding: 16px;
+  }
+  .topbar {
+    align-items: stretch;
+    flex-direction: column;
+  }
+  .layout {
+    grid-template-columns: 1fr;
+  }
+}

+ 81 - 0
src/views/conversas/ConversasHistoricoView.vue

@@ -0,0 +1,81 @@
+<script setup>
+import { computed } from "vue";
+import { useRouter } from "vue-router";
+import LayoutSistema from "../../layout/LayoutSistema.vue";
+import { useChat } from "../../composables/useChat.js";
+
+const router = useRouter();
+const { conversations, activeConversationId, setActiveConversation, deleteConversation } = useChat();
+
+const items = computed(() =>
+  (conversations.value ?? []).slice().sort((a, b) => Number(b.updatedAt ?? 0) - Number(a.updatedAt ?? 0))
+);
+
+function formatarData(ts) {
+  try {
+    return new Date(Number(ts ?? 0)).toLocaleString();
+  } catch {
+    return "";
+  }
+}
+
+function abrirConversa(id) {
+  setActiveConversation(id);
+  router.push({ name: "home" });
+}
+
+function excluirConversa(id) {
+  deleteConversation(id);
+}
+</script>
+
+<template>
+  <LayoutSistema>
+    <div class="panel">
+      <h2 class="title">Histórico</h2>
+      <div class="muted" style="margin-bottom: 10px">{{ items.length }} conversas</div>
+
+      <div class="historyList">
+        <div v-for="c in items" :key="c.id" class="historyItem" :class="{ active: c.id === activeConversationId }">
+          <button class="historyOpen" type="button" @click="abrirConversa(c.id)">
+            <div class="historyTitle">{{ c.title || "Nova conversa" }}</div>
+            <div class="muted">{{ formatarData(c.updatedAt) }} · {{ c.messageCount ?? 0 }} msgs</div>
+          </button>
+          <button class="historyDelete" type="button" @click="excluirConversa(c.id)">Excluir</button>
+        </div>
+      </div>
+    </div>
+  </LayoutSistema>
+</template>
+
+<style scoped>
+.historyList {
+  display: grid;
+  gap: 10px;
+}
+
+.historyItem {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  gap: 10px;
+  align-items: stretch;
+}
+
+.historyItem.active .historyOpen {
+  border-color: var(--ring);
+}
+
+.historyOpen {
+  text-align: left;
+}
+
+.historyTitle {
+  font-weight: 650;
+  margin-bottom: 6px;
+}
+
+.historyDelete {
+  white-space: nowrap;
+}
+</style>
+

+ 71 - 0
src/views/conversas/ConversasPesquisarView.vue

@@ -0,0 +1,71 @@
+<script setup>
+import { computed, ref } from "vue";
+import { useRouter } from "vue-router";
+import LayoutSistema from "../../layout/LayoutSistema.vue";
+import { useChat } from "../../composables/useChat.js";
+
+const router = useRouter();
+const { searchConversations, setActiveConversation } = useChat();
+
+const draftQuery = ref("");
+const query = ref("");
+const results = computed(() => searchConversations(query.value));
+
+function buscar() {
+  query.value = String(draftQuery.value ?? "");
+}
+
+function abrirConversa(id) {
+  setActiveConversation(id);
+  router.push({ name: "home" });
+}
+</script>
+
+<template>
+  <LayoutSistema>
+    <div class="panel">
+      <h2 class="title">Pesquisar conversas</h2>
+      <div class="row">
+        <input
+          v-model="draftQuery"
+          placeholder="Buscar por título ou conteúdo..."
+          @keydown.enter.prevent="buscar"
+        />
+        <button type="button" :disabled="!draftQuery.trim()" @click="buscar">Buscar</button>
+      </div>
+
+      <div class="muted" style="margin-top: 10px">
+        {{ query.trim() ? `${results.length} resultados` : "Digite para filtrar o histórico." }}
+      </div>
+
+      <div class="results" style="margin-top: 12px">
+        <button v-for="c in results" :key="c.id" class="resultItem" type="button" @click="abrirConversa(c.id)">
+          <div class="resultTitle">{{ c.title || "Nova conversa" }}</div>
+          <div class="muted">{{ c.messageCount ?? 0 }} msgs</div>
+        </button>
+      </div>
+    </div>
+  </LayoutSistema>
+</template>
+
+<style scoped>
+.row {
+  display: grid;
+  grid-template-columns: 1fr 120px;
+  gap: 10px;
+}
+
+.results {
+  display: grid;
+  gap: 10px;
+}
+
+.resultItem {
+  text-align: left;
+}
+
+.resultTitle {
+  font-weight: 650;
+  margin-bottom: 6px;
+}
+</style>

+ 48 - 0
src/views/pagina-inicial/PaginaInicialView.vue

@@ -0,0 +1,48 @@
+<script setup>
+import { computed, ref } from "vue";
+import ChatWindow from "../../components/ChatWindow.vue";
+import LayoutSistema from "../../layout/LayoutSistema.vue";
+import { useChat } from "../../composables/useChat.js";
+
+const { messages: chatMessages, loading: chatLoading, error: chatError, send: sendMessage } = useChat();
+
+const landingDraft = ref("");
+const isLanding = computed(() => {
+  const ms = chatMessages.value ?? [];
+  if (ms.length === 0) return true;
+  if (ms.length === 1 && ms[0]?.role === "assistant") return true;
+  return false;
+});
+
+async function onLandingSend() {
+  const text = landingDraft.value;
+  landingDraft.value = "";
+  await sendMessage(text);
+}
+</script>
+
+<template>
+  <LayoutSistema>
+    <div v-if="isLanding" class="landing">
+      <div class="landingInner">
+        <div class="landingTitle">Como posso ajudar?</div>
+        <div class="landingSub">Digite sua pergunta no campo abaixo. Você também pode alimentar a base pela barra lateral.</div>
+
+        <div class="landingComposer">
+          <textarea
+            v-model="landingDraft"
+            rows="2"
+            placeholder="Digite sua pergunta..."
+            @keydown.enter.exact.prevent="onLandingSend"
+          />
+        </div>
+
+        <div v-if="chatError" class="muted" style="margin-top: 10px">Erro: {{ chatError }}</div>
+      </div>
+    </div>
+
+    <div v-else class="mainChat">
+      <ChatWindow :messages="chatMessages" :loading="chatLoading" :error="chatError" @send="sendMessage" />
+    </div>
+  </LayoutSistema>
+</template>

+ 28 - 0
vite.config.js

@@ -0,0 +1,28 @@
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+import VueDevTools from "vite-plugin-vue-devtools";
+import tailwindcss from "@tailwindcss/postcss";
+import autoprefixer from "autoprefixer";
+import { fileURLToPath } from "node:url";
+import path from "node:path";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+export default defineConfig(({ command }) => ({
+  resolve: {
+    alias: {
+      "@": path.resolve(__dirname, "src")
+    }
+  },
+  plugins: command === "serve"
+    ? [VueDevTools({ launchEditor: fileURLToPath(new URL("./scripts/launch-editor-trae.sh", import.meta.url)) }), vue()]
+    : [vue()],
+  css: {
+    postcss: {
+      plugins: [tailwindcss(), autoprefixer()]
+    }
+  },
+  server: {
+    port: 5173
+  }
+}));

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff