|
@@ -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;
|
|
|
|
|
+}
|