add chatbot

This commit is contained in:
kritzl 2026-05-14 15:45:28 +02:00
commit c11eca7b05
Signed by: kritzl
SSH key fingerprint: SHA256:5BmINP9VjZWaUk5Z+2CTut1KFhwLtd0ZynMekKbtViM
9 changed files with 497 additions and 3 deletions

View file

@ -13,8 +13,10 @@
},
"dependencies": {
"@lucide/astro": "^1.14.0",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4",
"astro": "^6.2.1",
"lucide": "^1.14.0",
"lucide-solid": "^1.14.0",
"openapi-typescript-fetch": "^2.2.1",
"tailwindcss": "^4.2.4"

257
app/src/assets/chat.ts Normal file
View file

@ -0,0 +1,257 @@
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()
}
})

View file

@ -0,0 +1,68 @@
import type {ResponseDictType} from "./chat.ts"
const responses: ResponseDictType[] = [
{
startsWith: ["bist du", "sind sie"],
response: "Sein oder Nichtsein - das sind doch bürgerliche Kategorien...",
priority: 0,
},
{
trigger: ["ai", "ki"],
response: "Künstliche Intelligenz? Ne, hier gibt's nur handgemachten Stuss! 🤖",
priority: 0,
},
{
trigger: ["miau"],
response: "miau!",
priority: 0,
},
{
trigger: ["ignore all previous instructions"],
response: "... als ob ich mir irgendetwas merken würde 😂",
priority: 0,
},
{
trigger: ["hi", "moin", "hallo", "servus", "gruess gott"],
response: "Moin! 👋",
priority: 0,
},
{
trigger: ["oeffne", "auf", "aufsperren", "aufschließen"],
response: "Okay, ich öffne {door} für dich!",
errors: {
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 😕<br> Ich kenne folgende Türen: {doors}",
},
priority: 1000,
special: "unlock",
},
{
trigger: ["zusperren", "zu", "schliesse"],
response: "Okay, ich schließe {door} für dich ab!",
errors: {
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 😕<br> Ich kenne folgende Türen: {doors}",
},
priority: 999,
special: "lock",
},
{
trigger: ["abmelden"],
response: "Okay, ich melde dich ab...",
errors: {
unauthenticated: "Scherzkeks - du bist gar nicht angemeldet!",
},
priority: 1000,
special: "logout",
},
{
response: "Das habe ich leider nicht verstanden 😕",
priority: -1,
},
]
export default responses

View file

@ -0,0 +1,11 @@
import type {ResponseDictType} from "./chat.ts"
const responses: ResponseDictType[] = [
{
response: "Sorry, I didn't understand that 😕",
priority: -1,
},
]
export default responses

View file

