@kieksme/csp-plugin-chat 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ For local provider testing, set `CSP_DEMO=true` and `CSP_CHAT_DEMO=false`: porta
8
8
 
9
9
  Langsame lokale Modelle können mit der Runtime-Variable `CSP_CHAT_TIMEOUT_MS` mehr Antwortzeit erhalten (Standard `60000`, Bereich `1000`–`300000` ms).
10
10
 
11
- Send a message with Cmd + Enter on macOS, Ctrl + Enter on other systems, or the send button. The hint displays the system-specific shortcut. Plain Enter inserts a line break. A subtle shortcut hint appears at the bottom right inside the text field; blank messages and shortcuts during an active reply do not send.
11
+ Send a message with Cmd + Enter on macOS, Ctrl + Enter on other systems, or the send button. The hint displays the system-specific shortcut. Plain Enter inserts a line break. A subtle shortcut hint appears below the text field; blank messages and shortcuts during an active reply do not send.
12
12
 
13
13
  The input starts with one text row and automatically grows with wrapped text or line breaks. It shrinks when text is removed or sent and adjusts when its width changes.
14
14
 
@@ -29,3 +29,5 @@ WebMCP tool: `chat_ask` streams one answer from `POST /api/v1/chat` and returns
29
29
  Users can mark a chat **Problem gelöst** and start a new chat afterward. With a support transport this is an authenticated, persisted owner action, interrupting generation and updating the channel card; without a transport it applies to the current browser chat only. A resolved conversation is distinct from an ordinary closure.
30
30
 
31
31
  The assistant asks one short clarifying question for vague requests (for example “Hallo, meine Email”) before offering tickets. It uses supplied help/status facts and reserves ticket links for escalation or an explicit ticket request. Model behaviour requires a real-provider acceptance test.
32
+
33
+ Long histories follow new messages while the visitor reads at the bottom; scrolling up preserves the reading position and offers **Neue Nachrichten ↓**. Real OpenAI providers expose editable dictation through the server-only transcription API. With a support transport, **Ich möchte mit einer echten Person sprechen** pauses the bot immediately and queues a persistent, directed support request. See [chat behaviour and APIs](../../docs/chat.md) and [runtime options](../../docs/configuration.md#spracheingabe-und-supportanfragen).
package/dist/browser.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  cspPlugin
3
- } from "./chunk-W5MUDSVD.js";
3
+ } from "./chunk-U2H5BQCV.js";
4
4
 
5
5
  // src/tools.ts
