v1.38: KI-Assistent als einblendbares Widget (Lasche am rechten Rand)

- AssistChat: Chat-Logik aus der Assistenten-Seite extrahiert, wiederverwendbar
- AssistWidget: vertikale Lasche rechts (schwebt mitte), oeffnet Slide-over-Panel
  (430px, Teal-Header, Escape/zumacht bei Seitenwechsel), nur fuer Verwaltung,
  auf /assistent ausgeblendet
- AppLayout: Widget global eingebunden; Assistenten-Seite nutzt jetzt AssistChat
This commit is contained in:
2026-10-04 21:32:25 +02:00
parent 6eec3ac437
commit 2eada93332
4 changed files with 257 additions and 149 deletions
+4
View File
@@ -18,6 +18,7 @@ import {
} from 'lucide-react'
import { useStore } from '../store/store'
import ThemeToggle from './ThemeToggle'
import AssistWidget from './assist/AssistWidget'
import logo from '../assets/hinterstein/logo.png'
const navItems = [
@@ -147,6 +148,9 @@ export default function AppLayout() {
<Outlet />
</main>
</div>
{/* KI-Assistent als einblendbares Widget (nur Verwaltung) */}
<AssistWidget />
</div>
)
}
+154
View File
@@ -0,0 +1,154 @@
import { useRef, useState } from 'react'
import { Bot, CircleAlert, Eraser, Send, UserRound } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { apiPost } from '../../lib/api'
export interface Nachricht {
rolle: 'user' | 'assistant'
text: string
}
const BEISPIELE = [
'Welche Wohnungen stehen leer?',
'Wie hoch ist das monatliche Miet-Soll?',
'Gibt es offene Schadensmeldungen?',
'Bei welchen Zählern gab es Auffälligkeiten?',
'Wie hoch waren die umlegbaren Kosten 2026?',
]
/** Chat-Logik + UI des KI-Assistenten (v1.38 als Widget).
* Wird von der Assistenten-Seite und vom einblendbaren Widget genutzt.
* Der Verlauf lebt nur in dieser Sitzung (keine Speicherung). */
export default function AssistChat() {
const [nachrichten, setNachrichten] = useState<Nachricht[]>([])
const [eingabe, setEingabe] = useState('')
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const endeRef = useRef<HTMLDivElement>(null)
const scrollEnde = () => setTimeout(() => endeRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
const senden = async (frage: string) => {
const text = frage.trim()
if (!text || sende) return
setSende(true)
setFehler(null)
const verlauf = [...nachrichten, { rolle: 'user' as const, text }]
setNachrichten(verlauf)
setEingabe('')
try {
const { antwort } = await apiPost<{ antwort: string }>('/api/assist', {
frage: text,
verlauf: verlauf.slice(-20),
})
setNachrichten((x) => [...x, { rolle: 'assistant', text: antwort }])
} catch (e) {
setFehler(e instanceof Error ? e.message : 'Assistent antwortet nicht.')
// Nutzerfrage zurücknehmen, damit der Verlauf nicht kippt
setNachrichten((x) => x.slice(0, -1))
} finally {
setSende(false)
scrollEnde()
}
}
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
{/* Nachrichtenbereich */}
<div className="relative flex-1 space-y-3 overflow-y-auto rounded-2xl border bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
{nachrichten.length > 0 && (
<button
type="button"
onClick={() => setNachrichten([])}
className="absolute right-3 top-3 z-10 inline-flex items-center gap-1 rounded-lg bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500 transition hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:hover:bg-slate-700"
title="Verlauf leeren"
>
<Eraser className="h-3 w-3" /> Leeren
</button>
)}
{nachrichten.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<Bot className="mb-3 h-10 w-10 text-slate-300 dark:text-slate-600" />
<p className="max-w-md text-sm text-slate-500 dark:text-slate-400">
Stellen Sie eine Frage zu Ihrem Bestand – zum Beispiel:
</p>
<div className="mt-3 flex flex-wrap justify-center gap-2">
{BEISPIELE.map((b) => (
<button
key={b}
type="button"
onClick={() => senden(b)}
className="rounded-full border px-3 py-1.5 text-xs text-slate-600 transition-colors hover:border-teal-500 hover:text-teal-700 dark:border-slate-700 dark:text-slate-300 dark:hover:border-teal-500 dark:hover:text-teal-400"
>
{b}
</button>
))}
</div>
</div>
) : (
nachrichten.map((m, i) => (
<div key={i} className={`flex gap-2.5 ${m.rolle === 'user' ? 'justify-end' : ''}`}>
{m.rolle === 'assistant' && (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-teal-50 dark:bg-teal-950">
<Bot className="h-4 w-4 text-teal-700 dark:text-teal-300" />
</span>
)}
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed sm:max-w-[75%] ${
m.rolle === 'user'
? 'bg-teal-600 text-white'
: 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-100'
}`}
>
{m.text}
</div>
{m.rolle === 'user' && (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800">
<UserRound className="h-4 w-4 text-slate-500 dark:text-slate-400" />
</span>
)}
</div>
))
)}
{sende && (
<div className="flex items-center gap-2 text-sm text-slate-400 dark:text-slate-500">
<Bot className="h-4 w-4 animate-pulse" /> Kimi denkt nach …
</div>
)}
{fehler && (
<div className="flex items-start gap-2 rounded-xl bg-red-50 px-3.5 py-2.5 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0" /> {fehler}
</div>
)}
<div ref={endeRef} />
</div>
{/* Eingabe */}
<form
className="flex items-end gap-2"
onSubmit={(e) => {
e.preventDefault()
senden(eingabe)
}}
>
<textarea
value={eingabe}
onChange={(e) => setEingabe(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
senden(eingabe)
}
}}
placeholder={'Frage eingeben, z. B. „Welche Wohnungen stehen leer?" …'}
rows={2}
className="max-h-32 min-h-11 flex-1 resize-none rounded-xl border border-input bg-background px-3 py-2.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:bg-slate-900"
aria-label="Frage an den Assistenten"
/>
<Button type="submit" className="gap-1.5" disabled={sende || !eingabe.trim()}>
<Send className="h-4 w-4" /> Senden
</Button>
</form>
</div>
)
}
+87
View File
@@ -0,0 +1,87 @@
import { useEffect, useState } from 'react'
import { useLocation } from 'react-router'
import { Sparkles, X } from 'lucide-react'
import AssistChat from './AssistChat'
import { useStore } from '../../store/store'
/** KI-Assistent als einblendbares Widget (v1.38):
* Vertikale Lasche am rechten Rand öffnet ein Chat-Panel über der aktuellen Seite.
* Nur für die Verwaltung (role admin); auf der Assistenten-Seite selbst ausgeblendet. */
export default function AssistWidget() {
const { role } = useStore()
const location = useLocation()
const [offen, setOffen] = useState(false)
// Bei Seitenwechsel Panel schließen (außer manuell wieder geöffnet)
useEffect(() => {
setOffen(false)
}, [location.pathname])
// Escape schließt das Panel
useEffect(() => {
if (!offen) return
const zu = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOffen(false)
}
window.addEventListener('keydown', zu)
return () => window.removeEventListener('keydown', zu)
}, [offen])
if (role !== 'admin' || location.pathname === '/assistent') return null
return (
<>
{/* Lasche am rechten Rand */}
<button
type="button"
onClick={() => setOffen(true)}
aria-label="KI-Assistent öffnen"
className={`fixed right-0 top-1/2 z-40 flex -translate-y-1/2 flex-col items-center gap-2 rounded-l-xl bg-teal-600 py-4 pl-2.5 pr-2 text-white shadow-lg transition-all hover:bg-teal-500 hover:pr-3 ${
offen ? 'pointer-events-none translate-x-full opacity-0' : 'translate-x-0 opacity-100'
}`}
>
<Sparkles className="h-4 w-4" />
<span className="text-xs font-semibold tracking-widest [writing-mode:vertical-rl]">
KI-Assistent
</span>
</button>
{/* Chat-Panel */}
<div
role="dialog"
aria-label="KI-Assistent"
aria-hidden={!offen}
className={`fixed inset-y-0 right-0 z-50 flex w-full flex-col border-l border-slate-200 bg-white shadow-2xl transition-transform duration-300 ease-in-out dark:border-slate-700 dark:bg-slate-900 sm:w-[430px] ${
offen ? 'translate-x-0' : 'translate-x-full'
}`}
>
<header className="flex items-center gap-2.5 border-b border-slate-100 bg-teal-600 px-4 py-3 text-white dark:border-slate-800">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-white/15">
<Sparkles className="h-4 w-4" />
</span>
<div className="flex-1">
<div className="font-display text-sm font-semibold">KI-Assistent</div>
<div className="text-[11px] text-teal-100">
Fragt Kimi über N8N mit Live-Zugriff auf das Portal
</div>
</div>
<button
type="button"
onClick={() => setOffen(false)}
aria-label="KI-Assistent schließen"
className="rounded-lg p-1.5 text-teal-100 transition hover:bg-white/15 hover:text-white"
>
<X className="h-4 w-4" />
</button>
</header>
<div className="flex min-h-0 flex-1 flex-col p-4">
<AssistChat />
<p className="mt-2 text-[11px] leading-snug text-slate-400 dark:text-slate-500">
Antworten können fehlerhaft sein – kritische Daten bitte prüfen.
</p>
</div>
</div>
</>
)
}
+12 -149
View File
@@ -1,55 +1,10 @@
import { useRef, useState } from 'react'
import { Bot, CircleAlert, Eraser, Send, Sparkles, UserRound } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { apiPost } from '../lib/api'
import { Sparkles } from 'lucide-react'
import AssistChat from '../components/assist/AssistChat'
import { useStore } from '../store/store'
interface Nachricht {
rolle: 'user' | 'assistant'
text: string
}
const BEISPIELE = [
'Welche Wohnungen stehen leer?',
'Wie hoch ist das monatliche Miet-Soll?',
'Gibt es offene Schadensmeldungen?',
'Bei welchen Zählern gab es Auffälligkeiten?',
'Wie hoch waren die umlegbaren Kosten 2026?',
]
/** KI-Assistent (v1.37): Verwaltung fragt, N8N + Kimi antworten.
* Der Verlauf lebt nur in dieser Sitzung (keine Speicherung). */
/** KI-Assistent (v1.37): Verwaltung fragt, N8N + Kimi antworten. */
export default function Assistent() {
const { role } = useStore()
const [nachrichten, setNachrichten] = useState<Nachricht[]>([])
const [eingabe, setEingabe] = useState('')
const [sende, setSende] = useState(false)
const [fehler, setFehler] = useState<string | null>(null)
const endeRef = useRef<HTMLDivElement>(null)
const senden = async (frage: string) => {
const text = frage.trim()
if (!text || sende) return
setSende(true)
setFehler(null)
const verlauf = [...nachrichten, { rolle: 'user' as const, text }]
setNachrichten(verlauf)
setEingabe('')
try {
const { antwort } = await apiPost<{ antwort: string }>('/api/assist', {
frage: text,
verlauf: verlauf.slice(-20),
})
setNachrichten((x) => [...x, { rolle: 'assistant', text: antwort }])
} catch (e) {
setFehler(e instanceof Error ? e.message : 'Assistent antwortet nicht.')
// Nutzerfrage zurücknehmen, damit der Verlauf nicht kippt
setNachrichten((x) => x.slice(0, -1))
} finally {
setSende(false)
setTimeout(() => endeRef.current?.scrollIntoView({ behavior: 'smooth' }), 50)
}
}
if (role !== 'admin') {
return (
@@ -61,108 +16,16 @@ export default function Assistent() {
return (
<div className="flex h-[calc(100vh-12rem)] flex-col space-y-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Sparkles className="h-6 w-6 text-teal-600 dark:text-teal-400" /> KI-Assistent
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Fragen an das Portal – beantwortet von Kimi über N8N mit Live-Zugriff auf Bestand, Mieter,
Zähler, Kosten und Schäden. Antworten können fehlerhaft sein – kritische Daten bitte prüfen.
</p>
</div>
{nachrichten.length > 0 && (
<Button variant="outline" size="sm" className="gap-1.5" onClick={() => setNachrichten([])}>
<Eraser className="h-3.5 w-3.5" /> Verlauf leeren
</Button>
)}
<div>
<h1 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Sparkles className="h-6 w-6 text-teal-600 dark:text-teal-400" /> KI-Assistent
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Fragen an das Portal – beantwortet von Kimi über N8N mit Live-Zugriff auf Bestand, Mieter,
Zähler, Kosten und Schäden. Antworten können fehlerhaft sein – kritische Daten bitte prüfen.
</p>
</div>
{/* Nachrichtenbereich */}
<div className="flex-1 space-y-3 overflow-y-auto rounded-2xl border bg-white p-4 dark:border-slate-700 dark:bg-slate-900">
{nachrichten.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center text-center">
<Bot className="mb-3 h-10 w-10 text-slate-300 dark:text-slate-600" />
<p className="max-w-md text-sm text-slate-500 dark:text-slate-400">
Stellen Sie eine Frage zu Ihrem Bestand – zum Beispiel:
</p>
<div className="mt-3 flex flex-wrap justify-center gap-2">
{BEISPIELE.map((b) => (
<button
key={b}
type="button"
onClick={() => senden(b)}
className="rounded-full border px-3 py-1.5 text-xs text-slate-600 transition-colors hover:border-teal-500 hover:text-teal-700 dark:border-slate-700 dark:text-slate-300 dark:hover:border-teal-500 dark:hover:text-teal-400"
>
{b}
</button>
))}
</div>
</div>
) : (
nachrichten.map((m, i) => (
<div key={i} className={`flex gap-2.5 ${m.rolle === 'user' ? 'justify-end' : ''}`}>
{m.rolle === 'assistant' && (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-teal-50 dark:bg-teal-950">
<Bot className="h-4 w-4 text-teal-700 dark:text-teal-300" />
</span>
)}
<div
className={`max-w-[85%] whitespace-pre-wrap rounded-2xl px-3.5 py-2.5 text-sm leading-relaxed sm:max-w-[75%] ${
m.rolle === 'user'
? 'bg-teal-600 text-white'
: 'bg-slate-100 text-slate-800 dark:bg-slate-800 dark:text-slate-100'
}`}
>
{m.text}
</div>
{m.rolle === 'user' && (
<span className="mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800">
<UserRound className="h-4 w-4 text-slate-500 dark:text-slate-400" />
</span>
)}
</div>
))
)}
{sende && (
<div className="flex items-center gap-2 text-sm text-slate-400 dark:text-slate-500">
<Bot className="h-4 w-4 animate-pulse" /> Kimi denkt nach …
</div>
)}
{fehler && (
<div className="flex items-start gap-2 rounded-xl bg-red-50 px-3.5 py-2.5 text-sm text-red-700 dark:bg-red-950 dark:text-red-300" role="alert">
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0" /> {fehler}
</div>
)}
<div ref={endeRef} />
</div>
{/* Eingabe */}
<form
className="flex items-end gap-2"
onSubmit={(e) => {
e.preventDefault()
senden(eingabe)
}}
>
<textarea
value={eingabe}
onChange={(e) => setEingabe(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
senden(eingabe)
}
}}
placeholder={'Frage eingeben, z. B. „Welche Wohnungen stehen leer?" …'}
rows={2}
className="max-h-32 min-h-11 flex-1 resize-none rounded-xl border border-input bg-background px-3 py-2.5 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:bg-slate-900"
aria-label="Frage an den Assistenten"
/>
<Button type="submit" className="gap-1.5" disabled={sende || !eingabe.trim()}>
<Send className="h-4 w-4" /> Senden
</Button>
</form>
<AssistChat />
</div>
)
}