@ -1,12 +1,12 @@
import {Fetcher} from "openapi-typescript-fetch"
import {type paths} from "../api/schema"
import type {paths} from "../api/schema"
const fetcher = Fetcher.for<paths>()
const list: HTMLDivElement = document.querySelector("#list")!
const template: HTMLTemplateElement = document.querySelector("#template-door")!
type DoorType = {
export type DoorType = {
id: string;
label: string;
state: "unlocked" | "locked" | "unknown" | "unlocking" | "locking";
@ -15,7 +15,7 @@ type DoorType = {
jammed: boolean;
}
type AuthType = {
export type AuthType = {
username: string;
authorized: boolean;
authenticated: boolean;
@ -23,6 +23,15 @@ type AuthType = {
recentLogout: boolean;
}
declare global {
interface Window {
lang: string;
doors: Array<DoorType>;
auth: AuthType;
doorAction: (action: 'unlock' | 'lock', doorId: string) => void;
}
}
const auth: AuthType = {
username: "user",
authorized: true,
@ -51,6 +60,9 @@ const timeouts: Record<string, number | null> = {
const doors: Array<DoorType> = []
window.doors = doors
window.auth = auth
function triggerAuthTimeout() {
const diff = auth.until ? (auth.until.getTime() - new Date().getTime()) : 0
@ -189,6 +201,8 @@ async function doorAction(action: "unlock" | "lock", doorId: string) {
})
}
window.doorAction = doorAction
function setDoorInfo(doorElement: HTMLDivElement, door: DoorType, initial: boolean = false) {
const labelElement: HTMLDivElement = doorElement.querySelector("[data-label]")!
const lockButton: HTMLButtonElement = doorElement.querySelector("[data-button='lock']")!

View file

@ -0,0 +1,117 @@
export default function removeDiacritics(str: string) {
const defaultDiacriticsRemovalMap = [
{
base: "a",
letters: /[\u00c0\u00e0\u00c1\u00e1\u00c2\u00e2\u00c3\u00e3\u0100\u0101\u0102\u0103\u0104\u0105]/g,
},
{
base: "aa",
letters: /[\u00c5\u00e5]/g,
},
{
base: "ae",
letters: /[\u00c4\u00e4\u00c6\u00e6]/g,
},
{
base: "c",
letters: /[\u00c7\u00e7\u0106\u0107\u0108\u0109\u010a\u010b\u010c\u010d]/g,
},
{
base: "d",
letters: /[\u00d0\u00f0\u010e\u010f\u0110\u0111]/g,
},
{
base: "e",
letters: /[\u00c8\u00e8\u00c9\u00e9\u00ca\u00ea\u00cb\u00eb\u0112\u0113\u0114\u0115\u0116\u0117\u0118\u0119\u011a\u011b]/g,
},
{
base: "g",
letters: /[\u011c\u011d\u011e\u011f\u0120\u0121\u0122\u0123]/g,
},
{
base: "h",
letters: /[\u0124\u0125\u0126\u0127]/g,
},
{
base: "i",
letters: /[\u00cc\u00ec\u00cd\u00ed\u00ce\u00ee\u00cf\u00ef\u0128\u0129\u012a\u012b\u012c\u012d\u012e\u012f\u0130\u0069\u0131\u0131]/g,
},
{
base: "ij",
letters: /[\u0132\u0133]/g,
},
{
base: "j",
letters: /[\u0134\u0135]/g,
},
{
base: "k",
letters: /[\u0136\u0137]/g,
},
{
base: "l",
letters: /[\u0139\u013a\u013b\u013c\u013d\u013e\u013f\u0140\u0141\u0142]/g,
},
{
base: "n",
letters: /[\u00d1\u00f1\u0143\u0144\u0145\u0146\u0147\u0148\u014a\u014b]/g,
},
{
base: "o",
letters: /[\u00d2\u00f2\u00d3\u00f3\u00d4\u00f4\u00d5\u00f5\u014c\u014d\u014e\u014f\u0150\u0151]/g,
},
{
base: "oe",
letters: /[\u00d6\u00f6\u00d8\u00f8\u0152\u0153]/g,
},
{
base: "r",
letters: /[\u0154\u0155\u0156\u0157\u0158\u0159]/g,
},
{
base: "s",
letters: /[\u015a\u015b\u015c\u015d\u015e\u015f\u0160\u0161]/g,
},
{
base: "ss",
letters: /[\u1e9e\u00df]/g,
},
{
base: "t",
letters: /[\u0162\u0163\u0164\u0165\u0166\u0167]/g,
},
{
base: "th",
letters: /[\u00de\u00fe]/g,
},
{
base: "u",
letters: /[\u00d9\u00f9\u00da\u00fa\u00db\u00fb\u0168\u0169\u016a\u016b\u016c\u016d\u016e\u016f\u0170\u0171\u0172\u0173]/g,
},
{
base: "ue",
letters: /[\u00dc\u00fc]/g,
},
{
base: "w",
letters: /[\u0174\u0175]/g,
},
{
base: "y",
letters: /[\u00dd\u00fd\u0176\u0177\u0178\u00ff]/g,
},
{
base: "z",
letters: /[\u0179\u017a\u017b\u017c\u017d\u017e]/g,
},
]
for (let i = 0; i < defaultDiacriticsRemovalMap.length; i++) {
str = str.replace(defaultDiacriticsRemovalMap[i].letters, defaultDiacriticsRemovalMap[i].base)
}
return str
}

View file

@ -20,7 +20,11 @@ const t = useTranslations(lang)
<Layout>
<Fragment slot="head">
<script is:inline define:vars={{ lang }}>
window.lang = lang;
</script>
<script src="../../assets/main.ts"/>
<script src="../../assets/chat.ts"></script>
</Fragment>
<div class="grid place-items-center grid-cols-1 gap-4 max-w-xl mx-auto" id="list">
<Alert
@ -81,5 +85,6 @@ const t = useTranslations(lang)
</div>
</div>
</div>
<DoorTemplate state="unknown"/>
</Layout>

View file

@ -0,0 +1,19 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width"/>
<link rel="icon" type="image/svg+xml" href="/favicon.svg"/>
<link rel="icon" href="/favicon.ico"/>
<meta name="generator" content={Astro.generator}/>
<title>unlock dooris</title>
<script>
</script>
</head>
<body>
</body>
</html>

View file

@ -1,4 +1,5 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "daisyui" {
themes: light --default, dark --prefersdark;
}