|
@@ -1,282 +1,187 @@
|
|
|
-import { ref, watch } from "vue";
|
|
|
|
|
-import { sendChat } from "../api/chat.js";
|
|
|
|
|
|
|
+import { ref } from "vue";
|
|
|
|
|
+import { sendChatStream } from "../api/chat.js";
|
|
|
|
|
+import {
|
|
|
|
|
+ listConversations,
|
|
|
|
|
+ createConversation,
|
|
|
|
|
+ getConversationMessages,
|
|
|
|
|
+ deleteConversation as deleteConversationAPI
|
|
|
|
|
+} from "../api/conversations.js";
|
|
|
|
|
|
|
|
let singleton;
|
|
let singleton;
|
|
|
|
|
|
|
|
export function useChat() {
|
|
export function useChat() {
|
|
|
|
|
+ if (singleton) return singleton;
|
|
|
|
|
+
|
|
|
const defaultMessages = [
|
|
const defaultMessages = [
|
|
|
{
|
|
{
|
|
|
role: "assistant",
|
|
role: "assistant",
|
|
|
- content: "Posso responder usando a base de conhecimento da empresa. Envie uma pergunta ou faça upload de documentos."
|
|
|
|
|
|
|
+ content: "Posso responder usando a base de conhecimento da empresa. Envie uma pergunta ou faça upload de documentos.",
|
|
|
|
|
+ sentAt: 0
|
|
|
}
|
|
}
|
|
|
];
|
|
];
|
|
|
|
|
|
|
|
- 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 "";
|
|
|
|
|
|
|
+ const conversations = ref([]);
|
|
|
|
|
+ const activeConversationId = ref(null);
|
|
|
|
|
+ const messages = ref([...defaultMessages]);
|
|
|
|
|
+ const loading = ref(false);
|
|
|
|
|
+ const error = ref("");
|
|
|
|
|
+ const conversationsLoading = ref(false);
|
|
|
|
|
+
|
|
|
|
|
+ function normalizeApiMessages(items) {
|
|
|
|
|
+ return (items ?? []).map((m) => ({
|
|
|
|
|
+ role: m.Role === "user" ? "user" : "assistant",
|
|
|
|
|
+ content: String(m.Content ?? ""),
|
|
|
|
|
+ sources: m.Sources
|
|
|
|
|
+ ? typeof m.Sources === "string"
|
|
|
|
|
+ ? JSON.parse(m.Sources)
|
|
|
|
|
+ : m.Sources
|
|
|
|
|
+ : undefined,
|
|
|
|
|
+ sentAt: m.SentAt ? new Date(m.SentAt).getTime() : 0
|
|
|
|
|
+ }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ async function loadConversationsList() {
|
|
|
|
|
+ conversationsLoading.value = true;
|
|
|
try {
|
|
try {
|
|
|
- return String(window.localStorage.getItem(STORAGE_ACTIVE_ID) ?? "");
|
|
|
|
|
|
|
+ const data = await listConversations();
|
|
|
|
|
+ conversations.value = (data.items ?? []).map((c) => ({
|
|
|
|
|
+ id: String(c.Id),
|
|
|
|
|
+ title: c.Title || "Nova conversa",
|
|
|
|
|
+ createdAt: new Date(c.CreatedAt).getTime(),
|
|
|
|
|
+ updatedAt: new Date(c.UpdatedAt).getTime(),
|
|
|
|
|
+ messageCount: 0
|
|
|
|
|
+ }));
|
|
|
} catch {
|
|
} catch {
|
|
|
- return "";
|
|
|
|
|
|
|
+ conversations.value = [];
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ conversationsLoading.value = false;
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- function saveActiveConversationId(id) {
|
|
|
|
|
- if (typeof window === "undefined") return;
|
|
|
|
|
- try {
|
|
|
|
|
- window.localStorage.setItem(STORAGE_ACTIVE_ID, String(id ?? ""));
|
|
|
|
|
- } catch {}
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function clearActiveConversationId() {
|
|
|
|
|
- if (typeof window === "undefined") return;
|
|
|
|
|
- try {
|
|
|
|
|
- window.localStorage.removeItem(STORAGE_ACTIVE_ID);
|
|
|
|
|
- } catch {}
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- function loadMessagesForConversation(id) {
|
|
|
|
|
- if (typeof window === "undefined") return defaultMessages;
|
|
|
|
|
- const key = messagesKey(id);
|
|
|
|
|
|
|
+ async function setActiveConversation(id) {
|
|
|
|
|
+ const strId = String(id ?? "").trim();
|
|
|
|
|
+ if (!strId) return;
|
|
|
|
|
+ activeConversationId.value = strId;
|
|
|
|
|
+ messages.value = [...defaultMessages];
|
|
|
try {
|
|
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);
|
|
|
|
|
|
|
+ const numId = Number(strId);
|
|
|
|
|
+ if (!numId) return;
|
|
|
|
|
+ const data = await getConversationMessages(numId);
|
|
|
|
|
+ const normalized = normalizeApiMessages(data?.items);
|
|
|
|
|
+ messages.value = normalized.length ? normalized : [...defaultMessages];
|
|
|
} catch {
|
|
} catch {
|
|
|
- return defaultMessages;
|
|
|
|
|
|
|
+ messages.value = [...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() {
|
|
function newConversation() {
|
|
|
- activeConversationId.value = "";
|
|
|
|
|
- clearActiveConversationId();
|
|
|
|
|
|
|
+ activeConversationId.value = null;
|
|
|
messages.value = [...defaultMessages];
|
|
messages.value = [...defaultMessages];
|
|
|
- return "";
|
|
|
|
|
|
|
+ return null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- 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);
|
|
|
|
|
|
|
+ async function deleteConversation(id) {
|
|
|
|
|
+ const strId = String(id ?? "").trim();
|
|
|
|
|
+ const numId = Number(strId);
|
|
|
|
|
+ if (!numId) return;
|
|
|
|
|
+ try {
|
|
|
|
|
+ await deleteConversationAPI(numId);
|
|
|
|
|
+ } catch {}
|
|
|
|
|
+ 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();
|
|
else newConversation();
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function searchConversations(query) {
|
|
function searchConversations(query) {
|
|
|
const q = String(query ?? "").trim().toLowerCase();
|
|
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));
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ 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 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;
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- activeConversationId.value = "";
|
|
|
|
|
- clearActiveConversationId();
|
|
|
|
|
- messages.value = [...defaultMessages];
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- 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) {
|
|
async function send(content) {
|
|
|
error.value = "";
|
|
error.value = "";
|
|
|
const trimmed = String(content ?? "").trim();
|
|
const trimmed = String(content ?? "").trim();
|
|
|
if (!trimmed) return;
|
|
if (!trimmed) return;
|
|
|
|
|
|
|
|
- if (!String(activeConversationId.value ?? "").trim()) {
|
|
|
|
|
- activeConversationId.value = genId();
|
|
|
|
|
- ensureConversationExists(activeConversationId.value);
|
|
|
|
|
|
|
+ let convId = activeConversationId.value ? Number(activeConversationId.value) : null;
|
|
|
|
|
+
|
|
|
|
|
+ if (!convId) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const title = trimmed.slice(0, 60);
|
|
|
|
|
+ const conv = await createConversation(title);
|
|
|
|
|
+ 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 {
|
|
|
|
|
+ // Continue without persistence if creation fails
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- messages.value.push({ role: "user", content: trimmed });
|
|
|
|
|
- saveActiveConversationId(activeConversationId.value);
|
|
|
|
|
- saveMessagesForConversation(activeConversationId.value, messages.value);
|
|
|
|
|
- upsertConversationMetaFromMessages(activeConversationId.value, messages.value);
|
|
|
|
|
|
|
+ messages.value.push({ role: "user", content: trimmed, sentAt: Date.now() });
|
|
|
|
|
+
|
|
|
|
|
+ const assistantMsg = { role: "assistant", content: "", sources: [], sentAt: Date.now(), streaming: true };
|
|
|
|
|
+ messages.value.push(assistantMsg);
|
|
|
|
|
+ const msgIndex = messages.value.length - 1;
|
|
|
|
|
+
|
|
|
loading.value = true;
|
|
loading.value = true;
|
|
|
|
|
|
|
|
- try {
|
|
|
|
|
- const data = await sendChat(trimmed);
|
|
|
|
|
- messages.value.push({
|
|
|
|
|
- role: "assistant",
|
|
|
|
|
- content: data.answer || "(sem resposta)",
|
|
|
|
|
- sources: data.sources ?? []
|
|
|
|
|
- });
|
|
|
|
|
- saveMessagesForConversation(activeConversationId.value, messages.value);
|
|
|
|
|
- upsertConversationMetaFromMessages(activeConversationId.value, messages.value);
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- error.value = e?.message || "erro";
|
|
|
|
|
- messages.value.push({ role: "assistant", content: "Não consegui responder no momento." });
|
|
|
|
|
- saveMessagesForConversation(activeConversationId.value, messages.value);
|
|
|
|
|
- upsertConversationMetaFromMessages(activeConversationId.value, messages.value);
|
|
|
|
|
- } finally {
|
|
|
|
|
- loading.value = false;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ await sendChatStream(trimmed, {
|
|
|
|
|
+ conversationId: convId,
|
|
|
|
|
+ onChunk: (delta) => {
|
|
|
|
|
+ messages.value[msgIndex].content += delta;
|
|
|
|
|
+ },
|
|
|
|
|
+ onSources: (sources) => {
|
|
|
|
|
+ messages.value[msgIndex].sources = sources ?? [];
|
|
|
|
|
+ },
|
|
|
|
|
+ onDone: () => {
|
|
|
|
|
+ messages.value[msgIndex].streaming = false;
|
|
|
|
|
+ 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) => {
|
|
|
|
|
+ error.value = e?.message || "erro";
|
|
|
|
|
+ if (!messages.value[msgIndex].content) {
|
|
|
|
|
+ messages.value[msgIndex].content = "Não consegui responder no momento.";
|
|
|
|
|
+ }
|
|
|
|
|
+ messages.value[msgIndex].streaming = false;
|
|
|
|
|
+ loading.value = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ loadConversationsList();
|
|
|
|
|
+
|
|
|
singleton = {
|
|
singleton = {
|
|
|
conversations,
|
|
conversations,
|
|
|
activeConversationId,
|
|
activeConversationId,
|
|
|
messages,
|
|
messages,
|
|
|
loading,
|
|
loading,
|
|
|
error,
|
|
error,
|
|
|
|
|
+ conversationsLoading,
|
|
|
send,
|
|
send,
|
|
|
newConversation,
|
|
newConversation,
|
|
|
setActiveConversation,
|
|
setActiveConversation,
|
|
|
deleteConversation,
|
|
deleteConversation,
|
|
|
- searchConversations
|
|
|
|
|
|
|
+ searchConversations,
|
|
|
|
|
+ loadConversationsList
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
return singleton;
|
|
return singleton;
|