improved chat action handling, typing animation for chatbot

This commit is contained in:
kritzl 2026-05-15 11:37:16 +02:00
commit eaf0a7b7a4
Signed by: kritzl
SSH key fingerprint: SHA256:5BmINP9VjZWaUk5Z+2CTut1KFhwLtd0ZynMekKbtViM
2 changed files with 131 additions and 25 deletions

View file

@ -53,6 +53,8 @@ type ChatMessage = {
type: "bot" | "user", type: "bot" | "user",
message: string, message: string,
special?: boolean; special?: boolean;
id?: string;
fresh?: boolean;
} }
const chat: Array<ChatMessage> = [] const chat: Array<ChatMessage> = []
@ -67,28 +69,46 @@ const timeouts: {
message: null, message: null,
} }
let chatId: number = 0
function refreshChat() { function refreshChat() {
const templateBot = (message: string) => ` const templateBot = (id: string, message: string, special: boolean) => {
<div class="chat chat-start w-full prose"> const chatContainer = document.createElement("div")
<div class="chat-bubble chat-bubble-primary whitespace-normal">${message}</div> const chatBubble = document.createElement("div")
</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) => ` const templateUser = (message: string) => `
<div class="chat chat-end w-full prose"> <div class="chat chat-end w-full">
<div class="chat-bubble chat-bubble-secondary whitespace-normal">${message}</div> <div class="chat-bubble chat-bubble-secondary whitespace-normal">${message}</div>
</div>` </div>`
const chatElement: HTMLDivElement = document.querySelector("#chat")!
const chatWaitElement: HTMLDivElement = document.querySelector("#chat-waiting")!
chatElement.innerHTML = ""
chat.forEach(chatItem => { chat.forEach(chatItem => {
switch (chatItem.type) { switch (chatItem.type) {
case "bot": 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 break
case "user": case "user":
chatElement?.insertAdjacentHTML("beforeend", templateUser(chatItem.message)) if (!chatItem.fresh) {
chatItem.fresh = true
chatElement?.insertAdjacentHTML("beforeend", templateUser(chatItem.message))
}
break 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<string> = []
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() { function sendMessage() {
const chatTextElement: HTMLTextAreaElement = document.querySelector("#chat-text")! const chatTextElement: HTMLTextAreaElement = document.querySelector("#chat-text")!
const text = chatTextElement.value const text = chatTextElement.value
@ -128,14 +186,12 @@ function sendMessage() {
} }
function getResponse(message: string) { function getResponse(message: string) {
const sortedResponses = responses.sort(
(a, b) => b.priority - a.priority)
const msg = removeDiacritics(message.trim().normalize()).toLowerCase() const msg = removeDiacritics(message.trim().normalize()).toLowerCase()
const matchingResponses: Array<{ const matchingResponses: Array<{
message: string, message: string;
priority: number, priority: number;
special?: boolean;
}> = [] }> = []
let highestMatchingPriority = 0 let highestMatchingPriority = 0
for (const response of sortedResponses) { for (const response of sortedResponses) {
@ -182,6 +238,10 @@ function getResponse(message: string) {
message = response.errors ? response.errors["unknownDoor"].replace("{doors}", doorListElement) : "" message = response.errors ? response.errors["unknownDoor"].replace("{doors}", doorListElement) : ""
} }
if (doors.length == 0) {
message = response.errors ? response.errors["noDoors"] : ""
}
if (!auth.authorized) { if (!auth.authorized) {
message = response.errors ? response.errors["unauthorized"] : "" message = response.errors ? response.errors["unauthorized"] : ""
} }
@ -194,8 +254,7 @@ function getResponse(message: string) {
doorAction(action, door.id) doorAction(action, door.id)
}) })
message = response.response. message = response.response.replace("{door}", matchedDoors
replace("{door}", matchedDoors
.map(door => `<span class="badge">${door.label}</span>`) .map(door => `<span class="badge">${door.label}</span>`)
.join()) .join())
} }
@ -203,9 +262,29 @@ function getResponse(message: string) {
matchingResponses.push({ matchingResponses.push({
message: message, message: message,
priority: response.priority, 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 // stop searching for more matches
break break
@ -221,12 +300,12 @@ function getResponse(message: string) {
if (matchingResponses.length > 0) { if (matchingResponses.length > 0) {
const matchingMessages = matchingResponses const matchingMessages = matchingResponses
.filter(response => response.priority == highestMatchingPriority) .filter(response => response.priority == highestMatchingPriority)
.map(response => response.message)
const chatMessages = matchingMessages.map(message => { const chatMessages = matchingMessages.map(response => {
return { return {
type: "bot" as ChatMessage["type"], 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) 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({ createIcons({
nameAttr: "data-lucide", nameAttr: "data-lucide",
root: document.querySelector(".fab")!, root: document.querySelector("#chat-root")!,
icons: { icons: {
Stars, Stars,
Send, Send,
@ -256,3 +347,16 @@ document.querySelector("#chat-text")!.addEventListener("keydown", (e: Event) =>
sendMessage() sendMessage()
} }
}) })
document.querySelector("#chat-root")!.addEventListener("click", () => {
if (firstOpen) {
firstOpen = false
chatWaiting.value = true
refreshChat()
timeouts.message = setTimeout(() => {
chatWaiting.value = false
initChat()
}, 1000)
}
})

View file

@ -7,13 +7,13 @@ const responses: ResponseDictType[] = [
priority: 0, priority: 0,
}, },
{ {
trigger: ["ai", "ki"], trigger: ["ai", "ki", "llm"],
response: "Künstliche Intelligenz? Ne, hier gibt's nur handgemachten Stuss! 🤖", response: "Künstliche Intelligenz? Ne, hier gibt's nur handgemachten Stuss! 🤖",
priority: 0, priority: 0,
}, },
{ {
trigger: ["miau"], trigger: ["miau"],
response: "miau!", response: "miau! 😸",
priority: 0, priority: 0,
}, },
{ {
@ -22,7 +22,7 @@ const responses: ResponseDictType[] = [
priority: 0, priority: 0,
}, },
{ {
trigger: ["hi", "moin", "hallo", "servus", "gruess gott", "uwu"], trigger: ["hi", "moin", "hallo", "servus", "gruess gott"],
response: "Moin! 👋", response: "Moin! 👋",
priority: 0, priority: 0,
}, },
@ -33,6 +33,7 @@ const responses: ResponseDictType[] = [
unauthenticated: "Ich darf nur angemeldeten Wesen die Türen aufschließen 😕", unauthenticated: "Ich darf nur angemeldeten Wesen die Türen aufschließen 😕",
unauthorized: "Ich darf nur berechtigten 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 😕<br> Ich kenne folgende Türen: {doors}", unknownDoor: "Um eine Tür zu öffnen, musst du mir sagen, welche Tür du meinst 😕<br> Ich kenne folgende Türen: {doors}",
noDoors: "Es sind gerade keine Türen verfügbar, die aufgeschlossen werden können 😕",
}, },
priority: 1000, priority: 1000,
special: "unlock", special: "unlock",
@ -44,6 +45,7 @@ const responses: ResponseDictType[] = [
unauthenticated: "Ich darf nur angemeldeten Wesen die Türen zusperren 😕", unauthenticated: "Ich darf nur angemeldeten Wesen die Türen zusperren 😕",
unauthorized: "Ich darf nur berechtigten 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 😕<br> Ich kenne folgende Türen: {doors}", unknownDoor: "Um eine Tür zu schließen, musst du mir sagen, welche Tür du meinst 😕<br> Ich kenne folgende Türen: {doors}",
noDoors: "Es sind gerade keine Türen verfügbar, die zugeschlossen werden können 😕",
}, },
priority: 999, priority: 999,
special: "lock", special: "lock",