optimized webchat
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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":
|
||||
|
||||
66
frontend/src/lib/components/widgets/Label.svelte
Normal file
66
frontend/src/lib/components/widgets/Label.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user