feat(ki): Modell aus Liste der verfuegbaren OpenAI-Modelle waehlen

Bisher musste der Administrator den Modellnamen abtippen - ein Tippfehler
fiel erst beim Test oder bei der ersten Frage auf, und welche Modelle der
Schluessel ueberhaupt darf, war in Pulse nicht zu sehen.

Einstellungen -> KI-Assistent zeigt jetzt ein Auswahlfeld. Die Liste kommt
ueber POST /settings/ai/models live von OpenAI (/v1/models), neueste
zuerst; Embedding-, Bild-, Audio-, Realtime-, Such- und
Moderationsmodelle fliegen raus. "Liste laden" nimmt einen gerade
eingetippten, noch nicht gespeicherten Schluessel mit. Das eingestellte
Modell bleibt waehlbar, auch wenn OpenAI es nicht mehr listet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-23 10:25:21 +02:00
co-authored by Claude Opus 5.5
parent 8a8e433397
commit 2f9c58f202
5 changed files with 132 additions and 4 deletions
+17
View File
@@ -180,6 +180,23 @@ nach). Ausgang im Journal: `journalctl -u perinco-pulse-hrworks`. Die Unit-Datei
Billable und Festpreis Sinn. Am Abwesenheitsprojekt wäre er nur ein Ablaufdatum, das irgendwann
Buchungen blockiert; es hat deshalb keinen.
## KI-Assistent
**Einrichtung** (nur Administrator, *Einstellungen → KI-Assistent*): OpenAI-Schlüssel eintragen
(AES-verschlüsselt gespeichert, nie wieder ausgeliefert), Modell wählen, einschalten. Der Knopf
„Fragen" im Kopf erscheint dann für Management und Administration.
**Modell** wird aus einer Liste gewählt, nicht mehr eingetippt. Die Liste kommt live von OpenAI
(`POST /api/settings/ai/models` → `GET https://api.openai.com/v1/models`), also genau die Modelle,
die der Schlüssel darf – neueste zuerst, mit Veröffentlichungsdatum. Embedding-, Bild-, Audio-,
Realtime-, Such- und Moderationsmodelle filtert `listModels` in `server/src/lib/ai.js` heraus; sie
können keine Textantwort mit Werkzeugen. Ist ein Schlüssel hinterlegt, lädt die Seite die Liste
beim Öffnen; *Liste laden* holt sie neu – mit einem gerade eingetippten, noch nicht gespeicherten
Schlüssel, falls vorhanden (deshalb POST: der Schlüssel gehört nicht in eine URL). Ein eingestelltes
Modell, das OpenAI nicht mehr listet, bleibt in der Auswahl stehen, damit Speichern es nicht
stillschweigend ersetzt. Voreingestellt ist `gpt-5.6-luna`; *Verbindung testen* prüft Schlüssel und
Modell mit einer echten Anfrage.
## SSO / Active Directory
**Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung.
+37
View File
@@ -15,6 +15,7 @@
*/
const ENDPOINT = 'https://api.openai.com/v1/responses';
const MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
const TIMEOUT_MS = 120_000;
export const AI_DEFAULT_MODEL = 'gpt-5.6-luna';
@@ -73,6 +74,42 @@ export async function askModel(ai, { instructions, input, tools, maxOutputTokens
return data;
}
/*
* Modelle, die fuer den Assistenten in Frage kommen. /v1/models liefert alles,
* was der Schluessel darf - auch Embeddings, Bild-, Audio- und Moderationsmodelle.
* Die koennen keine Textantwort mit Werkzeugen und fliegen raus.
*/
const CHAT_MODEL = /^(gpt-|o\d|chatgpt-)/;
const NOT_CHAT = /(embedding|tts|whisper|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|codex)/;
/** Liste der Modelle fuer die Auswahl in den Einstellungen, neueste zuerst. */
export async function listModels(apiKey) {
if (!apiKey) throw new AiError('Es ist kein OpenAI-Schlüssel hinterlegt.', 400);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 20_000);
let response;
try {
response = await fetch(MODELS_ENDPOINT, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: controller.signal,
});
} catch (err) {
if (err.name === 'AbortError') throw new AiError('OpenAI hat nicht rechtzeitig geantwortet.', 504);
throw new AiError(`OpenAI ist nicht erreichbar: ${err.message}`);
} finally {
clearTimeout(timer);
}
const data = await response.json().catch(() => ({}));
if (response.status === 401) throw new AiError('OpenAI hat den Schlüssel abgelehnt.', 502);
if (!response.ok) throw new AiError(`OpenAI: ${data?.error?.message || `HTTP ${response.status}`}`);
return (data.data || [])
.filter((m) => CHAT_MODEL.test(m.id) && !NOT_CHAT.test(m.id))
.sort((a, b) => (b.created || 0) - (a.created || 0) || a.id.localeCompare(b.id))
.map((m) => ({ id: m.id, created: m.created ? new Date(m.created * 1000).toISOString().slice(0, 10) : null }));
}
/** Text aller Nachrichten einer Antwort. */
export function answerText(response) {
return (response.output || [])
+16 -1
View File
@@ -2,7 +2,7 @@ import express from 'express';
import { requireRole, ROLES } from '../middleware/auth.js';
import { DEFAULTS, getSettings, saveSetting } from '../lib/settings.js';
import { ssoConfigured, testConnection } from '../lib/sso.js';
import { answerText, askModel } from '../lib/ai.js';
import { answerText, askModel, listModels } from '../lib/ai.js';
import { sendDocument } from '../export/render.js';
const router = express.Router();
@@ -178,6 +178,21 @@ router.post('/ai/test', requireRole('admin'), async (req, res) => {
}
});
/*
* Verfuegbare Modelle fuer das Auswahlfeld. POST statt GET, weil ein gerade
* eingetippter, noch nicht gespeicherter Schluessel mitkommen darf - der
* gehoert nicht in eine URL. Ohne ihn gilt der hinterlegte.
*/
router.post('/ai/models', requireRole('admin'), async (req, res) => {
try {
const typed = typeof req.body?.api_key === 'string' ? req.body.api_key.trim().slice(0, 300) : '';
const apiKey = typed || (await getSettings()).ai.api_key;
res.json({ models: await listModels(apiKey) });
} catch (err) {
res.status(err.status || 502).json({ error: err.message });
}
});
router.post('/sso/test', requireRole('admin'), async (req, res) => {
try {
const { sso } = await getSettings();
+56 -3
View File
@@ -5,7 +5,7 @@ import { Team } from '../team/Team';
import { SsoDirectory } from './SsoDirectory';
import { HrworksSettings } from './HrworksSettings';
import { useAuth } from '../../lib/auth';
import { download, settingsApi, type AdminSettings } from '../../lib/api';
import { download, settingsApi, type AdminSettings, type AiModel } from '../../lib/api';
import { useLoader } from '../../lib/hooks';
import { useRouter } from '../../lib/router';
import { useToast } from '../Toast';
@@ -455,6 +455,28 @@ const AiSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = (
useEffect(() => setAi({ ...settings.ai, api_key: '' }), [settings.ai]);
// Auswahl kommt live von OpenAI - welche Modelle es gibt, haengt am Schluessel.
const [models, setModels] = useState<AiModel[]>([]);
const [modelsBusy, setModelsBusy] = useState(false);
const [modelsError, setModelsError] = useState('');
const loadModels = async (apiKey?: string) => {
setModelsBusy(true);
setModelsError('');
try {
setModels((await settingsApi.aiModels(apiKey)).models);
} catch (err) {
setModelsError(err instanceof Error ? err.message : 'Modelle konnten nicht geladen werden.');
} finally {
setModelsBusy(false);
}
};
useEffect(() => {
if (settings.ai.key_set) loadModels();
}, [settings.ai.key_set]);
// Das eingestellte Modell bleibt waehlbar, auch wenn OpenAI es nicht (mehr) listet.
const modelOptions = models.some((m) => m.id === ai.model) || !ai.model ? models : [{ id: ai.model, created: null }, ...models];
const test = async () => {
setTesting(true);
try {
@@ -510,8 +532,39 @@ const AiSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = (
/>
</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
label="Modell"
hint={
modelsError ||
(models.length
? `${models.length} Modelle für diesen Schlüssel, neueste zuerst. Voreingestellt gpt-5.6-luna; ein anderes Modell muss Werkzeuge über die Responses-API unterstützen.`
: 'Die Liste kommt von OpenAI, sobald ein Schlüssel hinterlegt oder eingegeben ist.')
}
>
<div className="flex gap-2">
<select
className={`${inputClass} font-mono text-xs`}
value={ai.model}
onChange={(e) => set({ model: e.target.value })}
disabled={modelsBusy}
>
{modelOptions.map((m) => (
<option key={m.id} value={m.id}>
{m.id}
{m.created ? ` (${m.created})` : ''}
</option>
))}
</select>
<Button
variant="secondary"
icon="refresh"
busy={modelsBusy}
onClick={() => loadModels(ai.api_key)}
disabled={!settings.ai.key_set && !ai.api_key}
>
Liste laden
</Button>
</div>
</Field>
<Toggle
+6
View File
@@ -137,6 +137,7 @@ export const settingsApi = {
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'),
aiModels: (apiKey?: string) => send<{ models: AiModel[] }>('POST', '/settings/ai/models', { api_key: apiKey || undefined }),
};
export interface ExportSettings {
@@ -226,6 +227,11 @@ export const hrworksApi = {
removeAll: () => send<{ deleted: number }>('DELETE', '/hrworks/entries'),
};
export interface AiModel {
id: string;
created: string | null;
}
export interface AiSettings {
enabled: boolean;
api_key: string;