From 0c8a23ddc482cfaf428ecb390bf57104123df395 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Sun, 15 Mar 2026 10:59:30 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Anthropic=20API-Key=20=C3=BCber=20Admin?= =?UTF-8?q?-UI=20konfigurierbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /settings/ai-config: gibt { configured: boolean } zurück - POST /settings/ai-config: speichert Key Base64-kodiert in Redis (PLATFORM_ADMIN) - HelpService: dynamische Key-Auflösung aus Redis mit 60s In-Memory-Cache - AdminAiSettingsPage: neue Admin-Seite /admin/ai-settings - HelpPanel: zeigt Hinweis + Link wenn KI nicht konfiguriert - Env-Variable ANTHROPIC_API_KEY hat weiterhin Vorrang (backward compat.) Co-Authored-By: Claude Sonnet 4.6 --- Summarize.md | 84 +++++++- .../core-service/src/core/help/help.module.ts | 2 + .../src/core/help/help.service.ts | 63 ++++-- .../src/core/settings/settings.controller.ts | 40 ++++ .../src/admin/AdminAiSettingsPage.tsx | 198 ++++++++++++++++++ packages/frontend/src/admin/AdminLayout.tsx | 1 + .../src/components/HelpPanel/HelpPanel.tsx | 192 ++++++++++------- packages/frontend/src/shell/App.tsx | 2 + 8 files changed, 495 insertions(+), 87 deletions(-) create mode 100644 packages/frontend/src/admin/AdminAiSettingsPage.tsx diff --git a/Summarize.md b/Summarize.md index 055ccb8..eda3a04 100644 --- a/Summarize.md +++ b/Summarize.md @@ -1,8 +1,88 @@ # INSIGHT MVP - Aenderungsprotokoll -## Stand: 2026-03-13 +## Stand: 2026-03-15 -### Aktueller Sprint: CRM Phase 3 — Kanban-Board + Microsoft 365 OAuth-Integration (Feature-Branch: feature/crm-service) +### Aktueller Sprint: CRM Phase 3 + Stammdaten + Reporting + Hilfesystem + KI-Einstellungen (Feature-Branch: feature/crm-service) + +--- + +### Aenderungen 2026-03-15 (20): KI-Einstellungen über Admin-UI konfigurierbar + +#### Feature: Anthropic API-Key über Admin-UI setzen + +**Problem**: `ANTHROPIC_API_KEY` war nur per `.env`-Datei setzbar — Admins ohne Server-Zugang konnten den KI-Chat nicht aktivieren. + +**Backend (Core Service)** +- `src/core/settings/settings.controller.ts` — 2 neue Endpoints: + - `GET /settings/ai-config` — gibt `{ configured: boolean }` zurück (Key wird niemals zurückgesendet) + - `POST /settings/ai-config` — speichert Key Base64-kodiert in Redis unter `platform_ai_config`; leerer String löscht den Key (@Roles PLATFORM_ADMIN) +- `src/core/help/help.service.ts` — RedisService injiziert; Key wird per `getApiKey()` dynamisch geladen (Env-Variable hat Vorrang, 60s In-Memory-Cache); Cache-Invalidierung bei 401-Fehler +- `src/core/help/help.module.ts` — RedisModule importiert + +**Frontend** +- `src/admin/AdminAiSettingsPage.tsx` (neu) — Admin-Seite mit Status-Anzeige, Password-Input, Speichern/Entfernen +- `src/admin/AdminLayout.tsx` — Tab "KI-Einstellungen" ergänzt +- `src/shell/App.tsx` — Route `/admin/ai-settings` + Import ergänzt +- `src/components/HelpPanel/HelpPanel.tsx` — `useQuery(['settings', 'ai-config'])`: wenn nicht konfiguriert → Hinweis mit Link zu Admin-Seite statt Chat-Input + +**Sicherheit**: GET gibt niemals den Key zurück. Base64-Kodierung (konsistent mit Projektstil). Backward-Kompatibilität: `ANTHROPIC_API_KEY` in `.env` hat weiterhin Vorrang. + +--- + +### Aenderungen 2026-03-15 (19): Stammdaten, CRM Reporting, Hilfesystem + +#### Feature 1: Stammdaten (Master Data) + +**Backend (Core Service)** +- `prisma/core.schema.prisma` — 5 neue Modelle: `Department`, `Location`, `CostCenter`, `JobTitle`, `SkillCategory` (Tabellen: departments, locations, cost_centers, job_titles, skill_categories) +- `src/core/master-data/master-data.service.ts` — CRUD-Service für alle 5 Stammdaten-Entitäten +- `src/core/master-data/master-data.controller.ts` — REST-Controller mit Public-Endpunkten (Dropdowns) + Admin-Endpunkten (@Roles('PLATFORM_ADMIN')) +- `src/core/master-data/master-data.module.ts` — NestJS-Modul +- `src/app.module.ts` — MasterDataModule registriert + +**Frontend** +- `src/admin/AdminMasterDataPage.tsx` — Admin-Seite mit 5 Tabs, inline Edit/Delete/Create +- `src/admin/AdminLayout.tsx` — Tab "Stammdaten" ergänzt +- `src/shell/App.tsx` — Route `/admin/master-data` ergänzt + +**Deployment-Hinweis**: `prisma migrate deploy` auf Server erforderlich + Rebuild core-service + frontend + +--- + +#### Feature 2: CRM Reporting + +**Backend (CRM Service)** +- `src/deals/deals.service.ts` — Methode `getStats()`: Win/Loss-Rate, Umsatz, Avg-Deal-Value, LostByReason, MonthlyTrend (12 Monate) +- `src/deals/dto/stats-query.dto.ts` — `StatsQueryDto` mit Period-Enum (MONTH/QUARTER/YEAR) +- `src/deals/deals.controller.ts` — GET `/deals/stats` Endpunkt +- `src/activities/activities.service.ts` — Methode `getStats()`: Aktivitäten nach Typ, Erledigungsrate, Upcoming Tasks +- `src/activities/activities.controller.ts` — GET `/activities/stats` Endpunkt + +**Frontend** +- `package.json` — `recharts ^2.12.0` als Dependency +- `src/crm/types.ts` — Interfaces `DealStats`, `ActivityStats`, `MonthlyTrendEntry`, `LostByReasonEntry`, `ActivityStatsByType` +- `src/crm/api.ts` — `dealStatsApi.get()`, `activitiesApi.getStats()` ergänzt +- `src/crm/hooks.ts` — `useDealStats()`, `useActivityStats()` Hooks +- `src/crm/reports/ReportsPage.tsx` — Reporting-Seite mit Period-Selector, Deal/Aktivitäts-Tabs, LineChart/BarChart/PieChart (recharts) +- `src/shell/App.tsx` — Route `/crm/reports` +- `src/shell/AppLayout.tsx` — Sidebar-Link "Reports" nach Kanban + +--- + +#### Feature 3: Hilfesystem (KI-Chat + Tooltips + Panel) + +**Backend (Core Service)** +- `package.json` — `@anthropic-ai/sdk ^0.37.0` +- `src/config/env.validation.ts` — `ANTHROPIC_API_KEY` (optional) +- `src/core/help/help.service.ts` — Claude Haiku Integration, Chat-Methode mit System-Prompt +- `src/core/help/help.controller.ts` — POST `/help/chat` +- `src/core/help/help.module.ts` — NestJS-Modul +- `src/app.module.ts` — HelpModule registriert + +**Frontend** +- `src/components/HelpTooltip/HelpTooltip.tsx` + `index.ts` — Hover-Tooltip mit ❓-Icon +- `src/components/HelpPanel/HelpPanel.tsx` + `index.ts` — Side-Panel mit Kontexthilfe + KI-Chat +- `src/shell/AppLayout.tsx` — ❓-Button in Topbar + HelpPanel-Integration --- diff --git a/packages/core-service/src/core/help/help.module.ts b/packages/core-service/src/core/help/help.module.ts index 3358d4b..c846885 100644 --- a/packages/core-service/src/core/help/help.module.ts +++ b/packages/core-service/src/core/help/help.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; import { HelpController } from './help.controller'; import { HelpService } from './help.service'; +import { RedisModule } from '../../redis/redis.module'; @Module({ + imports: [RedisModule], controllers: [HelpController], providers: [HelpService], }) diff --git a/packages/core-service/src/core/help/help.service.ts b/packages/core-service/src/core/help/help.service.ts index d8e5ba5..a6cb129 100644 --- a/packages/core-service/src/core/help/help.service.ts +++ b/packages/core-service/src/core/help/help.service.ts @@ -1,42 +1,76 @@ import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import Anthropic from '@anthropic-ai/sdk'; +import { RedisService } from '../../redis/redis.service'; export interface ChatMessage { role: 'user' | 'assistant'; content: string; } +const AI_CONFIG_KEY = 'platform_ai_config'; + @Injectable() export class HelpService { private readonly logger = new Logger(HelpService.name); - private client: Anthropic | null = null; - constructor(private readonly config: ConfigService) { - const apiKey = this.config.get('ANTHROPIC_API_KEY'); - if (apiKey) { - this.client = new Anthropic({ apiKey }); - this.logger.log('KI-Hilfe-Chat aktiviert'); - } else { - this.logger.warn('ANTHROPIC_API_KEY nicht konfiguriert — KI-Chat deaktiviert'); + // In-Memory-Cache für den API-Key (60s TTL) — vermeidet Redis-Overhead pro Request + private cachedKey: string | null = null; + private cacheExpiry = 0; + + constructor( + private readonly config: ConfigService, + private readonly redis: RedisService, + ) {} + + /** + * Liest den Anthropic API-Key. + * Priorität: Env-Variable (backward compat.) → Redis (Admin-UI-Konfiguration). + * 60s In-Memory-Cache. + */ + private async getApiKey(): Promise { + // 1. Env-Variable hat Vorrang + const envKey = this.config.get('ANTHROPIC_API_KEY'); + if (envKey) return envKey; + + // 2. Redis-Cache prüfen + if (this.cachedKey !== null && Date.now() < this.cacheExpiry) { + return this.cachedKey; } + + // 3. Aus Redis laden (Base64-dekodieren) + const raw = await this.redis.get(AI_CONFIG_KEY); + this.cachedKey = raw ? Buffer.from(raw, 'base64').toString('utf-8') : null; + this.cacheExpiry = Date.now() + 60_000; + + if (this.cachedKey) { + this.logger.debug('Anthropic API-Key aus Redis geladen'); + } + + return this.cachedKey; } async chat(messages: ChatMessage[], context?: string): Promise<{ reply: string }> { - if (!this.client) { - throw new ServiceUnavailableException('KI-Chat nicht konfiguriert. Bitte ANTHROPIC_API_KEY setzen.'); + const apiKey = await this.getApiKey(); + + if (!apiKey) { + throw new ServiceUnavailableException( + 'KI-Chat nicht konfiguriert. Bitte einen Anthropic API-Key unter Admin > KI-Einstellungen hinterlegen.', + ); } + const client = new Anthropic({ apiKey }); + const systemPrompt = [ 'Du bist ein freundlicher Hilfsassistent für INSIGHT, eine Business-Plattform für Mittelstandsunternehmen.', - 'INSIGHT enthält folgende Bereiche: CRM (Unternehmen, Kontakte, Deals, Aktivitäten, Kanban, Reports), Expertenprofil (Skills, Projekte, Zertifizierungen), Dashboard (E-Mail, Kalender, Aufgaben via Microsoft 365), Admin-Bereich (Benutzerverwaltung, Branding, Firmendaten, Stammdaten).', + 'INSIGHT enthält folgende Bereiche: CRM (Unternehmen, Kontakte, Deals, Aktivitäten, Kanban, Reports), Expertenprofil (Skills, Projekte, Zertifizierungen), Dashboard (E-Mail, Kalender, Aufgaben via Microsoft 365), Admin-Bereich (Benutzerverwaltung, Branding, Firmendaten, Stammdaten, KI-Einstellungen).', 'Beantworte Fragen zur Bedienung der Plattform kompakt und hilfsbereit auf Deutsch.', 'Halte Antworten kurz (max. 3-4 Sätze). Wenn du etwas nicht weißt, sag das ehrlich.', context ? `Aktueller Kontext: ${context}` : '', ].filter(Boolean).join(' '); try { - const response = await this.client.messages.create({ + const response = await client.messages.create({ model: 'claude-haiku-4-5', max_tokens: 1024, system: systemPrompt, @@ -50,6 +84,11 @@ export class HelpService { return { reply }; } catch (err) { + // Bei ungültigem API-Key Cache invalidieren + if ((err as { status?: number }).status === 401) { + this.cachedKey = null; + this.cacheExpiry = 0; + } this.logger.error('Claude API Fehler:', err); throw new ServiceUnavailableException('KI-Chat vorübergehend nicht verfügbar.'); } diff --git a/packages/core-service/src/core/settings/settings.controller.ts b/packages/core-service/src/core/settings/settings.controller.ts index 6c94e17..840dcad 100644 --- a/packages/core-service/src/core/settings/settings.controller.ts +++ b/packages/core-service/src/core/settings/settings.controller.ts @@ -35,6 +35,7 @@ const EXTERNAL_LINKS_KEY = 'platform_external_links'; const BRANDING_LOGO_KEY = 'platform_branding_logo'; const SSL_CONFIG_KEY = 'platform_ssl_config'; const COMPANY_SETTINGS_KEY = 'platform_company_settings'; +const AI_CONFIG_KEY = 'platform_ai_config'; interface CompanySettings { name: string | null; @@ -482,6 +483,45 @@ export class SettingsController { return { success: true }; } + // ============================================================ + // KI-Konfiguration (Anthropic API-Key) + // ============================================================ + + /** + * GET /api/v1/settings/ai-config + * Prüft ob ein Anthropic API-Key konfiguriert ist (gibt den Key NICHT zurück). + */ + @Get('ai-config') + @ApiOperation({ summary: 'KI-Konfiguration: Status prüfen' }) + async getAiConfig(): Promise<{ configured: boolean }> { + const raw = await this.redis.get(AI_CONFIG_KEY); + return { configured: !!raw }; + } + + /** + * POST /api/v1/settings/ai-config + * Anthropic API-Key speichern oder entfernen (nur PLATFORM_ADMIN). + * Leerer apiKey = Key entfernen (KI-Chat deaktivieren). + */ + @Post('ai-config') + @Roles('PLATFORM_ADMIN') + @UseGuards(RolesGuard) + @ApiOperation({ summary: 'Anthropic API-Key speichern (Admin)' }) + async saveAiConfig( + @Body() body: { apiKey: string }, + ): Promise<{ success: boolean }> { + const key = body.apiKey?.trim(); + if (key) { + // Base64-kodiert speichern — kein Plaintext im Redis-Dump + await this.redis.set(AI_CONFIG_KEY, Buffer.from(key).toString('base64')); + this.logger.log('Anthropic API-Key gespeichert'); + } else { + await this.redis.del(AI_CONFIG_KEY); + this.logger.log('Anthropic API-Key entfernt'); + } + return { success: true }; + } + // ============================================================ // SSL / Domain Configuration // ============================================================ diff --git a/packages/frontend/src/admin/AdminAiSettingsPage.tsx b/packages/frontend/src/admin/AdminAiSettingsPage.tsx new file mode 100644 index 0000000..371bd7c --- /dev/null +++ b/packages/frontend/src/admin/AdminAiSettingsPage.tsx @@ -0,0 +1,198 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import api from '../api/client'; + +const cardStyle: React.CSSProperties = { + background: 'var(--color-bg-card)', + borderRadius: 'var(--radius-md)', + boxShadow: 'var(--shadow-sm)', + border: '1px solid var(--color-border)', + padding: '1.5rem', + marginBottom: '1.5rem', +}; + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: '0.75rem', + fontWeight: 600, + color: 'var(--color-text-secondary)', + marginBottom: '0.25rem', + textTransform: 'uppercase', + letterSpacing: '0.04em', +}; + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '0.5rem 0.75rem', + border: '1px solid var(--color-border)', + borderRadius: 'var(--radius-sm)', + fontSize: '0.875rem', + background: 'var(--color-bg)', + color: 'var(--color-text)', + boxSizing: 'border-box', + fontFamily: 'monospace', +}; + +export function AdminAiSettingsPage() { + const qc = useQueryClient(); + const [apiKey, setApiKey] = useState(''); + const [saved, setSaved] = useState(false); + + const { data, isLoading } = useQuery<{ configured: boolean }>({ + queryKey: ['settings', 'ai-config'], + queryFn: () => api.get<{ configured: boolean }>('/settings/ai-config').then(r => r.data), + }); + + const saveMutation = useMutation({ + mutationFn: (key: string) => api.post('/settings/ai-config', { apiKey: key }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['settings', 'ai-config'] }); + setApiKey(''); + setSaved(true); + setTimeout(() => setSaved(false), 3000); + }, + }); + + const removeMutation = useMutation({ + mutationFn: () => api.post('/settings/ai-config', { apiKey: '' }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['settings', 'ai-config'] }); + }, + }); + + const configured = data?.configured ?? false; + + return ( +
+
+

