optimized webchat

This commit is contained in:
2025-10-05 17:00:46 +00:00
parent 8ae3c914d8
commit 740cb3e1e6
6 changed files with 397 additions and 74 deletions

View File

@@ -1,23 +1,80 @@
<script lang="ts">
import { WerkChatSession } from "@kontextwerk/web-sdk"
import { onDestroy } from "svelte"
import { WerkChatSession, type ChatMessage } from "@kontextwerk/web-sdk"
import InputRow from "./InputRow.svelte"
import Messages from "./Messages.svelte"
function generateUniqueId() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15)
}
let chat = $derived(
let chat: WerkChatSession
let messages = $state<ChatMessage[]>([])
let isStreaming = $state(false)
const refreshFromSession = () => {
if (!chat) return
messages = [...chat.messages]
isStreaming = chat.isStreaming
}
const createSession = () =>
new WerkChatSession(generateUniqueId(), {
url: "https://2schat-server.kontextwerk.info/api/v1/chatbot/stream",
headers: {
"Content-Type": "application/json",
},
events: {
onOpen: refreshFromSession,
onToken: refreshFromSession,
onHeartbeat: refreshFromSession,
onFinal: refreshFromSession,
onError: refreshFromSession,
onEvent: refreshFromSession,
},
})
chat = createSession()
refreshFromSession()
const sendMessage = (text: string) => {
const trimmed = text.trim()
if (!trimmed || !chat) return
const promise = chat.generateResponse(trimmed)
refreshFromSession()
void promise
.catch((err) => {
console.error("Chat response failed", err)
})
.finally(() => {
refreshFromSession()
})
}
onDestroy(() => {
chat?.abortActiveStream?.()
})
)
</script>
<div class="chat-wrapper">
<Messages {chat} />
<InputRow {chat} />
<Messages
{messages}
streaming={isStreaming}
/>
<InputRow
onSend={sendMessage}
disabled={isStreaming}
quickMessages={[
{
title: "Erzähl mir was über",
highlight: "Kontextwerk",
message: "Erzähl mir bitte mehr über Kontextwerk.",
},
{
title: "Ich möchte euch",
highlight: "kontaktieren",
message: "Ich möchte euch kontaktieren. Welche Möglichkeiten gibt es?",
},
]}
/>
</div>
<style lang="less">

View File

