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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ad7f806258
commit
49c03f5e1b
@@ -0,0 +1,104 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user