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:
Agent Zero
2026-07-18 11:21:51 +02:00
parent 4c9295de21
commit 8cebb4f4e9
52 changed files with 6493 additions and 41 deletions
@@ -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>
);
+5 -1
View File
@@ -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