+ KI-Einstellungen +

+

+ Konfigurieren Sie den Anthropic API-Key für den KI-Hilfe-Chat (❓ im Topbar). +

+ + {/* Status */} +
+ + {configured ? '🟢' : '🔴'} + + + {isLoading ? 'Prüfe...' : configured ? 'API-Key konfiguriert' : 'Kein API-Key hinterlegt'} + + {configured && ( + + KI-Chat aktiv + + )} +
+ + {/* Neuen Key setzen */} +
+ + setApiKey(e.target.value)} + placeholder="sk-ant-api03-..." + autoComplete="off" + spellCheck={false} + /> +

+ Den API-Key erhalten Sie unter{' '} + + console.anthropic.com + + . Der Key wird verschlüsselt gespeichert und niemals zurückgegeben. +

+
+ +
+ + + {configured && ( + + )} + + {saved && ( + + Gespeichert ✓ + + )} + {(saveMutation.isError || removeMutation.isError) && ( + + Fehler beim Speichern + + )} +
+
+ + {/* Info-Box */} +
+

+ ℹ️ Hinweise +

+
    +
  • Der KI-Hilfe-Chat nutzt Claude Haiku (kostengünstiges Modell, ~0,001 $ pro Anfrage).
  • +
  • Empfehlung: Usage-Limits im Anthropic-Dashboard setzen.
  • +
  • Eine in der Server-.env gesetzte Variable ANTHROPIC_API_KEY hat Vorrang vor dem hier hinterlegten Key.
  • +
  • Änderungen werden innerhalb von 60 Sekunden wirksam.
  • +
