import { ref } from "vue"; import { sendChatStream } from "../api/chat.js"; import { listConversations, createConversation, getConversationMessages, updateConversationTitle as updateConversationTitleAPI, deleteConversation as deleteConversationAPI, exportConversation as exportConversationAPI } from "../api/conversations.js"; import { useToast } from "./useToast.js"; let singleton; let _streamAbort = null; export function resetChatSingleton() { if (_streamAbort) { _streamAbort.abort(); _streamAbort = null; } singleton = undefined; } export function useChat() { if (singleton) return singleton; const toast = useToast(); const defaultMessages = [ { role: "assistant", content: "Posso responder usando a base de conhecimento da empresa. Envie uma pergunta ou faça upload de documentos.", sentAt: 0 } ]; const CONV_PAGE_SIZE = 20; const conversations = ref([]); const activeConversationId = ref(null); const messages = ref([...defaultMessages]); const chatMode = ref("documentos"); const loading = ref(false); const error = ref(""); const conversationsLoading = ref(false); const conversationsError = ref(""); const conversationsHasMore = ref(false); const conversationsOffset = ref(0); function normalizeApiMessages(items) { return (items ?? []).map((m) => { let sources; if (m.Sources) { if (typeof m.Sources === "string") { try { sources = JSON.parse(m.Sources); } catch { sources = undefined; } } else { sources = m.Sources; } } return { role: m.Role === "user" ? "user" : "assistant", content: String(m.Content ?? ""), sources, sentAt: m.SentAt ? new Date(m.SentAt).getTime() : 0 }; }); } function normalizeConversation(c) { return { id: String(c.Id), title: c.Title || "Nova conversa", createdAt: new Date(c.CreatedAt).getTime(), updatedAt: new Date(c.UpdatedAt).getTime(), messageCount: Number(c.MessageCount ?? 0) }; } async function loadConversationsList() { conversationsLoading.value = true; conversationsError.value = ""; conversationsOffset.value = 0; try { const data = await listConversations({ limit: CONV_PAGE_SIZE, offset: 0 }); const items = data.items ?? []; conversations.value = items.map(normalizeConversation); conversationsHasMore.value = items.length >= CONV_PAGE_SIZE; conversationsOffset.value = items.length; } catch (err) { conversations.value = []; conversationsHasMore.value = false; if (err?.message !== "session_expired") { conversationsError.value = err?.message || "Erro ao carregar conversas."; } } finally { conversationsLoading.value = false; } } async function loadMoreConversations() { if (!conversationsHasMore.value || conversationsLoading.value) return; conversationsLoading.value = true; try { const data = await listConversations({ limit: CONV_PAGE_SIZE, offset: conversationsOffset.value }); const items = data.items ?? []; conversations.value = [...conversations.value, ...items.map(normalizeConversation)]; conversationsHasMore.value = items.length >= CONV_PAGE_SIZE; conversationsOffset.value += items.length; } catch (err) { if (err?.message !== "session_expired") { toast.erro("Não foi possível carregar mais conversas."); console.warn("[useChat] loadMoreConversations:", err); } } finally { conversationsLoading.value = false; } } async function setActiveConversation(id) { const strId = String(id ?? "").trim(); if (!strId) return; cancelCurrentStream(); activeConversationId.value = strId; messages.value = [...defaultMessages]; try { const numId = Number(strId); if (!numId) return; const data = await getConversationMessages(numId); if (activeConversationId.value !== strId) return; const normalized = normalizeApiMessages(data?.items); messages.value = normalized.length ? normalized : [...defaultMessages]; } catch (err) { if (activeConversationId.value !== strId) return; messages.value = [...defaultMessages]; if (err?.message !== "session_expired") { conversationsError.value = "Erro ao carregar mensagens da conversa."; console.warn("[useChat] setActiveConversation:", err); } } } function newConversation() { cancelCurrentStream(); activeConversationId.value = null; messages.value = [...defaultMessages]; return null; } async function renameConversation(id, title) { const strId = String(id ?? "").trim(); const numId = Number(strId); if (!numId || !title?.trim()) return; try { await updateConversationTitleAPI(numId, title.trim()); conversations.value = conversations.value.map((c) => c.id === strId ? { ...c, title: title.trim() } : c ); } catch { toast.erro("Não foi possível renomear a conversa."); } } async function deleteConversation(id) { const strId = String(id ?? "").trim(); const numId = Number(strId); if (!numId) return; try { await deleteConversationAPI(numId); conversations.value = conversations.value.filter((c) => c.id !== strId); if (String(activeConversationId.value) === strId) { const next = conversations.value[0]; if (next) await setActiveConversation(next.id); else newConversation(); } } catch { toast.erro("Não foi possível excluir a conversa."); } } async function exportConversation(id) { const numId = Number(String(id ?? "").trim()); if (!numId) return; try { const data = await exportConversationAPI(numId); const conv = conversations.value.find((c) => c.id === String(id)); const filename = `conversa-${conv?.title?.slice(0, 40).replace(/[^a-zA-Z0-9À-ÿ\s]/g, "").trim().replace(/\s+/g, "-") || numId}.md`; const blob = new Blob([data.markdown ?? ""], { type: "text/markdown" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); URL.revokeObjectURL(url); } catch (err) { if (err?.message !== "session_expired") { toast.erro("Não foi possível exportar a conversa."); console.warn("[useChat] exportConversation:", err); } } } function searchConversations(query) { const q = String(query ?? "").trim().toLowerCase(); const sorted = conversations.value .slice() .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); if (!q) return sorted; return sorted.filter((c) => String(c.title ?? "").toLowerCase().includes(q)); } function clearError() { error.value = ""; } function setMode(mode) { chatMode.value = mode === "atendimentos" ? "atendimentos" : "documentos"; } function cancelCurrentStream() { if (_streamAbort) { _streamAbort.abort(); _streamAbort = null; } loading.value = false; } async function send(content) { error.value = ""; const trimmed = String(content ?? "").trim(); if (!trimmed) return; if (_streamAbort) { _streamAbort.abort(); } const abortController = new AbortController(); _streamAbort = abortController; let convId = activeConversationId.value ? Number(activeConversationId.value) : null; if (!convId) { try { const title = trimmed.slice(0, 60); const conv = await createConversation(title); if (_streamAbort !== abortController) { return; } convId = conv.id; activeConversationId.value = String(convId); const now = Date.now(); conversations.value = [ { id: String(convId), title, createdAt: now, updatedAt: now, messageCount: 0 }, ...conversations.value ]; } catch { } } if (_streamAbort !== abortController) { return; } messages.value.push({ role: "user", content: trimmed, sentAt: Date.now() }); const assistantMsg = { role: "assistant", content: "", sources: [], sentAt: Date.now(), streaming: true, statusMsg: "" }; messages.value.push(assistantMsg); const liveMsg = messages.value[messages.value.length - 1]; loading.value = true; try { await sendChatStream(trimmed, { conversationId: convId, mode: chatMode.value, signal: abortController.signal, onStatus: ({ stage, count }) => { if (stage === "buscando") liveMsg.statusMsg = "Buscando documentos..."; else if (stage === "encontrou") liveMsg.statusMsg = `Encontrei ${count} fonte${count !== 1 ? "s" : ""}...`; else if (stage === "reordenando") liveMsg.statusMsg = "Selecionando as fontes mais relevantes..."; else if (stage === "gerando") liveMsg.statusMsg = "Gerando resposta..."; }, onChunk: (delta) => { liveMsg.statusMsg = ""; liveMsg.content += delta; }, onSources: (sources) => { liveMsg.sources = sources ?? []; }, onDone: () => { liveMsg.streaming = false; if (_streamAbort !== abortController) return; _streamAbort = null; loading.value = false; if (convId) { conversations.value = conversations.value .map((c) => c.id === String(convId) ? { ...c, updatedAt: Date.now(), messageCount: (c.messageCount ?? 0) + 2 } : c ) .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0)); } }, onError: (e) => { liveMsg.streaming = false; if (!liveMsg.content) { liveMsg.content = "Não consegui responder no momento."; } if (_streamAbort !== abortController) return; _streamAbort = null; error.value = e?.message || "erro"; loading.value = false; } }); } catch (e) { liveMsg.streaming = false; if (!liveMsg.content) { liveMsg.content = "Não consegui responder no momento."; } if (_streamAbort === abortController) { _streamAbort = null; error.value = e?.message || "erro"; loading.value = false; } } } loadConversationsList(); singleton = { conversations, activeConversationId, messages, chatMode, loading, error, conversationsLoading, conversationsError, conversationsHasMore, send, setMode, clearError, cancelCurrentStream, newConversation, setActiveConversation, renameConversation, deleteConversation, searchConversations, loadConversationsList, loadMoreConversations, exportConversation }; return singleton; }