feat: unified_search + ai_proactive plugins with Ollama Cloud DeepSeek V4
- unified_search: Hybride Suche (PostgreSQL FTS + pgvector + RRF Fusion) - 5 Search Providers (Contact, Company, Mail, File, Event) - KI Query Understanding (Fuzzy, Facetten via LiteLLM) - DMS Text-Extraction (PDF, DOCX, XLSX, PPTX) - Embedding Pipeline (ollama/nomic-embed-text, 768 Dim) - Background Jobs für Indexierung - Plugin-basierte Provider Registry - ai_proactive: Proaktiver KI-Agent - Context-Tracking (Frontend → Backend → Event Bus) - Proactive Engine mit LLM Suggestion-Generierung - SSE Real-time Push an Frontend - 6 AI Tools für Tool Registry - Rate-Limiting + User Settings - Deep Analysis Background Jobs - Frontend Integration: - useAIContext Hook, SuggestionSidebar, SuggestionBadge - ProactiveAISettings Page, Search API Client - Globale Suche auf neue API umgestellt - Tests: test_unified_search.py + test_ai_proactive.py (alle bestanden) - Config: Ollama Cloud DeepSeek V4 als Default, konfigurierbar - Dependencies: PyMuPDF, python-docx, python-pptx, pgvector - Bugfixes: notification type_key length, migration IF NOT EXISTS
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { apiClient } from '@/api/client';
|
||||
|
||||
export interface Suggestion {
|
||||
id: string;
|
||||
entity_type: string;
|
||||
entity_id: string | null;
|
||||
suggestion_type: 'info' | 'warning' | 'action' | 'insight';
|
||||
title: string;
|
||||
content: string;
|
||||
confidence: number;
|
||||
actions: Array<{ method: string; path: string; body: any; description: string }>;
|
||||
created_at: string;
|
||||
is_dismissed: boolean;
|
||||
is_acted_upon: boolean;
|
||||
}
|
||||
|
||||
export function useSuggestions() {
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial load
|
||||
apiClient.get('/api/v1/ai-proactive/suggestions').then(r => {
|
||||
setSuggestions(r.data.items);
|
||||
}).catch(() => {});
|
||||
|
||||
// SSE Stream
|
||||
const eventSource = new EventSource('/api/v1/ai-proactive/suggestions/stream');
|
||||
eventSource.onopen = () => setConnected(true);
|
||||
eventSource.onerror = () => setConnected(false);
|
||||
eventSource.onmessage = (e) => {
|
||||
try {
|
||||
const suggestion = JSON.parse(e.data);
|
||||
setSuggestions(prev => [suggestion, ...prev].slice(0, 50));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
return () => eventSource.close();
|
||||
}, []);
|
||||
|
||||
const dismiss = useCallback(async (id: string) => {
|
||||
setSuggestions(prev => prev.filter(s => s.id !== id));
|
||||
await apiClient.post(`/api/v1/ai-proactive/suggestions/${id}/dismiss`).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const act = useCallback(async (id: string, actionIndex: number) => {
|
||||
const result = await apiClient.post(`/api/v1/ai-proactive/suggestions/${id}/act`, {
|
||||
action_index: actionIndex
|
||||
});
|
||||
setSuggestions(prev => prev.map(s => s.id === id ? { ...s, is_acted_upon: true } : s));
|
||||
return result.data;
|
||||
}, []);
|
||||
|
||||
return { suggestions, connected, dismiss, act };
|
||||
}
|
||||
|
||||
export function useProactiveSettings() {
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
apiClient.get('/api/v1/ai-proactive/settings').then(r => setSettings(r.data));
|
||||
}, []);
|
||||
|
||||
const update = useCallback(async (updates: any) => {
|
||||
const r = await apiClient.put('/api/v1/ai-proactive/settings', updates);
|
||||
setSettings(r.data);
|
||||
return r.data;
|
||||
}, []);
|
||||
|
||||
return { settings, update };
|
||||
}
|
||||
@@ -388,45 +388,9 @@ export function useGlobalSearch(query: string, entityTypes?: string[]) {
|
||||
queryKey: ['globalSearch', query, entityTypes],
|
||||
queryFn: async (): Promise<SearchResult[]> => {
|
||||
if (!query.trim()) return [];
|
||||
const results: SearchResult[] = [];
|
||||
const types = entityTypes && entityTypes.length > 0 ? entityTypes : ['company', 'contact'];
|
||||
const tasks: Promise<void>[] = [];
|
||||
if (types.includes('company')) {
|
||||
tasks.push(
|
||||
apiGet<PaginatedResponse<Company>>(`/companies?page=1&page_size=10&search=${encodeURIComponent(query)}`)
|
||||
.then((data) => {
|
||||
for (const item of data.items) {
|
||||
results.push({
|
||||
type: 'company',
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.industry || item.email || '',
|
||||
url: `/companies/${item.id}`,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
);
|
||||
}
|
||||
if (types.includes('contact')) {
|
||||
tasks.push(
|
||||
apiGet<PaginatedResponse<Contact>>(`/contacts?page=1&page_size=10&search=${encodeURIComponent(query)}`)
|
||||
.then((data) => {
|
||||
for (const item of data.items) {
|
||||
results.push({
|
||||
type: 'contact',
|
||||
id: item.id,
|
||||
name: `${item.first_name} ${item.last_name}`,
|
||||
description: item.email || item.phone || '',
|
||||
url: `/contacts/${item.id}`,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
);
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
return results;
|
||||
const { search } = await import('@/api/search');
|
||||
const response = await search(query, entityTypes, 20);
|
||||
return response.results || [];
|
||||
},
|
||||
enabled: query.trim().length > 0,
|
||||
staleTime: 30 * 1000,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface SearchResult {
|
||||
type: 'company' | 'contact' | 'mail' | 'file' | 'event';
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[];
|
||||
facets?: Record<string, Array<{ value: string; count: number }>>;
|
||||
summary?: string;
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
export async function search(query: string, entityTypes?: string[], limit = 20): Promise<SearchResponse> {
|
||||
const r = await apiClient.post('/api/v1/search', {
|
||||
query,
|
||||
entity_types: entityTypes,
|
||||
limit,
|
||||
});
|
||||
return r.data;
|
||||
}
|
||||
|
||||
export async function searchSuggest(q: string): Promise<string[]> {
|
||||
const r = await apiClient.get('/api/v1/search/suggest', { params: { q } });
|
||||
return r.data.suggestions;
|
||||
}
|
||||
|
||||
export async function searchSimilar(entityType: string, entityId: string): Promise<SearchResult[]> {
|
||||
const r = await apiClient.post('/api/v1/search/similar', {
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
});
|
||||
return r.data.similar;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiClient } from '@/api/client';
|
||||
|
||||
interface SuggestionBadgeProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function SuggestionBadge({ onClick }: SuggestionBadgeProps) {
|
||||
const [count, setCount] = useState(0);
|
||||
const [pulsing, setPulsing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Initial count
|
||||
apiClient.get('/api/v1/ai-proactive/suggestions').then(r => {
|
||||
setCount(r.data.items.length);
|
||||
}).catch(() => {});
|
||||
|
||||
// SSE for real-time updates
|
||||
const eventSource = new EventSource('/api/v1/ai-proactive/suggestions/stream');
|
||||
eventSource.onmessage = () => {
|
||||
setCount(prev => prev + 1);
|
||||
setPulsing(true);
|
||||
setTimeout(() => setPulsing(false), 2000);
|
||||
};
|
||||
|
||||
return () => eventSource.close();
|
||||
}, []);
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="relative p-2 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="KI Vorschläge"
|
||||
>
|
||||
🤖
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`relative p-2 text-gray-500 hover:text-gray-700 transition-colors ${
|
||||
pulsing ? 'animate-pulse' : ''
|
||||
}`}
|
||||
title={`${count} KI Vorschläge`}
|
||||
>
|
||||
🤖
|
||||
<span
|
||||
className={`absolute -top-0 -right-0 min-w-[18px] h-[18px] flex items-center justify-center text-[10px] font-bold text-white rounded-full ${
|
||||
pulsing ? 'bg-red-500 animate-bounce' : 'bg-blue-500'
|
||||
}`}
|
||||
>
|
||||
{count > 99 ? '99+' : count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState } from 'react';
|
||||
import type { Suggestion } from '@/api/aiProactive';
|
||||
|
||||
const typeConfig = {
|
||||
info: { icon: '💡', color: 'blue', bg: 'bg-blue-50', border: 'border-blue-200', text: 'text-blue-700', bar: 'bg-blue-500' },
|
||||
warning: { icon: '⚠️', color: 'orange', bg: 'bg-orange-50', border: 'border-orange-200', text: 'text-orange-700', bar: 'bg-orange-500' },
|
||||
action: { icon: '✅', color: 'green', bg: 'bg-green-50', border: 'border-green-200', text: 'text-green-700', bar: 'bg-green-500' },
|
||||
insight: { icon: '🔍', color: 'purple', bg: 'bg-purple-50', border: 'border-purple-200', text: 'text-purple-700', bar: 'bg-purple-500' },
|
||||
};
|
||||
|
||||
interface SuggestionCardProps {
|
||||
suggestion: Suggestion;
|
||||
onDismiss: (id: string) => void;
|
||||
onAct: (id: string, actionIndex: number) => void;
|
||||
}
|
||||
|
||||
export function SuggestionCard({ suggestion, onDismiss, onAct }: SuggestionCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const config = typeConfig[suggestion.suggestion_type] || typeConfig.info;
|
||||
const confidencePercent = Math.round(suggestion.confidence * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`animate-slide-in ${config.bg} ${config.border} border rounded-lg p-4 mb-3 shadow-sm hover:shadow-md transition-shadow`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{config.icon}</span>
|
||||
<h3 className={`font-semibold text-sm ${config.text}`}>{suggestion.title}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDismiss(suggestion.id)}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title="Ignorieren"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<p className="text-sm text-gray-600 mb-3 line-clamp-3">{suggestion.content}</p>
|
||||
|
||||
{/* Confidence Bar */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="flex-1 h-1.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full ${config.bar} rounded-full transition-all duration-500`}
|
||||
style={{ width: `${confidencePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 font-mono">{confidencePercent}%</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{suggestion.actions && suggestion.actions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{suggestion.actions.slice(0, expanded ? undefined : 2).map((action, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => onAct(suggestion.id, idx)}
|
||||
disabled={suggestion.is_acted_upon}
|
||||
className={`w-full text-left px-3 py-2 rounded-md text-xs font-medium transition-all ${
|
||||
suggestion.is_acted_upon
|
||||
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||||
: 'bg-white hover:bg-gray-50 text-gray-700 border border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="font-mono text-[10px] uppercase mr-1 px-1 py-0.5 bg-gray-100 rounded">
|
||||
{action.method}
|
||||
</span>
|
||||
{action.description}
|
||||
</button>
|
||||
))}
|
||||
{suggestion.actions.length > 2 && (
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
{expanded ? 'Weniger' : `${suggestion.actions.length - 2} weitere Aktionen`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Acted upon badge */}
|
||||
{suggestion.is_acted_upon && (
|
||||
<div className="mt-2 text-xs text-green-600 font-medium flex items-center gap-1">
|
||||
✓ Ausgeführt
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from 'react';
|
||||
import { useSuggestions } from '@/api/aiProactive';
|
||||
import { SuggestionCard } from '@/components/ai/SuggestionCard';
|
||||
|
||||
interface SuggestionSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SuggestionSidebar({ isOpen, onClose }: SuggestionSidebarProps) {
|
||||
const { suggestions, connected, dismiss, act } = useSuggestions();
|
||||
const [filter, setFilter] = useState<string>('all');
|
||||
|
||||
const filteredSuggestions = filter === 'all'
|
||||
? suggestions
|
||||
: suggestions.filter(s => s.suggestion_type === filter);
|
||||
|
||||
const filterOptions = [
|
||||
{ value: 'all', label: 'Alle' },
|
||||
{ value: 'info', label: 'Info' },
|
||||
{ value: 'warning', label: 'Warnung' },
|
||||
{ value: 'action', label: 'Aktion' },
|
||||
{ value: 'insight', label: 'Erkenntnis' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/20 z-40 lg:hidden"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed right-0 top-0 h-full w-96 bg-white shadow-2xl z-50 transform transition-transform duration-300 flex flex-col ${
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-semibold text-gray-800">KI Vorschläge</h2>
|
||||
{connected && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
Live
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors p-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<div className="flex gap-1 p-3 border-b border-gray-100 overflow-x-auto">
|
||||
{filterOptions.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setFilter(opt.value)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium whitespace-nowrap transition-colors ${
|
||||
filter === opt.value
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{filteredSuggestions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-gray-400">
|
||||
<div className="text-4xl mb-3">🤖</div>
|
||||
<p className="text-sm">Keine Vorschläge vorhanden</p>
|
||||
<p className="text-xs mt-1">Die KI analysiert deinen Kontext...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{filteredSuggestions.map(suggestion => (
|
||||
<SuggestionCard
|
||||
key={suggestion.id}
|
||||
suggestion={suggestion}
|
||||
onDismiss={dismiss}
|
||||
onAct={act}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-3 border-t border-gray-200 text-center">
|
||||
<span className="text-xs text-gray-400">
|
||||
{suggestions.length} Vorschläge insgesamt
|
||||
</span>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,17 @@ import { Sidebar } from './Sidebar';
|
||||
import { TopBar } from './TopBar';
|
||||
import { PluginToolbar } from './PluginToolbar';
|
||||
import { AISidebar } from './AISidebar';
|
||||
import { SuggestionSidebar } from '@/components/ai/SuggestionSidebar';
|
||||
import { ToastContainer } from '@/components/ui/Toast';
|
||||
import { useUIStore } from '@/store/uiStore';
|
||||
import { useAIContext } from '@/hooks/useAIContext';
|
||||
|
||||
export function AppShell() {
|
||||
const location = useLocation();
|
||||
const { suggestionSidebarOpen, toggleSuggestionSidebar } = useUIStore();
|
||||
|
||||
// Track context for AI Proactive suggestions
|
||||
useAIContext();
|
||||
|
||||
// Hide AI sidebar on the AI Assistant page itself
|
||||
const showAISidebar = !location.pathname.startsWith('/ai-assistant');
|
||||
@@ -33,6 +39,11 @@ export function AppShell() {
|
||||
</div>
|
||||
{/* AI Sidebar — full height, right of TopBar and Toolbar */}
|
||||
{showAISidebar && <AISidebar />}
|
||||
{/* Suggestion Sidebar — overlays from right */}
|
||||
<SuggestionSidebar
|
||||
isOpen={suggestionSidebarOpen}
|
||||
onClose={() => toggleSuggestionSidebar()}
|
||||
/>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,12 +9,13 @@ import { useLogout } from '@/api/hooks';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { SearchDropdown } from '@/components/shared/SearchDropdown';
|
||||
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
|
||||
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
const { toggleSidebar, toggleAISidebar, aiSidebarCollapsed } = useUIStore();
|
||||
const { toggleSidebar, toggleAISidebar, toggleSuggestionSidebar, aiSidebarCollapsed } = useUIStore();
|
||||
const { currentTenant, availableTenants, switchTenant, isSwitching } = useTenant();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
@@ -141,6 +142,9 @@ export function TopBar() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* AI Suggestions Badge */}
|
||||
<SuggestionBadge onClick={toggleSuggestionSidebar} />
|
||||
|
||||
{/* Notifications */}
|
||||
<div ref={notifRef} className="relative">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { apiClient } from '@/api/client';
|
||||
|
||||
export function useAIContext(entityType?: string, entityId?: string, entityData?: any) {
|
||||
const location = useLocation();
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
apiClient.post('/api/v1/ai-proactive/context', {
|
||||
page: location.pathname,
|
||||
entity_type: entityType,
|
||||
entity_id: entityId,
|
||||
entity_data: entityData,
|
||||
}).catch(() => {}); // Silent fail, don't bother user
|
||||
}, 500); // Debounce 500ms
|
||||
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [location.pathname, entityType, entityId, entityData]);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { useProactiveSettings } from '@/api/aiProactive';
|
||||
|
||||
const categoryLabels: Record<string, string> = {
|
||||
mail: 'E-Mails',
|
||||
tasks: 'Aufgaben',
|
||||
contacts: 'Kontakte',
|
||||
companies: 'Firmen',
|
||||
insights: 'Erkenntnisse',
|
||||
};
|
||||
|
||||
const modelOptions = [
|
||||
{ value: 'ollama/deepseek-v4', label: 'DeepSeek V4 (empfohlen)' },
|
||||
{ value: 'ollama/deepseek-v4-pro', label: 'DeepSeek V4 Pro (präzise)' },
|
||||
{ value: 'ollama/llama3.2', label: 'Llama 3.2 (Alternative)' },
|
||||
{ value: 'ollama/gpt-4o-mini', label: 'GPT-4o mini via Ollama' },
|
||||
{ value: 'gpt-4o-mini', label: 'GPT-4o mini (Direct OpenAI)' },
|
||||
];
|
||||
|
||||
export function ProactiveAISettings() {
|
||||
const { settings, update } = useProactiveSettings();
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 text-gray-400">
|
||||
<div className="animate-pulse">Lade Einstellungen...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toggleCategory = async (cat: string) => {
|
||||
const current = settings.suggestion_categories || [];
|
||||
const newCats = current.includes(cat)
|
||||
? current.filter((c: string) => c !== cat)
|
||||
: [...current, cat];
|
||||
await update({ suggestion_categories: newCats });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
{/* Enable/Disable */}
|
||||
<div className="flex items-center justify-between p-4 bg-white rounded-lg border border-gray-200">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800">Proaktive KI</h3>
|
||||
<p className="text-sm text-gray-500">Aktiviert oder deaktiviert alle Vorschläge</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => update({ enabled: !settings.enabled })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
settings.enabled ? 'bg-blue-500' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
settings.enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Categories */}
|
||||
<div className="p-4 bg-white rounded-lg border border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800 mb-3">Kategorien</h3>
|
||||
<p className="text-sm text-gray-500 mb-4">Für welche Bereiche sollen Vorschläge generiert werden?</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Object.entries(categoryLabels).map(([key, label]) => {
|
||||
const checked = settings.suggestion_categories?.includes(key);
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={`flex items-center gap-2 p-3 rounded-lg border cursor-pointer transition-colors ${
|
||||
checked ? 'bg-blue-50 border-blue-300' : 'bg-gray-50 border-gray-200 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked || false}
|
||||
onChange={() => toggleCategory(key)}
|
||||
className="w-4 h-4 rounded text-blue-500 focus:ring-blue-400"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">{label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confidence Threshold */}
|
||||
<div className="p-4 bg-white rounded-lg border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold text-gray-800">Confidence Schwellwert</h3>
|
||||
<span className="text-sm font-mono text-blue-600">
|
||||
{Math.round((settings.confidence_threshold || 0.5) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Nur Vorschläge mit höherer Confidence anzeigen
|
||||
</p>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={settings.confidence_threshold || 0.5}
|
||||
onChange={(e) => update({ confidence_threshold: parseFloat(e.target.value) })}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>Alle</span>
|
||||
<span>Sehr sicher</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate Limit */}
|
||||
<div className="p-4 bg-white rounded-lg border border-gray-200">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold text-gray-800">Rate Limit</h3>
|
||||
<span className="text-sm font-mono text-blue-600">
|
||||
{settings.rate_limit_seconds || 10}s
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Mindestabstand zwischen Vorschlägen
|
||||
</p>
|
||||
<input
|
||||
type="range"
|
||||
min="5"
|
||||
max="60"
|
||||
step="5"
|
||||
value={settings.rate_limit_seconds || 10}
|
||||
onChange={(e) => update({ rate_limit_seconds: parseInt(e.target.value) })}
|
||||
className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
||||
<span>5s</span>
|
||||
<span>60s</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<div className="p-4 bg-white rounded-lg border border-gray-200">
|
||||
<h3 className="font-semibold text-gray-800 mb-2">KI Modell (Ollama Cloud)</h3>
|
||||
<p className="text-sm text-gray-500 mb-3">Wähle das Modell für Vorschläge. DeepSeek V4 ist empfohlen für beste Performance/Kosten.</p>
|
||||
<select
|
||||
value={settings.model || 'ollama/deepseek-v4'}
|
||||
onChange={(e) => update({ model: e.target.value })}
|
||||
className="w-full px-3 py-2 rounded-lg border border-gray-200 bg-white text-sm text-gray-700 focus:ring-2 focus:ring-blue-400 focus:border-blue-400 outline-none"
|
||||
>
|
||||
{modelOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export function SettingsPage() {
|
||||
{ to: '/settings/sequences', label: t('sequences.title'), icon: '\ud83d\udd22' },
|
||||
{ to: '/settings/mail', label: t('mail.settings'), icon: '\ud83d\udce7' },
|
||||
{ to: '/settings/ai', label: t('nav.aiAssistant'), icon: '\ud83e\udde0' },
|
||||
{ to: '/settings/ai-proactive', label: 'Proaktive KI', icon: '\ud83e\udd16' },
|
||||
{ to: '/settings/notifications', label: t('settings.notifications'), icon: '\ud83d\udd14' },
|
||||
];
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import { MailSettingsPage } from '@/pages/MailSettings';
|
||||
import { SettingsNotificationsPage } from '@/pages/SettingsNotifications';
|
||||
import { AIAssistantPage } from '@/pages/AIAssistant';
|
||||
import { AISettingsPage } from '@/pages/AISettings';
|
||||
import { ProactiveAISettings } from '@/pages/ProactiveAISettings';
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
@@ -89,6 +90,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'mail', element: <MailSettingsPage /> },
|
||||
{ path: 'notifications', element: <SettingsNotificationsPage /> },
|
||||
{ path: 'ai', element: <AISettingsPage /> },
|
||||
{ path: 'ai-proactive', element: <ProactiveAISettings /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -14,12 +14,14 @@ export interface UIState {
|
||||
theme: Theme;
|
||||
locale: Locale;
|
||||
sidebarOpen: boolean;
|
||||
suggestionSidebarOpen: boolean;
|
||||
aiSidebarCollapsed: boolean;
|
||||
toasts: Toast[];
|
||||
setTheme: (theme: Theme) => void;
|
||||
setLocale: (locale: Locale) => void;
|
||||
toggleSidebar: () => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
toggleSuggestionSidebar: () => void;
|
||||
toggleAISidebar: () => void;
|
||||
setAISidebarCollapsed: (collapsed: boolean) => void;
|
||||
addToast: (toast: Omit<Toast, 'id'>) => void;
|
||||
@@ -33,6 +35,7 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
theme: (localStorage.getItem('leocrm_theme') as Theme) || 'light',
|
||||
locale: (localStorage.getItem('leocrm_lang') as Locale) || 'de',
|
||||
sidebarOpen: true,
|
||||
suggestionSidebarOpen: false,
|
||||
aiSidebarCollapsed: true,
|
||||
toasts: [],
|
||||
setTheme: (theme) => {
|
||||
@@ -45,6 +48,7 @@ export const useUIStore = create<UIState>((set) => ({
|
||||
},
|
||||
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
||||
setSidebarOpen: (open) => set({ sidebarOpen: open }),
|
||||
toggleSuggestionSidebar: () => set((s) => ({ suggestionSidebarOpen: !s.suggestionSidebarOpen })),
|
||||
toggleAISidebar: () => set((s) => ({ aiSidebarCollapsed: !s.aiSidebarCollapsed })),
|
||||
setAISidebarCollapsed: (collapsed) => set({ aiSidebarCollapsed: collapsed }),
|
||||
addToast: (toast) => {
|
||||
|
||||
Reference in New Issue
Block a user