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
+10
View File
@@ -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>
);
}
+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>
);
}
+97
View File
@@ -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);
});
}
+240
View 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(); });
});
});