Od teme magazina do projektne prakse
Povezane stranice usluga i tehnologije za članak
Zašto „ChatGPT API s Delphi FMX/VCL“ u praksi nije samo POST
Tko želi povezati ChatGPT API s Delphi FMX/VCL, brzo završi s jednostavnim HTTP-POST. U stvarnim poslovnim softverskim okruženjima to puca na tri mjesta: (1) timeouti i ponovni pokušaji moraju biti deterministički, jer korisnici inače doživljavaju „zaglavljeno“ UI, (2) streaming (Server-Sent Events, skraćeno SSE) često je koristan za dobar UX, ali u Delphi-threadingu brzo postaje sklon pogreškama, i (3) JSON nije samo „jedan objekt“: poruke o pogrešci, problemi s kvotama, prazna polja ili blago izmijenjeni oblici odgovora moraju se robusno obraditi.
Sljedeći isječak izvornog koda pokazuje pristup koji djeluje jednako u FMX i VCL: vlastiti, testabilni klijent koji radi po izboru ne-streaming ili streaming, uredno prenosi UI-azuriranja (tj. izvršava ih kroz sinkronizaciju glavne niti) i kod pogrešaka zapisuje informativne logove. Usput je strukturiran tako da se uklapa u postojeće slojevite arhitekture (npr. „API-Client“ u integracijskom sloju, UI ostaje tanak).
Arhitektonska skica: razdvojiti UI, zadržati klijent testabilnim
U Delphi-projektima s dugom poviješću često se nalazi „HTTP u ButtonClick“. To funkcionira do prvog incidenta. Preporučljivo je mali klijent s:
- Konfiguracija: Base-URL, API-Key, Modell, Timeouts.
- Transportni sloj: HTTP-Request/Response, Retry, Timeout, opcije Proxy/SSL (ovisno o okruženju).
- Parser: JSON-dekodiranje, objekti pogrešaka, ekstrakcija rezultata.
- UI-hookovi: callback za token-/text-streaming, ali bez čvrste ovisnosti o VCL/FMX kontrolama.
Tako se integracija u prilagođeni poslovni softver može uredno održavati: klijent se može ponovno koristiti u servisima, desktop-klijentima, admin-alatima ili test-harnessima.
Isječak izvornog koda: Delphi-klijent s SSE-streamingom, Timeout/Retry i robusnim JSON-om
Kod koristi THTTPClient (System.Net.HttpClient) i namjerno parsira samo minimalno s System.JSON. Za SSE se čita po retku i reagira na „data: …“. To nije „WebSocket“, nego HTTP-Response-Stream koji kontinuirano isporučuje tekstualne retke. Važno: čitamo u radničkoj niti i maršaliramo UI-azuriranja 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č kao "Authorization: Bearer …".
// U poslovnim okruženjima dodatno pripaziti 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
// Uobičajeni 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čekivani JSON-odgovor (nije objekt).', 0, AJsonText);
Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>('choices');
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create('Neočekivani JSON-odgovor: nedostaje/je prazan element choices.', 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čekivani JSON-odgovor: nedostaje message.', 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
// Mrežne/TLS/timeout pogreške: jednostavan retry s backoffom.
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 pogreš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 bilo blokirano.
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 streama 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: retci 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.
Za što je ovaj pristup dobar
Kod rješava tri tipične klase problema koje u VCL/FMX brzo postaju skupe:
- Streaming bez zamrzavanja korisničkog sučelja: HTTP-stream se čita u pozadini; ažuriranja sučelja prolaze putem
TThread.Queue(asinkrono u glavnu dretvu). To je u FMX i VCL robustan standardni pristup. - Pokušaji ponovnog slanja za mrežne greške: Kod
ENetHTTPClientExceptionpokušava se ponovno s backoffom. To je svjesno jednostavno i kasnije se može proširiti za statusne kodove (429/5xx). - Robusno parsiranje JSON‑a: Umjesto da se polja slijepo kastaju, provjera se radi korak po korak. Time se smanjuju pogreške „Invalid type cast“ kod posebnih odgovora.
Okvirni uvjeti, zamke i varijante
- SSE nije „normalan JSON“: Kod streaminga dolazi niz događaja, a ne jedan JSON‑odgovor. Zato je čitanje po retku i prepoznavanje
[DONE]ključno. - THTTPClient i proxyji/SSL: U administratorskim mrežama TLS‑inspekcija i obaveze korištenja proxyja su stvarne. Planirajte
THTTPClient.ProxySettingsi po potrebi pitanja certifikata. Za debugiranje: uvijek zapisujte statusni kod i zaglavlja (bez API‑Keya). - Strategija timeouta:
ResponseTimeoutje kod streaminga osjetljiv: ako odaberete prenisku vrijednost, klijent će prekinuti duge odgovore. U UI‑alatima je dulji timeout i gumb „Prekini“ često prikladniji od „kratko i strogo“. - Prekid dretve: Isječak ne prikazuje Cancel‑Token. Za produktivne alate isplati se implementirati mehanizam prekida (npr. flag +
Http.CancelAllu novijim Delphi‑verzijama ili kontroliranim prekidom streama). - Razvoj modela i API‑ja: Struktura odgovora može varirati. Održavajte parsere defenzivnima i centraliziranima, a ne razbacane po kodu obrasca.
Debugiranje u postojećim Delphi‑klijentima: što biste stvarno trebali zapisivati
U integracijskim projektima prvo puštanje u rad rijetko zakaže zbog JSON‑a, već zbog detalja okoline. U tehnički log (datoteka, Eventlog, centralni logger) u praksi treba zapisivati:
- Request‑ID (dodijeljena od strane vas), vremenska oznaka, ciljna URL (bez tajnih parametara upita).
- HTTP statusni kod, Content‑Type, duljina odgovora, trajanje.
- Skraćeno tijelo odgovora pri greškama (npr. max. 4–8 KB), kako bi se mogli prepoznati pogreške vezane uz kvote/politike.
- Jasno označavanje pokušaja ponovnog slanja: broj pokušaja, odgoda, klasa iznimke.
API‑Key nikad ne smije završiti u logu. Ako zapisujete Request‑Body, radite to samo u dijagnostičkim buildovima i uz maskiranje, jer prompti mogu sadržavati osobne ili poslovne informacije.
Kontekst za legacy situacije: VCL, FMX i Layer-3 arhitektura
Mnoge Delphi‑aplikacije rade prema klasičnoj 3‑slojnoj logici („Layer-3 Architektur“: UI, poslovna logika, podaci/integracija). Za povezivanje s ChatGPT‑om to je korisno: prikazani klijent pripada integracijskom sloju; poslovna logika odlučuje, što se pita; UI prikazuje samo tijek i status. Time izbjegavate da kasnija promjena (drugi provider, on‑prem proxy, novi endpointi) naruši forme.
I za modernizaciju Delphi to je dobar početak: prvo stabilan klijent, zatim poboljšanja UI‑a (streaming, prekid, povijest), a tek potom „inteligentnije“ značajke poput strukturiranih odgovora ili poziva alata.
Zaključak: Solidan temelj, ali ne svaka aplikacija treba streaming
Vrijedi pažljivo povezati ChatGPT API s Delphi FMX/VCL naročito tamo gdje su važni reaktivnost UI-ja, operativna pouzdanost i mogućnost debugiranja: alati za administratore, desktop-klijenti bliski procesima ili alati za podršku u digitalnim poslovnim rješenjima. Prikazani isječak je namjerno pragmatičan: SSE-streaming bez specijaliziranih biblioteka, retry samo za stvarne mrežne pogreške, JSON parsiran defenzivno.
Ograničenja upotrebe: Ako trebate stroge zahtjeve usklađenosti, centralnu Prompt-Governance, podršku za višekorisničnost ili detaljne audit-trailove, „jedan klijent na desktopu” obično nije dovoljan. U tom slučaju povezivanje tipično pripada u kontrolirani poslužitelj (npr. vlastiti REST-servis), koji centralno provodi politike, logging i kontrolu pristupa. Za mnoge Delphi instalacije prikazani klijent ipak predstavlja pouzdanu polaznu točku koju se može postupno integrirati u čišću cjelokupnu arhitekturu.
U stručnom okruženju važnu ulogu igraju i Openai API u Delphi te Delphi Http Client Timeout Retry, kada integracije, protoci podataka i daljnji razvoj moraju uredno surađivati.
Razgovarajte o projektu ili modernizacijskom zahvatu s Net-Base.
Nächster Schritt
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
Podržavamo vas ne samo u pojedinačnim pitanjima, već i kada iz isječaka izvornog koda, naslijeđenih sustava ili ideja za portale treba nastati pouzdan poslovni projekt.
- Postojeće stanje, ciljna slika 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.