Add Chat feature #1

Merged
june merged 9 commits from chat into main 2026-05-18 18:33:15 +02:00
7 changed files with 170 additions and 48 deletions
Showing only changes of commit 81923c4f8c - Show all commits

improve chat ui
All checks were successful
Build Container / Build Container (push) Successful in 1m29s

kritzl 2026-05-18 16:49:24 +02:00
Signed by: kritzl
SSH key fingerprint: SHA256:5BmINP9VjZWaUk5Z+2CTut1KFhwLtd0ZynMekKbtViM

View file

@ -1,4 +1,5 @@
import {createIcons, Send, Stars} from "lucide"
import {Bot, createIcons, UserRound, Send, Stars} from "lucide"
import {useTranslations} from "../i18n/utils.ts"
import removeDiacritics from "./removeDiacritics.ts"
export type ResponseDictType = {
@ -16,6 +17,8 @@ const doors = window.doors
const auth = window.auth
const doorAction = window.doorAction
const t = useTranslations(lang)
const responses =
lang === "de"
? (await import("./chatDict_de")).default
@ -25,19 +28,51 @@ const responses =
const sortedResponses = responses.sort(
(a, b) => b.priority - a.priority)
const chatAvatar = `
<div class="chat-image avatar">
<div class="w-10 rounded-full ${lang === 'uwu' ? 'bg-transparent' : 'bg-primary'} flex items-center justify-center">
${lang === 'uwu' ? '<span class="text-4xl">🥺</span>' : '<i data-lucide="bot"></i>'}
</div>
</div>
`
const chatHeader = `
<div class="chat-header">
${t("chat.dooris")}
</div>
`
const chatAvatarUser = `
<div class="chat-image avatar">
<div class="w-10 rounded-full ${lang === 'uwu' ? 'bg-transparent' : 'bg-secondary'} flex items-center justify-center">
${lang === 'uwu' ? '<span class="text-4xl">🥺</span>' : '<i data-lucide="user-round"></i>'}
</div>
</div>
`
const chatHeaderUser = `
<div class="chat-header">
${t("chat.user")}
</div>
`
const chatWait = `
<div class="chat chat-start w-full prose" data-waiting>
${chatAvatar}
${chatHeader}
<div class="chat-bubble chat-bubble-primary whitespace-normal min-h-10">
<span class="loading loading-dots loading-md"></span>
</div>
</div>
`
const templateInjectChat = `
<details class="dropdown dropdown-top dropdown-end absolute right-4 bottom-4" id="chat-root">
<summary class="btn btn-lg btn-circle btn-primary">
<details class="dropdown dropdown-top dropdown-end absolute right-2 bottom-2" id="chat-root">
<summary class="btn btn-lg btn-circle btn-primary me-2 mb-2">
<i data-lucide="stars"></i>
</summary>
<div class="dropdown-content card min-h-120 max-h-200 w-80 bg-base-300 p-4 mb-4">
<div class="w-full overflow-auto h-full prose leading-5" 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="mt-4"></div>
<div class="dropdown-content card h-140 w-90 bg-base-300 p-4 mb-4" style="max-height: min(calc(100vh - 10rem), 50rem); max-width: calc(100vw - 1rem)">
<div class="w-full overflow-y-auto h-full prose leading-5 grow pb-4" id="chat"></div>
<div class="join mt-auto w-full">
<div class="w-full">
<label class="input join-item">
@ -79,11 +114,14 @@ function refreshChat() {
const chatContainer = document.createElement("div")
const chatBubble = document.createElement("div")
chatBubble.classList.add("chat-bubble", "chat-bubble-primary", "whitespace-normal")
chatBubble.classList.add("chat-bubble", "chat-bubble-primary", "whitespace-normal", "min-h-10", "break-[break-word]")
chatBubble.style.wordBreak = "break-word"
chatBubble.classList.toggle("shimmer", special)
chatBubble.id = id
chatBubble.dataset.content = message
chatContainer.classList.add("chat", "chat-start", "w-full")
chatContainer.classList.add("chat", "chat-start", "w-full", "overflow-visible", "hidden", "pe-10")
chatContainer.id = id
chatContainer.insertAdjacentHTML("afterbegin", chatAvatar)
chatContainer.insertAdjacentHTML("afterbegin", chatHeader)
chatContainer.append(chatBubble)
return chatContainer
@ -91,10 +129,16 @@ function refreshChat() {
const templateUser = (message: string) => `
<div class="chat chat-end w-full">
<div class="chat-bubble chat-bubble-secondary whitespace-normal">${message}</div>
<div class="chat chat-end w-full ps-10">
${chatAvatarUser}
${chatHeaderUser}
<div class="chat-bubble chat-bubble-secondary whitespace-normal" style="word-break: break-word;">${message}</div>
</div>`
const chatBubbleIds: Array<string> = []
console.log(chat)
chat.forEach(chatItem => {
switch (chatItem.type) {
case "bot":
@ -102,8 +146,7 @@ function refreshChat() {
chatItem.id = `chat-${chatId}`
chatId++
chatElement?.appendChild(templateBot(chatItem.id, chatItem.message, chatItem.special ?? false))
typeBubble(chatItem.id, () => {
})
chatBubbleIds.push(chatItem.id)
}
break
case "user":
@ -115,16 +158,35 @@ function refreshChat() {
break
}
})
chatWaitElement.classList.toggle("hidden", !chatWaiting.value)
typeBubble(chatBubbleIds)
chatElement.querySelector("[data-waiting]")?.remove()
if (chatWaiting.value) {
chatElement.insertAdjacentHTML("beforeend", chatWait)
}
chatElement.scrollTo({
top: chatElement.scrollHeight,
})
refreshIcons()
}
function typeBubble(id: string, callback: Function) {
const element = document.getElementById(id)!
function typeBubble(ids: Array<string>) {
if (!ids.length) return
console.log(ids)
const firstId = ids.slice(0, 1)[0]
const restIds = ids.slice(1)!
const chatContainer = document.getElementById(firstId)!
chatContainer.classList.remove('hidden')
const chatBubble: HTMLDivElement = chatContainer.querySelector('.chat-bubble')!
const tmpDiv = document.createElement("div")
tmpDiv.innerHTML = element.getAttribute("data-content") || ""
tmpDiv.innerHTML = chatBubble.getAttribute("data-content") || ""
const typingArray: Array<string> = []
@ -144,14 +206,14 @@ function typeBubble(id: string, callback: Function) {
function typeChar() {
if (charIndex < typingArray.length) {
text += typingArray.at(charIndex)
element.innerHTML = text
chatBubble.innerHTML = text
charIndex++
setTimeout(typeChar, typingSpeed)
chatElement.scrollTo({
top: chatElement.scrollHeight,
})
} else if (callback) {
callback()
} else if (restIds.length > 0) {
typeBubble(restIds)
}
}
@ -320,7 +382,7 @@ function getResponse(message: string) {
return {
type: "bot" as ChatMessage["type"],
message: response.message,
special: response.special
special: response.special,
}
})
@ -335,7 +397,6 @@ 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({
@ -347,14 +408,20 @@ function initChat() {
let firstOpen = true
createIcons({
nameAttr: "data-lucide",
root: document.querySelector("#chat-root")!,
icons: {
Stars,
Send,
},
})
function refreshIcons() {
createIcons({
nameAttr: "data-lucide",
root: document.querySelector("#chat-root")!,
icons: {
Stars,
Send,
UserRound,
Bot,
},
})
}
refreshIcons()
document.querySelector("#send-chat")!.addEventListener("click", sendMessage)
document.querySelector("#chat-text")!.addEventListener("keydown", (e: Event) => {

View file

@ -1,6 +1,11 @@
import type {ResponseDictType} from "./chat.ts"
const responses: ResponseDictType[] = [
{
trigger: ["hi", "moin", "hallo", "servus", "gruess gott"],
response: "Moin! 👋",
priority: 0,
},
{
startsWith: ["bist du", "sind sie"],
response: "Sein oder Nichtsein - das sind doch bürgerliche Kategorien...",
@ -22,8 +27,8 @@ const responses: ResponseDictType[] = [
priority: 0,
},
{
trigger: ["hi", "moin", "hallo", "servus", "gruess gott"],
response: "Moin! 👋",
trigger: [":3"],
response: ":3",
priority: 0,
},
{
@ -68,7 +73,7 @@ const responses: ResponseDictType[] = [
{
response: "Moin!<br>Wie kann ich dir behilflich sein? 🤓",
response: "Moin, ich bin TüRIS!<br>Wie kann ich dir behilflich sein? 🤓",
priority: -1,
},
{

View file

@ -1,6 +1,11 @@
import type {ResponseDictType} from "./chat.ts"
const responses: ResponseDictType[] = [
{
trigger: ["hi", "hello"],
response: "Hi! 👋",
priority: 0,
},
{
startsWith: ["are you"],
response: "To be or not to be - those are bourgeois categories, after all...",
@ -22,8 +27,8 @@ const responses: ResponseDictType[] = [
priority: 0,
},
{
trigger: ["hi", "hello"],
response: "Hi! 👋",
trigger: [":3"],
response: ":3",
priority: 0,
},
{
@ -68,7 +73,7 @@ const responses: ResponseDictType[] = [
{
response: "Hi!<br>How can I help you? 🤓",
response: "Hi, I'm DOORIS!<br>How can I help you? 🤓",
priority: -1,
},
{

View file

@ -1,6 +1,11 @@
import type {ResponseDictType} from "./chat.ts"
const responses: ResponseDictType[] = [
{
trigger: ["uwu", "hi", "hello"],
response: "uwu!",
priority: 0,
},
{
startsWith: ["are you"],
response: "me is uwu!",
@ -22,8 +27,8 @@ const responses: ResponseDictType[] = [
priority: 0,
},
{
trigger: ["uwu", "hi", "hello"],
response: "uwu!",
trigger: [":3"],
response: ":3",
priority: 0,
},
{

View file

@ -1,5 +1,6 @@
import {Fetcher} from "openapi-typescript-fetch"
import type {paths} from "../api/schema"
import type {ui} from "../i18n/ui.ts"
const fetcher = Fetcher.for<paths>()
@ -25,7 +26,7 @@ export type AuthType = {
declare global {
interface Window {
lang: string;
lang: keyof typeof ui;
doors: Array<DoorType>;
auth: AuthType;
doorAction: (action: 'unlock' | 'lock', doorId: string) => void;
@ -160,6 +161,9 @@ async function fetchDoors() {
jammed: door.status.is_error_jammed,
})
})
loading.doors = false
refresh()
} catch (e) {
// check which operation threw the exception
if (e instanceof getDoors.Error) {
@ -167,6 +171,8 @@ async function fetchDoors() {
if (error.status === 401) {
console.log("unauthorized")
loading.doors = false
refresh()
} else if (error.status >= 500 && error.status < 600) {
apiError.current = "serverError"
clearInterval(doorsInterval)
@ -181,9 +187,6 @@ async function fetchDoors() {
apiError.current = "networkError"
}
}
} finally {
loading.doors = false
refresh()
}
}

View file

@ -33,6 +33,8 @@ export const ui = {
"networkError.title": "Network error",
"networkError.description": `Please check your network connection.`,
"loadingDoors": 'Loading doors',
"chat.dooris": "DOORIS",
"chat.user": "You",
},
de: {
"dooris": "TüRIS <span class='text-neutral-content/50 text-xs'>(DOORIS)</span>",
@ -61,6 +63,8 @@ export const ui = {
"networkError.title": "Netzwerkfehler",
"networkError.description": `Bitte überprüfe deine Internetverbindung.`,
"loadingDoors": 'Lade Türen',
"chat.dooris": "TüRIS",
"chat.user": "Du",
},
uwu: {
"dooris": "uwu <span class='opacity-50 text-xs'>(DOORIS)</span>",
@ -89,5 +93,7 @@ export const ui = {
"networkError.title": "network is not worky",
"networkError.description": `pwease connect to internet`,
"loadingDoors": 'loading portals',
"chat.dooris": "uwu",
"chat.user": "my fren :3",
},
} as const

View file

@ -6,14 +6,45 @@
}
.shimmer {
background-image: linear-gradient(60deg, transparent 30%, rgba(100%,100%,100%,0.2) 50%, transparent 70%);
background-image: linear-gradient(60deg, transparent 30%, rgba(100%, 100%, 100%, 0.2) 50%, transparent 70%);
background-size: 300%;
background-position-x: 100%;
animation: shimmer 2s infinite linear;
box-shadow: var(--color-primary) 0 0 1em 0.2em;
border-end-start-radius: var(--radius-field);
}
.shimmer::before {
display: none;
}
@keyframes shimmer {
to {
background-position-x: 0;
}
}
.chat-start:has( + .chat-start:not(.hidden)) {
padding-bottom: 0.1rem;
> .chat-image {
opacity: 0;
}
> .chat-bubble {
border-end-start-radius: var(--radius-field);
}
> .chat-bubble::before {
display: none;
}
& + .chat-start {
padding-top: 0.1rem;
> .chat-header {
display: none;
}
}
}