feat(T07): KI-Copilot with OpenRouter chat, voice input, action system, chat history

This commit is contained in:
2026-07-17 02:28:41 +02:00
parent cb89e3ff1e
commit 5cc9f8e9a9
24 changed files with 2878 additions and 188 deletions
@@ -0,0 +1,63 @@
'use client';
import { type ActionItem } from '@/lib/copilot';
interface ActionPreviewProps {
actions: ActionItem[];
onConfirm: (action: ActionItem) => void;
onDismiss: (action: ActionItem) => void;
}
const ACTION_LABELS: Record<string, string> = {
search_vehicles: 'Fahrzeuge durchsuchen',
search_contacts: 'Kontakte durchsuchen',
get_sale_overview: 'Verkaufsuebersicht abrufen',
create_vehicle: 'Fahrzeug anlegen',
create_contact: 'Kontakt anlegen',
};
export function ActionPreview({ actions, onConfirm, onDismiss }: ActionPreviewProps) {
if (actions.length === 0) return null;
return (
<div className="border border-yellow-300 bg-yellow-50 rounded-lg p-3 space-y-2" data-testid="action-preview">
<p className="font-semibold text-yellow-800">
Der Copilot moechte folgende Aktionen ausfuehren:
</p>
{actions.map((action, idx) => (
<div
key={`${action.type}-${idx}`}
className="flex items-center justify-between bg-white rounded p-2 border border-yellow-200"
data-testid={`action-item-${idx}`}
>
<div className="flex-1">
<p className="font-medium">
{ACTION_LABELS[action.type] || action.type}
</p>
<p className="text-sm text-gray-600">
{Object.entries(action.params).map(([key, value]) =>
`${key}: ${String(value)}`
).join(', ') || 'Keine Parameter'}
</p>
</div>
<div className="flex gap-2 ml-2">
<button
onClick={() => onConfirm(action)}
className="bg-green-600 text-white px-3 py-1 rounded text-sm hover:bg-green-700"
data-testid={`confirm-action-${idx}`}
>
Bestaetigen
</button>
<button
onClick={() => onDismiss(action)}
className="bg-gray-300 text-gray-700 px-3 py-1 rounded text-sm hover:bg-gray-400"
data-testid={`dismiss-action-${idx}`}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,91 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { getChatHistory, type ChatMessage } from '@/lib/copilot';
interface ChatHistoryProps {
onSelectSession: (sessionId: string) => void;
onClose: () => void;
}
interface SessionGroup {
sessionId: string;
firstMessage: string;
messageCount: number;
lastActivity: string;
}
export function ChatHistory({ onSelectSession, onClose }: ChatHistoryProps) {
const [sessions, setSessions] = useState<SessionGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadHistory = async () => {
setLoading(true);
setError(null);
try {
const result = await getChatHistory(1, 100);
const sessionMap = new Map<string, ChatMessage[]>();
for (const msg of result.items) {
const existing = sessionMap.get(msg.session_id) || [];
existing.push(msg);
sessionMap.set(msg.session_id, existing);
}
const groups: SessionGroup[] = [];
for (const [sessionId, msgs] of sessionMap) {
const userMsg = msgs.find((m) => m.role === 'user');
groups.push({
sessionId,
firstMessage: userMsg?.content || 'Unbekannt',
messageCount: msgs.length,
lastActivity: msgs[msgs.length - 1]?.created_at || '',
});
}
groups.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
setSessions(groups);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verlauf konnte nicht geladen werden');
} finally {
setLoading(false);
}
};
loadHistory();
}, []);
const handleSelect = useCallback((sessionId: string) => {
onSelectSession(sessionId);
}, [onSelectSession]);
return (
<div className="w-64 border-r border-gray-200 pr-4" data-testid="chat-history">
<div className="flex items-center justify-between mb-3">
<h2 className="font-semibold text-sm">Konversationen</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-sm" data-testid="close-history">
</button>
</div>
{loading && <p className="text-sm text-gray-400" data-testid="history-loading">Wird geladen...</p>}
{error && <p className="text-sm text-red-600" data-testid="history-error">{error}</p>}
{!loading && !error && sessions.length === 0 && (
<p className="text-sm text-gray-400" data-testid="history-empty">Keine Konversationen vorhanden.</p>
)}
{!loading && !error && sessions.length > 0 && (
<ul className="space-y-1">
{sessions.map((session) => (
<li key={session.sessionId}>
<button
onClick={() => handleSelect(session.sessionId)}
className="w-full text-left p-2 rounded hover:bg-gray-100 text-sm"
data-testid={`history-session-${session.sessionId}`}
>
<p className="font-medium truncate">{session.firstMessage}</p>
<p className="text-xs text-gray-400">{session.messageCount} Nachrichten</p>
</button>
</li>
))}
</ul>
)}
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
'use client';
import { useState, useCallback, type KeyboardEvent } from 'react';
interface ChatInputProps {
onSend: (text: string) => void;
disabled?: boolean;
}
export function ChatInput({ onSend, disabled }: ChatInputProps) {
const [text, setText] = useState('');
const handleSend = useCallback(() => {
if (!text.trim() || disabled) return;
onSend(text.trim());
setText('');
}, [text, disabled, onSend]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
return (
<div className="flex-1 flex gap-2" data-testid="chat-input-container">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
disabled={disabled}
placeholder="Nachricht an Copilot..."
className="flex-1 border border-gray-300 rounded-lg px-3 py-2 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500"
rows={1}
data-testid="chat-input"
/>
<button
onClick={handleSend}
disabled={disabled || !text.trim()}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
data-testid="send-button"
>
Senden
</button>
</div>
);
}
@@ -0,0 +1,162 @@
'use client';
import { useState, useCallback } from 'react';
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import { VoiceInput } from './VoiceInput';
import { ActionPreview } from './ActionPreview';
import { ChatHistory } from './ChatHistory';
import {
sendChatMessage,
executeAction,
sendVoiceMessage,
type ChatMessage,
type ActionItem,
} from '@/lib/copilot';
export function ChatInterface() {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [sessionId, setSessionId] = useState<string | undefined>(undefined);
const [pendingActions, setPendingActions] = useState<ActionItem[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showHistory, setShowHistory] = useState(false);
const handleSend = useCallback(async (text: string) => {
if (!text.trim() || loading) return;
setLoading(true);
setError(null);
const userMsg: ChatMessage = {
id: `temp-${Date.now()}`,
session_id: sessionId || '',
role: 'user',
content: text,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg]);
try {
const result = await sendChatMessage(text, sessionId);
setSessionId(result.session_id);
const assistantMsg: ChatMessage = {
id: result.message_id,
session_id: result.session_id,
role: 'assistant',
content: result.response,
actions: result.actions,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMsg]);
if (result.actions && result.actions.length > 0) {
setPendingActions(result.actions);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
} finally {
setLoading(false);
}
}, [sessionId, loading]);
const handleVoice = useCallback(async (audioBase64: string, mimeType: string) => {
if (loading) return;
setLoading(true);
setError(null);
try {
const result = await sendVoiceMessage(audioBase64, mimeType, sessionId);
setSessionId(result.session_id);
const userMsg: ChatMessage = {
id: `voice-${Date.now()}`,
session_id: result.session_id,
role: 'user',
content: `🎤 ${result.transcription}`,
created_at: new Date().toISOString(),
};
const assistantMsg: ChatMessage = {
id: result.message_id,
session_id: result.session_id,
role: 'assistant',
content: result.response,
actions: result.actions,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMsg, assistantMsg]);
if (result.actions && result.actions.length > 0) {
setPendingActions(result.actions);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Spracherkennung fehlgeschlagen');
} finally {
setLoading(false);
}
}, [sessionId, loading]);
const handleConfirmAction = useCallback(async (action: ActionItem) => {
setLoading(true);
setError(null);
try {
const result = await executeAction(action.type, action.params, sessionId);
const resultMsg: ChatMessage = {
id: `action-${Date.now()}`,
session_id: sessionId || '',
role: 'assistant',
content: `Aktion "${result.action}" ausgefuehrt. ${result.success ? 'Erfolgreich.' : 'Fehlgeschlagen.'}`,
created_at: new Date().toISOString(),
};
setMessages((prev) => [...prev, resultMsg]);
setPendingActions((prev) => prev.filter((a) => a !== action));
} catch (err) {
setError(err instanceof Error ? err.message : 'Aktion fehlgeschlagen');
} finally {
setLoading(false);
}
}, [sessionId]);
const handleDismissAction = useCallback((action: ActionItem) => {
setPendingActions((prev) => prev.filter((a) => a !== action));
}, []);
const handleSelectSession = useCallback((selectedSessionId: string) => {
setSessionId(selectedSessionId);
setMessages([]);
setPendingActions([]);
setShowHistory(false);
}, []);
return (
<div className="flex gap-4 h-[calc(100vh-12rem)]">
{showHistory && (
<ChatHistory
onSelectSession={handleSelectSession}
onClose={() => setShowHistory(false)}
/>
)}
<div className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-2">
<button
onClick={() => setShowHistory(!showHistory)}
className="text-sm text-blue-600 hover:underline"
data-testid="toggle-history"
>
{showHistory ? 'Verlauf ausblenden' : 'Verlauf anzeigen'}
</button>
</div>
<MessageList messages={messages} loading={loading} />
{error && (
<div className="text-red-600 text-sm p-2" data-testid="chat-error">
{error}
</div>
)}
{pendingActions.length > 0 && (
<ActionPreview
actions={pendingActions}
onConfirm={handleConfirmAction}
onDismiss={handleDismissAction}
/>
)}
<div className="flex gap-2 items-end pt-2">
<ChatInput onSend={handleSend} disabled={loading} />
<VoiceInput onVoice={handleVoice} disabled={loading} />
</div>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
'use client';
import { type ChatMessage } from '@/lib/copilot';
interface MessageListProps {
messages: ChatMessage[];
loading?: boolean;
}
export function MessageList({ messages, loading }: MessageListProps) {
return (
<div
className="flex-1 overflow-y-auto space-y-3 p-2"
data-testid="message-list"
>
{messages.length === 0 && !loading && (
<div className="flex-1 flex items-center justify-center text-gray-400">
<p>Starte eine Konversation mit dem KI-Copilot...</p>
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
data-testid={`message-${msg.role}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
msg.role === 'user'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-900'
}`}
>
<p className="whitespace-pre-wrap">{msg.content}</p>
{msg.actions && msg.actions.length > 0 && (
<div className="mt-2 text-sm opacity-75">
<p className="font-semibold">Vorgeschlagene Aktionen:</p>
<ul className="list-disc list-inside">
{msg.actions.map((action, idx) => (
<li key={idx}>{action.type}</li>
))}
</ul>
</div>
)}
</div>
</div>
))}
{loading && (
<div className="flex justify-start" data-testid="loading-indicator">
<div className="bg-gray-100 rounded-lg px-4 py-2 text-gray-500">
<span className="animate-pulse">Copilot denkt nach...</span>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,94 @@
'use client';
import { useState, useCallback, useRef, type MouseEvent } from 'react';
import { fileToBase64 } from '@/lib/copilot';
interface VoiceInputProps {
onVoice: (audioBase64: string, mimeType: string) => void;
disabled?: boolean;
}
declare global {
interface Window {
SpeechRecognition?: new () => SpeechRecognitionLike;
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
}
}
interface SpeechRecognitionLike {
start: () => void;
stop: () => void;
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
onerror: (() => void) | null;
onend: (() => void) | null;
}
interface SpeechRecognitionEventLike {
results: { length: number; [key: number]: { 0: { transcript: string } } };
}
export function VoiceInput({ onVoice, disabled }: VoiceInputProps) {
const [recording, setRecording] = useState(false);
const [supported, setSupported] = useState(true);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<Blob[]>([]);
const handleStartRecording = useCallback(async () => {
if (disabled) return;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
chunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) chunksRef.current.push(e.data);
};
recorder.onstop = async () => {
const blob = new Blob(chunksRef.current, { type: 'audio/webm' });
const base64 = await fileToBase64(blob);
onVoice(base64, 'audio/webm');
stream.getTracks().forEach((track) => track.stop());
};
recorder.start();
mediaRecorderRef.current = recorder;
setRecording(true);
} catch {
setSupported(false);
}
}, [disabled, onVoice]);
const handleStopRecording = useCallback(() => {
if (mediaRecorderRef.current && recording) {
mediaRecorderRef.current.stop();
setRecording(false);
}
}, [recording]);
const handleToggle = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
if (recording) handleStopRecording();
else handleStartRecording();
},
[recording, handleStartRecording, handleStopRecording]
);
if (!supported) {
return (
<button disabled className="bg-gray-300 text-gray-500 px-3 py-2 rounded-lg cursor-not-allowed" data-testid="voice-button">
🎤
</button>
);
}
return (
<button
onClick={handleToggle}
disabled={disabled}
className={`${recording ? 'bg-red-600 animate-pulse' : 'bg-gray-200 hover:bg-gray-300'} text-gray-800 px-3 py-2 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed`}
title={recording ? 'Aufnahme stoppen' : 'Sprachnachricht aufnehmen'}
data-testid="voice-button"
>
🎤
</button>
);
}