diff --git a/frontend/src/lib/components/chatbotDemo/Chatbot.svelte b/frontend/src/lib/components/chatbotDemo/Chatbot.svelte
index c8a8d1d..df81076 100644
--- a/frontend/src/lib/components/chatbotDemo/Chatbot.svelte
+++ b/frontend/src/lib/components/chatbotDemo/Chatbot.svelte
@@ -1,12 +1,12 @@
diff --git a/frontend/src/lib/components/chatbotDemo/chat.ts b/frontend/src/lib/components/chatbotDemo/chat.ts
index 79f61c8..3346d46 100644
--- a/frontend/src/lib/components/chatbotDemo/chat.ts
+++ b/frontend/src/lib/components/chatbotDemo/chat.ts
@@ -1,175 +1,2 @@
-import { fetchEventSource } from "@microsoft/fetch-event-source"
-
-export type Role = "user" | "assistant" | "system"
-
-export interface ChatMessage {
- role: Role
- content: string
-}
-
-export interface ChatRequestPayload {
- user_id: string
- message: string
- session_id: string
-}
-
-type ServerEvent =
- | { type: "token"; delta: string }
- | { type: "heartbeat" }
- | { type: "error"; message: string }
- | { type: "final"; state: unknown }
-
-// Optionale Konfiguration für Headers, URL usw.
-export interface ChatOptions {
- /** Vollständige URL, z. B. "https://api.example.com/stream" */
- url: string
- /** Zusätzliche Request-Header (z. B. Auth) */
- headers?: Record
- /** Fallback-Assistant-Fehlermeldung für die UI */
- fallbackErrorText?: string
- /** Offen lassen, wenn Tab im Hintergrund – sinnvoll für Safari */
- openWhenHidden?: boolean
- /** User-ID, wenn du sie nicht pro Aufruf übergibst */
- userId?: string
-}
-
-export class Chat {
- public messages: ChatMessage[] = []
- public isStreaming = false
- public lastFinalState: unknown = null
-
- private sessionId: string
- private opts: ChatOptions
- private controller: AbortController | null = null
-
- constructor(sessionId: string, opts: ChatOptions) {
- if (!sessionId) throw new Error("session_id ist erforderlich")
- if (!opts?.url) throw new Error("ChatOptions.url ist erforderlich")
- this.sessionId = sessionId
- this.opts = {
- fallbackErrorText: "Es ist ein Fehler aufgetreten.",
- openWhenHidden: true,
- ...opts,
- }
- }
-
- public abortActiveStream() {
- this.controller?.abort()
- this.controller = null
- this.isStreaming = false
- }
-
- /**
- * Sendet eine Benutzer-Nachricht und streamt die Assistent-Antwort in `messages`.
- * @param message Text der Benutzer-Nachricht
- * @param userId optionaler Override der User-ID (falls nicht in opts.userId)
- */
- public async generateResponse(message: string, userId?: string): Promise {
- const text = (message ?? "").trim()
- if (!text) return
- if (this.isStreaming) this.abortActiveStream()
- this.messages = [...this.messages, { role: "user", content: text }]
- const assistantIndex = this.messages.length
- this.messages = [...this.messages, { role: "assistant", content: "" }]
-
- const payload: ChatRequestPayload = {
- user_id: userId ?? this.opts.userId ?? "anonymous",
- message: text,
- session_id: this.sessionId,
- }
-
- this.controller = new AbortController()
- this.isStreaming = true
- this.lastFinalState = null
-
- try {
- await fetchEventSource(this.opts.url, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- ...(this.opts.headers ?? {}),
- },
- body: JSON.stringify(payload),
- signal: this.controller.signal,
- openWhenHidden: this.opts.openWhenHidden,
-
- onopen: async (res) => {
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
- },
-
- onmessage: (ev) => {
- if (!ev.data) return
- let parsed: ServerEvent | null = null
- try {
- parsed = JSON.parse(ev.data) as ServerEvent
- } catch {
- return
- }
-
- switch (parsed.type) {
- case "token": {
- const delta = parsed.delta ?? ""
- const updated = {
- ...this.messages[assistantIndex],
- content: (this.messages[assistantIndex]?.content ?? "") + delta,
- }
- this.messages = [
- ...this.messages.slice(0, assistantIndex),
- updated,
- ...this.messages.slice(assistantIndex + 1),
- ]
- break
- }
-
- case "heartbeat": {
- break
- }
-
- case "error": {
- const msg = parsed.message || this.opts.fallbackErrorText!
- const updated = {
- ...this.messages[assistantIndex],
- content: (this.messages[assistantIndex]?.content || "") + `\n\n⚠️ ${msg}`,
- }
- this.messages = [
- ...this.messages.slice(0, assistantIndex),
- updated,
- ...this.messages.slice(assistantIndex + 1),
- ]
- this.abortActiveStream()
- break
- }
-
- case "final": {
- this.lastFinalState = parsed.state
- this.abortActiveStream()
- break
- }
-
- default: {
- break
- }
- }
- },
-
- onerror: (err) => {
- const updated = {
- ...this.messages[assistantIndex],
- content:
- (this.messages[assistantIndex]?.content || "") +
- `\n\n⚠️ ${this.opts.fallbackErrorText} (${String(err)})`,
- }
- this.messages = [
- ...this.messages.slice(0, assistantIndex),
- updated,
- ...this.messages.slice(assistantIndex + 1),
- ]
- throw err
- },
- })
- } finally {
- this.isStreaming = false
- this.controller = null
- }
- }
-}
+export { WerkChatSession as Chat } from "@kontextwerk/web-sdk"
+export type { ChatMessage, ChatOptions } from "@kontextwerk/web-sdk"
diff --git a/frontend/src/lib/components/staticPageRows/VoicebotPreview.svelte b/frontend/src/lib/components/staticPageRows/VoicebotPreview.svelte
index b1ba328..8cdc3bc 100644
--- a/frontend/src/lib/components/staticPageRows/VoicebotPreview.svelte
+++ b/frontend/src/lib/components/staticPageRows/VoicebotPreview.svelte
@@ -3,7 +3,7 @@
import { mdiBookAccountOutline, mdiCreation, mdiFaceAgent, mdiHours24 } from "@mdi/js"
import ProductCategoryFrame from "../widgets/ProductCategoryFrame.svelte"
import CrinkledSection from "../CrinkledSection.svelte"
- import { createVoicebotPreviewController } from "./voicebotPreviewController"
+ import { createVoicebotPreviewController } from "../voicebotDemo/voicebotPreviewController"
const voiceProperties: Array<{ title: string; icon: string; color: string }> = [
{
@@ -149,12 +149,6 @@
transform: translateY(-4px);
}
- &.connected {
- }
-
- &.errored {
- }
-
&:focus-visible {
outline: 2px solid var(--primary-200);
outline-offset: 4px;
diff --git a/frontend/src/lib/components/staticPageRows/voicebotPreviewController.ts b/frontend/src/lib/components/staticPageRows/voicebotPreviewController.ts
index 0778aac..16c415f 100644
--- a/frontend/src/lib/components/staticPageRows/voicebotPreviewController.ts
+++ b/frontend/src/lib/components/staticPageRows/voicebotPreviewController.ts
@@ -1,207 +1,2 @@
-import { derived, get, writable, type Readable } from "svelte/store"
-import {
- ConnectorLifecycleEvents,
- createVoiceConnector,
- WS_URL,
-} from "../voicebotDemo/helper"
-import type { WerkRealtimeConnector } from "../voicebotDemo/helper"
-
-export type VoiceStatus = "idle" | "connecting" | "connected" | "error"
-
-interface VoicebotPreviewController {
- status: Readable
- errorMessage: Readable
- statusHint: Readable
- setup: () => void
- teardown: () => void
- start: () => Promise
- stop: (silent?: boolean) => Promise
- toggle: () => Promise
-}
-
-const isBrowser = typeof window !== "undefined"
-
-const extractErrorMessage = (err: unknown, fallback: string) => {
- if (err instanceof DOMException) {
- if (err.name === "NotAllowedError") return "Zugriff auf das Mikrofon wurde verweigert."
- if (err.name === "NotFoundError") return "Kein Mikrofon gefunden oder verfügbar."
- if (err.name === "NotReadableError") return "Auf das Mikrofon konnte nicht zugegriffen werden."
- if (err.name === "SecurityError") return "Der Browser blockiert den Zugriff – bitte die Seite über HTTPS öffnen."
- }
- if (err instanceof Error && err.message) return err.message
- return fallback
-}
-
-export const createVoicebotPreviewController = (): VoicebotPreviewController => {
- const statusStore = writable("idle")
- const errorStore = writable("")
-
- const statusHint = derived([statusStore, errorStore], ([$status, $error]) => {
- switch ($status) {
- case "idle":
- return "Tippen, um die Voice-Demo zu starten"
- case "connecting":
- return "Verbindung wird aufgebaut …"
- case "connected":
- return "Live – sprechen Sie jetzt"
- case "error":
- return $error || "Verbindung fehlgeschlagen"
- default:
- return "Voice-Demo"
- }
- })
-
- let connector: WerkRealtimeConnector | null = null
- let detachHandlers: Array<() => void> = []
- let startPromise: Promise | null = null
- let stopPromise: Promise | null = null
- let closing = false
- const handleRealtimeEvent = (rawType: string, msg: any) => {
- const type = rawType
- if (!type) return
-
- const now = new Date()
-
- }
- const ensureConnector = () => {
- if (!isBrowser || connector) return
-
- const instance = createVoiceConnector()
-
- const detachConnecting = instance.onLifecycle(ConnectorLifecycleEvents.CONNECTING, () => {
- statusStore.set("connecting")
- errorStore.set("")
- })
- const detachConnected = instance.onLifecycle(ConnectorLifecycleEvents.CONNECTED, () => {
- statusStore.set("connected")
- closing = false
- })
- const detachDisconnected = instance.onLifecycle(ConnectorLifecycleEvents.DISCONNECTED, () => {
- if (closing) {
- closing = false
- return
- }
- if (get(statusStore) !== "error") {
- statusStore.set("idle")
- errorStore.set("")
- }
- })
- const detachError = instance.onLifecycle(ConnectorLifecycleEvents.ERROR, (evt) => {
- const message =
- typeof evt?.message === "string" && evt.message.trim().length
- ? evt.message
- : "Verbindung fehlgeschlagen"
- errorStore.set(`${message} (${WS_URL})`)
- statusStore.set("error")
- closing = false
- })
-
- detachHandlers = [detachConnecting, detachConnected, detachDisconnected, detachError]
- connector = instance
- }
-
- const cleanupConnector = () => {
- detachHandlers.forEach((fn) => fn())
- detachHandlers = []
- const instance = connector
- connector = null
- if (instance) {
- void instance.stop()
- }
- startPromise = null
- stopPromise = null
- closing = false
- }
-
- const stop = async (silent = false) => {
- if (!connector) return
- if (stopPromise) {
- await stopPromise
- return
- }
-
- closing = true
- if (!silent && get(statusStore) !== "error") {
- statusStore.set("idle")
- errorStore.set("")
- }
-
- stopPromise = connector.stop()
-
- try {
- await stopPromise
- } catch (err) {
- console.error("VoicebotPreview stop error", err)
- if (!silent) {
- errorStore.set(extractErrorMessage(err, "Verbindung konnte nicht beendet werden."))
- statusStore.set("error")
- }
- } finally {
- stopPromise = null
- closing = false
- }
- }
-
- const start = async () => {
- if (!isBrowser) {
- statusStore.set("error")
- errorStore.set("Die Sprach-Demo steht nur im Browser zur Verfügung.")
- return
- }
-
- ensureConnector()
- if (!connector || startPromise) return
-
- await stop(true)
-
- statusStore.set("connecting")
- errorStore.set("")
-
- startPromise = (async () => {
- await connector!.start()
- connector!.setInputMuted(false)
- connector!.setOutputMuted(false)
- })()
-
- try {
- await startPromise
- } catch (err) {
- console.error("VoicebotPreview start error", err)
- errorStore.set(extractErrorMessage(err, "Verbindung konnte nicht aufgebaut werden."))
- statusStore.set("error")
- closing = false
- } finally {
- startPromise = null
- }
- }
-
- const toggle = async () => {
- const current = get(statusStore)
- if (current === "connecting") return
- if (current === "connected") {
- await stop()
- return
- }
- await start()
- }
-
- const setup = () => {
- ensureConnector()
- }
-
- const teardown = () => {
- void stop(true)
- cleanupConnector()
- }
-
- return {
- status: { subscribe: statusStore.subscribe },
- errorMessage: { subscribe: errorStore.subscribe },
- statusHint,
- setup,
- teardown,
- start,
- stop,
- toggle,
- }
-}
+export { createVoicebotPreviewController } from "../voicebotDemo/voicebotPreviewController"
+export type { VoiceStatus } from "../voicebotDemo/voicebotPreviewController"
diff --git a/frontend/src/lib/components/voicebotDemo/voicebotPreviewController.ts b/frontend/src/lib/components/voicebotDemo/voicebotPreviewController.ts
new file mode 100644
index 0000000..068ff1e
--- /dev/null
+++ b/frontend/src/lib/components/voicebotDemo/voicebotPreviewController.ts
@@ -0,0 +1,199 @@
+import { derived, get, writable, type Readable } from "svelte/store"
+import { ConnectorLifecycleEvents, createVoiceConnector, WS_URL } from "./helper"
+import type { WerkRealtimeConnector } from "./helper"
+
+export type VoiceStatus = "idle" | "connecting" | "connected" | "error"
+
+interface VoicebotPreviewController {
+ status: Readable
+ errorMessage: Readable
+ statusHint: Readable
+ setup: () => void
+ teardown: () => void
+ start: () => Promise
+ stop: (silent?: boolean) => Promise
+ toggle: () => Promise
+}
+
+const isBrowser = typeof window !== "undefined"
+
+const extractErrorMessage = (err: unknown, fallback: string) => {
+ if (err instanceof DOMException) {
+ if (err.name === "NotAllowedError") return "Zugriff auf das Mikrofon wurde verweigert."
+ if (err.name === "NotFoundError") return "Kein Mikrofon gefunden oder verfügbar."
+ if (err.name === "NotReadableError") return "Auf das Mikrofon konnte nicht zugegriffen werden."
+ if (err.name === "SecurityError") {
+ return "Der Browser blockiert den Zugriff – bitte die Seite über HTTPS öffnen."
+ }
+ }
+ if (err instanceof Error && err.message) return err.message
+ return fallback
+}
+
+export const createVoicebotPreviewController = (): VoicebotPreviewController => {
+ const statusStore = writable("idle")
+ const errorStore = writable("")
+
+ const statusHint = derived([statusStore, errorStore], ([$status, $error]) => {
+ switch ($status) {
+ case "idle":
+ return "Tippen, um die Voice-Demo zu starten"
+ case "connecting":
+ return "Verbindung wird aufgebaut …"
+ case "connected":
+ return "Live – sprechen Sie jetzt"
+ case "error":
+ return $error || "Verbindung fehlgeschlagen"
+ default:
+ return "Voice-Demo"
+ }
+ })
+
+ let connector: WerkRealtimeConnector | null = null
+ let detachHandlers: Array<() => void> = []
+ let startPromise: Promise | null = null
+ let stopPromise: Promise | null = null
+ let closing = false
+
+ const ensureConnector = () => {
+ if (!isBrowser || connector) return
+
+ const instance = createVoiceConnector()
+
+ const detachConnecting = instance.onLifecycle(ConnectorLifecycleEvents.CONNECTING, () => {
+ statusStore.set("connecting")
+ errorStore.set("")
+ })
+ const detachConnected = instance.onLifecycle(ConnectorLifecycleEvents.CONNECTED, () => {
+ statusStore.set("connected")
+ closing = false
+ })
+ const detachDisconnected = instance.onLifecycle(ConnectorLifecycleEvents.DISCONNECTED, () => {
+ if (closing) {
+ closing = false
+ return
+ }
+ if (get(statusStore) !== "error") {
+ statusStore.set("idle")
+ errorStore.set("")
+ }
+ })
+ const detachError = instance.onLifecycle(ConnectorLifecycleEvents.ERROR, (evt) => {
+ const message =
+ typeof evt?.message === "string" && evt.message.trim().length
+ ? evt.message
+ : "Verbindung fehlgeschlagen"
+ errorStore.set(`${message} (${WS_URL})`)
+ statusStore.set("error")
+ closing = false
+ })
+
+ detachHandlers = [detachConnecting, detachConnected, detachDisconnected, detachError]
+ connector = instance
+ }
+
+ const cleanupConnector = () => {
+ detachHandlers.forEach((fn) => fn())
+ detachHandlers = []
+ const instance = connector
+ connector = null
+ if (instance) {
+ void instance.stop()
+ }
+ startPromise = null
+ stopPromise = null
+ closing = false
+ }
+
+ const stop = async (silent = false) => {
+ if (!connector) return
+ if (stopPromise) {
+ await stopPromise
+ return
+ }
+
+ closing = true
+ if (!silent && get(statusStore) !== "error") {
+ statusStore.set("idle")
+ errorStore.set("")
+ }
+
+ stopPromise = connector.stop()
+
+ try {
+ await stopPromise
+ } catch (err) {
+ console.error("VoicebotPreview stop error", err)
+ if (!silent) {
+ errorStore.set(extractErrorMessage(err, "Verbindung konnte nicht beendet werden."))
+ statusStore.set("error")
+ }
+ } finally {
+ stopPromise = null
+ closing = false
+ }
+ }
+
+ const start = async () => {
+ if (!isBrowser) {
+ statusStore.set("error")
+ errorStore.set("Die Sprach-Demo steht nur im Browser zur Verfügung.")
+ return
+ }
+
+ ensureConnector()
+ if (!connector || startPromise) return
+
+ await stop(true)
+
+ statusStore.set("connecting")
+ errorStore.set("")
+
+ startPromise = (async () => {
+ await connector!.start()
+ connector!.setInputMuted(false)
+ connector!.setOutputMuted(false)
+ })()
+
+ try {
+ await startPromise
+ } catch (err) {
+ console.error("VoicebotPreview start error", err)
+ errorStore.set(extractErrorMessage(err, "Verbindung konnte nicht aufgebaut werden."))
+ statusStore.set("error")
+ closing = false
+ } finally {
+ startPromise = null
+ }
+ }
+
+ const toggle = async () => {
+ const current = get(statusStore)
+ if (current === "connecting") return
+ if (current === "connected") {
+ await stop()
+ return
+ }
+ await start()
+ }
+
+ const setup = () => {
+ ensureConnector()
+ }
+
+ const teardown = () => {
+ void stop(true)
+ cleanupConnector()
+ }
+
+ return {
+ status: { subscribe: statusStore.subscribe },
+ errorMessage: { subscribe: errorStore.subscribe },
+ statusHint,
+ setup,
+ teardown,
+ start,
+ stop,
+ toggle,
+ }
+}
diff --git a/package.json b/package.json
index dd0d071..9d91c0a 100644
--- a/package.json
+++ b/package.json
@@ -24,7 +24,7 @@
"upload:sourcemaps": "scripts/upload-sourcemaps.sh"
},
"devDependencies": {
- "@babel/cli": "^7.27.2",
+ "@babel/cli": "^7.28.3",
"@babel/core": "^7.27.1",
"@babel/plugin-transform-async-to-generator": "^7.27.1",
"@babel/preset-env": "^7.27.2",
@@ -55,7 +55,7 @@
"typescript": "^5.8.3"
},
"dependencies": {
- "@kontextwerk/web-sdk": "0.1.0",
+ "@kontextwerk/web-sdk": "0.1.1",
"@mdi/js": "^7.4.47",
"@microsoft/fetch-event-source": "^2.0.1",
"@okrad/svelte-progressbar": "^2.2.0",
diff --git a/yarn.lock b/yarn.lock
index d85b2f0..72f7e77 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5,7 +5,7 @@ __metadata:
version: 6
cacheKey: 8
-"@babel/cli@npm:^7.27.2":
+"@babel/cli@npm:^7.28.3":
version: 7.28.3
resolution: "@babel/cli@npm:7.28.3"
dependencies:
@@ -1779,12 +1779,12 @@ __metadata:
languageName: node
linkType: hard
-"@kontextwerk/web-sdk@npm:0.1.0":
- version: 0.1.0
- resolution: "@kontextwerk/web-sdk@npm:0.1.0"
+"@kontextwerk/web-sdk@npm:0.1.1":
+ version: 0.1.1
+ resolution: "@kontextwerk/web-sdk@npm:0.1.1"
dependencies:
zod: ^3.23.8
- checksum: b99d6a71584c1db40ab2eb83f5b269bca6a70c4eec4c644eed0873e909c08fbae9600556599b7012ff13fb6150086687612c59c98c2f03699ad8559302090f83
+ checksum: 8b7aa7608f2f71efc6c299088eb4f9f89fcd18b49edad194cd8e19e9ac6617c762f092ad286d3974f781acfc5137a7154f350e673c030f6cfe92f54fb2a9898b
languageName: node
linkType: hard
@@ -6092,11 +6092,11 @@ __metadata:
version: 0.0.0-use.local
resolution: "renz-shop-2020@workspace:."
dependencies:
- "@babel/cli": ^7.27.2
+ "@babel/cli": ^7.28.3
"@babel/core": ^7.27.1
"@babel/plugin-transform-async-to-generator": ^7.27.1
"@babel/preset-env": ^7.27.2
- "@kontextwerk/web-sdk": 0.1.0
+ "@kontextwerk/web-sdk": 0.1.1
"@mdi/js": ^7.4.47
"@microsoft/fetch-event-source": ^2.0.1
"@okrad/svelte-progressbar": ^2.2.0