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:
co-authored by
Claude Opus 5
parent
c3aed8a3d3
commit
e908db2788
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user