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:
co-authored by
Claude Opus 5.5
parent
3f25355a6d
commit
cbac02a96a
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user