KI: Modellauswahl als Dropdown + Abgleich mit Produktionsstand

Einstellungen -> KI: statt freiem Modellnamen ein Dropdown mit den
Chat-Modellen, die der hinterlegte OpenAI-Schluessel nutzen darf.

- Backend: listAiModels() fragt OpenAI GET /v1/models, filtert auf
  Chat-Modelle (gpt-*, o*, chatgpt-*; ohne Embedding/Audio/Bild/Instruct
  usw.), neueste zuerst, 10 min im Speicher (Schluesselwechsel laedt neu)
- Neuer Endpunkt GET /api/ai/models (admin)
- Frontend: aktives Modell bleibt waehlbar, auch wenn nicht mehr gelistet;
  Rueckfall auf Textfeld, wenn die Liste nicht abrufbar ist
- Doku: 05-ki (Modellauswahl mit Ablaufdiagramm), 10-api, 08-anleitung

Zieht ausserdem den bereits live laufenden, bisher nicht eingecheckten
Stand nach (perinco-Kontaktabgleich/MergeDialog, Projektmargen,
Whisper-Anpassungen u. a.). Repo entspricht damit icrm.icon-experts.de.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-23 10:26:30 +02:00
co-authored by Claude Opus 5.5
parent 3f25355a6d
commit cbac02a96a
40 changed files with 1476 additions and 80 deletions
+128 -1
View File
@@ -1,5 +1,6 @@
import express from 'express';
import { requireAuth } from '../middleware/auth.js';
import { applyBridgePatch } from '../contactBridge.js';
const router = express.Router();
router.use(requireAuth);
@@ -7,12 +8,16 @@ router.use(requireAuth);
/*
* Lesender Zugriff auf die Kontakte des perinco CRM.
*
* Es wird NICHTS gespeichert. Die Kontakte gehören dem perinco CRM, werden dort
* Es wird NICHTS gespiegelt. Die Kontakte gehören dem perinco CRM, werden dort
* gepflegt und hier nur angezeigt - gekennzeichnet, nicht vermischt. Ein
* nächtlicher Abgleich in die eigene Datenbank wäre der falsche Weg: dann
* stünde derselbe Mensch in zwei Systemen, und irgendwann wäre zu klären,
* welcher Eintrag gilt.
*
* Eine Ausnahme gibt es: steht derselbe Mensch nachweislich in beiden Häusern,
* kann ein Benutzer die Unterschiede abgleichen. Dann wird auch nach drüben
* geschrieben - Feld für Feld und nur, was er selbst entschieden hat.
*
* Die Felder werden auf die vorhandenen Felder eines ICON-Kontakts abgebildet.
* Neue Felder anzulegen war ausdrücklich nicht gewollt - was drüben kein
* Gegenstück hat (Anrede, Anschrift, Website), bleibt drüben.
@@ -131,4 +136,126 @@ function toIconShape(row) {
};
}
/**
* Einen Kontakt drueben aendern.
*
* Gegenstueck zu `PATCH /api/integrations/contacts/:id` hier - dieselbe enge
* Feldliste, derselbe feste Token. Wirft nicht, sondern meldet den Grund:
* der Aufrufer muss wissen, ob die andere Seite geschrieben hat, bevor er
* hier schreibt.
*/
async function patchRemoteContact(remoteId, fields) {
if (!CONFIGURED()) return { ok: false, error: 'Der Zugriff auf das perinco CRM ist nicht eingerichtet.' };
const base = String(process.env.PERINCO_API_URL).replace(/\/+$/, '');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15_000);
try {
const response = await fetch(`${base}/api/integrations/contacts/${remoteId}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.PERINCO_API_TOKEN}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(fields),
signal: controller.signal,
});
if (!response.ok) {
const payload = await response.json().catch(() => null);
return {
ok: false,
error:
payload?.error ??
(response.status === 401
? 'Das perinco CRM hat den Zugang abgelehnt (Token stimmt nicht).'
: `Das perinco CRM antwortete mit HTTP ${response.status}.`),
};
}
return { ok: true, body: await response.json().catch(() => ({})) };
} catch (err) {
return {
ok: false,
error:
err?.name === 'AbortError'
? 'Das perinco CRM hat nicht rechtzeitig geantwortet.'
: 'Das perinco CRM ist gerade nicht erreichbar.',
};
} finally {
clearTimeout(timer);
}
}
/*
* Der Abgleich eines doppelt gefuehrten Kontakts.
*
* `local` sind die Felder, die hier gelten sollen, `remote` die, die drueben
* gelten sollen - beide in der Sprache der Bruecke und beide ausschliesslich
* aus dem, was der Benutzer im Dialog entschieden hat.
*
* Zuerst drueben, dann hier. Der Weg ueber das Netz ist der, der scheitern
* kann; scheitert er, ist hier noch nichts passiert und der Benutzer kann es
* unveraendert erneut versuchen. Andersherum bliebe ein halber Abgleich stehen.
*/
router.post('/contacts/:remoteId/merge', async (req, res, next) => {
// Die Kennung kommt mit Praefix aus der Oberflaeche ("perinco-31").
const remoteId = Number(String(req.params.remoteId).replace(/^perinco-/, ''));
const contactId = Number(req.body?.contact_id);
const local = req.body?.local ?? {};
const remote = req.body?.remote ?? {};
if (!Number.isInteger(remoteId) || remoteId <= 0) {
return res.status(400).json({ error: 'Ungültige Kennung im perinco CRM.' });
}
if (!Number.isInteger(contactId) || contactId <= 0) {
return res.status(400).json({ error: 'Ungültige Kennung des Kontakts.' });
}
const wantsRemote = Object.keys(remote).length > 0;
const wantsLocal = Object.keys(local).length > 0;
if (!wantsRemote && !wantsLocal) {
return res.status(400).json({ error: 'Es wurde nichts zum Abgleichen ausgewählt.' });
}
try {
let remoteChanged = [];
if (wantsRemote) {
const result = await patchRemoteContact(remoteId, remote);
if (!result.ok) return res.status(502).json({ error: result.error });
remoteChanged = result.body?.changed ?? [];
// Der Zwischenspeicher zeigt sonst bis zu einer Minute den alten Stand.
cache = { at: 0, contacts: [] };
}
let localChanged = [];
if (wantsLocal) {
const result = await applyBridgePatch({
id: contactId,
body: local,
userId: req.user.id,
note: 'Abgleich mit dem perinco CRM',
});
if (result.status !== 200) {
// Drueben steht die Aenderung dann schon - das gehoert gesagt, sonst
// sucht jemand den Unterschied auf der falschen Seite.
return res.status(result.status).json({
error: result.body.error,
remote_changed: remoteChanged,
hint: remoteChanged.length > 0
? 'Im perinco CRM wurde bereits geschrieben, hier nicht.'
: undefined,
});
}
localChanged = result.body.changed;
}
res.json({ ok: true, local_changed: localChanged, remote_changed: remoteChanged });
} catch (err) {
next(err);
}
});
export default router;