KI: Vorgabemodell auf gpt-6-luna umgestellt

gpt-6-luna kostet rund 50 % weniger als gpt-5.6-luna. Das Modell in
icon_settings (Einstellungen -> KI) hat weiter Vorrang vor der Vorgabe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 10:10:41 +02:00
co-authored by Claude Opus 5.5
parent 4adf48f336
commit 6fe5b18c11
6 changed files with 524 additions and 524 deletions
+281 -281
View File
@@ -1,281 +1,281 @@
# 05 KI # 05 KI
Modell: **`gpt-5.6-luna`** (OpenAI), angesprochen über `chat/completions`. Der Modell: **`gpt-6-luna`** (OpenAI, seit 29.09.2026; vorher `gpt-5.6-luna`, rund 50 % teurer), angesprochen über `chat/completions`. Der
Browser spricht nie direkt mit OpenAI, jede Anfrage läuft über das Backend Browser spricht nie direkt mit OpenAI, jede Anfrage läuft über das Backend
(`server/src/ai.js`). Schlüssel und Modell werden unter *Einstellungen → KI* (`server/src/ai.js`). Schlüssel und Modell werden unter *Einstellungen → KI*
gepflegt und liegen in `icon_settings`; `server/.env` (chmod 600) bleibt gepflegt und liegen in `icon_settings`; `server/.env` (chmod 600) bleibt
Startwert und Rückfall. Startwert und Rückfall.
Fehlt der Schlüssel, läuft das CRM normal weiter und blendet die KI-Knöpfe aus. Fehlt der Schlüssel, läuft das CRM normal weiter und blendet die KI-Knöpfe aus.
`GET /api/ai/status` sagt der Oberfläche, ob etwas eingerichtet ist. `GET /api/ai/status` sagt der Oberfläche, ob etwas eingerichtet ist.
## Schlüssel pflegen ## Schlüssel pflegen
*Einstellungen → KI* (nur Administratoren). Der Schlüssel steht in *Einstellungen → KI* (nur Administratoren). Der Schlüssel steht in
`icon_settings` (`openai_api_key`), die `.env` bleibt Startwert und Rückfall – `icon_settings` (`openai_api_key`), die `.env` bleibt Startwert und Rückfall –
**die Datenbank gewinnt**. Grund: ein Schlüssel läuft ab oder wird gewechselt, **die Datenbank gewinnt**. Grund: ein Schlüssel läuft ab oder wird gewechselt,
und dann soll niemand dafür eine Datei auf dem Server bearbeiten und einen und dann soll niemand dafür eine Datei auf dem Server bearbeiten und einen
Dienst neu starten müssen. Dienst neu starten müssen.
| Was | Verhalten | | Was | Verhalten |
| --- | --- | | --- | --- |
| Anzeige | nur verkürzt (`sk-proj…a1b2`) – vollständig verlässt der Schlüssel den Server nie | | Anzeige | nur verkürzt (`sk-proj…a1b2`) – vollständig verlässt der Schlüssel den Server nie |
| Eingabe | Passwortfeld, schreibt nur; ein bestehender Wert wird ersetzt | | Eingabe | Passwortfeld, schreibt nur; ein bestehender Wert wird ersetzt |
| Entfernen | löscht den Wert in der Datenbank; steht in der `.env` einer, gilt wieder dieser | | Entfernen | löscht den Wert in der Datenbank; steht in der `.env` einer, gilt wieder dieser |
| Modell | Auswahlliste der Chat-Modelle, die der Schlüssel nutzen darf (siehe unten); Vorgabe `gpt-5.6-luna` | | Modell | Auswahlliste der Chat-Modelle, die der Schlüssel nutzen darf (siehe unten); Vorgabe `gpt-6-luna` |
| Test | ein Miniaufruf gegen das Modell – sagt, ob Schlüssel, Modellname und Kontingent zusammenpassen | | Test | ein Miniaufruf gegen das Modell – sagt, ob Schlüssel, Modellname und Kontingent zusammenpassen |
In der Historie landet, **dass** jemand den Schlüssel gewechselt hat, nie der In der Historie landet, **dass** jemand den Schlüssel gewechselt hat, nie der
Wert. Ein stiller Wechsel wäre später nicht mehr nachvollziehbar. Wert. Ein stiller Wechsel wäre später nicht mehr nachvollziehbar.
### Modellauswahl ### Modellauswahl
Statt eines freien Textfelds zeigt der Reiter ein Dropdown. Die Liste kommt von Statt eines freien Textfelds zeigt der Reiter ein Dropdown. Die Liste kommt von
`GET /api/ai/models`, das Backend fragt dafür OpenAI `GET /v1/models` mit dem `GET /api/ai/models`, das Backend fragt dafür OpenAI `GET /v1/models` mit dem
hinterlegten Schlüssel (`listAiModels()` in `ai.js`) – angezeigt wird also genau hinterlegten Schlüssel (`listAiModels()` in `ai.js`) – angezeigt wird also genau
das, was dieser Schlüssel darf. das, was dieser Schlüssel darf.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant B as Browser (AiSettings) participant B as Browser (AiSettings)
participant S as Backend /api/ai/models participant S as Backend /api/ai/models
participant O as OpenAI /v1/models participant O as OpenAI /v1/models
B->>S: GET (admin) B->>S: GET (admin)
alt Liste im Speicher und < 10 min alt, gleicher Schlüssel alt Liste im Speicher und < 10 min alt, gleicher Schlüssel
S-->>B: { models } S-->>B: { models }
else else
S->>O: GET mit Bearer-Schlüssel S->>O: GET mit Bearer-Schlüssel
O-->>S: alle Modelle O-->>S: alle Modelle
S->>S: nur Chat-Modelle, neueste zuerst S->>S: nur Chat-Modelle, neueste zuerst
S-->>B: { models } S-->>B: { models }
end end
``` ```
| Regel | Umsetzung | | Regel | Umsetzung |
| --- | --- | | --- | --- |
| Nur Chat-Modelle | ID beginnt mit `gpt-`, `o<Ziffer>` oder `chatgpt-`; raus fallen Embedding, Whisper, TTS, Bild, Audio, Realtime, Transcribe, Moderation, Search, Instruct, Codex, Computer-Use, Sora | | Nur Chat-Modelle | ID beginnt mit `gpt-`, `o<Ziffer>` oder `chatgpt-`; raus fallen Embedding, Whisper, TTS, Bild, Audio, Realtime, Transcribe, Moderation, Search, Instruct, Codex, Computer-Use, Sora |
| Reihenfolge | nach `created` absteigend – neueste oben | | Reihenfolge | nach `created` absteigend – neueste oben |
| Zwischenspeicher | 10 Minuten im Speicher; ein anderer Schlüssel lädt sofort neu | | Zwischenspeicher | 10 Minuten im Speicher; ein anderer Schlüssel lädt sofort neu |
| Aktives Modell nicht mehr gelistet | bleibt im Dropdown, markiert „– nicht verfügbar" | | Aktives Modell nicht mehr gelistet | bleibt im Dropdown, markiert „– nicht verfügbar" |
| Vorgabemodell | markiert „(Vorgabe)" | | Vorgabemodell | markiert „(Vorgabe)" |
| Liste nicht abrufbar (kein Schlüssel, OpenAI down) | Rückfall auf das Textfeld mit Hinweis und *Erneut versuchen* | | Liste nicht abrufbar (kein Schlüssel, OpenAI down) | Rückfall auf das Textfeld mit Hinweis und *Erneut versuchen* |
| Schlüssel gespeichert/entfernt | Liste wird neu geladen bzw. verworfen | | Schlüssel gespeichert/entfernt | Liste wird neu geladen bzw. verworfen |
> **Fallstrick:** `gpt-5.6-luna` akzeptiert **kein eigenes `temperature`** und > **Fallstrick:** die Luna-Modelle (`gpt-5.6-luna`, `gpt-6-luna`) akzeptieren **kein eigenes `temperature`** und
> weist jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb > weist jeden Wert außer dem Standard mit HTTP 400 ab. `ai.js` schickt deshalb
> keines. `response_format: {type:'json_object'}` funktioniert dagegen. > keines. `response_format: {type:'json_object'}` funktioniert dagegen.
Drei Stellen nutzen das Modell: Drei Stellen nutzen das Modell:
```mermaid ```mermaid
flowchart LR flowchart LR
CV["CV-Import<br/>Lebenslauf → Kontaktvorschlag"] --> AI["server/src/ai.js"] CV["CV-Import<br/>Lebenslauf → Kontaktvorschlag"] --> AI["server/src/ai.js"]
TX["Schreibhilfe<br/>Ausschreibungstext"] --> AI TX["Schreibhilfe<br/>Ausschreibungstext"] --> AI
SU["Erweiterte Suche<br/>Frage in Worten"] --> AI SU["Erweiterte Suche<br/>Frage in Worten"] --> AI
AI -->|"HTTPS, Schluessel bleibt am Server"| OAI["api.openai.com"] AI -->|"HTTPS, Schluessel bleibt am Server"| OAI["api.openai.com"]
``` ```
## CV-Import ## CV-Import
*Kontakte → CV-Import.* Lebensläufe als PDF, DOCX oder Text, per Dateiauswahl *Kontakte → CV-Import.* Lebensläufe als PDF, DOCX oder Text, per Dateiauswahl
oder Ziehen, auch mehrere auf einmal (bis 20, je 15 MB). oder Ziehen, auch mehrere auf einmal (bis 20, je 15 MB).
```mermaid ```mermaid
flowchart LR flowchart LR
U["Hochladen"] --> E["Eingang"] U["Hochladen"] --> E["Eingang"]
E -->|"Mensch: Ja, auslesen"| S["Screening"] E -->|"Mensch: Ja, auslesen"| S["Screening"]
E -->|"Mensch: Nein"| V["Verworfen"] E -->|"Mensch: Nein"| V["Verworfen"]
S -->|"Formular bestaetigt"| A["Auswahl<br/>Kontakt angelegt"] S -->|"Formular bestaetigt"| A["Auswahl<br/>Kontakt angelegt"]
S -->|"abgelehnt"| V S -->|"abgelehnt"| V
``` ```
| Ordner | Bedeutung | | Ordner | Bedeutung |
| --- | --- | | --- | --- |
| Eingang | hochgeladen; ein Mensch entscheidet je Datei „Ja, auslesen" oder „Nein" | | Eingang | hochgeladen; ein Mensch entscheidet je Datei „Ja, auslesen" oder „Nein" |
| Screening | ausgelesen; der Vorschlag steht im Formular und wird bestätigt | | Screening | ausgelesen; der Vorschlag steht im Formular und wird bestätigt |
| Auswahl | übernommen – Kontakt angelegt, Lebenslauf hängt daran | | Auswahl | übernommen – Kontakt angelegt, Lebenslauf hängt daran |
Es wird bewusst **nichts automatisch angelegt**: ein falsch gelesener Name oder Es wird bewusst **nichts automatisch angelegt**: ein falsch gelesener Name oder
Stundensatz wäre in der Datenbank schwerer zu finden als im Formular. Was dort Stundensatz wäre in der Datenbank schwerer zu finden als im Formular. Was dort
korrigiert wird, gewinnt gegen die KI. korrigiert wird, gewinnt gegen die KI.
Übernommen werden Name, Position, Kontaktdaten, LinkedIn, aktueller Arbeitgeber, Übernommen werden Name, Position, Kontaktdaten, LinkedIn, aktueller Arbeitgeber,
Schlagworte, eine Zusammenfassung und – nur wenn sie ausdrücklich im Text stehen Schlagworte, eine Zusammenfassung und – nur wenn sie ausdrücklich im Text stehen
– die **Stundensätze**. Steht im Lebenslauf ein Tagessatz, wird er *nicht* – die **Stundensätze**. Steht im Lebenslauf ein Tagessatz, wird er *nicht*
umgerechnet, sondern weggelassen. umgerechnet, sondern weggelassen.
Frühere Stationen werden als **beendete** Anstellungen angelegt; dadurch Frühere Stationen werden als **beendete** Anstellungen angelegt; dadurch
erscheint der Kontakt beim jeweiligen Unternehmen sofort im Zweig „Verlassen". erscheint der Kontakt beim jeweiligen Unternehmen sofort im Zweig „Verlassen".
Unbekannte Unternehmen legt der Import mit an. Unbekannte Unternehmen legt der Import mit an.
### Der Werdegang bekommt einen eigenen Durchgang ### Der Werdegang bekommt einen eigenen Durchgang
Der große Prompt muss Name, Kontaktdaten, Schlagworte, Zusammenfassung und Sätze Der große Prompt muss Name, Kontaktdaten, Schlagworte, Zusammenfassung und Sätze
zugleich liefern – die Stationen kamen dabei **unzuverlässig** heraus: derselbe zugleich liefern – die Stationen kamen dabei **unzuverlässig** heraus: derselbe
Lebenslauf ergab einmal drei, einmal zwei, ein Beraterprofil mit fünf Kunden nur Lebenslauf ergab einmal drei, einmal zwei, ein Beraterprofil mit fünf Kunden nur
einen. Deshalb läuft ein **zweiter Aufruf mit einer einzigen Aufgabe** einen. Deshalb läuft ein **zweiter Aufruf mit einer einzigen Aufgabe**
(`STATIONS_PROMPT` in `server/src/routes/cvs.js`), und beide Listen werden (`STATIONS_PROMPT` in `server/src/routes/cvs.js`), und beide Listen werden
zusammengeführt. zusammengeführt.
Was dieser Durchgang ausdrücklich verlangt: Was dieser Durchgang ausdrücklich verlangt:
* Vollständigkeit vor Kürze – auch kurze und nebenberufliche Stationen. * Vollständigkeit vor Kürze – auch kurze und nebenberufliche Stationen.
* Alle Abschnitte, die Stationen tragen können: *Berufserfahrung*, *Beruflicher * Alle Abschnitte, die Stationen tragen können: *Berufserfahrung*, *Beruflicher
Werdegang*, *Projekte*, *Projekterfahrung*, *Referenzen*, *Kunden*. Werdegang*, *Projekte*, *Projekterfahrung*, *Referenzen*, *Kunden*.
* Bei freiberuflicher Tätigkeit ist der **Kunde** das Unternehmen, nicht die * Bei freiberuflicher Tätigkeit ist der **Kunde** das Unternehmen, nicht die
eigene Firma. eigene Firma.
* Derselbe Kunde zu verschiedenen Zeiten gehört mehrfach in die Liste. * Derselbe Kunde zu verschiedenen Zeiten gehört mehrfach in die Liste.
* Schule, Studium, Zertifikate gehören **nicht** hinein. * Schule, Studium, Zertifikate gehören **nicht** hinein.
* Eine fehlende Zeitangabe ist kein Grund, eine Station wegzulassen. * Eine fehlende Zeitangabe ist kein Grund, eine Station wegzulassen.
* Der Text aus PDF und DOCX ist bei mehrspaltigem Layout durcheinander – Daten * Der Text aus PDF und DOCX ist bei mehrspaltigem Layout durcheinander – Daten
stehen dann als Block neben den Firmennamen und müssen der Reihenfolge nach stehen dann als Block neben den Firmennamen und müssen der Reihenfolge nach
zugeordnet werden. zugeordnet werden.
Zusammengeführt wird über Firmenname und Anfangsjahr, wobei der Name vorher auf Zusammengeführt wird über Firmenname und Anfangsjahr, wobei der Name vorher auf
das Vergleichbare heruntergebrochen wird (Rechtsformen wie GmbH, AG, SE fliegen das Vergleichbare heruntergebrochen wird (Rechtsformen wie GmbH, AG, SE fliegen
raus). So erscheint „Bayerische Versorgungskammer" nicht zweimal, weil ein raus). So erscheint „Bayerische Versorgungskammer" nicht zweimal, weil ein
Durchgang sie mit Klammerzusatz gelesen hat. Durchgang sie mit Klammerzusatz gelesen hat.
Die Obergrenze beim Übernehmen liegt bei **60** Stationen statt bei 20: Die Obergrenze beim Übernehmen liegt bei **60** Stationen statt bei 20:
Beraterprofile mit Projektliste kommen leicht über zwanzig Kunden, und alles Beraterprofile mit Projektliste kommen leicht über zwanzig Kunden, und alles
darüber verschwand vorher stillschweigend. darüber verschwand vorher stillschweigend.
> Gemessen an den vorhandenen Lebensläufen: 1 → 5 Stationen, 3 → 3, und ein > Gemessen an den vorhandenen Lebensläufen: 1 → 5 Stationen, 3 → 3, und ein
> reines Projektportfolio ohne einen einzigen Arbeitgeber bleibt bei 0 – dort > reines Projektportfolio ohne einen einzigen Arbeitgeber bleibt bei 0 – dort
> stehen nur Produktnamen im Fließtext, und daraus Stationen zu erfinden wäre > stehen nur Produktnamen im Fließtext, und daraus Stationen zu erfinden wäre
> schlechter als keine. > schlechter als keine.
### Die laufende Station ### Die laufende Station
Der aktuelle Arbeitgeber wird beim Übernehmen als laufende Station angelegt. Der aktuelle Arbeitgeber wird beim Übernehmen als laufende Station angelegt.
Zwei Fehler hingen daran, und beide sahen in der Karte nach einem Lesefehler Zwei Fehler hingen daran, und beide sahen in der Karte nach einem Lesefehler
aus: aus:
* Sie begann am **Tag des Imports**. Im Lebenslauf stand „seit 03/2023", in der * Sie begann am **Tag des Imports**. Im Lebenslauf stand „seit 03/2023", in der
Karte das heutige Datum. Karte das heutige Datum.
* Und sie stand **zweimal** da: einmal als laufende Station ohne Datum, einmal * Und sie stand **zweimal** da: einmal als laufende Station ohne Datum, einmal
als frühere mit Datum. als frühere mit Datum.
Beides kam daher, dass die Liste der Stationen und der Arbeitgeber getrennt Beides kam daher, dass die Liste der Stationen und der Arbeitgeber getrennt
verarbeitet wurden. Jetzt wird zuerst gesucht, welche Station den Arbeitgeber verarbeitet wurden. Jetzt wird zuerst gesucht, welche Station den Arbeitgeber
beschreibt – über den Firmennamen ohne Rechtsform und daran, dass sie laufend beschreibt – über den Firmennamen ohne Rechtsform und daran, dass sie laufend
ist (als aktuell gekennzeichnet oder ohne Enddatum). Ihr Startdatum eröffnet die ist (als aktuell gekennzeichnet oder ohne Enddatum). Ihr Startdatum eröffnet die
laufende Station, und sie wird nicht noch einmal als frühere angelegt. laufende Station, und sie wird nicht noch einmal als frühere angelegt.
Eine **früher datierte** Station beim selben Unternehmen bleibt davon unberührt: Eine **früher datierte** Station beim selben Unternehmen bleibt davon unberührt:
ein Berater ist oft zweimal beim selben Kunden, und beide Einsätze gehören in ein Berater ist oft zweimal beim selben Kunden, und beide Einsätze gehören in
den Werdegang. den Werdegang.
Der Text wird vor der Anfrage lokal aus der Datei geholt Der Text wird vor der Anfrage lokal aus der Datei geholt
(`server/src/cvText.js`) – PDF und DOCX ohne Fremddienst. Die Datei bleibt am (`server/src/cvText.js`) – PDF und DOCX ohne Fremddienst. Die Datei bleibt am
Kontakt (`icon_contacts.cv_file`) und lässt sich aus dessen Karte herunterladen; Kontakt (`icon_contacts.cv_file`) und lässt sich aus dessen Karte herunterladen;
der Download läuft über `fetch` mit Bearer-Kopf, ein normaler Link schickt ihn der Download läuft über `fetch` mit Bearer-Kopf, ein normaler Link schickt ihn
nicht mit. nicht mit.
### Das Bewerbungsfoto ### Das Bewerbungsfoto
Trägt der Lebenslauf ein Portrait, wird es beim Auslesen mit herausgeholt und im Trägt der Lebenslauf ein Portrait, wird es beim Auslesen mit herausgeholt und im
Screening-Formular oben angezeigt – mit Haken, standardmäßig gesetzt. Beim Screening-Formular oben angezeigt – mit Haken, standardmäßig gesetzt. Beim
Übernehmen landet es als Foto am Kontakt. Übernehmen landet es als Foto am Kontakt.
Gefunden wird es **ohne zusätzliches Paket** (`server/src/cvPhoto.js`): Gefunden wird es **ohne zusätzliches Paket** (`server/src/cvPhoto.js`):
| Format | Weg | | Format | Weg |
| --- | --- | | --- | --- |
| DOCX | ist ein ZIP – die Dateien unter `word/media/` werden mit dem eingebauten `zlib` herausgelöst | | DOCX | ist ein ZIP – die Dateien unter `word/media/` werden mit dem eingebauten `zlib` herausgelöst |
| PDF | mit `/DCTDecode` abgelegte JPEGs stehen unverändert in der Datei und lassen sich an ihren eigenen Markierungen (FFD8 … FFD9) ausschneiden | | PDF | mit `/DCTDecode` abgelegte JPEGs stehen unverändert in der Datei und lassen sich an ihren eigenen Markierungen (FFD8 … FFD9) ausschneiden |
Bilder in anderen PDF-Kodierungen (FlateDecode, also rohe Bitmaps) bleiben außen Bilder in anderen PDF-Kodierungen (FlateDecode, also rohe Bitmaps) bleiben außen
vor: sie müssten neu kodiert werden, und dafür bräuchte es eine Bildbibliothek vor: sie müssten neu kodiert werden, und dafür bräuchte es eine Bildbibliothek
auf dem Server. auf dem Server.
**Welches Bild das Portrait ist**, entscheidet die Form – die Reihenfolge in der **Welches Bild das Portrait ist**, entscheidet die Form – die Reihenfolge in der
Datei hilft nicht, in DOCX steht das Firmenlogo oft vor dem Foto: Datei hilft nicht, in DOCX steht das Firmenlogo oft vor dem Foto:
* kleiner als 100 × 100 px → Symbol, Trennlinie, Aufzählungszeichen * kleiner als 100 × 100 px → Symbol, Trennlinie, Aufzählungszeichen
* Seitenverhältnis außerhalb 0,4–1,6 → Kopfzeile, Banner, Diagramm * Seitenverhältnis außerhalb 0,4–1,6 → Kopfzeile, Banner, Diagramm
* vom Rest gewinnt **Hochformat** vor Querformat, dann das größere * vom Rest gewinnt **Hochformat** vor Querformat, dann das größere
Verkleinert wird im Browser, über denselben Weg wie jedes andere Foto Verkleinert wird im Browser, über denselben Weg wie jedes andere Foto
(`src/lib/imageCompress.ts`): mittiger Ausschnitt, 400 px im Quadrat, WebP – und (`src/lib/imageCompress.ts`): mittiger Ausschnitt, 400 px im Quadrat, WebP – und
eine harte Obergrenze von **512 kB**, unter die notfalls die Kantenlänge eine harte Obergrenze von **512 kB**, unter die notfalls die Kantenlänge
schrittweise fällt. In der Praxis liegt ein Ergebnis bei 20–60 kB; die Grenze schrittweise fällt. In der Praxis liegt ein Ergebnis bei 20–60 kB; die Grenze
ist die Zusicherung, nicht die Regel. ist die Zusicherung, nicht die Regel.
Das gefundene Bild liegt bis zur Übernahme in `icon_cv_imports.photo` und wird Das gefundene Bild liegt bis zur Übernahme in `icon_cv_imports.photo` und wird
danach dort geleert – sonst stünde dasselbe Foto zweimal in der Datenbank. In danach dort geleert – sonst stünde dasselbe Foto zweimal in der Datenbank. In
der Liste der Importe steht es **nicht**: sie trägt nur `has_photo`, das Bild der Liste der Importe steht es **nicht**: sie trägt nur `has_photo`, das Bild
holt das Formular einzeln (`GET /api/cvs/:id/photo`). holt das Formular einzeln (`GET /api/cvs/:id/photo`).
## Schreibhilfe für Ausschreibungen ## Schreibhilfe für Ausschreibungen
Das Feld *Ausschreibungstext* (`icon_tenders.notes`) ist ein WYSIWYG-Editor mit Das Feld *Ausschreibungstext* (`icon_tenders.notes`) ist ein WYSIWYG-Editor mit
fünf Knöpfen: *Text erzeugen*, *Verbessern*, *Kürzen*, *Stichpunkte*, fünf Knöpfen: *Text erzeugen*, *Verbessern*, *Kürzen*, *Stichpunkte*,
*Anforderungsprofil*. Darunter ein Feld für die Vorgabe („Interim PL SAP *Anforderungsprofil*. Darunter ein Feld für die Vorgabe („Interim PL SAP
S/4HANA, Bank, 12 Monate, 80 % remote"). S/4HANA, Bank, 12 Monate, 80 % remote").
Der vorherige Stand lässt sich mit *Zurück* zurückholen – die KI überschreibt Der vorherige Stand lässt sich mit *Zurück* zurückholen – die KI überschreibt
nichts unwiderruflich. Gespeichert wird 800 ms nach dem letzten Tastendruck, es nichts unwiderruflich. Gespeichert wird 800 ms nach dem letzten Tastendruck, es
gibt keinen Speichern-Knopf. gibt keinen Speichern-Knopf.
Der Diktat-Knopf steht in derselben Leiste und ist **unabhängig** von der KI: er Der Diktat-Knopf steht in derselben Leiste und ist **unabhängig** von der KI: er
rechnet im Browser und braucht keinen Schlüssel (siehe [04 Diktat](04-diktat.md)). rechnet im Browser und braucht keinen Schlüssel (siehe [04 Diktat](04-diktat.md)).
## Erweiterte Suche ## Erweiterte Suche
Ein Feld, zwei Modi – umgeschaltet über die Symbole rechts im Feld: Ein Feld, zwei Modi – umgeschaltet über die Symbole rechts im Feld:
**Lupe – die normale Suche** (`Strg+K`). Über Kontakte, Unternehmen, **Lupe – die normale Suche** (`Strg+K`). Über Kontakte, Unternehmen,
Ausschreibungen, Projekte, Leads und die Historie; läuft beim Tippen mit, ein Ausschreibungen, Projekte, Leads und die Historie; läuft beim Tippen mit, ein
Treffer öffnet direkt die Karte. Bewusst `LIKE` statt Volltextindex: die Treffer öffnet direkt die Karte. Bewusst `LIKE` statt Volltextindex: die
Datenmengen sind klein, und ein `FULLTEXT`-Index hülfe bei Teilwortsuche Datenmengen sind klein, und ein `FULLTEXT`-Index hülfe bei Teilwortsuche
(„informat" soll „Finanz Informatik" finden) nicht. („informat" soll „Finanz Informatik" finden) nicht.
**Funke – die erweiterte Suche.** Eine Frage in Worten, beantwortet vom Modell. **Funke – die erweiterte Suche.** Eine Frage in Worten, beantwortet vom Modell.
Die genannten Datensätze kommen als anklickbare Chips zurück. Die genannten Datensätze kommen als anklickbare Chips zurück.
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
actor B as Benutzer actor B as Benutzer
participant E as Express participant E as Express
participant M as MariaDB participant M as MariaDB
participant O as OpenAI participant O as OpenAI
B->>E: POST /api/search/ai { question } B->>E: POST /api/search/ai { question }
E->>M: Bestand lesen (Kontakte, Unternehmen, Ausschreibungen, Projekte, Leads) E->>M: Bestand lesen (Kontakte, Unternehmen, Ausschreibungen, Projekte, Leads)
E->>E: zusammenfassen, ab 400 Zeilen je Liste kuerzen E->>E: zusammenfassen, ab 400 Zeilen je Liste kuerzen
E->>O: Systemprompt + Bestand + Frage E->>O: Systemprompt + Bestand + Frage
O-->>E: { answer, refs } O-->>E: { answer, refs }
E-->>B: Antwort + anklickbare Chips E-->>B: Antwort + anklickbare Chips
``` ```
Sie läuft **nicht** beim Tippen mit, sondern erst auf Enter – jede Anfrage Sie läuft **nicht** beim Tippen mit, sondern erst auf Enter – jede Anfrage
kostet. Und sie erzeugt **kein SQL**: der Bestand wird mitgegeben. Ein Modell, kostet. Und sie erzeugt **kein SQL**: der Bestand wird mitgegeben. Ein Modell,
das Abfragen auf die Datenbank schreiben darf, wäre eine Angriffsfläche; der das Abfragen auf die Datenbank schreiben darf, wäre eine Angriffsfläche; der
Bestand ist klein genug, um ihn vollständig mitzuliefern. Wird gekürzt, sagt die Bestand ist klein genug, um ihn vollständig mitzuliefern. Wird gekürzt, sagt die
Antwort das dazu. Antwort das dazu.
### Was im Bestand steht ### Was im Bestand steht
Je Kontakt: Name, Position, Art, Status, E-Mail, Telefon, Quelle, Schlagworte, Je Kontakt: Name, Position, Art, Status, E-Mail, Telefon, Quelle, Schlagworte,
freiberuflich, Kontaktstand, **`rate_onsite` und `rate_remote`**, LinkedIn, freiberuflich, Kontaktstand, **`rate_onsite` und `rate_remote`**, LinkedIn,
Unternehmen und dessen Branche. Unternehmen und dessen Branche.
Zwei Regeln stehen ausdrücklich im Systemprompt, weil die Antworten sonst falsch Zwei Regeln stehen ausdrücklich im Systemprompt, weil die Antworten sonst falsch
werden: werden:
* Ein fehlender Satz ist **nicht** null Euro. Verglichen wird nur, wo ein Wert * Ein fehlender Satz ist **nicht** null Euro. Verglichen wird nur, wo ein Wert
steht; die Antwort sagt dazu, bei wie vielen keiner hinterlegt ist. steht; die Antwort sagt dazu, bei wie vielen keiner hinterlegt ist.
* Es sind **Stundensätze**, keine Tagessätze. Fragt jemand nach dem Tagessatz, * Es sind **Stundensätze**, keine Tagessätze. Fragt jemand nach dem Tagessatz,
antwortet die Suche mit dem Stundensatz und sagt das dazu. antwortet die Suche mit dem Stundensatz und sagt das dazu.
Beispiel: Beispiel:
> **Frage:** Wer hat den höchsten Tagessatz? > **Frage:** Wer hat den höchsten Tagessatz?
> **Antwort:** Oliver Schaible hat mit 165,00 € pro Stunde den höchsten > **Antwort:** Oliver Schaible hat mit 165,00 € pro Stunde den höchsten
> hinterlegten Satz vor Ort. Im CRM sind Stundensätze, keine Tagessätze, > hinterlegten Satz vor Ort. Im CRM sind Stundensätze, keine Tagessätze,
> gepflegt. Bei 8 Kontakten ist kein Satz hinterlegt. > gepflegt. Bei 8 Kontakten ist kein Satz hinterlegt.
Die Kontakte des perinco CRM stehen dem Bestand ebenfalls zur Verfügung, solange Die Kontakte des perinco CRM stehen dem Bestand ebenfalls zur Verfügung, solange
die Schnittstelle eingerichtet ist – gelesen über denselben Zwischenspeicher wie die Schnittstelle eingerichtet ist – gelesen über denselben Zwischenspeicher wie
die Ansicht (`fetchPerincoContacts()`), und ausdrücklich als fremd gekennzeichnet. die Ansicht (`fetchPerincoContacts()`), und ausdrücklich als fremd gekennzeichnet.
+1 -1
View File
@@ -88,7 +88,7 @@ Zuordnung in `navigation.open()` und der Filter in der Historie.
## Bekannte Fallen ## Bekannte Fallen
**`gpt-5.6-luna` und `temperature`.** Jeder eigene Wert wird mit HTTP 400 **Luna-Modelle und `temperature`.** Jeder eigene Wert wird mit HTTP 400
abgewiesen. `ai.js` schickt keinen. abgewiesen. `ai.js` schickt keinen.
**Instabile Arrays in der Netzwerkansicht.** Ein `= []` als Vorgabewert erzeugt **Instabile Arrays in der Netzwerkansicht.** Ein `= []` als Vorgabewert erzeugt
+1 -1
View File
@@ -40,7 +40,7 @@ flowchart TB
DB[("MariaDB<br/>icon_CRM")] DB[("MariaDB<br/>icon_CRM")]
end end
OAI["OpenAI<br/>gpt-5.6-luna"] OAI["OpenAI<br/>gpt-6-luna"]
PCRM["perinco CRM<br/>pcrm.perinco.de"] PCRM["perinco CRM<br/>pcrm.perinco.de"]
HF["Hugging-Face-CDN"] HF["Hugging-Face-CDN"]
+2 -2
View File
@@ -36,10 +36,10 @@ UPLOAD_DIR=/var/www/icon_CRM/uploads
# Der Schluessel bleibt auf dem Server - der Browser spricht nie direkt mit # Der Schluessel bleibt auf dem Server - der Browser spricht nie direkt mit
# OpenAI. Fehlt er, laeuft das CRM normal weiter und blendet die KI-Knoepfe aus. # OpenAI. Fehlt er, laeuft das CRM normal weiter und blendet die KI-Knoepfe aus.
# #
# Hinweis zum Modell: gpt-5.6-luna akzeptiert KEIN eigenes "temperature"; # Hinweis zum Modell: die Luna-Modelle akzeptieren KEIN eigenes "temperature";
# ai.js schickt deshalb nur den Standardwert. # ai.js schickt deshalb nur den Standardwert.
OPENAI_API_KEY= OPENAI_API_KEY=
OPENAI_MODEL=gpt-5.6-luna OPENAI_MODEL=gpt-6-luna
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Lesender Zugriff auf die Kontakte des perinco CRM # Lesender Zugriff auf die Kontakte des perinco CRM
+238 -238
View File
@@ -1,238 +1,238 @@
import { getSetting, setSetting } from './db.js'; import { getSetting, setSetting } from './db.js';
/* /*
* Anbindung an OpenAI. * Anbindung an OpenAI.
* *
* Bewusst ohne SDK: gebraucht werden genau zwei Aufrufe (Text und JSON), und * Bewusst ohne SDK: gebraucht werden genau zwei Aufrufe (Text und JSON), und
* `fetch` ist in Node 22 eingebaut. Ein Paket weniger, das gepflegt werden muss. * `fetch` ist in Node 22 eingebaut. Ein Paket weniger, das gepflegt werden muss.
* *
* Der Schluessel verlaesst den Server nie in Richtung Browser - jede KI-Anfrage * Der Schluessel verlaesst den Server nie in Richtung Browser - jede KI-Anfrage
* laeuft ueber das Backend, und nach aussen gibt es ihn nur verkuerzt * laeuft ueber das Backend, und nach aussen gibt es ihn nur verkuerzt
* ("sk-proj-...a1b2"). * ("sk-proj-...a1b2").
* *
* Er kann an zwei Stellen stehen: * Er kann an zwei Stellen stehen:
* *
* 1. in der Datenbank (icon_settings), gepflegt unter Einstellungen -> KI * 1. in der Datenbank (icon_settings), gepflegt unter Einstellungen -> KI
* 2. in server/.env als OPENAI_API_KEY * 2. in server/.env als OPENAI_API_KEY
* *
* Die Datenbank gewinnt. Grund: ein Schluessel laeuft ab oder wird gewechselt, * Die Datenbank gewinnt. Grund: ein Schluessel laeuft ab oder wird gewechselt,
* und dann soll niemand dafuer eine Datei auf dem Server bearbeiten und einen * und dann soll niemand dafuer eine Datei auf dem Server bearbeiten und einen
* Dienst neu starten muessen. Die .env bleibt als Startwert und als Weg, das * Dienst neu starten muessen. Die .env bleibt als Startwert und als Weg, das
* System ohne Oberflaeche in Gang zu bringen. * System ohne Oberflaeche in Gang zu bringen.
*/ */
const ENDPOINT = 'https://api.openai.com/v1/chat/completions'; const ENDPOINT = 'https://api.openai.com/v1/chat/completions';
/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */ /** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */
const TIMEOUT_MS = 120_000; const TIMEOUT_MS = 120_000;
export const DEFAULT_MODEL = 'gpt-5.6-luna'; export const DEFAULT_MODEL = 'gpt-6-luna';
const KEY_SETTING = 'openai_api_key'; const KEY_SETTING = 'openai_api_key';
const MODEL_SETTING = 'openai_model'; const MODEL_SETTING = 'openai_model';
/* /*
* Im Speicher gehalten, damit nicht jede Anfrage die Datenbank fragt. Gefuellt * Im Speicher gehalten, damit nicht jede Anfrage die Datenbank fragt. Gefuellt
* beim Start (loadAiConfig) und bei jeder Aenderung. * beim Start (loadAiConfig) und bei jeder Aenderung.
*/ */
const config = { const config = {
key: process.env.OPENAI_API_KEY || '', key: process.env.OPENAI_API_KEY || '',
model: process.env.OPENAI_MODEL || DEFAULT_MODEL, model: process.env.OPENAI_MODEL || DEFAULT_MODEL,
/** Woher der gueltige Schluessel stammt - fuer die Anzeige. */ /** Woher der gueltige Schluessel stammt - fuer die Anzeige. */
source: process.env.OPENAI_API_KEY ? 'env' : 'none', source: process.env.OPENAI_API_KEY ? 'env' : 'none',
}; };
/** Beim Start aufrufen, sobald die Datenbank steht. */ /** Beim Start aufrufen, sobald die Datenbank steht. */
export async function loadAiConfig() { export async function loadAiConfig() {
try { try {
const [key, model] = await Promise.all([getSetting(KEY_SETTING), getSetting(MODEL_SETTING)]); const [key, model] = await Promise.all([getSetting(KEY_SETTING), getSetting(MODEL_SETTING)]);
if (key) { if (key) {
config.key = key; config.key = key;
config.source = 'db'; config.source = 'db';
} }
if (model) config.model = model; if (model) config.model = model;
} catch (err) { } catch (err) {
// Ohne Datenbank bleibt es bei der .env - das Backend soll trotzdem starten. // Ohne Datenbank bleibt es bei der .env - das Backend soll trotzdem starten.
console.warn('[ICON CRM] KI-Einstellungen nicht lesbar:', err.message); console.warn('[ICON CRM] KI-Einstellungen nicht lesbar:', err.message);
} }
} }
export function aiConfigured() { export function aiConfigured() {
return Boolean(config.key); return Boolean(config.key);
} }
export function aiModel() { export function aiModel() {
return config.model; return config.model;
} }
/** /**
* Zustand fuer die Oberflaeche. Der Schluessel wird nur verkuerzt gezeigt - * Zustand fuer die Oberflaeche. Der Schluessel wird nur verkuerzt gezeigt -
* genug, um zwei Schluessel auseinanderzuhalten, zu wenig, um ihn zu benutzen. * genug, um zwei Schluessel auseinanderzuhalten, zu wenig, um ihn zu benutzen.
*/ */
export function aiStatus() { export function aiStatus() {
return { return {
configured: aiConfigured(), configured: aiConfigured(),
model: config.model, model: config.model,
defaultModel: DEFAULT_MODEL, defaultModel: DEFAULT_MODEL,
source: config.source, source: config.source,
masked: maskKey(config.key), masked: maskKey(config.key),
/** Steht in der .env noch einer? Dann greift er, wenn der aus der DB faellt. */ /** Steht in der .env noch einer? Dann greift er, wenn der aus der DB faellt. */
envAvailable: Boolean(process.env.OPENAI_API_KEY), envAvailable: Boolean(process.env.OPENAI_API_KEY),
}; };
} }
function maskKey(key) { function maskKey(key) {
if (!key) return null; if (!key) return null;
return key.length <= 12 ? '****' : `${key.slice(0, 7)}…${key.slice(-4)}`; return key.length <= 12 ? '****' : `${key.slice(0, 7)}…${key.slice(-4)}`;
} }
/** /**
* Schluessel setzen oder entfernen. * Schluessel setzen oder entfernen.
* *
* Entfernen heisst: der Wert in der Datenbank faellt weg. Steht in der .env * Entfernen heisst: der Wert in der Datenbank faellt weg. Steht in der .env
* einer, gilt wieder dieser - das ist kein Versehen, sondern der Rueckfall. * einer, gilt wieder dieser - das ist kein Versehen, sondern der Rueckfall.
*/ */
export async function saveAiKey(key) { export async function saveAiKey(key) {
const value = String(key ?? '').trim(); const value = String(key ?? '').trim();
await setSetting(KEY_SETTING, value || null); await setSetting(KEY_SETTING, value || null);
if (value) { if (value) {
config.key = value; config.key = value;
config.source = 'db'; config.source = 'db';
} else { } else {
config.key = process.env.OPENAI_API_KEY || ''; config.key = process.env.OPENAI_API_KEY || '';
config.source = config.key ? 'env' : 'none'; config.source = config.key ? 'env' : 'none';
} }
} }
/** Modell setzen; leer heisst zurueck zur Vorgabe. */ /** Modell setzen; leer heisst zurueck zur Vorgabe. */
export async function saveAiModel(model) { export async function saveAiModel(model) {
const value = String(model ?? '').trim(); const value = String(model ?? '').trim();
await setSetting(MODEL_SETTING, value || null); await setSetting(MODEL_SETTING, value || null);
config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL; config.model = value || process.env.OPENAI_MODEL || DEFAULT_MODEL;
} }
/* /*
* Die Modelle, die der hinterlegte Schluessel nutzen darf. * Die Modelle, die der hinterlegte Schluessel nutzen darf.
* *
* /v1/models liefert alles - auch Bild-, Audio-, Embedding- und Moderations- * /v1/models liefert alles - auch Bild-, Audio-, Embedding- und Moderations-
* modelle. Fuer das CRM zaehlen nur Chat-Modelle, also werden die anderen * modelle. Fuer das CRM zaehlen nur Chat-Modelle, also werden die anderen
* ausgefiltert. Kurz im Speicher gehalten, damit das Aufklappen der Liste nicht * ausgefiltert. Kurz im Speicher gehalten, damit das Aufklappen der Liste nicht
* jedes Mal OpenAI fragt; ein Schluesselwechsel leert den Speicher. * jedes Mal OpenAI fragt; ein Schluesselwechsel leert den Speicher.
*/ */
const MODELS_ENDPOINT = 'https://api.openai.com/v1/models'; const MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
const MODELS_TTL_MS = 10 * 60_000; const MODELS_TTL_MS = 10 * 60_000;
const NON_CHAT = /(embedding|whisper|tts|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|davinci|babbage|computer-use|codex|sora)/i; const NON_CHAT = /(embedding|whisper|tts|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|davinci|babbage|computer-use|codex|sora)/i;
let modelCache = { key: '', at: 0, list: [] }; let modelCache = { key: '', at: 0, list: [] };
export async function listAiModels() { export async function listAiModels() {
if (!aiConfigured()) throw new AiError('Es ist kein Schlüssel hinterlegt.', 503); if (!aiConfigured()) throw new AiError('Es ist kein Schlüssel hinterlegt.', 503);
if (modelCache.key === config.key && Date.now() - modelCache.at < MODELS_TTL_MS) { if (modelCache.key === config.key && Date.now() - modelCache.at < MODELS_TTL_MS) {
return modelCache.list; return modelCache.list;
} }
let response; let response;
try { try {
response = await fetch(MODELS_ENDPOINT, { response = await fetch(MODELS_ENDPOINT, {
headers: { Authorization: `Bearer ${config.key}` }, headers: { Authorization: `Bearer ${config.key}` },
signal: AbortSignal.timeout(15_000), signal: AbortSignal.timeout(15_000),
}); });
} catch { } catch {
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502); throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
} }
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502); throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
} }
const list = (payload?.data ?? []) const list = (payload?.data ?? [])
.map((m) => ({ id: String(m.id), created: Number(m.created) || 0 })) .map((m) => ({ id: String(m.id), created: Number(m.created) || 0 }))
.filter((m) => /^(gpt-|o\d|chatgpt-)/i.test(m.id) && !NON_CHAT.test(m.id)) .filter((m) => /^(gpt-|o\d|chatgpt-)/i.test(m.id) && !NON_CHAT.test(m.id))
// Neueste zuerst - die will man meistens. // Neueste zuerst - die will man meistens.
.sort((a, b) => b.created - a.created || a.id.localeCompare(b.id)); .sort((a, b) => b.created - a.created || a.id.localeCompare(b.id));
modelCache = { key: config.key, at: Date.now(), list }; modelCache = { key: config.key, at: Date.now(), list };
return list; return list;
} }
class AiError extends Error { class AiError extends Error {
constructor(message, status = 502) { constructor(message, status = 502) {
super(message); super(message);
this.status = status; this.status = status;
} }
} }
/** /**
* Ein Aufruf an das Modell. * Ein Aufruf an das Modell.
* *
* `temperature` wird bewusst NICHT mitgeschickt: gpt-5.6-luna laesst nur den * `temperature` wird bewusst NICHT mitgeschickt: die Luna-Modelle lassen nur den
* Standardwert zu und weist jeden anderen mit 400 ab. * Standardwert zu und weisen jeden anderen mit 400 ab.
* *
* @param {object} options * @param {object} options
* @param {string} options.system Rollenanweisung * @param {string} options.system Rollenanweisung
* @param {string} options.user Eigentliche Anfrage * @param {string} options.user Eigentliche Anfrage
* @param {boolean} [options.json] true erzwingt eine JSON-Antwort * @param {boolean} [options.json] true erzwingt eine JSON-Antwort
* @param {number} [options.maxTokens] * @param {number} [options.maxTokens]
* @returns {Promise<string>} der Antworttext * @returns {Promise<string>} der Antworttext
*/ */
export async function askModel({ system, user, json = false, maxTokens }) { export async function askModel({ system, user, json = false, maxTokens }) {
if (!aiConfigured()) { if (!aiConfigured()) {
throw new AiError( throw new AiError(
'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI, oder OPENAI_API_KEY in server/.env).', 'Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI, oder OPENAI_API_KEY in server/.env).',
503 503
); );
} }
const body = { const body = {
model: config.model, model: config.model,
messages: [ messages: [
...(system ? [{ role: 'system', content: system }] : []), ...(system ? [{ role: 'system', content: system }] : []),
{ role: 'user', content: user }, { role: 'user', content: user },
], ],
}; };
if (json) body.response_format = { type: 'json_object' }; if (json) body.response_format = { type: 'json_object' };
if (maxTokens) body.max_completion_tokens = maxTokens; if (maxTokens) body.max_completion_tokens = maxTokens;
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
let response; let response;
try { try {
response = await fetch(ENDPOINT, { response = await fetch(ENDPOINT, {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${config.key}`, Authorization: `Bearer ${config.key}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
signal: controller.signal, signal: controller.signal,
}); });
} catch (err) { } catch (err) {
if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504); if (err?.name === 'AbortError') throw new AiError('Die KI hat nicht rechtzeitig geantwortet.', 504);
throw new AiError('OpenAI ist gerade nicht erreichbar.', 502); throw new AiError('OpenAI ist gerade nicht erreichbar.', 502);
} finally { } finally {
clearTimeout(timer); clearTimeout(timer);
} }
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
// Die Meldung von OpenAI ist meist praezise ("model not found", "quota") - // Die Meldung von OpenAI ist meist praezise ("model not found", "quota") -
// sie hilft mehr als ein eigener Text, enthaelt aber nie den Schluessel. // 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); throw new AiError(payload?.error?.message || `OpenAI antwortete mit HTTP ${response.status}.`, 502);
} }
const text = payload?.choices?.[0]?.message?.content; const text = payload?.choices?.[0]?.message?.content;
if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502); if (!text) throw new AiError('Die KI hat keine Antwort geliefert.', 502);
return text; return text;
} }
/** Wie askModel, gibt aber das geparste JSON zurueck. */ /** Wie askModel, gibt aber das geparste JSON zurueck. */
export async function askModelJson(options) { export async function askModelJson(options) {
const text = await askModel({ ...options, json: true }); const text = await askModel({ ...options, json: true });
try { try {
return JSON.parse(text); return JSON.parse(text);
} catch { } catch {
throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502); throw new AiError('Die KI hat kein gültiges JSON geliefert.', 502);
} }
} }
export { AiError }; export { AiError };
+1 -1
View File
@@ -174,7 +174,7 @@ export const AiSettings: React.FC = () => {
hint={ hint={
models models
? `${models.length} Modelle für diesen Schlüssel verfügbar. Vorgabe ist ${config.defaultModel}.` ? `${models.length} Modelle für diesen Schlüssel verfügbar. Vorgabe ist ${config.defaultModel}.`
: `Vorgabe ist ${config.defaultModel}. Achtung: gpt-5.6-luna erlaubt keinen eigenen „temperature"-Wert – das Backend schickt deshalb keinen.` : `Vorgabe ist ${config.defaultModel}. Achtung: die Luna-Modelle erlauben keinen eigenen „temperature"-Wert – das Backend schickt deshalb keinen.`
} }
> >
{models ? ( {models ? (