Od teme magazina do projektne prakse
Povezane stranice usluga i tehnologije za članak
Zašto „ChatGPT API mit Delphi FMX/VCL“ u praksi nije samo POST
Ko želi da poveže ChatGPT API mit Delphi FMX/VCL, brzo završi sa jednostavnim HTTP-POST-om. U stvarnim poslovnim softverskim okruženjima to puca na tri mjesta: (1) vremenska ograničenja (Timeouts) i ponovni pokušaji (Retries) moraju biti deterministički, jer će korisnici inače doživjeti „zaglavljeno“ UI, (2) Streaming (Server-Sent Events, skraćeno SSE) je za dobar UX često koristan, ali u Delphi-threadingu brzo postane sklon greškama, i (3) JSON nije samo „jedan objekt“: poruke o greškama, problemi s kvotama, prazna polja ili blago promijenjeni oblici odgovora moraju se robustno obraditi.
Sledeći izvod koda prikazuje pristup koji u FMX i VCL funkcioniše podjednako: vlastiti, testabilni klijent koji po izboru radi bez streaminga ili sa streamingom, uredno marshaluje UI-azuriranja (tj. izvršava ih kroz sinhronizaciju glavne niti) i pri greškama vodi smislen zapis. Uzgred, napravljen je tako da se uklopi u postojeće slojne strukture (npr. „API-Client“ u integracionom sloju, UI ostaje tanak).
Skica arhitekture: odvojiti UI, zadržati klijent testabilnim
U Delphi-projektima s dugom historijom često se nalazi „HTTP im ButtonClick“. To funkcioniše dok se ne dogodi prvi incident. Preporučljivo je imati mali klijent sa:
- Konfiguracija: Base-URL, API-Key, Modell, Timeouts.
- Transportni sloj: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-Optionen (ovisno o okruženju).
- Parser: JSON-Decoding, Fehlerobjekte, Ergebnisextraktion.
- UI-Hooks: Callback za token-/text-streaming, ali bez čvrste ovisnosti o VCL/FMX kontrolama.
Na taj način integracija u prilagođeni poslovni softver može se uredno provoditi: Klijent se može ponovno koristiti u servisima, desktop-klijentima, admin-alatima ili test-harnessima.
Source-Schnipsel: Delphi-Client mit SSE-Streaming, Timeout/Retry und robustem JSON
Kod koristi THTTPClient (System.Net.HttpClient) i parsira namjerno samo minimalno pomoću System.JSON. Za SSE se čita liniju po liniju i reagira na „data: …“. To nije „WebSocket“, već HTTP-Response-Stream koji kontinuirano isporučuje tekstualne linije. Važno: Čitamo u radnoj niti (Worker-Thread) i marshalujemo ažuriranja UI u glavnu nit.
unit Net-Base.OpenAI.ChatClient;
interface
uses
System.SysUtils, System.Classes, System.Net.URLClient, System.Net.HttpClient,
System.Net.HttpClientComponent, System.JSON, System.Threading,
System.SyncObjs;
type
EChatApiError = class(Exception)
private
FHttpStatus: Integer;
FResponseText: string;
public
constructor Create(const Msg: string; AHttpStatus: Integer; const AResponseText: string);
property HttpStatus: Integer read FHttpStatus;
property ResponseText: string read FResponseText;
end;
TChatStreamEvent = reference to procedure(const AChunkText: string; AIsFinal: Boolean);
TChatCompletionOptions = record
Model: string;
Temperature: Double;
MaxTokens: Integer;
constructor Create(const AModel: string; ATemperature: Double = 0.2; AMaxTokens: Integer = 512);
end;
TOpenAIChatClient = class
private
FBaseUrl: string;
FApiKey: string;
FConnectTimeoutMs: Integer;
FResponseTimeoutMs: Integer;
function BuildChatRequestBody(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
AStream: Boolean): TJSONObject;
function ExtractTextFromNonStreamingResponse(const AJsonText: string): string;
function TryExtractErrorMessage(const AJsonText: string; out AMessage: string): Boolean;
procedure ApplyAuthHeaders(ARequest: IHTTPRequest);
function ExecuteWithRetry(const ADoRequest: TFunc): IHTTPResponse;
public
constructor Create(const ABaseUrl, AApiKey: string);
property ConnectTimeoutMs: Integer read FConnectTimeoutMs write FConnectTimeoutMs;
property ResponseTimeoutMs: Integer read FResponseTimeoutMs write FResponseTimeoutMs;
function ChatOnce(const AUserPrompt: string; const AOptions: TChatCompletionOptions): string;
procedure ChatStream(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
const AOnEvent: TChatStreamEvent);
end;
implementation
{ EChatApiError }
constructor EChatApiError.Create(const Msg: string; AHttpStatus: Integer; const AResponseText: string);
begin
inherited Create(Msg);
FHttpStatus := AHttpStatus;
FResponseText := AResponseText;
end;
{ TChatCompletionOptions }
constructor TChatCompletionOptions.Create(const AModel: string; ATemperature: Double; AMaxTokens: Integer);
begin
Model := AModel;
Temperature := ATemperature;
MaxTokens := AMaxTokens;
end;
{ TOpenAIChatClient }
constructor TOpenAIChatClient.Create(const ABaseUrl, AApiKey: string);
begin
inherited Create;
FBaseUrl := ABaseUrl.TrimRight([‚/‘]);
FApiKey := AApiKey;
FConnectTimeoutMs := 8000;
FResponseTimeoutMs := 60000;
end;
procedure TOpenAIChatClient.ApplyAuthHeaders(ARequest: IHTTPRequest);
begin
// Bearer token: ovdje API ključ u obliku „Authorization: Bearer …“.
// U korporativnim okruženjima vodite računa da ključevi ne završe u logovima.
ARequest.AddHeader(‚Authorization‘, ‚Bearer ‚ + FApiKey);
ARequest.AddHeader(‚Content-Type‘, ‚application/json‘);
ARequest.AddHeader(‚Accept‘, ‚application/json‘);
end;
function TOpenAIChatClient.BuildChatRequestBody(const AUserPrompt: string;
const AOptions: TChatCompletionOptions; AStream: Boolean): TJSONObject;
var
Msgs: TJSONArray;
Msg: TJSONObject;
begin
Result := TJSONObject.Create;
Result.AddPair(‚model‘, AOptions.Model);
Result.AddPair(‚temperature‘, TJSONNumber.Create(AOptions.Temperature));
Result.AddPair(‚max_tokens‘, TJSONNumber.Create(AOptions.MaxTokens));
Result.AddPair(’stream‘, TJSONBool.Create(AStream));
// Minimalna struktura messages (Chat Completions): role/content.
Msgs := TJSONArray.Create;
Msg := TJSONObject.Create;
Msg.AddPair(‚role‘, ‚user‘);
Msg.AddPair(‚content‘, AUserPrompt);
Msgs.AddElement(Msg);
Result.AddPair(‚messages‘, Msgs);
end;
function TOpenAIChatClient.TryExtractErrorMessage(const AJsonText: string; out AMessage: string): Boolean;
var
J: TJSONValue;
EObj: TJSONObject;
begin
Result := False;
AMessage := “;
J := TJSONObject.ParseJSONValue(AJsonText);
try
if (J is TJSONObject) then
begin
// Čest oblik: { „error“: { „message“: „…“, „type“: „…“ } }
EObj := (J as TJSONObject).GetValue<TJSONObject>(‚error‘);
if Assigned(EObj) then
begin
AMessage := EObj.GetValue<string>(‚message‘, “);
Result := AMessage <> “;
end;
end;
finally
J.Free;
end;
end;
function TOpenAIChatClient.ExtractTextFromNonStreamingResponse(const AJsonText: string): string;
var
J: TJSONValue;
Root: TJSONObject;
Choices: TJSONArray;
Choice0: TJSONObject;
Msg: TJSONObject;
begin
Result := “;
J := TJSONObject.ParseJSONValue(AJsonText);
try
if not (J is TJSONObject) then
raise EChatApiError.Create(‚Neočekivan JSON-odgovor (nije JSON-objekt).‘, 0, AJsonText);
Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>(‚choices‘);
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create(‚Neočekivan JSON-odgovor: „choices“ nedostaje/je prazan.‘, 0, AJsonText);
Choice0 := Choices.Items[0] as TJSONObject;
// Chat Completions: choices[0].message.content
Msg := Choice0.GetValue<TJSONObject>(‚message‘);
if Msg = nil then
raise EChatApiError.Create(‚Neočekivan JSON-odgovor: „message“ nedostaje.‘, 0, AJsonText);
Result := Msg.GetValue<string>(‚content‘, “);
finally
J.Free;
end;
end;
function TOpenAIChatClient.ExecuteWithRetry(const ADoRequest: TFunc<IHTTPResponse>): IHTTPResponse;
const
MaxAttempts = 3;
var
Attempt: Integer;
DelayMs: Integer;
begin
DelayMs := 350;
for Attempt := 1 to MaxAttempts do
begin
try
Exit(ADoRequest());
except
on E: ENetHTTPClientException do
begin
// Greške mreže/TLS/time-out: jednostavan ponovni pokušaj s povećanjem intervala čekanja (backoff).
if Attempt = MaxAttempts then
raise;
Sleep(DelayMs);
DelayMs := DelayMs * 2;
end;
end;
end;
Result := nil;
end;
function TOpenAIChatClient.ChatOnce(const AUserPrompt: string; const AOptions: TChatCompletionOptions): string;
var
Http: THTTPClient;
Req: IHTTPRequest;
Resp: IHTTPResponse;
Body: TJSONObject;
Payload: TStringStream;
RespText: string;
ErrMsg: string;
begin
Http := THTTPClient.Create;
try
Http.ConnectionTimeout := FConnectTimeoutMs;
Http.ResponseTimeout := FResponseTimeoutMs;
Body := BuildChatRequestBody(AUserPrompt, AOptions, False);
try
Payload := TStringStream.Create(Body.ToJSON, TEncoding.UTF8);
try
Req := Http.GetRequest(‚POST‘, FBaseUrl + ‚/v1/chat/completions‘);
ApplyAuthHeaders(Req);
Resp := ExecuteWithRetry(
function: IHTTPResponse
begin
Result := Http.Execute(Req, Payload);
end
);
RespText := Resp.ContentAsString(TEncoding.UTF8);
if (Resp.StatusCode < 200) or (Resp.StatusCode >= 300) then
begin
if TryExtractErrorMessage(RespText, ErrMsg) then
raise EChatApiError.Create(ErrMsg, Resp.StatusCode, RespText)
else
raise EChatApiError.Create(‚HTTP greška ‚ + Resp.StatusCode.ToString, Resp.StatusCode, RespText);
end;
Result := ExtractTextFromNonStreamingResponse(RespText);
finally
Payload.Free;
end;
finally
Body.Free;
end;
finally
Http.Free;
end;
end;
procedure TOpenAIChatClient.ChatStream(const AUserPrompt: string; const AOptions: TChatCompletionOptions;
const AOnEvent: TChatStreamEvent);
var
Task: ITask;
begin
// Streaming se namjerno pokreće asinkrono kako FMX/VCL ne bi blokirao.
Task := TTask.Run(
procedure
var
Http: THTTPClient;
Req: IHTTPRequest;
Resp: IHTTPResponse;
Body: TJSONObject;
Payload: TStringStream;
Stream: TStream;
Reader: TStreamReader;
Line, Data: string;
J: TJSONValue;
Delta, Choice0, Choices: TJSONValue;
ContentChunk: string;
begin
Http := THTTPClient.Create;
try
Http.ConnectionTimeout := FConnectTimeoutMs;
Http.ResponseTimeout := FResponseTimeoutMs;
Body := BuildChatRequestBody(AUserPrompt, AOptions, True);
try
Payload := TStringStream.Create(Body.ToJSON, TEncoding.UTF8);
try
Req := Http.GetRequest(‚POST‘, FBaseUrl + ‚/v1/chat/completions‘);
ApplyAuthHeaders(Req);
Req.AddHeader(‚Accept‘, ‚text/event-stream‘);
Resp := ExecuteWithRetry(
function: IHTTPResponse
begin
Result := Http.Execute(Req, Payload);
end
);
if (Resp.StatusCode < 200) or (Resp.StatusCode >= 300) then
begin
// Kod grešaka pri streamingu sadržaj je često ipak JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Pokretanje streaminga nije uspjelo (HTTP ‚ + Resp.StatusCode.ToString + ‚).‘, True);
end);
Exit;
end;
Stream := Resp.ContentStream;
Reader := TStreamReader.Create(Stream, TEncoding.UTF8, True, 4096, False);
try
while not Reader.EndOfStream do
begin
Line := Reader.ReadLine;
if Line = “ then
Continue;
// SSE format: linije poput „data: {…}“ ili „data: [DONE]“
if Line.StartsWith(‚data:‘) then
begin
Data := Line.Substring(5).Trim;
if SameText(Data, ‚[DONE]‘) then
begin
TThread.Queue(nil,
procedure
begin
AOnEvent(“, True);
end);
Break;
end;
// Obrada JSON-chunka: choices[0].delta.content
J := TJSONObject.ParseJSONValue(Data);
try
ContentChunk := “;
if J <> nil then
begin
Choices := (J as TJSONObject).GetValue(‚choices‘);
if (Choices is TJSONArray) and (TJSONArray(Choices).Count > 0) then
begin
Choice0 := TJSONArray(Choices).Items[0];
Delta := (Choice0 as TJSONObject).GetValue(‚delta‘);
if (Delta is TJSONObject) then
ContentChunk := TJSONObject(Delta).GetValue<string>(‚content‘, “);
end;
end;
finally
J.Free;
end;
if ContentChunk <> “ then
TThread.Queue(nil,
procedure
begin
AOnEvent(ContentChunk, False);
end);
end;
end;
finally
Reader.Free;
end;
finally
Payload.Free;
end;
finally
Body.Free;
end;
finally
Http.Free;
end;
end);
end;
end.
Čemu pristup služi
Kod rješava tri tipične klase problema koje u VCL/FMX brzo postanu skupe:
- Streaming bez zamrzavanja UI-a: HTTP-stream se čita u pozadini; ažuriranja UI-a idu preko
TThread.Queue(asinhrono u glavnu nit). To je u FMX i VCL robusni standardni pristup. - Ponovni pokušaj za mrežne greške: Kod
ENetHTTPClientExceptionpokušava se ponovo koristeći backoff. To je svjesno jednostavno i može se kasnije proširiti za statusne kodove (429/5xx). - Robusno parsiranje JSON-a: Umjesto slijepog kastovanja polja, provjerava se postupno. Time se smanjuju „Invalid type cast“-greške kod posebnih odgovora.
Ograničenja, zamke i varijante
- SSE nije „obični JSON“: Kod streaminga dolazi niz događaja, a ne jedan JSON-odgovor. Zato je čitanje po linijama i prepoznavanje
[DONE]centralno. - THTTPClient i proxy/SSL: U administrativnim mrežama TLS-inspekcija i obaveze proxyja su realnost. Planirajte
THTTPClient.ProxySettingsi, po potrebi, pitanja certifikata. Debugging: uvijek zabilježite statusni kod i zaglavlja (bez API-Key). - Strategija timeouta:
ResponseTimeoutje kod streaminga osjetljiv: Ako odaberete prekratko, klijent prekida duže odgovore. U UI-alatima duži timeout i dugme „Otkaži“ često su prikladniji od „kratko i strogo“. - Prekid threada: Primjerak ne pokazuje Cancel-Token. Za produktivne alate isplati se mehanizam za prekid (npr. flag +
Http.CancelAllu novijim Delphi-verzijama ili putem kontroliranog prekida streama). - Razvoj modela i API-ja: Struktura odgovora se može razlikovati. Držite parser defanzivnim i centraliziranim, ne razbacanim po kodu formulara.
Debugging u postojećim Delphi-klijentima: Šta biste zaista trebali logovati
U integracionim projektima početna puštanja rijetko se razbiju zbog JSON-a, već zbog detalja okoline. U tehnički log (datoteka, Eventlog, centralni logger) u praksi trebaju ići:
- Request-ID (dodijeljena od strane aplikacije), timestamp, ciljna URL (bez tajnih query-stringova).
- HTTP-statusni kod, Content-Type, dužina odgovora, trajanje.
- Skraćeni Response-Body kod grešaka (npr. max. 4–8 KB), kako bi se mogli prepoznati Quota-/Policy-greške.
- Jasno označavanje ponovnih pokušaja: Attempt, Delay, Exception-Klasse.
API-Key nikad ne ide u log. Ako logujete Request-Body, onda samo u dijagnostičkim buildovima i uz maskiranje, jer Prompts mogu sadržavati lične ili poslovne podatke.
Kontekst za naslijeđene situacije: VCL, FMX i Layer-3 arhitektura
Mnoge Delphi-aplikacije rade u klasičnoj 3-slojevnoj logici („Layer-3 arhitektura“: UI, poslovna logika, podaci/integracija). Za ChatGPT-povezivanje je to korisno: prikazani klijent pripada integracijskom sloju; poslovna logika odlučuje šta se pita; UI prikazuje samo tok i status. Tako izbjegavate da kasnija promjena (drugi provider, On-Prem-Proxy, novi endpointi) „razbije“ Forms.
I za modernizaciju Delphi to je dobar početni korak: Prvo stabilan klijent, zatim poboljšanja UI-a (Streaming, Otkaži, Historija), pa tek onda „inteligentnije“ funkcije poput strukturiranih odgovora ili poziva alata.
Zaključak: Solidna osnova, ali ne svaka aplikacija treba Streaming
Vrijedi uredno povezati ChatGPT API s Delphi FMX/VCL posebno tamo gdje su reaktivnost korisničkog sučelja, operativna sigurnost i mogućnost debugiranja važni: Admin-Tools, procesno bliski Desktop-Clients ili Support-Werkzeuge u digitalnim poslovnim rješenjima. Prikazani isječak je namjerno pragmatičan: SSE-Streaming ohne Spezialbibliotheken, Retry nur für echte Netzfehler, JSON defensiv geparst.
Einsatzgrenzen: Ako trebate stroge zahtjeve za usklađenost, centralizovanu Prompt-Governance, podršku za multitenancy ili detaljne audit-trail zapise, „ein Client im Desktop“ obično nije dovoljan. Tada povezivanje tipično treba biti smješteno na kontrolirani server (z. B. eigener REST-Service), koji centralno provodi Politiken, Logging i kontrolu pristupa. Für viele Delphi-Installationen ist der hier gezeigte Client aber ein belastbarer Startpunkt, der sich schrittweise in eine sauberere Gesamtarchitektur überführen lässt.
Im fachlichen Umfeld spielen auch Openai API In Delphi und Delphi Http Client Timeout Retry eine wichtige Rolle, wenn Integrationen, Datenflüsse und Weiterentwicklung sauber zusammenspielen müssen.
Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.
Nächster Schritt
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
Pružamo podršku ne samo pri pojedinačnim pitanjima, već i kada iz fragmenata izvornog koda, naslijeđenih sistema ili ideja za portal treba nastati robustan poslovni projekat.
- Postojeće stanje, ciljno stanje i tehnički rizici procjenjuju se zajedno.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.