From 3f8d1bd59d8fe00704d610727852f45fe19fa3ac Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 13 Sep 2026 10:36:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(skills):=20UI=20fuer=20AI-Skill-Definition?= =?UTF-8?q?en=20=E2=80=94=20Modul=207/16=20des=20UI-Backlogs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zweites Modul via Phase-Q-Manifest-Architektur: Registrierung komplett ueber das automation-Plugin-Manifest (page_route /skills + menu_item, Sparkles-Icon) — routes/index.tsx und Sidebar.tsx unangetastet. Der Komponenten-Map-Generator wired den lazy import (39 Komponenten). Backend existierte vollstaendig im automation-Plugin (skill_routes.py: list mit is_active/category-Filtern, create, get, patch, delete; automation:read/write/delete), Frontend hatte 0% Abdeckung. - api/skills.ts: TanStack-Hooks (useSkills mit Filtern, useSkill, create/update/delete mit Cache-Invalidierung) - pages/Skills.tsx: Filter-Tabs (alle/aktiv/inaktiv), Skill-Karten (Name, Aktiv/Inaktiv-Badge, Kategorie, Beschreibung, Tool-Count), Create/Edit-Dialog (Name, Beschreibung, Instructions-Textarea, Kategorie, Tool-IDs als Komma-Liste, Aktiv-Toggle), Delete mit Confirm — Create/Edit hinter automation:write, Delete hinter automation:delete - i18n skills.* + nav.skills de/en Hinweis: Skills sind Orchestrierungs-Metadaten, KEINE Berechtigungsquelle — erlaubte Tools verweisen auf Tool-IDs, fuer die Agent und User bereits berechtigt sein muessen (Backend-Docstring). Verifikation: Vitest 10/10 (Filter-Tabs, Badges inkl. Inaktiv, Tool-Count, Create-Flow, Edit prefilled, Delete mit+ohne Confirm, separates Write/Delete-Gating) · tsc exit 0 · production build exit 0 · Backend-Regressionen (route-order, m5-miniapps, n4-scope) 28/28 · compileall sauber · Cross-Plugin-Checker 0 · Manifest-Check: page_route + menu_item korrekt. --- app/plugins/builtins/automation/plugin.py | 17 + frontend/src/__tests__/pages/Skills.test.tsx | 200 +++++++++ frontend/src/api/skills.ts | 111 +++++ .../generated/pluginComponents.generated.ts | 1 + frontend/src/i18n/locales/de.json | 32 +- frontend/src/i18n/locales/en.json | 32 +- frontend/src/pages/Skills.tsx | 394 ++++++++++++++++++ 7 files changed, 785 insertions(+), 2 deletions(-) create mode 100644 frontend/src/__tests__/pages/Skills.test.tsx create mode 100644 frontend/src/api/skills.ts create mode 100644 frontend/src/pages/Skills.tsx diff --git a/app/plugins/builtins/automation/plugin.py b/app/plugins/builtins/automation/plugin.py index 4389e5d..6635e03 100644 --- a/app/plugins/builtins/automation/plugin.py +++ b/app/plugins/builtins/automation/plugin.py @@ -112,6 +112,15 @@ class AutomationPlugin(BasePlugin): order=53, permission="import_export:read", ), + # UI-Backlog Modul 7: skills menu item (Phase Q pattern) + FrontendMenuItem( + label_key="nav.skills", + label="Skills", + path="/skills", + icon="Sparkles", + order=54, + permission="automation:read", + ), FrontendMenuItem( label_key="nav.dedupMerge", label="Duplikate", @@ -153,6 +162,14 @@ class AutomationPlugin(BasePlugin): order=53, permission="import_export:read", ), + # UI-Backlog Modul 7 (2026-09-13): skills definitions page, + # registered via the manifest (Phase Q pattern). + FrontendPageRoute( + path="/skills", + component="@/pages/Skills", + order=54, + permission="automation:read", + ), ], settings_pages=[ FrontendSettingsPage( diff --git a/frontend/src/__tests__/pages/Skills.test.tsx b/frontend/src/__tests__/pages/Skills.test.tsx new file mode 100644 index 0000000..1e64f92 --- /dev/null +++ b/frontend/src/__tests__/pages/Skills.test.tsx @@ -0,0 +1,200 @@ +/** + * Skills page tests — AI skill definitions (module 7/16). + * + * Covers: rendering, filter tabs, skill cards with active badge + tool + * count, create flow, edit flow prefilled, delete with confirmation, + * permission gating (automation:write / automation:delete), empty and + * error states. + */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { SkillsPage } from '@/pages/Skills'; +import type { SkillDefinition } from '@/api/skills'; + +const createMut = vi.fn().mockImplementation((_payload, opts) => { + opts?.onSuccess?.({}); + return Promise.resolve({}); +}); +const updateMut = vi.fn().mockImplementation((_payload, opts) => { + opts?.onSuccess?.({}); + return Promise.resolve({}); +}); +const deleteMut = vi.fn().mockResolvedValue({}); + +const makeSkill = (overrides: Partial = {}): SkillDefinition => ({ + id: 'sk-1', + name: 'Rechnungspruefung', + description: 'Prueft eingehende Rechnungen', + instructions: '1. Lade die Rechnung\n2. Pruefe Betraege', + allowed_tool_ids: ['11111111-1111-1111-1111-111111111111', '22222222-2222-2222-2222-222222222222'], + context_policy: null, + category: 'finance', + is_active: true, + created_at: '2026-09-01T10:00:00Z', + updated_at: null, + ...overrides, +}); + +let mockItems: SkillDefinition[] = []; +let mockPerms: Record = { 'automation:read': true, 'automation:write': true, 'automation:delete': true }; +let mockError = false; + +vi.mock('@/api/skills', () => ({ + useSkills: () => ({ + data: { items: mockItems, total: mockItems.length }, + isLoading: false, + isError: mockError, + isFetching: false, + refetch: vi.fn(), + }), + useCreateSkill: () => ({ + mutate: createMut, + isPending: false, + }), + useUpdateSkill: () => ({ + mutate: updateMut, + isPending: false, + }), + useDeleteSkill: () => ({ + mutate: deleteMut, + isPending: false, + }), +})); + +vi.mock('@/hooks/usePermission', () => ({ + usePermission: () => ({ + hasPermission: (perm: string) => mockPerms[perm] ?? false, + }), +})); + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockItems = []; + mockPerms = { 'automation:read': true, 'automation:write': true, 'automation:delete': true }; + mockError = false; +}); + +describe('SkillsPage', () => { + it('renders the page with title and filter tabs', () => { + renderPage(); + expect(screen.getByTestId('skills-page')).toBeInTheDocument(); + expect(screen.getByTestId('skill-filter-all')).toBeInTheDocument(); + expect(screen.getByTestId('skill-filter-active')).toBeInTheDocument(); + expect(screen.getByTestId('skill-filter-inactive')).toBeInTheDocument(); + }); + + it('shows empty state when no skills exist', () => { + renderPage(); + expect(screen.getByTestId('skills-empty')).toBeInTheDocument(); + }); + + it('shows error state on load failure', () => { + mockError = true; + renderPage(); + expect(screen.getByTestId('skills-error')).toBeInTheDocument(); + }); + + it('renders skill cards with active badge, category and tool count', () => { + mockItems = [makeSkill()]; + renderPage(); + expect(screen.getByTestId('skill-card-sk-1')).toBeInTheDocument(); + expect(screen.getByText('Rechnungspruefung')).toBeInTheDocument(); + expect(screen.getByTestId('skill-active-sk-1')).toBeInTheDocument(); + expect(screen.getByText('finance')).toBeInTheDocument(); + expect(screen.getByTestId('skill-tools-sk-1')).toHaveTextContent('2'); + }); + + it('shows inactive badge for inactive skills', () => { + mockItems = [makeSkill({ is_active: false })]; + renderPage(); + expect(screen.getByTestId('skill-active-sk-1')).toHaveTextContent(/inaktiv|inactive/i); + }); + + it('hides create/edit without automation:write and delete without automation:delete', () => { + mockItems = [makeSkill()]; + mockPerms = { 'automation:read': true, 'automation:write': false, 'automation:delete': false }; + renderPage(); + expect(screen.queryByTestId('skill-create-open')).not.toBeInTheDocument(); + expect(screen.queryByTestId('skill-edit-sk-1')).not.toBeInTheDocument(); + expect(screen.queryByTestId('skill-delete-sk-1')).not.toBeInTheDocument(); + }); + + it('creates a skill via the dialog', async () => { + renderPage(); + fireEvent.click(screen.getByTestId('skill-create-open')); + expect(screen.getByTestId('skill-form-submit')).toBeInTheDocument(); + + const nameInput = screen.getByLabelText(/name/i, { selector: 'input' }) as HTMLInputElement; + fireEvent.change(nameInput, { target: { value: 'Neuer Skill' } }); + const descInput = screen.getByLabelText(/beschreibung|description/i, { selector: 'input' }) as HTMLInputElement; + fireEvent.change(descInput, { target: { value: 'Beschreibung' } }); + const instrArea = screen.getByTestId('skill-instructions') as HTMLTextAreaElement; + fireEvent.change(instrArea, { target: { value: 'Mach dies, mach das.' } }); + + fireEvent.click(screen.getByTestId('skill-form-submit')); + + await waitFor(() => { + expect(createMut).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Neuer Skill', + description: 'Beschreibung', + instructions: 'Mach dies, mach das.', + }), + expect.anything(), + ); + }); + }); + + it('opens the edit dialog prefilled and submits the update', async () => { + mockItems = [makeSkill()]; + renderPage(); + fireEvent.click(screen.getByTestId('skill-edit-sk-1')); + + const nameInput = screen.getByLabelText(/name/i, { selector: 'input' }) as HTMLInputElement; + expect(nameInput).toHaveValue('Rechnungspruefung'); + fireEvent.change(nameInput, { target: { value: 'Geaenderter Skill' } }); + + fireEvent.click(screen.getByTestId('skill-form-submit')); + + await waitFor(() => { + expect(updateMut).toHaveBeenCalledWith( + { + skillId: 'sk-1', + payload: expect.objectContaining({ + name: 'Geaenderter Skill', + instructions: '1. Lade die Rechnung\n2. Pruefe Betraege', + }), + }, + expect.anything(), + ); + }); + }); + + it('deletes a skill after confirmation', async () => { + mockItems = [makeSkill()]; + window.confirm = vi.fn(() => true); + renderPage(); + fireEvent.click(screen.getByTestId('skill-delete-sk-1')); + await waitFor(() => { + expect(deleteMut).toHaveBeenCalledWith('sk-1'); + }); + }); + + it('does not delete when the confirmation is rejected', () => { + mockItems = [makeSkill()]; + window.confirm = vi.fn(() => false); + renderPage(); + fireEvent.click(screen.getByTestId('skill-delete-sk-1')); + expect(deleteMut).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/api/skills.ts b/frontend/src/api/skills.ts new file mode 100644 index 0000000..4d8d928 --- /dev/null +++ b/frontend/src/api/skills.ts @@ -0,0 +1,111 @@ +/** + * Skills API client — AI skill definitions (orchestration metadata). + * + * Backend: /api/v1/skills (list, create, get, update, delete). Skills are + * NOT a permission source — they reference tool IDs the agent/user must + * already be permitted to use. + * Permissions: automation:read (list/get) / automation:write (create, + * update) / automation:delete (delete). + */ + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client'; + +export interface SkillDefinition { + id: string; + name: string; + description: string; + instructions: string; + allowed_tool_ids: string[]; + context_policy: Record | null; + category: string; + is_active: boolean; + created_at: string | null; + updated_at: string | null; +} + +export interface SkillListResponse { + items: SkillDefinition[]; + total: number; +} + +export interface SkillCreatePayload { + name: string; + description: string; + instructions: string; + allowed_tool_ids?: string[]; + context_policy?: Record | null; + category?: string; + is_active?: boolean; +} + +export type SkillUpdatePayload = Partial; + +export interface SkillListParams { + isActive?: boolean; + category?: string; + limit?: number; + offset?: number; +} + +// ─── Query hooks ───────────────────────────────────────────── + +export function useSkills(params: SkillListParams = {}) { + const searchParams = new URLSearchParams(); + if (params.isActive !== undefined) searchParams.set('is_active', String(params.isActive)); + if (params.category) searchParams.set('category', params.category); + if (params.limit) searchParams.set('limit', String(params.limit)); + if (params.offset) searchParams.set('offset', String(params.offset)); + const qs = searchParams.toString(); + + return useQuery({ + queryKey: ['skills', params.isActive, params.category, params.limit, params.offset], + queryFn: () => apiGet(`/skills${qs ? `?${qs}` : ''}`), + }); +} + +export function useSkill(skillId: string | null) { + return useQuery({ + queryKey: ['skills', skillId], + queryFn: () => apiGet(`/skills/${skillId}`), + enabled: !!skillId, + }); +} + +// ─── Mutation hooks ────────────────────────────────────────── + +function useInvalidateSkills() { + const qc = useQueryClient(); + return () => { + qc.invalidateQueries({ queryKey: ['skills'] }); + }; +} + +export function useCreateSkill() { + const invalidate = useInvalidateSkills(); + return useMutation({ + mutationFn: (data) => apiPost('/skills/', data), + onSuccess: invalidate, + }); +} + +export function useUpdateSkill() { + const invalidate = useInvalidateSkills(); + return useMutation< + SkillDefinition, + Error, + { skillId: string; payload: SkillUpdatePayload } + >({ + mutationFn: ({ skillId, payload }) => + apiPatch(`/skills/${skillId}`, payload), + onSuccess: invalidate, + }); +} + +export function useDeleteSkill() { + const invalidate = useInvalidateSkills(); + return useMutation<{ status: string }, Error, string>({ + mutationFn: (skillId) => apiDelete<{ status: string }>(`/skills/${skillId}`), + onSuccess: invalidate, + }); +} diff --git a/frontend/src/generated/pluginComponents.generated.ts b/frontend/src/generated/pluginComponents.generated.ts index 47ddf0b..0e3a599 100644 --- a/frontend/src/generated/pluginComponents.generated.ts +++ b/frontend/src/generated/pluginComponents.generated.ts @@ -52,6 +52,7 @@ export const PLUGIN_COMPONENT_MAP: Record = { '@/pages/SettingsNotifications': () => import('@/pages/SettingsNotifications').then((m) => ({ default: m.SettingsNotificationsPage })), '@/pages/SettingsRoles': () => import('@/pages/SettingsRoles').then((m) => ({ default: m.SettingsRolesPage })), '@/pages/SettingsUsers': () => import('@/pages/SettingsUsers').then((m) => ({ default: m.SettingsUsersPage })), + '@/pages/Skills': () => import('@/pages/Skills').then(normalizeModule), '@/pages/Tags': () => import('@/pages/Tags').then((m) => ({ default: m.TagsPage })), '@/pages/Tasks': () => import('@/pages/Tasks').then((m) => ({ default: m.TasksPage })), '@/pages/Wiki': () => import('@/pages/Wiki').then((m) => ({ default: m.WikiPage })), diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 41d273c..d4cd691 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -24,7 +24,8 @@ "systemDashboard": "System Dashboard", "approvals": "Freigaben", "delegations": "Delegationen", - "marketplace": "Marketplace" + "marketplace": "Marketplace", + "skills": "Skills" }, "auth": { "login": "Anmelden", @@ -1758,5 +1759,34 @@ "deleteConfirm": "Vorlage '{{name}}' wirklich loeschen?", "empty": "Keine Berechtigungs-Vorlagen vorhanden.", "loadError": "Vorlagen konnten nicht geladen werden." + }, + "skills": { + "title": "Skills", + "create": "Skill erstellen", + "createTitle": "Neuen Skill definieren", + "createSubmit": "Erstellen", + "editTitle": "Skill bearbeiten", + "save": "Speichern", + "name": "Name", + "namePlaceholder": "z.B. Rechnungspruefung", + "description": "Beschreibung", + "descriptionPlaceholder": "Wofuer ist dieser Skill?", + "instructions": "Anweisungen", + "instructionsPlaceholder": "Schritt-fuer-Schritt-Anweisungen fuer den Agenten...", + "category": "Kategorie", + "tools": "Erlaubte Tools", + "toolsHelper": "Kommagetrennte Tool-IDs. Leer = keine Tools.", + "isActive": "Aktiv", + "active": "Aktiv", + "inactive": "Inaktiv", + "filterAll": "Alle", + "filterActive": "Aktiv", + "filterInactive": "Inaktiv", + "toolCount": "{{count}} Tool(s)", + "edit": "Bearbeiten", + "delete": "Loeschen", + "deleteConfirm": "Skill '{{name}}' wirklich loeschen?", + "empty": "Keine Skills vorhanden.", + "loadError": "Skills konnten nicht geladen werden." } } diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ab18cc0..81484f9 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -24,7 +24,8 @@ "systemDashboard": "System Dashboard", "approvals": "Approvals", "delegations": "Delegations", - "marketplace": "Marketplace" + "marketplace": "Marketplace", + "skills": "Skills" }, "auth": { "login": "Sign In", @@ -1758,5 +1759,34 @@ "deleteConfirm": "Really delete template '{{name}}'?", "empty": "No permission templates yet.", "loadError": "Failed to load templates." + }, + "skills": { + "title": "Skills", + "create": "Create skill", + "createTitle": "Define a new skill", + "createSubmit": "Create", + "editTitle": "Edit skill", + "save": "Save", + "name": "Name", + "namePlaceholder": "e.g. Invoice check", + "description": "Description", + "descriptionPlaceholder": "What is this skill for?", + "instructions": "Instructions", + "instructionsPlaceholder": "Step-by-step instructions for the agent...", + "category": "Category", + "tools": "Allowed tools", + "toolsHelper": "Comma-separated tool IDs. Empty = no tools.", + "isActive": "Active", + "active": "Active", + "inactive": "Inactive", + "filterAll": "All", + "filterActive": "Active", + "filterInactive": "Inactive", + "toolCount": "{{count}} tool(s)", + "edit": "Edit", + "delete": "Delete", + "deleteConfirm": "Really delete skill '{{name}}'?", + "empty": "No skills yet.", + "loadError": "Failed to load skills." } } diff --git a/frontend/src/pages/Skills.tsx b/frontend/src/pages/Skills.tsx new file mode 100644 index 0000000..5f2b80a --- /dev/null +++ b/frontend/src/pages/Skills.tsx @@ -0,0 +1,394 @@ +/** + * Skills page — AI skill definitions (orchestration metadata) + * (UI-Backlog module 7/16). + * + * Backend: /api/v1/skills. Skills are NOT a permission source — they + * reference tool IDs the agent/user must already be permitted to use. + * Permissions: automation:read (list) / automation:write (create, update) / + * automation:delete (delete). + * + * NOTE (Phase Q): registered via the automation PLUGIN MANIFEST + * (page_routes + menu_items) — routes/index.tsx and Sidebar.tsx untouched. + */ + +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Sparkles, + Plus, + Pencil, + Trash2, + Wrench, + AlertTriangle, + Inbox, +} from 'lucide-react'; +import { + useSkills, + useCreateSkill, + useUpdateSkill, + useDeleteSkill, + type SkillDefinition, +} from '@/api/skills'; +import { usePermission } from '@/hooks/usePermission'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Modal } from '@/components/ui/Modal'; +import { Input } from '@/components/ui/Input'; +import { Badge } from '@/components/ui/Badge'; + +type ActiveFilter = 'all' | 'active' | 'inactive'; + +const FILTER_TABS: { key: ActiveFilter; labelKey: string }[] = [ + { key: 'all', labelKey: 'skills.filterAll' }, + { key: 'active', labelKey: 'skills.filterActive' }, + { key: 'inactive', labelKey: 'skills.filterInactive' }, +]; + +function SkillCard({ + skill, + canWrite, + canDelete, + onEdit, + onDelete, + isMutating, +}: { + skill: SkillDefinition; + canWrite: boolean; + canDelete: boolean; + onEdit: (s: SkillDefinition) => void; + onDelete: (s: SkillDefinition) => void; + isMutating: boolean; +}) { + const { t } = useTranslation(); + + return ( + +
+
+
+
+

