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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user