feat(T07): KI-Copilot with OpenRouter chat, voice input, action system, chat history
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user