diff --git a/app/ai/agent_loop.py b/app/ai/agent_loop.py index f376e94..9feff45 100644 --- a/app/ai/agent_loop.py +++ b/app/ai/agent_loop.py @@ -197,6 +197,7 @@ async def run_react_loop( "tenant_id": str(tenant_id), "user_id": str(user_id), "db": db, + "agent_name": getattr(agent_definition, "name", "Agent"), } # Audit helper — records every tool call in the audit log. diff --git a/app/ai/miniapp_tools.py b/app/ai/miniapp_tools.py new file mode 100644 index 0000000..2dcf6dc --- /dev/null +++ b/app/ai/miniapp_tools.py @@ -0,0 +1,127 @@ +"""Core AI agent tools for MiniApp output (Phase M6). + +``send_miniapp`` lets an agent embed a MiniApp as an interactive output +block in its chat room (block_type "miniapp", approval_request precedent +from agent_loop). Permission is checked fail-closed against the CALLING +user for the target app's own permission — the tool never widens access. +""" + +from __future__ import annotations + +import logging +import uuid +from typing import Any + +from app.ai.tool_registry import get_tool_registry +from app.core.permissions import check_permission, resolve_permissions +from app.plugins.miniapp_registry import get_miniapp_registry + +logger = logging.getLogger(__name__) + + +def _get_komm_contract() -> Any | None: + """Resolve the kommunikation contract (None when plugin inactive).""" + from app.plugins.builtins.contracts import get_contract_registry + + return get_contract_registry().get("kommunikation") + + +async def _send_miniapp_handler(arguments: dict[str, Any], context: dict[str, Any]) -> str: + """Send a MiniApp as an output block to the agent's chat room.""" + app_id = str(arguments.get("app_id") or "") + settings = arguments.get("settings") or {} + if not isinstance(settings, dict): + settings = {} + + app = get_miniapp_registry().get_app(app_id) + if app is None: + return f"Error: MiniApp '{app_id}' not found" + + db = context.get("db") + tenant_id = context.get("tenant_id") + user_id = context.get("user_id") + if not tenant_id or not user_id or db is None: + return "Error: Missing tenant/user context" + + try: + tenant_uuid = uuid.UUID(str(tenant_id)) + user_uuid = uuid.UUID(str(user_id)) + except (ValueError, TypeError): + return "Error: Invalid tenant/user context" + + # Fail-closed permission check against the calling user + resolved = await resolve_permissions(db, user_uuid, tenant_uuid) + if app.permission and not check_permission(resolved, app.permission): + return f"Error: Permission '{app.permission}' required for MiniApp '{app.app_id}'" + + komm = _get_komm_contract() + if komm is None: + return "Error: Communication plugin not available" + + agent_name = str(context.get("agent_name") or "Agent") + conv_id = await komm.find_locked_room_id( + db=db, + tenant_id=tenant_uuid, + plugin_name="automation", + title=f"Agent: {agent_name}", + ) + if conv_id is None: + return f"Info: No agent chat room found for '{agent_name}' — MiniApp not posted" + + agent_id_raw = context.get("agent_id") + try: + sender_id = uuid.UUID(str(agent_id_raw)) if agent_id_raw else None + except (ValueError, TypeError): + sender_id = None + + await komm.send_message( + db=db, + tenant_id=tenant_uuid, + conversation_id=conv_id, + sender_id=sender_id, + sender_type="agent", + content=f"MiniApp: {app.name}", + content_format="text", + blocks=[ + { + "block_type": "miniapp", + "block_data": {"app_id": app_id, "config": settings}, + "sort_order": 0, + } + ], + ) + return f"MiniApp '{app_id}' sent to chat" + + +# ─── Registration ─── + + +def register_miniapp_tools() -> None: + """Register the MiniApp agent tools in the global tool registry.""" + registry = get_tool_registry() + registry.register( + name="send_miniapp", + description=( + "Eine MiniApp als interaktiven Ausgabe-Block in den Agent-Chat senden " + "(z.B. ein Widget mit Einstellungen anzeigen). Verfügbare App-IDs " + "stehen in /api/v1/miniapps." + ), + parameters={ + "type": "object", + "properties": { + "app_id": { + "type": "string", + "description": "ID der MiniApp (z.B. recent_contacts, tasks_summary)", + }, + "settings": { + "type": "object", + "description": "Optionale Einstellungen für die MiniApp-Instanz", + }, + }, + "required": ["app_id"], + }, + handler=_send_miniapp_handler, + plugin_name="system", + required_permission=None, # per-app check inside the handler (fail-closed) + category="ui", + ) diff --git a/app/main.py b/app/main.py index 9e9eafc..b1e3cd1 100644 --- a/app/main.py +++ b/app/main.py @@ -223,6 +223,11 @@ async def lifespan(app: FastAPI): register_system_miniapps() + # Core AI agent tools for MiniApp output (Phase M6) + from app.ai.miniapp_tools import register_miniapp_tools + + register_miniapp_tools() + # Install discovered builtin plugins and activate only those marked active in DB from sqlalchemy import select as sa_select from sqlalchemy.ext.asyncio import async_sessionmaker diff --git a/frontend/src/__tests__/dashboard/MiniAppWindow.test.tsx b/frontend/src/__tests__/dashboard/MiniAppWindow.test.tsx new file mode 100644 index 0000000..048027e --- /dev/null +++ b/frontend/src/__tests__/dashboard/MiniAppWindow.test.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MiniAppWindowContent } from '@/components/dashboard/MiniAppWindowContent'; +import { openMiniAppWindow } from '@/components/dashboard/openMiniAppWindow'; +import { useWindowStore } from '@/store/windowStore'; + +/** + * M6 — Windows host tests: MiniApps open in floating windows via the + * existing window manager (openMiniAppWindow + MiniAppWindowContent). + */ + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +vi.mock('@/api/miniapps', () => ({ + useMiniapps: () => ({ + data: { + items: [ + { + app_id: 'recent_contacts', name: 'Recent Contacts', icon: 'Users', description: 'desc', + plugin_name: 'contacts', render_schema: {}, permission: '', settings_schema: {}, + col_span: 2, row_span: 1, hosts: ['chat', 'dashboard', 'window'], + component: '@/components/dashboard/RecentContactsWidget', order: 10, builtin: true, + }, + ], + total: 1, + }, + isLoading: false, + }), +})); + +vi.mock('@/components/dashboard/MiniAppHost', () => ({ + MiniAppHost: ({ appId }: { appId: string }) => ( +
host:{appId}
+ ), +})); + +const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +function renderContent() { + return render( + + + + ); +} + +describe('MiniAppWindowContent', () => { + it('renders the MiniApp host with the given app id', () => { + renderContent(); + expect(screen.getByTestId('miniapp-window-recent_contacts')).toBeInTheDocument(); + expect(screen.getByTestId('miniapp-recent_contacts')).toBeInTheDocument(); + }); + + it('shows the app description when present', () => { + renderContent(); + expect(screen.getByText('desc')).toBeInTheDocument(); + }); +}); + +describe('openMiniAppWindow', () => { + it('opens a window with miniapp type and props', () => { + const spy = vi.fn(); + // spy on the store's openWindow implementation + const state = useWindowStore.getState(); + const origOpen = state.openWindow; + useWindowStore.setState({ openWindow: spy as never }); + + openMiniAppWindow({ + appId: 'recent_contacts', + settings: { limit: 3 }, + component: MiniAppWindowContent as never, + }); + + expect(spy).toHaveBeenCalledTimes(1); + const config = spy.mock.calls[0][0]; + expect(config.type).toBe('miniapp-recent_contacts'); + expect(config.title).toBe('recent_contacts'); + expect(config.componentProps).toEqual({ appId: 'recent_contacts', settings: { limit: 3 } }); + + // restore + useWindowStore.setState({ openWindow: origOpen as never, windows: [] }); + }); + + it('uses the def name as window title when provided', () => { + const spy = vi.fn(); + const state = useWindowStore.getState(); + const origOpen = state.openWindow; + useWindowStore.setState({ openWindow: spy as never }); + + openMiniAppWindow({ + appId: 'x', + def: { name: 'Mein Widget' } as never, + component: MiniAppWindowContent as never, + }); + + expect(spy.mock.calls[0][0].title).toBe('Mein Widget'); + useWindowStore.setState({ openWindow: origOpen as never, windows: [] }); + }); +}); diff --git a/frontend/src/components/comm/blocks/MiniAppBlock.tsx b/frontend/src/components/comm/blocks/MiniAppBlock.tsx index e305a0e..2af1ae1 100644 --- a/frontend/src/components/comm/blocks/MiniAppBlock.tsx +++ b/frontend/src/components/comm/blocks/MiniAppBlock.tsx @@ -1,42 +1,36 @@ -import React, { useState, useEffect } from 'react'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; import type { MessageBlock } from '@/store/commStore'; -import { AppWindow, Loader2 } from 'lucide-react'; -import { apiClient } from '@/api/client'; +import { AppWindow, ExternalLink, Loader2 } from 'lucide-react'; +import { useMiniapps, type MiniAppDef } from '@/api/miniapps'; +import { MiniAppWindowContent } from '@/components/dashboard/MiniAppWindowContent'; +import { openMiniAppWindow } from '@/components/dashboard/openMiniAppWindow'; interface MiniAppBlockProps { block: MessageBlock; } -interface MiniAppDef { - app_id: string; - name: string; - icon: string; - description: string; - plugin_name: string; - render_schema: Record; -} - const MiniAppBlock: React.FC = ({ block }) => { + const { t } = useTranslation(); const { app_id, config } = block.block_data; - const [appDef, setAppDef] = useState(null); - const [loading, setLoading] = useState(false); + // M6: universal registry endpoint (carries component/permission fields; + // the legacy /comm/miniapps list never did, hiding the window button) + const { data: miniappsData, isLoading: loading } = useMiniapps(); + const appDef: MiniAppDef | null = + miniappsData?.items.find((a) => a.app_id === app_id) ?? null; - useEffect(() => { - if (!app_id) return; - setLoading(true); - apiClient.get('/comm/miniapps') - .then((res) => { - const apps: MiniAppDef[] = res.data || []; - const found = apps.find((a) => a.app_id === app_id); - setAppDef(found || null); - }) - .catch(() => setAppDef(null)) - .finally(() => setLoading(false)); - }, [app_id]); + const canOpenInWindow = Boolean(appDef?.component); + const openInWindow = () => + openMiniAppWindow({ + appId: app_id || '', + def: appDef ?? undefined, + settings: (config as Record) ?? {}, + component: MiniAppWindowContent, + }); const appId: string = app_id || 'Unbekannt'; const hasConfig = config && typeof config === 'object' && Object.keys(config).length > 0; - const schema = appDef?.render_schema; + const schema = appDef?.render_schema as { fields?: { name: string; label?: string }[] } | undefined; const hasSchema = schema && Object.keys(schema).length > 0; if (loading) { @@ -60,6 +54,18 @@ const MiniAppBlock: React.FC = ({ block }) => { {appDef?.plugin_name && ( {appDef.plugin_name} )} + {canOpenInWindow && ( + + )} {hasSchema && schema.fields && ( diff --git a/frontend/src/components/dashboard/DashboardBuilder.tsx b/frontend/src/components/dashboard/DashboardBuilder.tsx index f9d4e71..f4b71d0 100644 --- a/frontend/src/components/dashboard/DashboardBuilder.tsx +++ b/frontend/src/components/dashboard/DashboardBuilder.tsx @@ -28,6 +28,7 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { + ExternalLink, LayoutDashboard, Pencil, Check, @@ -56,6 +57,8 @@ import { } from '@/api/dashboards'; import { useMiniapps, renderableDashboardApps, type MiniAppDef } from '@/api/miniapps'; import { MiniAppHost } from './MiniAppHost'; +import { MiniAppWindowContent } from './MiniAppWindowContent'; +import { openMiniAppWindow } from './openMiniAppWindow'; import { WidgetSettingsForm } from './WidgetSettingsForm'; let widgetIdCounter = 0; @@ -166,6 +169,22 @@ function SortableWidget({ widget, def, editMode, onRemove, onResize, onOpenSetti >