Від теми журналу до практики проєкту
Відповідні сторінки послуг і технічні сторінки до публікації
Warum „ChatGPT API mit Delphi FMX/VCL“ in der Praxis nicht nur ein POST ist
Хто хоче підключити ChatGPT API mit Delphi FMX/VCL, швидко потрапляє на простий HTTP-POST. У реальних бізнес-програмних середовищах це, однак, ламається в трьох місцях: (1) Timeouts und Retries повинні бути детерміністичними, бо інакше користувач отримає «завислий» UI, (2) Streaming (Server-Sent Events, kurz SSE) часто корисний для хорошої UX, але в Delphi-потоках швидко стає схильним до помилок, та (3) JSON — це не просто «об’єкт»: повідомлення про помилки, проблеми з квотами, порожні поля або незначно змінені форми відповіді потрібно обробляти надійно.
Наведений фрагмент коду показує підхід, що однаково працює в FMX і VCL: власний, тестований клієнт, який опціонально працює у режимі nicht-streaming або streaming, коректно маршалить оновлення UI (тобто виконує їх через синхронізацію з головним потоком) і логгирує інформативно у разі помилок. Додатково він спроєктований так, щоб легко вписуватися в існуючі шарові структури (наприклад «API-Client» в шарі інтеграції, UI залишається тонким).
Architektur-Skizze: UI entkoppeln, Client testbar halten
У Delphi-проєктах з великою історією часто зустрічається «HTTP im ButtonClick». Це працює до першого інциденту. Рекомендується невеликий клієнт з такими складовими:
- Konfiguration: Base-URL, API-Key, Modell, Timeouts.
- Transportschicht: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-Optionen (je nach Betrieb).
- Parser: JSON-Decoding, Fehlerobjekte, Ergebnisextraktion.
- UI-Hooks: Callback für Token-/Text-Streaming, aber ohne harte Abhängigkeit auf VCL/FMX Controls.
Таким чином інтеграцію в індивідуальне корпоративне ПЗ можна виконувати акуратно: клієнт легко повторно використовувати в сервісах, десктоп-клієнтах, адмін-інструментах або тестових стендах.
Source-Schnipsel: Delphi-Client mit SSE-Streaming, Timeout/Retry und robustem JSON
Код використовує THTTPClient (System.Net.HttpClient) і свідомо парсить мінімально з допомогою System.JSON. Для SSE читається по рядках і реагують на «data: …». Це не «WebSocket», а HTTP-Response-Stream, що безперервно постачає текстові рядки. Важливо: читання відбувається у Worker-Thread, а оновлення UI маршаляться в Main Thread.
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-токен: тут API-ключ як «Authorization: Bearer …».
// У корпоративних середовищах додатково стежте, щоб ключі не потрапляли до логів.
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));
// Мінімальна структура 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
// Поширений формат: { „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(‚Несподівана JSON-відповідь (не об“єкт).‘, 0, AJsonText);
Root := J as TJSONObject;
Choices := Root.GetValue<TJSONArray>(‚choices‘);
if (Choices = nil) or (Choices.Count = 0) then
raise EChatApiError.Create(‚Несподівана JSON-відповідь: відсутній/пустий 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(‚Несподівана JSON-відповідь: поле 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
// Помилки мережі/TLS/таймауту: просте повторення з експоненційним 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-помилка ‚ + 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
// Старт стрімінгу спеціально асинхронно, щоб 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
// При помилках стрімінгу вміст часто все одно JSON.
TThread.Queue(nil,
procedure
begin
AOnEvent(‚Запуск стримінгу не вдався (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: рядки типу „data: {…}“ або „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-чанку: 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.
Навіщо цей підхід
Код вирішує три типові класи проблем, які у VCL/FMX швидко призводять до значних витрат:
- Streaming ohne UI-Freezes: Der HTTP-Stream wird im Hintergrund gelesen; UI-Updates laufen über
TThread.Queue(asynchron in den Main Thread). Das ist in FMX und VCL der robuste Standardweg. - Повторні спроби при помилках мережі: Bei
ENetHTTPClientExceptionwird mit Backoff erneut versucht. Das ist bewusst simpel und lässt sich später um Statuscodes (429/5xx) erweitern. - Стійкий JSON-розбір: Statt blind Felder zu casten, wird schrittweise geprüft. Das reduziert „Invalid type cast“-Fehler bei Sonderantworten.
Обмеження, підводні камені та варіанти
- SSE ist kein „normales JSON“: Bei Streaming kommt eine Folge von Events, nicht eine einzelne JSON-Antwort. Deshalb ist das zeilenweise Lesen und das Erkennen von
[DONE]zentral. - THTTPClient und Proxies/SSL: In Admin-Netzen sind TLS-Inspection und Proxy-Pflichten real. Planen Sie
THTTPClient.ProxySettingsund ggf. Zertifikatsthemen ein. Debugging: Statuscode/Headers immer mitloggen (ohne API-Key). - Timeout-Strategie:
ResponseTimeoutist bei Streaming heikel: Wenn Sie zu kurz wählen, schneidet der Client lange Antworten ab. In UI-Tools ist ein längerer Timeout und ein „Abbrechen“-Button oft sinnvoller als „kurz und hart“. - Thread-Abbruch: Der Schnipsel zeigt keinen Cancel-Token. Für produktive Tools lohnt sich ein Abbruchmechanismus (z. B. Flag +
Http.CancelAllin neueren Delphi-Versionen oder per kontrolliertem Stream-Abbruch). - Modell- und API-Weiterentwicklung: Die Struktur von Responses kann sich unterscheiden. Halten Sie Parser defensiv und zentral, nicht in Formularcode verteilt.
Налагодження у дорослих Delphi-Clients: що слід реально логувати
У інтеграційних проєктах перший запуск рідко провалюється через JSON, натомість — через деталі середовища. У технічний лог (файл, Eventlog, централізований Logger) на практиці слід записувати:
- Request-ID (selbst vergeben), мітка часу, цільовий URL (ohne Secret-Querystrings).
- HTTP-Statuscode, Content-Type, довжина відповіді, Laufzeit.
- Обрізане Response-Body bei Fehlern (z. B. max. 4–8 KB), um Quota-/Policy-Fehler erkennen zu können.
- Explizite Kennzeichnung von Retry-Versuchen: Attempt, Delay, Exception-Klasse.
Der API-Key gehört nie ins Log. Wenn Sie den Request-Body loggen, dann nur in Diagnose-Builds und mit Maskierung, weil Prompts durchaus personenbezogene oder geschäftliche Inhalte enthalten können.
Контекст для застарілих систем: VCL, FMX und Layer-3 Architektur
Viele Delphi-Anwendungen laufen in einer klassischen 3-Schichten-Logik („Layer-3 архітектура“: UI, Geschäftslogik, Daten/Integration). Für die ChatGPT-Anbindung ist das nützlich: Der gezeigte Client gehört in die Integrationsschicht; die Geschäftslogik entscheidet, was gefragt wird; die UI zeigt nur Verlauf und Status. Damit vermeiden Sie, dass ein späterer Wechsel (anderer Provider, On-Prem-Proxy, neue Endpunkte) die Forms „zerreißt“.
Auch für Delphi-Modernisierung ist das ein guter Einstiegspunkt: Erst einen stabilen Client, dann UI-Verbesserungen (Streaming, Abbrechen, Verlauf), dann erst „intelligentere“ Features wie strukturierte Antworten oder Tool-Aufrufe.
Fazit: Solide Basis, aber nicht jede Anwendung braucht Streaming
Чітке підключення ChatGPT API з Delphi для FMX/VCL особливо виправдане там, де важливі UI-реактивність, надійність експлуатації та можливості відлагодження: інструменти адміністрування, процесно наближені десктоп-клієнти або засоби підтримки в цифрових корпоративних рішеннях. Наведений фрагмент свідомо прагматичний: SSE-стрімінг без спеціальних бібліотек, повторні спроби лише для реальних мережевих помилок, JSON обробляється обережно.
Межі застосування: Якщо вам потрібні суворі вимоги комплаєнсу, централізована Prompt-Governance, багатоклієнтність або детальні аудиторські трейли, «клієнта на десктопі» зазвичай недостатньо. У такому випадку підключення належить винести в контрольований сервер (наприклад, власний REST-Service), який централізовано реалізує політики, логування та контроль доступу. Для багатьох інсталяцій Delphi показаний тут клієнт проте є надійною відправною точкою, яку можна поетапно перевести в більш чисту загальну архітектуру.
У професійному контексті також важливу роль відіграють Openai API у Delphi та механізми Delphi Http Client Timeout Retry, коли інтеграції, потоки даних і подальший розвиток мають працювати узгоджено.
Nächster Schritt
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
Ми підтримуємо не лише в окремих питаннях, а й тоді, коли з уривків вихідного коду, питань, пов’язаних із legacy, або ідей порталу має вирости надійний корпоративний проєкт.
- Поточний стан, цільова архітектура та технічні ризики оцінюються спільно.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.