@@ -1,43 +1,91 @@
<script lang="ts">
import { mdiSendVariantOutline } from "@mdi/js"
import Icon from "../widgets/Icon.svelte"
import type { WerkChatSession } from "@kontextwerk/web-sdk"
let { chat }: { chat: WerkChatSession } = $props()
import { tick } from "svelte"
let {
onSend,
disabled = false,
quickMessages = [
{
title: "Erzähl mir was über",
highlight: "Kontextwerk",
message: "Erzähl mir bitte mehr über Kontextwerk.",
},
{
title: "Ich möchte euch",
highlight: "kontaktieren",
message: "Ich möchte euch kontaktieren. Welche Möglichkeiten gibt es?",
},
],
}: {
onSend: (message: string) => void
disabled?: boolean
quickMessages?: Array<{ title: string; highlight: string; message: string }>
} = $props()
let value = $state("")
let textareaEl: HTMLTextAreaElement = $state(null)
let textareaEl: HTMLTextAreaElement | null = $state(null)
function autoResize() {
if (textareaEl) {
textareaEl.style.height = "auto"
textareaEl.style.height = textareaEl.scrollHeight + "px"
}
}
let submittedOnce = $state(false)
const submit = async () => {
submittedOnce = true
const text = value.trim()
if (!text || disabled) return
onSend(text)
value = ""
await tick()
autoResize()
}
$effect(() => {
void tick().then(autoResize)
})
</script>
<div class="input-row">
{#if quickMessages.length && !submittedOnce}
<div class="quick-actions">
{#each quickMessages as quick, index (index)}
<button
class="quick-chip"
type="button"
{disabled}
onclick={() => {
if (!disabled) {
onSend(quick.message)
}
}}
>
<span class="title">{quick.title}</span>
<span class="highlight">{quick.highlight}</span>
</button>
{/each}
</div>
{/if}
<textarea
placeholder="Schreibe eine Nachricht..."
bind:value
rows="1"
bind:this={textareaEl}
oninput={autoResize}
{disabled}
onkeydown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
if (value.trim()) {
chat.generateResponse(value.trim())
value = ""
}
void submit()
}
}}
></textarea>
<button
class="btn"
onclick={() => {
if (value.trim()) {
chat.generateResponse(value.trim())
value = ""
}
}}
onclick={() => void submit()}
disabled={disabled || !value.trim()}
aria-label="Nachricht senden"
>
<Icon
@@ -54,6 +102,59 @@
flex-direction: column;
align-items: flex-end;
position: relative;
gap: 0.75rem;
.quick-actions {
width: 100%;
display: grid;
gap: 0.65rem;
flex-wrap: wrap;
justify-content: flex-start;
padding: 0px 0.6rem;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
.quick-chip {
display: inline-flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
padding: 0.65rem 0.95rem;
border-radius: 4px;
background: var(--bg-200);
color: white;
border: 1px solid var(--bg-100);
cursor: pointer;
gap: 0.25rem;
transition:
background 0.3s ease,
transform 0.3s ease;
&:hover:not(:disabled) {
background: var(--bg-100);
border: 1px solid var(--bg-200);
transform: translateY(-1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.title {
font-size: 0.8rem;
letter-spacing: 0.01em;
opacity: 0.8;
}
.highlight {
font-size: 0.95rem;
font-weight: 600;
color: var(--neutral-white);
text-transform: capitalize;
}
}
}
textarea {
width: 100%;
min-height: 3.6rem;
@@ -65,6 +166,8 @@
resize: none;
overflow: hidden;
color: white;
border-top: 1px solid white;
transition: border-top 0.3s ease;
&:focus {
outline: none;
border-top: 1px solid var(--primary-100);
@@ -79,6 +182,10 @@
min-width: unset;
background-color: var(--primary-100);
border-radius: 4px;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
</style>

View File

@@ -1,23 +1,141 @@
<script lang="ts">
import type { WerkChatSession } from "@kontextwerk/web-sdk"
import type { ChatMessage } from "@kontextwerk/web-sdk"
let {
chat,
messages = [],
streaming,
}: {
chat: WerkChatSession
messages?: ChatMessage[]
streaming: boolean
} = $props()
</script>
<div>
{#each chat.messages as message, idx (idx)}
<div
class="message"
class:assistant={message.role === "assistant"}
class:user={message.role === "user"}
>
<div class="messages">
{#each messages as message, idx (idx)}
{#if message.role === "assistant"}
<div class="message assistant">
<div class="bubble assistant-bubble">
{@html message.content || ""}
</div>
</div>
{:else if message.role === "user"}
<div class="message user">
<div class="bubble user-bubble">
{@html message.content}
</div>
</div>
{:else}
<div class="message system">
<div class="bubble system-bubble">
{@html message.content}
</div>
</div>
{/if}
{/each}
{#if streaming}
<div class="message assistant typing-indicator">
<div class="bubble assistant-bubble">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
{/if}
</div>
<style lang="less">
.messages {
display: flex;
flex-direction: column;
gap: 0.9rem;
padding: 1rem 0;
color: var(--text-reverse-100);
}
.message {
display: flex;
flex-direction: column;
max-width: 85%;
&.assistant {
align-self: flex-start;
}
&.user {
align-self: flex-end;
}
&.system {
align-self: center;
}
}
.bubble {
padding: 0.85rem 1.05rem;
border-radius: 1.1rem;
line-height: 1.45;
font-size: 0.95rem;
box-shadow: 0 0.8rem 2rem rgba(0, 0, 0, 0.08);
position: relative;
overflow-wrap: anywhere;
background: white;
&.assistant-bubble {
background: white;
color: var(--text-reverse-100);
border: 1px solid rgba(0, 0, 0, 0.04);
}
&.user-bubble {
background: linear-gradient(135deg, var(--primary-100), var(--primary-200));
color: white;
}
&.system-bubble {
background: rgba(255, 255, 255, 0.6);
color: var(--text-reverse-100);
font-style: italic;
}
}
.typing-indicator .bubble {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.typing-indicator .dot {
height: 8px;
width: 8px;
background-color: rgba(0, 0, 0, 0.25);
border-radius: 50%;
display: inline-block;
animation: typingAnimation 1.4s infinite both;
}
.typing-indicator .dot:nth-child(2) {
animation-delay: 0.2s;
}
.typing-indicator .dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes typingAnimation {
0%,
80%,
100% {
opacity: 0.3;
transform: translateY(0);
}
40% {
opacity: 1;
transform: translateY(-4px);
}
}
@media (max-width: 768px) {
.message {
max-width: 92%;
}
}
</style>

View File

@@ -4,6 +4,7 @@
import ProductCategoryFrame from "../widgets/ProductCategoryFrame.svelte"
import CrinkledSection from "../CrinkledSection.svelte"
import { createVoicebotPreviewController } from "../voicebotDemo/voicebotPreviewController"
import Label from "../widgets/Label.svelte"
const voiceProperties: Array<{ title: string; icon: string; color: string }> = [
{
@@ -65,22 +66,23 @@
aria-pressed={$status === "connected"}
aria-busy={$status === "connecting"}
aria-label="Voicebot Demo starten"
on:click={() => void toggle()}
on:keydown={handleKeydown}
onclick={() => void toggle()}
onkeydown={handleKeydown}
>
<img
src="/media/iphone.png"
alt="Kontextwerk is calling"
/>
<div class="shadow"></div>
<div
class="voice-overlay"
</div>
<button
onclick={() => void toggle()}
class="voice-status"
data-status={$status}
aria-live="polite"
>
<span>{$statusHint}</span>
</div>
</div>
<Label label={$statusHint} />
</button>
{/snippet}
</ProductCategoryFrame>
{/snippet}
@@ -111,40 +113,6 @@
user-select: none;
}
.voice-overlay {
position: absolute;
bottom: 1.2rem;
left: 50%;
transform: translateX(-50%);
padding: 0.45rem 1.1rem;
border-radius: 999px;
background: rgba(13, 12, 12, 0.8);
color: white;
font-size: 0.8rem;
font-weight: 500;
letter-spacing: 0.01em;
display: flex;
align-items: center;
gap: 0.4rem;
pointer-events: none;
white-space: nowrap;
transition:
background 0.2s ease,
color 0.2s ease;
}
.voice-overlay[data-status="connected"] {
background: rgba(76, 175, 80, 0.85);
}
.voice-overlay[data-status="connecting"] {
background: rgba(255, 152, 0, 0.85);
}
.voice-overlay[data-status="error"] {
background: rgba(235, 87, 87, 0.9);
}
&:hover {
transform: translateY(-4px);
}
@@ -154,4 +122,11 @@
outline-offset: 4px;
}
}
.voice-status {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1.2rem;
}
</style>

View File

@@ -37,7 +37,7 @@ export const createVoicebotPreviewController = (): VoicebotPreviewController =>
const statusHint = derived([statusStore, errorStore], ([$status, $error]) => {
switch ($status) {
case "idle":
return "Tippen, um die Voice-Demo zu starten"
return "Tippen, um die Demo zu starten"
case "connecting":
return "Verbindung wird aufgebaut …"
case "connected":

View File

@@ -0,0 +1,66 @@
<script lang="ts">
let {
label,
}: {
label: string
} = $props()
</script>
<span>
<img
src="/media/bottomRightCrinkleRed.svg"
id="lefternCrinkle"
alt="crinkle"
/>
<em>
{label}
</em>
<img
id="righternCrinkle"
src="/media/topLeftCrinkleRed.svg"
alt="crinkle"
/>
</span>
<style lang="less">
span {
background: var(--primary-100);
padding: 6px 12px;
display: flex;
flex-direction: row;
height: fit-content;
gap: 0.5rem;
position: relative;
font-size: 20px;
img {
position: absolute;
top: 0px;
width: unset !important;
}
#righternCrinkle {
right: -7px;
}
em {
color: var(--neutral-white);
font-weight: 400;
font-size: 1em;
line-height: 14px;
font-family: "Outfit", sans-serif;
font-style: normal;
}
#lefternCrinkle {
left: -7px;
}
&.smallVersion {
#righternCrinkle {
right: -6px;
}
em {
font-size: 0.7em;
line-height: 9px;
}
}
}
</style>