useChat.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import { ref } from "vue";
  2. import { sendChatStream } from "../api/chat.js";
  3. import {
  4. listConversations,
  5. createConversation,
  6. getConversationMessages,
  7. updateConversationTitle as updateConversationTitleAPI,
  8. deleteConversation as deleteConversationAPI,
  9. exportConversation as exportConversationAPI
  10. } from "../api/conversations.js";
  11. import { useToast } from "./useToast.js";
  12. let singleton;
  13. let _streamAbort = null;
  14. export function resetChatSingleton() {
  15. if (_streamAbort) {
  16. _streamAbort.abort();
  17. _streamAbort = null;
  18. }
  19. singleton = undefined;
  20. }
  21. export function useChat() {
  22. if (singleton) return singleton;
  23. const toast = useToast();
  24. const defaultMessages = [
  25. {
  26. role: "assistant",
  27. content: "Posso responder usando a base de conhecimento da empresa. Envie uma pergunta ou faça upload de documentos.",
  28. sentAt: 0
  29. }
  30. ];
  31. const CONV_PAGE_SIZE = 20;
  32. const conversations = ref([]);
  33. const activeConversationId = ref(null);
  34. const messages = ref([...defaultMessages]);
  35. const chatMode = ref("documentos");
  36. const loading = ref(false);
  37. const error = ref("");
  38. const conversationsLoading = ref(false);
  39. const conversationsError = ref("");
  40. const conversationsHasMore = ref(false);
  41. const conversationsOffset = ref(0);
  42. function normalizeApiMessages(items) {
  43. return (items ?? []).map((m) => {
  44. let sources;
  45. if (m.Sources) {
  46. if (typeof m.Sources === "string") {
  47. try { sources = JSON.parse(m.Sources); } catch { sources = undefined; }
  48. } else {
  49. sources = m.Sources;
  50. }
  51. }
  52. return {
  53. role: m.Role === "user" ? "user" : "assistant",
  54. content: String(m.Content ?? ""),
  55. sources,
  56. sentAt: m.SentAt ? new Date(m.SentAt).getTime() : 0
  57. };
  58. });
  59. }
  60. function normalizeConversation(c) {
  61. return {
  62. id: String(c.Id),
  63. title: c.Title || "Nova conversa",
  64. createdAt: new Date(c.CreatedAt).getTime(),
  65. updatedAt: new Date(c.UpdatedAt).getTime(),
  66. messageCount: Number(c.MessageCount ?? 0)
  67. };
  68. }
  69. async function loadConversationsList() {
  70. conversationsLoading.value = true;
  71. conversationsError.value = "";
  72. conversationsOffset.value = 0;
  73. try {
  74. const data = await listConversations({ limit: CONV_PAGE_SIZE, offset: 0 });
  75. const items = data.items ?? [];
  76. conversations.value = items.map(normalizeConversation);
  77. conversationsHasMore.value = items.length >= CONV_PAGE_SIZE;
  78. conversationsOffset.value = items.length;
  79. } catch (err) {
  80. conversations.value = [];
  81. conversationsHasMore.value = false;
  82. if (err?.message !== "session_expired") {
  83. conversationsError.value = err?.message || "Erro ao carregar conversas.";
  84. }
  85. } finally {
  86. conversationsLoading.value = false;
  87. }
  88. }
  89. async function loadMoreConversations() {
  90. if (!conversationsHasMore.value || conversationsLoading.value) return;
  91. conversationsLoading.value = true;
  92. try {
  93. const data = await listConversations({ limit: CONV_PAGE_SIZE, offset: conversationsOffset.value });
  94. const items = data.items ?? [];
  95. conversations.value = [...conversations.value, ...items.map(normalizeConversation)];
  96. conversationsHasMore.value = items.length >= CONV_PAGE_SIZE;
  97. conversationsOffset.value += items.length;
  98. } catch (err) {
  99. if (err?.message !== "session_expired") {
  100. toast.erro("Não foi possível carregar mais conversas.");
  101. console.warn("[useChat] loadMoreConversations:", err);
  102. }
  103. } finally {
  104. conversationsLoading.value = false;
  105. }
  106. }
  107. async function setActiveConversation(id) {
  108. const strId = String(id ?? "").trim();
  109. if (!strId) return;
  110. cancelCurrentStream();
  111. activeConversationId.value = strId;
  112. messages.value = [...defaultMessages];
  113. try {
  114. const numId = Number(strId);
  115. if (!numId) return;
  116. const data = await getConversationMessages(numId);
  117. if (activeConversationId.value !== strId) return;
  118. const normalized = normalizeApiMessages(data?.items);
  119. messages.value = normalized.length ? normalized : [...defaultMessages];
  120. } catch (err) {
  121. if (activeConversationId.value !== strId) return;
  122. messages.value = [...defaultMessages];
  123. if (err?.message !== "session_expired") {
  124. conversationsError.value = "Erro ao carregar mensagens da conversa.";
  125. console.warn("[useChat] setActiveConversation:", err);
  126. }
  127. }
  128. }
  129. function newConversation() {
  130. cancelCurrentStream();
  131. activeConversationId.value = null;
  132. messages.value = [...defaultMessages];
  133. return null;
  134. }
  135. async function renameConversation(id, title) {
  136. const strId = String(id ?? "").trim();
  137. const numId = Number(strId);
  138. if (!numId || !title?.trim()) return;
  139. try {
  140. await updateConversationTitleAPI(numId, title.trim());
  141. conversations.value = conversations.value.map((c) =>
  142. c.id === strId ? { ...c, title: title.trim() } : c
  143. );
  144. } catch {
  145. toast.erro("Não foi possível renomear a conversa.");
  146. }
  147. }
  148. async function deleteConversation(id) {
  149. const strId = String(id ?? "").trim();
  150. const numId = Number(strId);
  151. if (!numId) return;
  152. try {
  153. await deleteConversationAPI(numId);
  154. conversations.value = conversations.value.filter((c) => c.id !== strId);
  155. if (String(activeConversationId.value) === strId) {
  156. const next = conversations.value[0];
  157. if (next) await setActiveConversation(next.id);
  158. else newConversation();
  159. }
  160. } catch {
  161. toast.erro("Não foi possível excluir a conversa.");
  162. }
  163. }
  164. async function exportConversation(id) {
  165. const numId = Number(String(id ?? "").trim());
  166. if (!numId) return;
  167. try {
  168. const data = await exportConversationAPI(numId);
  169. const conv = conversations.value.find((c) => c.id === String(id));
  170. const filename = `conversa-${conv?.title?.slice(0, 40).replace(/[^a-zA-Z0-9À-ÿ\s]/g, "").trim().replace(/\s+/g, "-") || numId}.md`;
  171. const blob = new Blob([data.markdown ?? ""], { type: "text/markdown" });
  172. const url = URL.createObjectURL(blob);
  173. const a = document.createElement("a");
  174. a.href = url;
  175. a.download = filename;
  176. a.click();
  177. URL.revokeObjectURL(url);
  178. } catch (err) {
  179. if (err?.message !== "session_expired") {
  180. toast.erro("Não foi possível exportar a conversa.");
  181. console.warn("[useChat] exportConversation:", err);
  182. }
  183. }
  184. }
  185. function searchConversations(query) {
  186. const q = String(query ?? "").trim().toLowerCase();
  187. const sorted = conversations.value
  188. .slice()
  189. .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
  190. if (!q) return sorted;
  191. return sorted.filter((c) => String(c.title ?? "").toLowerCase().includes(q));
  192. }
  193. function clearError() {
  194. error.value = "";
  195. }
  196. function setMode(mode) {
  197. chatMode.value = mode === "atendimentos" ? "atendimentos" : "documentos";
  198. }
  199. function cancelCurrentStream() {
  200. if (_streamAbort) {
  201. _streamAbort.abort();
  202. _streamAbort = null;
  203. }
  204. loading.value = false;
  205. }
  206. async function send(content) {
  207. error.value = "";
  208. const trimmed = String(content ?? "").trim();
  209. if (!trimmed) return;
  210. if (_streamAbort) {
  211. _streamAbort.abort();
  212. }
  213. const abortController = new AbortController();
  214. _streamAbort = abortController;
  215. let convId = activeConversationId.value ? Number(activeConversationId.value) : null;
  216. if (!convId) {
  217. try {
  218. const title = trimmed.slice(0, 60);
  219. const conv = await createConversation(title);
  220. if (_streamAbort !== abortController) {
  221. return;
  222. }
  223. convId = conv.id;
  224. activeConversationId.value = String(convId);
  225. const now = Date.now();
  226. conversations.value = [
  227. { id: String(convId), title, createdAt: now, updatedAt: now, messageCount: 0 },
  228. ...conversations.value
  229. ];
  230. } catch {
  231. }
  232. }
  233. if (_streamAbort !== abortController) {
  234. return;
  235. }
  236. messages.value.push({ role: "user", content: trimmed, sentAt: Date.now() });
  237. const assistantMsg = { role: "assistant", content: "", sources: [], sentAt: Date.now(), streaming: true, statusMsg: "" };
  238. messages.value.push(assistantMsg);
  239. const liveMsg = messages.value[messages.value.length - 1];
  240. loading.value = true;
  241. try {
  242. await sendChatStream(trimmed, {
  243. conversationId: convId,
  244. mode: chatMode.value,
  245. signal: abortController.signal,
  246. onStatus: ({ stage, count }) => {
  247. if (stage === "buscando") liveMsg.statusMsg = "Buscando documentos...";
  248. else if (stage === "encontrou") liveMsg.statusMsg = `Encontrei ${count} fonte${count !== 1 ? "s" : ""}...`;
  249. else if (stage === "reordenando") liveMsg.statusMsg = "Selecionando as fontes mais relevantes...";
  250. else if (stage === "gerando") liveMsg.statusMsg = "Gerando resposta...";
  251. },
  252. onChunk: (delta) => {
  253. liveMsg.statusMsg = "";
  254. liveMsg.content += delta;
  255. },
  256. onSources: (sources) => {
  257. liveMsg.sources = sources ?? [];
  258. },
  259. onDone: () => {
  260. liveMsg.streaming = false;
  261. if (_streamAbort !== abortController) return;
  262. _streamAbort = null;
  263. loading.value = false;
  264. if (convId) {
  265. conversations.value = conversations.value
  266. .map((c) =>
  267. c.id === String(convId)
  268. ? { ...c, updatedAt: Date.now(), messageCount: (c.messageCount ?? 0) + 2 }
  269. : c
  270. )
  271. .sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
  272. }
  273. },
  274. onError: (e) => {
  275. liveMsg.streaming = false;
  276. if (!liveMsg.content) {
  277. liveMsg.content = "Não consegui responder no momento.";
  278. }
  279. if (_streamAbort !== abortController) return;
  280. _streamAbort = null;
  281. error.value = e?.message || "erro";
  282. loading.value = false;
  283. }
  284. });
  285. } catch (e) {
  286. liveMsg.streaming = false;
  287. if (!liveMsg.content) {
  288. liveMsg.content = "Não consegui responder no momento.";
  289. }
  290. if (_streamAbort === abortController) {
  291. _streamAbort = null;
  292. error.value = e?.message || "erro";
  293. loading.value = false;
  294. }
  295. }
  296. }
  297. loadConversationsList();
  298. singleton = {
  299. conversations,
  300. activeConversationId,
  301. messages,
  302. chatMode,
  303. loading,
  304. error,
  305. conversationsLoading,
  306. conversationsError,
  307. conversationsHasMore,
  308. send,
  309. setMode,
  310. clearError,
  311. cancelCurrentStream,
  312. newConversation,
  313. setActiveConversation,
  314. renameConversation,
  315. deleteConversation,
  316. searchConversations,
  317. loadConversationsList,
  318. loadMoreConversations,
  319. exportConversation
  320. };
  321. return singleton;
  322. }