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