since3-portal/modules/templates/ai/ui/use-ai-chat.ts
2026-10-05 08:23:02 +02:00

120 lines
4.5 KiB
TypeScript

"use client"
import { useRef, useState } from "react"
import type { AiAccess } from "../demos"
import type { AiEvent, Booking } from "../schemas"
export type ChatPart = { type: "text"; text: string } | { type: "booking"; data: Booking }
export type ChatMessage = { role: "user" | "assistant"; parts: ChatPart[] }
const FALLBACK_ERROR = "Die KI ist gerade nicht erreichbar."
export async function readNdjson(response: Response, onEvent: (event: AiEvent) => void) {
if (!response.ok || !response.body) {
const data = (await response.json().catch(() => null)) as { error?: string } | null
throw new Error(data?.error ?? FALLBACK_ERROR)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let newline = buffer.indexOf("\n")
while (newline >= 0) {
const line = buffer.slice(0, newline).trim()
buffer = buffer.slice(newline + 1)
if (line) onEvent(JSON.parse(line) as AiEvent)
newline = buffer.indexOf("\n")
}
}
}
export function messageText(message: ChatMessage) {
return message.parts.map((part) => (part.type === "text" ? part.text : "")).join("").trim()
}
// Der Verlauf liegt zusätzlich in einer Ref: Sprach-Callbacks des KI-Telefons rufen `send` aus älteren Renders auf
export function useAiChat({ access, mode, onReply, onError }: {
access: AiAccess | null
mode: "chat" | "phone"
onReply?: (text: string) => void
onError?: () => void
}) {
const [messages, setMessages] = useState<ChatMessage[]>([])
const [pending, setPending] = useState(false)
const [error, setError] = useState<string | null>(null)
const history = useRef<ChatMessage[]>([])
const busy = useRef(false)
function commit(next: ChatMessage[]) {
history.current = next
setMessages(next)
}
function updateLast(change: (last: ChatMessage) => ChatMessage) {
const current = history.current
commit([...current.slice(0, -1), change(current[current.length - 1])])
}
async function send(text: string) {
const content = text.trim()
if (!content || !access || busy.current) return
busy.current = true
const base: ChatMessage[] = [...history.current, { role: "user", parts: [{ type: "text", text: content }] }]
commit([...base, { role: "assistant", parts: [] }])
setPending(true)
setError(null)
let reply = ""
try {
const response = await fetch("/api/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
access,
mode,
messages: base
.map((message) => ({ role: message.role, content: messageText(message) }))
.filter((message) => message.content)
.slice(-30),
}),
})
await readNdjson(response, (event) => {
if (event.type === "error") throw new Error(event.message)
if (event.type === "booking") {
updateLast((last) => ({ ...last, parts: [...last.parts, { type: "booking", data: event.data }] }))
} else if (event.type === "text") {
reply += event.text
updateLast((last) => {
const parts = [...last.parts]
const tail = parts[parts.length - 1]
if (tail?.type === "text") parts[parts.length - 1] = { type: "text", text: tail.text + event.text }
else parts.push({ type: "text", text: event.text })
return { ...last, parts }
})
}
})
busy.current = false
setPending(false)
onReply?.(reply.trim())
} catch (cause) {
const last = history.current[history.current.length - 1]
if (last?.role === "assistant" && last.parts.length === 0) commit(history.current.slice(0, -1))
busy.current = false
setPending(false)
setError(cause instanceof Error ? cause.message : FALLBACK_ERROR)
onError?.()
}
}
function reset() {
commit([])
setError(null)
}
return { messages, pending, error, send, reset }
}