Net-Base Žurnāls

15.07.2026

ChatGPT API ar Delphi FMX/VCL: robusta integrācija ar straumēšanu, atkārtotiem mēģinājumiem un korektu JSON parsēšanu

Kā robusti integrēt ChatGPT API ar Delphi FMX/VCL: HTTP klients ar laika noilgumiem un atkārtotiem mēģinājumiem, SSE straumēšana bez UI sasalumiem, kā arī uzticama JSON parsēšana rīku izsaukumiem un kļūmju apstrādei.

15.07.2026

No žurnāla tēmas līdz projektu praksei

Atbilstošas pakalpojumu un tehniskās lapas rakstam

Kāpēc „ChatGPT API ar Delphi FMX/VCL“ praksē nav tikai POST

Kas vēlas ChatGPT API ar Delphi FMX/VCL pieslēgt, ātri nonāk pie vienkārša HTTP-POST. Tomēr reālā biznesa programmatūras vidē tas sagruva trīs vietās: (1) Timeouts un Retries jābūt deterministiskiem, jo citādi lietotāji piedzīvo «iestrēgušu» UI, (2) Streaming (Server-Sent Events, saīsinājumā SSE) bieži ir lietderīgs labai UX, taču Delphi-threaddingā tas ātri kļūst kļūdjutīgs, un (3) JSON nav tikai «viens objekts»: kļūdu ziņojumi, kvotu problēmas, tukši lauki vai viegli mainītas atbildes formas ir jāapstrādā robusti.

Nākamais Source-sīkums parāda pieeju, kas darbojas gan FMX, gan VCL: pašvaldīts, testējams clients, kas pēc izvēles strādā ne-streaming vai streaming režīmā, korekti marshalo UI-atjauninājumus (t.i., izpilda tos caur Main-Thread-sinhronizāciju) un kļūdu gadījumā veic saprotamu logēšanu. Papildus tas ir veidots tā, lai ērti iekļautos esošās slāņu struktūrās (piem., „API-Client“ integrācijas slānī, UI paliek plāns).

Architektur-Skizze: UI entkoppeln, Client testbar halten

Delphi-projektiem ar garu vēsturi bieži sastopams „HTTP im ButtonClick“. Tas darbojas līdz pirmajam incidentam. Ieteicams neliels clients ar:

  • Konfiguration: Base-URL, API-Key, Modell, Timeouts.
  • Transportschicht: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-Optionen (atkarībā no darbības vides).
  • Parser: JSON-Decoding, Fehlerobjekte, Ergebnisextraktion.
  • UI-Hooks: Callback für Token-/Text-Streaming, aber ohne harte Abhängigkeit auf VCL/FMX Controls.

Tādā veidā integrāciju individuālajā uzņēmuma programmatūrā var uzturēt tīru: clients ir atkārtoti izmantojams servisos, desktop-klientos, admin-rīkos vai test-harnessos.

Source-Schnipsel: Delphi-Client mit SSE-Streaming, Timeout/Retry und robustem JSON

Kods izmanto THTTPClient (System.Net.HttpClient) un parsē apzināti tikai minimāli ar System.JSON. SSE gadījumā tiek lasīts pa rindiņām un reaģēts uz „data: …“. Tas nav „WebSocket“, bet gan HTTP-response-stream, kas nepārtraukti piegādā teksta rindas. Svarīgi: lasām darba pavedienā (Worker-Thread) un marshalojam UI-atjauninājumus galvenajā pavedienā (Main Thread).