6
6
  import {
@@ -167,10 +167,10 @@ var chatTools = [
167
167
  ];
168
168
 
169
169
  // src/browser.tsx
170
- import { useEffect as useEffect2, useLayoutEffect, useRef as useRef2, useState as useState2 } from "react";
170
+ import { useEffect as useEffect4, useLayoutEffect as useLayoutEffect2, useRef as useRef4, useState as useState4 } from "react";
171
171
 
172
172
  // src/support-chat.tsx
173
- import { useEffect, useRef, useState } from "react";
173
+ import { useEffect as useEffect3, useRef as useRef3, useState as useState3 } from "react";
174
174
 
175
175
  // src/support-session.ts
176
176
  async function supportSession(ctx) {
@@ -297,14 +297,327 @@ function anonymousSession(ctx) {
297
297
  return current;
298
298
  }
299
299
 
300
+ // src/chat-controls.tsx
301
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
302
+ import { jsx, jsxs } from "react/jsx-runtime";
303
+ function sendShortcut(event, disabled) {
304
+ if (event.key !== "Enter" || !(event.ctrlKey || event.metaKey) || event.nativeEvent.isComposing || event.repeat)
305
+ return;
306
+ event.preventDefault();
307
+ if (!disabled) event.currentTarget.form?.requestSubmit();
308
+ }
309
+ function Shortcut({ id }) {
310
+ const [mac, setMac] = useState(false);
311
+ useEffect(() => setMac(/Mac|iPhone|iPad/.test(navigator.platform)), []);
312
+ return /* @__PURE__ */ jsxs("span", { id, className: "block text-[0.6875rem] text-muted mt-2", children: [
313
+ mac ? "\u2318" : "Strg",
314
+ " Enter zum Senden"
315
+ ] });
316
+ }
317
+ function useChatScroll(content, conversation) {
318
+ const history = useRef(null), pinned = useRef(true);
319
+ const [unread, setUnread] = useState(false);
320
+ const follow = () => {
321
+ pinned.current = true;
322
+ setUnread(false);
323
+ const element = history.current;
324
+ if (element) element.scrollTop = element.scrollHeight;
325
+ };
326
+ useLayoutEffect(follow, [conversation]);
327
+ useLayoutEffect(() => {
328
+ if (pinned.current) follow();
329
+ else setUnread(true);
330
+ }, [content]);
331
+ useEffect(() => {
332
+ const element = history.current;
333
+ if (!element) return;
334
+ const observer = new ResizeObserver(() => {
335
+ if (pinned.current) follow();
336
+ });
337
+ observer.observe(element);
338
+ return () => observer.disconnect();
339
+ }, []);
340
+ return {
341
+ history,
342
+ follow,
343
+ unread,
344
+ onScroll: () => {
345
+ const element = history.current;
346
+ if (!element) return;
347
+ pinned.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
348
+ if (pinned.current) setUnread(false);
349
+ }
350
+ };
351
+ }
352
+ function NewMessages({
353
+ visible,
354
+ follow
355
+ }) {
356
+ return visible ? /* @__PURE__ */ jsx(
357
+ "button",
358
+ {
359
+ type: "button",
360
+ className: "border border-line rounded-card px-3 py-2 mb-3 text-[0.8125rem]",
361
+ onClick: follow,
362
+ children: "Neue Nachrichten \u2193"
363
+ }
364
+ ) : null;
365
+ }
366
+
367
+ // src/voice-input.tsx
368
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
369
+ import { Fragment, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
370
+ function VoiceInput({
371
+ ctx,
372
+ value,
373
+ setValue,
374
+ disabled,
375
+ token,
376
+ onBusy
377
+ }) {
378
+ const [enabled, setEnabled] = useState2(false), [state, setState] = useState2("idle"), [error, setError] = useState2("");
379
+ const recorder = useRef2(null), stream = useRef2(null), timer = useRef2(null), request = useRef2(null);
380
+ const alive = useRef2(true), cancelled = useRef2(false), draft = useRef2(value);
381
+ const operation = useRef2(0);
382
+ draft.current = value;
383
+ const release = () => {
384
+ if (timer.current) clearTimeout(timer.current);
385
+ stream.current?.getTracks().forEach((track) => track.stop());
386
+ stream.current = null;
387
+ };
388
+ const idle = () => {
389
+ if (alive.current) {
390
+ setState("idle");
391
+ onBusy(false);
392
+ }
393
+ };
394
+ useEffect2(() => {
395
+ alive.current = true;
396
+ const controller = new AbortController();
397
+ if (typeof MediaRecorder !== "undefined" && typeof navigator.mediaDevices?.getUserMedia === "function") {
398
+ void (ctx.request ?? fetch)(
399
+ ctx.config.apiUrl + "/api/v1/chat/capabilities",
400
+ { signal: controller.signal }
401
+ ).then((response) => response.ok ? response.json() : null).then((result) => {
402
+ if (alive.current) setEnabled(result?.transcription === true);
403
+ }).catch(() => {
404
+ });
405
+ }
406
+ return () => {
407
+ operation.current++;
408
+ alive.current = false;
409
+ cancelled.current = true;
410
+ controller.abort();
411
+ request.current?.abort();
412
+ if (recorder.current?.state === "recording") recorder.current.stop();
413
+ release();
414
+ };
415
+ }, [ctx.config.apiUrl]);
416
+ useEffect2(() => {
417
+ if (disabled && state !== "idle") {
418
+ operation.current++;
419
+ cancelled.current = true;
420
+ request.current?.abort();
421
+ if (recorder.current?.state === "recording") recorder.current.stop();
422
+ release();
423
+ idle();
424
+ }
425
+ }, [disabled]);
426
+ async function start() {
427
+ if (state !== "idle" || disabled) return;
428
+ const attempt = ++operation.current;
429
+ setState("starting");
430
+ onBusy(true);
431
+ setError("");
432
+ cancelled.current = false;
433
+ try {
434
+ const media = await navigator.mediaDevices.getUserMedia({ audio: true });
435
+ if (!alive.current || cancelled.current || operation.current !== attempt) {
436
+ media.getTracks().forEach((track) => track.stop());
437
+ if (operation.current === attempt) idle();
438
+ return;
439
+ }
440
+ stream.current = media;
441
+ const mime = [
442
+ "audio/webm;codecs=opus",
443
+ "audio/mp4",
444
+ "audio/ogg;codecs=opus"
445
+ ].find((type) => MediaRecorder.isTypeSupported(type));
446
+ if (!mime)
447
+ throw new Error(
448
+ "Dieser Browser unterst\xFCtzt keine passende Audioaufnahme."
449
+ );
450
+ const capture = new MediaRecorder(media, { mimeType: mime });
451
+ recorder.current = capture;
452
+ const chunks = [];
453
+ let bytes = 0;
454
+ capture.ondataavailable = (event) => {
455
+ if (event.data.size) {
456
+ bytes += event.data.size;
457
+ chunks.push(event.data);
458
+ if (bytes > 10 * 1024 * 1024) {
459
+ cancelled.current = true;
460
+ if (capture.state === "recording") capture.stop();
461
+ release();
462
+ if (alive.current)
463
+ setError("Die Aufnahme ist zu gro\xDF. Bitte k\xFCrzer diktieren.");
464
+ }
465
+ }
466
+ };
467
+ capture.onerror = () => {
468
+ if (operation.current !== attempt) return;
469
+ cancelled.current = true;
470
+ release();
471
+ if (alive.current)
472
+ setError("Aufnahme fehlgeschlagen. Ihr Entwurf bleibt erhalten.");
473
+ idle();
474
+ };
475
+ capture.onstop = () => {
476
+ if (operation.current !== attempt) return;
477
+ release();
478
+ if (cancelled.current || !alive.current) {
479
+ idle();
480
+ return;
481
+ }
482
+ void transcribe(new Blob(chunks, { type: mime }));
483
+ };
484
+ capture.start(1e3);
485
+ setState("recording");
486
+ timer.current = setTimeout(() => {
487
+ if (capture.state === "recording") capture.stop();
488
+ }, 6e4);
489
+ } catch (failure) {
490
+ if (operation.current !== attempt) return;
491
+ release();
492
+ if (alive.current)
493
+ setError(
494
+ failure instanceof DOMException && failure.name === "NotAllowedError" ? "Bitte erlauben Sie den Mikrofonzugriff f\xFCr die Spracheingabe." : failure instanceof Error ? failure.message : "Mikrofon nicht verf\xFCgbar."
495
+ );
496
+ idle();
497
+ }
498
+ }
499
+ async function transcribe(audio) {
500
+ const attempt = operation.current;
501
+ setState("transcribing");
502
+ const controller = new AbortController();
503
+ request.current = controller;
504
+ try {
505
+ const bearer = await token?.();
506
+ if (controller.signal.aborted) return;
507
+ const response = await (ctx.request ?? fetch)(
508
+ ctx.config.apiUrl + "/api/v1/chat/transcriptions",
509
+ {
510
+ method: "POST",
511
+ body: audio,
512
+ credentials: "same-origin",
513
+ signal: controller.signal,
514
+ headers: {
515
+ "Content-Type": audio.type,
516
+ ...bearer ? { Authorization: `Bearer ${bearer}` } : {}
517
+ }
518
+ }
519
+ );
520
+ const result = await response.json();
521
+ if (!response.ok)
522
+ throw new Error(
523
+ result.error ?? "Spracheingabe derzeit nicht verf\xFCgbar."
524
+ );
525
+ if (typeof result.text !== "string" || !result.text.trim())
526
+ throw new Error("Keine Sprache erkannt.");
527
+ if (draft.current.length + result.text.length + (draft.current ? 1 : 0) > 4e3)
528
+ throw new Error(
529
+ "Der Entwurf w\xE4re zu lang. Bitte k\xFCrzer diktieren. Ihr Text bleibt erhalten."
530
+ );
531
+ if (alive.current && !controller.signal.aborted && operation.current === attempt)
532
+ setValue((previous) => previous + (previous ? "\n" : "") + result.text);
533
+ } catch (failure) {
534
+ if (alive.current && !controller.signal.aborted)
535
+ setError(
536
+ failure instanceof Error ? failure.message : "Spracheingabe fehlgeschlagen. Ihr Entwurf bleibt erhalten."
537
+ );
538
+ } finally {
539
+ if (operation.current === attempt) {
540
+ request.current = null;
541
+ idle();
542
+ }
543
+ }
544
+ }
545
+ if (!enabled) return null;
546
+ return /* @__PURE__ */ jsxs2("div", { className: "mt-3 text-[0.8125rem]", children: [
547
+ state === "idle" ? /* @__PURE__ */ jsxs2(
548
+ "button",
549
+ {
550
+ type: "button",
551
+ className: "border border-line rounded-card px-3 py-2",
552
+ disabled,
553
+ onClick: () => void start(),
554
+ children: [
555
+ /* @__PURE__ */ jsxs2(
556
+ "svg",
557
+ {
558
+ "aria-hidden": "true",
559
+ viewBox: "0 0 24 24",
560
+ width: "16",
561
+ height: "16",
562
+ className: "inline mr-2",
563
+ fill: "none",
564
+ stroke: "currentColor",
565
+ strokeWidth: "2",
566
+ children: [
567
+ /* @__PURE__ */ jsx2("rect", { x: "9", y: "2", width: "6", height: "12", rx: "3" }),
568
+ /* @__PURE__ */ jsx2("path", { d: "M5 10v2a7 7 0 0 0 14 0v-2M12 19v3M8 22h8" })
569
+ ]
570
+ }
571
+ ),
572
+ "Nachricht diktieren"
573
+ ]
574
+ }
575
+ ) : /* @__PURE__ */ jsxs2(Fragment, { children: [
576
+ /* @__PURE__ */ jsx2("span", { role: "status", children: state === "recording" ? "\u25CF Aufnahme l\xE4uft \xB7 maximal 60 Sekunden" : state === "starting" ? "Mikrofon wird gestartet \u2026" : "Sprache wird in Text umgewandelt \u2026" }),
577
+ state === "recording" && /* @__PURE__ */ jsx2(
578
+ "button",
579
+ {
580
+ type: "button",
581
+ className: "border border-line rounded-card px-3 py-2 ml-2",
582
+ onClick: () => recorder.current?.stop(),
583
+ children: "Aufnahme stoppen"
584
+ }
585
+ ),
586
+ /* @__PURE__ */ jsx2(
587
+ "button",
588
+ {
589
+ type: "button",
590
+ className: "underline ml-2",
591
+ onClick: () => {
592
+ operation.current++;
593
+ cancelled.current = true;
594
+ request.current?.abort();
595
+ if (recorder.current?.state === "recording")
596
+ recorder.current.stop();
597
+ release();
598
+ idle();
599
+ },
600
+ children: "Abbrechen"
601
+ }
602
+ )
603
+ ] }),
604
+ /* @__PURE__ */ jsx2("p", { className: "text-[0.6875rem] text-muted mt-2", children: "Die Aufnahme wird zur Texterkennung an OpenAI gesendet. Pr\xFCfen Sie den Text vor dem Senden." }),
605
+ error && /* @__PURE__ */ jsx2("p", { role: "alert", className: "text-danger dark:text-warning mt-2", children: error })
606
+ ] });
607
+ }
608
+
300
609
  // src/support-chat.tsx
301
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
610
+ import { Fragment as Fragment2, jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
302
611
  function SupportChat(ctx) {
303
- const [current, setCurrent] = useState(null), [input, setInput] = useState(""), [error, setError] = useState(""), [busy, setBusy] = useState(false), [ready, setReady] = useState(false), [auth, setAuth] = useState(null);
304
- const seq = useRef(0), requestRef = useRef(null);
612
+ const [current, setCurrent] = useState3(null), [input, setInput] = useState3(""), [error, setError] = useState3(""), [busy, setBusy] = useState3(false), [ready, setReady] = useState3(false), [auth, setAuth] = useState3(null);
613
+ const [voiceBusy, setVoiceBusy] = useState3(false);
614
+ const scroll = useChatScroll(current?.messages, current?.id);
615
+ const sending = useRef3(false);
616
+ const blocked = busy || voiceBusy || !ready || current?.status === "closed" || !!current?.messages.some((m) => !m.complete);
617
+ const seq = useRef3(0), requestRef = useRef3(null);
305
618
  const anonymous = ctx.config.chatSupport?.auth === "anonymous";
306
619
  const demo = ctx.config.chatSupport?.mode === "demo";
307
- useEffect(() => {
620
+ useEffect3(() => {
308
621
  let alive = true;
309
622
  const controller = new AbortController();
310
623
  async function init() {
@@ -358,7 +671,7 @@ function SupportChat(ctx) {
358
671
  controller.abort();
359
672
  };
360
673
  }, [ctx.config.apiUrl]);
361
- useEffect(() => {
674
+ useEffect3(() => {
362
675
  if (!current?.id || !ready) return;
363
676
  let alive = true;
364
677
  const controller = new AbortController();
@@ -418,14 +731,17 @@ function SupportChat(ctx) {
418
731
  }, [current?.id, ready]);
419
732
  async function newConversation() {
420
733
  const c = await (await requestRef.current("", { method: "POST" })).json();
734
+ scroll.follow();
421
735
  seq.current = c.seq;
422
736
  setCurrent(c);
423
737
  return c;
424
738
  }
425
- const pending = useRef(null);
739
+ const pending = useRef3(null);
426
740
  async function submit(e) {
427
741
  e.preventDefault();
428
- if (!input.trim() || busy || !ready) return;
742
+ if (!input.trim() || blocked || sending.current) return;
743
+ sending.current = true;
744
+ scroll.follow();
429
745
  setBusy(true);
430
746
  setError("");
431
747
  try {
@@ -439,10 +755,38 @@ function SupportChat(ctx) {
439
755
  pending.current = null;
440
756
  setInput("");
441
757
  const refreshed = await (await requestRef.current(`/${c.id}`)).json();
442
- setCurrent(refreshed);
758
+ applySnapshot(refreshed);
443
759
  } catch (e2) {
444
760
  setError(e2 instanceof Error ? e2.message : "Nachricht nicht gesendet.");
445
761
  } finally {
762
+ sending.current = false;
763
+ setBusy(false);
764
+ }
765
+ }
766
+ function applySnapshot(result) {
767
+ setCurrent(
768
+ (previous) => previous?.id === result.id && previous.seq > result.seq ? previous : result
769
+ );
770
+ }
771
+ async function requestHuman() {
772
+ if (busy || !ready || sending.current) return;
773
+ sending.current = true;
774
+ setBusy(true);
775
+ setError("");
776
+ try {
777
+ const c = current ?? await newConversation();
778
+ const result = await (await requestRef.current(`/${c.id}/request-support`, {
779
+ method: "POST",
780
+ body: "{}"
781
+ })).json();
782
+ applySnapshot(result);
783
+ scroll.follow();
784
+ } catch (error2) {
785
+ setError(
786
+ error2 instanceof Error ? error2.message : "Support konnte nicht angefragt werden."
787
+ );
788
+ } finally {
789
+ sending.current = false;
446
790
  setBusy(false);
447
791
  }
448
792
  }
@@ -455,7 +799,7 @@ function SupportChat(ctx) {
455
799
  method: "POST",
456
800
  body: "{}"
457
801
  })).json();
458
- setCurrent(result);
802
+ applySnapshot(result);
459
803
  pending.current = null;
460
804
  setInput("");
461
805
  } catch (e) {
@@ -463,6 +807,7 @@ function SupportChat(ctx) {
463
807
  e instanceof Error ? e.message : "Problem konnte nicht als gel\xF6st markiert werden."
464
808
  );
465
809
  } finally {
810
+ sending.current = false;
466
811
  setBusy(false);
467
812
  }
468
813
  }
@@ -479,26 +824,28 @@ function SupportChat(ctx) {
479
824
  setError(e instanceof Error ? e.message : "Aktion fehlgeschlagen.");
480
825
  }
481
826
  }
482
- return /* @__PURE__ */ jsxs("div", { className: "chat-panel support-chat text-ink bg-card border border-line p-6 rounded-card compact:p-4", children: [
483
- /* @__PURE__ */ jsx("p", { className: "text-[0.75rem] text-ink mb-3", children: demo ? "Synthetische Support-Demo. Keine \xDCbertragung an einen Support-Channel." : `Ihre Nachrichten und die Antworten werden an den Support in ${ctx.config.chatSupport?.mode === "slack" ? "Slack" : ctx.config.chatSupport?.mode === "telegram" ? "Telegram" : "Microsoft Teams"} weitergegeben.` }),
484
- /* @__PURE__ */ jsxs("p", { role: "status", className: "text-[0.8125rem] mb-3", children: [
485
- current?.status === "support" ? `${current.support?.name} \xB7 Support` : current?.status === "closed" ? current.resolution ? "Problem gel\xF6st" : "Gespr\xE4ch abgeschlossen" : "Digitaler Assistent",
827
+ return /* @__PURE__ */ jsxs3("div", { className: "chat-panel support-chat text-ink bg-card border border-line p-6 rounded-card compact:p-4", children: [
828
+ /* @__PURE__ */ jsx3("p", { className: "text-[0.75rem] text-ink mb-3", children: demo ? "Synthetische Support-Demo. Keine \xDCbertragung an einen Support-Channel." : `Ihre Nachrichten und die Antworten werden an den Support in ${ctx.config.chatSupport?.mode === "slack" ? "Slack" : ctx.config.chatSupport?.mode === "telegram" ? "Telegram" : "Microsoft Teams"} weitergegeben.` }),
829
+ /* @__PURE__ */ jsxs3("p", { role: "status", className: "text-[0.8125rem] mb-3", children: [
830
+ current?.status === "support" ? `${current.support?.name} \xB7 Support` : current?.status === "waiting" ? current.supportRequest?.notification === "sent" ? "Support-Team informiert \xB7 KI pausiert" : current.supportRequest?.notification === "failed" ? "Zustellung verz\xF6gert \xB7 erneuter Versuch l\xE4uft \xB7 KI pausiert" : "Pers\xF6nlicher Support angefragt \xB7 KI pausiert" : current?.status === "closed" ? current.resolution ? "Problem gel\xF6st" : "Gespr\xE4ch abgeschlossen" : "Digitaler Assistent",
486
831
  auth && ready && !demo ? anonymous ? ` \xB7 ${auth.name}` : ` \xB7 Angemeldet als ${auth.name}` : ""
487
832
  ] }),
488
- /* @__PURE__ */ jsx(
833
+ /* @__PURE__ */ jsx3(
489
834
  "div",
490
835
  {
491
- className: "chat-history grid gap-3 max-h-[400px] overflow-auto mb-4",
836
+ ref: scroll.history,
837
+ onScroll: scroll.onScroll,
838
+ className: "chat-history grid gap-3 max-h-[min(400px,45dvh)] overflow-auto overscroll-contain mb-4",
492
839
  "aria-live": "polite",
493
840
  "aria-label": "Gespr\xE4chsverlauf",
494
- children: current?.messages.map((m) => /* @__PURE__ */ jsxs(
841
+ children: current?.messages.map((m) => /* @__PURE__ */ jsxs3(
495
842
  "div",
496
843
  {
497
844
  className: `message ${m.kind === "user" ? "user bg-soft p-3 rounded-card" : "assistant border-l-2 border-accent pl-4"} text-[0.8125rem] whitespace-pre-wrap [overflow-wrap:anywhere]`,
498
845
  children: [
499
- /* @__PURE__ */ jsx("span", { className: "block text-[0.6875rem] text-ink mb-1", children: m.kind === "user" ? "Sie" : m.kind === "support" ? `${m.name} \xB7 Support` : m.kind === "bot" ? m.responder?.role === "on-duty" && m.responder.name ? `Digitaler Assistent von ${m.responder.name}` : "Digitaler Assistent des Service-Teams" : "Gespr\xE4ch" }),
846
+ /* @__PURE__ */ jsx3("span", { className: "block text-[0.6875rem] text-ink mb-1", children: m.kind === "user" ? "Sie" : m.kind === "support" ? `${m.name} \xB7 Support` : m.kind === "bot" ? m.responder?.role === "on-duty" && m.responder.name ? `Digitaler Assistent von ${m.responder.name}` : "Digitaler Assistent des Service-Teams" : "Gespr\xE4ch" }),
500
847
  m.kind === "bot" ? answerParts(m.content, m.sources ?? [], !m.complete).map(
501
- (part, i) => part.href ? /* @__PURE__ */ jsx(
848
+ (part, i) => part.href ? /* @__PURE__ */ jsx3(
502
849
  "a",
503
850
  {
504
851
  href: part.href,
@@ -510,14 +857,15 @@ function SupportChat(ctx) {
510
857
  i
511
858
  ) : part.text
512
859
  ) : m.content,
513
- !m.complete && !m.content && /* @__PURE__ */ jsx("span", { children: "Antwort wird erstellt \u2026" })
860
+ !m.complete && !m.content && /* @__PURE__ */ jsx3("span", { children: "Antwort wird erstellt \u2026" })
514
861
  ]
515
862
  },
516
863
  m.id
517
864
  ))
518
865
  }
519
866
  ),
520
- !ready && /* @__PURE__ */ jsx(
867
+ /* @__PURE__ */ jsx3(NewMessages, { visible: scroll.unread, follow: scroll.follow }),
868
+ !ready && /* @__PURE__ */ jsx3(
521
869
  "button",
522
870
  {
523
871
  type: "button",
@@ -531,32 +879,59 @@ function SupportChat(ctx) {
531
879
  children: anonymous ? "Chat neu verbinden" : ctx.config.chatSupport?.auth === "session" ? "Portal-Anmeldung erneuern" : "Mit Microsoft anmelden"
532
880
  }
533
881
  ),
534
- ready && /* @__PURE__ */ jsxs("form", { onSubmit: submit, className: "chat-form flex gap-2.5 items-end", children: [
535
- /* @__PURE__ */ jsx("label", { htmlFor: "support-chat-input", className: "sr-only", children: "Ihre Nachricht" }),
536
- /* @__PURE__ */ jsx(
537
- "textarea",
882
+ ready && /* @__PURE__ */ jsxs3("div", { children: [
883
+ /* @__PURE__ */ jsxs3("form", { onSubmit: submit, className: "chat-form flex gap-2.5 items-end", children: [
884
+ /* @__PURE__ */ jsx3("label", { htmlFor: "support-chat-input", className: "sr-only", children: "Ihre Nachricht" }),
885
+ /* @__PURE__ */ jsx3(
886
+ "textarea",
887
+ {
888
+ id: "support-chat-input",
889
+ value: input,
890
+ rows: 2,
891
+ maxLength: 4e3,
892
+ disabled: current?.status === "closed",
893
+ className: "flex-1 min-w-0 max-h-[180px] overflow-auto resize-none border border-line rounded-card bg-card p-3",
894
+ onChange: (e) => setInput(e.target.value),
895
+ onKeyDown: (e) => sendShortcut(e, blocked || !input.trim()),
896
+ "aria-keyshortcuts": "Control+Enter Meta+Enter",
897
+ "aria-describedby": "support-chat-shortcut",
898
+ placeholder: "Wie k\xF6nnen wir Ihnen helfen?"
899
+ }
900
+ ),
901
+ /* @__PURE__ */ jsx3(
902
+ "button",
903
+ {
904
+ type: "submit",
905
+ className: "bg-accent text-accent-ink rounded-card p-3",
906
+ disabled: blocked || !input.trim(),
907
+ children: "Senden"
908
+ }
909
+ )
910
+ ] }),
911
+ /* @__PURE__ */ jsx3(Shortcut, { id: "support-chat-shortcut" }),
912
+ /* @__PURE__ */ jsx3(
913
+ VoiceInput,
538
914
  {
539
- id: "support-chat-input",
915
+ ctx,
540
916
  value: input,
541
- rows: 2,
542
- maxLength: 4e3,
917
+ setValue: setInput,
543
918
  disabled: current?.status === "closed",
544
- className: "flex-1 min-w-0 border border-line rounded-card bg-card p-3",
545
- onChange: (e) => setInput(e.target.value),
546
- placeholder: "Wie k\xF6nnen wir Ihnen helfen?"
547
- }
548
- ),
549
- /* @__PURE__ */ jsx(
550
- "button",
551
- {
552
- type: "submit",
553
- className: "bg-accent text-accent-ink rounded-card p-3",
554
- disabled: busy || !input.trim() || current?.status === "closed" || !!current?.messages.some((m) => !m.complete),
555
- children: "Senden"
919
+ token: auth?.token,
920
+ onBusy: setVoiceBusy
556
921
  }
557
922
  )
558
923
  ] }),
559
- ready && current && current.status !== "closed" && current.messages.some((m) => m.kind === "user") && /* @__PURE__ */ jsx(
924
+ ready && (!current || current.status === "bot") && /* @__PURE__ */ jsx3(
925
+ "button",
926
+ {
927
+ type: "button",
928
+ className: "border border-line rounded-card px-4 py-2 mt-3",
929
+ disabled: busy,
930
+ onClick: () => void requestHuman(),
931
+ children: "Ich m\xF6chte mit einer echten Person sprechen"
932
+ }
933
+ ),
934
+ ready && current && current.status !== "closed" && current.messages.some((m) => m.kind === "user") && /* @__PURE__ */ jsx3(
560
935
  "button",
561
936
  {
562
937
  type: "button",
@@ -566,7 +941,7 @@ function SupportChat(ctx) {
566
941
  children: "Problem gel\xF6st"
567
942
  }
568
943
  ),
569
- ready && current?.status === "closed" && /* @__PURE__ */ jsx(
944
+ ready && current?.status === "closed" && /* @__PURE__ */ jsx3(
570
945
  "button",
571
946
  {
572
947
  type: "button",
@@ -575,12 +950,12 @@ function SupportChat(ctx) {
575
950
  children: "Neuen Chat starten"
576
951
  }
577
952
  ),
578
- demo && current && /* @__PURE__ */ jsx(
953
+ demo && current && /* @__PURE__ */ jsx3(
579
954
  "div",
580
955
  {
581
956
  className: "flex flex-wrap gap-3 mt-3 text-[0.75rem]",
582
957
  "aria-label": "Support-Demo-Steuerung",
583
- children: current.status === "bot" ? /* @__PURE__ */ jsx(
958
+ children: current.status === "bot" || current.status === "waiting" ? /* @__PURE__ */ jsx3(
584
959
  "button",
585
960
  {
586
961
  type: "button",
@@ -588,8 +963,8 @@ function SupportChat(ctx) {
588
963
  onClick: () => void simulate("take"),
589
964
  children: "Lena \xFCbernimmt"
590
965
  }
591
- ) : current.status === "support" ? /* @__PURE__ */ jsxs(Fragment, { children: [
592
- /* @__PURE__ */ jsx(
966
+ ) : current.status === "support" ? /* @__PURE__ */ jsxs3(Fragment2, { children: [
967
+ /* @__PURE__ */ jsx3(
593
968
  "button",
594
969
  {
595
970
  type: "button",
@@ -598,7 +973,7 @@ function SupportChat(ctx) {
598
973
  children: "Lena antwortet"
599
974
  }
600
975
  ),
601
- /* @__PURE__ */ jsx(
976
+ /* @__PURE__ */ jsx3(
602
977
  "button",
603
978
  {
604
979
  type: "button",
@@ -607,7 +982,7 @@ function SupportChat(ctx) {
607
982
  children: "Bot freigeben"
608
983
  }
609
984
  ),
610
- /* @__PURE__ */ jsx(
985
+ /* @__PURE__ */ jsx3(
611
986
  "button",
612
987
  {
613
988
  type: "button",
@@ -619,7 +994,7 @@ function SupportChat(ctx) {
619
994
  ] }) : null
620
995
  }
621
996
  ),
622
- error && !demo && auth && ready && /* @__PURE__ */ jsx(
997
+ error && !demo && auth && ready && /* @__PURE__ */ jsx3(
623
998
  "button",
624
999
  {
625
1000
  type: "button",
@@ -632,27 +1007,27 @@ function SupportChat(ctx) {
632
1007
  children: anonymous ? "Chat neu verbinden" : ctx.config.chatSupport?.auth === "session" ? "Portal-Anmeldung erneuern" : "Microsoft-Anmeldung erneuern"
633
1008
  }
634
1009
  ),
635
- error && /* @__PURE__ */ jsx("p", { role: "alert", className: "text-danger text-[0.75rem] mt-3", children: error })
1010
+ error && /* @__PURE__ */ jsx3("p", { role: "alert", className: "text-danger text-[0.75rem] mt-3", children: error })
636
1011
  ] });
637
1012
  }
638
1013
 
639
1014
  // src/browser.tsx
640
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1015
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
641
1016
  function resizeInput(field) {
642
1017
  if (!field) return;
643
1018
  field.style.height = "auto";
644
1019
  field.style.height = `${field.scrollHeight + field.offsetHeight - field.clientHeight}px`;
645
1020
  }
646
1021
  function Chat(ctx) {
647
- return ctx.config.chatSupport ? /* @__PURE__ */ jsx2(SupportChat, { ...ctx }) : /* @__PURE__ */ jsx2(LegacyChat, { ...ctx });
1022
+ return ctx.config.chatSupport ? /* @__PURE__ */ jsx4(SupportChat, { ...ctx }) : /* @__PURE__ */ jsx4(LegacyChat, { ...ctx });
648
1023
  }
649
1024
  function LegacyChat(ctx) {
650
- const [messages, setMessages] = useState2([]);
651
- const [input, setInput] = useState2("");
652
- const [resolved, setResolved] = useState2(false);
653
- const inputField = useRef2(null);
654
- useLayoutEffect(() => resizeInput(inputField.current), [input]);
655
- useEffect2(() => {
1025
+ const [messages, setMessages] = useState4([]);
1026
+ const [input, setInput] = useState4("");
1027
+ const [resolved, setResolved] = useState4(false);
1028
+ const inputField = useRef4(null);
1029
+ useLayoutEffect2(() => resizeInput(inputField.current), [input]);
1030
+ useEffect4(() => {
656
1031
  const field = inputField.current;
657
1032
  if (!field) return;
658
1033
  let width = field.clientWidth;
@@ -665,15 +1040,19 @@ function LegacyChat(ctx) {
665
1040
  observer.observe(field);
666
1041
  return () => observer.disconnect();
667
1042
  }, []);
668
- const [busy, setBusy] = useState2(false);
669
- const [isMac, setIsMac] = useState2(false);
670
- useEffect2(() => setIsMac(/Mac/.test(navigator.platform)), []);
671
- const [error, setError] = useState2("");
672
- const abort = useRef2(null);
673
- useEffect2(() => () => abort.current?.abort(), []);
1043
+ const [busy, setBusy] = useState4(false);
1044
+ const [voiceBusy, setVoiceBusy] = useState4(false);
1045
+ const scroll = useChatScroll(messages);
1046
+ const sending = useRef4(false);
1047
+ const [error, setError] = useState4("");
1048
+ const abort = useRef4(null);
1049
+ useEffect4(() => () => abort.current?.abort(), []);
674
1050
  async function submit(e) {
675
1051
  e.preventDefault();
676
- if (!input.trim() || busy || resolved) return;
1052
+ if (!input.trim() || busy || voiceBusy || resolved || sending.current)
1053
+ return;
1054
+ sending.current = true;
1055
+ scroll.follow();
677
1056
  const history = [
678
1057
  ...messages.map(({ role, content }) => ({ role, content })),
679
1058
  { role: "user", content: input.trim() }
@@ -759,45 +1138,49 @@ function LegacyChat(ctx) {
759
1138
  setError(err instanceof Error ? err.message : "Chat nicht verf\xFCgbar.");
760
1139
  } finally {
761
1140
  setBusy(false);
1141
+ sending.current = false;
762
1142
  abort.current = null;
763
1143
  }
764
1144
  }
765
- return /* @__PURE__ */ jsxs2(
1145
+ return /* @__PURE__ */ jsxs4(
766
1146
  "div",
767
1147
  {
768
1148
  className: ctx.surface === "hero" ? "chat-layout block" : "chat-layout grid grid-cols-[1fr_1.7fr] gap-13.75 compact:grid-cols-[1fr] compact:gap-3.75",
769
1149
  children: [
770
- ctx.surface !== "hero" && /* @__PURE__ */ jsxs2("div", { className: "chat-intro [&_p]:text-[0.875rem] [&_p]:text-muted [&_p]:leading-[1.8] compact:max-w-[500px]", children: [
771
- /* @__PURE__ */ jsx2("h3", { children: "Eine Frage reicht." }),
772
- /* @__PURE__ */ jsx2("p", { children: "Unser Assistent kennt die Hilfe-Inhalte, das Team und die aktuellen Meldungen. F\xFCr dringende St\xF6rungen bleibt die Hotline der direkte Weg." }),
773
- /* @__PURE__ */ jsx2("p", { className: "data-state font-mono text-[0.625rem] text-muted m-[calc(var(--spacing)_*_3)_0_0] [&.warning]:text-warning", children: "\xD6ffentlicher Chat \xB7 Verlauf nur in dieser Sitzung" })
1150
+ ctx.surface !== "hero" && /* @__PURE__ */ jsxs4("div", { className: "chat-intro [&_p]:text-[0.875rem] [&_p]:text-muted [&_p]:leading-[1.8] compact:max-w-[500px]", children: [
1151
+ /* @__PURE__ */ jsx4("h3", { children: "Eine Frage reicht." }),
1152
+ /* @__PURE__ */ jsx4("p", { children: "Unser Assistent kennt die Hilfe-Inhalte, das Team und die aktuellen Meldungen. F\xFCr dringende St\xF6rungen bleibt die Hotline der direkte Weg." }),
1153
+ /* @__PURE__ */ jsx4("p", { className: "data-state font-mono text-[0.625rem] text-muted m-[calc(var(--spacing)_*_3)_0_0] [&.warning]:text-warning", children: "\xD6ffentlicher Chat \xB7 Verlauf nur in dieser Sitzung" })
774
1154
  ] }),
775
- /* @__PURE__ */ jsxs2("div", { className: "chat-panel text-ink compact:p-4 bg-card border border-line p-6 rounded-card", children: [
776
- /* @__PURE__ */ jsxs2(
1155
+ /* @__PURE__ */ jsxs4("div", { className: "chat-panel text-ink compact:p-4 bg-card border border-line p-6 rounded-card", children: [
1156
+ /* @__PURE__ */ jsxs4(
777
1157
  "div",
778
1158
  {
779
- className: "chat-history empty:hidden grid gap-3.75 max-h-[400px] overflow-auto mb-4",
1159
+ ref: scroll.history,
1160
+ onScroll: scroll.onScroll,
1161
+ className: "chat-history empty:hidden grid gap-3.75 max-h-[min(400px,45dvh)] overflow-auto overscroll-contain mb-4",
1162
+ "aria-label": "Gespr\xE4chsverlauf",
780
1163
  "aria-live": "polite",
781
1164
  "aria-busy": busy,
782
1165
  children: [
783
- !messages.length && ctx.surface !== "hero" && /* @__PURE__ */ jsx2("p", { className: "message assistant text-[0.8125rem] leading-[1.8] whitespace-pre-wrap [overflow-wrap:anywhere] [&.user]:bg-soft [&.user]:pt-3 [&.user]:pb-3 [&.user]:pl-4 [&.user]:pr-4 [&.user]:rounded-card [&.assistant]:[border-left:2px_solid_var(--accent)] [&.assistant]:pt-[0] [&.assistant]:pb-[0] [&.assistant]:pl-4 [&.assistant]:pr-4", children: "Wie kann ich Ihnen helfen?" }),
784
- messages.map((m, i) => /* @__PURE__ */ jsxs2(
1166
+ !messages.length && ctx.surface !== "hero" && /* @__PURE__ */ jsx4("p", { className: "message assistant text-[0.8125rem] leading-[1.8] whitespace-pre-wrap [overflow-wrap:anywhere] [&.user]:bg-soft [&.user]:pt-3 [&.user]:pb-3 [&.user]:pl-4 [&.user]:pr-4 [&.user]:rounded-card [&.assistant]:[border-left:2px_solid_var(--accent)] [&.assistant]:pt-[0] [&.assistant]:pb-[0] [&.assistant]:pl-4 [&.assistant]:pr-4", children: "Wie kann ich Ihnen helfen?" }),
1167
+ messages.map((m, i) => /* @__PURE__ */ jsxs4(
785
1168
  "div",
786
1169
  {
787
1170
  className: "message text-[0.8125rem] leading-[1.8] whitespace-pre-wrap [overflow-wrap:anywhere] [&.user]:bg-soft [&.user]:pt-3 [&.user]:pb-3 [&.user]:pl-4 [&.user]:pr-4 [&.user]:rounded-card [&.assistant]:[border-left:2px_solid_var(--accent)] [&.assistant]:pt-[0] [&.assistant]:pb-[0] [&.assistant]:pl-4 [&.assistant]:pr-4 " + m.role,
788
1171
  children: [
789
- m.role === "user" ? /* @__PURE__ */ jsx2("span", { className: "sr-only", children: "Sie: " }) : /* @__PURE__ */ jsx2("span", { className: "block text-[0.6875rem] text-muted mb-1", children: m.responder?.role === "on-duty" && m.responder.name ? `Digitaler Assistent von ${m.responder.name}` : "Digitaler Assistent des Service-Teams" }),
1172
+ m.role === "user" ? /* @__PURE__ */ jsx4("span", { className: "sr-only", children: "Sie: " }) : /* @__PURE__ */ jsx4("span", { className: "block text-[0.6875rem] text-muted mb-1", children: m.responder?.role === "on-duty" && m.responder.name ? `Digitaler Assistent von ${m.responder.name}` : "Digitaler Assistent des Service-Teams" }),
790
1173
  m.role === "user" ? m.content : m.content ? answerParts(m.content, m.sources ?? [], !m.complete).map(
791
- (part, index) => part.href ? /* @__PURE__ */ jsx2("a", { href: part.href, children: part.text }, index) : /* @__PURE__ */ jsx2("span", { children: part.text }, index)
1174
+ (part, index) => part.href ? /* @__PURE__ */ jsx4("a", { href: part.href, children: part.text }, index) : /* @__PURE__ */ jsx4("span", { children: part.text }, index)
792
1175
  ) : "Antwort wird vorbereitet \u2026",
793
- m.role === "assistant" && !!m.sources?.length && /* @__PURE__ */ jsxs2("div", { className: "sources text-[0.6875rem] text-muted mt-3 mb-1 [&_a]:mr-3", children: [
1176
+ m.role === "assistant" && !!m.sources?.length && /* @__PURE__ */ jsxs4("div", { className: "sources text-[0.6875rem] text-muted mt-3 mb-1 [&_a]:mr-3", children: [
794
1177
  "Referenzquellen:",
795
1178
  " ",
796
1179
  m.sources.map(
797
- (source) => sourceHref(source.href) ? /* @__PURE__ */ jsxs2("a", { href: sourceHref(source.href), children: [
1180
+ (source) => sourceHref(source.href) ? /* @__PURE__ */ jsxs4("a", { href: sourceHref(source.href), children: [
798
1181
  source.title,
799
1182
  source.stale ? " (veraltet)" : ""
800
- ] }, source.id) : /* @__PURE__ */ jsxs2("span", { children: [
1183
+ ] }, source.id) : /* @__PURE__ */ jsxs4("span", { children: [
801
1184
  source.title,
802
1185
  " "
803
1186
  ] }, source.id)
@@ -810,56 +1193,41 @@ function LegacyChat(ctx) {
810
1193
  ]
811
1194
  }
812
1195
  ),
813
- /* @__PURE__ */ jsxs2(
1196
+ /* @__PURE__ */ jsx4(NewMessages, { visible: scroll.unread, follow: scroll.follow }),
1197
+ /* @__PURE__ */ jsxs4(
814
1198
  "form",
815
1199
  {
816
1200
  className: "chat-form flex gap-2.5 items-end [&_textarea]:resize-none [&_textarea]:min-h-[50px] [&_textarea]:[flex:1] [&_textarea]:min-w-0 [&_button]:bg-accent [&_button]:text-accent-ink [&_button]:[border:0] [&_button]:w-[50px] [&_button]:h-[50px] [&_button]:rounded-card [&_button]:text-[1.25rem] [&_button:disabled]:opacity-[0.5]",
817
1201
  onSubmit: submit,
818
1202
  children: [
819
- /* @__PURE__ */ jsx2("label", { className: "sr-only", htmlFor: "chat-input", children: "Ihre Frage" }),
820
- /* @__PURE__ */ jsxs2("div", { className: "relative flex-1 min-w-0", children: [
821
- /* @__PURE__ */ jsx2(
1203
+ /* @__PURE__ */ jsx4("label", { className: "sr-only", htmlFor: "chat-input", children: "Ihre Frage" }),
1204
+ /* @__PURE__ */ jsxs4("div", { className: "relative flex-1 min-w-0", children: [
1205
+ /* @__PURE__ */ jsx4(
822
1206
  "textarea",
823
1207
  {
824
1208
  id: "chat-input",
825
1209
  ref: inputField,
826
1210
  rows: 1,
827
1211
  autoFocus: true,
828
- className: "search bg-card border border-line rounded-card pt-3.5 pb-7 px-4.5 text-ink w-full block overflow-hidden",
1212
+ className: "search bg-card border border-line rounded-card pt-3.5 pb-7 px-4.5 text-ink w-full block max-h-[180px] overflow-auto",
829
1213
  value: input,
830
1214
  disabled: resolved,
831
1215
  maxLength: 4e3,
832
1216
  onChange: (e) => setInput(e.target.value),
833
- onKeyDown: (e) => {
834
- if (e.key === "Enter" && (e.ctrlKey || e.metaKey) && !e.nativeEvent.isComposing && !e.repeat) {
835
- e.preventDefault();
836
- if (!busy && input.trim())
837
- e.currentTarget.form?.requestSubmit();
838
- }
839
- },
1217
+ onKeyDown: (e) => sendShortcut(e, busy || voiceBusy || resolved || !input.trim()),
840
1218
  "aria-keyshortcuts": "Control+Enter Meta+Enter",
841
1219
  "aria-describedby": "chat-shortcut",
842
1220
  placeholder: "Ihre Frage an unser Team \u2026",
843
1221
  required: true
844
1222
  }
845
1223
  ),
846
- /* @__PURE__ */ jsxs2(
847
- "span",
848
- {
849
- id: "chat-shortcut",
850
- className: "absolute right-4.5 bottom-2 text-[0.625rem] text-muted pointer-events-none",
851
- children: [
852
- isMac ? "\u2318" : "Strg",
853
- " + Enter zum Senden"
854
- ]
855
- }
856
- )
1224
+ /* @__PURE__ */ jsx4(Shortcut, { id: "chat-shortcut" })
857
1225
  ] }),
858
- /* @__PURE__ */ jsx2(
1226
+ /* @__PURE__ */ jsx4(
859
1227
  "button",
860
1228
  {
861
1229
  type: "submit",
862
- disabled: busy || resolved || !input.trim(),
1230
+ disabled: busy || voiceBusy || resolved || !input.trim(),
863
1231
  "aria-label": "Frage senden",
864
1232
  children: "\u2191"
865
1233
  }
@@ -867,8 +1235,18 @@ function LegacyChat(ctx) {
867
1235
  ]
868
1236
  }
869
1237
  ),
870
- ctx.surface === "hero" && /* @__PURE__ */ jsx2("p", { className: "data-state font-mono text-[0.625rem] text-muted mt-3 mb-0", children: "Digitaler Assistent \xB7 Ihre Frage wird automatisch beantwortet. F\xFCr pers\xF6nlichen Kontakt nutzen Sie die Hotline." }),
871
- messages.length > 0 && !resolved && /* @__PURE__ */ jsx2(
1238
+ /* @__PURE__ */ jsx4(
1239
+ VoiceInput,
1240
+ {
1241
+ ctx,
1242
+ value: input,
1243
+ setValue: setInput,
1244
+ disabled: resolved,
1245
+ onBusy: setVoiceBusy
1246
+ }
1247
+ ),
1248
+ ctx.surface === "hero" && /* @__PURE__ */ jsx4("p", { className: "data-state font-mono text-[0.625rem] text-muted mt-3 mb-0", children: "Digitaler Assistent \xB7 Ihre Frage wird automatisch beantwortet. F\xFCr pers\xF6nlichen Kontakt nutzen Sie die Hotline." }),
1249
+ messages.length > 0 && !resolved && /* @__PURE__ */ jsx4(
872
1250
  "button",
873
1251
  {
874
1252
  type: "button",
@@ -891,15 +1269,16 @@ function LegacyChat(ctx) {
891
1269
  children: "Problem gel\xF6st"
892
1270
  }
893
1271
  ),
894
- resolved && /* @__PURE__ */ jsxs2("div", { className: "mt-3", children: [
895
- /* @__PURE__ */ jsx2("p", { role: "status", children: "Problem gel\xF6st" }),
896
- /* @__PURE__ */ jsx2(
1272
+ resolved && /* @__PURE__ */ jsxs4("div", { className: "mt-3", children: [
1273
+ /* @__PURE__ */ jsx4("p", { role: "status", children: "Problem gel\xF6st" }),
1274
+ /* @__PURE__ */ jsx4(
897
1275
  "button",
898
1276
  {
899
1277
  type: "button",
900
1278
  className: "underline mt-2",
901
1279
  disabled: busy,
902
1280
  onClick: () => {
1281
+ scroll.follow();
903
1282
  setMessages([]);
904
1283
  setResolved(false);
905
1284
  setInput("");
@@ -909,7 +1288,7 @@ function LegacyChat(ctx) {
909
1288
  }
910
1289
  )
911
1290
  ] }),
912
- error && /* @__PURE__ */ jsx2(
1291
+ error && /* @__PURE__ */ jsx4(
913
1292
  "p",
914
1293
  {
915
1294
  className: "chat-error text-danger dark:text-warning text-[0.75rem]",
@@ -1,7 +1,7 @@
1
1
  // package.json
2
2
  var cspPlugin = {
3
3
  id: "chat",
4
- sdkVersion: "^0.11.0",
4
+ sdkVersion: "^0.12.1",
5
5
  browser: "./browser",
6
6
  server: "./server"
7
7
  };
package/dist/server.d.ts CHANGED
@@ -7,6 +7,8 @@ declare const configSchema: z.ZodEffects<z.ZodObject<{
7
7
  CSP_CHAT_DEMO: z.ZodOptional<z.ZodEnum<["true", "false"]>>;
8
8
  CSP_CHAT_PROVIDER: z.ZodDefault<z.ZodEnum<["openai", "azure", "ollama"]>>;
9
9
  CSP_CHAT_MODEL: z.ZodOptional<z.ZodString>;
10
+ CSP_CHAT_TRANSCRIPTION_ENABLED: z.ZodDefault<z.ZodEnum<["true", "false"]>>;
11
+ CSP_CHAT_TRANSCRIPTION_MODEL: z.ZodDefault<z.ZodString>;
10
12
  CSP_CHAT_OPENAI_API_KEY: z.ZodOptional<z.ZodString>;
11
13
  CSP_CHAT_OPENAI_URL: z.ZodOptional<z.ZodString>;
12
14
  CSP_CHAT_AZURE_API_KEY: z.ZodOptional<z.ZodString>;
@@ -19,6 +21,8 @@ declare const configSchema: z.ZodEffects<z.ZodObject<{
19
21
  CSP_CHAT_CONTEXT_CHARS: z.ZodDefault<z.ZodNumber>;
20
22
  }, "strip", z.ZodTypeAny, {
21
23
  CSP_CHAT_PROVIDER: "openai" | "azure" | "ollama";
24
+ CSP_CHAT_TRANSCRIPTION_ENABLED: "true" | "false";
25
+ CSP_CHAT_TRANSCRIPTION_MODEL: string;
22
26
  CSP_CHAT_RATE_LIMIT: number;
23
27
  CSP_CHAT_CONCURRENCY: number;
24
28
  CSP_CHAT_MAX_TOKENS: number;
@@ -37,6 +41,8 @@ declare const configSchema: z.ZodEffects<z.ZodObject<{
37
41
  CSP_CHAT_DEMO?: "true" | "false" | undefined;
38
42
  CSP_CHAT_PROVIDER?: "openai" | "azure" | "ollama" | undefined;
39
43
  CSP_CHAT_MODEL?: string | undefined;
44
+ CSP_CHAT_TRANSCRIPTION_ENABLED?: "true" | "false" | undefined;
45
+ CSP_CHAT_TRANSCRIPTION_MODEL?: string | undefined;
40
46
  CSP_CHAT_OPENAI_API_KEY?: string | undefined;
41
47
  CSP_CHAT_OPENAI_URL?: string | undefined;
42
48
  CSP_CHAT_AZURE_API_KEY?: string | undefined;
@@ -49,6 +55,8 @@ declare const configSchema: z.ZodEffects<z.ZodObject<{
49
55
  CSP_CHAT_CONTEXT_CHARS?: number | undefined;
50
56
  }>, {
51
57
  CSP_CHAT_PROVIDER: "openai" | "azure" | "ollama";
58
+ CSP_CHAT_TRANSCRIPTION_ENABLED: "true" | "false";
59
+ CSP_CHAT_TRANSCRIPTION_MODEL: string;
52
60
  CSP_CHAT_RATE_LIMIT: number;
53
61
  CSP_CHAT_CONCURRENCY: number;
54
62
  CSP_CHAT_MAX_TOKENS: number;
@@ -67,6 +75,8 @@ declare const configSchema: z.ZodEffects<z.ZodObject<{
67
75
  CSP_CHAT_DEMO?: "true" | "false" | undefined;
68
76
  CSP_CHAT_PROVIDER?: "openai" | "azure" | "ollama" | undefined;
69
77
  CSP_CHAT_MODEL?: string | undefined;
78
+ CSP_CHAT_TRANSCRIPTION_ENABLED?: "true" | "false" | undefined;
79
+ CSP_CHAT_TRANSCRIPTION_MODEL?: string | undefined;
70
80
  CSP_CHAT_OPENAI_API_KEY?: string | undefined;
71
81
  CSP_CHAT_OPENAI_URL?: string | undefined;
72
82
  CSP_CHAT_AZURE_API_KEY?: string | undefined;
@@ -88,6 +98,8 @@ declare const _default: {
88
98
  CSP_CHAT_DEMO: z.ZodOptional<z.ZodEnum<["true", "false"]>>;
89
99
  CSP_CHAT_PROVIDER: z.ZodDefault<z.ZodEnum<["openai", "azure", "ollama"]>>;
90
100
  CSP_CHAT_MODEL: z.ZodOptional<z.ZodString>;
101
+ CSP_CHAT_TRANSCRIPTION_ENABLED: z.ZodDefault<z.ZodEnum<["true", "false"]>>;
102
+ CSP_CHAT_TRANSCRIPTION_MODEL: z.ZodDefault<z.ZodString>;
91
103
  CSP_CHAT_OPENAI_API_KEY: z.ZodOptional<z.ZodString>;
92
104
  CSP_CHAT_OPENAI_URL: z.ZodOptional<z.ZodString>;
93
105
  CSP_CHAT_AZURE_API_KEY: z.ZodOptional<z.ZodString>;
@@ -100,6 +112,8 @@ declare const _default: {
100
112
  CSP_CHAT_CONTEXT_CHARS: z.ZodDefault<z.ZodNumber>;
101
113
  }, "strip", z.ZodTypeAny, {
102
114
  CSP_CHAT_PROVIDER: "openai" | "azure" | "ollama";
115
+ CSP_CHAT_TRANSCRIPTION_ENABLED: "true" | "false";
116
+ CSP_CHAT_TRANSCRIPTION_MODEL: string;
103
117
  CSP_CHAT_RATE_LIMIT: number;
104
118
  CSP_CHAT_CONCURRENCY: number;
105
119
  CSP_CHAT_MAX_TOKENS: number;
@@ -118,6 +132,8 @@ declare const _default: {
118
132
  CSP_CHAT_DEMO?: "true" | "false" | undefined;
119
133
  CSP_CHAT_PROVIDER?: "openai" | "azure" | "ollama" | undefined;
120
134
  CSP_CHAT_MODEL?: string | undefined;
135
+ CSP_CHAT_TRANSCRIPTION_ENABLED?: "true" | "false" | undefined;
136
+ CSP_CHAT_TRANSCRIPTION_MODEL?: string | undefined;
121
137
  CSP_CHAT_OPENAI_API_KEY?: string | undefined;
122
138
  CSP_CHAT_OPENAI_URL?: string | undefined;
123
139
  CSP_CHAT_AZURE_API_KEY?: string | undefined;
@@ -130,6 +146,8 @@ declare const _default: {
130
146
  CSP_CHAT_CONTEXT_CHARS?: number | undefined;
131
147
  }>, {
132
148
  CSP_CHAT_PROVIDER: "openai" | "azure" | "ollama";
149
+ CSP_CHAT_TRANSCRIPTION_ENABLED: "true" | "false";
150
+ CSP_CHAT_TRANSCRIPTION_MODEL: string;
133
151
  CSP_CHAT_RATE_LIMIT: number;
134
152
  CSP_CHAT_CONCURRENCY: number;
135
153
  CSP_CHAT_MAX_TOKENS: number;
@@ -148,6 +166,8 @@ declare const _default: {
148
166
  CSP_CHAT_DEMO?: "true" | "false" | undefined;
149
167
  CSP_CHAT_PROVIDER?: "openai" | "azure" | "ollama" | undefined;
150
168
  CSP_CHAT_MODEL?: string | undefined;
169
+ CSP_CHAT_TRANSCRIPTION_ENABLED?: "true" | "false" | undefined;
170
+ CSP_CHAT_TRANSCRIPTION_MODEL?: string | undefined;
151
171
  CSP_CHAT_OPENAI_API_KEY?: string | undefined;
152
172
  CSP_CHAT_OPENAI_URL?: string | undefined;
153
173
  CSP_CHAT_AZURE_API_KEY?: string | undefined;
package/dist/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  cspPlugin
3
- } from "./chunk-W5MUDSVD.js";
3
+ } from "./chunk-U2H5BQCV.js";
4
4
 
5
5
  // src/server.ts
6
6
  import { z as z2 } from "zod";
@@ -194,12 +194,138 @@ Bieten Sie bei erforderlicher Eskalation belegte pers\xF6nliche Kontaktm\xF6glic
194
194
  Nur vorhandene Quellen-IDs zitieren, keine URLs oder technischen Ticketbezeichnungen erfinden. Kein Markdown-Link, kein HTML. Keine Aktionen ausf\xFChren, keine Ticketanlage oder pers\xF6nliche Pr\xFCfung behaupten. Notf\xE4lle: Hotline ${JSON.stringify(phone)}. Automatische Antwort, kein menschlicher Live-Chat.`;
195
195
  }
196
196
 
197
+ // src/transcription.ts
198
+ import OpenAI2, { toFile } from "openai";
199
+ var limit = 10 * 1024 * 1024;
200
+ var formats = {
201
+ "audio/webm": "webm",
202
+ "audio/mp4": "mp4",
203
+ "video/mp4": "mp4",
204
+ "audio/ogg": "ogg",
205
+ "audio/wav": "wav",
206
+ "audio/mpeg": "mp3"
207
+ };
208
+ function validAudio(bytes, format) {
209
+ if (format === "webm")
210
+ return bytes.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]));
211
+ if (format === "mp4") return bytes.subarray(4, 8).toString() === "ftyp";
212
+ if (format === "ogg") return bytes.subarray(0, 4).toString() === "OggS";
213
+ if (format === "wav")
214
+ return bytes.subarray(0, 4).toString() === "RIFF" && bytes.subarray(8, 12).toString() === "WAVE";
215
+ return bytes.subarray(0, 3).toString() === "ID3" || bytes[0] === 255 && (bytes[1] & 224) === 224;
216
+ }
217
+ function setupTranscription(ctx, config, demo) {
218
+ const enabled = !demo && config.CSP_CHAT_TRANSCRIPTION_ENABLED === "true" && config.CSP_CHAT_PROVIDER === "openai" && Boolean(config.CSP_CHAT_OPENAI_API_KEY);
219
+ ctx.app.get("/api/v1/chat/capabilities", async () => ({
220
+ transcription: enabled,
221
+ maxRecordingSeconds: 60,
222
+ maxAudioBytes: limit
223
+ }));
224
+ const client = enabled ? new OpenAI2({
225
+ apiKey: config.CSP_CHAT_OPENAI_API_KEY,
226
+ baseURL: config.CSP_CHAT_OPENAI_URL,
227
+ fetch: ctx.fetch,
228
+ maxRetries: 0
229
+ }) : void 0;
230
+ const windows = /* @__PURE__ */ new Map();
231
+ let active = 0;
232
+ const reservations = /* @__PURE__ */ new WeakSet();
233
+ const release = (req) => {
234
+ if (reservations.delete(req)) active--;
235
+ };
236
+ const controllers = /* @__PURE__ */ new Set();
237
+ ctx.app.addHook("preClose", async () => {
238
+ for (const controller of controllers) controller.abort();
239
+ });
240
+ ctx.app.register(async (scope) => {
241
+ scope.addHook("onResponse", async (req) => release(req));
242
+ scope.addHook("onRequestAbort", async (req) => release(req));
243
+ scope.addContentTypeParser(
244
+ Object.keys(formats),
245
+ { parseAs: "buffer", bodyLimit: limit },
246
+ (_req, body, done) => done(null, body)
247
+ );
248
+ scope.post(
249
+ "/api/v1/chat/transcriptions",
250
+ {
251
+ bodyLimit: limit,
252
+ onRequest: async (req, reply) => {
253
+ if (!enabled)
254
+ return reply.code(404).send({ error: "Spracheingabe ist nicht verf\xFCgbar." });
255
+ const identity = ctx.config.chatSupport ? await ctx.authenticateChat?.(req.headers.authorization) : void 0;
256
+ if (ctx.config.chatSupport && !identity)
257
+ return reply.code(401).send({ error: "Chat-Anmeldung erforderlich." });
258
+ const now = Date.now();
259
+ for (const [key, value] of windows)
260
+ if (value.until <= now) windows.delete(key);
261
+ const keys = [
262
+ "ip:" + req.ip,
263
+ ...identity ? ["user:" + identity.tenantId + ":" + identity.userId] : []
264
+ ];
265
+ if (active >= config.CSP_CHAT_CONCURRENCY || windows.size >= 1e4 || keys.some(
266
+ (key) => (windows.get(key)?.count ?? 0) >= config.CSP_CHAT_RATE_LIMIT
267
+ ))
268
+ return reply.code(429).header("Retry-After", "60").send({ error: "Bitte warten Sie eine Minute." });
269
+ active++;
270
+ reservations.add(req);
271
+ for (const key of keys) {
272
+ const value = windows.get(key) ?? { count: 0, until: now + 6e4 };
273
+ value.count++;
274
+ windows.set(key, value);
275
+ }
276
+ }
277
+ },
278
+ async (req, reply) => {
279
+ const mime = req.headers["content-type"]?.split(";")[0].trim() ?? "", format = formats[mime], bytes = req.body;
280
+ if (!format || !Buffer.isBuffer(bytes) || !bytes.length || !validAudio(bytes, format))
281
+ return reply.code(400).send({ error: "Ung\xFCltige Audioaufnahme." });
282
+ const controller = new AbortController();
283
+ controllers.add(controller);
284
+ const timeout = setTimeout(
285
+ () => controller.abort(),
286
+ config.CSP_CHAT_TIMEOUT_MS
287
+ ), cancel = () => controller.abort();
288
+ reply.raw.on("close", cancel);
289
+ try {
290
+ const result = await client.audio.transcriptions.create(
291
+ {
292
+ file: await toFile(bytes, "recording." + format, { type: mime }),
293
+ model: config.CSP_CHAT_TRANSCRIPTION_MODEL,
294
+ response_format: "json"
295
+ },
296
+ { signal: controller.signal }
297
+ );
298
+ if (typeof result.text !== "string" || !result.text.trim())
299
+ return reply.code(422).send({
300
+ error: "Keine Sprache erkannt. Bitte versuchen Sie es erneut."
301
+ });
302
+ if (result.text.length > 4e3)
303
+ return reply.code(422).send({
304
+ error: "Die Aufnahme enth\xE4lt zu viel Text. Bitte k\xFCrzer diktieren."
305
+ });
306
+ return { text: result.text.trim() };
307
+ } catch {
308
+ return reply.code(controller.signal.aborted ? 504 : 503).send({
309
+ error: "Spracheingabe derzeit nicht verf\xFCgbar. Ihr Entwurf bleibt erhalten."
310
+ });
311
+ } finally {
312
+ clearTimeout(timeout);
313
+ reply.raw.off("close", cancel);
314
+ controllers.delete(controller);
315
+ }
316
+ }
317
+ );
318
+ });
319
+ }
320
+
197
321
  // src/server.ts
198
322
  var configSchema = z2.object({
199
323
  CSP_DEMO: z2.string().optional(),
200
324
  CSP_CHAT_DEMO: z2.enum(["true", "false"]).optional(),
201
325
  CSP_CHAT_PROVIDER: z2.enum(["openai", "azure", "ollama"]).default("ollama"),
202
326
  CSP_CHAT_MODEL: z2.string().optional(),
327
+ CSP_CHAT_TRANSCRIPTION_ENABLED: z2.enum(["true", "false"]).default("true"),
328
+ CSP_CHAT_TRANSCRIPTION_MODEL: z2.string().min(1).max(100).default("gpt-4o-mini-transcribe"),
203
329
  CSP_CHAT_OPENAI_API_KEY: z2.string().optional(),
204
330
  CSP_CHAT_OPENAI_URL: z2.string().url().optional(),
205
331
  CSP_CHAT_AZURE_API_KEY: z2.string().optional(),
@@ -286,6 +412,7 @@ var server_default = {
286
412
  setup(ctx) {
287
413
  const c = configSchema.parse(ctx.env);
288
414
  const demo = c.CSP_CHAT_DEMO === "true" || c.CSP_CHAT_DEMO !== "false" && ctx.demo;
415
+ setupTranscription(ctx, c, demo);
289
416
  const provider = demo ? void 0 : createProvider(
290
417
  {
291
418
  ...ctx.env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kieksme/csp-plugin-chat",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "dependencies": {
25
25
  "@azure/msal-browser": "^5.25.0",
26
- "@kieksme/csp-sdk": "^0.11.0",
26
+ "@kieksme/csp-sdk": "^0.12.1",
27
27
  "openai": "^6.3.0",
28
28
  "zod": "^3.25.76"
29
29
  },
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "cspPlugin": {
37
37
  "id": "chat",
38
- "sdkVersion": "^0.11.0",
38
+ "sdkVersion": "^0.12.1",
39
39
  "browser": "./browser",
40
40
  "server": "./server"
41
41
  },