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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user