useChat.js 9.5 KB

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