diff --git a/frontend/src/api/hooks.ts b/frontend/src/api/hooks.ts index 9f2b84b..456289b 100644 --- a/frontend/src/api/hooks.ts +++ b/frontend/src/api/hooks.ts @@ -24,13 +24,8 @@ export * from './automation'; // ── Search hook (uses dynamic import, kept inline) ── -export interface SearchResult { - type: 'company' | 'contact' | 'mail' | 'file' | 'event'; - id: string; - name: string; - description?: string; - url: string; -} +import type { SearchResult } from './search'; +export type { SearchResult }; export function useGlobalSearch(query: string, entityTypes?: string[]) { return useQuery({ diff --git a/frontend/src/api/search.ts b/frontend/src/api/search.ts index 87a5d34..e712c02 100644 --- a/frontend/src/api/search.ts +++ b/frontend/src/api/search.ts @@ -1,11 +1,13 @@ import { apiClient } from './client'; export interface SearchResult { - type: 'contact' | 'mail' | 'file' | 'event'; + type: string; id: string; name: string; description?: string; url: string; + score?: number; + data?: Record; } export interface SearchResponse { @@ -15,18 +17,55 @@ export interface SearchResponse { suggestions?: string[]; } +// Map backend entity_type to frontend route URL +const ENTITY_URL_MAP: Record) => string> = { + contact: (id) => `/contacts/${id}`, + company: (id) => `/contacts/${id}`, + mail: (id) => `/mail?message_id=${id}`, + file: (id) => `/dms?file_id=${id}`, + event: (id) => `/calendar?event_id=${id}`, + message: (_id, data) => data?.conversation_id ? `/communication?conversation_id=${data.conversation_id}` : '/communication', +}; + +function mapBackendResult(r: Record): SearchResult { + const entityType = (r.entity_type as string) || 'unknown'; + const entityId = (r.entity_id as string) || (r.id as string) || ''; + const title = (r.title as string) || (r.name as string) || ''; + const snippet = (r.snippet as string) || (r.description as string) || ''; + const data = (r.data as Record) || {}; + const urlFn = ENTITY_URL_MAP[entityType]; + const url = urlFn ? urlFn(entityId, data) : '#'; + return { + type: entityType, + id: entityId, + name: title, + description: snippet, + url, + score: (r.score as number) || 0, + data, + }; +} + export async function search(query: string, entityTypes?: string[], limit = 20): Promise { const r = await apiClient.post('/search', { query, entity_types: entityTypes, limit, }); - return r.data; + const data = r.data; + // Backend returns { results: [{entity_type, entity_id, title, snippet, score, data}], ... } + const rawResults = data.results || []; + return { + results: rawResults.map(mapBackendResult), + facets: data.facets, + summary: data.summary, + suggestions: data.suggestions, + }; } export async function searchSuggest(q: string): Promise { const r = await apiClient.get('/search/suggest', { params: { q } }); - return r.data.suggestions; + return r.data.suggestions || []; } export async function searchSimilar(entityType: string, entityId: string): Promise { @@ -34,5 +73,12 @@ export async function searchSimilar(entityType: string, entityId: string): Promi entity_type: entityType, entity_id: entityId, }); - return r.data.similar; + const similar = r.data.similar || {}; + const all: SearchResult[] = []; + for (const items of Object.values(similar) as Record[][]) { + for (const item of items) { + all.push(mapBackendResult(item)); + } + } + return all; } diff --git a/frontend/src/pages/Communication.tsx b/frontend/src/pages/Communication.tsx new file mode 100644 index 0000000..38b64bd --- /dev/null +++ b/frontend/src/pages/Communication.tsx @@ -0,0 +1,858 @@ +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import clsx from 'clsx'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { + MessageSquare, Bot, Users, Pin, Search, Plus, Send, Paperclip, + Smile, MoreVertical, ChevronDown, ChevronRight, Hash, Bell, + ExternalLink, Loader2, X, Sparkles, +} from 'lucide-react'; +import { ResizablePanel } from '@/components/ui/ResizablePanel'; +import { usePluginToolbarStore } from '@/store/pluginToolbarStore'; +import { apiClient } from '@/api/client'; +import { streamChat, fetchAgents, type AIAgent } from '@/api/ai'; + +// ─── Types ─── + +interface Conversation { + id: string; + title: string | null; + is_pinned: boolean; + is_direct: boolean; + is_archived: boolean; + is_locked: boolean; + last_msg_at: string | null; + last_msg_preview: string | null; + last_msg_sender_type: string | null; + unread_count: number; + participants: Participant[]; + metadata: Record; +} + +interface Participant { + id: string; + participant_id: string | null; + participant_type: string; + display_name: string | null; + role: string; +} + +interface Message { + id: string; + conversation_id: string; + sender_id: string | null; + sender_type: string; + content: string; + content_format: string; + created_at: string | null; + edited_at: string | null; + is_pinned: boolean; + blocks: MessageBlock[]; + attachments: MessageAttachment[]; + reactions: MessageReaction[]; + reply_to_id: string | null; +} + +interface MessageBlock { + id: string; + block_type: string; + block_data: Record; + sort_order: number; +} + +interface MessageAttachment { + id: string; + file_id: string | null; + file_name: string; + file_type: string; + file_size: number | null; +} + +interface MessageReaction { + id: string; + message_id: string; + user_id: string; + emoji: string; +} + +interface MiniApp { + app_id: string; + name: string; + icon: string; + description: string; + plugin_name: string; +} + +type ConversationCategory = 'system' | 'ai' | 'colleague'; + +// ─── Helpers ─── + +function categorizeConversation(conv: Conversation): ConversationCategory { + // System messages: created by system participant type + if (conv.participants.some(p => p.participant_type === 'system')) return 'system'; + // AI conversations: have an AI participant + if (conv.participants.some(p => p.participant_type === 'ai')) return 'ai'; + // Everything else is colleague chat + return 'colleague'; +} + +function formatTime(iso: string | null): string { + if (!iso) return ''; + const d = new Date(iso); + const now = new Date(); + if (d.toDateString() === now.toDateString()) { + return d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + } + return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); +} + +function getConversationDisplayName(conv: Conversation, currentUserId?: string): string { + if (conv.title) return conv.title; + const others = conv.participants.filter(p => p.participant_id !== currentUserId); + if (others.length === 0) return 'Neue Konversation'; + if (others.length === 1) return others[0].display_name || 'Unbekannt'; + return others.map(p => p.display_name || '?').join(', '); +} + +// ─── API calls ─── + +async function fetchConversations(): Promise { + const r = await apiClient.get('/comm/conversations'); + return r.data.items || []; +} + +async function fetchMessages(convId: string): Promise { + const r = await apiClient.get(`/comm/conversations/${convId}/messages`); + return r.data.items || []; +} + +async function sendMessage(convId: string, content: string, replyToId?: string): Promise { + const r = await apiClient.post(`/comm/conversations/${convId}/messages`, { + content, + content_format: 'text', + reply_to_id: replyToId, + }); + return r.data; +} + +async function createConversation(title: string | null, participantIds: string[], isDirect: boolean, initialMessage?: string): Promise { + const r = await apiClient.post('/comm/conversations', { + title, + participant_ids: participantIds, + is_direct: isDirect, + initial_message: initialMessage, + }); + return r.data; +} + +async function pinConversation(convId: string): Promise { + await apiClient.post(`/comm/conversations/${convId}/pin`); +} + +async function unpinConversation(convId: string): Promise { + await apiClient.delete(`/comm/conversations/${convId}/pin`); +} + +async function markConversationRead(convId: string): Promise { + await apiClient.post(`/comm/conversations/${convId}/read`, {}); +} + +async function fetchMiniApps(): Promise { + const r = await apiClient.get('/comm/miniapps'); + return r.data.items || []; +} + +async function fetchUsers(): Promise<{id: string; name: string; email: string}[]> { + const r = await apiClient.get('/users'); + return r.data.items || r.data || []; +} + +// ─── Tree Node ─── + +interface TreeSection { + category: ConversationCategory; + label: string; + icon: React.ReactNode; + conversations: Conversation[]; +} + +function buildTree(conversations: Conversation[], currentUserId?: string): TreeSection[] { + const system = conversations.filter(c => categorizeConversation(c) === 'system'); + const ai = conversations.filter(c => categorizeConversation(c) === 'ai'); + const colleague = conversations.filter(c => categorizeConversation(c) === 'colleague'); + + // Sort: pinned first, then by last_msg_at + const sortFn = (a: Conversation, b: Conversation) => { + if (a.is_pinned !== b.is_pinned) return a.is_pinned ? -1 : 1; + const aTime = a.last_msg_at || ''; + const bTime = b.last_msg_at || ''; + return bTime.localeCompare(aTime); + }; + + system.sort(sortFn); + ai.sort(sortFn); + colleague.sort(sortFn); + + return [ + { category: 'system', label: 'Systemnachrichten', icon: , conversations: system }, + { category: 'ai', label: 'KI Chats', icon: , conversations: ai }, + { category: 'colleague', label: 'Kollegen', icon: , conversations: colleague }, + ]; +} + +// ─── Conversation Tree ─── + +interface ConversationTreeProps { + conversations: Conversation[]; + activeConvId: string | null; + onSelect: (id: string) => void; + onNewChat: (category: ConversationCategory) => void; loading: boolean; +} + +function ConversationTree({ conversations, activeConvId, onSelect, onNewChat, loading }: ConversationTreeProps) { + const [expanded, setExpanded] = useState>({ + system: true, + ai: true, + colleague: true, + }); + const tree = useMemo(() => buildTree(conversations), [conversations]); + + const toggle = (cat: ConversationCategory) => setExpanded(s => ({ ...s, [cat]: !s[cat] })); + + return ( +
+
+

