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>
105 lines
3.2 KiB
JavaScript
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 };
|