feat(ki): Einstellungen -> KI mit OpenAI-Schluessel und Modellwahl

Neuer Admin-Reiter "KI": zeigt die letzten vier Zeichen des hinterlegten
OpenAI-Schluessels samt Quelle, nimmt einen neuen an (vorher gegen
/v1/models geprueft) und laesst je Einsatzzweck das Modell per Dropdown
waehlen - Scanner-Bewertung, Abgleich "bereits beworben", erweiterte Suche.
Die Auswahl kommt live von OpenAI, Vorgaben sind die bisher fest
verdrahteten Modelle. Reasoning-Modelle bekommen reasoning_effort statt
temperature, damit ein Wechsel keinen 400er ausloest.

Schluesselfeld aus dem Scanner-Reiter entfernt; Doku in docs/14-ki.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-23 10:31:55 +02:00
co-authored by Claude Opus 5.5
parent 0d41d46a3c
commit e35c7518db
26 changed files with 930 additions and 144 deletions
+1 -1
View File
@@ -181,7 +181,7 @@ const App: React.FC = () => {
Unpassend: 'Projekt Scanner - als „passt nicht" markierte Treffer',
Suchagenten: 'Projekt Scanner - Beraterprofile mit CV und Suchbegriffen',
'Börsen-Konten': 'Projekt Scanner - Verbindungen zu den Jobbörsen',
Einstellungen: 'Konto, Diktat, Benutzer, Postfach und Scanner',
Einstellungen: 'Konto, Diktat, Benutzer, Postfach, Scanner und KI',
};
const subtitle =
+287
View File
@@ -0,0 +1,287 @@
import React, { useEffect, useState } from 'react';
import {
aiApi,
type AiAvailableModels,
type AiKeyStatus,
type AiPurpose,
type AiSettings as AiSettingsData,
} from '../../lib/api';
import { inputClass, labelClass } from '../leads/leadUi';
import { useToast } from '../Toast';
/*
* KI - Adminsicht: OpenAI-Schlüssel und das Modell je Einsatzzweck.
*
* Der Schlüssel geht nur hinein. Angezeigt werden seine letzten vier Zeichen,
* damit man erkennt, welcher hinterlegt ist - mehr gibt der Server nicht her.
* Es gibt genau einen Schlüssel für Scanner und CRM.
*/
const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div className="flex justify-between gap-3 text-sm">
<dt className="shrink-0 text-ink-400">{label}</dt>
<dd className="truncate text-right font-semibold text-ink-800">{value}</dd>
</div>
);
const KeySection: React.FC<{
keyStatus: AiKeyStatus;
onChange: (key: AiKeyStatus, available: AiAvailableModels) => void;
}> = ({ keyStatus, onChange }) => {
const { addToast } = useToast();
const [value, setValue] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [isClearing, setIsClearing] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (!value.trim()) return;
setIsSaving(true);
try {
const result = await aiApi.saveKey(value.trim());
onChange(result.key, result.available);
setValue('');
addToast('Schlüssel gespeichert - OpenAI hat ihn akzeptiert.', 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setIsSaving(false);
}
};
const handleClear = async () => {
setIsClearing(true);
try {
const result = await aiApi.clearKey();
onChange(result.key, result.available);
addToast(
result.key.configured
? 'Hinterlegter Schlüssel verworfen - es gilt wieder der Wert aus server/.env.'
: 'Hinterlegter Schlüssel verworfen.',
'success'
);
} catch (err) {
addToast(err instanceof Error ? err.message : 'Verwerfen fehlgeschlagen.', 'error');
} finally {
setIsClearing(false);
}
};
return (
<section
className={`rounded-2xl border border-l-4 border-ink-200 bg-surface p-5 ${
keyStatus.configured ? 'border-l-green-500' : 'border-l-amber-500'
}`}
>
<h3 className="text-base text-ink-900">OpenAI-Schlüssel</h3>
<p className="mt-1 text-xs text-ink-400">
Ein Schlüssel für alles: Scanner-Bewertung, Abgleich „bereits beworben“ und erweiterte Suche.
Angezeigt wird nur das Ende.
</p>
<dl className="mt-4 space-y-2">
<Fact
label="Hinterlegt"
value={
keyStatus.configured ? (
<code className="font-mono">
{keyStatus.last4 ? `sk-…${keyStatus.last4}` : 'ja'}
</code>
) : (
<span className="text-amber-700">kein Schlüssel</span>
)
}
/>
<Fact
label="Quelle"
value={
keyStatus.source === 'db'
? 'hier hinterlegt'
: keyStatus.source === 'env'
? 'server/.env'
: 'fehlt'
}
/>
</dl>
{!keyStatus.configured && (
<p className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800">
Ohne Schlüssel bewertet der Scanner keine Projekte und die erweiterte Suche bleibt aus.
</p>
)}
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
<div>
<label className={labelClass} htmlFor="ai-key">
Neuer Schlüssel
</label>
<input
id="ai-key"
type="password"
autoComplete="off"
spellCheck={false}
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="sk-proj-..."
className={inputClass}
/>
<p className="mt-1 text-xs text-ink-400">
Beim Speichern fragt das CRM testweise bei OpenAI an - ein vertippter Schlüssel wird gar
nicht erst übernommen. Der alte bleibt bis dahin gültig.
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="submit"
disabled={!value.trim() || isSaving}
className="rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
>
{isSaving ? 'Prüfe...' : 'Speichern'}
</button>
{keyStatus.source === 'db' && (
<button
type="button"
onClick={handleClear}
disabled={isClearing}
className="rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:text-red-700 disabled:opacity-50"
>
{isClearing
? 'Verwerfe...'
: keyStatus.env_fallback
? 'Verwerfen, .env nutzen'
: 'Schlüssel entfernen'}
</button>
)}
</div>
</form>
</section>
);
};
const PURPOSE_ORDER: AiPurpose[] = ['matching', 'applied', 'search'];
const ModelSection: React.FC<{
models: AiSettingsData['models'];
available: AiAvailableModels;
onChange: (models: AiSettingsData['models']) => void;
}> = ({ models, available, onChange }) => {
const { addToast } = useToast();
const [draft, setDraft] = useState<Record<AiPurpose, string>>(() => ({
matching: models.matching.model,
applied: models.applied.model,
search: models.search.model,
}));
const [isSaving, setIsSaving] = useState(false);
const changed = PURPOSE_ORDER.filter((purpose) => draft[purpose] !== models[purpose].model);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
if (changed.length === 0) return;
setIsSaving(true);
try {
const payload: Partial<Record<AiPurpose, string>> = {};
for (const purpose of changed) payload[purpose] = draft[purpose];
const result = await aiApi.saveModels(payload);
onChange(result.models);
addToast('Modellwahl gespeichert. Gilt ab dem nächsten Aufruf.', 'success');
} catch (err) {
addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
} finally {
setIsSaving(false);
}
};
return (
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h3 className="text-base text-ink-900">Modell</h3>
<p className="mt-1 text-xs text-ink-400">
{available.source === 'openai'
? 'Die Auswahl stammt live von OpenAI - alle Textmodelle, die dieser Schlüssel nutzen darf.'
: 'Feste Auswahl - die Liste von OpenAI war nicht abrufbar.'}
</p>
{available.error && (
<p className="mt-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-xs font-semibold text-amber-800">
OpenAI: {available.error}
</p>
)}
<form onSubmit={handleSubmit} className="mt-4 space-y-4">
{PURPOSE_ORDER.map((purpose) => (
<div key={purpose}>
<label className={labelClass} htmlFor={`ai-model-${purpose}`}>
{models[purpose].label}
</label>
<select
id={`ai-model-${purpose}`}
value={draft[purpose]}
onChange={(event) => setDraft((prev) => ({ ...prev, [purpose]: event.target.value }))}
className={inputClass}
>
{available.models.map((model) => (
<option key={model} value={model}>
{model}
{model === models[purpose].fallback ? ' (Vorgabe)' : ''}
</option>
))}
</select>
</div>
))}
<button
type="submit"
disabled={changed.length === 0 || isSaving}
className="rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
>
{isSaving ? 'Speichere...' : 'Modellwahl speichern'}
</button>
</form>
</section>
);
};
export const AiSettings: React.FC = () => {
const { addToast } = useToast();
const [data, setData] = useState<AiSettingsData | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
aiApi
.settings()
.then((result) => {
if (!cancelled) setData(result);
})
.catch((err) => {
if (!cancelled) {
addToast(err instanceof Error ? err.message : 'KI-Einstellungen nicht erreichbar.', 'error');
}
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (isLoading) return <p className="text-sm text-ink-400">Wird geladen...</p>;
if (!data) return null;
return (
<div className="grid gap-6 lg:grid-cols-2">
<KeySection
keyStatus={data.key}
onChange={(key, available) => setData((prev) => (prev ? { ...prev, key, available } : prev))}
/>
<ModelSection
models={data.models}
available={data.available}
onChange={(models) => setData((prev) => (prev ? { ...prev, models } : prev))}
/>
</div>
);
};
+1 -28
View File
@@ -109,7 +109,6 @@ export const ScannerSettings: React.FC = () => {
const isScanningActive = values.scanning_active === 'true';
const emailActive = values.email_notifications_active === 'true';
const keyStored = values.openai_api_key === SECRET_MASK;
const appliedCheckActive = values.applied_check_active === 'true';
const periodDays = Math.max(1, Number.parseInt(values.applied_check_days, 10) || 14);
const periodFactor = periodUnit === 'weeks' ? 7 : 1;
@@ -190,7 +189,7 @@ export const ScannerSettings: React.FC = () => {
<Section
title="Abgleich mit Akquise und Opportunities"
description="Jedes neu gefundene Projekt mit Treffer wird mit den Einträgen der Akquise-Liste und den Opportunities verglichen. Ist es dort schon erfasst – auch unter anderem Titel oder von einer anderen Börse –, erscheint es im Dashboard mit „bereits beworben!“. Die KI (gpt-5.6-luna) nutzt den OpenAI-Key unten."
description="Jedes neu gefundene Projekt mit Treffer wird mit den Einträgen der Akquise-Liste und den Opportunities verglichen. Ist es dort schon erfasst – auch unter anderem Titel oder von einer anderen Börse –, erscheint es im Dashboard mit „bereits beworben!“. KI-Modell und OpenAI-Schlüssel stehen unter Einstellungen → KI."
accent={appliedCheckActive ? 'border-l-brand-500' : 'border-l-ink-300'}
>
<Checkbox
@@ -241,32 +240,6 @@ export const ScannerSettings: React.FC = () => {
</div>
</Section>
<Section
title="OpenAI"
description="Wird für die semantische Bewertung des Lebenslaufs gegen die Projektbeschreibung gebraucht (gpt-4o-mini)."
>
<label className={labelClass}>
API-Key
{keyStored && (
<span className="ml-2 rounded-md border border-brand-200 bg-brand-50 px-2 py-0.5 text-[10px] font-extrabold uppercase tracking-wider text-brand-700">
gespeichert
</span>
)}
</label>
<input
type="password"
autoComplete="new-password"
value={keyStored ? '' : values.openai_api_key}
onChange={(event) => set('openai_api_key', event.target.value)}
placeholder={
keyStored
? 'Leer lassen, um den gespeicherten Key zu behalten'
: 'sk-proj-...'
}
className={inputClass}
/>
</Section>
<Section
title="Browser-Benachrichtigungen"
description={`Bei einem neuen Treffer über ${values.match_threshold}% spielt das Scanner-Dashboard einen Signalton. Voraussetzung: Der Reiter ist im Browser geöffnet. Browser lassen Töne erst nach dem ersten Klick auf der Seite zu.`}
+3
View File
@@ -4,6 +4,7 @@ import { MailSettings } from './MailSettings';
import { SsoSettings } from './SsoSettings';
import { AcquisitionSettings } from './AcquisitionSettings';
import { ScannerSettings } from './ScannerSettings';
import { AiSettings } from './AiSettings';
import { ColorPicker } from './ColorPicker';
import { AvatarPicker } from '../ui/AvatarPicker';
import { WhisperSettings } from '../whisper/WhisperSettings';
@@ -20,6 +21,7 @@ const TABS: { id: SettingsTab; label: string }[] = [
{ id: 'mail', label: 'Postfach' },
{ id: 'sso', label: 'Anmeldung' },
{ id: 'scanner', label: 'Scanner' },
{ id: 'ki', label: 'KI' },
{ id: 'whisper', label: 'Whisper' },
];
@@ -227,6 +229,7 @@ export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab })
{tab === 'mail' && isAdmin && <MailSettings />}
{tab === 'sso' && isAdmin && <SsoSettings />}
{tab === 'scanner' && isAdmin && <ScannerSettings />}
{tab === 'ki' && isAdmin && <AiSettings />}
{tab === 'whisper' && <WhisperSettings />}
</div>
);
+51
View File
@@ -166,6 +166,57 @@ export const msSecretApi = {
request<{ ok: true; secret: MsSecretStatus }>('/auth/microsoft-secret', { method: 'DELETE' }),
};
// ---------- KI: OpenAI-Schluessel und Modellwahl (nur admin) ----------
export interface AiKeyStatus {
configured: boolean;
/** 'db' = in den Einstellungen hinterlegt, 'env' = aus server/.env */
source: 'db' | 'env' | null;
/** Letzte vier Zeichen - mehr gibt der Server nie heraus. */
last4: string | null;
env_fallback: boolean;
}
export type AiPurpose = 'matching' | 'applied' | 'search';
export interface AiModelSetting {
label: string;
model: string;
fallback: string;
}
export interface AiAvailableModels {
models: string[];
/** 'openai' = live abgefragt, 'fallback' = feste Liste */
source: 'openai' | 'fallback';
error: string | null;
}
export interface AiSettings {
key: AiKeyStatus;
models: Record<AiPurpose, AiModelSetting>;
available: AiAvailableModels;
}
export const aiApi = {
settings: () => request<AiSettings>('/ai/settings'),
saveKey: (key: string) =>
request<{ ok: true; key: AiKeyStatus; available: AiAvailableModels }>('/ai/key', {
method: 'PUT',
body: JSON.stringify({ key }),
}),
/** Verwirft den hinterlegten Schluessel; ab dann gilt wieder server/.env. */
clearKey: () =>
request<{ ok: true; key: AiKeyStatus; available: AiAvailableModels }>('/ai/key', {
method: 'DELETE',
}),
saveModels: (models: Partial<Record<AiPurpose, string>>) =>
request<{ ok: true; models: Record<AiPurpose, AiModelSetting> }>('/ai/models', {
method: 'PUT',
body: JSON.stringify(models),
}),
};
// ---------- Benutzerverwaltung (nur admin) ----------
export interface UserInput {
-2
View File
@@ -89,7 +89,6 @@ export interface ScannerMatch {
export interface ScannerSettings {
scanning_active: string;
search_interval: string;
openai_api_key: string;
match_threshold: string;
display_threshold: string;
sound_notifications_active: string;
@@ -108,7 +107,6 @@ export interface ScannerSettings {
export const SCANNER_SETTINGS_DEFAULTS: ScannerSettings = {
scanning_active: 'true',
search_interval: '60',
openai_api_key: '',
match_threshold: '70',
display_threshold: '50',
sound_notifications_active: 'true',
+2 -1
View File
@@ -31,9 +31,10 @@ export type SettingsTab =
| 'mail'
| 'sso'
| 'scanner'
| 'ki'
| 'whisper';
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'sso', 'scanner'];
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'sso', 'scanner', 'ki'];
/**
* Reiter des Projekt Scanners. "Scanner" selbst ist das Dashboard und damit die