From 2f9c58f202c29af42275aa37dfd2ae0a4f514839 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Wed, 23 Sep 2026 10:25:21 +0200 Subject: [PATCH] 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 --- README.md | 17 ++++++++ server/src/lib/ai.js | 37 +++++++++++++++++ server/src/routes/settings.js | 17 +++++++- src/components/settings/Settings.tsx | 59 ++++++++++++++++++++++++++-- src/lib/api.ts | 6 +++ 5 files changed, 132 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 48a5cb0..d136722 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/server/src/lib/ai.js b/server/src/lib/ai.js index 327407a..e0d3e99 100644 --- a/server/src/lib/ai.js +++ b/server/src/lib/ai.js @@ -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 || []) diff --git a/server/src/routes/settings.js b/server/src/routes/settings.js index 7c13906..ae8d150 100644 --- a/server/src/routes/settings.js +++ b/server/src/routes/settings.js @@ -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(); diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index c9c22a0..622b07d 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -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([]); + 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 }> = ( /> - - set({ model: e.target.value })} /> + +
+ + +
) => 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;