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"> <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 InputRow from "./InputRow.svelte"
import Messages from "./Messages.svelte" import Messages from "./Messages.svelte"
function generateUniqueId() { function generateUniqueId() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) 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(), { new WerkChatSession(generateUniqueId(), {
url: "https://2schat-server.kontextwerk.info/api/v1/chatbot/stream", url: "https://2schat-server.kontextwerk.info/api/v1/chatbot/stream",
headers: { headers: {
"Content-Type": "application/json", "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> </script>
<div class="chat-wrapper"> <div class="chat-wrapper">
<Messages {chat} /> <Messages
<InputRow {chat} /> {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> </div>
<style lang="less"> <style lang="less">

View File

@@ -1,43 +1,91 @@
<script lang="ts"> <script lang="ts">
import { mdiSendVariantOutline } from "@mdi/js" import { mdiSendVariantOutline } from "@mdi/js"
import Icon from "../widgets/Icon.svelte" import Icon from "../widgets/Icon.svelte"
import type { WerkChatSession } from "@kontextwerk/web-sdk" import { tick } from "svelte"
let { chat }: { chat: WerkChatSession } = $props() 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 value = $state("")
let textareaEl: HTMLTextAreaElement = $state(null) let textareaEl: HTMLTextAreaElement | null = $state(null)
function autoResize() { function autoResize() {
if (textareaEl) { if (textareaEl) {
textareaEl.style.height = "auto" textareaEl.style.height = "auto"
textareaEl.style.height = textareaEl.scrollHeight + "px" 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> </script>
<div class="input-row"> <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 <textarea
placeholder="Schreibe eine Nachricht..." placeholder="Schreibe eine Nachricht..."
bind:value bind:value
rows="1" rows="1"
bind:this={textareaEl} bind:this={textareaEl}
oninput={autoResize} oninput={autoResize}
{disabled}
onkeydown={(e) => { onkeydown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault() e.preventDefault()
if (value.trim()) { void submit()
chat.generateResponse(value.trim())
value = ""
}
} }
}} }}
></textarea> ></textarea>
<button <button
class="btn" class="btn"
onclick={() => { onclick={() => void submit()}
if (value.trim()) { disabled={disabled || !value.trim()}
chat.generateResponse(value.trim())
value = ""
}
}}
aria-label="Nachricht senden" aria-label="Nachricht senden"
> >
<Icon <Icon
@@ -54,6 +102,59 @@
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
position: relative; 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 { textarea {
width: 100%; width: 100%;
min-height: 3.6rem; min-height: 3.6rem;
@@ -65,6 +166,8 @@
resize: none; resize: none;
overflow: hidden; overflow: hidden;
color: white; color: white;
border-top: 1px solid white;
transition: border-top 0.3s ease;
&:focus { &:focus {
outline: none; outline: none;
border-top: 1px solid var(--primary-100); border-top: 1px solid var(--primary-100);
@@ -79,6 +182,10 @@
min-width: unset; min-width: unset;
background-color: var(--primary-100); background-color: var(--primary-100);
border-radius: 4px; border-radius: 4px;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
} }
} }
</style> </style>

View File

@@ -1,23 +1,141 @@
<script lang="ts"> <script lang="ts">
import type { WerkChatSession } from "@kontextwerk/web-sdk" import type { ChatMessage } from "@kontextwerk/web-sdk"
let { let {
chat, messages = [],
streaming,
}: { }: {
chat: WerkChatSession messages?: ChatMessage[]
streaming: boolean
} = $props() } = $props()
</script> </script>
<div> <div class="messages">
{#each chat.messages as message, idx (idx)} {#each messages as message, idx (idx)}
<div {#if message.role === "assistant"}
class="message" <div class="message assistant">
class:assistant={message.role === "assistant"} <div class="bubble assistant-bubble">
class:user={message.role === "user"} {@html message.content || ""}
> </div>
{@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} {/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> </div>
<style lang="less"> <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> </style>

View File

@@ -4,6 +4,7 @@
import ProductCategoryFrame from "../widgets/ProductCategoryFrame.svelte" import ProductCategoryFrame from "../widgets/ProductCategoryFrame.svelte"
import CrinkledSection from "../CrinkledSection.svelte" import CrinkledSection from "../CrinkledSection.svelte"
import { createVoicebotPreviewController } from "../voicebotDemo/voicebotPreviewController" import { createVoicebotPreviewController } from "../voicebotDemo/voicebotPreviewController"
import Label from "../widgets/Label.svelte"
const voiceProperties: Array<{ title: string; icon: string; color: string }> = [ const voiceProperties: Array<{ title: string; icon: string; color: string }> = [
{ {
@@ -65,22 +66,23 @@
aria-pressed={$status === "connected"} aria-pressed={$status === "connected"}
aria-busy={$status === "connecting"} aria-busy={$status === "connecting"}
aria-label="Voicebot Demo starten" aria-label="Voicebot Demo starten"
on:click={() => void toggle()} onclick={() => void toggle()}
on:keydown={handleKeydown} onkeydown={handleKeydown}
> >
<img <img
src="/media/iphone.png" src="/media/iphone.png"
alt="Kontextwerk is calling" alt="Kontextwerk is calling"
/> />
<div class="shadow"></div> <div class="shadow"></div>
<div
class="voice-overlay"
data-status={$status}
aria-live="polite"
>
<span>{$statusHint}</span>
</div>
</div> </div>
<button
onclick={() => void toggle()}
class="voice-status"
data-status={$status}
aria-live="polite"
>
<Label label={$statusHint} />
</button>
{/snippet} {/snippet}
</ProductCategoryFrame> </ProductCategoryFrame>
{/snippet} {/snippet}
@@ -111,40 +113,6 @@
user-select: none; 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 { &:hover {
transform: translateY(-4px); transform: translateY(-4px);
} }
@@ -154,4 +122,11 @@
outline-offset: 4px; outline-offset: 4px;
} }
} }
.voice-status {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
margin-top: 1.2rem;
}
</style> </style>

View File

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