Delphi
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: šeit API atslēga kā "Authorization: Bearer …".
  // Uz uzņēmuma vidi uzmanīgi nodrošināt, lai atslēgas neiekļūtu žurnālos.
  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));

  // Minimāla messages struktūra (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
      // Biežākā forma: { "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('Neparedzēta JSON-atbilde (nav objekts).', 0, AJsonText);

    Root := J as TJSONObject;
    Choices := Root.GetValue<TJSONArray>('choices');
    if (Choices = nil) or (Choices.Count = 0) then
      raise EChatApiError.Create('Neparedzēta JSON-atbilde: choices trūkst/tukšs.', 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('Neparedzēta JSON-atbilde: message trūkst.', 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
        // Tīkla/TLS/laika pārsnieguma kļūmes: vienkāršs atkārtojums ar 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 kļūda ' + 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 mērķtiecīgi startē asinhroni, lai FMX/VCL netiktu bloķēts.
  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
              // Streaming kļūmju gadījumā saturs bieži vien tomēr ir JSON.
              TThread.Queue(nil,
                procedure
                begin
                  AOnEvent('Streaming uzsākšana neizdevās (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 formāts: rindas kā "data: {...}" vai "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;

                  // JSON fragmenta novērtēšana: 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.

Kāpēc šī pieeja ir noderīga

Kods risina trīs tipiskas problēmu kategorijas, kas VCL/FMX vidē ātri kļūst dārgas:

  • Streaming bez UI iesaldēšanas: HTTP plūsmu lasa fonā; UI atjauninājumi notiek caur TThread.Queue (asinhroni uz galveno pavedienu). FMX un VCL tas ir drošs standarta risinājums.
  • Atkārtotas mēģināšanas tīkla kļūmju gadījumā: Pie ENetHTTPClientException tiek mēģināts vēlreiz ar backoff mehānisma izmantošanu. Tas ir apzināti vienkārši un vēlāk var tikt paplašināts, ņemot vērā statuskodus (429/5xx).
  • Robusta JSON parsēšana: Tā vietā, lai lauciņus akli pārvērstu, tie tiek pārbaudīti soli pa solim. Tas samazina “Invalid type cast” kļūdas īpašu atbilžu gadījumā.

Nosacījumi, slazdi un varianti

  • SSE nav „parasts JSON”: Streaming gadījumā tiek saņemta notikumu secība, nevis viena JSON atbilde. Tāpēc būtiski ir lasīt pa rindām un atpazīt [DONE].
  • THTTPClient un proksi/SSL: Administratīvajos tīklos bieži sastopama TLS inspekcija un obligāti proksi. Ieplānojiet THTTPClient.ProxySettings un, ja nepieciešams, sertifikātu jautājumus. Debugošanai: vienmēr žurnālojiet statuskodu/galvenes (bez API atslēgas).
  • Timeout stratēģija: ResponseTimeout streamingā ir jūtīgs: ja iestatāt pārāk īsu, klients pārgriezīs garas atbildes. UI rīkos biežāk jēgpilnāk ir ilgāks timeout un „Atcelt” poga, nevis „īsais un cietais” variants.
  • Pavediena pārtraukšana: Paraugkodā nav Cancel-Token. Produktīvām rīku versijām atmaksājas pārtraukšanas mehānisms (piem., karoga mainīgais + Http.CancelAll jaunākās Delphi versijās vai ar kontrolētu plūsmas pārtraukšanu).
  • Modeļa un API attīstība: Atbilžu struktūra var mainīties. Turiet parserus defensīvus un centrālus, neizkaisītus pa formu kodu.

Debugēšana esošajos Delphi klientos: Was Sie wirklich loggen sollten

Integrācijas projektos pirmā iedarbināšana reti neizdodas JSON dēļ; biežāk to izraisa vides nianses. Tehniskajā žurnālā (fails, Eventlog, centrālais logger) praksē jāietver:

  • Request-ID (paša piešķirta), laika atzīme, mērķa URL (bez slepenajiem query stringiem).
  • HTTP statuskods, Content-Type, atbildes garums, izpildlaiks.
  • Saīsināts Response-Body kļūdu gadījumā (piem., max. 4–8 KB), lai varētu atpazīt kvotu/politikas kļūdas.
  • Eksplīcīta atkārtoto mēģinājumu atzīmēšana: Attempt, Delay, Exception klase.

API atslēga nekad nedrīkst nonākt žurnālā. Ja žurnālojat Request-Body, tad tikai diagnostikas buildos un ar maskēšanu, jo prompti var saturēt personas datus vai uzņēmuma informāciju.

Novietojums vecās vides situācijām: VCL, FMX und Layer-3 Architektur

Daudzas Delphi lietotnes darbojas klasiskā 3-slāņu loģikā („Layer-3 Architektur“: UI, biznesa loģika, dati/integrācija). ChatGPT savienojumam tas ir noderīgi: parādītais klients pieder integrācijas slānim; biznesa loģika nosaka, ko jautāt; UI tikai attēlo norises vēsturi un statusu. Tas novērš, ka vēlākas izmaiņas (cits pakalpojuma sniedzējs, On-Prem-Proxy, jauni galapunkti) formas „saplēš”.

Arī Delphi modernizācijai tas ir labs sākumpunkts: vispirms stabils klients, pēc tam UI uzlabojumi (straumēšana, Atcelt, vēsture), un tikai pēc tam „inteliģentākas” funkcijas, piemēram strukturētas atbildes vai rīku izsaukumi.

Secinājums: Stabils pamats, bet ne katrai lietojumprogrammai nepieciešama straumēšana

ChatGPT API ar Delphi FMX/VCL tīru integrāciju īpaši atmaksājas tur, kur svarīga lietotāja saskarnes atsaucība, sistēmas stabilitāte un atkļūdošanas iespējas: administrēšanas rīki, procesam tuvie darbvirsmas klienti vai atbalsta rīki digitālajos uzņēmumu risinājumos. Parādītais fragments ir apzināti pragmatisks: SSE straumēšana bez speciālām bibliotēkām, atkārtošana tikai īsteniem tīkla kļūdām, JSON defensīvi parsēts.

Lietošanas ierobežojumi: ja jums nepieciešamas stingras atbilstības prasības, centrāla promptu pārvaldība, vairāku nomnieku atbalsts vai detalizēti audita ieraksti, „klients darbvirsmā“ parasti nepietiek. Tad savienošanai parasti jābūt kontrolētam serverim (piem., īpašs REST-serviss), kas centrāli īsteno politikas, žurnēšanu un piekļuves kontroli. Tomēr daudzām Delphi instalācijām rādītais klients ir noturīgs sākumpunkts, ko pakāpeniski var pārnest uz tīrāku kopējo arhitektūru.

Tehniskajā kontekstā nozīmīgu lomu spēlē arī Openai API in Delphi un Delphi Http Client Timeout Retry, kad integrācijām, datu plūsmām un turpmākajai attīstībai jādarbojas saskaņoti.

Apspriest projektu vai modernizācijas pasākumu ar Net-Base.

Nächster Schritt

Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.

Mēs atbalstām ne tikai atsevišķu jautājumu risināšanā, bet arī tad, kad no avota koda fragmentiem, mantojuma sistēmu jautājumiem vai portāla idejām jāizveido stabils uzņēmuma līmeņa projekts.

  • Esošais stāvoklis, mērķa stāvoklis un tehniskie riski tiek kopīgi vērtēti.
  • REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
  • Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.

Kopīgot ierakstu

Kopīgot šo ierakstu tieši

LinkedIn, X, XING, Facebook, WhatsApp un e-pasts ir uzreiz pieejami. Instagramam saiti un īsu tekstu sagatavosim nekavējoties.

E-pasts

Instagram atveras jaunā cilnē. Saite un īss teksts tiek iepriekš nokopēti starpliktuvē.