Files
perinco-CRM/server/src/ai.js
T
schakal00783andClaude Opus 5 49c03f5e1b feat: globale Suche und Anbindung an das ICON CRM
Beides lag bisher nur als nicht eingecheckte Arbeit im Arbeitsverzeichnis,
waehrend auf Gitea nur die Doku dazu stand (docs/12-suche.md). Damit ist
der Code jetzt versioniert.

Globale Suche:
- server/src/routes/search.js: Volltext ueber Kontakte, Opportunities und
  Akquise, dazu die erweiterte Suche mit dem Modell des Scanners
- src/components/GlobalSearch.tsx im Kopf der Anwendung; ein Treffer
  springt in die Ansicht und oeffnet den Datensatz (navigation.tsx)

ICON CRM:
- server/src/routes/integrations.js liefert die Kontakte mit gesetztem
  Haekchen hinaus, server/src/routes/iconCrm.js holt die Kontakte von
  drueben herein - beide nur lesend, mit festem Token statt JWT
- server/src/contactBridge.js und src/lib/contactMatch.ts erkennen
  Dubletten, src/components/leads/IconMergeDialog.tsx laesst sie
  zusammenfuehren
- server/src/ai.js buendelt die OpenAI-Aufrufe

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 23:07:59 +02:00

105 lines
3.2 KiB
JavaScript

import Setting from './scanner/models/Setting.js';
/*
* Anbindung an OpenAI fuer das CRM (bisher nutzte nur der Scanner ein Modell).
*
* Der Schluessel wird NICHT zum zweiten Mal gepflegt: es ist derselbe, den der
* Scanner benutzt - Einstellungen -> Scanner, gespeichert als `openai_api_key`
* in der Scanner-Tabelle `settings`. Ein zweiter Schluessel an zweiter Stelle
* waere eine Quelle fuer den Fall "die eine Funktion geht, die andere nicht,
* und keiner weiss warum".
*
* Bewusst ohne das `openai`-Paket: gebraucht wird ein einziger Aufruf, und
* `fetch` ist in Node eingebaut. Der Scanner behaelt sein SDK - dort haengt
* mehr daran.
*
* Der Schluessel bleibt auf dem Server. Der Browser spricht nie mit OpenAI.
*/
const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
const TIMEOUT_MS = 120_000;
/** Dasselbe Modell wie im Scanner - was dort ausreicht, reicht hier auch. */
export const AI_MODEL = process.env.OPENAI_MODEL || 'gpt-4o-mini';
class AiError extends Error {
constructor(message, status = 502) {
super(message);
this.status = status;
}
}
/** Der Schluessel des Scanners, ersatzweise aus der Umgebung. */
export async function aiKey() {
const fromDb = await Setting.getVal('openai_api_key');
return (fromDb || process.env.OPENAI_API_KEY || '').trim();
}
export async function aiConfigured() {
return Boolean(await aiKey());
}
/**
* Ein Aufruf an das Modell, Antwort als JSON.
*
* @param {object} options
* @param {string} options.system
* @param {string} options.user
* @param {number} [options.maxTokens]
*/
export async function askModelJson({ system, user, maxTokens }) {
const key = await aiKey();
if (!key) {
throw new AiError(
'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → Scanner).',
503
);
}
const body = {
model: AI_MODEL,
messages: [
...(system ? [{ role: 'system', content: system }] : []),
{ role: 'user', content: user },
],
response_format: { type: 'json_object' },
};
if (maxTokens) body.max_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 ${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);
try {
return JSON.parse(text);
} catch {
throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502);
}
}
export { AiError };