+ {skill.description} +

+
+
+
+ {(canWrite || canDelete) && ( +
+ {canWrite && ( + + )} + {canDelete && ( + + )} +
+ )} +
+
+ ); +} + +function SkillFormDialog({ + open, + skill, + onClose, + onSubmit, + isSubmitting, +}: { + open: boolean; + skill: SkillDefinition | null; + onClose: () => void; + onSubmit: (payload: { + name: string; + description: string; + instructions: string; + allowed_tool_ids: string[]; + category: string; + is_active: boolean; + }) => void; + isSubmitting: boolean; +}) { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [instructions, setInstructions] = useState(''); + const [tools, setTools] = useState(''); + const [category, setCategory] = useState('general'); + const [isActive, setIsActive] = useState(true); + + React.useEffect(() => { + if (open) { + setName(skill?.name ?? ''); + setDescription(skill?.description ?? ''); + setInstructions(skill?.instructions ?? ''); + setTools((skill?.allowed_tool_ids ?? []).join(', ')); + setCategory(skill?.category ?? 'general'); + setIsActive(skill?.is_active ?? true); + } + }, [open, skill]); + + const parsedTools = tools + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + + const valid = name.trim().length > 0 && description.trim().length > 0 && instructions.trim().length > 0; + + const submit = () => { + if (!valid) return; + onSubmit({ + name: name.trim(), + description: description.trim(), + instructions: instructions.trim(), + allowed_tool_ids: parsedTools, + category: category.trim() || 'general', + is_active: isActive, + }); + }; + + return ( + +
+ setName(e.target.value)} + required + placeholder={t('skills.namePlaceholder')} + /> + setDescription(e.target.value)} + required + placeholder={t('skills.descriptionPlaceholder')} + /> +
+ +