De la tema din revistă la practica în proiecte
Pagini relevante de servicii și pagini tehnice pentru articol
De ce „ChatGPT API cu Delphi FMX/VCL“ în practică nu este doar un POST
Cine dorește să conecteze ChatGPT API cu Delphi FMX/VCL ajunge rapid la un simplu HTTP-POST. În software-ul de business real, însă, aceasta dă greș în trei puncte: (1) timeout-urile și reîncercările trebuie să fie deterministe, altfel utilizatorii vor experimenta o interfață care pare blocată, (2) streamingul (Server-Sent Events, pe scurt SSE) este adesea util pentru o bună UX, dar în threading-ul Delphi devine rapid predispus la erori, și (3) JSON nu este doar „un obiect”: mesaje de eroare, probleme de cotă, câmpuri goale sau forme de răspuns ușor modificate trebuie tratate robust.
Fragmentul de cod următor arată o abordare care funcționează atât în FMX cât și în VCL: un client propriu, testabil, care opțional lucrează în mod non-streaming sau streaming, sincronizează curat actualizările UI (adică le execută prin sincronizarea pe firul principal) și înregistrează erorile cu mesaje explicite. În plus, este construit astfel încât să se potrivească în structuri de layer existente (de ex. „API-Client” în stratul de integrare, UI rămâne subțire).
Schiță arhitecturală: decuplarea UI, menținerea clientului testabil
În proiectele Delphi cu istorie îndelungată se găsește frecvent „HTTP în ButtonClick”. Asta funcționează până la primul incident. Recomandabil este un client mic cu:
- Configurație: Base-URL, API-Key, model, timeout-uri.
- Strat de transport: request/response HTTP, retry, timeout, opțiuni Proxy/SSL (în funcție de mediul de operare).
- Parser: decodare JSON, obiecte de eroare, extracție de rezultat.
- UI-Hooks: callback pentru token-/text-streaming, dar fără dependență puternică de controalele VCL/FMX.
Astfel integrarea în software-ul enterprise individual poate fi operată curat: clientul poate fi reutilizat în servicii, clienți desktop, unelte administrative sau test-harness-uri.
Fragment de cod: Delphi-Client cu SSE-Streaming, Timeout/Retry și JSON robust
Codul folosește THTTPClient (System.Net.HttpClient) și îl parsează în mod intenționat doar minimal cu System.JSON. Pentru SSE se citește linie cu linie și se reacționează la „data: …”. Asta nu este un „WebSocket”, ci un HTTP-Response-Stream care livrează continuu linii de text. Important: citim într-un Worker-Thread și sincronizăm actualizările UI pe firul principal.
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: aici API-Key ca „Authorization: Bearer …“.
// În medii enterprise, asigurați-vă suplimentar că cheile nu ajung în log.
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));
// Structură minimă pentru 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
// Formă frecventă: { „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(‚Răspuns JSON neașteptat (nu este un obiect).‘, 0, AJsonText);
Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>(‚choices‘);
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create(‚Răspuns JSON neașteptat: câmpul choices lipsește/este gol.‘, 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(‚Răspuns JSON neașteptat: câmpul message lipsește.‘, 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
// Erori de rețea/TLS/timeout: reîncercare simplă cu 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(‚Eroare HTTP ‚ + 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
// Pornire intenționată asincronă a streaming-ului, pentru a nu bloca FMX/VCL.
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
// La erorile de streaming, conținutul este adesea tot JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Pornire streaming eșuată (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;
// Format SSE: linii de tipul „data: {…}“ sau „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;
// Evaluare fragment JSON: 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.
La ce folosește această abordare
Codul rezolvă trei clase tipice de probleme care, în VCL/FMX, pot deveni rapid costisitoare:
- Streaming fără înghețări ale UI: Fluxul HTTP este citit în fundal; actualizările UI rulează prin
TThread.Queue(asincron în Main Thread). Aceasta este în FMX și VCL calea standard robustă. - Reîncercare pentru erori de rețea: La
ENetHTTPClientExceptionse încearcă din nou cu backoff. Este intenționat simplu și poate fi extins mai târziu cu tratarea codurilor de stare (429/5xx). - Parsing JSON robust: În loc să convertiți câmpurile în mod necontrolat, se verifică treptat. Aceasta reduce erorile „Invalid type cast” la răspunsuri speciale.
Condiții, capcane și variante
- SSE nu este un „JSON normal”: La streaming vine o succesiune de evenimente, nu un singur răspuns JSON. Din acest motiv citirea linie cu linie și detectarea
[DONE]sunt esențiale. - THTTPClient și proxy-uri/SSL: În rețelele administrative există TLS-Inspection și obligații privind proxy-urile. Planificați
THTTPClient.ProxySettingsși, dacă e cazul, aspecte legate de certificate. Debugging: înregistrați întotdeauna codul de stare și antetele (fără API-Key). - Strategie de timeout:
ResponseTimeouteste delicat la streaming: dacă alegeți prea scurt, clientul taie răspunsurile lungi. În unelte UI, un timeout mai lung și un buton „Anulează” sunt adesea mai potrivite decât o abordare „scurt și dur”. - Anularea thread-ului: Fragmentul nu arată un Cancel-Token. Pentru unelte productive merită un mecanism de anulare (de ex. flag +
Http.CancelAllîn versiunile mai noi Delphi sau prin întreruperea controlată a stream-ului). - Evoluția modelului și a API-ului: Structura răspunsurilor se poate schimba. Păstrați parser-ele defensive și centrale, nu răspândite în codul formularelor.
Debugging în clienți Delphi existenți: Ce ar trebui să înregistrați
În proiectele de integrare, prima punere în funcțiune rar eșuează din cauza JSON-ului, mai degrabă din cauza detaliilor de mediu. Într-un log tehnic (fișier, Eventlog, logger central) ar trebui, în practică, să intre:
- Request-ID (atribuită de dvs.), timestamp, URL țintă (fără query string-uri care conțin secrete).
- Codul de stare HTTP, Content-Type, lungimea răspunsului, durata.
- Un Response-Body trunchiat în caz de erori (de ex. max. 4–8 KB), pentru a putea detecta erori de tip quota/policy.
- Marcarea explicită a încercărilor de retry: număr încercare (Attempt), delay, clasa excepției.
API-Key-ul nu trebuie niciodată să ajungă în log. Dacă înregistrați Request-Body, faceți asta doar în build-uri de diagnostic și cu mascarea datelor, deoarece prompturile pot conține date personale sau informații de business.
Încadrarea pentru situații legacy: VCL, FMX și Layer-3 arhitectură
Multe aplicații Delphi rulează într-o logică clasică cu 3 straturi („Layer-3 arhitectură“: UI, logica de business, date/integrare). Pentru integrarea ChatGPT asta este util: clientul prezentat aparține stratului de integrare; logica de business decide, ce este întrebat; UI afișează doar istoricul și statusul. Astfel evitați ca o schimbare ulterioară (alt furnizor, proxy On-Prem, endpoint-uri noi) să rupă formularele.
Și pentru modernizarea Delphi acesta este un bun punct de pornire: mai întâi un client stabil, apoi îmbunătățiri UI (streaming, anulare, istoric), și abia apoi funcționalități „mai inteligente” precum răspunsuri structurate sau apeluri către tool-uri.
Concluzie: Bază solidă, dar nu orice aplicație are nevoie de streaming
Merită să integrați API-ul ChatGPT cu Delphi FMX/VCL într-un mod curat, în special acolo unde reactivitatea interfeței, fiabilitatea operațională și capacitățile de depanare sunt importante: instrumente de administrare, clienți desktop apropiați de proces sau unelte de suport în soluții digitale pentru întreprinderi. Fragmentul de cod prezentat este intenționat pragmatic: SSE-Streaming fără biblioteci speciale, reîncercări doar pentru erori reale de rețea, JSON analizat defensiv.
Limitele de utilizare: dacă aveți cerințe stricte de conformitate, guvernanță centrală a prompturilor, suport pentru multi-tenancy sau audit-trail-uri detaliate, un „client pe desktop” de obicei nu este suficient. În acest caz, integrarea aparține de regulă unui server controlat (de ex. un serviciu REST propriu), care implementează central politicile, logging-ul și controlul accesului. Pentru multe instalări Delphi clientul prezentat aici este totuși un punct de pornire solid, care poate fi migrat treptat către o arhitectură generală mai curată.
În context profesional, Openai API în Delphi și mecanismele de Retry pentru Http Client Timeout din Delphi joacă, de asemenea, un rol important atunci când integrările, fluxurile de date și evoluția aplicației trebuie să funcționeze coerent.
Discutați proiectul sau o inițiativă de modernizare cu Net-Base.
Nächster Schritt
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
Nu oferim sprijin doar pentru întrebări punctuale, ci și atunci când fragmente de cod sursă, probleme legacy sau idei de portal trebuie transformate într-un proiect robust la nivel de companie.
- Situația curentă, starea țintă și riscurile tehnice sunt evaluate împreună.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.