refactorings

This commit is contained in:
2025-10-05 14:04:25 +00:00
parent 57b5415e66
commit 2a5a5e2121
9 changed files with 219 additions and 404 deletions

View File

@@ -1,12 +1,12 @@
<script lang="ts"> <script lang="ts">
import { Chat } from "./chat" import { WerkChatSession } 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 = $derived(
new Chat(generateUniqueId(), { new WerkChatSession(generateUniqueId(), {
url: "https://2schat-server.robins-spielwiese.de/api/v1/chatbot/stream", url: "https://2schat-server.robins-spielwiese.de/api/v1/chatbot/stream",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -1,8 +1,8 @@
<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 { Chat } from "./chat" import type { WerkChatSession } from "@kontextwerk/web-sdk"
let { chat }: { chat: Chat } = $props() let { chat }: { chat: WerkChatSession } = $props()
let value = $state("") let value = $state("")
let textareaEl: HTMLTextAreaElement = $state(null) let textareaEl: HTMLTextAreaElement = $state(null)
function autoResize() { function autoResize() {

View File

@@ -1,9 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { Chat } from "./chat" import type { WerkChatSession } from "@kontextwerk/web-sdk"
let { let {
chat, chat,
}: { }: {
chat: Chat chat: WerkChatSession
} = $props() } = $props()
</script> </script>

View File

@@ -1,175 +1,2 @@
import { fetchEventSource } from "@microsoft/fetch-event-source" export { WerkChatSession as Chat } from "@kontextwerk/web-sdk"
export type { ChatMessage, ChatOptions } from "@kontextwerk/web-sdk"
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<string, string>
/** 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<void> {
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
}
}
}

View File

@@ -3,7 +3,7 @@
import { mdiBookAccountOutline, mdiCreation, mdiFaceAgent, mdiHours24 } from "@mdi/js" import { mdiBookAccountOutline, mdiCreation, mdiFaceAgent, mdiHours24 } from "@mdi/js"
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 "./voicebotPreviewController" import { createVoicebotPreviewController } from "../voicebotDemo/voicebotPreviewController"
const voiceProperties: Array<{ title: string; icon: string; color: string }> = [ const voiceProperties: Array<{ title: string; icon: string; color: string }> = [
{ {
@@ -149,12 +149,6 @@
transform: translateY(-4px); transform: translateY(-4px);
} }
&.connected {
}
&.errored {
}
&:focus-visible { &:focus-visible {
outline: 2px solid var(--primary-200); outline: 2px solid var(--primary-200);
outline-offset: 4px; outline-offset: 4px;

View File

@@ -1,207 +1,2 @@
import { derived, get, writable, type Readable } from "svelte/store" export { createVoicebotPreviewController } from "../voicebotDemo/voicebotPreviewController"
import { export type { VoiceStatus } from "../voicebotDemo/voicebotPreviewController"
ConnectorLifecycleEvents,
createVoiceConnector,
WS_URL,
} from "../voicebotDemo/helper"
import type { WerkRealtimeConnector } from "../voicebotDemo/helper"
export type VoiceStatus = "idle" | "connecting" | "connected" | "error"
interface VoicebotPreviewController {
status: Readable<VoiceStatus>
errorMessage: Readable<string>
statusHint: Readable<string>
setup: () => void
teardown: () => void
start: () => Promise<void>
stop: (silent?: boolean) => Promise<void>
toggle: () => Promise<void>
}
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<VoiceStatus>("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<void> | null = null
let stopPromise: Promise<void> | 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,
}
}

View File

@@ -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<VoiceStatus>
errorMessage: Readable<string>
statusHint: Readable<string>
setup: () => void
teardown: () => void
start: () => Promise<void>
stop: (silent?: boolean) => Promise<void>
toggle: () => Promise<void>
}
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<VoiceStatus>("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<void> | null = null
let stopPromise: Promise<void> | 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,
}
}

View File

@@ -24,7 +24,7 @@
"upload:sourcemaps": "scripts/upload-sourcemaps.sh" "upload:sourcemaps": "scripts/upload-sourcemaps.sh"
}, },
"devDependencies": { "devDependencies": {
"@babel/cli": "^7.27.2", "@babel/cli": "^7.28.3",
"@babel/core": "^7.27.1", "@babel/core": "^7.27.1",
"@babel/plugin-transform-async-to-generator": "^7.27.1", "@babel/plugin-transform-async-to-generator": "^7.27.1",
"@babel/preset-env": "^7.27.2", "@babel/preset-env": "^7.27.2",
@@ -55,7 +55,7 @@
"typescript": "^5.8.3" "typescript": "^5.8.3"
}, },
"dependencies": { "dependencies": {
"@kontextwerk/web-sdk": "0.1.0", "@kontextwerk/web-sdk": "0.1.1",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@microsoft/fetch-event-source": "^2.0.1", "@microsoft/fetch-event-source": "^2.0.1",
"@okrad/svelte-progressbar": "^2.2.0", "@okrad/svelte-progressbar": "^2.2.0",

View File

@@ -5,7 +5,7 @@ __metadata:
version: 6 version: 6
cacheKey: 8 cacheKey: 8
"@babel/cli@npm:^7.27.2": "@babel/cli@npm:^7.28.3":
version: 7.28.3 version: 7.28.3
resolution: "@babel/cli@npm:7.28.3" resolution: "@babel/cli@npm:7.28.3"
dependencies: dependencies:
@@ -1779,12 +1779,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@kontextwerk/web-sdk@npm:0.1.0": "@kontextwerk/web-sdk@npm:0.1.1":
version: 0.1.0 version: 0.1.1
resolution: "@kontextwerk/web-sdk@npm:0.1.0" resolution: "@kontextwerk/web-sdk@npm:0.1.1"
dependencies: dependencies:
zod: ^3.23.8 zod: ^3.23.8
checksum: b99d6a71584c1db40ab2eb83f5b269bca6a70c4eec4c644eed0873e909c08fbae9600556599b7012ff13fb6150086687612c59c98c2f03699ad8559302090f83 checksum: 8b7aa7608f2f71efc6c299088eb4f9f89fcd18b49edad194cd8e19e9ac6617c762f092ad286d3974f781acfc5137a7154f350e673c030f6cfe92f54fb2a9898b
languageName: node languageName: node
linkType: hard linkType: hard
@@ -6092,11 +6092,11 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "renz-shop-2020@workspace:." resolution: "renz-shop-2020@workspace:."
dependencies: dependencies:
"@babel/cli": ^7.27.2 "@babel/cli": ^7.28.3
"@babel/core": ^7.27.1 "@babel/core": ^7.27.1
"@babel/plugin-transform-async-to-generator": ^7.27.1 "@babel/plugin-transform-async-to-generator": ^7.27.1
"@babel/preset-env": ^7.27.2 "@babel/preset-env": ^7.27.2
"@kontextwerk/web-sdk": 0.1.0 "@kontextwerk/web-sdk": 0.1.1
"@mdi/js": ^7.4.47 "@mdi/js": ^7.4.47
"@microsoft/fetch-event-source": ^2.0.1 "@microsoft/fetch-event-source": ^2.0.1
"@okrad/svelte-progressbar": ^2.2.0 "@okrad/svelte-progressbar": ^2.2.0