Files
leocrm/frontend/src/components/ai/ChatWindow.tsx
T
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

198 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TODO: P3-F5 — Replace inline styles with Tailwind classes
import React, { useState, useRef, useEffect } from 'react';
import clsx from 'clsx';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
streamChat, fetchMessages, fetchAttachments, uploadAttachment, getAttachmentDownloadUrl,
type ChatMessage, type ChatAttachment,
} from '@/api/ai';
interface ChatWindowProps {
sessionId: string;
agentId?: string | null;
className?: string;
compact?: boolean;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function MessageContent({ content, role }: { content: string; role: string }) {
if (role === 'user') {
return <div className="whitespace-pre-wrap break-words">{content}</div>;
}
return (
<div className="ai-markdown max-w-none break-words">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
);
}
function ActivityIndicator({ status }: { status: string | null }) {
if (!status) return null;
return (
<div className="flex items-center gap-2 px-4 py-2 text-xs text-secondary-500">
<div className="flex gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-primary-400 animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1.5 h-1.5 rounded-full bg-primary-400 animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1.5 h-1.5 rounded-full bg-primary-400 animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<span>{status}</span>
</div>
);
}
export function ChatWindow({ sessionId, agentId, className, compact }: ChatWindowProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
const [input, setInput] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const [activity, setActivity] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploadingFile, setUploadingFile] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!sessionId) return;
setMessages([]);
setAttachments([]);
setError(null);
fetchMessages(sessionId).then(setMessages).catch((e) => setError(e?.message || 'Failed to load'));
fetchAttachments(sessionId).then(setAttachments).catch(() => {});
}, [sessionId]);
useEffect(() => {
if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}, [messages, streamingContent, activity]);
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
if (files.length === 0) return;
setUploadingFile(true);
try {
for (const file of files) {
const att = await uploadAttachment(sessionId, file);
setAttachments((prev) => [...prev, att]);
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed');
} finally {
setUploadingFile(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleSend = async () => {
const content = input.trim();
if (!content || isStreaming) return;
setInput('');
setIsStreaming(true);
setStreamingContent('');
setActivity('Denke nach...');
setError(null);
const userMsg: ChatMessage = { id: 'temp-' + Date.now(), session_id: sessionId, role: 'user', content, tokens: 0, model_used: '' };
setMessages((prev) => [...prev, userMsg]);
try {
const stream = streamChat(sessionId, content, agentId || undefined);
let accumulated = '';
for await (const event of stream) {
if (event.type === 'token' && event.content) {
accumulated += event.content;
setStreamingContent(accumulated);
setActivity(null);
} else if (event.type === 'tool_calls' && event.tools) {
setActivity(`Rufe Tool auf: ${event.tools.join(', ')}`);
} else if (event.type === 'tool_result' && event.tool) {
setActivity(`Tool '${event.tool}' ausgeführt`);
} else if (event.type === 'done') {
setActivity(null);
const msgs = await fetchMessages(sessionId);
setMessages(msgs);
setStreamingContent('');
} else if (event.type === 'error') {
setError(event.content || 'Unknown error');
setActivity(null);
}
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Stream failed');
} finally {
setIsStreaming(false);
setStreamingContent('');
setActivity(null);
inputRef.current?.focus();
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
};
return (
<div className={clsx('flex flex-col h-full bg-white', className)}>
{attachments.length > 0 && (
<div className={clsx('border-b border-secondary-200 bg-secondary-50', compact ? 'px-2 py-1' : 'px-4 py-2')}>
<div className="flex flex-wrap gap-2">
{attachments.map((att) => (
<a key={att.id} href={getAttachmentDownloadUrl(att.id)} target="_blank" rel="noopener noreferrer" className="flex items-center gap-1.5 rounded-md bg-white border border-secondary-200 px-2 py-1 text-xs hover:border-primary-400">
<svg className="w-3.5 h-3.5 text-secondary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
<span className="max-w-32 truncate">{att.filename}</span>
<span className="text-secondary-400">{formatSize(att.size_bytes)}</span>
</a>
))}
</div>
</div>
)}
<div ref={scrollRef} className={clsx('flex-1 overflow-y-auto', compact ? 'p-3' : 'p-6')}>
{messages.length === 0 && !streamingContent && !error && (
<div className="flex items-center justify-center h-full text-secondary-400 text-sm">Starte eine Konversation...</div>
)}
{messages.map((msg) => (
<div key={msg.id} className="mb-6">
<div className={clsx('text-xs font-medium mb-1', msg.role === 'user' ? 'text-primary-600' : 'text-secondary-400')}>
{msg.role === 'user' ? 'Du' : 'KI'}
</div>
<div className={clsx('rounded-lg px-4 py-2 text-sm', msg.role === 'user' ? 'bg-primary-50 border border-primary-100' : '')}>
<MessageContent content={msg.content} role={msg.role} />
{msg.tool_calls && msg.tool_calls.length > 0 && (
<div className="mt-2 text-xs text-secondary-400 border-l-2 border-secondary-200 pl-2">
Tools: {msg.tool_calls.map((tc: any) => tc.function?.name).join(', ')}
</div>
)}
</div>
</div>
))}
{streamingContent && (
<div className="mb-6">
<div className="text-xs font-medium mb-1 text-secondary-400">KI</div>
<div className="text-sm text-secondary-900">
<MessageContent content={streamingContent} role="assistant" />
</div>
</div>
)}
<ActivityIndicator status={activity} />
{error && <div className="mb-4 text-sm text-red-600">{error}</div>}
</div>
<div className={clsx('border-t border-secondary-200', compact ? 'p-2' : 'p-4')}>
<div className="flex items-end gap-2">
<input ref={fileInputRef} type="file" multiple onChange={handleFileSelect} className="hidden" />
<button onClick={() => fileInputRef.current?.click()} disabled={uploadingFile || isStreaming} className="rounded-lg border border-secondary-300 p-2 text-secondary-500 hover:bg-secondary-100 disabled:opacity-50" title="Datei anhängen">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" /></svg>
</button>
<textarea ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} disabled={isStreaming} placeholder="Nachricht eingeben..." rows={compact ? 1 : 2} className={clsx('flex-1 resize-none rounded-lg border border-secondary-300 px-3 py-2 text-sm', 'focus:outline-none focus:ring-2 focus:ring-primary-500', 'disabled:opacity-50')} />
<button onClick={handleSend} disabled={isStreaming || !input.trim()} className={clsx('rounded-lg px-4 py-2 text-sm font-medium text-white', 'bg-primary-600 hover:bg-primary-700', 'disabled:opacity-50 disabled:cursor-not-allowed')}>{isStreaming ? '...' : 'Senden'}</button>
</div>
</div>
</div>
);
}