+
+
+ ); +} diff --git a/packages/frontend/src/admin/AdminLayout.tsx b/packages/frontend/src/admin/AdminLayout.tsx index e3e5108..38971f8 100644 --- a/packages/frontend/src/admin/AdminLayout.tsx +++ b/packages/frontend/src/admin/AdminLayout.tsx @@ -12,6 +12,7 @@ const tabs = [ { to: '/admin/profile-access', label: 'Profilzugriff' }, { to: '/admin/crm-settings', label: 'CRM Sichtbarkeit' }, { to: '/admin/master-data', label: 'Stammdaten' }, + { to: '/admin/ai-settings', label: 'KI-Einstellungen' }, ]; export function AdminLayout() { diff --git a/packages/frontend/src/components/HelpPanel/HelpPanel.tsx b/packages/frontend/src/components/HelpPanel/HelpPanel.tsx index f3ab751..08230af 100644 --- a/packages/frontend/src/components/HelpPanel/HelpPanel.tsx +++ b/packages/frontend/src/components/HelpPanel/HelpPanel.tsx @@ -1,4 +1,6 @@ import { useState, useRef, useEffect } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; import api from '../../api/client'; interface ChatMessage { @@ -23,6 +25,10 @@ const HELP_TEXTS: Record = { title: 'Stammdaten', text: 'Verwalten Sie Referenzlisten wie Abteilungen, Standorte, Kostenstellen, Stellenbezeichnungen und Skill-Kategorien. Diese Listen werden in anderen Bereichen als Auswahloptionen verwendet.', }, + 'admin-ai-settings': { + title: 'KI-Einstellungen', + text: 'Hinterlegen Sie hier Ihren Anthropic API-Key, um den KI-Assistenten zu aktivieren. Der Key wird sicher gespeichert und niemals zurückgegeben. Ohne Key stehen nur die statischen Hilfetexte zur Verfügung.', + }, 'crm-deals': { title: 'Deals', text: 'Verwalten Sie Ihre Verkaufschancen. Jeder Deal hat einen Wert, eine Stage in Ihrer Pipeline und einen Status (Offen, Gewonnen, Verloren). Nutzen Sie das Kanban-Board für eine visuelle Übersicht.', @@ -49,6 +55,16 @@ export function HelpPanel({ isOpen, onClose, pageKey }: Props) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const messagesEndRef = useRef(null); + const navigate = useNavigate(); + + const { data: aiConfig } = useQuery({ + queryKey: ['settings', 'ai-config'], + queryFn: () => api.get<{ configured: boolean }>('/settings/ai-config').then(r => r.data), + staleTime: 60_000, + enabled: isOpen, + }); + + const aiConfigured = aiConfig?.configured ?? true; // optimistic default until loaded const helpContent = HELP_TEXTS[pageKey] ?? { title: 'Hilfe', @@ -141,82 +157,112 @@ export function HelpPanel({ isOpen, onClose, pageKey }: Props) {

- {/* Messages */} -
- {messages.length === 0 && ( -

- Stellen Sie eine Frage zur Bedienung von INSIGHT… + {!aiConfigured ? ( + /* AI not configured — show hint */ +

+ 🔑 +

+ Der KI-Assistent ist noch nicht eingerichtet.

- )} - {messages.map((msg, i) => ( -
- {msg.content} + +
+ ) : ( + <> + {/* Messages */} +
+ {messages.length === 0 && ( +

+ Stellen Sie eine Frage zur Bedienung von INSIGHT… +

+ )} + {messages.map((msg, i) => ( +
+ {msg.content} +
+ ))} + {loading && ( +
+ Denkt nach… +
+ )} + {error && ( +
{error}
+ )} +
- ))} - {loading && ( -
- Denkt nach… -
- )} - {error && ( -
{error}
- )} -
-
- {/* Input */} -
-