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:
co-authored by
Claude Opus 5.5
parent
8a8e433397
commit
2f9c58f202
@@ -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.
|
||||
|
||||
@@ -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 || [])
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user