From eaf0a7b7a40174cf87c5dea0d93ae2ad210872e8 Mon Sep 17 00:00:00 2001 From: kritzl Date: Fri, 15 May 2026 11:37:16 +0200 Subject: [PATCH] improved chat action handling, typing animation for chatbot --- app/src/assets/chat.ts | 148 +++++++++++++++++++++++++++++----- app/src/assets/chatDict_de.ts | 8 +- 2 files changed, 131 insertions(+), 25 deletions(-) diff --git a/app/src/assets/chat.ts b/app/src/assets/chat.ts index e4dc2ba..af7d980 100644 --- a/app/src/assets/chat.ts +++ b/app/src/assets/chat.ts @@ -53,6 +53,8 @@ type ChatMessage = { type: "bot" | "user", message: string, special?: boolean; + id?: string; + fresh?: boolean; } const chat: Array = [] @@ -67,28 +69,46 @@ const timeouts: { message: null, } +let chatId: number = 0 + function refreshChat() { - const templateBot = (message: string) => ` -
-
${message}
-
` + const templateBot = (id: string, message: string, special: boolean) => { + const chatContainer = document.createElement("div") + const chatBubble = document.createElement("div") + + chatBubble.classList.add("chat-bubble", "chat-bubble-primary", "whitespace-normal") + chatBubble.classList.toggle("shimmer", special) + chatBubble.id = id + chatBubble.dataset.content = message + chatContainer.classList.add("chat", "chat-start", "w-full") + chatContainer.append(chatBubble) + + return chatContainer + } + const templateUser = (message: string) => ` -
+
${message}
` - const chatElement: HTMLDivElement = document.querySelector("#chat")! - const chatWaitElement: HTMLDivElement = document.querySelector("#chat-waiting")! - - chatElement.innerHTML = "" chat.forEach(chatItem => { switch (chatItem.type) { case "bot": - chatElement?.insertAdjacentHTML("beforeend", templateBot(chatItem.message)) + if (!chatItem.id) { + chatItem.id = `chat-${chatId}` + chatId++ + chatElement?.appendChild(templateBot(chatItem.id, chatItem.message, chatItem.special ?? false)) + typeBubble(chatItem.id, () => { + }) + } break case "user": - chatElement?.insertAdjacentHTML("beforeend", templateUser(chatItem.message)) + if (!chatItem.fresh) { + chatItem.fresh = true + chatElement?.insertAdjacentHTML("beforeend", templateUser(chatItem.message)) + } + break } }) @@ -98,6 +118,44 @@ function refreshChat() { }) } +function typeBubble(id: string, callback: Function) { + const element = document.getElementById(id)! + const tmpDiv = document.createElement("div") + tmpDiv.innerHTML = element.getAttribute("data-content") || "" + + const typingArray: Array = [] + + tmpDiv.childNodes.forEach(child => { + // check if textNode + if (child.nodeType === 3) { + typingArray.push(...(child.textContent?.split("")) ?? []) + } else { + typingArray.push((child as Element).outerHTML) + } + }) + + let text = "" + let charIndex = 0 + const typingSpeed = 25 + + function typeChar() { + if (charIndex < typingArray.length) { + text += typingArray.at(charIndex) + element.innerHTML = text + charIndex++ + setTimeout(typeChar, typingSpeed) + chatElement.scrollTo({ + top: chatElement.scrollHeight, + }) + } else if (callback) { + callback() + } + } + + typeChar() +} + + function sendMessage() { const chatTextElement: HTMLTextAreaElement = document.querySelector("#chat-text")! const text = chatTextElement.value @@ -128,14 +186,12 @@ function sendMessage() { } function getResponse(message: string) { - const sortedResponses = responses.sort( - (a, b) => b.priority - a.priority) - const msg = removeDiacritics(message.trim().normalize()).toLowerCase() const matchingResponses: Array<{ - message: string, - priority: number, + message: string; + priority: number; + special?: boolean; }> = [] let highestMatchingPriority = 0 for (const response of sortedResponses) { @@ -182,6 +238,10 @@ function getResponse(message: string) { message = response.errors ? response.errors["unknownDoor"].replace("{doors}", doorListElement) : "" } + if (doors.length == 0) { + message = response.errors ? response.errors["noDoors"] : "" + } + if (!auth.authorized) { message = response.errors ? response.errors["unauthorized"] : "" } @@ -194,8 +254,7 @@ function getResponse(message: string) { doorAction(action, door.id) }) - message = response.response. - replace("{door}", matchedDoors + message = response.response.replace("{door}", matchedDoors .map(door => `${door.label}`) .join()) } @@ -203,9 +262,29 @@ function getResponse(message: string) { matchingResponses.push({ message: message, priority: response.priority, + special: true, }) } + if (response.special === "logout") { + if (auth.authenticated) { + setTimeout(() => { + window.location.href = "/auth/logout?next=/" + }, 1000) + + matchingResponses.push({ + message: response.response, + priority: response.priority, + special: true, + }) + } else { + matchingResponses.push({ + message: response.errors ? response.errors["unauthenticated"] : "", + priority: response.priority, + special: true, + }) + } + } // stop searching for more matches break @@ -221,12 +300,12 @@ function getResponse(message: string) { if (matchingResponses.length > 0) { const matchingMessages = matchingResponses .filter(response => response.priority == highestMatchingPriority) - .map(response => response.message) - const chatMessages = matchingMessages.map(message => { + const chatMessages = matchingMessages.map(response => { return { type: "bot" as ChatMessage["type"], - message: message, + message: response.message, + special: response.special } }) @@ -240,10 +319,22 @@ function getResponse(message: string) { } document.querySelector("body")?.insertAdjacentHTML("beforeend", templateInjectChat) +const chatElement: HTMLDivElement = document.querySelector("#chat")! +const chatWaitElement: HTMLDivElement = document.querySelector("#chat-waiting")! + +function initChat() { + chat.push({ + type: "bot", + message: sortedResponses.at(-2)?.response ?? "", + }) + refreshChat() +} + +let firstOpen = true createIcons({ nameAttr: "data-lucide", - root: document.querySelector(".fab")!, + root: document.querySelector("#chat-root")!, icons: { Stars, Send, @@ -255,4 +346,17 @@ document.querySelector("#chat-text")!.addEventListener("keydown", (e: Event) => if ((e as KeyboardEvent).key === "Enter") { sendMessage() } +}) + +document.querySelector("#chat-root")!.addEventListener("click", () => { + if (firstOpen) { + firstOpen = false + chatWaiting.value = true + refreshChat() + + timeouts.message = setTimeout(() => { + chatWaiting.value = false + initChat() + }, 1000) + } }) \ No newline at end of file diff --git a/app/src/assets/chatDict_de.ts b/app/src/assets/chatDict_de.ts index b651efb..dedaa59 100644 --- a/app/src/assets/chatDict_de.ts +++ b/app/src/assets/chatDict_de.ts @@ -7,13 +7,13 @@ const responses: ResponseDictType[] = [ priority: 0, }, { - trigger: ["ai", "ki"], + trigger: ["ai", "ki", "llm"], response: "Künstliche Intelligenz? Ne, hier gibt's nur handgemachten Stuss! 🤖", priority: 0, }, { trigger: ["miau"], - response: "miau!", + response: "miau! 😸", priority: 0, }, { @@ -22,7 +22,7 @@ const responses: ResponseDictType[] = [ priority: 0, }, { - trigger: ["hi", "moin", "hallo", "servus", "gruess gott", "uwu"], + trigger: ["hi", "moin", "hallo", "servus", "gruess gott"], response: "Moin! 👋", priority: 0, }, @@ -33,6 +33,7 @@ const responses: ResponseDictType[] = [ unauthenticated: "Ich darf nur angemeldeten Wesen die Türen aufschließen 😕", unauthorized: "Ich darf nur berechtigten Wesen die Türen aufschließen 😕", unknownDoor: "Um eine Tür zu öffnen, musst du mir sagen, welche Tür du meinst 😕
Ich kenne folgende Türen: {doors}", + noDoors: "Es sind gerade keine Türen verfügbar, die aufgeschlossen werden können 😕", }, priority: 1000, special: "unlock", @@ -44,6 +45,7 @@ const responses: ResponseDictType[] = [ unauthenticated: "Ich darf nur angemeldeten Wesen die Türen zusperren 😕", unauthorized: "Ich darf nur berechtigten Wesen die Türen zusperren 😕", unknownDoor: "Um eine Tür zu schließen, musst du mir sagen, welche Tür du meinst 😕
Ich kenne folgende Türen: {doors}", + noDoors: "Es sind gerade keine Türen verfügbar, die zugeschlossen werden können 😕", }, priority: 999, special: "lock",