+ + Kommunikation +

+
+
+ {loading && ( +
+ +
+ )} + {!loading && tree.map(section => ( +
+
toggle(section.category)} + > + {expanded[section.category] + ? + : + } + {section.icon} + {section.label} + {section.conversations.length} + +
+ {expanded[section.category] && section.conversations.map(conv => ( + + ))} + {expanded[section.category] && section.conversations.length === 0 && ( +
Keine Konversationen
+ )} +
+ ))} +
+
+ ); +} + +// ─── Chat Window ─── + +interface ChatWindowProps { + convId: string; + category: ConversationCategory; + onBack?: () => void; +} + +function ChatWindow({ convId, category, onBack }: ChatWindowProps) { + const [messages, setMessages] = useState([]); + const [input, setInput] = useState(''); + const [loading, setLoading] = useState(true); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const [aiStreaming, setAiStreaming] = useState(false); + const [streamingContent, setStreamingContent] = useState(''); + const [showMiniApps, setShowMiniApps] = useState(false); + const [miniApps, setMiniApps] = useState([]); + const [agents, setAgents] = useState([]); + const [selectedAgentId, setSelectedAgentId] = useState(null); + const [aiSessionId, setAiSessionId] = useState(null); + const scrollRef = useRef(null); + const wsRef = useRef(null); + + // Load messages + useEffect(() => { + if (!convId) return; + setLoading(true); + setError(null); + fetchMessages(convId) + .then(msgs => { setMessages(msgs); setLoading(false); }) + .catch(e => { setError(e?.message || 'Fehler beim Laden'); setLoading(false); }); + markConversationRead(convId).catch(() => {}); + }, [convId]); + + // Load agents and mini-apps + useEffect(() => { + fetchAgents().then(setAgents).catch(() => {}); + fetchMiniApps().then(setMiniApps).catch(() => {}); + }, []); + + // Auto-select default agent for AI chats + useEffect(() => { + if (category === 'ai' && agents.length > 0 && !selectedAgentId) { + const def = agents.find(a => a.is_default) || agents[0]; + setSelectedAgentId(def.id); + } + }, [category, agents, selectedAgentId]); + + // WebSocket connection + useEffect(() => { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/v1/comm/ws`; + try { + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + ws.onopen = () => { + ws.send(JSON.stringify({ type: 'subscribe', conversation_id: convId })); + }; + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'message' && data.conversation_id === convId) { + setMessages(prev => [...prev, data.message]); + } + } catch {} + }; + return () => { ws.close(); wsRef.current = null; }; + } catch {} + }, [convId]); + + // Auto-scroll + useEffect(() => { + if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + }, [messages, streamingContent]); + + const handleSend = async () => { + if (!input.trim() || sending) return; + const content = input.trim(); + setInput(''); + setSending(true); + setError(null); + + try { + if (category === 'ai' && aiSessionId) { + // AI streaming chat + setAiStreaming(true); + setStreamingContent(''); + let accumulated = ''; + for await (const event of streamChat(aiSessionId, content, selectedAgentId || undefined)) { + if (event.type === 'token' && event.content) { + accumulated += event.content; + setStreamingContent(accumulated); + } else if (event.type === 'done') { + break; + } else if (event.type === 'error') { + setError(event.content || 'KI Fehler'); + break; + } + } + setAiStreaming(false); + setStreamingContent(''); + // Reload messages to get the AI response + const msgs = await fetchMessages(convId); + setMessages(msgs); + } else { + // Regular message + const msg = await sendMessage(convId, content); + setMessages(prev => [...prev, msg]); + } + } catch (e: any) { + setError(e?.message || 'Senden fehlgeschlagen'); + } finally { + setSending(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }; + + const startAiSession = async () => { + try { + const r = await apiClient.post('/ai/sessions', { + title: 'KI Chat', + agent_id: selectedAgentId, + is_sidebar: false, + }); + setAiSessionId(r.data.id); + } catch (e: any) { + setError('KI Session konnte nicht gestartet werden'); + } + }; + + // Auto-start AI session for AI conversations + useEffect(() => { + if (category === 'ai' && !aiSessionId && selectedAgentId) { + startAiSession(); + } + }, [category, aiSessionId, selectedAgentId]); + + return ( +
+ {/* Header */} +
+ {onBack && ( + + )} + + + {messages[0]?.conversation_id === convId ? 'Konversation' : 'Chat'} + + {category === 'ai' && agents.length > 0 && ( + + )} + + +
+ + {/* Mini-Apps panel */} + {showMiniApps && miniApps.length > 0 && ( +
+ {miniApps.map(app => ( + + ))} +
+ )} + + {/* Messages */} +
+ {loading && ( +
+ +
+ )} + {error && ( +
{error}
+ )} + {!loading && messages.length === 0 && !aiStreaming && ( +
+ +

Keine Nachrichten. Schreibe die erste!

+
+ )} + {messages.map(msg => ( + + ))} + {aiStreaming && ( +
+
+ +
+
+
KI antwortet...
+
+ {streamingContent || '...'} +
+
+
+ )} +
+ + {/* Input */} +
+
+ + +