>(
484 | TFile.ReadAllText(FName, TEncoding.UTF8));
485 | ActiveTopicIndex := High(ChatTopics);
486 | end;
487 | end;
488 |
489 | procedure TLLMChat.NewTopic;
490 | begin
491 | if Length(ActiveTopic.QAItems) = 0 then
492 | Exit;
493 | if Length(ChatTopics[High(ChatTopics)].QAItems) > 0 then
494 | ChatTopics := ChatTopics + [Default(TChatTopic)];
495 | ActiveTopicIndex := High(ChatTopics);
496 | end;
497 |
498 | procedure TLLMChat.NextTopic;
499 | begin
500 | if ActiveTopicIndex < Length(ChatTopics) - 1 then
501 | Inc(ActiveTopicIndex);
502 | end;
503 |
504 | procedure TLLMChat.PreviousTopic;
505 | begin
506 | if ActiveTopicIndex > 0 then
507 | Dec(ActiveTopicIndex);
508 | end;
509 |
510 | function TLLMChat.RequestParams(const Prompt: string; const Suffix: string = ''): string;
511 |
512 | function GeminiParams: string;
513 | begin
514 | var JSON := TJSONObject.Create;
515 | try
516 | // start with the system message
517 | AddGeminiSystemPrompt(JSON);
518 |
519 | // then add the chat history
520 | var Contents := TJSONArray.Create;
521 | for var QAItem in ActiveTopic.QAItems do
522 | begin
523 | Contents.Add(GeminiMessage('user', QAItem.Prompt));
524 | Contents.Add(GeminiMessage('model', QAItem.Answer));
525 | end;
526 | // finally add the new prompt
527 | Contents.Add(GeminiMessage('user', Prompt));
528 | JSON.AddPair('contents', Contents);
529 |
530 | // now add parameters
531 | var GenerationConfig := TJSONObject.Create;
532 | GenerationConfig.AddPair('maxOutputTokens', Settings.MaxTokens);
533 | JSON.AddPair('generationConfig', GenerationConfig);
534 |
535 | Result := JSON.ToJSON;
536 | finally
537 | JSON.Free;
538 | end;
539 | end;
540 |
541 | function NewOpenAIMessage(const Role, Content: string): TJSONObject;
542 | begin
543 | Result := TJSONObject.Create;
544 | if Settings.Model.StartsWith('o') and (Role = 'system') then
545 | // newer OpenAI models do support system messages
546 | Result.AddPair('role', 'user')
547 | else
548 | Result.AddPair('role', Role);
549 | Result.AddPair('content', Content);
550 | end;
551 |
552 | begin
553 | if FEndPointType = etGemini then
554 | Exit(GeminiParams);
555 |
556 | var JSON := TJSONObject.Create;
557 | try
558 | JSON.AddPair('model', Settings.Model);
559 | JSON.AddPair('stream', False);
560 |
561 | case FEndPointType of
562 | etOllamaChat:
563 | begin
564 | var Options := TJSONObject.Create;
565 | Options.AddPair('num_predict', Settings.MaxTokens);
566 | Options.AddPair('temperature', Settings.Temperature);
567 | JSON.AddPair('options', Options);
568 | end;
569 | etOpenAIChatCompletion:
570 | begin
571 | JSON.AddPair('temperature', Settings.Temperature);
572 | // Newer OpenAI models do not support max_tokens
573 | if Settings.Model.StartsWith('o') then
574 | JSON.AddPair('max_completion_tokens', Settings.MaxTokens)
575 | else
576 | JSON.AddPair('max_tokens', Settings.MaxTokens);
577 | end;
578 | end;
579 |
580 | var Messages := TJSONArray.Create;
581 | // start with the system message
582 | if Settings.SystemPrompt <> '' then
583 | Messages.Add(NewOpenAIMessage('system', Settings.SystemPrompt));
584 | // add the history
585 | for var QAItem in ActiveTopic.QAItems do
586 | begin
587 | Messages.Add(NewOpenAIMessage('user', QAItem.Prompt));
588 | Messages.Add(NewOpenAIMessage('assistant', QAItem.Answer));
589 | end;
590 | // finally add the new prompt
591 | Messages.Add(NewOpenAIMessage('user', Prompt));
592 |
593 | JSON.AddPair('messages', Messages);
594 |
595 | Result := JSON.ToJSON;
596 | finally
597 | JSON.Free;
598 | end;
599 | end;
600 |
601 | procedure TLLMChat.RemoveTopic;
602 | begin
603 | Delete(ChatTopics, ActiveTopicIndex, 1);
604 |
605 | if ActiveTopicIndex > High(ChatTopics) then
606 | begin
607 | if ActiveTopicIndex > 0 then
608 | Dec(ActiveTopicIndex)
609 | else
610 | ChatTopics := [Default(TChatTopic)];
611 | end;
612 | end;
613 |
614 | procedure TLLMChat.SaveChat(const FName: string);
615 | begin
616 | TFile.WriteAllText(FName, FSerializer.Serialize(ChatTopics));
617 | end;
618 |
619 | function TLLMChat.ValidateSettings: TLLMSettingsValidation;
620 | begin
621 | Result := Settings.Validate;
622 | if (Result = svValid) and
623 | not (Settings.EndpointType in [etOllamaChat, etGemini, etOpenAIChatCompletion])
624 | then
625 | Result := svInvalidEndpoint;
626 | end;
627 |
628 | { TQAItem }
629 |
630 | constructor TQAItem.Create(const AQuestion, AnAnswer, Reason: string);
631 | begin
632 | Self.Prompt := AQuestion;
633 | Self.Answer := AnAnswer;
634 | Self.Reason := Reason;
635 | end;
636 |
637 | { TLLMSettings }
638 |
639 | function TLLMSettings.EndpointType: TEndpointType;
640 | begin
641 | Result := etUnsupported;
642 | if EndPoint.Contains('googleapis') then
643 | Result := etGemini
644 | else if EndPoint.Contains('openai') or EndPoint.Contains('deepseek') then
645 | begin
646 | if EndPoint.EndsWith('chat/completions') then
647 | Result := etOpenAIChatCompletion
648 | else if EndPoint.EndsWith('/completions') then
649 | Result := etOpenAICompletion;
650 | end
651 | else
652 | begin
653 | if EndPoint.EndsWith('api/generate') then
654 | Result := etOllamaGenerate
655 | else if EndPoint.EndsWith('api/chat') then
656 | Result := etOllamaChat;
657 | end;
658 | end;
659 |
660 | function TLLMSettings.IsLocal: Boolean;
661 | begin
662 | Result := EndPoint.Contains('localhost') or EndPoint.Contains('127.0.0.1');
663 | end;
664 |
665 | function TLLMSettings.Validate: TLLMSettingsValidation;
666 | begin
667 | if Model = '' then
668 | Exit(svModelEmpty);
669 | if not InRange(Temperature, 0.0, 2.0) then Exit(svInvalidTemperature);
670 | case EndpointType of
671 | etUnsupported: Exit(svInvalidEndpoint);
672 | etOpenAICompletion, etOpenAIChatCompletion, etGemini:
673 | if ApiKey = '' then
674 | Exit(svAPIKeyMissing);
675 | end;
676 | Result := svValid;
677 | end;
678 |
679 | end.
680 |
--------------------------------------------------------------------------------
/LLMChatUI.pas:
--------------------------------------------------------------------------------
1 | unit LLMChatUI;
2 |
3 | interface
4 |
5 | uses
6 | Winapi.Windows,
7 | Winapi.Messages,
8 | Winapi.WebView2,
9 | Winapi.ActiveX,
10 | System.UITypes,
11 | System.SysUtils,
12 | System.Variants,
13 | System.Classes,
14 | System.ImageList,
15 | System.Actions,
16 | System.RegularExpressions,
17 | Vcl.Graphics,
18 | Vcl.Controls,
19 | Vcl.Forms,
20 | Vcl.Menus,
21 | Vcl.Dialogs,
22 | Vcl.StdCtrls,
23 | Vcl.ExtCtrls,
24 | Vcl.Buttons,
25 | Vcl.ImgList,
26 | Vcl.VirtualImageList,
27 | Vcl.ComCtrls,
28 | Vcl.WinXPanels,
29 | Vcl.WinXCtrls,
30 | Vcl.ActnList,
31 | Vcl.AppEvnts,
32 | Vcl.StdActns,
33 | Vcl.Edge,
34 | SynEdit,
35 | SynEditHighlighter,
36 | SynHighlighterMulti,
37 | SpTBXSkins,
38 | SpTBXItem,
39 | SpTBXControls,
40 | SpTBXDkPanels,
41 | TB2Dock,
42 | TB2Toolbar,
43 | TB2Item,
44 | SpTBXEditors,
45 | MarkdownProcessor,
46 | LLMSupport;
47 |
48 | type
49 | TLLMChatForm = class(TForm)
50 | pnlQuestion: TPanel;
51 | vilImages: TVirtualImageList;
52 | aiBusy: TActivityIndicator;
53 | ChatActionList: TActionList;
54 | actChatSave: TAction;
55 | sbAsk: TSpeedButton;
56 | SpTBXDock: TSpTBXDock;
57 | SpTBXToolbar: TSpTBXToolbar;
58 | spiSave: TSpTBXItem;
59 | spiSettings: TSpTBXSubmenuItem;
60 | spiApiKey: TSpTBXEditItem;
61 | SpTBXRightAlignSpacerItem: TSpTBXRightAlignSpacerItem;
62 | spiEndpoint: TSpTBXEditItem;
63 | spiModel: TSpTBXEditItem;
64 | SpTBXSeparatorItem1: TSpTBXSeparatorItem;
65 | spiTimeout: TSpTBXEditItem;
66 | spiMaxTokens: TSpTBXEditItem;
67 | spiSystemPrompt: TSpTBXEditItem;
68 | actChatRemove: TAction;
69 | actChatNew: TAction;
70 | actChatPrevious: TAction;
71 | actChatNext: TAction;
72 | spiNextTopic: TSpTBXItem;
73 | spiPreviousTopic: TSpTBXItem;
74 | SpTBXSeparatorItem2: TSpTBXSeparatorItem;
75 | spiNewTopic: TSpTBXItem;
76 | spiRemoveTopic: TSpTBXItem;
77 | actAskQuestion: TAction;
78 | synQuestion: TSynEdit;
79 | Splitter: TSpTBXSplitter;
80 | pmAsk: TSpTBXPopupMenu;
81 | mnCopy: TSpTBXItem;
82 | mnPaste: TSpTBXItem;
83 | actTopicTitle: TAction;
84 | SpTBXSeparatorItem4: TSpTBXSeparatorItem;
85 | spiTitle: TSpTBXItem;
86 | actCancelRequest: TAction;
87 | spiCancel: TTBItem;
88 | SpTBXSeparatorItem6: TSpTBXSeparatorItem;
89 | spiOpenai: TSpTBXItem;
90 | spiOllama: TSpTBXItem;
91 | spiGemini: TSpTBXItem;
92 | SpTBXSeparatorItem7: TSpTBXSeparatorItem;
93 | SpTBXSubmenuItem1: TSpTBXSubmenuItem;
94 | SpTBXSkinGroupItem1: TSpTBXSkinGroupItem;
95 | actEditCopy: TEditCopy;
96 | actEditPaste: TEditPaste;
97 | spiDeepSeek: TSpTBXItem;
98 | spiTemperature: TSpTBXEditItem;
99 | EdgeBrowser: TEdgeBrowser;
100 | actPrint: TAction;
101 | SpTBXSeparatorItem: TSpTBXSeparatorItem;
102 | spiPrint: TSpTBXItem;
103 | procedure actChatSaveExecute(Sender: TObject);
104 | procedure FormDestroy(Sender: TObject);
105 | procedure FormCreate(Sender: TObject);
106 | procedure synQuestionKeyDown(Sender: TObject; var Key: Word; Shift: TShiftState);
107 | procedure AcceptSettings(Sender: TObject; var NewText: string; var
108 | Accept: Boolean);
109 | procedure actAskQuestionExecute(Sender: TObject);
110 | procedure actCancelRequestExecute(Sender: TObject);
111 | procedure actChatNewExecute(Sender: TObject);
112 | procedure actChatNextExecute(Sender: TObject);
113 | procedure actChatPreviousExecute(Sender: TObject);
114 | procedure actChatRemoveExecute(Sender: TObject);
115 | procedure actPrintExecute(Sender: TObject);
116 | procedure actTopicTitleExecute(Sender: TObject);
117 | procedure ChatActionListUpdate(Action: TBasicAction; var Handled: Boolean);
118 | procedure EdgeBrowserCreateWebViewCompleted(Sender: TCustomEdgeBrowser;
119 | AResult: HRESULT);
120 | procedure EdgeBrowserNavigationCompleted(Sender: TCustomEdgeBrowser; IsSuccess:
121 | Boolean; WebErrorStatus: COREWEBVIEW2_WEB_ERROR_STATUS);
122 | procedure EdgeBrowserWebMessageReceived(Sender: TCustomEdgeBrowser; Args:
123 | TWebMessageReceivedEventArgs);
124 | procedure mnProviderClick(Sender: TObject);
125 | procedure HighlightCheckedImg(Sender: TObject; ACanvas: TCanvas; State:
126 | TSpTBXSkinStatesType; const PaintStage: TSpTBXPaintStage; var AImageList:
127 | TCustomImageList; var AImageIndex: Integer; var ARect: TRect; var
128 | PaintDefault: Boolean);
129 | procedure spiSettingsInitPopup(Sender: TObject; PopupView: TTBView);
130 | procedure synQuestionEnter(Sender: TObject);
131 | private
132 | FDefaultLang: string;
133 | FBlockCount: Integer;
134 | FCodeBlocksRE: TRegEx;
135 | FBrowserReady: Boolean;
136 | FMarkdownProcessor: TMarkdownProcessor;
137 | procedure CMStyleChanged(var Message: TMessage); message CM_STYLECHANGED;
138 | procedure ClearConversation;
139 | procedure DisplayActiveChatTopic;
140 | procedure DisplayQA(const Prompt, Answer, Reason: string);
141 | procedure DisplayTopicTitle(Title: string);
142 | procedure LoadBoilerplate;
143 | function MarkdownToHTML(const MD: string): string;
144 | function NavigateToString(Html: string): Boolean;
145 | procedure SetBrowserColorScheme;
146 | procedure SetQuestionTextHint;
147 | procedure StyleForm;
148 | procedure StyleWebPage;
149 | procedure OnLLMResponse(Sender: TObject; const Prompt, Answer, Reason: string);
150 | procedure OnLLMError(Sender: TObject; const Error: string);
151 | public
152 | LLMChat: TLLMChat;
153 | end;
154 |
155 | var
156 | LLMChatForm: TLLMChatForm;
157 |
158 | implementation
159 |
160 | {$R *.dfm}
161 |
162 | uses
163 | System.Math,
164 | System.IOUtils,
165 | System.RegularExpressionsCore,
166 | Vcl.Themes,
167 | Vcl.Clipbrd,
168 | MarkdownUtils,
169 | dmResources,
170 | SynEditMiscProcs,
171 | SynEditKeyCmds;
172 |
173 | resourcestring
174 | SQuestionHintValid = 'Ask me anything';
175 | SQuestionHintInvalid = 'Chat setup incomplete';
176 |
177 |
178 | {$REGION 'HTML templates'}
179 |
180 | const
181 | Boilerplate = '''
182 |
183 |
184 |
185 |
186 |
187 | LLM Chat
188 |
189 |
190 |
191 | %s
192 |
193 |
194 |
195 | %s
196 |
197 | %s
198 |
199 |
200 |
201 |
202 | %s
203 |
204 |
205 | ''';
206 |
207 | MainStyleSheetTemplate = '''
208 |
257 |
258 | ''';
259 | var
260 | MainStyleSheet: string;
261 |
262 | const
263 | QAStyleSheet = '''
264 |
288 |
289 | ''';
290 |
291 | const
292 | CodeStyleSheetTemplate = '''
293 |
347 |
348 | ''';
349 | var
350 | CodeStyleSheet: string;
351 |
352 | const
353 | CodeBlock = '''
354 |
367 |
368 | ''';
369 |
370 | SvgIcons = '''
371 |
372 |
392 | ''';
393 |
394 | JSScripts = '''
395 |
396 |
397 |
464 | ''';
465 |
466 | {$ENDREGION 'HTML templates'}
467 |
468 | {$REGION 'Utility functions'}
469 |
470 | function IsStyleDark: Boolean;
471 | var
472 | LStyle: TCustomStyleServices;
473 | LColor: TColor;
474 | begin
475 | Result := False;
476 | LStyle := TStyleManager.ActiveStyle;
477 | if Assigned(LStyle) then
478 | begin
479 | LColor := LStyle.GetSystemColor(clWindow);
480 | // Check if the background color is dark
481 | Result := (LColor and $FFFFFF) < $808080;
482 | end;
483 | end;
484 |
485 | function RemoveCommonIndentation(const Text: string): string;
486 | var
487 | Trimmed: string;
488 | MinIndent: Integer;
489 | begin
490 | // Split the input text into lines
491 | var Lines := Text.Split([#13#10, #10]);
492 | if Length(Lines) = 0 then
493 | Exit(Text.TrimLeft);
494 |
495 | // Find the minimum indentation (number of leading spaces or tabs)
496 | MinIndent := MaxInt;
497 | for var Line in Lines do
498 | begin
499 | Trimmed := Line.TrimLeft;
500 | if (Trimmed <> '') and ((Line.Length - Trimmed.Length) < MinIndent) then
501 | MinIndent := (Line.Length - Trimmed.Length);
502 | end;
503 |
504 | if MinIndent = 0 then Exit(Text);
505 |
506 | // Remove the common indentation from each line
507 | for var I := Low(Lines) to High(Lines) do
508 | begin
509 | Lines[I] := Copy(Lines[I], MinIndent + 1);
510 | end;
511 |
512 | // Combine the lines back into a single string
513 | Result := string.Join(#13#10, Lines);
514 | end;
515 |
516 | function HTMLEncode(const Str: string): string;
517 | var
518 | Chr: Char;
519 | SB: TStringBuilder;
520 | begin
521 | if Str = '' then Exit('');
522 |
523 | SB := TStringBuilder.Create(Length(Str) * 2); // Initial capacity estimate
524 | try
525 | for Chr in Str do
526 | begin
527 | case Chr of
528 | '&': SB.Append('&');
529 | '"': SB.Append('"');
530 | '<': SB.Append('<');
531 | '>': SB.Append('>');
532 | else SB.Append(Chr);
533 | end;
534 | end;
535 | Result := SB.ToString;
536 | finally
537 | SB.Free;
538 | end;
539 | end;
540 |
541 | {$ENDREGION 'Utility functions'}
542 |
543 | procedure TLLMChatForm.actChatSaveExecute(Sender: TObject);
544 | begin
545 | var Folder := TPath.Combine(TPath.GetCachePath, 'LLMChat');
546 | var FileName := TPath.Combine(Folder, 'Chat history.json');
547 | LLMChat.SaveChat(FileName);
548 | end;
549 |
550 | procedure TLLMChatForm.FormDestroy(Sender: TObject);
551 | begin
552 | var Folder := TPath.Combine(TPath.GetCachePath, 'LLMChat');
553 | var FileName := TPath.Combine(Folder, 'Chat Settings.json');
554 | LLMChat.SaveSettings(FileName);
555 | LLMChat.Free;
556 | FMarkdownProcessor.Free;
557 | end;
558 |
559 | procedure TLLMChatForm.ClearConversation;
560 | begin
561 | FBlockCount := 0;
562 | EdgeBrowser.ExecuteScript('clearQA()')
563 | end;
564 |
565 | procedure TLLMChatForm.CMStyleChanged(var Message: TMessage);
566 | begin
567 | StyleForm;
568 | end;
569 |
570 | procedure TLLMChatForm.DisplayQA(const Prompt, Answer, Reason: string);
571 | const
572 | QAScriptCode = '''
573 | var question = `%s`;
574 | var answer = `%s`;
575 | addQA(question, answer);
576 | Prism.highlightAll();
577 | window.scroll(0,100000);
578 | ''';
579 | ReasonTemplate = '''
580 |
581 | Reasoning
582 | %s
583 |
584 |
585 | ''';
586 | begin
587 | if not FBrowserReady then Exit;
588 |
589 | var PromptHtml := MarkdownToHTML(Prompt);
590 | var AnswerHtml := MarkdownToHTML(Answer);
591 |
592 | if Reason <> '' then
593 | begin
594 | var ReasonHtml := MarkdownToHTML(Reason).Trim;
595 | ReasonHtml := Format(ReasonTemplate, [ReasonHtml]);
596 | AnswerHtml := ReasonHtml + AnswerHtml;
597 | end;
598 | EdgeBrowser.ExecuteScript(Format(QAScriptCode, [PromptHtml, AnswerHtml]));
599 | end;
600 |
601 | procedure TLLMChatForm.DisplayTopicTitle(Title: string);
602 | begin
603 | if Title = '' then
604 | Caption := 'Chat'
605 | else
606 | Caption := 'Chat' + ' - ' + Title;
607 | end;
608 |
609 | procedure TLLMChatForm.DisplayActiveChatTopic;
610 | begin
611 | ClearConversation;
612 | DisplayTopicTitle(LLMChat.ActiveTopic.Title);
613 |
614 | for var QAItem in LLMChat.ActiveTopic.QAItems do
615 | DisplayQA(QAItem.Prompt, QAItem.Answer, QAItem.Reason);
616 |
617 | if SynQuestion.HandleAllocated then
618 | synQuestion.SetFocus;
619 | end;
620 |
621 | procedure TLLMChatForm.FormCreate(Sender: TObject);
622 | const
623 | CodeRegEx = '```(\w+)?\s*\n([\s\S]*?)\n?```';
624 | begin
625 | FDefaultLang := 'pascal';
626 | FCodeBlocksRE := TRegEx.Create(CodeRegEx, [roCompiled]);
627 | FCodeBlocksRE.Study([preJIT]);
628 | FMarkdownProcessor := TMarkdownProcessor.CreateDialect(mdCommonMark);
629 | EdgeBrowser.CreateWebView;
630 |
631 | Resources.SynMultiSyn.Schemes[0].MarkerAttri.Foreground :=
632 | Resources.SynPythonSyn.IdentifierAttri.Foreground;
633 | Resources.SynMultiSyn.Schemes[1].MarkerAttri.Foreground :=
634 | Resources.SynPythonSyn.IdentifierAttri.Foreground;
635 | Resources.SynMultiSyn.Schemes[2].MarkerAttri.Foreground :=
636 | Resources.SynPythonSyn.IdentifierAttri.Foreground;
637 | Resources.SynMultiSyn.Schemes[3].MarkerAttri.Foreground :=
638 | Resources.SynPythonSyn.IdentifierAttri.Foreground;
639 | LLMChat := TLLMChat.Create;
640 | LLMChat.OnLLMError := OnLLMError;
641 | LLMChat.OnLLMResponse := OnLLMResponse;
642 |
643 | // Restore settings and history
644 | var Folder := TPath.Combine(TPath.GetCachePath, 'LLMChat');
645 | var FileName := TPath.Combine(Folder, 'Chat history.json');
646 | try
647 | LLMChat.LoadChat(FileName);
648 | except
649 | ShowMessage('Error in reading history');
650 | DeleteFile(FileName);
651 | end;
652 |
653 | FileName := TPath.Combine(Folder, 'Chat Settings.json');
654 | try
655 | LLMChat.LoadSettrings(FileName);
656 | except
657 | ShowMessage('Error in reading settings');
658 | DeleteFile(FileName);
659 | end;
660 |
661 | SetQuestionTextHint;
662 | SkinManager.SkinsList.Clear;
663 | SpTBXSkinGroupItem1.Recreate;
664 |
665 | StyleForm;
666 | end;
667 |
668 | procedure TLLMChatForm.OnLLMError(Sender: TObject; const Error: string);
669 | begin
670 | MessageDlg(Error, TMsgDlgType.mtError, [TMsgDlgBtn.mbOK], 0);
671 | end;
672 |
673 | procedure TLLMChatForm.OnLLMResponse(Sender: TObject; const Prompt,
674 | Answer, Reason: string);
675 | begin
676 | DisplayQA(Prompt, Answer, Reason);
677 | synQuestion.Clear;
678 | end;
679 |
680 | procedure TLLMChatForm.synQuestionKeyDown(Sender: TObject; var Key: Word; Shift:
681 | TShiftState);
682 | begin
683 | if Key = vkReturn then
684 | begin
685 | if Shift * [ssShift, ssCtrl] <> [] then
686 | synQuestion.ExecuteCommand(ecLineBreak, ' ', nil)
687 | else
688 | actAskQuestion.Execute;
689 | Key := 0;
690 | end;
691 | end;
692 |
693 | procedure TLLMChatForm.AcceptSettings(Sender: TObject; var NewText:
694 | string; var Accept: Boolean);
695 | begin
696 | Accept := False;
697 | try
698 | var Settings := LLMChat.Settings;
699 | if Sender = spiEndpoint then
700 | Settings.EndPoint := NewText
701 | else if Sender = spiModel then
702 | Settings.Model := NewText
703 | else if Sender = spiApiKey then
704 | Settings.ApiKey := NewText
705 | else if Sender = spiTimeout then
706 | Settings.TimeOut := NewText.ToInteger * 1000
707 | else if Sender = spiTemperature then
708 | Settings.Temperature := NewText.ToSingle
709 | else if Sender = spiMaxTokens then
710 | Settings.MaxTokens := NewText.ToInteger
711 | else if Sender = spiSystemPrompt then
712 | Settings.SystemPrompt := NewText;
713 |
714 | case LLMChat.Providers.Provider of
715 | llmProviderOpenAI: LLMChat.Providers.OpenAI := Settings;
716 | llmProviderDeepSeek: LLMChat.Providers.DeepSeek := Settings;
717 | llmProviderGemini: LLMChat.Providers.Gemini := Settings;
718 | llmProviderOllama: LLMChat.Providers.Ollama := Settings;
719 | end;
720 |
721 | Accept := True;
722 | except
723 | on E: Exception do
724 | MessageDlg(E.Message, TMsgDlgType.mtError, [TMsgDlgBtn.mbOK], 0);
725 | end;
726 | if Accept then
727 | SetQuestionTextHint;
728 | end;
729 |
730 | procedure TLLMChatForm.actAskQuestionExecute(Sender: TObject);
731 | begin
732 | if synQuestion.Text = '' then
733 | Exit;
734 | LLMChat.Ask(synQuestion.Text);
735 | end;
736 |
737 | procedure TLLMChatForm.actCancelRequestExecute(Sender: TObject);
738 | begin
739 | LLMChat.CancelRequest;
740 | end;
741 |
742 | procedure TLLMChatForm.actChatNewExecute(Sender: TObject);
743 | begin
744 | LLMChat.NewTopic;
745 | DisplayActiveChatTopic;
746 | end;
747 |
748 | procedure TLLMChatForm.actChatNextExecute(Sender: TObject);
749 | begin
750 | LLMChat.NextTopic;
751 | DisplayActiveChatTopic;
752 | end;
753 |
754 | procedure TLLMChatForm.actChatPreviousExecute(Sender: TObject);
755 | begin
756 | LLMChat.PreviousTopic;
757 | DisplayActiveChatTopic;
758 | end;
759 |
760 | procedure TLLMChatForm.actChatRemoveExecute(Sender: TObject);
761 | begin
762 | LLMChat.RemoveTopic;
763 | DisplayActiveChatTopic;
764 | end;
765 |
766 | procedure TLLMChatForm.actPrintExecute(Sender: TObject);
767 | begin
768 | EdgeBrowser.ExecuteScript('window.print();');
769 | end;
770 |
771 | procedure TLLMChatForm.actTopicTitleExecute(Sender: TObject);
772 | var
773 | Title: string;
774 | begin
775 | Title := LLMChat.ChatTopics[LLMChat.ActiveTopicIndex].Title;
776 | if InputQuery('Topic Title', 'Enter title:', Title) then
777 | LLMChat.ChatTopics[LLMChat.ActiveTopicIndex].Title := Title;
778 | DisplayTopicTitle(Title);
779 | end;
780 |
781 | procedure TLLMChatForm.ChatActionListUpdate(Action: TBasicAction; var Handled:
782 | Boolean);
783 | begin
784 | Handled := True;
785 | actChatNew.Enabled := FBrowserReady and (Length(LLMChat.ActiveTopic.QAItems) > 0);
786 | actChatNext.Enabled := FBrowserReady and (LLMChat.ActiveTopicIndex < High(LLMChat.ChatTopics));
787 | actChatPrevious.Enabled := FBrowserReady and (LLMChat.ActiveTopicIndex > 0);
788 | actAskQuestion.Enabled := FBrowserReady and (LLMChat.ValidateSettings = svValid);
789 |
790 | var IsBusy := LLMChat.IsBusy;
791 | if aiBusy.Animate <> IsBusy then
792 | aiBusy.Animate := IsBusy;
793 | actCancelRequest.Visible := IsBusy;
794 | actCancelRequest.Enabled := IsBusy;
795 | end;
796 |
797 | procedure TLLMChatForm.EdgeBrowserCreateWebViewCompleted(Sender:
798 | TCustomEdgeBrowser; AResult: HRESULT);
799 | // Also called when the Browser is recreated (style change)
800 | begin
801 | if AResult <> S_OK then
802 | ShowMessage('Initialization of the browser failed with error code: ' +
803 | IntToStr(AResult))
804 | else
805 | begin
806 | FBrowserReady := True;
807 | StyleWebPage;
808 | SetBrowserColorScheme;
809 | LoadBoilerplate;
810 | end;
811 | end;
812 |
813 | procedure TLLMChatForm.EdgeBrowserNavigationCompleted(Sender:
814 | TCustomEdgeBrowser; IsSuccess: Boolean; WebErrorStatus:
815 | COREWEBVIEW2_WEB_ERROR_STATUS);
816 | begin
817 | // Called after LoadBoireplate loads the basic Web page
818 | if not IsSuccess then
819 | ShowMessage('Error in loading html in the browser: ' + IntToStr(WebErrorStatus));
820 | DisplayActiveChatTopic;
821 | end;
822 |
823 | procedure TLLMChatForm.EdgeBrowserWebMessageReceived(Sender:
824 | TCustomEdgeBrowser; Args: TWebMessageReceivedEventArgs);
825 | var
826 | ArgsString: PWideChar;
827 | begin
828 | Args.ArgsInterface.TryGetWebMessageAsString(ArgsString);
829 | Clipboard.AsText := ArgsString;
830 | end;
831 |
832 | procedure TLLMChatForm.HighlightCheckedImg(Sender: TObject; ACanvas: TCanvas;
833 | State: TSpTBXSkinStatesType; const PaintStage: TSpTBXPaintStage; var
834 | AImageList: TCustomImageList; var AImageIndex: Integer; var ARect: TRect;
835 | var PaintDefault: Boolean);
836 | begin
837 | if (PaintStage = pstPrePaint) and (Sender as TSpTBXItem).Checked then
838 | begin
839 | ACanvas.Brush.Color := StyleServices.GetSystemColor(clHighlight);
840 | ACanvas.FillRect(ARect);
841 | end;
842 | PaintDefault := True;
843 | end;
844 |
845 | procedure TLLMChatForm.mnProviderClick(Sender: TObject);
846 | begin
847 | if Sender = spiOpenai then
848 | LLMChat.Providers.Provider := llmProviderOpenAI
849 | else if Sender = spiDeepSeek then
850 | LLMChat.Providers.Provider := llmProviderDeepSeek
851 | else if Sender = spiOllama then
852 | LLMChat.Providers.Provider := llmProviderOllama
853 | else if Sender = spiGemini then
854 | LLMChat.Providers.Provider := llmProviderGemini;
855 |
856 | spiSettingsInitPopup(Sender, nil);
857 | SetQuestionTextHint;
858 | end;
859 |
860 | procedure TLLMChatForm.LoadBoilerplate;
861 | // Loads the basic web pages
862 | begin
863 | NavigateToString(Format(Boilerplate,
864 | [MainStyleSheet + CodeStyleSheet + QAStyleSheet,
865 | SvgIcons, '', JSScripts]));
866 | end;
867 |
868 | function TLLMChatForm.NavigateToString(Html: string): Boolean;
869 | begin
870 | if not FBrowserReady then Exit(False);
871 |
872 | EdgeBrowser.NavigateToString(Html);
873 | Result := True;
874 | end;
875 |
876 | function TLLMChatForm.MarkdownToHTML(const MD: string): string;
877 | begin
878 | Result := '';
879 | var Matches := FCodeBlocksRE.Matches(MD);
880 | if Matches.Count > 0 then
881 | begin
882 | var CodeEnd := 1;
883 | for var Match in Matches do
884 | begin
885 | var TextBefore := Copy(MD, CodeEnd, Match.Index - CodeEnd);
886 | if TextBefore <> '' then
887 | Result := Result + FMarkdownProcessor.process(TextBefore);
888 | Inc(FBlockCount);
889 | var Lang := Match.Groups[1].Value;
890 | var Code := RemoveCommonIndentation(Match.Groups[2].Value);
891 | Code := HTMLEncode(Code);
892 |
893 | if Lang = 'delphi' then
894 | Lang := 'pascal';
895 | var LangId := Lang;
896 | if Lang = '' then
897 | begin
898 | Lang := ' ';
899 | LangId := FDefaultLang;
900 | end;
901 | Result := Result + Format(CodeBlock, [Lang, FBlockCount.ToString, LangId, Code]);
902 | CodeEnd := Match.Index + Match.Length;
903 | end;
904 | var TextAfter := Copy(MD, CodeEnd);
905 | if TextAfter <> '' then
906 | Result := Result + FMarkdownProcessor.process(TextAfter);
907 | end
908 | else
909 | Result := FMarkdownProcessor.process(MD);
910 |
911 | if Result.StartsWith('
') then
912 | Delete(Result, 1, 3);
913 | // Escape for JavaScript template strings (within backticks)
914 | Result := Result.Replace('\', '\\');
915 | Result := Result.Replace('$', '\$');
916 | Result := Result.Replace('`', '\`');
917 | end;
918 |
919 | procedure TLLMChatForm.SetBrowserColorScheme;
920 | var
921 | Profile: ICoreWebView2Profile;
922 | Scheme: COREWEBVIEW2_PREFERRED_COLOR_SCHEME;
923 | begin
924 | if IsStyleDark then
925 | Scheme := COREWEBVIEW2_PREFERRED_COLOR_SCHEME_DARK
926 | else
927 | Scheme := COREWEBVIEW2_PREFERRED_COLOR_SCHEME_LIGHT;
928 | (EdgeBrowser.DefaultInterface as ICoreWebView2_13).Get_Profile(Profile);
929 | Profile.Set_PreferredColorScheme(Scheme);
930 | end;
931 |
932 | procedure TLLMChatForm.SetQuestionTextHint;
933 | begin
934 | var Validation := LLMChat.ValidateSettings;
935 |
936 | if Validation = svValid then
937 | synQuestion.TextHint := SQuestionHintValid
938 | else
939 | synQuestion.TextHint := SQuestionHintInvalid + ': ' + LLMChat.ValidationErrMsg(Validation);
940 | end;
941 |
942 | procedure TLLMChatForm.spiSettingsInitPopup(Sender: TObject; PopupView:
943 | TTBView);
944 | begin
945 | case LLMChat.Providers.Provider of
946 | llmProviderDeepSeek: spiDeepSeek.Checked := True;
947 | llmProviderOpenAI: spiOpenai.Checked := True;
948 | llmProviderGemini: spiGemini.Checked := True;
949 | llmProviderOllama: spiOllama.Checked := True;
950 | end;
951 |
952 | var Settings := LLMChat.Settings;
953 | spiEndpoint.Text := Settings.EndPoint;
954 | spiModel.Text := Settings.Model;
955 | spiApiKey.Text := Settings.ApiKey;
956 | spiTimeout.Text := (Settings.TimeOut div 1000).ToString;
957 | spiTemperature.Text := Format('%4.2f', [Settings.Temperature]);
958 | spiMaxTokens.Text := Settings.MaxTokens.ToString;
959 | spiSystemPrompt.Text := Settings.SystemPrompt;
960 | end;
961 |
962 | procedure TLLMChatForm.StyleForm;
963 | begin
964 | Resources.LLMImages.FixedColor := StyleServices.GetSystemColor(clWindowText);
965 | synQuestion.Font.Color := StyleServices.GetSystemColor(clWindowText);
966 | synQuestion.Color := StyleServices.GetSystemColor(clWindow);
967 | {$IF CompilerVersion >= 36}
968 | aiBusy.IndicatorColor := aicCustom;
969 | aiBusy.IndicatorCustomColor := StyleServices.GetSystemColor(clWindowText);
970 | {$ENDIF};
971 | end;
972 |
973 | procedure TLLMChatForm.StyleWebPage;
974 | var
975 | LinkColor: TColor;
976 | CodeHeaderBkg, CodeHeaderFg: string;
977 | ThumbColor, ThumbHoverColor: string;
978 | begin
979 | if IsStyleDark then
980 | begin
981 | LinkColor := TColors.LightBlue;
982 | CodeHeaderBkg := '#2d2d2d';
983 | CodeHeaderFg := '#f4f4f4';
984 | ThumbColor := '#666';
985 | ThumbHoverColor := '#888';
986 | end
987 | else
988 | begin
989 | LinkColor := clBlue;
990 | CodeHeaderBkg := '#f4f4f4';
991 | CodeHeaderFg := '#333';
992 | ThumbColor := '#ccc';
993 | ThumbHoverColor := '#999';
994 | end;
995 |
996 | // Style the main sheet
997 | MainStyleSheet := Format(MainStyleSheetTemplate, [
998 | ColorToHtml(StyleServices.GetSystemColor(clWindow)),
999 | ThumbColor,
1000 | ThumbHoverColor,
1001 | ColorToHtml(StyleServices.GetSystemColor(clWindowText)),
1002 | ColorToHtml(LinkColor)]);
1003 |
1004 | CodeStyleSheet := Format(CodeStyleSheetTemplate,[CodeHeaderBkg, CodeHeaderFg]);
1005 | end;
1006 |
1007 | procedure TLLMChatForm.synQuestionEnter(Sender: TObject);
1008 | begin
1009 | // Spell Checking
1010 | end;
1011 |
1012 | end.
1013 |
--------------------------------------------------------------------------------