mirror of
https://github.com/Since-3/since3-portal.git
synced 2026-10-12 19:51:17 +00:00
294 lines
13 KiB
TypeScript
294 lines
13 KiB
TypeScript
"use client"
|
||
|
||
import { motion } from "motion/react"
|
||
import { useEffect, useRef, useState, useSyncExternalStore } from "react"
|
||
import { ArrowUpIcon, PhoneIcon, PhoneOffIcon } from "lucide-react"
|
||
import { cn } from "@/lib/utils"
|
||
import type { DemoInfo } from "../demo-info"
|
||
import type { AiAccess } from "../demos"
|
||
import { BookingCard } from "./booking-card"
|
||
import { useAiChat } from "./use-ai-chat"
|
||
|
||
// Web Speech API: nicht in allen TS-DOM-Typen enthalten, daher minimal selbst beschrieben
|
||
type RecognitionEvent = { resultIndex: number; results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }> }
|
||
type Recognition = {
|
||
lang: string
|
||
interimResults: boolean
|
||
continuous: boolean
|
||
start(): void
|
||
abort(): void
|
||
onresult: ((event: RecognitionEvent) => void) | null
|
||
onerror: ((event: { error: string }) => void) | null
|
||
onend: (() => void) | null
|
||
}
|
||
type RecognitionCtor = new () => Recognition
|
||
|
||
function recognitionCtor(): RecognitionCtor | null {
|
||
if (typeof window === "undefined") return null
|
||
const speech = window as unknown as { SpeechRecognition?: RecognitionCtor; webkitSpeechRecognition?: RecognitionCtor }
|
||
return speech.SpeechRecognition ?? speech.webkitSpeechRecognition ?? null
|
||
}
|
||
|
||
const subscribe = () => () => {}
|
||
const EXAMPLES = ["Haben Sie heute geöffnet?", "Ich möchte einen Termin vereinbaren", "Was kostet das bei Ihnen?"]
|
||
|
||
type CallState = "idle" | "speaking" | "listening" | "thinking" | "ended"
|
||
|
||
const statusLabel: Record<CallState, string> = {
|
||
idle: "Bereit für Ihren Anruf",
|
||
speaking: "Spricht …",
|
||
listening: "Hört zu …",
|
||
thinking: "Denkt nach …",
|
||
ended: "Anruf beendet",
|
||
}
|
||
|
||
export function PhoneDemo({ access, info }: { access: AiAccess; info: DemoInfo }) {
|
||
const [call, setCall] = useState<CallState>("idle")
|
||
const [heard, setHeard] = useState("")
|
||
const [draft, setDraft] = useState("")
|
||
const [seconds, setSeconds] = useState(0)
|
||
const [micError, setMicError] = useState<string | null>(null)
|
||
const active = useRef(false)
|
||
const voice = useRef<SpeechSynthesisVoice | null>(null)
|
||
const recognition = useRef<Recognition | null>(null)
|
||
const canListen = useSyncExternalStore(subscribe, () => recognitionCtor() !== null, () => true)
|
||
const chat = useAiChat({ access, mode: "phone", onReply: speak, onError: () => listen() })
|
||
const live = call !== "idle" && call !== "ended"
|
||
|
||
useEffect(() => {
|
||
const synth = typeof window !== "undefined" ? window.speechSynthesis : undefined
|
||
const pick = () => {
|
||
const german = (synth?.getVoices() ?? []).filter((v) => v.lang.toLowerCase().startsWith("de"))
|
||
voice.current = german.find((v) => /google|anna|petra|helena|vicki|marlene|katja/i.test(v.name)) ?? german[0] ?? null
|
||
}
|
||
pick()
|
||
synth?.addEventListener("voiceschanged", pick)
|
||
return () => {
|
||
synth?.removeEventListener("voiceschanged", pick)
|
||
active.current = false
|
||
recognition.current?.abort()
|
||
synth?.cancel()
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (!live) return
|
||
const timer = setInterval(() => setSeconds((s) => s + 1), 1000)
|
||
return () => clearInterval(timer)
|
||
}, [live])
|
||
|
||
function speak(text: string) {
|
||
if (!active.current) return
|
||
const synth = window.speechSynthesis
|
||
if (!text || !synth) return listen()
|
||
|
||
synth.cancel()
|
||
const utterance = new SpeechSynthesisUtterance(text)
|
||
utterance.lang = "de-DE"
|
||
utterance.rate = 1.04
|
||
if (voice.current) utterance.voice = voice.current
|
||
utterance.onend = () => listen()
|
||
setCall("speaking")
|
||
synth.speak(utterance)
|
||
}
|
||
|
||
function listen() {
|
||
if (!active.current) return
|
||
setHeard("")
|
||
const Ctor = recognitionCtor()
|
||
if (!Ctor || micError) {
|
||
setCall("listening")
|
||
return
|
||
}
|
||
|
||
const rec = new Ctor()
|
||
rec.lang = "de-DE"
|
||
rec.interimResults = true
|
||
rec.continuous = false
|
||
let finalText = ""
|
||
let blocked = false
|
||
|
||
rec.onresult = (event) => {
|
||
let interim = ""
|
||
for (let i = event.resultIndex; i < event.results.length; i++) {
|
||
const result = event.results[i]
|
||
if (result.isFinal) finalText += result[0].transcript
|
||
else interim += result[0].transcript
|
||
}
|
||
setHeard(`${finalText} ${interim}`.trim())
|
||
}
|
||
rec.onerror = (event) => {
|
||
if (event.error === "not-allowed" || event.error === "service-not-allowed") {
|
||
blocked = true
|
||
setMicError("Kein Mikrofonzugriff – bitte erlauben oder unten tippen.")
|
||
}
|
||
}
|
||
rec.onend = () => {
|
||
recognition.current = null
|
||
if (!active.current) return
|
||
if (finalText.trim()) {
|
||
setHeard("")
|
||
setCall("thinking")
|
||
void chat.send(finalText)
|
||
} else if (blocked) {
|
||
setCall("listening")
|
||
} else {
|
||
listen()
|
||
}
|
||
}
|
||
|
||
recognition.current = rec
|
||
setCall("listening")
|
||
rec.start()
|
||
}
|
||
|
||
function start() {
|
||
active.current = true
|
||
setSeconds(0)
|
||
setMicError(null)
|
||
chat.reset()
|
||
speak(info.greeting)
|
||
}
|
||
|
||
function hangUp() {
|
||
active.current = false
|
||
recognition.current?.abort()
|
||
window.speechSynthesis?.cancel()
|
||
setCall("ended")
|
||
setHeard("")
|
||
}
|
||
|
||
function sendText(text: string) {
|
||
if (!text.trim() || chat.pending) return
|
||
if (!active.current) {
|
||
active.current = true
|
||
setSeconds(0)
|
||
}
|
||
recognition.current?.abort()
|
||
setDraft("")
|
||
setCall("thinking")
|
||
void chat.send(text)
|
||
}
|
||
|
||
const time = `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`
|
||
|
||
return (
|
||
<div className="grid gap-6 lg:grid-cols-[minmax(0,400px)_minmax(0,1fr)]">
|
||
<section className="flex flex-col items-center rounded-[2rem] border border-white/10 bg-white/[0.03] px-6 py-10 text-center">
|
||
<p className="text-xs uppercase tracking-[0.25em] text-zinc-400">{live ? "Verbunden" : "KI-Telefon"}</p>
|
||
<div className="relative mt-10 grid size-52 place-items-center">
|
||
{[0, 1, 2].map((ring) => (
|
||
<motion.span
|
||
key={ring}
|
||
className="absolute inset-0 rounded-full border border-t-accent"
|
||
animate={call === "speaking" ? { scale: [1, 1.45], opacity: [0.5, 0] } : { scale: 1, opacity: live ? 0.25 : 0.1 }}
|
||
transition={call === "speaking" ? { duration: 1.8, repeat: Infinity, delay: ring * 0.6, ease: "easeOut" } : { duration: 0.4 }}
|
||
/>
|
||
))}
|
||
<div className="relative grid size-36 place-items-center rounded-full bg-t-accent text-5xl font-semibold text-t-accent-fg shadow-[0_0_90px_-10px_var(--t-accent)]">
|
||
{info.brand.charAt(0).toUpperCase()}
|
||
</div>
|
||
</div>
|
||
<p className="mt-8 text-xl font-semibold">{info.assistantName}</p>
|
||
<p className="text-sm text-zinc-400">{info.brand}</p>
|
||
<p className="mt-4 flex h-6 items-center gap-2 text-sm text-t-accent">
|
||
{statusLabel[call]}
|
||
{live && <span className="font-mono text-zinc-400">{time}</span>}
|
||
</p>
|
||
<div className="mt-3 flex h-8 items-end gap-1" aria-hidden>
|
||
{call === "listening" &&
|
||
[0, 1, 2, 3, 4].map((bar) => (
|
||
<motion.span
|
||
key={bar}
|
||
className="w-1.5 rounded-full bg-t-accent"
|
||
animate={{ height: [8, 26, 12, 30, 8] }}
|
||
transition={{ duration: 1.1, repeat: Infinity, delay: bar * 0.12 }}
|
||
/>
|
||
))}
|
||
</div>
|
||
<div className="mt-6">
|
||
{live ? (
|
||
<button type="button" onClick={hangUp} aria-label="Auflegen" className="grid size-16 cursor-pointer place-items-center rounded-full bg-red-500 text-white transition-transform hover:scale-105">
|
||
<PhoneOffIcon className="size-6" />
|
||
</button>
|
||
) : (
|
||
<button type="button" onClick={start} aria-label="Anrufen" className="relative grid size-16 cursor-pointer place-items-center rounded-full bg-emerald-500 text-white transition-transform hover:scale-105">
|
||
<span className="absolute inset-0 animate-ping rounded-full bg-emerald-500/40" />
|
||
<PhoneIcon className="relative size-6" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<p className="mt-6 max-w-xs text-xs text-zinc-500">
|
||
{micError ??
|
||
(canListen
|
||
? "Nach dem Anrufen den Mikrofonzugriff erlauben und einfach sprechen. Ton einschalten!"
|
||
: "Dieser Browser kann keine Sprache erkennen – tippen Sie rechts (Chrome, Edge oder Safari empfohlen).")}
|
||
</p>
|
||
</section>
|
||
|
||
<section className="flex min-h-[32rem] flex-col rounded-[2rem] border border-white/10 bg-white/[0.03]">
|
||
<header className="border-b border-white/10 px-6 py-4 text-sm font-medium">Live-Mitschrift</header>
|
||
<div className="flex-1 space-y-3 overflow-y-auto px-6 py-5">
|
||
{call === "idle" && chat.messages.length === 0 && (
|
||
<p className="text-sm text-zinc-500">Hier erscheint das Gespräch, sobald Sie anrufen oder eine Frage tippen.</p>
|
||
)}
|
||
{call !== "idle" && <Line role="assistant" text={info.greeting} />}
|
||
{chat.messages.map((message, i) =>
|
||
message.parts.map((part, j) =>
|
||
part.type === "booking" ? (
|
||
<BookingCard key={`${i}-${j}`} data={part.data} className="border-t-accent/40 bg-white/5" />
|
||
) : (
|
||
<Line key={`${i}-${j}`} role={message.role} text={part.text} />
|
||
)
|
||
)
|
||
)}
|
||
{heard && <p className="text-sm italic text-zinc-400">„{heard} …“</p>}
|
||
{chat.error && <p className="text-sm text-red-400">{chat.error}</p>}
|
||
</div>
|
||
<div className="space-y-3 border-t border-white/10 p-4">
|
||
<div className="flex flex-wrap gap-2">
|
||
{EXAMPLES.map((example) => (
|
||
<button
|
||
key={example}
|
||
type="button"
|
||
onClick={() => sendText(example)}
|
||
className="cursor-pointer rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 transition-colors hover:border-t-accent hover:text-white"
|
||
>
|
||
{example}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<form
|
||
className="flex gap-2"
|
||
onSubmit={(event) => {
|
||
event.preventDefault()
|
||
sendText(draft)
|
||
}}
|
||
>
|
||
<input
|
||
value={draft}
|
||
onChange={(event) => setDraft(event.target.value)}
|
||
placeholder="Oder hier tippen …"
|
||
aria-label="Nachricht an den Assistenten"
|
||
className="min-w-0 flex-1 rounded-full border border-white/10 bg-white/5 px-4 py-2.5 text-sm outline-none focus:border-t-accent"
|
||
/>
|
||
<button type="submit" aria-label="Senden" disabled={!draft.trim() || chat.pending} className="grid size-10 shrink-0 cursor-pointer place-items-center rounded-full bg-t-accent text-t-accent-fg disabled:opacity-40">
|
||
<ArrowUpIcon className="size-4" />
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function Line({ role, text }: { role: "user" | "assistant"; text: string }) {
|
||
return (
|
||
<div className={cn("flex", role === "user" && "justify-end")}>
|
||
<p className={cn("max-w-[85%] rounded-2xl px-4 py-2.5 text-sm leading-relaxed", role === "user" ? "bg-t-accent text-t-accent-fg" : "bg-white/[0.06]")}>
|
||
<span className="mb-0.5 block text-[10px] uppercase tracking-wider opacity-60">{role === "user" ? "Anrufer:in" : "Assistent"}</span>
|
||
{text}
|
||
</p>
|
||
</div>
|
||
)
|
||
}
|