dooris/app/src/assets/chat.ts
2026-05-14 20:34:18 +02:00

257 lines
No EOL
7.7 KiB
TypeScript

import {createIcons, Send, Stars} from "lucide"
import removeDiacritics from "./removeDiacritics.ts"
export type ResponseDictType = {
trigger?: Array<string>;
startsWith?: Array<string>;
endsWith?: Array<string>;
response: string;
errors?: Record<string, string>
priority: number;
special?: string;
}
const lang = window.lang
const doors = window.doors
const auth = window.auth
const doorAction = window.doorAction
const responses =
lang === "de"
? (await import("./chatDict_de")).default
: (await import("./chatDict_en")).default
const templateInjectChat = `
<div class="fab">
<div tabindex="0" role="button" class="btn btn-lg btn-circle btn-primary">
<i data-lucide="stars"></i>
</div>
<div class="card min-h-120 max-h-200 w-80 bg-base-300 p-4">
<div class="w-full overflow-auto h-full" id="chat"></div>
<div class="chat chat-start w-full hidden" id="chat-waiting">
<div class="chat-bubble chat-bubble-primary whitespace-normal">
<span class="loading loading-dots loading-md"></span>
</div>
</div>
<div class="join mt-auto w-full">
<div class="w-full">
<label class="input join-item">
<input type="text" id="chat-text" />
</label>
</div>
<button class="btn btn-secondary join-item" id="send-chat">
<i data-lucide="send"></i>
</button>
</div>
</div>
</div>`
type ChatMessage = {
type: "bot" | "user",
message: string,
special?: boolean;
}
const chat: Array<ChatMessage> = []
const chatWaiting = {
value: false,
}
const timeouts: {
wait: number | null;
message: number | null;
} = {
wait: null,
message: null,
}
function refreshChat() {
const templateBot = (message: string) => `
<div class="chat chat-start w-full prose">
<div class="chat-bubble chat-bubble-primary whitespace-normal">${message}</div>
</div>`
const templateUser = (message: string) => `
<div class="chat chat-end w-full prose">
<div class="chat-bubble chat-bubble-secondary whitespace-normal">${message}</div>
</div>`
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))
break
case "user":
chatElement?.insertAdjacentHTML("beforeend", templateUser(chatItem.message))
break
}
})
chatWaitElement.classList.toggle("hidden", !chatWaiting.value)
chatElement.scrollTo({
top: chatElement.scrollHeight,
})
}
function sendMessage() {
const chatTextElement: HTMLTextAreaElement = document.querySelector("#chat-text")!
const text = chatTextElement.value
chatTextElement.value = ""
if (text.trim() === "")
return
chat.push({
type: "user",
message: text,
})
refreshChat()
timeouts.wait !== null && clearTimeout(timeouts.wait)
timeouts.message !== null && clearTimeout(timeouts.message)
timeouts.wait = setTimeout(() => {
chatWaiting.value = true
refreshChat()
}, 500)
timeouts.message = setTimeout(() => {
chatWaiting.value = false
getResponse(text)
refreshChat()
}, 2000)
}
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,
}> = []
let highestMatchingPriority = 0
for (const response of sortedResponses) {
let match = false
for (const trigger of response.trigger ?? []) {
if (msg.includes(trigger)) match = true
}
for (const start of response.startsWith ?? []) {
if (msg.startsWith(start)) match = true
}
for (const end of response.endsWith ?? []) {
if (msg.endsWith(end)) match = true
}
if (match) {
if (response.priority > highestMatchingPriority) highestMatchingPriority = response.priority
if (response.special) {
// clear previous matches
while (matchingResponses.length) {
matchingResponses.pop()
}
if (response.special === "unlock" || response.special === "lock") {
let matchedDoors = []
const action = response.special
let doorListElement = `<ul>`
for (let door of doors) {
if (msg.includes(door.label.toLowerCase())) {
matchedDoors.push(door)
}
doorListElement += `<li>${door.label}</li>`
}
doorListElement += `</ul>`
let message = ""
if (matchedDoors.length == 0 || !auth.authorized || !auth.authenticated) {
if (matchedDoors.length == 0) {
message = response.errors ? response.errors["unknownDoor"].replace("{doors}", doorListElement) : ""
}
if (!auth.authorized) {
message = response.errors ? response.errors["unauthorized"] : ""
}
if (!auth.authenticated) {
message = response.errors ? response.errors["unauthenticated"] : ""
}
} else {
matchedDoors.forEach(door => {
doorAction(action, door.id)
})
message = response.response.
replace("{door}", matchedDoors
.map(door => `<span class="badge">${door.label}</span>`)
.join())
}
matchingResponses.push({
message: message,
priority: response.priority,
})
}
// stop searching for more matches
break
} else {
matchingResponses.push({
message: response.response,
priority: response.priority,
})
}
}
}
if (matchingResponses.length > 0) {
const matchingMessages = matchingResponses
.filter(response => response.priority == highestMatchingPriority)
.map(response => response.message)
const chatMessages = matchingMessages.map(message => {
return {
type: "bot" as ChatMessage["type"],
message: message,
}
})
chat.push(...chatMessages)
} else {
chat.push({
type: "bot",
message: sortedResponses.at(-1)?.response ?? "",
})
}
}
document.querySelector("body")?.insertAdjacentHTML("beforeend", templateInjectChat)
createIcons({
nameAttr: "data-lucide",
root: document.querySelector(".fab")!,
icons: {
Stars,
Send,
},
})
document.querySelector("#send-chat")!.addEventListener("click", sendMessage)
document.querySelector("#chat-text")!.addEventListener("keydown", (e: Event) => {
if ((e as KeyboardEvent).key === "Enter") {
sendMessage()
}
})