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:
co-authored by
Claude Opus 5.5
parent
0d41d46a3c
commit
e35c7518db
+1
-1
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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.`}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user