import { getSetting, setSetting } from './db.js'; /* * Anbindung an OpenAI. * * Bewusst ohne SDK: gebraucht werden genau zwei Aufrufe (Text und JSON), und * `fetch` ist in Node 22 eingebaut. Ein Paket weniger, das gepflegt werden muss. * * Der Schluessel verlaesst den Server nie in Richtung Browser - jede KI-Anfrage * laeuft ueber das Backend, und nach aussen gibt es ihn nur verkuerzt * ("sk-proj-...a1b2"). * * Er kann an zwei Stellen stehen: * * 1. in der Datenbank (icon_settings), gepflegt unter Einstellungen -> KI * 2. in server/.env als OPENAI_API_KEY * * Die Datenbank gewinnt. Grund: ein Schluessel laeuft ab oder wird gewechselt, * und dann soll niemand dafuer eine Datei auf dem Server bearbeiten und einen * Dienst neu starten muessen. Die .env bleibt als Startwert und als Weg, das * System ohne Oberflaeche in Gang zu bringen. */ const ENDPOINT = 'https://api.openai.com/v1/chat/completions'; /** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */ const TIMEOUT_MS = 120_000; export const DEFAULT_MODEL = 'gpt-6-luna'; const KEY_SETTING = 'openai_api_key'; const MODEL_SETTING = 'openai_model'; /* * Im Speicher gehalten, damit nicht jede Anfrage die Datenbank fragt. Gefuellt * beim Start (loadAiConfig) und bei jeder Aenderung. */ const config = { key: process.env.OPENAI_API_KEY || '', model: process.env.OPENAI_MODEL || DEFAULT_MODEL, /** Woher der gueltige Schluessel stammt - fuer die Anzeige. */ source: process.env.OPENAI_API_KEY ? 'env' : 'none', }; /** Beim Start aufrufen, sobald die Datenbank steht. */ export async function loadAiConfig() { try { const [key, model] = await Promise.all([getSetting(KEY_SETTING), getSetting(MODEL_SETTING)]); if (key) { config.key = key; config.source = 'db'; } if (model) config.model = model; } catch (err) { // Ohne Datenbank bleibt es bei der .env - das Backend soll trotzdem starten. console.warn('[ICON CRM] KI-Einstellungen nicht lesbar:', err.message); } } export function aiConfigured() { return Boolean(config.key); } export function aiModel() { return config.model; } /** * Zustand fuer die Oberflaeche. Der Schluessel wird nur verkuerzt gezeigt - * genug, um zwei Schluessel auseinanderzuhalten, zu wenig, um ihn zu benutzen. */ export function aiStatus() { return { configured: aiConfigured(), model: config.model, defaultModel: DEFAULT_MODEL, source: config.source, masked: maskKey(config.key), /** Steht in der .env noch einer? Dann greift er, wenn der aus der DB faellt. */ envAvailable: Boolean(process.env.OPENAI_API_KEY), }; } function maskKey(key) { if (!key) return null; return key.length <= 12 ? '****' : `${key.slice(0, 7)}…${key.slice(-4)}`; } /** * Schluessel setzen oder entfernen. * * Entfernen heisst: der Wert in der Datenbank faellt weg. Steht in der .env * einer, gilt wieder dieser - das ist kein Versehen, sondern der Rueckfall. */ export async function saveAiKey(key) { const value = String(key ?? '').trim(); await setSetting(KEY_SETTING, value || null); if (value) { config.key = value; config.source = 'db'; } else { config.key = process.env.OPENAI_API_KEY || ''; config.source = config.key ? 'env' : 'none'; } } /** Modell setzen; leer heisst zurueck zur Vorgabe. */ export async function saveAiModel(model) { const value = String(model ?? '').trim(); await setSetting(MODEL_SETTING, value || null); config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL; } /* * Die Modelle, die der hinterlegte Schluessel nutzen darf. * * /v1/models liefert alles - auch Bild-, Audio-, Embedding- und Moderations- * modelle. Fuer das CRM zaehlen nur Chat-Modelle, also werden die anderen * ausgefiltert. Kurz im Speicher gehalten, damit das Aufklappen der Liste nicht * jedes Mal OpenAI fragt; ein Schluesselwechsel leert den Speicher. */ const MODELS_ENDPOINT = 'https://api.openai.com/v1/models'; const MODELS_TTL_MS = 10 * 60_000; const NON_CHAT = /(embedding|whisper|tts|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|davinci|babbage|computer-use|codex|sora)/i; let modelCache = { key: '', at: 0, list: [] }; export async function listAiModels() { if (!aiConfigured()) throw new AiError('Es ist kein Schlüssel hinterlegt.', 503); if (modelCache.key === config.key && Date.now() - modelCache.at < MODELS_TTL_MS) { return modelCache.list; } let response; try { response = await fetch(MODELS_ENDPOINT, { headers: { Authorization: `Bearer ${config.key}` }, signal: AbortSignal.timeout(15_000), }); } catch { throw new AiError('OpenAI ist gerade nicht erreichbar.', 502); } const payload = await response.json().catch(() => null); if (!response.ok) { throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502); } const list = (payload?.data ?? []) .map((m) => ({ id: String(m.id), created: Number(m.created) || 0 })) .filter((m) => /^(gpt-|o\d|chatgpt-)/i.test(m.id) && !NON_CHAT.test(m.id)) // Neueste zuerst - die will man meistens. .sort((a, b) => b.created - a.created || a.id.localeCompare(b.id)); modelCache = { key: config.key, at: Date.now(), list }; return list; } class AiError extends Error { constructor(message, status = 502) { super(message); this.status = status; } } /** * Ein Aufruf an das Modell. * * `temperature` wird bewusst NICHT mitgeschickt: die Luna-Modelle lassen nur den * Standardwert zu und weisen jeden anderen mit 400 ab. * * @param {object} options * @param {string} options.system Rollenanweisung * @param {string} options.user Eigentliche Anfrage * @param {boolean} [options.json] true erzwingt eine JSON-Antwort * @param {number} [options.maxTokens] * @returns {Promise} der Antworttext */ export async function askModel({ system, user, json = false, maxTokens }) { if (!aiConfigured()) { throw new AiError( 'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI, oder OPENAI_API_KEY in server/.env).', 503 ); } const body = { model: config.model, messages: [ ...(system ? [{ role: 'system', content: system }] : []), { role: 'user', content: user }, ], }; if (json) body.response_format = { type: 'json_object' }; if (maxTokens) body.max_completion_tokens = maxTokens; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); let response; try { response = await fetch(ENDPOINT, { method: 'POST', headers: { Authorization: `Bearer ${config.key}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), signal: controller.signal, }); } catch (err) { if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504); throw new AiError('OpenAI ist gerade nicht erreichbar.', 502); } finally { clearTimeout(timer); } const payload = await response.json().catch(() => null); if (!response.ok) { // Die Meldung von OpenAI ist meist praezise ("model not found", "quota") - // sie hilft mehr als ein eigener Text, enthaelt aber nie den Schluessel. throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502); } const text = payload?.choices?.[0]?.message?.content; if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502); return text; } /** Wie askModel, gibt aber das geparste JSON zurueck. */ export async function askModelJson(options) { const text = await askModel({ ...options, json: true }); try { return JSON.parse(text); } catch { throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502); } } export { AiError };