├── LLMChat.ico ├── LLMChat.dpr ├── dmResources.pas ├── LICENSE ├── .gitignore ├── README.md ├── LLMChat.dproj ├── LLMChatUI.dfm ├── dmResources.dfm ├── LLMSupport.pas └── LLMChatUI.pas /LLMChat.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pyscripter/ChatLLM/HEAD/LLMChat.ico -------------------------------------------------------------------------------- /LLMChat.dpr: -------------------------------------------------------------------------------- 1 | program LLMChat; 2 | 3 | uses 4 | Vcl.Forms, 5 | LLMChatUI in 'LLMChatUI.pas' {LLMChatForm}, 6 | dmResources in 'dmResources.pas' {Resources: TDataModule}, 7 | Vcl.Themes, 8 | Vcl.Styles, 9 | LLMSupport in 'LLMSupport.pas'; 10 | 11 | {$R *.res} 12 | 13 | begin 14 | Application.Initialize; 15 | Application.MainFormOnTaskbar := True; 16 | TStyleManager.TrySetStyle('Windows11 MineShaft'); 17 | Application.CreateForm(TResources, Resources); 18 | Application.CreateForm(TLLMChatForm, LLMChatForm); 19 | Application.Run; 20 | end. 21 | -------------------------------------------------------------------------------- /dmResources.pas: -------------------------------------------------------------------------------- 1 | unit dmResources; 2 | 3 | interface 4 | 5 | uses 6 | System.SysUtils, System.Classes, Vcl.BaseImageCollection, 7 | SVGIconImageCollection, SynHighlighterGeneral, SynEditCodeFolding, 8 | SynHighlighterPython, SynEditHighlighter, SynHighlighterMulti, 9 | SynHighlighterPas; 10 | 11 | type 12 | TResources = class(TDataModule) 13 | LLMImages: TSVGIconImageCollection; 14 | SynMultiSyn: TSynMultiSyn; 15 | SynPythonSyn: TSynPythonSyn; 16 | SynPasSyn: TSynPasSyn; 17 | procedure DataModuleCreate(Sender: TObject); 18 | private 19 | { Private declarations } 20 | public 21 | { Public declarations } 22 | end; 23 | 24 | var 25 | Resources: TResources; 26 | 27 | implementation 28 | 29 | {%CLASSGROUP 'Vcl.Controls.TControl'} 30 | 31 | {$R *.dfm} 32 | 33 | uses 34 | Vcl.Graphics, 35 | Vcl.Themes; 36 | 37 | procedure TResources.DataModuleCreate(Sender: TObject); 38 | begin 39 | LLMImages.FixedColor := StyleServices.GetSystemColor(clWindowText) 40 | end; 41 | 42 | end. 43 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 pyscripter 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Uncomment these types if you want even more clean repository. But be careful. 2 | # It can make harm to an existing project source. Read explanations below. 3 | # 4 | # Resource files are binaries containing manifest, project icon and version info. 5 | # They can not be viewed as text or compared by diff-tools. Consider replacing them with .rc files. 6 | #*.res 7 | # 8 | # Type library file (binary). In old Delphi versions it should be stored. 9 | # Since Delphi 2009 it is produced from .ridl file and can safely be ignored. 10 | #*.tlb 11 | # 12 | # Diagram Portfolio file. Used by the diagram editor up to Delphi 7. 13 | # Uncomment this if you are not using diagrams or use newer Delphi version. 14 | #*.ddp 15 | # 16 | # Visual LiveBindings file. Added in Delphi XE2. 17 | # Uncomment this if you are not using LiveBindings Designer. 18 | #*.vlb 19 | # 20 | # Deployment Manager configuration file for your project. Added in Delphi XE2. 21 | # Uncomment this if it is not mobile development and you do not use remote debug feature. 22 | #*.deployproj 23 | # 24 | # C++ object files produced when C/C++ Output file generation is configured. 25 | # Uncomment this if you are not using external objects (zlib library for example). 26 | #*.obj 27 | # 28 | 29 | # Delphi compiler-generated binaries (safe to delete) 30 | *.exe 31 | *.dll 32 | *.bpl 33 | *.bpi 34 | *.dcp 35 | *.so 36 | *.apk 37 | *.drc 38 | *.map 39 | *.dres 40 | *.rsm 41 | *.tds 42 | *.dcu 43 | *.lib 44 | *.a 45 | *.o 46 | *.ocx 47 | 48 | # Delphi autogenerated files (duplicated info) 49 | *.cfg 50 | *.hpp 51 | *Resource.rc 52 | 53 | # Delphi local files (user-specific info) 54 | *.local 55 | *.identcache 56 | *.projdata 57 | *.tvsconfig 58 | *.dsk 59 | 60 | # Delphi history and backups 61 | __history/ 62 | __recovery/ 63 | *.~* 64 | 65 | # Castalia statistics file (since XE7 Castalia is distributed with Delphi) 66 | *.stat 67 | 68 | # Boss dependency manager vendor folder https://github.com/HashLoad/boss 69 | modules/ 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChatLLM 2 | 3 | ChatLLM is a simple Delphi application for chatting with Large Language Models (LLMs). Its primary purpose is to act as a coding assistant. 4 | 5 | ## Features 6 | - Supports both cloud based LLM models ([DeepSeek](https://www.deepseek.com/), [ChatGPT](https://openai.com/chatgpt) and [Gemini](https://gemini.google.com/)) and local models using [Ollama](https://github.com/ollama/ollama). 7 | - Supports reasoning models such as OpenAI's *o1-mini* and DeeepSeeks *deepseek-reasoner*. 8 | - Supports both the legacy [completions](https://platform.openai.com/docs/api-reference/completions) and the [chat/completions] (https://platform.openai.com/docs/api-reference/chat) API endpoints. 9 | - The chat is organized around multiple topics. 10 | - Persistent chat history and settings. 11 | - Streamlined user interface. 12 | - Syntax highlighting of code (300 languages supported thanks to [Prism](https://prismjs.com/)). 13 | - Support for Markdown output. 14 | 15 | The application uses standard HTTP client and JSON components from the Delphi RTL and can be easily integrated in other Delphi applications. 16 | 17 | ## Usage 18 | 19 | ### Chat with cloud-based LLM providers 20 | You need to get an API key if you don't have one: 21 | - Deepseek: from https://platform.deepseek.com/api_keys 22 | - Gemini: from https://aistudio.google.com/app/apikey 23 | - OpenAI: from https://platform.openai.com/api-keys 24 | 25 | You also need to top-up your balance. Gemini is free for light usage and Deepseek is very 26 | competitively priced. 27 | 28 | **Settings**: 29 | 30 | ![image](https://github.com/user-attachments/assets/c1b997f8-8279-4677-9ce7-ca6e58d3c142) 31 | 32 | - Endpoint: The base URL for accessing the cloud API 33 | - Model: The model you want to use. Example values: 34 | - Deepseek: deepseek-chat deepseek-reasoner 35 | - OpenAI: gpt4-o, gpt-4o-mini, o1-mini 36 | - Gemini: gemini-1.5-flash, gemini-1.5-pro 37 | - API key: Required 38 | - Temperature: A decimal value between 0 and 2 that controls the randomness of the answers 39 | (the greater the temperature the greater the randomness). Default value: 1.0. 40 | - Timeout: How long you are prepared to wait for an answer. 41 | - Maximum number of response tokens: An integer value that determines the maximum length of the response. 42 | - System prompt: A string providing context to the LLM, e.g. "You are my python coding assistant". 43 | 44 | Suitable defaults are provided for all parameters except the API key which you need to provide. 45 | 46 | ### Chat with local models 47 | 48 | You first need to download the [ollama installer](https://ollama.com/download/OllamaSetup.exe) 49 | and install it. Ollama provides access to a large and rapidly expanding number of 50 | [LLM models](https://ollama.com/library?sort=popular) such as codegemma from Google and 51 | codelllama from Meta. To use a given model you need to install it locally. You do that from a 52 | command prompt by issuing the command: 53 | ``` 54 | ollama pull model_name 55 | ``` 56 | After that you are ready to use the local model in ChatLLM. The settings are similar to those 57 | for cloud-based models, except that there is no need for an API key. The downside of Ollama is 58 | that it may take a long time to get answers, depending on the question, the size of the model 59 | and the power of your CPU and GPU. A fast GPU with a lot of memory, can make Ollama much more 60 | responsive. 61 | 62 | ### Chat topics 63 | 64 | The chat is organized around topics. You can create new topics and back and forth between the 65 | topics using the next/previous buttons on the toolbar. When you save the chat all topics are 66 | soved and then restored when you next start the application. _Questions within a topic are 67 | asked in the context of the previous questions and answers of that topic_. 68 | 69 | ## Screenshots 70 | 71 | ![image](https://github.com/user-attachments/assets/39d825ae-0b72-4f05-9238-79563bec8871) 72 | 73 | ![image](https://github.com/user-attachments/assets/23d842f3-b1ef-4561-92cd-b8554d25e110) 74 | 75 | ## Compilation requirements 76 | 77 | - Delphi 12 (Restriction due to the use of multi-line strings) 78 | - [SpTBXLib](https://github.com/SilverpointDev/sptbxlib) 79 | - [SVGIconImageList](https://github.com/EtheaDev/SVGIconImageList) 80 | - [SynEdit](https://github.com/pyscripter/SynEdit) 81 | - [Markdown Processor](https://github.com/EtheaDev/MarkdownProcessor) 82 | -------------------------------------------------------------------------------- /LLMChat.dproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | True 4 | Application 5 | Debug 6 | VCL 7 | LLMChat.dpr 8 | Win64 9 | {A8ECA907-8892-459A-B41C-285359D6A1B7} 10 | LLMChat 11 | 20.2 12 | 2 13 | 14 | 15 | true 16 | 17 | 18 | true 19 | Base 20 | true 21 | 22 | 23 | true 24 | Base 25 | true 26 | 27 | 28 | true 29 | Cfg_1 30 | true 31 | true 32 | 33 | 34 | true 35 | Base 36 | true 37 | 38 | 39 | true 40 | Cfg_2 41 | true 42 | true 43 | 44 | 45 | LLMChat 46 | "Windows11 Impressive Dark|VCLSTYLE|$(BDSCOMMONDIR)\Styles\Windows11_Impressive_Dark.vsf";"Windows11 Impressive Light|VCLSTYLE|$(BDSCOMMONDIR)\Styles\Windows11_Impressive_Light.vsf";"Windows11 MineShaft|VCLSTYLE|$(BDSCOMMONDIR)\Styles\Windows11_MineShaft.vsf";"Windows11 Modern Dark|VCLSTYLE|$(BDSCOMMONDIR)\Styles\Windows11_Modern_Dark.vsf" 47 | .\$(Platform)\$(Config) 48 | .\$(Platform)\$(Config) 49 | System;Xml;Data;Datasnap;Web;Soap;Vcl;Vcl.Imaging;Vcl.Touch;Vcl.Samples;Vcl.Shell;$(DCC_Namespace) 50 | $(BDS)\bin\delphi_PROJECTICON.ico 51 | 52 | 54 | 55 | true 56 | CompanyName=;FileDescription=LLM Chat application;FileVersion=1.2.0.0;InternalName=;LegalCopyright=;LegalTrademarks=;OriginalFilename=;ProgramID=LLMChat;ProductName=LLMChat;ProductVersion=1.1.0.0;Comments= 57 | 2057 58 | 2 59 | 60 | 61 | none 62 | Debug 63 | Winapi;System.Win;Data.Win;Datasnap.Win;Web.Win;Soap.Win;Xml.Win;$(DCC_Namespace) 64 | LLMChat.ico 65 | $(BDS)\bin\default_app.manifest 66 | true 67 | 1033 68 | 69 | 70 | true 71 | true 72 | DEBUG;$(DCC_Define) 73 | true 74 | true 75 | false 76 | true 77 | true 78 | 79 | 80 | PerMonitorV2 81 | 3 82 | 83 | 84 | 0 85 | RELEASE;$(DCC_Define) 86 | false 87 | 0 88 | 89 | 90 | PerMonitorV2 91 | 92 | 93 | 94 | MainSource 95 | 96 | 97 |
LLMChatForm
98 | dfm 99 |
100 | 101 |
Resources
102 | dfm 103 | TDataModule 104 |
105 | 106 | 107 | Base 108 | 109 | 110 | Cfg_1 111 | Base 112 | 113 | 114 | Cfg_2 115 | Base 116 | 117 |
118 | 119 | Delphi.Personality.12 120 | Application 121 | 122 | 123 | 124 | LLMChat.dpr 125 | 126 | 127 | 128 | 129 | False 130 | True 131 | 132 | 133 | 134 | Winapi;System.Win;System;Data;REST;Xml;Vcl;FMX 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 12 143 | 144 | 145 | 146 | 147 | 148 | 149 | False 150 | 151 | False 152 | copy "$(BDS)\Redist\$(Platform)\WebView2Loader.dll" $(OUTPUTDIR) 153 | False 154 | 155 | 156 | 157 | False 158 | 159 | False 160 | copy "$(BDS)\Redist\$(Platform)\WebView2Loader.dll" $(OUTPUTDIR) 161 | False 162 | 163 |
164 | -------------------------------------------------------------------------------- /LLMChatUI.dfm: -------------------------------------------------------------------------------- 1 | object LLMChatForm: TLLMChatForm 2 | Left = 0 3 | Top = 0 4 | HelpContext = 497 5 | Caption = 'Chat' 6 | ClientHeight = 655 7 | ClientWidth = 852 8 | Color = clBtnFace 9 | Font.Charset = DEFAULT_CHARSET 10 | Font.Color = clWindowText 11 | Font.Height = -12 12 | Font.Name = 'Segoe UI' 13 | Font.Style = [] 14 | StyleElements = [seFont, seBorder] 15 | OnCreate = FormCreate 16 | OnDestroy = FormDestroy 17 | TextHeight = 15 18 | object pnlQuestion: TPanel 19 | Left = 0 20 | Top = 570 21 | Width = 852 22 | Height = 85 23 | Align = alBottom 24 | ParentBackground = False 25 | ParentColor = True 26 | TabOrder = 0 27 | DesignSize = ( 28 | 852 29 | 85) 30 | object sbAsk: TSpeedButton 31 | Left = 815 32 | Top = 6 33 | Width = 32 34 | Height = 32 35 | Action = actAskQuestion 36 | Anchors = [akTop, akRight] 37 | Images = vilImages 38 | Flat = True 39 | end 40 | object aiBusy: TActivityIndicator 41 | Left = 815 42 | Top = 46 43 | Anchors = [akRight, akBottom] 44 | FrameDelay = 150 45 | IndicatorType = aitRotatingSector 46 | end 47 | object synQuestion: TSynEdit 48 | AlignWithMargins = True 49 | Left = 4 50 | Top = 4 51 | Width = 805 52 | Height = 77 53 | Cursor = crDefault 54 | Align = alLeft 55 | Anchors = [akLeft, akTop, akRight, akBottom] 56 | Font.Charset = ANSI_CHARSET 57 | Font.Color = clWindowText 58 | Font.Height = -13 59 | Font.Name = 'Consolas' 60 | Font.Style = [] 61 | Font.Quality = fqClearTypeNatural 62 | PopupMenu = pmAsk 63 | TabOrder = 0 64 | OnEnter = synQuestionEnter 65 | OnKeyDown = synQuestionKeyDown 66 | UseCodeFolding = False 67 | Gutter.Font.Charset = DEFAULT_CHARSET 68 | Gutter.Font.Color = clWindowText 69 | Gutter.Font.Height = -11 70 | Gutter.Font.Name = 'Consolas' 71 | Gutter.Font.Style = [] 72 | Gutter.Font.Quality = fqClearTypeNatural 73 | Gutter.Visible = False 74 | Gutter.Bands = < 75 | item 76 | Kind = gbkMarks 77 | Width = 13 78 | end 79 | item 80 | Kind = gbkLineNumbers 81 | end 82 | item 83 | Kind = gbkFold 84 | end 85 | item 86 | Kind = gbkTrackChanges 87 | end 88 | item 89 | Kind = gbkMargin 90 | Width = 3 91 | end> 92 | HideSelection = True 93 | Highlighter = Resources.SynMultiSyn 94 | RightEdge = 0 95 | ScrollBars = ssVertical 96 | ScrollbarAnnotations = < 97 | item 98 | AnnType = sbaCarets 99 | AnnPos = sbpFullWidth 100 | FullRow = False 101 | end> 102 | VisibleSpecialChars = [] 103 | WordWrap = True 104 | end 105 | end 106 | object Splitter: TSpTBXSplitter 107 | Left = 0 108 | Top = 565 109 | Width = 852 110 | Height = 5 111 | Cursor = crSizeNS 112 | Align = alBottom 113 | ParentColor = False 114 | MinSize = 90 115 | end 116 | object SpTBXDock: TSpTBXDock 117 | Left = 0 118 | Top = 0 119 | Width = 852 120 | Height = 34 121 | AllowDrag = False 122 | DoubleBuffered = True 123 | object SpTBXToolbar: TSpTBXToolbar 124 | Left = 0 125 | Top = 0 126 | CloseButton = False 127 | DockMode = dmCannotFloatOrChangeDocks 128 | DockPos = 0 129 | DragHandleStyle = dhNone 130 | FullSize = True 131 | Images = vilImages 132 | ParentShowHint = False 133 | ShowHint = True 134 | ShrinkMode = tbsmNone 135 | Stretch = True 136 | TabOrder = 0 137 | Customizable = False 138 | object spiNewTopic: TSpTBXItem 139 | Action = actChatNew 140 | end 141 | object spiRemoveTopic: TSpTBXItem 142 | Action = actChatRemove 143 | end 144 | object SpTBXSeparatorItem2: TSpTBXSeparatorItem 145 | end 146 | object spiPreviousTopic: TSpTBXItem 147 | Action = actChatPrevious 148 | end 149 | object spiNextTopic: TSpTBXItem 150 | Action = actChatNext 151 | end 152 | object SpTBXSeparatorItem4: TSpTBXSeparatorItem 153 | end 154 | object spiTitle: TSpTBXItem 155 | Action = actTopicTitle 156 | end 157 | object SpTBXSeparatorItem7: TSpTBXSeparatorItem 158 | end 159 | object spiPrint: TSpTBXItem 160 | Action = actPrint 161 | end 162 | object spiSave: TSpTBXItem 163 | Action = actChatSave 164 | end 165 | object SpTBXSeparatorItem: TSpTBXSeparatorItem 166 | end 167 | object spiCancel: TTBItem 168 | Action = actCancelRequest 169 | end 170 | object SpTBXRightAlignSpacerItem: TSpTBXRightAlignSpacerItem 171 | CustomWidth = 506 172 | end 173 | object SpTBXSubmenuItem1: TSpTBXSubmenuItem 174 | Caption = 'Style' 175 | Hint = 'Select application style' 176 | ImageIndex = 12 177 | ImageName = 'Styles' 178 | object SpTBXSkinGroupItem1: TSpTBXSkinGroupItem 179 | end 180 | end 181 | object spiSettings: TSpTBXSubmenuItem 182 | Caption = 'Settings' 183 | HelpContext = 770 184 | ImageIndex = 8 185 | ImageName = 'Settings' 186 | Options = [tboDropdownArrow] 187 | OnInitPopup = spiSettingsInitPopup 188 | object spiDeepSeek: TSpTBXItem 189 | Caption = 'DeepSeek' 190 | Hint = 'Use DeepSeek' 191 | AutoCheck = True 192 | GroupIndex = 1 193 | ImageIndex = 14 194 | ImageName = 'deepseek' 195 | OnClick = mnProviderClick 196 | OnDrawImage = HighlightCheckedImg 197 | end 198 | object spiGemini: TSpTBXItem 199 | Caption = 'Gemini' 200 | Hint = 'Use Gemini' 201 | AutoCheck = True 202 | GroupIndex = 1 203 | ImageIndex = 15 204 | ImageName = 'gemini' 205 | OnClick = mnProviderClick 206 | OnDrawImage = HighlightCheckedImg 207 | end 208 | object spiOpenai: TSpTBXItem 209 | Caption = 'OpenAI' 210 | Hint = 'Use OpenAI' 211 | AutoCheck = True 212 | Checked = True 213 | GroupIndex = 1 214 | ImageIndex = 16 215 | ImageName = 'openai' 216 | OnClick = mnProviderClick 217 | OnDrawImage = HighlightCheckedImg 218 | end 219 | object spiOllama: TSpTBXItem 220 | Caption = 'Ollama' 221 | Hint = 'Use Ollama' 222 | AutoCheck = True 223 | GroupIndex = 1 224 | ImageIndex = 13 225 | ImageName = 'ollama' 226 | OnClick = mnProviderClick 227 | OnDrawImage = HighlightCheckedImg 228 | end 229 | object SpTBXSeparatorItem6: TSpTBXSeparatorItem 230 | end 231 | object spiEndpoint: TSpTBXEditItem 232 | CustomWidth = 100 233 | EditCaption = 'Endpoint:' 234 | ExtendedAccept = True 235 | OnAcceptText = AcceptSettings 236 | end 237 | object spiModel: TSpTBXEditItem 238 | CustomWidth = 100 239 | EditCaption = 'Model:' 240 | ExtendedAccept = True 241 | OnAcceptText = AcceptSettings 242 | end 243 | object spiApiKey: TSpTBXEditItem 244 | CustomWidth = 300 245 | EditCaption = 'Api key:' 246 | ExtendedAccept = True 247 | PasswordChar = #9679 248 | OnAcceptText = AcceptSettings 249 | end 250 | object SpTBXSeparatorItem1: TSpTBXSeparatorItem 251 | end 252 | object spiTimeout: TSpTBXEditItem 253 | EditCaption = 'Timeout (in seconds):' 254 | ExtendedAccept = True 255 | OnAcceptText = AcceptSettings 256 | end 257 | object spiTemperature: TSpTBXEditItem 258 | EditCaption = 'Temperature:' 259 | OnAcceptText = AcceptSettings 260 | end 261 | object spiMaxTokens: TSpTBXEditItem 262 | EditCaption = 'Maximum number of response tokens:' 263 | ExtendedAccept = True 264 | OnAcceptText = AcceptSettings 265 | end 266 | object spiSystemPrompt: TSpTBXEditItem 267 | EditCaption = 'System prompt:' 268 | ExtendedAccept = True 269 | OnAcceptText = AcceptSettings 270 | end 271 | end 272 | end 273 | end 274 | object EdgeBrowser: TEdgeBrowser 275 | Left = 0 276 | Top = 34 277 | Width = 852 278 | Height = 531 279 | Align = alClient 280 | TabOrder = 3 281 | AllowSingleSignOnUsingOSPrimaryAccount = False 282 | TargetCompatibleBrowserVersion = '117.0.2045.28' 283 | UserDataFolder = '%LOCALAPPDATA%\LLMChat\WebView2' 284 | OnCreateWebViewCompleted = EdgeBrowserCreateWebViewCompleted 285 | OnNavigationCompleted = EdgeBrowserNavigationCompleted 286 | OnWebMessageReceived = EdgeBrowserWebMessageReceived 287 | end 288 | object vilImages: TVirtualImageList 289 | Images = < 290 | item 291 | CollectionIndex = 0 292 | CollectionName = 'UserQuestion' 293 | Name = 'UserQuestion' 294 | end 295 | item 296 | CollectionIndex = 1 297 | CollectionName = 'Assistant' 298 | Name = 'Assistant' 299 | end 300 | item 301 | CollectionIndex = 2 302 | CollectionName = 'ChatPlus' 303 | Name = 'ChatPlus' 304 | end 305 | item 306 | CollectionIndex = 3 307 | CollectionName = 'ChatRemove' 308 | Name = 'ChatRemove' 309 | end 310 | item 311 | CollectionIndex = 4 312 | CollectionName = 'ChatQuestion' 313 | Name = 'ChatQuestion' 314 | end 315 | item 316 | CollectionIndex = 5 317 | CollectionName = 'ChatNext' 318 | Name = 'ChatNext' 319 | end 320 | item 321 | CollectionIndex = 6 322 | CollectionName = 'ChatPrev' 323 | Name = 'ChatPrev' 324 | end 325 | item 326 | CollectionIndex = 7 327 | CollectionName = 'Save' 328 | Name = 'Save' 329 | end 330 | item 331 | CollectionIndex = 8 332 | CollectionName = 'Settings' 333 | Name = 'Settings' 334 | end 335 | item 336 | CollectionIndex = 9 337 | CollectionName = 'Copy' 338 | Name = 'Copy' 339 | end 340 | item 341 | CollectionIndex = 10 342 | CollectionName = 'Title' 343 | Name = 'Title' 344 | end 345 | item 346 | CollectionIndex = 11 347 | CollectionName = 'Cancel' 348 | Name = 'Cancel' 349 | end 350 | item 351 | CollectionIndex = 12 352 | CollectionName = 'Styles' 353 | Name = 'Styles' 354 | end 355 | item 356 | CollectionIndex = 13 357 | CollectionName = 'ollama' 358 | Name = 'ollama' 359 | end 360 | item 361 | CollectionIndex = 14 362 | CollectionName = 'deepseek' 363 | Name = 'deepseek' 364 | end 365 | item 366 | CollectionIndex = 15 367 | CollectionName = 'gemini' 368 | Name = 'gemini' 369 | end 370 | item 371 | CollectionIndex = 16 372 | CollectionName = 'openai' 373 | Name = 'openai' 374 | end 375 | item 376 | CollectionIndex = 17 377 | CollectionName = 'Paste' 378 | Name = 'Paste' 379 | end 380 | item 381 | CollectionIndex = 18 382 | CollectionName = 'Print' 383 | Name = 'Print' 384 | end> 385 | ImageCollection = Resources.LLMImages 386 | Width = 24 387 | Height = 24 388 | Left = 24 389 | Top = 456 390 | end 391 | object ChatActionList: TActionList 392 | Images = vilImages 393 | OnUpdate = ChatActionListUpdate 394 | Left = 32 395 | Top = 392 396 | object actChatSave: TAction 397 | Category = 'Chat' 398 | Caption = 'Save chat' 399 | Hint = 'Save chat history' 400 | ImageIndex = 7 401 | ImageName = 'Save' 402 | OnExecute = actChatSaveExecute 403 | end 404 | object actChatRemove: TAction 405 | Category = 'Chat' 406 | Caption = 'Remove Chat Topic' 407 | Hint = 'Remove current chat topic' 408 | ImageIndex = 3 409 | ImageName = 'ChatRemove' 410 | OnExecute = actChatRemoveExecute 411 | end 412 | object actChatNew: TAction 413 | Category = 'Chat' 414 | Caption = 'New Chat Topic' 415 | Hint = 'Add a new chat topic' 416 | ImageIndex = 2 417 | ImageName = 'ChatPlus' 418 | OnExecute = actChatNewExecute 419 | end 420 | object actChatPrevious: TAction 421 | Category = 'Chat' 422 | Caption = 'Previous Chat Topic' 423 | Hint = 'Show previous chat topic' 424 | ImageIndex = 6 425 | ImageName = 'ChatPrev' 426 | OnExecute = actChatPreviousExecute 427 | end 428 | object actChatNext: TAction 429 | Category = 'Chat' 430 | Caption = 'Next Chat Topic' 431 | Hint = 'Show next chat topic' 432 | ImageIndex = 5 433 | ImageName = 'ChatNext' 434 | OnExecute = actChatNextExecute 435 | end 436 | object actAskQuestion: TAction 437 | Category = 'Chat' 438 | Hint = 'Ask question' 439 | ImageIndex = 4 440 | ImageName = 'ChatQuestion' 441 | OnExecute = actAskQuestionExecute 442 | end 443 | object actTopicTitle: TAction 444 | Category = 'Chat' 445 | Caption = 'Topic Title' 446 | Hint = 'Set the title of the chat topic' 447 | ImageIndex = 10 448 | ImageName = 'Title' 449 | OnExecute = actTopicTitleExecute 450 | end 451 | object actCancelRequest: TAction 452 | Category = 'Chat' 453 | Caption = 'Cancel Request' 454 | Hint = 'Cancel active request' 455 | ImageIndex = 11 456 | ImageName = 'Cancel' 457 | OnExecute = actCancelRequestExecute 458 | end 459 | object actEditCopy: TEditCopy 460 | Category = 'Edit' 461 | Caption = '&Copy' 462 | Hint = 'Copy|Copies the selection and puts it on the Clipboard' 463 | ImageIndex = 9 464 | ImageName = 'Copy' 465 | ShortCut = 16451 466 | end 467 | object actEditPaste: TEditPaste 468 | Category = 'Edit' 469 | Caption = '&Paste' 470 | Hint = 'Paste|Inserts Clipboard contents' 471 | ImageIndex = 17 472 | ImageName = 'Paste' 473 | ShortCut = 16470 474 | end 475 | object actPrint: TAction 476 | Category = 'Chat' 477 | Caption = 'Print' 478 | Hint = 'Print chat topic' 479 | ImageIndex = 18 480 | ImageName = 'Print' 481 | OnExecute = actPrintExecute 482 | end 483 | end 484 | object pmAsk: TSpTBXPopupMenu 485 | Images = vilImages 486 | Left = 24 487 | Top = 104 488 | object mnCopy: TSpTBXItem 489 | Action = actEditCopy 490 | end 491 | object mnPaste: TSpTBXItem 492 | Action = actEditPaste 493 | end 494 | end 495 | end 496 | -------------------------------------------------------------------------------- /dmResources.dfm: -------------------------------------------------------------------------------- 1 | object Resources: TResources 2 | OnCreate = DataModuleCreate 3 | Height = 250 4 | Width = 376 5 | object LLMImages: TSVGIconImageCollection 6 | SVGIconItems = < 7 | item 8 | IconName = 'UserQuestion' 9 | SVGText = 10 | ''#13#10' '#13#10''#13#10 20 | end 21 | item 22 | IconName = 'Assistant' 23 | SVGText = 24 | ''#13#10' '#13#10' '#13#10#13#10' '#13#10'' 33 | end 34 | item 35 | IconName = 'ChatPlus' 36 | SVGText = 37 | ''#13#10' '#13#10''#13#10 44 | end 45 | item 46 | IconName = 'ChatRemove' 47 | SVGText = 48 | ''#13#10' '#13#10''#13#10 57 | end 58 | item 59 | IconName = 'ChatQuestion' 60 | SVGText = 61 | ''#13#10' '#13#10''#13#10 73 | end 74 | item 75 | IconName = 'ChatNext' 76 | SVGText = 77 | ''#13#10' '#13#10' ' + 82 | ' '#13#10''#13#10 84 | end 85 | item 86 | IconName = 'ChatPrev' 87 | SVGText = 88 | ''#13#10' ' + 89 | ' '#13#10' ' + 95 | #13#10'' 96 | end 97 | item 98 | IconName = 'Save' 99 | SVGText = 100 | ''#13#10' '#13#10''#13#10 104 | end 105 | item 106 | IconName = 'Settings' 107 | SVGText = 108 | ''#13#10' '#13#10''#13#10 123 | end 124 | item 125 | IconName = 'Copy' 126 | SVGText = 127 | ''#13#10' '#13#10''#13#10 130 | end 131 | item 132 | IconName = 'Title' 133 | SVGText = 134 | ''#13#10' '#13#10'' 136 | end 137 | item 138 | IconName = 'Cancel' 139 | SVGText = 140 | ''#13#10' ' + 141 | ''#13#10'' 148 | end 149 | item 150 | IconName = 'Styles' 151 | SVGText = 152 | ''#13#10#9''#13 + 155 | #10#9''#13#10#9''#13#10#9''#13#10#9''#13#10#9''#13#10#9''#13#10''#13#10 161 | end 162 | item 163 | IconName = 'ollama' 164 | SVGText = 165 | ''#13#10' '#13#10''#13#10 215 | end 216 | item 217 | IconName = 'deepseek' 218 | SVGText = 219 | ''#13#10' '#13#10''#13#10 251 | end 252 | item 253 | IconName = 'gemini' 254 | SVGText = 255 | ''#13#10' '#13#10''#13#10 258 | end 259 | item 260 | IconName = 'openai' 261 | SVGText = 262 | ''#13#10' '#13#10''#13#10 287 | end 288 | item 289 | IconName = 'Paste' 290 | SVGText = 291 | ''#13#10''#13#10#9''#13#10''#13#10''#13#10 298 | end 299 | item 300 | IconName = 'Print' 301 | SVGText = 302 | ''#13#10#9''#13#10#9''#13#10'' 307 | end> 308 | ApplyFixedColorToRootOnly = True 309 | Left = 15 310 | Top = 15 311 | end 312 | object SynMultiSyn: TSynMultiSyn 313 | Schemes = < 314 | item 315 | StartExpr = '```python' 316 | EndExpr = '```' 317 | Highlighter = SynPythonSyn 318 | MarkerAttri.Background = clNone 319 | SchemeName = 'Python' 320 | end 321 | item 322 | StartExpr = '```delphi' 323 | EndExpr = '```' 324 | Highlighter = SynPasSyn 325 | MarkerAttri.Background = clNone 326 | SchemeName = 'Delphi' 327 | end 328 | item 329 | StartExpr = '```pascal' 330 | EndExpr = '```' 331 | Highlighter = SynPasSyn 332 | MarkerAttri.Background = clNone 333 | SchemeName = 'Pascal' 334 | end 335 | item 336 | StartExpr = '```' 337 | EndExpr = '```' 338 | Highlighter = SynPasSyn 339 | MarkerAttri.Background = clNone 340 | SchemeName = 'Pascal' 341 | end> 342 | Left = 23 343 | Top = 111 344 | end 345 | object SynPythonSyn: TSynPythonSyn 346 | Left = 103 347 | Top = 111 348 | end 349 | object SynPasSyn: TSynPasSyn 350 | Left = 191 351 | Top = 111 352 | end 353 | end 354 | -------------------------------------------------------------------------------- /LLMSupport.pas: -------------------------------------------------------------------------------- 1 | unit LLMSupport; 2 | 3 | interface 4 | 5 | uses 6 | System.Classes, 7 | System.JSON, 8 | System.JSON.Serializers, 9 | System.Net.HttpClient, 10 | System.Net.HttpClientComponent; 11 | 12 | type 13 | TLLMProvider = ( 14 | llmProviderOpenAI, 15 | llmProviderGemini, 16 | llmProviderDeepSeek, 17 | llmProviderOllama); 18 | 19 | TEndpointType = ( 20 | etUnsupported, 21 | etOllamaGenerate, 22 | etOllamaChat, 23 | etOpenAICompletion, 24 | etOpenAIChatCompletion, 25 | etGemini); 26 | 27 | TLLMSettingsValidation = ( 28 | svValid, 29 | svModelEmpty, 30 | svInvalidEndpoint, 31 | svInvalidModel, 32 | svAPIKeyMissing, 33 | svInvalidTemperature); 34 | 35 | TLLMSettings = record 36 | EndPoint: string; 37 | ApiKey: string; 38 | Model: string; 39 | TimeOut: Integer; 40 | MaxTokens: Integer; 41 | Temperature: Single; 42 | SystemPrompt: string; 43 | function Validate: TLLMSettingsValidation; 44 | function IsLocal: Boolean; 45 | function EndpointType: TEndpointType; 46 | end; 47 | 48 | TLLMProviders = record 49 | Provider: TLLMProvider; 50 | DeepSeek: TLLMSettings; 51 | OpenAI: TLLMSettings; 52 | Gemini: TLLMSettings; 53 | Ollama: TLLMSettings; 54 | end; 55 | 56 | TQAItem = record 57 | Prompt: string; 58 | Answer: string; 59 | Reason: string; 60 | constructor Create(const AQuestion, AnAnswer, Reason: string); 61 | end; 62 | 63 | TChatTopic = record 64 | Title: string; 65 | QAItems: TArray; 66 | end; 67 | TChatTopics = TArray; 68 | 69 | TOnLLMResponseEvent = procedure(Sender: TObject; const Prompt, Answer, Reason: string) of object; 70 | TOnLLMErrorEvent = procedure(Sender: TObject; const Error: string) of object; 71 | 72 | TLLMBase = class 73 | private 74 | FHttpClient: TNetHTTPClient; 75 | FHttpResponse: IHTTPResponse; 76 | FSourceStream: TStringStream; 77 | FOnLLMResponse: TOnLLMResponseEvent; 78 | FOnLLMError: TOnLLMErrorEvent; 79 | FLastPrompt: string; 80 | FEndPointType: TEndpointType; 81 | procedure OnRequestError(const Sender: TObject; const AError: string); 82 | procedure OnRequestCompleted(const Sender: TObject; const AResponse: IHTTPResponse); 83 | function GetIsBusy: Boolean; 84 | function GetLLMSettings: TLLMSettings; 85 | protected 86 | FSerializer: TJsonSerializer; 87 | procedure DoResponseCompleted(const AResponse: IHTTPResponse); virtual; 88 | procedure DoResponseCreated(const AResponse: IHTTPResponse); virtual; 89 | procedure DoResponseOK(const Msg, Reason: string); virtual; 90 | function RequestParams(const Prompt: string; const Suffix: string = ''): string; virtual; abstract; 91 | // Gemini support 92 | procedure AddGeminiSystemPrompt(Params: TJSONObject); 93 | function GeminiMessage(const Role, Content: string): TJsonObject; 94 | public 95 | Providers: TLLMProviders; 96 | ActiveTopicIndex: Integer; 97 | ChatTopics: TArray; 98 | function ValidateSettings: TLLMSettingsValidation; virtual; 99 | function ValidationErrMsg(Validation: TLLMSettingsValidation): string; 100 | constructor Create; 101 | destructor Destroy; override; 102 | 103 | procedure Ask(const Prompt: string; const Suffix: string = ''); 104 | procedure CancelRequest; 105 | procedure SaveSettings(const FName: string); 106 | procedure LoadSettrings(const FName: string); 107 | 108 | property Settings: TLLMSettings read GetLLMSettings; 109 | property IsBusy: Boolean read GetIsBusy; 110 | property OnLLMResponse: TOnLLMResponseEvent read FOnLLMResponse write FOnLLMResponse; 111 | property OnLLMError: TOnLLMErrorEvent read FOnLLMError write FOnLLMError; 112 | end; 113 | 114 | TLLMChat = class(TLLMBase) 115 | protected 116 | procedure DoResponseOK(const Msg, Reason: string); override; 117 | function RequestParams(const Prompt: string; const Suffix: string = ''): string; override; 118 | public 119 | ActiveTopicIndex: Integer; 120 | ChatTopics: TArray; 121 | function ValidateSettings: TLLMSettingsValidation; override; 122 | constructor Create; 123 | 124 | function ActiveTopic: TChatTopic; 125 | procedure NextTopic; 126 | procedure PreviousTopic; 127 | procedure ClearTopic; 128 | procedure RemoveTopic; 129 | procedure NewTopic; 130 | 131 | procedure SaveChat(const FName: string); 132 | procedure LoadChat(const FName: string); 133 | end; 134 | 135 | const 136 | DefaultSystemPrompt = 'You are my expert Pascal/Delphi coding assistant.'; 137 | 138 | OpenaiChatSettings: TLLMSettings = ( 139 | EndPoint: 'https://api.openai.com/v1/chat/completions'; 140 | ApiKey: ''; 141 | Model: 'gpt-4o'; 142 | TimeOut: 20000; 143 | MaxTokens: 2000; 144 | Temperature: 1.0; 145 | SystemPrompt: DefaultSystemPrompt); 146 | 147 | GeminiSettings: TLLMSettings = ( 148 | EndPoint: 'https://generativelanguage.googleapis.com/v1beta'; 149 | ApiKey: ''; 150 | Model: 'gemini-1.5-flash'; 151 | TimeOut: 20000; 152 | MaxTokens: 2000; 153 | Temperature: 1.0; 154 | SystemPrompt: DefaultSystemPrompt); 155 | 156 | DeepSeekChatSettings: TLLMSettings = ( 157 | EndPoint: 'https://api.deepseek.com/chat/completions'; 158 | ApiKey: ''; 159 | Model: 'deepseek-chat'; 160 | TimeOut: 20000; 161 | MaxTokens: 3000; 162 | Temperature: 1.0; 163 | SystemPrompt: DefaultSystemPrompt); 164 | 165 | OllamaChatSettings: TLLMSettings = ( 166 | EndPoint: 'http://localhost:11434/api/chat'; 167 | ApiKey: ''; 168 | Model: 'codellama'; 169 | //Model: 'codegema'; 170 | //Model: 'starcoder2'; 171 | //Model: 'stable-code'; 172 | TimeOut: 60000; 173 | MaxTokens: 2000; 174 | Temperature: 1.0; 175 | SystemPrompt: DefaultSystemPrompt); 176 | 177 | implementation 178 | 179 | uses 180 | System.SysUtils, 181 | System.Math, 182 | System.IOUtils; 183 | 184 | resourcestring 185 | sLLMBusy = 'The LLM client is busy'; 186 | sNoResponse = 'No response from the LLM Server'; 187 | sNoAPIKey = 'The LLM API key is missing'; 188 | sNoModel = 'The LLM model has not been set'; 189 | sInvalidTemperature = 'Invalid temperature: It should be a decimal number between 0.0 and 2.0'; 190 | sUnsupportedEndpoint = 'The LLM endpoint is missing or not supported'; 191 | sUnsupportedModel = 'The LLM model is not supported'; 192 | sUnexpectedResponse = 'Unexpected response from the LLM Server'; 193 | 194 | function Obfuscate(const S: string): string; 195 | // Reversible string obfuscation using the ROT13 algorithm 196 | begin 197 | Result := S; 198 | for var I := 1 to Length(S) do 199 | case Ord(S[I]) of 200 | Ord('A')..Ord('M'), Ord('a')..Ord('m'): Result[I] := Chr(Ord(S[I]) + 13); 201 | Ord('N')..Ord('Z'), Ord('n')..Ord('z'): Result[I] := Chr(Ord(S[I]) - 13); 202 | Ord('0')..Ord('4'): Result[I] := Chr(Ord(S[I]) + 5); 203 | Ord('5')..Ord('9'): Result[I] := Chr(Ord(S[I]) - 5); 204 | end; 205 | end; 206 | 207 | { TLLMBase } 208 | 209 | procedure TLLMBase.AddGeminiSystemPrompt(Params: TJSONObject); 210 | begin 211 | if Settings.SystemPrompt <> '' then 212 | begin 213 | var JsonText := TJSONObject.Create; 214 | JsonText.AddPair('text', Settings.SystemPrompt); 215 | 216 | var JsonParts := TJSONObject.Create; 217 | JsonParts.AddPair('parts', JsonText); 218 | 219 | Params.AddPair('system_instruction', JsonParts); 220 | end; 221 | end; 222 | 223 | procedure TLLMBase.Ask(const Prompt: string; const Suffix: string = ''); 224 | var 225 | ErrMsg: string; 226 | Params: string; 227 | begin 228 | if Prompt = '' then Exit; 229 | 230 | if Assigned(FHttpResponse) then 231 | ErrMsg := sLLMBusy 232 | else 233 | begin 234 | var Validation := ValidateSettings; 235 | ErrMsg := ValidationErrMsg(Validation); 236 | end; 237 | 238 | if ErrMsg <> '' then 239 | begin 240 | if Assigned(FOnLLMError) then 241 | FOnLLMError(Self, ErrMsg); 242 | Exit; 243 | end; 244 | 245 | FEndPointType := Settings.EndpointType; 246 | FHttpClient.ConnectionTimeout := Settings.TimeOut; 247 | FHttpClient.ResponseTimeout := Settings.TimeOut * 2; 248 | 249 | FLastPrompt := Prompt; 250 | Params := RequestParams(Prompt, Suffix); 251 | 252 | FSourceStream.Clear; 253 | FSourceStream.WriteString(Params); 254 | FSourceStream.Position := 0; 255 | 256 | FHttpClient.CustHeaders.Clear; 257 | var EndPoint := Settings.EndPoint; 258 | case FEndPointType of 259 | etOpenAICompletion, etOpenAIChatCompletion: 260 | FHttpClient.CustomHeaders['Authorization'] := 'Bearer ' + Settings.ApiKey; 261 | etGemini: 262 | EndPoint := Format('%s/models/%s:generateContent?key=%s', 263 | [Settings.EndPoint, Settings.Model, Settings.ApiKey]); 264 | end; 265 | 266 | FHttpClient.CustomHeaders['Content-Type'] := 'application/json'; 267 | FHttpClient.CustomHeaders['AcceptEncoding'] := 'deflate, gzip;q=1.0, *;q=0.5'; 268 | FHttpResponse := FHttpClient.Post(EndPoint , FSourceStream); 269 | DoResponseCreated(FHttpResponse); 270 | end; 271 | 272 | procedure TLLMBase.CancelRequest; 273 | begin 274 | if Assigned(FHttpResponse) then 275 | FHttpResponse.AsyncResult.Cancel; 276 | end; 277 | 278 | constructor TLLMBase.Create; 279 | begin 280 | inherited; 281 | FHttpClient := TNetHTTPClient.Create(nil); 282 | FHttpClient.OnRequestCompleted := OnRequestCompleted; 283 | FHttpClient.OnRequestError := OnRequestError; 284 | FHttpClient.Asynchronous := True; 285 | 286 | FSourceStream := TStringStream.Create('', TEncoding.UTF8); 287 | 288 | FSerializer := TJsonSerializer.Create; 289 | end; 290 | 291 | destructor TLLMBase.Destroy; 292 | begin 293 | FSerializer.Free; 294 | FSourceStream.Free; 295 | FHttpClient.Free; 296 | inherited; 297 | end; 298 | 299 | procedure TLLMBase.DoResponseCompleted(const AResponse: IHTTPResponse); 300 | begin 301 | // Do nothing 302 | end; 303 | 304 | procedure TLLMBase.DoResponseCreated(const AResponse: IHTTPResponse); 305 | begin 306 | // Do Nothing 307 | end; 308 | 309 | procedure TLLMBase.DoResponseOK(const Msg, Reason: string); 310 | begin 311 | // Do nothing 312 | end; 313 | 314 | function TLLMBase.GeminiMessage(const Role, Content: string): TJSONObject; 315 | begin 316 | Result := TJSONObject.Create; 317 | Result.AddPair('role', Role); 318 | var Parts := TJSONObject.Create; 319 | Parts.AddPair('text', Content); 320 | Result.AddPair('parts', Parts); 321 | end; 322 | 323 | function TLLMBase.GetIsBusy: Boolean; 324 | begin 325 | Result := Assigned(FHttpResponse); 326 | end; 327 | 328 | function TLLMBase.GetLLMSettings: TLLMSettings; 329 | begin 330 | case Providers.Provider of 331 | llmProviderDeepSeek: Result := Providers.DeepSeek; 332 | llmProviderOpenAI: Result := Providers.OpenAI; 333 | llmProviderOllama: Result := Providers.Ollama; 334 | llmProviderGemini: Result := Providers.Gemini; 335 | end; 336 | end; 337 | 338 | procedure TLLMBase.LoadSettrings(const FName: string); 339 | begin 340 | if FileExists(FName) then 341 | begin 342 | FSerializer.Populate(TFile.ReadAllText(FName), Providers); 343 | Providers.DeepSeek.ApiKey := Obfuscate(Providers.DeepSeek.ApiKey); 344 | Providers.OpenAI.ApiKey := Obfuscate(Providers.OpenAI.ApiKey); 345 | Providers.Gemini.ApiKey := Obfuscate(Providers.Gemini.ApiKey); 346 | // backward compatibility 347 | if (Providers.Gemini.EndPoint = '') and (Providers.Gemini.Model = '') then 348 | Providers.Gemini := GeminiSettings; 349 | end; 350 | end; 351 | 352 | procedure TLLMBase.OnRequestCompleted(const Sender: TObject; 353 | const AResponse: IHTTPResponse); 354 | var 355 | ResponseData: TBytes; 356 | ResponseOK: Boolean; 357 | ErrMsg, Msg, Reason: string; 358 | begin 359 | FHttpResponse := nil; 360 | DoResponseCompleted(AResponse); 361 | if AResponse.AsyncResult.IsCancelled then 362 | Exit; 363 | ResponseOK := False; 364 | if AResponse.ContentStream.Size > 0 then 365 | begin 366 | SetLength(ResponseData, AResponse.ContentStream.Size); 367 | AResponse.ContentStream.Read(ResponseData, AResponse.ContentStream.Size); 368 | var JsonResponse := TJSONValue.ParseJSONValue(ResponseData, 0); 369 | try 370 | if not (JsonResponse.TryGetValue('error.message', ErrMsg) 371 | or JsonResponse.TryGetValue('error', ErrMsg)) 372 | then 373 | case FEndPointType of 374 | etOpenAIChatCompletion: 375 | begin 376 | ResponseOK := JsonResponse.TryGetValue('choices[0].message.content', Msg); 377 | // for DeepSeek R1 model (deepseek-reasoner) 378 | JsonResponse.TryGetValue('choices[0].message.reasoning_content', Reason); 379 | end; 380 | etOpenAICompletion: 381 | ResponseOK := JsonResponse.TryGetValue('choices[0].text', Msg); 382 | etOllamaGenerate: 383 | ResponseOK := JsonResponse.TryGetValue('response', Msg); 384 | etOllamaChat: 385 | ResponseOK := JsonResponse.TryGetValue('message.content', Msg); 386 | etGemini: 387 | ResponseOK := JsonResponse.TryGetValue('candidates[0].content.parts[0].text', Msg); 388 | end; 389 | finally 390 | JsonResponse.Free; 391 | end; 392 | end else 393 | ErrMsg := sNoResponse; 394 | 395 | if ResponseOK then 396 | begin 397 | DoResponseOK(Msg, Reason); 398 | if Assigned(FOnLLMResponse) then 399 | FOnLLMResponse(Self, FLastPrompt, Msg, Reason); 400 | end 401 | else 402 | begin 403 | if ErrMsg = '' then 404 | ErrMsg := sUnexpectedResponse; 405 | if Assigned(FOnLLMError) then 406 | FOnLLMError(Self, ErrMsg); 407 | end; 408 | end; 409 | 410 | procedure TLLMBase.OnRequestError(const Sender: TObject; const AError: string); 411 | begin 412 | FHttpResponse := nil; 413 | if Assigned(FOnLLMError) then 414 | FOnLLMError(Self, AError); 415 | end; 416 | 417 | procedure TLLMBase.SaveSettings(const FName: string); 418 | begin 419 | Providers.DeepSeek.ApiKey := Obfuscate(Providers.DeepSeek.ApiKey); 420 | Providers.OpenAI.ApiKey := Obfuscate(Providers.OpenAI.ApiKey); 421 | Providers.Gemini.ApiKey := Obfuscate(Providers.Gemini.ApiKey); 422 | try 423 | TFile.WriteAllText(FName, FSerializer.Serialize(Providers)); 424 | finally 425 | Providers.DeepSeek.ApiKey := Obfuscate(Providers.DeepSeek.ApiKey); 426 | Providers.OpenAI.ApiKey := Obfuscate(Providers.OpenAI.ApiKey); 427 | Providers.Gemini.ApiKey := Obfuscate(Providers.Gemini.ApiKey); 428 | end; 429 | end; 430 | 431 | function TLLMBase.ValidateSettings: TLLMSettingsValidation; 432 | begin 433 | Result := Settings.Validate; 434 | end; 435 | 436 | function TLLMBase.ValidationErrMsg(Validation: TLLMSettingsValidation): string; 437 | begin 438 | case Validation of 439 | svValid: Result := ''; 440 | svModelEmpty: Result := sNoModel; 441 | svInvalidEndpoint: Result := sUnsupportedEndpoint; 442 | svInvalidModel: Result := sUnsupportedModel; 443 | svAPIKeyMissing: Result := sNoAPIKey; 444 | svInvalidTemperature: Result := sInvalidTemperature; 445 | end; 446 | end; 447 | 448 | { TLLMChat } 449 | 450 | function TLLMChat.ActiveTopic: TChatTopic; 451 | begin 452 | Result := ChatTopics[ActiveTopicIndex]; 453 | end; 454 | 455 | procedure TLLMChat.ClearTopic; 456 | begin 457 | ChatTopics[ActiveTopicIndex] := Default(TChatTopic); 458 | end; 459 | 460 | constructor TLLMChat.Create; 461 | begin 462 | inherited; 463 | Providers.Provider := llmProviderOpenAI; 464 | Providers.DeepSeek := DeepSeekChatSettings; 465 | Providers.OpenAI := OpenaiChatSettings; 466 | Providers.Ollama := OllamaChatSettings; 467 | Providers.Gemini := GeminiSettings; 468 | 469 | ChatTopics := [Default(TChatTopic)]; 470 | ActiveTopicIndex := 0; 471 | end; 472 | 473 | procedure TLLMChat.DoResponseOK(const Msg, Reason: string); 474 | begin 475 | ChatTopics[ActiveTopicIndex].QAItems := ActiveTopic.QAItems + [TQAItem.Create(FLastPrompt, Msg, Reason)]; 476 | end; 477 | 478 | procedure TLLMChat.LoadChat(const FName: string); 479 | begin 480 | if FileExists(FName) then 481 | begin 482 | ChatTopics := 483 | FSerializer.Deserialize>( 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 |
355 |
356 | %s 357 | 362 |
363 |
364 |
%3:s
365 |
366 |
367 | 368 | '''; 369 | 370 | SvgIcons = ''' 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 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 | --------------------------------------------------------------------------------