Phase 4: KI-UI-Steuerung — AI agent UI control via WebSocket

- New ai_ui_control plugin: WS endpoint /ws/ai-ui-control, REST API (POST /command, GET /command/{id}/status, GET /online-users)
- UI-Command-Protocol: 6 command types (navigate, filter, open_contact, modal, tab, settings) with Pydantic schemas
- WebSocket manager: per-user connections, command delivery, feedback storage, stale cleanup
- Frontend useAIUIControl hook: WS client with auto-reconnect, command dispatch, feedback sending
- aiUIControlStore: Zustand store for command state, active modal/tab, pending filter/settings
- AIUIControlIndicator: visual KI indication (Bot icon, toast, pulse animation)
- ContactDetail integration: syncs activeTab and personModalOpen from AI control store
- AppShell integration: useAIUIControl hook + AIUIControlIndicator
- i18n keys for DE/EN
- 18 Vitest tests: command protocol, store actions, feedback, visual indication
- TSC: 0 new errors (only 2 pre-existing Dms.tsx errors)
This commit is contained in:
Agent Zero
2026-07-23 20:13:39 +02:00
parent 5dc6f29ac1
commit 903d649a0f
15 changed files with 1582 additions and 5 deletions
@@ -0,0 +1,86 @@
/**
* AIUIControlIndicator — Visual indication when AI is controlling the UI.
*
* Task 4.11: Visuelle KI-Indikation
* When AI executes an action: highlight effect + toast "KI führt Aktion aus...".
* User sees that AI is acting.
*/
import React, { useEffect } from 'react';
import { Bot, X } from 'lucide-react';
import { useAIUIControlStore } from '@/store/aiUIControlStore';
import { useUIStore } from '@/store/uiStore';
import { useTranslation } from 'react-i18next';
export function AIUIControlIndicator() {
const { t } = useTranslation();
const aiActive = useAIUIControlStore((s) => s.aiActive);
const activeCommand = useAIUIControlStore((s) => s.activeCommand);
const clearPending = useAIUIControlStore((s) => s.clearPending);
const addToast = useUIStore((s) => s.addToast);
// Show toast when a new command starts
useEffect(() => {
if (aiActive && activeCommand) {
const desc = activeCommand.description || t(`ai.uiControl.${actionToKey(activeCommand.action)}`, getCommandFallback(activeCommand.action));
addToast({
type: 'info',
message: `${t('ai.uiControl.executing', 'KI führt Aktion aus')}: ${desc}`,
duration: 4000,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [aiActive, activeCommand?.command_id]);
if (!aiActive) return null;
return (
<div
className="fixed top-4 right-4 z-50 flex items-center gap-2 rounded-lg border border-primary-300 bg-primary-50 px-4 py-2 shadow-lg animate-pulse"
data-testid="ai-ui-control-indicator"
role="status"
aria-label={t('ai.uiControl.active', 'KI steuert die UI')}
>
<Bot className="h-5 w-5 text-primary-600 animate-pulse" aria-hidden="true" />
<span className="text-sm font-medium text-primary-700">
{t('ai.uiControl.active', 'KI steuert die UI')}
</span>
{activeCommand?.description && (
<span className="text-xs text-primary-500">
{activeCommand.description}
</span>
)}
<button
onClick={() => clearPending()}
className="ml-2 text-primary-400 hover:text-primary-600"
aria-label={t('common.dismiss', 'Schließen')}
>
<X className="h-4 w-4" />
</button>
</div>
);
}
function actionToKey(action: string): string {
const keyMap: Record<string, string> = {
navigate: 'navigate',
filter: 'filter',
open_contact: 'openContact',
modal: 'modal',
tab: 'tab',
settings: 'settings',
};
return keyMap[action] || action;
}
function getCommandFallback(action: string): string {
const fallbacks: Record<string, string> = {
navigate: 'Navigation',
filter: 'Filter setzen',
open_contact: 'Kontakt öffnen',
modal: 'Dialog öffnen',
tab: 'Tab wechseln',
settings: 'Einstellungen ändern',
};
return fallbacks[action] || action;
}
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Badge } from '@/components/ui/Badge';
@@ -11,6 +11,7 @@ import { Loader2 } from 'lucide-react';
import * as LucideIcons from 'lucide-react';
import { HistoryViewer } from '@/components/HistoryViewer';
import { usePluginStore } from '@/store/pluginStore';
import { useAIUIControlStore } from '@/store/aiUIControlStore';
import { PluginPage } from '@/components/plugins/PluginLoader';
import { useAuthStore } from '@/store/authStore';
import {
@@ -155,6 +156,25 @@ export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDe
const [editingPerson, setEditingPerson] = useState<ContactPerson | null>(null);
const [activeTab, setActiveTab] = useState('details');
const pluginTabs = usePluginStore(s => s.getDetailTabsForEntity('contact'));
const aiActiveTab = useAIUIControlStore(s => s.activeTab);
const aiActiveModal = useAIUIControlStore(s => s.activeModal);
// Sync tab from AI UI Control (Phase 4.8)
useEffect(() => {
if (aiActiveTab) {
setActiveTab(aiActiveTab);
}
}, [aiActiveTab]);
// Sync modal from AI UI Control (Phase 4.7)
useEffect(() => {
if (aiActiveModal === 'edit' || aiActiveModal === 'create') {
setEditingPerson(null);
setPersonModalOpen(true);
} else if (aiActiveModal === 'close') {
setPersonModalOpen(false);
}
}, [aiActiveModal]);
const user = useAuthStore(s => s.user);
if (loading) {
@@ -6,7 +6,9 @@ import { PluginToolbar } from './PluginToolbar';
import { MessageSidebar } from './MessageSidebar';
import { ToastContainer } from '@/components/ui/Toast';
import { useAIContext } from '@/hooks/useAIContext';
import { useAIUIControl } from '@/hooks/useAIUIControl';
import { PluginRegistry } from '@/components/plugins/PluginRegistry';
import { AIUIControlIndicator } from '@/components/ai-ui-control/AIUIControlIndicator';
export function AppShell() {
const location = useLocation();
@@ -14,6 +16,9 @@ export function AppShell() {
// Track context for AI Proactive suggestions
useAIContext();
// AI UI Control — WebSocket-based UI control from AI agents (Phase 4)
useAIUIControl();
// Hide message sidebar on the AI Assistant page itself
const showMessageSidebar = !location.pathname.startsWith('/ai-assistant');
@@ -38,6 +43,7 @@ export function AppShell() {
</div>
{/* Message Sidebar — full height, right of TopBar and Toolbar */}
{showMessageSidebar && <MessageSidebar />}
<AIUIControlIndicator />
<ToastContainer />
</div>
);