От темата в списанието към проектната практика
Подходящи страници за услуги и технологии към публикацията
Защо „ChatGPT API mit Delphi FMX/VCL“ на практика не е само POST
Който иска да свърже ChatGPT API mit Delphi FMX/VCL, бързо стига до един прост HTTP-POST. В реални бизнес-софтуерни среди това обаче се проваля на три места: (1) Timeouts и Retries трябва да са детерминистични, защото иначе потребителите ще изпитат „замръзнал“ потребителски интерфейс, (2) Streaming (Server-Sent Events, накратко SSE) често е целесъобразно за добра UX, но при Delphi-нишково изпълнение бързо става склонно към грешки, и (3) JSON не е само „обект“: съобщения за грешки, проблеми с квотите, празни полета или леко променени формати на отговорите трябва да се обработват надеждно.
Следният изходен фрагмент показва подход, който работи еднакво в FMX и VCL: собствен, тестируем клиент, който по избор работи не-поточно или поточно, правилно маршалира актуализациите на UI (т.е. извършва ги чрез синхронизация с главния нишка) и записва информативни логове при грешки. При това е структуриран така, че да се вписва в изградени слоеви структури (например „API-Client“ в интеграционния слой, UI остава тънък).
Архитектурна скица: разкачване на UI, поддържане на клиента тестируем
В Delphi-проекти с дълга история често се среща „HTTP im ButtonClick“. Това работи до първия инцидент. Препоръчително е малък клиент с:
- Конфигурация: Base-URL, API-Key, Modell, Timeouts.
- Транспортен слой: HTTP-Request/Response, Retry, Timeout, Proxy/SSL-Optionen (je nach Betrieb).
- Парсър: 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, който непрекъснато доставя текстови редове. Важно: четем в работен нишка и маршалираме актуализациите на UI в главния нишка.
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: тук 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 бързо стават скъпи:
- Стрийминг без блокиране на интерфейса: HTTP-стриймът се чете на заден план; обновленията на интерфейса се извършват чрез
TThread.Queue(асинхронно в основния поток). Това е в FMX и VCL надеждният стандартен подход. - Повтори при мрежови грешки: При
ENetHTTPClientExceptionсе правят повторни опити с backoff. Това е умишлено просто и може по-късно да се разшири с обработка на статус кодове (429/5xx). - Робустно JSON-парсиране: Вместо слепо кастване на полета, те се проверяват поетапно. Това намалява грешките „Invalid type cast“ при специални отговори.
Условия, капани и варианти
- SSE не е „нормален JSON“: При стрийминг идва поредица от събития, а не един единствен JSON-отговор. Затова четенето ред по ред и разпознаването на
[DONE]са централни. - THTTPClient и проксита/SSL: В административни мрежи TLS-Inspection и изисквания за прокси са реалност. Планирайте
THTTPClient.ProxySettingsи евентуално въпроси със сертификати. За дебъг: винаги логвайте статус код/хедъри (без API-Key). - Стратегия за таймаут:
ResponseTimeoutе при стрийминг деликатен параметър: ако изберете твърде кратък, клиентът ще отреже дълги отговори. В UI-инструменти по-дълъг таймаут и бутон „Отказ“ често са по-подходящи от „кратко и твърдо“. - Прекъсване на нишка: Фрагментът не показва Cancel-Token. За продуктивни инструменти си струва да имате механизъм за прекратяване (напр. флаг +
Http.CancelAllв по-нови Delphi версии или чрез контролиран прекъснат стрийм). - Развитие на моделите и на API-то: Структурата на отговорите може да се различава. Дръжте парсърите дефанзивни и централни, а не разпръснати в кода на формите.
Дебъгване в съществуващи Delphi клиенти: Какво наистина трябва да логвате
В интеграционни проекти първоначалното пускане рядко се проваля заради JSON, а по-скоро заради детайли на средата. В техническо лого (файл, Eventlog, централен логер) на практика трябва да се записва:
- Request-ID (присвоена от вас), времеви печат, целеви URL (без secret query-стрингове).
- HTTP-статус код, Content-Type, дължина на отговора, време за изпълнение.
- Съкратен Response-Body при грешки (напр. макс. 4–8 KB), за да могат да се разпознаят quota-/policy-грешки.
- Ясна маркировка на опитите за повтор: брой опит, забавяне, клас на изключението.
API-Key никога не трябва да влиза в логовете. Ако логвате Request-Body, правете го само в диагностични билдове и с маскиране, тъй като prompts могат да съдържат лични или бизнес-данни.
Поставяне в контекста на наследствени ситуации: VCL, FMX и Layer-3 архитектура
Много Delphi приложения работят по класическа три-слойна логика („Layer-3 архитектура“: UI, бизнес логика, данни/интеграция). За свързването с ChatGPT това е полезно: показаният клиент принадлежи към интеграционния слой; бизнес логиката решава какво да се поиска; UI показва само хронологията и статуса. Така избягвате, че по-късна смяна (друг провайдър, On-Prem-Proxy, нови крайни точки) „разкъса“ формите.
Дори за модернизация на Delphi това е добра отправна точка: първо един стабилен клиент, после подобрения в UI (стрийминг, прекъсване, хронология), и едва след това „по-интелигентни“ функции като структурирани отговори или извиквания на инструменти.
Извод: Солидна основа, но не всяко приложение се нуждае от стрийминг
Интегрирането на ChatGPT API с Delphi във FMX/VCL си заслужава особено там, където реактивността на потребителския интерфейс, надеждността при експлоатация и възможностите за отстраняване на грешки са важни: административни инструменти, десктоп клиенти близо до процеса или инструменти за поддръжка в цифрови корпоративни решения. Показаният фрагмент е съзнателно прагматичен: SSE-стрийминг без специални библиотеки, повторни опити само при реални мрежови грешки, JSON се парсва защитно.
Граници на приложимостта: Ако имате строги изисквания за съответствие, централизирана Prompt-Governance, поддръжка на множество клиенти или детайлни записи за одит, „един клиент на десктопа“ обикновено не е достатъчен. Тогава интеграцията типично трябва да бъде в контролиран сървър (например собствен REST-сървис), който централизирано прилага политики, логиране и контрол на достъпа. За много Delphi инсталации показаният тук клиент обаче е надеждна отправна точка, която може стъпка по стъпка да се преведе в по-чиста цялостна архитектура.
В професионалния контекст важна роля играят и Openai API в Delphi и Delphi — таймаути и повторни опити на HTTP клиента, когато интеграциите, потоците от данни и по-нататъшното развитие трябва да работят заедно безпроблемно.
Nächster Schritt
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
Подпомагаме не само при отделни въпроси, но и когато от фрагменти от изходен код, проблеми с наследени системи или идеи за портал трябва да бъде реализиран надежден корпоративен проект.
- Сегашното състояние, целевото състояние и техническите рискове се оценяват съвместно.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.