163 lines
5.4 KiB
TypeScript
163 lines
5.4 KiB
TypeScript
|
|
'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>
|
||
|
|
);
|
||
|
|
}
|