feat(T07): KI-Copilot with OpenRouter chat, voice input, action system, chat history
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
import { ChatInterface } from '@/components/copilot/ChatInterface';
|
||||
|
||||
export default function CopilotPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6">
|
||||
<h1 className="text-2xl font-bold mb-4">KI-Copilot</h1>
|
||||
<ChatInterface />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { apiFetch, type PaginatedResponse } from './api';
|
||||
|
||||
export interface ActionItem {
|
||||
type: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
response: string;
|
||||
actions: ActionItem[];
|
||||
session_id: string;
|
||||
message_id: string;
|
||||
}
|
||||
|
||||
export interface ActionResponse {
|
||||
action: string;
|
||||
result: unknown;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
session_id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
actions?: ActionItem[] | null;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface VoiceResponse {
|
||||
transcription: string;
|
||||
response: string;
|
||||
actions: ActionItem[];
|
||||
session_id: string;
|
||||
message_id: string;
|
||||
}
|
||||
|
||||
export async function sendChatMessage(
|
||||
message: string,
|
||||
sessionId?: string
|
||||
): Promise<ChatResponse> {
|
||||
return apiFetch<ChatResponse>('/copilot/chat', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ message, session_id: sessionId || undefined }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function executeAction(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
sessionId?: string
|
||||
): Promise<ActionResponse> {
|
||||
return apiFetch<ActionResponse>('/copilot/action', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, params, session_id: sessionId || undefined }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChatHistory(
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sessionId?: string
|
||||
): Promise<PaginatedResponse<ChatMessage>> {
|
||||
let path = `/copilot/history?page=${page}&page_size=${pageSize}`;
|
||||
if (sessionId) {
|
||||
path += `&session_id=${sessionId}`;
|
||||
}
|
||||
return apiFetch<PaginatedResponse<ChatMessage>>(path);
|
||||
}
|
||||
|
||||
export async function sendVoiceMessage(
|
||||
audioBase64: string,
|
||||
mimeType = 'audio/webm',
|
||||
sessionId?: string
|
||||
): Promise<VoiceResponse> {
|
||||
return apiFetch<VoiceResponse>('/copilot/voice', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
audio: audioBase64,
|
||||
mime_type: mimeType,
|
||||
session_id: sessionId || undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function fileToBase64(file: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
const base64 = result.split(',')[1] || result;
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { ChatInterface } from '@/components/copilot/ChatInterface';
|
||||
import { MessageList } from '@/components/copilot/MessageList';
|
||||
import { ChatInput } from '@/components/copilot/ChatInput';
|
||||
import { ActionPreview } from '@/components/copilot/ActionPreview';
|
||||
import { ChatHistory } from '@/components/copilot/ChatHistory';
|
||||
|
||||
vi.mock('@/lib/copilot', () => ({
|
||||
sendChatMessage: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
getChatHistory: vi.fn(),
|
||||
sendVoiceMessage: vi.fn(),
|
||||
fileToBase64: vi.fn(),
|
||||
}));
|
||||
|
||||
import { sendChatMessage, executeAction, getChatHistory } from '@/lib/copilot';
|
||||
import type { ActionItem, ChatMessage } from '@/lib/copilot';
|
||||
|
||||
describe('ChatInput', () => {
|
||||
it('renders input and send button', () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
expect(screen.getByTestId('chat-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('send-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onSend when send button clicked', () => {
|
||||
const onSend = vi.fn();
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: 'Test message' } });
|
||||
fireEvent.click(screen.getByTestId('send-button'));
|
||||
expect(onSend).toHaveBeenCalledWith('Test message');
|
||||
});
|
||||
|
||||
it('clears input after send', () => {
|
||||
render(<ChatInput onSend={vi.fn()} />);
|
||||
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: 'Test' } });
|
||||
fireEvent.click(screen.getByTestId('send-button'));
|
||||
expect(input.value).toBe('');
|
||||
});
|
||||
|
||||
it('does not send empty messages', () => {
|
||||
const onSend = vi.fn();
|
||||
render(<ChatInput onSend={onSend} />);
|
||||
fireEvent.click(screen.getByTestId('send-button'));
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is disabled when disabled prop is true', () => {
|
||||
render(<ChatInput onSend={vi.fn()} disabled />);
|
||||
expect(screen.getByTestId('send-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageList', () => {
|
||||
it('shows placeholder when no messages', () => {
|
||||
render(<MessageList messages={[]} />);
|
||||
expect(screen.getByText('Starte eine Konversation mit dem KI-Copilot...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders user and assistant messages', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ id: '1', session_id: 's1', role: 'user', content: 'Hallo', created_at: '2025-01-01T00:00:00Z' },
|
||||
{ id: '2', session_id: 's1', role: 'assistant', content: 'Hi there!', created_at: '2025-01-01T00:00:01Z' },
|
||||
];
|
||||
render(<MessageList messages={messages} />);
|
||||
expect(screen.getByText('Hallo')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hi there!')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading indicator', () => {
|
||||
render(<MessageList messages={[]} loading />);
|
||||
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays action proposals in assistant messages', () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{
|
||||
id: '1', session_id: 's1', role: 'assistant', content: 'Ich suche LKWs.',
|
||||
actions: [{ type: 'search_vehicles', params: { type: 'lkw' } }],
|
||||
created_at: '2025-01-01T00:00:00Z',
|
||||
},
|
||||
];
|
||||
render(<MessageList messages={messages} />);
|
||||
expect(screen.getByText('Vorgeschlagene Aktionen:')).toBeInTheDocument();
|
||||
expect(screen.getByText('search_vehicles')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ActionPreview', () => {
|
||||
const mockActions: ActionItem[] = [
|
||||
{ type: 'search_vehicles', params: { type: 'lkw' } },
|
||||
];
|
||||
|
||||
it('renders action preview with confirm and dismiss buttons', () => {
|
||||
render(<ActionPreview actions={mockActions} onConfirm={vi.fn()} onDismiss={vi.fn()} />);
|
||||
expect(screen.getByTestId('action-preview')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('confirm-action-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('dismiss-action-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onConfirm when confirm button clicked', () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(<ActionPreview actions={mockActions} onConfirm={onConfirm} onDismiss={vi.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('confirm-action-0'));
|
||||
expect(onConfirm).toHaveBeenCalledWith(mockActions[0]);
|
||||
});
|
||||
|
||||
it('calls onDismiss when dismiss button clicked', () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<ActionPreview actions={mockActions} onConfirm={vi.fn()} onDismiss={onDismiss} />);
|
||||
fireEvent.click(screen.getByTestId('dismiss-action-0'));
|
||||
expect(onDismiss).toHaveBeenCalledWith(mockActions[0]);
|
||||
});
|
||||
|
||||
it('returns null when no actions', () => {
|
||||
const { container } = render(<ActionPreview actions={[]} onConfirm={vi.fn()} onDismiss={vi.fn()} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('shows action label for known action types', () => {
|
||||
render(<ActionPreview actions={mockActions} onConfirm={vi.fn()} onDismiss={vi.fn()} />);
|
||||
expect(screen.getByText('Fahrzeuge durchsuchen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatHistory', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('renders history sidebar with title', async () => {
|
||||
vi.mocked(getChatHistory).mockResolvedValue({ items: [], total: 0, page: 1, page_size: 100 });
|
||||
render(<ChatHistory onSelectSession={vi.fn()} onClose={vi.fn()} />);
|
||||
expect(screen.getByText('Konversationen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no sessions', async () => {
|
||||
vi.mocked(getChatHistory).mockResolvedValue({ items: [], total: 0, page: 1, page_size: 100 });
|
||||
render(<ChatHistory onSelectSession={vi.fn()} onClose={vi.fn()} />);
|
||||
await waitFor(() => { expect(screen.getByTestId('history-empty')).toBeInTheDocument(); });
|
||||
});
|
||||
|
||||
it('displays sessions grouped by session_id', async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ id: '1', session_id: 's1', role: 'user', content: 'Erste Frage', created_at: '2025-01-01T00:00:00Z' },
|
||||
{ id: '2', session_id: 's1', role: 'assistant', content: 'Antwort', created_at: '2025-01-01T00:00:01Z' },
|
||||
{ id: '3', session_id: 's2', role: 'user', content: 'Zweite Frage', created_at: '2025-01-02T00:00:00Z' },
|
||||
];
|
||||
vi.mocked(getChatHistory).mockResolvedValue({ items: messages, total: 3, page: 1, page_size: 100 });
|
||||
render(<ChatHistory onSelectSession={vi.fn()} onClose={vi.fn()} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Erste Frage')).toBeInTheDocument();
|
||||
expect(screen.getByText('Zweite Frage')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('calls onSelectSession when session clicked', async () => {
|
||||
const messages: ChatMessage[] = [
|
||||
{ id: '1', session_id: 's1', role: 'user', content: 'Test', created_at: '2025-01-01T00:00:00Z' },
|
||||
];
|
||||
vi.mocked(getChatHistory).mockResolvedValue({ items: messages, total: 1, page: 1, page_size: 100 });
|
||||
const onSelectSession = vi.fn();
|
||||
render(<ChatHistory onSelectSession={onSelectSession} onClose={vi.fn()} />);
|
||||
await waitFor(() => { expect(screen.getByText('Test')).toBeInTheDocument(); });
|
||||
fireEvent.click(screen.getByTestId('history-session-s1'));
|
||||
expect(onSelectSession).toHaveBeenCalledWith('s1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatInterface', () => {
|
||||
beforeEach(() => { vi.clearAllMocks(); });
|
||||
|
||||
it('renders chat interface with input and message list', () => {
|
||||
render(<ChatInterface />);
|
||||
expect(screen.getByTestId('chat-input')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('voice-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends message and displays response', async () => {
|
||||
vi.mocked(sendChatMessage).mockResolvedValue({
|
||||
response: 'Ich helfe dir!', actions: [], session_id: 'test-session', message_id: 'msg-1',
|
||||
});
|
||||
render(<ChatInterface />);
|
||||
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: 'Hallo' } });
|
||||
fireEvent.click(screen.getByTestId('send-button'));
|
||||
await waitFor(() => { expect(screen.getByText('Ich helfe dir!')).toBeInTheDocument(); });
|
||||
expect(sendChatMessage).toHaveBeenCalledWith('Hallo', undefined);
|
||||
});
|
||||
|
||||
it('shows action preview when response has actions', async () => {
|
||||
vi.mocked(sendChatMessage).mockResolvedValue({
|
||||
response: 'Ich suche LKWs.',
|
||||
actions: [{ type: 'search_vehicles', params: { type: 'lkw' } }],
|
||||
session_id: 'test-session', message_id: 'msg-1',
|
||||
});
|
||||
render(<ChatInterface />);
|
||||
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: 'Zeige LKWs' } });
|
||||
fireEvent.click(screen.getByTestId('send-button'));
|
||||
await waitFor(() => { expect(screen.getByTestId('action-preview')).toBeInTheDocument(); });
|
||||
});
|
||||
|
||||
it('executes action when confirm button clicked', async () => {
|
||||
vi.mocked(sendChatMessage).mockResolvedValue({
|
||||
response: 'Ich suche LKWs.',
|
||||
actions: [{ type: 'search_vehicles', params: { type: 'lkw' } }],
|
||||
session_id: 'test-session', message_id: 'msg-1',
|
||||
});
|
||||
vi.mocked(executeAction).mockResolvedValue({
|
||||
action: 'search_vehicles', result: { items: [], total: 0, page: 1, page_size: 20 }, success: true,
|
||||
});
|
||||
render(<ChatInterface />);
|
||||
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: 'Zeige LKWs' } });
|
||||
fireEvent.click(screen.getByTestId('send-button'));
|
||||
await waitFor(() => { expect(screen.getByTestId('action-preview')).toBeInTheDocument(); });
|
||||
fireEvent.click(screen.getByTestId('confirm-action-0'));
|
||||
await waitFor(() => { expect(executeAction).toHaveBeenCalled(); });
|
||||
});
|
||||
|
||||
it('shows error on API failure', async () => {
|
||||
vi.mocked(sendChatMessage).mockRejectedValue(new Error('Network error'));
|
||||
render(<ChatInterface />);
|
||||
const input = screen.getByTestId('chat-input') as HTMLTextAreaElement;
|
||||
fireEvent.change(input, { target: { value: 'Test' } });
|
||||
fireEvent.click(screen.getByTestId('send-button'));
|
||||
await waitFor(() => { expect(screen.getByTestId('chat-error')).toBeInTheDocument(); });
|
||||
});
|
||||
|
||||
it('toggles history sidebar', async () => {
|
||||
vi.mocked(getChatHistory).mockResolvedValue({ items: [], total: 0, page: 1, page_size: 100 });
|
||||
render(<ChatInterface />);
|
||||
expect(screen.queryByTestId('chat-history')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId('toggle-history'));
|
||||
await waitFor(() => { expect(screen.getByTestId('chat-history')).toBeInTheDocument(); });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user