feat(assistant): Fragen an die Daten im Kopf der Anwendung

Management und Administration koennen oben rechts eine Frage in Worten
stellen - "wie viele Stunden im August", "welches Projekt hat noch
Budget", "wie ist die Auslastung".

Aufbau: das Modell bekommt eine Uebersicht (Kunden, laufende Projekte,
Eckdaten) und vier Werkzeuge - auswertung, projekte, buchungen,
personen. Damit fragt es gezielt nach, statt dass ihm der ganze Bestand
vorgelegt wird; 19.000 Buchungen passen in keinen Kontext. Die Werkzeuge
rechnen mit loadEntries und loadProjects, also mit denselben Funktionen
wie die Reports - der Assistent kann so keine zweite Wahrheit erzeugen.
Was einfliesst, begrenzt visibleUserIds wie ueberall sonst.

Benutzt wird die Responses-API von OpenAI: die neueren Modelle nehmen
Werkzeuge in /v1/chat/completions nur ohne Nachdenken an. Mit
"store: false" bleibt nichts bei OpenAI liegen, der Verlauf geht deshalb
je Runde komplett mit. Der Schluessel liegt verschluesselt in den
Einstellungen (AES-256-GCM wie das SSO-Secret) und wird nie ausgeliefert;
das Modell ist einstellbar, Vorgabe gpt-5.6-luna.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-21 17:31:51 +02:00
co-authored by Claude Opus 5
parent c3aed8a3d3
commit e908db2788
11 changed files with 909 additions and 2 deletions
+172
View File
@@ -0,0 +1,172 @@
import React, { useEffect, useRef, useState } from 'react';
import { Modal } from '../ui/Modal';
import { Button, ErrorBox, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { assistantApi, type AssistantAnswer } from '../../lib/api';
import { useAuth } from '../../lib/auth';
import { atLeast } from '../../types';
/*
* Assistent im Kopf: eine Frage in Worten, eine Antwort aus den Daten von
* Pulse. Sichtbar nur fuer Management und Administration - die Antworten
* fassen Zeiten und Umsaetze ueber alle Mitarbeiter zusammen.
*
* Der Knopf erscheint erst, wenn in den Einstellungen ein Schluessel steht;
* sonst waere er eine Tuer, hinter der nichts ist.
*/
const BEISPIELE = [
'Wie viele Stunden hat das Team diesen Monat gebucht?',
'Welche Projekte haben weniger als 20 Prozent Restbudget?',
'Wie ist die Auslastung im September?',
'Wie viel Umsatz kam dieses Jahr von Hays?',
];
type Runde = { frage: string; antwort: string; werkzeuge: AssistantAnswer['tools'] };
export const Assistant: React.FC = () => {
const { user } = useAuth();
const erlaubt = atLeast(user, 'management');
const [status, setStatus] = useState<{ available: boolean; model: string } | null>(null);
const [offen, setOffen] = useState(false);
useEffect(() => {
if (!erlaubt) return;
assistantApi
.status()
.then((s) => setStatus({ available: s.available, model: s.model }))
.catch(() => setStatus(null));
}, [erlaubt]);
if (!erlaubt || !status?.available) return null;
return (
<>
<button
onClick={() => setOffen(true)}
title="Fragen zu den Daten stellen"
className="flex items-center gap-2 rounded-xl border border-brand-200 bg-brand-50 px-2.5 py-2 text-xs font-bold text-brand-700 transition hover:border-brand-400 hover:bg-brand-100 sm:px-3"
>
<Icon name="spark" className="h-4 w-4" />
<span className="hidden md:inline">Fragen</span>
</button>
{offen && <AssistantDialog model={status.model} onClose={() => setOffen(false)} />}
</>
);
};
const AssistantDialog: React.FC<{ model: string; onClose: () => void }> = ({ model, onClose }) => {
const [frage, setFrage] = useState('');
const [verlauf, setVerlauf] = useState<Runde[]>([]);
const [laeuft, setLaeuft] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
const ende = useRef<HTMLDivElement>(null);
useEffect(() => {
ende.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [verlauf, laeuft]);
const fragen = async (text: string) => {
const gestellt = text.trim();
if (!gestellt || laeuft) return;
setFrage('');
setFehler(null);
setLaeuft(true);
try {
const antwort = await assistantApi.ask(
gestellt,
verlauf.map((r) => ({ frage: r.frage, antwort: r.antwort }))
);
setVerlauf((v) => [...v, { frage: gestellt, antwort: antwort.answer, werkzeuge: antwort.tools }]);
} catch (err) {
setFehler(err instanceof Error ? err.message : 'Die Anfrage ist fehlgeschlagen.');
setFrage(gestellt);
} finally {
setLaeuft(false);
}
};
return (
<Modal
title="Fragen an die Daten"
subtitle={`Antworten kommen aus Pulse, gerechnet wie in den Reports · Modell ${model}`}
size="lg"
onClose={onClose}
footer={
<div className="flex w-full items-center gap-2">
<input
autoFocus
className={inputClass}
placeholder="Frage eingeben…"
value={frage}
onChange={(e) => setFrage(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void fragen(frage);
}
}}
/>
<Button onClick={() => void fragen(frage)} disabled={laeuft || !frage.trim()}>
{laeuft ? 'Denkt…' : 'Fragen'}
</Button>
</div>
}
>
<div className="space-y-4">
{verlauf.length === 0 && !laeuft && (
<div className="space-y-3">
<p className="text-sm text-ink-500">
Frag nach Stunden, Umsatz, Budgets oder Auslastung. Der Assistent liest nur - er ändert nichts.
</p>
<div className="flex flex-wrap gap-2">
{BEISPIELE.map((b) => (
<button
key={b}
onClick={() => void fragen(b)}
className="rounded-xl border border-ink-200 px-3 py-2 text-left text-xs font-semibold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
>
{b}
</button>
))}
</div>
</div>
)}
{verlauf.map((runde, i) => (
<div key={i} className="space-y-2">
<p className="ml-auto w-fit max-w-[85%] rounded-2xl rounded-br-sm bg-brand-500 px-3.5 py-2 text-sm font-semibold text-white">
{runde.frage}
</p>
<div className="w-fit max-w-[92%] space-y-2 rounded-2xl rounded-bl-sm bg-ink-50 px-3.5 py-2.5">
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-800">{runde.antwort}</p>
{runde.werkzeuge.length > 0 && (
<p className="text-[11px] text-ink-400">
Grundlage:{' '}
{runde.werkzeuge
.map((w) => {
const args = Object.entries(w.args)
.filter(([, v]) => v !== null && v !== undefined && v !== '')
.map(([k, v]) => `${k}=${String(v)}`)
.join(', ');
return args ? `${w.name} (${args})` : w.name;
})
.join(' · ')}
</p>
)}
</div>
</div>
))}
{laeuft && (
<p className="flex items-center gap-2 text-sm text-ink-400">
<Icon name="spark" className="h-4 w-4 animate-pulse" />
Sucht die Zahlen zusammen…
</p>
)}
{fehler && <ErrorBox message={fehler} />}
<div ref={ende} />
</div>
</Modal>
);
};
+3
View File
@@ -3,6 +3,7 @@ import { useTheme } from '../../lib/theme';
import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../../types';
import { Icon } from '../ui/Icon';
import { PulseMark } from './PulseMark';
import { Assistant } from '../assistant/Assistant';
interface HeaderProps {
title: string;
@@ -37,6 +38,8 @@ export const Header: React.FC<HeaderProps> = ({ title, subtitle, onToggleSidebar
</div>
<div className="flex shrink-0 items-center gap-2">
{/* Nur Management und Administration - zeigt Zahlen ueber alle Mitarbeiter. */}
<Assistant />
<label className="sr-only" htmlFor="theme-palette">
Design
</label>
+93 -1
View File
@@ -10,12 +10,13 @@ import { useLoader } from '../../lib/hooks';
import { useRouter } from '../../lib/router';
import { useToast } from '../Toast';
type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'hrworks' | 'benutzer';
type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'hrworks' | 'ki' | 'benutzer';
const TABS: { key: Tab; label: string; icon: IconName; adminOnly: boolean }[] = [
{ key: 'allgemein', label: 'Allgemein', icon: 'settings', adminOnly: true },
{ key: 'export', label: 'Export: Kopf & Fuß', icon: 'document', adminOnly: true },
{ key: 'sso', label: 'SSO / Entra ID', icon: 'key', adminOnly: true },
{ key: 'hrworks', label: 'Abwesenheiten (HRworks)', icon: 'calendar', adminOnly: true },
{ key: 'ki', label: 'KI-Assistent', icon: 'spark', adminOnly: true },
// Management sieht in den Einstellungen nur diesen Reiter.
{ key: 'sso-verwaltung', label: 'SSO-Verwaltung', icon: 'globe', adminOnly: false },
{ key: 'benutzer', label: 'Benutzerverwaltung', icon: 'team', adminOnly: true },
@@ -60,6 +61,7 @@ export const Settings: React.FC = () => {
{tab === 'export' && <ExportSettings settings={data} onSaved={(s) => setData(s)} />}
{tab === 'sso' && <SsoSettings settings={data} onSaved={reload} />}
{tab === 'hrworks' && <HrworksSettings settings={data} onSaved={reload} />}
{tab === 'ki' && <AiSettings settings={data} onSaved={reload} />}
</div>
)}
</div>
@@ -438,3 +440,93 @@ const SsoSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> =
</div>
);
};
/*
* KI-Assistent: Schluessel und Modell. Der Schluessel wird verschluesselt
* abgelegt und nie wieder ausgeliefert - wie das SSO-Secret.
*/
const AiSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = ({ settings, onSaved }) => {
const [ai, setAi] = useState({ ...settings.ai, api_key: '' });
const { busy, save } = useSaver();
const { addToast } = useToast();
const [testing, setTesting] = useState(false);
const set = (patch: Partial<typeof ai>) => setAi({ ...ai, ...patch });
useEffect(() => setAi({ ...settings.ai, api_key: '' }), [settings.ai]);
const test = async () => {
setTesting(true);
try {
const r = await settingsApi.aiTest();
addToast(`Verbindung ok – ${r.model} antwortet: ${r.answer || 'OK'}`, 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Test fehlgeschlagen.', 'error');
} finally {
setTesting(false);
}
};
return (
<div className="space-y-4">
<Section
title="KI-Assistent"
hint="Fragen zu Kunden, Projekten, Budgets und Auslastung – im Kopf der Anwendung, für Management und Administration"
footer={
<>
<Button variant="secondary" icon="refresh" busy={testing} onClick={test} disabled={!settings.ai.key_set && !ai.api_key}>
Verbindung testen
</Button>
<Button icon="check" busy={busy} onClick={() => save({ ai: { ...ai, api_key: ai.api_key || undefined } }, onSaved)}>
Speichern
</Button>
</>
}
>
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold ${settings.ai.enabled ? 'bg-brand-50 text-brand-800' : 'bg-ink-50 text-ink-600'}`}>
<Icon name={settings.ai.enabled ? 'check' : 'info'} className="h-5 w-5" />
{settings.ai.enabled
? 'Der Assistent ist aktiv – Management und Administration sehen oben rechts „Fragen“.'
: settings.ai.key_set
? 'Schlüssel hinterlegt, der Assistent ist noch nicht eingeschaltet.'
: 'Noch kein Schlüssel hinterlegt.'}
</div>
<Field
label="OpenAI API-Schlüssel"
hint={settings.ai.key_set ? 'Hinterlegt (verschlüsselt). Leer lassen = unverändert.' : 'Wird verschlüsselt gespeichert und nie wieder angezeigt.'}
>
{/* Kein type="password" - Passwortmanager sollen hier nicht anspringen. */}
<input
name="pulse-openai-credential"
autoComplete="off"
data-lpignore="true"
data-1p-ignore="true"
className={`${inputClass} font-mono text-xs`}
style={{ WebkitTextSecurity: 'disc' } as React.CSSProperties}
value={ai.api_key}
onChange={(e) => set({ api_key: e.target.value })}
placeholder={settings.ai.key_set ? '••••••••••••' : 'sk-…'}
/>
</Field>
<Field label="Modell" hint="Voreingestellt gpt-5.6-luna. Ein anderes Modell muss Werkzeuge über die Responses-API unterstützen.">
<input className={`${inputClass} font-mono text-xs`} value={ai.model} onChange={(e) => set({ model: e.target.value })} />
</Field>
<Toggle
checked={ai.enabled}
onChange={(v) => set({ enabled: v })}
label="Assistent einschalten"
hint="Zeigt den Knopf „Fragen“ im Kopf – nur für Management und Administration."
/>
<div className="rounded-xl border border-ink-200 bg-ink-50/60 px-4 py-3 text-xs leading-relaxed text-ink-500">
Jede Frage geht zusammen mit den dafür nötigen Zahlen aus Pulse an OpenAI. Übertragen werden Kunden-,
Projekt- und Mitarbeiternamen sowie Stunden und Beträge – keine Zugangsdaten. OpenAI speichert die Anfragen
nicht (die Schnittstelle wird mit „store: false“ aufgerufen). Der Assistent liest nur und ändert nichts.
</div>
</Section>
</div>
);
};
+1
View File
@@ -33,6 +33,7 @@ const PATHS = {
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
key: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
spark: 'M12 3l1.9 4.6L18.5 9.5l-4.6 1.9L12 16l-1.9-4.6L5.5 9.5l4.6-1.9L12 3zM18 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9L18 15z',
tag: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z',
euro: 'M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z',
pot: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
+22 -1
View File
@@ -136,6 +136,7 @@ export const settingsApi = {
all: () => get<AdminSettings>('/settings'),
save: (changes: Partial<AdminSettingsInput>) => send<{ ok: true }>('PUT', '/settings', changes),
ssoTest: () => send<{ ok: true; sample: string[]; checked: number; eligible: number }>('POST', '/settings/sso/test'),
aiTest: () => send<{ ok: true; model: string; answer: string }>('POST', '/settings/ai/test'),
};
export interface ExportSettings {
@@ -225,15 +226,35 @@ export const hrworksApi = {
removeAll: () => send<{ deleted: number }>('DELETE', '/hrworks/entries'),
};
export interface AiSettings {
enabled: boolean;
api_key: string;
key_set?: boolean;
configured?: boolean;
model: string;
}
export interface AdminSettings extends PublicSettings {
export: ExportSettings;
sso: SsoSettings;
hrworks: HrworksSettings;
ai: AiSettings;
}
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso' | 'hrworks'> & {
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso' | 'hrworks' | 'ai'> & {
export: Partial<ExportSettings>;
sso: Partial<SsoSettings> & { clear_secret?: boolean };
hrworks: Partial<HrworksSettings> & { clear_secret?: boolean };
ai: Partial<AiSettings> & { clear_key?: boolean };
};
export interface AssistantAnswer {
answer: string;
model: string;
tools: { name: string; args: Record<string, unknown> }[];
}
export const assistantApi = {
status: () => get<{ available: boolean; enabled: boolean; model: string }>('/assistant/status'),
ask: (question: string, verlauf: { frage: string; antwort: string }[]) =>
send<AssistantAnswer>('POST', '/assistant/ask', { question, verlauf }),
};
export interface AdUser {