Quellcode des ICON CRM (3)

This commit is contained in:
2026-09-14 16:55:34 +02:00
parent 0a597ae527
commit 28763fed6d
25 changed files with 6158 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
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-5.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;
}
class AiError extends Error {
constructor(message, status = 502) {
super(message);
this.status = status;
}
}
/**
* Ein Aufruf an das Modell.
*
* `temperature` wird bewusst NICHT mitgeschickt: gpt-5.6-luna laesst nur den
* Standardwert zu und weist 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<string>} 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 };