├── .dockerignore ├── .gitattributes ├── .gitignore ├── .idea └── .idea.FsOpenAI │ └── .idea │ ├── .gitignore │ ├── indexLayout.xml │ └── vcs.xml ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── Dockerfile ├── ExampleSettings.json ├── FsOpenAI.sln ├── LICENSE ├── codecheck └── decompile.fsx ├── global.json ├── readme.md └── src ├── FsOpenAI.Client ├── App │ ├── App.fs │ ├── ClientHub.fs │ └── Subscription.fs ├── FsOpenAI.Client.fsproj ├── Model │ ├── Auth.fs │ ├── Graph.fs │ ├── IO.fs │ ├── Initialization.fs │ ├── Model.fs │ ├── Submission.fs │ ├── TmpState.fs │ └── Update.fs ├── Properties │ └── launchSettings.json ├── Startup.fs ├── Views │ ├── AssistantMessageView.fs │ ├── AsyncExts.fs │ ├── AuthenticationViews.fs │ ├── Binding.fs │ ├── ChatHistoryView.fs │ ├── ChatSettingsView.fs │ ├── CodeEvalView.fs │ ├── DocView.fs │ ├── FeedbackView.fs │ ├── FooterView.fs │ ├── HeaderView.fs │ ├── MainLayout.fs │ ├── MainSettingsView.fs │ ├── PromptView.fs │ ├── QuestionView.fs │ ├── SearchResultsView.fs │ ├── SidebarView.fs │ ├── SourcesView.fs │ └── UserMessageView.fs └── wwwroot │ ├── app │ └── imgs │ │ ├── Persona.png │ │ ├── favicon.png │ │ └── logo.png │ ├── appsettings.json.template │ ├── css │ ├── index.css │ └── theme-override.css │ ├── favicon.png │ ├── imgs │ └── person.png │ ├── scripts │ └── utils.js │ └── theme-override.css ├── FsOpenAI.CodeEvaluator ├── CodeEval.fs ├── FsOpenAI.CodeEvaluator.fsproj ├── Valdiate.fs └── scripts │ └── Sandbox.fsx ├── FsOpenAI.GenAI ├── AsyncExts.fs ├── Connection.fs ├── Env.fs ├── FsOpenAI.GenAI.fsproj ├── Gen │ ├── ChatUtils.fs │ ├── Completions.fs │ ├── DocQnA.fs │ ├── Endpoints.fs │ ├── GenUtils.fs │ ├── IndexQnA.fs │ ├── Indexes.fs │ ├── Models.fs │ ├── Prompts.fs │ ├── SKernel.fs │ ├── SemanticVectorSearch.fs │ ├── StreamParser.fs │ ├── TemplateParser.fs │ ├── Tokens.fs │ └── WebCompletion.fs ├── Monitoring.fs └── Sessions.fs ├── FsOpenAI.Server ├── .config │ └── dotnet-tools.json ├── BackgroundTasks.fs ├── FsOpenAI.Server.fsproj ├── Index.fs ├── Properties │ └── launchSettings.json ├── Samples.fs ├── ServerHub.fs ├── Startup.fs ├── Templates.fs ├── appsettings.json.template └── wwwroot │ └── app │ ├── AppConfig.json │ └── Templates │ └── default │ ├── DocQuerySkill │ ├── Contextual │ │ ├── config.json │ │ ├── question.txt │ │ └── skprompt.txt │ └── ContextualBrief │ │ ├── config.json │ │ ├── question.txt │ │ └── skprompt.txt │ ├── ExtractionSkill │ └── Concepts │ │ ├── config.json │ │ └── skprompt.txt │ └── Samples.json ├── FsOpenAI.Shared ├── AppConfig.fs ├── Constants.fs ├── FsOpenAI.Shared.fsproj ├── Interactions.CodeEval.fs ├── Interactions.Core.fs ├── Interactions.fs ├── Settings.fs ├── Types.fs └── Utils.fs ├── FsOpenAI.Tasks ├── FsOpenAI.Tasks.fsproj ├── Library.fs ├── deployments │ └── default │ │ ├── client │ │ ├── app │ │ │ └── imgs │ │ │ │ ├── Persona.png │ │ │ │ ├── favicon.png │ │ │ │ └── logo.png │ │ ├── appsettings.json.template │ │ └── theme-override.css │ │ ├── config_default.fsx │ │ ├── indexed_config.fsx │ │ ├── indexed_create.fsx │ │ ├── server │ │ └── appsettings.json.template │ │ └── templates │ │ └── default │ │ ├── DocQuerySkill │ │ ├── Contextual │ │ │ ├── config.json │ │ │ ├── question.txt │ │ │ └── skprompt.txt │ │ └── ContextualBrief │ │ │ ├── config.json │ │ │ ├── question.txt │ │ │ └── skprompt.txt │ │ ├── ExtractionSkill │ │ └── Concepts │ │ │ ├── config.json │ │ │ └── skprompt.txt │ │ └── Samples.json └── scripts │ ├── CopyAppSettings.fsx │ ├── CosmoDB.fsx │ ├── CreateSettings.fsx │ ├── ListOpenAIModels.fsx │ ├── LoadIndex.fsx │ ├── Sandbox.fsx │ ├── ScriptEnv.fsx │ ├── SerializeSettings.fsx │ ├── TemplateParsingSandbox.fsx │ ├── TestOpenAIClient.fsx │ └── packages.fsx └── FsOpenAI.Vision ├── FsOpenAI.Vision.fsproj ├── Image.fs ├── Video.fs └── VisionApi.fs /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/bin 15 | **/charts 16 | **/docker-compose* 17 | **/compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md 26 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.idea/.idea.FsOpenAI/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Rider ignored files 5 | /modules.xml 6 | /projectSettingsUpdater.xml 7 | /.idea.FsOpenAI.iml 8 | /contentModel.xml 9 | # Editor-based HTTP Client requests 10 | /httpRequests/ 11 | # Datasource local storage ignored files 12 | /dataSources/ 13 | /dataSources.local.xml 14 | -------------------------------------------------------------------------------- /.idea/.idea.FsOpenAI/.idea/indexLayout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/.idea.FsOpenAI/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Fast Client - Debug Server", 9 | "type": "coreclr", 10 | "request": "launch", 11 | "preLaunchTask": "build", 12 | "launchSettingsFilePath": "${workspaceFolder}/src/FsOpenAI.Server/Properties/launchSettings.json", 13 | "launchSettingsProfile": "https", 14 | "program": "${workspaceFolder}/src/FsOpenAI.Server/bin/Debug/net9.0/FsOpenAI.Server.dll", 15 | "args": [], 16 | "cwd": "${workspaceFolder}/src/FsOpenAI.Server", 17 | "stopAtEntry": false, 18 | "serverReadyAction": { 19 | "action": "openExternally", 20 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)", 21 | }, 22 | "env": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | }, 25 | "sourceFileMap": { 26 | "/Views": "${workspaceFolder}/Views" 27 | } 28 | }, 29 | { 30 | "name": "UNAUTHENTICATED", 31 | "type": "coreclr", 32 | "request": "launch", 33 | "preLaunchTask": "build UNAUTHENTICATED", 34 | "launchSettingsFilePath": "${workspaceFolder}/src/FsOpenAI.Server/Properties/launchSettings.json", 35 | "launchSettingsProfile": "https", 36 | "program": "${workspaceFolder}/src/FsOpenAI.Server/bin/Debug/net9.0/FsOpenAI.Server.dll", 37 | "args": [], 38 | "cwd": "${workspaceFolder}/src/FsOpenAI.Server", 39 | "stopAtEntry": false, 40 | "serverReadyAction": { 41 | "action": "openExternally", 42 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)", 43 | }, 44 | "env": { 45 | "ASPNETCORE_ENVIRONMENT": "Development" 46 | }, 47 | "sourceFileMap": { 48 | "/Views": "${workspaceFolder}/Views" 49 | } 50 | }, 51 | { 52 | "name": "WASM Debug", 53 | "type": "blazorwasm", 54 | "hosted": true, 55 | "request": "launch", 56 | "preLaunchTask": "build",//this does not seem to work; run build task separately 57 | "launchSettingsFilePath": "${workspaceFolder}/src/FsOpenAI.Server/Properties/launchSettings.json", 58 | "launchSettingsProfile": "https", 59 | "program": "${workspaceFolder}/src/FsOpenAI.Server/bin/Debug/net9.0/FsOpenAI.Server.dll", 60 | "args": [], 61 | "cwd": "${workspaceFolder}/src/FsOpenAI.Server", 62 | "stopAtEntry": false, 63 | "serverReadyAction": { 64 | "action": "openExternally", 65 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)", 66 | }, 67 | "env": { 68 | "ASPNETCORE_ENVIRONMENT": "Development" 69 | }, 70 | "sourceFileMap": { 71 | "/Views": "${workspaceFolder}/Views" 72 | } 73 | }, 74 | { 75 | "name": "WASM UNAUTHENTICATED", 76 | "type": "blazorwasm", 77 | "hosted": true, 78 | "request": "launch", 79 | "preLaunchTask": "build UNAUTHENTICATED", //this does not seem to work; run build task separately 80 | "launchSettingsFilePath": "${workspaceFolder}/src/FsOpenAI.Server/Properties/launchSettings.json", 81 | "launchSettingsProfile": "https", 82 | "program": "${workspaceFolder}/src/FsOpenAI.Server/bin/Debug/net9.0/FsOpenAI.Server.dll", 83 | "args": [], 84 | "cwd": "${workspaceFolder}/src/FsOpenAI.Server", 85 | "stopAtEntry": false, 86 | "serverReadyAction": { 87 | "action": "openExternally", 88 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)", 89 | }, 90 | "env": { 91 | "ASPNETCORE_ENVIRONMENT": "Development" 92 | }, 93 | "sourceFileMap": { 94 | "/Views": "${workspaceFolder}/Views" 95 | } 96 | } 97 | ] 98 | } 99 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "FSharp.inlayHints.typeAnnotations": false, 3 | "FSharp.inlayHints.parameterNames": false, 4 | "FSharp.codeLenses.signature.enabled": false, 5 | "FSharp.codeLenses.references.enabled": false, 6 | "FSharp.enableMSBuildProjectGraph": true, 7 | "FSharp.fsac.parallelReferenceResolution": true, 8 | "editor.inlayHints.enabled": "off", 9 | "FSharp.inlineValues.enabled": false, 10 | "FSharp.fsac.gc.conserveMemory": 0 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "command": "dotnet", 6 | "type": "process", 7 | "args": [ 8 | "build", 9 | "FsOpenAI.sln" 10 | ], 11 | "problemMatcher": [ 12 | "$msCompile" 13 | ], 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | }, 18 | "label": "build", 19 | "detail": "Build the FsOpenAI.sln solution using dotnet build" 20 | }, 21 | { 22 | "command": "dotnet", 23 | "type": "process", 24 | "args": [ 25 | "build", 26 | "FsOpenAI.sln", 27 | "-p:DefineConstants=UNAUTHENTICATED" 28 | ], 29 | "problemMatcher": [ 30 | "$msCompile" 31 | ], 32 | "group": { 33 | "kind": "build", 34 | "isDefault": false 35 | }, 36 | "label": "build UNAUTHENTICATED" 37 | }, 38 | { 39 | "command": "dotnet", 40 | "type": "process", 41 | "args": [ 42 | "publish", 43 | "src/FsOpenAI.Server/FsOpenAI.Server.fsproj", 44 | "-c:Release", 45 | "-o:../appunauth", 46 | "-p:DefineConstants=UNAUTHENTICATED" 47 | ], 48 | "problemMatcher": [ 49 | "$msCompile" 50 | ], 51 | "group": { 52 | "kind": "build", 53 | "isDefault": false 54 | }, 55 | "label": "publish UNAUTHENTICATED" 56 | }, 57 | { 58 | "command": "dotnet", 59 | "type": "process", 60 | "args": [ 61 | "publish", 62 | "src/FsOpenAI.Server/FsOpenAI.Server.fsproj", 63 | "-c:Release", 64 | "-o:../app" 65 | ], 66 | "problemMatcher": [ 67 | "$msCompile" 68 | ], 69 | "group": { 70 | "kind": "build", 71 | "isDefault": false 72 | }, 73 | "label": "publish" 74 | } 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:8.0.404-bookworm-slim AS base 2 | EXPOSE 52037 3 | #EXPOSE 52035 4 | 5 | ENV ASPNETCORE_URLS=http://+:52037 6 | #ENV ASPNETCORE_URLS=https://+:52035 7 | #ENV ASPNETCORE_Kestrel__Certificates__Default__Path=/usr/local/share/ca-certificates/localhost-tm1.pfx 8 | #ENV ASPNETCORE_Kestrel__Certificates__Default__Password=tm1 9 | #need 'docker_extra' folder in the same directory as the Dockerfile 10 | #the contents of this folder are not in the repo 11 | # WORKDIR /usr/local/share/ca-certificates 12 | # COPY ./certs . 13 | # COPY ./docker_extra/localhost-tm1.pfx . 14 | # RUN update-ca-certificates 15 | 16 | FROM base AS prepped 17 | 18 | RUN cd /tmp \ 19 | && apt-get update \ 20 | && apt-get install -y wget \ 21 | && apt-get install -y gnupg \ 22 | && apt-get install -y unzip \ 23 | && apt-get update \ 24 | && wget -O- https://apt.repos.intel.com/intel-gpg-keys/GPG-PUB-KEY-INTEL-SW-PRODUCTS.PUB \ 25 | | gpg --dearmor | tee /usr/share/keyrings/oneapi-archive-keyring.gpg > /dev/null \ 26 | && echo "deb [signed-by=/usr/share/keyrings/oneapi-archive-keyring.gpg] https://apt.repos.intel.com/oneapi all main" \ 27 | | tee /etc/apt/sources.list.d/oneAPI.list \ 28 | && apt-get update \ 29 | && apt install -y intel-basekit \ 30 | && apt-get install -y libsasl2-modules-gssapi-mit \ 31 | && apt-get install -y libsasl2-dev \ 32 | && apt-get install -y krb5-user \ 33 | && apt-get install -y librdkafka-dev \ 34 | && find /opt -name "libiomp5.so" \ 35 | && ldconfig /opt/intel/compilers_and_libraries_2020.0.166/linux/compiler/lib/intel64_lin 36 | # && wget https://databricks-bi-artifacts.s3.us-east-2.amazonaws.com/simbaspark-drivers/odbc/2.8.0/SimbaSparkODBC-2.8.0.1002-Debian-64bit.zip \ 37 | # && unzip SimbaSparkODBC-2.8.0.1002-Debian-64bit.zip \ 38 | # && dpkg -i simbaspark_2.8.0.1002-2_amd64.deb \ 39 | # && apt-get install -y unixodbc-dev 40 | 41 | FROM base AS build 42 | WORKDIR /src 43 | COPY src . 44 | WORKDIR /src/FsOpenAI.Server 45 | RUN dotnet workload update 46 | 47 | # RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \ 48 | # --mount=type=secret,id=nugetconfig \ 49 | # dotnet restore -r linux-x64 50 | # RUN --mount=type=cache,id=nuget,target=/root/.nuget/packages \ 51 | # --mount=type=secret,id=nugetconfig \ 52 | # dotnet publish -c Release -o /app --no-restore 53 | 54 | 55 | RUN dotnet restore -r linux-x64 56 | RUN dotnet publish -c:Release -p:DefineConstants=UNAUTHENTICATED -o:/app --no-restore 57 | 58 | FROM prepped AS final 59 | WORKDIR /app 60 | COPY --from=build /app . 61 | #COPY ./docker_extra . 62 | #ENV ODBCINI=/app/odbc.ini 63 | ENTRYPOINT ["dotnet", "FsOpenAI.Server.dll"] 64 | -------------------------------------------------------------------------------- /ExampleSettings.json: -------------------------------------------------------------------------------- 1 | //multiple endpoints (if supplied) will be used in a random fashion to distribute load for scaling 2 | { 3 | "AZURE_OPENAI_ENDPOINTS": [ 4 | { 5 | "API_KEY": "key1", 6 | "RESOURCE_GROUP": "resoruce group 1", 7 | "API_VERSION": "2023-03-15-preview" 8 | }, 9 | { 10 | "API_KEY": "key2", 11 | "RESOURCE_GROUP": "resoruce group 2", 12 | "API_VERSION": "2023-03-15-preview" 13 | } 14 | ], 15 | 16 | //separate embedding endpoints for better scaling 17 | "EMBEDDING_ENDPOINTS": [ 18 | { 19 | "API_KEY": "key1", 20 | "RESOURCE_GROUP": "resoruce group 1", 21 | "API_VERSION": "2023-03-15-preview" 22 | }, 23 | { 24 | "API_KEY": "key2", 25 | "RESOURCE_GROUP": "resoruce group 2", 26 | "API_VERSION": "2023-03-15-preview" 27 | } 28 | ], 29 | 30 | // OpenAI API key. Can be overridden in the client app settings 31 | "OPENAI_KEY": null, 32 | 33 | //cosmos db connection string for storing persistent sessions and chat logs 34 | "LOG_CONN_STR": null, 35 | 36 | //bing search endpoint for RAG with web search 37 | "BING_ENDPOINT": { 38 | "API_KEY": "search key 1", 39 | "ENDPOINT": "https://api.bing.microsoft.com/" 40 | }, 41 | 42 | //Azure AI Search endpoints for RAG with custom knowledge base 43 | "AZURE_SEARCH_ENDPOINTS": [ 44 | { 45 | "API_KEY": "search key 1", 46 | "ENDPOINT": "https://[search service 1].search.windows.net" 47 | }, 48 | { 49 | "API_KEY": "search key 2", 50 | "ENDPOINT": "https://[search service 2].search.windows.net" 51 | } 52 | ] 53 | } 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 fwaris 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 | 23 | This license does not cover any 3rd party packages used in building the software. 24 | 25 | -------------------------------------------------------------------------------- /codecheck/decompile.fsx: -------------------------------------------------------------------------------- 1 | //need ILSpy tool installed 'dotnet tool install -g ilspycmd' 2 | //decompiles FsOpenAI.*.dll files into equivalent C# code 3 | open System.IO 4 | 5 | let outputDir = 6 | let dir = __SOURCE_DIRECTORY__ + "/../../FsOpenAI.CodeCheck" 7 | Path.GetFullPath(dir) 8 | 9 | if Directory.Exists(outputDir) then 10 | Directory.Delete(outputDir, true) |> ignore 11 | 12 | if not (Directory.Exists(outputDir)) then 13 | Directory.CreateDirectory(outputDir) |> ignore 14 | 15 | let inputDir = 16 | let dir = __SOURCE_DIRECTORY__ + @"/../src/FsOpenAI.Server/bin/Debug/net8.0" 17 | Path.GetFullPath dir 18 | 19 | let fsFiles = Directory.GetFiles(inputDir,"FsOpenAI.*.dll") 20 | 21 | let decompile (fsFile:string) = 22 | printfn $"Converting {fsFile} to C#" 23 | let output = Path.Combine(outputDir, Path.GetFileNameWithoutExtension(fsFile) + ".fs") 24 | let psi = new System.Diagnostics.ProcessStartInfo() 25 | psi.FileName <- "ilspycmd" 26 | psi.Arguments <- $"--disable-updatecheck --nested-directories -p -o {outputDir} {fsFile}" 27 | psi.UseShellExecute <- false 28 | psi.RedirectStandardOutput <- true 29 | let p = System.Diagnostics.Process.Start(psi) 30 | p.WaitForExit() 31 | ;; 32 | fsFiles |> Array.iter decompile 33 | 34 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { "sdk": { "version": "9.0.201", "rollForward": "latestFeature" }} -------------------------------------------------------------------------------- /src/FsOpenAI.Client/App/App.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client 2 | open System.Net.Http 3 | open Microsoft.AspNetCore.Components 4 | open Microsoft.Extensions.Logging 5 | open Microsoft.AspNetCore.Components.Authorization 6 | open Microsoft.AspNetCore.Components.WebAssembly.Authentication 7 | open Microsoft.AspNetCore.SignalR.Client 8 | open Elmish 9 | open Bolero 10 | open Bolero.Html 11 | open Bolero.Remoting.Client 12 | open Bolero.Templating.Client 13 | open FsOpenAI.Client.Views 14 | open FsOpenAI.Shared 15 | 16 | module App = 17 | open Radzen 18 | let router = Router.infer SetPage (fun model -> model.page) 19 | 20 | let view model dispatch = 21 | ecomp model dispatch {attr.empty()} 22 | //MainLayout.view model dispatch //homePage model dispatch 23 | 24 | type MyApp() = 25 | inherit ProgramComponent() 26 | 27 | [] 28 | member val LocalStore : Blazored.LocalStorage.ILocalStorageService = Unchecked.defaultof<_> with get, set 29 | 30 | [] 31 | member val logger:ILoggerProvider = Unchecked.defaultof<_> with get, set 32 | 33 | [] 34 | member val Auth : AuthenticationStateProvider = Unchecked.defaultof<_> with get, set 35 | 36 | [] 37 | member val HttpFac : IHttpClientFactory = Unchecked.defaultof<_> with get, set 38 | 39 | [] 40 | member val TokenProvider : IAccessTokenProvider = Unchecked.defaultof<_> with get, set 41 | 42 | [] 43 | member val NotificationService : NotificationService = Unchecked.defaultof<_> with get, set 44 | 45 | [] 46 | member val DialogService : DialogService = Unchecked.defaultof<_> with get, set 47 | 48 | 49 | member val hubConn = ref None 50 | 51 | member this.Connect() = 52 | task { 53 | let! hc = ClientHub.connection this.TokenProvider this.logger this.NavigationManager 54 | this.hubConn.Value <- Some hc 55 | let clientDispatch msg = this.Dispatch (FromServer msg) 56 | hc.On(C.ClientHub.fromServer,clientDispatch) |> ignore 57 | } 58 | 59 | override this.Program = 60 | 61 | //authentication 62 | let handler = new AuthenticationStateChangedHandler(fun t -> 63 | task { 64 | let! s = t 65 | this.Dispatch (SetAuth (Some s.User)) 66 | } |> ignore) 67 | this.Auth.add_AuthenticationStateChanged(handler) 68 | 69 | let getInitConfig() = 70 | task { 71 | let! v = this.JSRuntime.InvokeAsync("inputValue", [|C.LOAD_CONFIG_ID|]) 72 | let cfg = 73 | try 74 | let str = v |> System.Convert.FromBase64String |> System.Text.Encoding.UTF8.GetString 75 | System.Text.Json.JsonSerializer.Deserialize(str,Utils.serOptions()) 76 | with ex -> 77 | LoadConfig.Default 78 | return cfg 79 | } 80 | 81 | let uparms = 82 | { 83 | localStore = this.LocalStore 84 | notificationService = this.NotificationService 85 | dialogService = this.DialogService 86 | serverConnect = this.Connect 87 | serverDispatch = ClientHub.send this.Dispatch this.hubConn 88 | serverCall = ClientHub.call this.hubConn 89 | navMgr = this.NavigationManager 90 | httpFac = this.HttpFac 91 | getInitConfig = getInitConfig 92 | } 93 | 94 | let update = Update.update uparms 95 | 96 | Program.mkProgram (fun _ -> Model.initModel, Cmd.ofMsg GetInitConfig) update view 97 | |> Program.withSubscription Subscription.asyncMessages 98 | |> Program.withRouter router 99 | #if DEBUG 100 | |> Program.withHotReload 101 | //|> Program.withConsoleTrace 102 | #endif 103 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/App/ClientHub.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client 2 | open System 3 | open System.Text.Json 4 | open System.Text.Json.Serialization 5 | open Microsoft.AspNetCore.Components 6 | open Microsoft.AspNetCore.Components.WebAssembly.Authentication 7 | open Microsoft.Extensions.Logging 8 | open Microsoft.AspNetCore.SignalR.Client 9 | open Microsoft.Extensions.DependencyInjection 10 | open FSharp.Control 11 | open FsOpenAI.Shared 12 | 13 | module ClientHub = 14 | 15 | let configureSer (o:JsonSerializerOptions)= 16 | JsonFSharpOptions.Default() 17 | .WithAllowNullFields(true) 18 | .WithAllowOverride(true) 19 | .AddToJsonSerializerOptions(o) 20 | o 21 | 22 | let getToken (accessTokenProvider:IAccessTokenProvider) () = 23 | task { 24 | if accessTokenProvider = null then 25 | printfn "access token provider not set" 26 | return null 27 | else 28 | let! token = accessTokenProvider.RequestAccessToken() 29 | match token.TryGetToken() with 30 | | true, token -> printfn $"have access token; expires: {token.Expires}"; return token.Value 31 | | _ -> printfn "don't have access token"; return null 32 | } 33 | 34 | let retryPolicy = [| TimeSpan(0,0,5); TimeSpan(0,0,10); TimeSpan(0,0,30); TimeSpan(0,0,30) |] 35 | 36 | //signalr hub connection that can send/receive messages to/from server 37 | let connection 38 | (tokenProvider:IAccessTokenProvider) 39 | (loggerProvider: ILoggerProvider) 40 | (navMgr:NavigationManager) 41 | = 42 | task { 43 | let hubConnection = 44 | HubConnectionBuilder() 45 | .AddJsonProtocol(fun o -> configureSer o.PayloadSerializerOptions |> ignore) 46 | .WithUrl( 47 | navMgr.ToAbsoluteUri(C.ClientHub.urlPath), 48 | fun w -> 49 | w.AccessTokenProvider <- (getToken tokenProvider) 50 | //w.Transports <- Microsoft.AspNetCore.Http.Connections.HttpTransportType.WebSockets 51 | ) 52 | .WithAutomaticReconnect(retryPolicy) 53 | .ConfigureLogging(fun logging -> 54 | logging.AddProvider(loggerProvider) |> ignore 55 | ) 56 | .Build() 57 | do! hubConnection.StartAsync() 58 | return hubConnection 59 | } 60 | 61 | 62 | let reconnect (conn:HubConnection) = 63 | task { 64 | try 65 | do! conn.StopAsync() 66 | do! conn.StartAsync() 67 | printfn "hub reconnected" 68 | with ex -> 69 | printfn $"hub reconnect failed {ex.Message}" 70 | } |> ignore 71 | 72 | let rec private retrySend methodName count (conn:HubConnection) (msg:ClientInitiatedMessages) = 73 | if count < 7 then 74 | async { 75 | printfn $"try resend message {count + 1}" 76 | try 77 | if conn.State = HubConnectionState.Connected then 78 | do! conn.SendAsync(methodName,msg) |> Async.AwaitTask 79 | else 80 | do! Async.Sleep 1000 81 | return! retrySend methodName (count+1) conn msg 82 | with ex -> 83 | do! Async.Sleep 1000 84 | return! retrySend methodName (count+1) conn msg 85 | } 86 | else 87 | async { 88 | printfn $"retry limit reached of {count}" 89 | return () 90 | } 91 | 92 | let private _send invokeMethod clientDispatch (conn:HubConnection) (msg:ClientInitiatedMessages) = 93 | task { 94 | try 95 | if conn.State = HubConnectionState.Connected then 96 | do! conn.SendAsync(invokeMethod,msg) 97 | else 98 | retrySend invokeMethod 0 conn msg |> Async.Start 99 | with ex -> 100 | retrySend invokeMethod 0 conn msg |> Async.Start 101 | clientDispatch (ShowError ex.Message) 102 | } 103 | |> ignore 104 | 105 | let send clientDispatch (conn:Ref) (msg:ClientInitiatedMessages) = 106 | match conn.Value with 107 | | Some conn -> _send C.ClientHub.fromClient clientDispatch conn msg 108 | | None -> printfn "no hub connection" 109 | 110 | let call (conn:Ref) (msg:ClientInitiatedMessages) = 111 | match conn.Value with 112 | | Some conn -> conn.SendAsync(C.ClientHub.fromClient,msg) 113 | | None -> printfn "no hub connection"; Threading.Tasks.Task.CompletedTask 114 | 115 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/App/Subscription.fs: -------------------------------------------------------------------------------- 1 | 2 | namespace FsOpenAI.Client 3 | open System 4 | open Elmish 5 | open System.Threading.Channels 6 | open FSharp.Control 7 | 8 | //pump background-generated messages into the main program message loop via an Elmish subscription 9 | module Subscription = 10 | 11 | let asyncMsgQueue = 12 | let ops = BoundedChannelOptions(1000, 13 | SingleReader = true, 14 | FullMode = BoundedChannelFullMode.DropNewest, 15 | SingleWriter = true) 16 | Channel.CreateBounded(ops) 17 | 18 | let queueReader() = 19 | asyncSeq{ 20 | while true do 21 | let! msg = asyncMsgQueue.Reader.ReadAsync().AsTask() |> Async.AwaitTask 22 | yield msg 23 | } 24 | 25 | let asyncMessages (model:Model) : (SubId * Subscribe) list = 26 | let sub dispatch : IDisposable = 27 | queueReader() 28 | |> AsyncSeq.iter(fun msg -> 29 | try dispatch msg with ex -> printfn "%A" ex.Message) 30 | |> Async.Start 31 | {new IDisposable with member _.Dispose() = ()} 32 | [["asyncMessages"],sub] 33 | 34 | let post msg = asyncMsgQueue.Writer.TryWrite(msg) |> ignore -------------------------------------------------------------------------------- /src/FsOpenAI.Client/FsOpenAI.Client.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net9.0 4 | false 5 | false 6 | Debug;Release;UNAUTHENTICATED 7 | AnyCPU 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Model/Auth.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client 2 | open System 3 | open Elmish 4 | open FSharp.Control 5 | open Microsoft.AspNetCore.Components 6 | open Microsoft.AspNetCore.Components.WebAssembly.Authentication 7 | open System.Security.Claims 8 | open FsOpenAI.Shared 9 | 10 | //manage user authentication 11 | module Auth = 12 | let getEmail (u:ClaimsPrincipal) = 13 | u.Claims 14 | |> Seq.tryFind(fun x -> x.Type="preferred_username") 15 | |> Option.map(_.Value) 16 | |> Option.defaultValue u.Identity.Name 17 | 18 | let initiateLogin() = 19 | async{ 20 | do! Async.Sleep 1000 //post login after delay so user can see flash message 21 | return LoginLogout 22 | } 23 | 24 | let checkAuth model apply = 25 | match model.appConfig.RequireLogin, model.user with 26 | | true,Unauthenticated -> model, Cmd.batch [Cmd.ofMsg (ShowInfo "Authenticating..."); (Cmd.OfAsync.perform initiateLogin () id) ] 27 | | true,Authenticated u when not u.IsAuthorized -> model, Cmd.ofMsg (ShowInfo "User not authorized") 28 | | _,_ -> apply model 29 | 30 | let checkAuthFlip apply model = checkAuth model apply 31 | 32 | let isAuthorized model = 33 | match model.appConfig.RequireLogin, model.user with 34 | | true,Unauthenticated -> false 35 | | true,Authenticated u when not u.IsAuthorized -> false 36 | | _,_ -> true 37 | 38 | //Ultimately takes the user to the login/logout page of AD 39 | let loginLogout (navMgr:NavigationManager) model = 40 | match model.user with 41 | | Unauthenticated -> navMgr.NavigateToLogin("authentication/login") 42 | | Authenticated _ -> navMgr.NavigateToLogout("authentication/logout") 43 | 44 | ///Post authentication processing 45 | let postAuth model (claimsPrincipal:ClaimsPrincipal option) = 46 | match claimsPrincipal with 47 | | None -> {model with user=Unauthenticated}, Cmd.none 48 | | Some p when not p.Identity.IsAuthenticated -> {model with user=Unauthenticated}, Cmd.none 49 | | Some p -> 50 | //printfn $"Claims: %O{p.Identity.Name}" 51 | //p.Identities |> Seq.iter(fun x -> printfn $"{x.Name}") 52 | //p.Claims |> Seq.iter(fun x -> printfn $"{x.Type}={x.Value}") 53 | let claims = 54 | p.Claims 55 | |> Seq.tryFind(fun x ->x.Type="roles") 56 | |> Option.map(fun x->Text.Json.JsonSerializer.Deserialize(x.Value)) 57 | |> Option.defaultValue [] 58 | |> set 59 | let roles = model.appConfig.Roles |> set 60 | let userRoles = Set.intersect claims roles 61 | let hasAuth = model.appConfig.Roles.IsEmpty || not userRoles.IsEmpty 62 | let email = getEmail p 63 | let user = {Name=p.Identity.Name; IsAuthorized=hasAuth; Principal=p; Roles=userRoles; Email=email} 64 | let model = 65 | {model with 66 | user = UserState.Authenticated user 67 | busy = hasAuth && Model.isChatPeristenceConfigured model 68 | } 69 | let cmds = 70 | Cmd.batch 71 | [ 72 | if not hasAuth then 73 | Cmd.ofMsg (ShowError "User not authorized") 74 | else 75 | Cmd.ofMsg StartInit 76 | Cmd.ofMsg GetUserDetails 77 | ] 78 | model,cmds 79 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Model/Graph.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Graph 2 | open System 3 | open System.Net.Http 4 | open Microsoft.AspNetCore.Components 5 | open Microsoft.AspNetCore.Components.WebAssembly.Authentication 6 | 7 | type GraphAPIAuthorizationMessageHandler(p,n) as this = 8 | inherit AuthorizationMessageHandler(p,n) 9 | do 10 | this.ConfigureHandler( 11 | authorizedUrls = [|"https://graph.microsoft.com"|], 12 | scopes = [| "https://graph.microsoft.com/User.Read"|] 13 | ) 14 | |> ignore 15 | 16 | module Api = 17 | open System.Net.Http.Headers 18 | let CLIENT_ID = "GraphAPI" 19 | 20 | let configure (client:HttpClient) = 21 | client.BaseAddress <- new Uri("https://graph.microsoft.com") 22 | 23 | let getDetails (user,httpFac:IHttpClientFactory) = 24 | task { 25 | let client = httpFac.CreateClient(CLIENT_ID) 26 | let! ph = client.GetAsync("v1.0/me/photo/$value") 27 | let! photo = 28 | if ph.IsSuccessStatusCode then 29 | task { 30 | let! bytes = ph.Content.ReadAsByteArrayAsync() 31 | let str = bytes |> Convert.ToBase64String 32 | let str = $"data:image/png;base64,{str}" 33 | return Some str 34 | } 35 | else 36 | task {return None} 37 | return photo 38 | } 39 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Model/TmpState.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client 2 | open FsOpenAI.Shared 3 | 4 | //manage temporary state for chat UI settings 5 | module TmpState = 6 | let isOpenDef defVal key model = 7 | model.settingsOpen 8 | |> Map.tryFind key 9 | |> Option.defaultValue defVal 10 | 11 | let isOpen key model = isOpenDef false key model 12 | 13 | let toggle key model = 14 | {model with 15 | settingsOpen = 16 | model.settingsOpen 17 | |> Map.tryFind key 18 | |> Option.map(fun b -> model.settingsOpen |> Map.add key (not b)) 19 | |> Option.defaultWith(fun _ -> model.settingsOpen |> Map.add key true)} 20 | 21 | let setState key value model = 22 | {model with settingsOpen = model.settingsOpen |> Map.add key value} 23 | 24 | let openClose id model = 25 | {model with 26 | settingsOpen = 27 | model.settingsOpen 28 | |> Map.tryFind id 29 | |> Option.map(fun b -> model.settingsOpen |> Map.add id (not b)) 30 | |> Option.defaultWith(fun _ -> model.settingsOpen |> Map.add id true) 31 | } 32 | 33 | let updateChatTempState id model fSet defaultValue = 34 | let st = 35 | model.tempChatSettings 36 | |> Map.tryFind id 37 | |> Option.map fSet 38 | |> Option.defaultValue defaultValue 39 | |> fun cs -> Map.add id cs model.tempChatSettings 40 | {model with tempChatSettings=st} 41 | 42 | let toggleChatSettings id model = 43 | updateChatTempState id model 44 | (fun s -> {s with SettingsOpen = not s.SettingsOpen}) 45 | {TempChatState.Default with SettingsOpen=true} 46 | 47 | let toggleChatDocs (id,msgId) model = 48 | updateChatTempState id model 49 | (fun s -> {s with DocsOpen = msgId}) 50 | {TempChatState.Default with DocsOpen=msgId} 51 | 52 | let toggleDocDetails id model = 53 | updateChatTempState id model 54 | (fun s -> {s with DocDetailsOpen = not s.DocDetailsOpen}) 55 | {TempChatState.Default with DocDetailsOpen=true} 56 | 57 | let togglePrompts id model = 58 | updateChatTempState id model 59 | (fun s -> {s with PromptsOpen = not s.PromptsOpen}) 60 | {TempChatState.Default with PromptsOpen=true} 61 | 62 | let toggleIndex id model = 63 | updateChatTempState id model 64 | (fun s -> {s with IndexOpen = not s.IndexOpen}) 65 | {TempChatState.Default with IndexOpen=true} 66 | 67 | let toggleSysMsg id model = 68 | updateChatTempState id model 69 | (fun s -> {s with SysMsgOpen = not s.SysMsgOpen}) 70 | {TempChatState.Default with SysMsgOpen=true} 71 | 72 | let toggleFeedback id model = 73 | updateChatTempState id model 74 | (fun s -> {s with FeedbackOpen = not s.FeedbackOpen}) 75 | {TempChatState.Default with FeedbackOpen=true} 76 | 77 | let isDocsOpen model = 78 | let selChat = Model.selectedChat model 79 | let docs = 80 | selChat 81 | |> Option.bind (fun chat -> 82 | model.tempChatSettings 83 | |> Map.tryFind chat.Id 84 | |> Option.bind (_.DocsOpen) 85 | |> Option.bind(fun msgId -> 86 | chat.Messages 87 | |> List.filter(fun m -> m.MsgId=msgId) 88 | |> List.tryHead 89 | |> Option.map(fun m -> match m.Role with Assistant s -> s.DocRefs | _ -> failwith "unexpected")) 90 | ) 91 | |> Option.defaultValue [] 92 | selChat |> Option.map (fun c -> c.Id),docs 93 | 94 | let chatSettingsOpen id model = 95 | model.tempChatSettings 96 | |> Map.tryFind id 97 | |> Option.map(fun x -> x.SettingsOpen) 98 | |> Option.defaultValue false 99 | 100 | let isDocDetailsOpen id model = 101 | model.tempChatSettings 102 | |> Map.tryFind id 103 | |> Option.map(fun x -> x.DocDetailsOpen) 104 | |> Option.defaultValue false 105 | 106 | let isPromptsOpen id model = 107 | model.tempChatSettings 108 | |> Map.tryFind id 109 | |> Option.map(fun x -> x.PromptsOpen) 110 | |> Option.defaultValue false 111 | 112 | let isIndexOpen id model = 113 | model.tempChatSettings 114 | |> Map.tryFind id 115 | |> Option.map(fun x -> x.IndexOpen) 116 | |> Option.defaultValue false 117 | 118 | let isSysMsgOpen id model = 119 | model.tempChatSettings 120 | |> Map.tryFind id 121 | |> Option.map(fun x -> x.SysMsgOpen) 122 | |> Option.defaultValue false 123 | 124 | let isFeedbackOpen id model = 125 | model.tempChatSettings 126 | |> Map.tryFind id 127 | |> Option.map(fun x -> x.FeedbackOpen) 128 | |> Option.defaultValue false 129 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "https": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | }, 9 | "dotnetRunMessages": true, 10 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 11 | "applicationUrl": "https://localhost:52035;http://localhost:52037" 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Startup.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client 2 | open System 3 | open System.Net.Http 4 | open Microsoft.Extensions.Configuration 5 | open Microsoft.Extensions.DependencyInjection 6 | open Microsoft.AspNetCore.Components.WebAssembly.Hosting 7 | open Blazored.LocalStorage 8 | open Bolero.Remoting.Client 9 | open Radzen 10 | 11 | module Program = 12 | open Microsoft.AspNetCore.Components.Web 13 | 14 | [] 15 | let Main args = 16 | let builder = WebAssemblyHostBuilder.CreateDefault(args) 17 | builder.RootComponents.Add("#main") 18 | builder.RootComponents.Add("head::after") 19 | builder.Services.AddBoleroRemoting(builder.HostEnvironment) |> ignore 20 | builder.Services.AddRadzenComponents() |> ignore 21 | builder.Services.AddBlazoredLocalStorage(fun o -> o.JsonSerializerOptions <- ClientHub.configureSer o.JsonSerializerOptions) |> ignore 22 | 23 | //http factory to create clients to call Microsoft graph api 24 | builder.Services.AddScoped() |> ignore 25 | builder.Services.AddHttpClient( 26 | Graph.Api.CLIENT_ID, 27 | Action(Graph.Api.configure)) //need type annotation to bind to the correct overload 28 | 29 | .AddHttpMessageHandler() 30 | |> ignore 31 | 32 | //add authentication that internally uses the msal.js library 33 | builder.Services.AddMsalAuthentication(fun o -> 34 | //read configuration to reference the AD-app 35 | builder.Configuration.Bind("AzureAd", o.ProviderOptions.Authentication) 36 | let defScope = builder.Configuration.["AzureAd:DefaultScope"] 37 | //NOTE: EntraID app registration should have a scope called API.Access in 'expose an api' section 38 | let defScope = 39 | if defScope = null then 40 | $"api://{o.ProviderOptions.Authentication.ClientId}/API.Access" 41 | else 42 | defScope 43 | printfn $"adding msal authentication with default scope {defScope}" 44 | 45 | o.ProviderOptions.DefaultAccessTokenScopes.Add(defScope) 46 | ) |> ignore 47 | 48 | builder.Build().RunAsync() |> ignore 49 | 0 50 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/AssistantMessageView.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open Bolero.Html 3 | open FsOpenAI.Client 4 | open FsOpenAI.Shared 5 | open FSharp.Formatting.Markdown 6 | 7 | module AssistantMessage = 8 | open Radzen 9 | open Radzen.Blazor 10 | 11 | let view (msg:InteractionMessage) (chat:Interaction) lastMsg model dispatch = 12 | let docs = match msg.Role with Assistant r -> r.DocRefs | _ -> [] 13 | let docsr = 14 | if docs |> List.exists (fun x -> x.SortOrder.IsSome) then 15 | docs |> List.filter (fun x -> x.SortOrder.IsSome) 16 | else 17 | docs 18 | comp { 19 | attr.``class`` $"rz-mt-1 rz-border-radius-3" 20 | comp { 21 | comp { 22 | "Size" => 1 23 | match model.appConfig.AssistantIcon with //assume alt icon is an image (updated to match radzen changes) 24 | | Some path -> 25 | comp { 26 | "Path" => path 27 | "Style" => "width: 2rem; height: 2rem;" 28 | } 29 | | None -> 30 | comp { 31 | "Icon" => C.DFLT_ASST_ICON 32 | "IconColor" => (model.appConfig.AssistantIconColor |> Option.defaultValue C.DFLT_ASST_ICON_COLOR) 33 | } 34 | } 35 | comp { 36 | "Size" => 11 37 | div { 38 | attr.style "white-space: pre-line;" 39 | if Utils.isEmpty msg.Message then 40 | "..." 41 | else 42 | let html = Markdown.ToHtml(Markdown.Parse(msg.Message)) 43 | let html = html 44 | .Replace("

", "") 45 | .Replace("

", "
") 46 | 47 | Bolero.Html.rawHtml(html) 48 | } 49 | table { 50 | attr.style "width: 100%;" 51 | if not docs.IsEmpty && not chat.IsBuffering then 52 | tr { 53 | attr.``class`` "rz-mt-1"// rz-color-on-primary-darker rz-background-color-primary-darker" 54 | attr.style "background-color: var(--rz-primary-lighter);" 55 | td { 56 | attr.style "width: 1.5rem;" 57 | ecomp (Some chat.Id,docs) dispatch { attr.empty() } 58 | } 59 | td { 60 | comp { 61 | attr.``class`` "rz-p-2" 62 | "Style" => "height: 3.5rem; overflow: auto;" 63 | "Orientation" => Orientation.Horizontal 64 | "Wrap" => FlexWrap.Wrap 65 | for d in docsr do 66 | comp { 67 | attr.title (Utils.shorten 40 d.Title) 68 | attr.``class`` "rz-ml-2 " 69 | "Style" => "max-width: 140px; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; color: var(--rz-danger-light);" 70 | "Path" => d.Ref 71 | "Target" => "_blank" 72 | $"{d.Id}: {d.Title}" 73 | } 74 | } 75 | } 76 | } 77 | if lastMsg && not chat.IsBuffering then 78 | match Interactions.CodeEval.Interaction.codeBag chat with 79 | | None -> () 80 | | Some v -> 81 | tr { 82 | td { 83 | attr.colspan 2 84 | ecomp model dispatch {attr.empty()} 85 | } 86 | } 87 | tr { 88 | 89 | td { 90 | attr.colspan 2 91 | match chat.Feedback with 92 | | Some fb -> 93 | ecomp (fb,chat,model) dispatch { attr.empty() } 94 | | None -> () 95 | } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | 102 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/AsyncExts.fs: -------------------------------------------------------------------------------- 1 | module AsyncExts 2 | open System 3 | open FSharp.Control 4 | open System.Threading 5 | open System.Threading.Channels 6 | open System.Threading.Tasks 7 | 8 | module Async = 9 | let map f a = async.Bind(a, f >> async.Return) 10 | 11 | module AsyncSeq = 12 | let mapAsyncParallelThrottled (parallelism:int) (f:'a -> Async<'b>) (s:AsyncSeq<'a>) : AsyncSeq<'b> = asyncSeq { 13 | use mb = MailboxProcessor.Start (ignore >> async.Return) 14 | use sm = new SemaphoreSlim(parallelism) 15 | let! err = 16 | s 17 | |> AsyncSeq.iterAsync (fun a -> async { 18 | let! _ = sm.WaitAsync () |> Async.AwaitTask 19 | let! b = Async.StartChild (async { 20 | try return! f a 21 | finally sm.Release () |> ignore }) 22 | mb.Post (Some b) }) 23 | |> Async.map (fun _ -> mb.Post None) 24 | |> Async.StartChildAsTask 25 | yield! 26 | AsyncSeq.unfoldAsync (fun (t:Task) -> async{ 27 | if t.IsFaulted then 28 | return None 29 | else 30 | let! d = mb.Receive() 31 | match d with 32 | | Some c -> 33 | let! d' = c 34 | return Some (d',t) 35 | | None -> return None 36 | }) 37 | err 38 | } 39 | (* 40 | //implementation possible within AsyncSeq, with the supporting code available there 41 | let mapAsyncParallelThrottled (parallelism:int) (f:'a -> Async<'b>) (s:AsyncSeq<'a>) : AsyncSeq<'b> = asyncSeq { 42 | use mb = MailboxProcessor.Start (ignore >> async.Return) 43 | use sm = new SemaphoreSlim(parallelism) 44 | let! err = 45 | s 46 | |> iterAsync (fun a -> async { 47 | do! sm.WaitAsync () |> Async.awaitTaskUnitCancellationAsError 48 | let! b = Async.StartChild (async { 49 | try return! f a 50 | finally sm.Release () |> ignore }) 51 | mb.Post (Some b) }) 52 | |> Async.map (fun _ -> mb.Post None) 53 | |> Async.StartChildAsTask 54 | yield! 55 | replicateUntilNoneAsync (Task.chooseTask (err |> Task.taskFault) (async.Delay mb.Receive)) 56 | |> mapAsync id } 57 | *) 58 | 59 | let private _mapAsyncParallelUnits (units:string) (unitsPerMinute:float) (f:'a -> Async<'b>) (s:AsyncSeq) : AsyncSeq<'b> = asyncSeq { 60 | use mb = MailboxProcessor.Start (ignore >> async.Return) 61 | let resolution = 0.5 //minutes 62 | let mutable markTime = DateTime.Now.Ticks 63 | let mutable unitCount = 0uL 64 | 65 | let incrUnits i = Interlocked.Add(&unitCount,i) 66 | 67 | let reset i t = 68 | Interlocked.Exchange(&unitCount,i) |> ignore 69 | Interlocked.Exchange(&markTime,t) |> ignore 70 | 71 | //some randomness to stagger calls 72 | let rand5Pct() = 73 | let rng = Random() 74 | (rng.Next(0,5) |> float) / 100.0 75 | 76 | let! err = 77 | s 78 | |> AsyncSeq.iterAsync (fun (t,a) -> async { 79 | let unitCountF = incrUnits t |> float 80 | let elapsed = (DateTime.Now - DateTime(markTime)).TotalMinutes 81 | if elapsed > resolution then 82 | //use a sliding rate 83 | let t = DateTime.Now.AddMinutes (-resolution / 2.0) 84 | let i = (uint64 (unitCountF / 2.0)) 85 | reset i t.Ticks 86 | let rate = 87 | if elapsed < 0.001 then 88 | let overagePct = rand5Pct() 89 | unitsPerMinute * (1.0 + overagePct) //initially (elapsed ~= 0) assume small random overage so initial calls are staggered 90 | else 91 | unitCountF / elapsed |> min (unitsPerMinute * 2.0) //cap rate to 2x 92 | printfn $"{units}/min: %0.0f{rate} [per sec %0.1f{rate/60.0}]" 93 | let overage = rate - unitsPerMinute 94 | if overage > 0.0 then 95 | //how much of next resolution period we should wait? 96 | //scale based on overage as %age of base rate 97 | let overagePct = overage / unitsPerMinute + (rand5Pct()) 98 | let wait = resolution * overagePct * 60000.0 |> int 99 | printfn $"wait sec %0.1f{float wait/1000.0}" 100 | do! Async.Sleep wait 101 | let! b = Async.StartChild (async { 102 | try return! f a 103 | finally () }) 104 | mb.Post (Some b) }) 105 | |> Async.map (fun _ -> mb.Post None) 106 | |> Async.StartChildAsTask 107 | yield! 108 | AsyncSeq.unfoldAsync (fun (t:Task) -> async{ 109 | if t.IsFaulted then 110 | return None 111 | else 112 | let! d = mb.Receive() 113 | match d with 114 | | Some c -> 115 | let! d' = c 116 | return Some (d',t) 117 | | None -> return None 118 | }) 119 | err 120 | } 121 | 122 | ///Invoke f in parallel while maintaining the tokens per minute rate. 123 | ///Input is a sequence of (tokens:unint64 *'a) where the tokens is the number of input tokens associated with value 'a. 124 | ///Note: ordering is not maintained 125 | let mapAsyncParallelTokenLimit (tokensPerMinute:float) (f:'a -> Async<'b>) (s:AsyncSeq) : AsyncSeq<'b> = 126 | _mapAsyncParallelUnits "tokens" tokensPerMinute f s 127 | 128 | ///Invoke f in parallel while maintaining opsPerSecond rate. 129 | ///Note: ordering is not maintained 130 | let mapAsyncParallelRateLimit (opsPerSecond:float) (f:'a -> Async<'b>) (s:AsyncSeq<'a>) : AsyncSeq<'b> = 131 | _mapAsyncParallelUnits "ops" (opsPerSecond * 60.0) f (s |> AsyncSeq.map (fun a -> 1UL,a)) 132 | 133 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/AuthenticationViews.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open System 3 | open Bolero 4 | open Bolero.Html 5 | open Elmish 6 | open Radzen 7 | open Radzen.Blazor 8 | open FsOpenAI.Client 9 | open Microsoft.AspNetCore.Components 10 | open Microsoft.AspNetCore.Components.WebAssembly.Authentication 11 | open Microsoft.AspNetCore.Components.Web 12 | open FsOpenAI.Shared 13 | 14 | type LoginRedirectView() = 15 | inherit ElmishComponent() 16 | 17 | [] 18 | member val NavMgr : NavigationManager = Unchecked.defaultof<_> with get, set 19 | 20 | [] 21 | member val ThemeService = Unchecked.defaultof with get, set 22 | 23 | override this.View mdl (dispatch:Message -> unit) = 24 | let action,model = mdl 25 | concat { 26 | comp { text (model.appConfig.AppName |> Option.defaultValue "") } 27 | comp { "Theme" => this.ThemeService.Theme } 28 | comp{attr.empty()} 29 | comp{attr.empty()} 30 | comp { 31 | comp { 32 | comp { 33 | "Action" => action 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/ChatHistoryView.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open System 3 | open Bolero 4 | open Bolero.Html 5 | open Radzen 6 | open Radzen.Blazor 7 | open FsOpenAI.Client 8 | open Microsoft.AspNetCore.Components 9 | open Microsoft.JSInterop 10 | open FsOpenAI.Shared 11 | open FsOpenAI.Shared.Interactions 12 | 13 | module Notes = 14 | let timeline markerId (chat:Interaction) (marker:Ref) = 15 | let notes = List.rev chat.Notifications 16 | comp { 17 | attr.``class`` "rz-mt-1 rz-p-2" 18 | "LinePosition" => LinePosition.Left 19 | attr.fragment "Items" ( 20 | concat { 21 | yield 22 | comp { 23 | "PointSize" => PointSize.Small 24 | attr.fragment "ChildContent" ( 25 | comp { 26 | "TextStyle" => TextStyle.Caption 27 | attr.id markerId 28 | "Text" => (notes |> List.tryHead |> Option.defaultValue "...") 29 | marker 30 | } 31 | ) 32 | } 33 | if notes.Length > 1 then 34 | yield 35 | comp { 36 | "PointSize" => PointSize.Small 37 | comp { 38 | "Gap" => "0.5rem" 39 | "Orientation" => Orientation.Horizontal 40 | concat { 41 | for t in notes.Tail do 42 | yield 43 | concat{ 44 | comp { 45 | "Shade" => Shade.Light 46 | "BadgeStyle" => BadgeStyle.Base 47 | } 48 | comp { 49 | "Style" => "max-width: 10rem; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;" 50 | "TextStyle" => TextStyle.Caption 51 | attr.title t 52 | "Text" => t 53 | } 54 | } 55 | 56 | } 57 | } 58 | } 59 | }) 60 | } 61 | 62 | type ChatHistoryView() = 63 | inherit ElmishComponent() 64 | 65 | let marker = Ref() 66 | 67 | member val IsBuffering = false with get, set 68 | member val markerId = "" with get, set 69 | 70 | [] 71 | member val JSRuntime = Unchecked.defaultof with get, set 72 | 73 | member this.CopyToClipboard(text:string) = 74 | this.JSRuntime.InvokeVoidAsync ("navigator.clipboard.writeText", text) |> ignore 75 | 76 | member this.ScrollToEnd() = 77 | if Utils.notEmpty this.markerId then 78 | this.JSRuntime.InvokeVoidAsync ("fso_scrollTo", [|this.markerId|]) |> ignore 79 | 80 | override this.OnAfterRenderAsync(a) = 81 | if this.IsBuffering then this.ScrollToEnd() 82 | base.OnAfterRenderAsync(a) 83 | 84 | override this.View model dispatch = 85 | comp { 86 | attr.``class`` "rz-mr-1" 87 | comp { 88 | comp { 89 | comp { 90 | "Sytle" => "height: 1rem;" 91 | //"Size" => 1 92 | match Model.selectedChat model with 93 | | Some chat -> 94 | let m = 95 | { 96 | ChatId = chat.Id 97 | Model = model 98 | Parms = chat.Parameters 99 | QaBag = 100 | Interaction.qaBag chat 101 | |> Option.orElseWith (fun _ -> 102 | if Model.isEnabledAny [M_Index; M_Doc_Index] this.Model then 103 | Some QABag.Default 104 | else 105 | None) 106 | SystemMessage = Interaction.systemMessage chat 107 | } 108 | ecomp m dispatch {attr.empty()} 109 | | None -> () 110 | } 111 | } 112 | comp { 113 | "Style" => "max-height: calc(100vh - 18rem);overflow-y:auto;overflow-x:hidden;" 114 | comp { 115 | match Model.selectedChat model with 116 | | Some chat -> 117 | let lastMsgId = List.tryLast chat.Messages |> Option.map (fun x -> x.MsgId) |> Option.defaultValue "" 118 | this.markerId <- $"{chat.Id}_marker" 119 | this.IsBuffering <- chat.IsBuffering 120 | for m in chat.Messages do 121 | if m.IsUser then 122 | yield UserMessage.view m chat model dispatch 123 | else 124 | yield AssistantMessage.view m chat (m.MsgId=lastMsgId) model dispatch 125 | if chat.IsBuffering then 126 | yield 127 | Notes.timeline this.markerId chat marker 128 | | None -> () 129 | } 130 | } 131 | comp { 132 | "Style" => "height: auto; margin-top: 1rem;" 133 | attr.``class`` "rz-mt-1" 134 | ecomp model dispatch {attr.empty()} 135 | } 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/FeedbackView.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open System 3 | open Microsoft.AspNetCore.Components.Web 4 | open Bolero 5 | open Bolero.Html 6 | open Radzen 7 | open Radzen.Blazor 8 | open FsOpenAI.Client 9 | open FsOpenAI.Shared 10 | 11 | type FeedbackView() = 12 | inherit ElmishComponent() 13 | let commentRef = Ref() 14 | 15 | override this.View m dispatch = 16 | let fb,chat,model = m 17 | comp { 18 | attr.``class`` "rz-p-1" 19 | "AlignItems" => Radzen.AlignItems.Center 20 | "Orientation" => Radzen.Orientation.Horizontal 21 | let colorUp = if fb.ThumbsUpDn > 0 then "var(--rz-success)" else "" 22 | let colorDn = if fb.ThumbsUpDn < 0 then "var(--rz-danger)" else "" 23 | comp { 24 | "Placeholder" => "Comment (optional)" 25 | "Rows" => 1 26 | "Cols" => 30 27 | "Style" => "resize: none; width: 100%; outline: none;" 28 | "Value" => (fb.Comment |> Option.defaultValue "") 29 | on.blur (fun _ -> 30 | commentRef.Value 31 | |> Option.iter(fun m -> 32 | dispatch(Ia_Feedback_Set (chat.Id, {fb with Comment = Some m.Value})))) 33 | commentRef 34 | } 35 | comp { 36 | "Icon" => "thumb_up" 37 | "ButtonStyle" => ButtonStyle.Base 38 | "Style" => "background: transparent;" 39 | "Size" => ButtonSize.ExtraSmall 40 | "IconColor" => colorUp 41 | attr.callback "Click" (fun (e:MouseEventArgs) -> 42 | dispatch (Ia_Feedback_Set(chat.Id, {fb with ThumbsUpDn = +1 })) 43 | dispatch (Ia_Feedback_Submit(chat.Id))) 44 | } 45 | comp { 46 | "Icon" => "thumb_down" 47 | "IconColor" => colorDn 48 | "Style" => "background: transparent;" 49 | "ButtonStyle" => ButtonStyle.Base 50 | "Size" => ButtonSize.ExtraSmall 51 | attr.callback "Click" (fun (e:MouseEventArgs) -> 52 | dispatch (Ia_Feedback_Set(chat.Id, {fb with ThumbsUpDn = -1})) 53 | dispatch (Ia_Feedback_Submit(chat.Id))) 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/FooterView.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open FsOpenAI.Client 3 | open FsOpenAI.Shared 4 | open FsOpenAI.Shared.Interactions 5 | open System 6 | open Microsoft.AspNetCore.Components 7 | open Microsoft.AspNetCore.Components.Web 8 | open Bolero 9 | open Bolero.Html 10 | open Radzen 11 | open Radzen.Blazor 12 | 13 | 14 | module Footer = 15 | open Microsoft.JSInterop 16 | let copyToClipboard (jsr:IJSRuntime) (text:string) = 17 | jsr.InvokeVoidAsync ("navigator.clipboard.writeText", text) |> ignore 18 | 19 | let view jsr model dispatch = 20 | comp { 21 | //"Style" => "height:3rem;" 22 | comp { 23 | comp { 24 | "Size" => 1 25 | } 26 | comp { 27 | "Size" => 10 28 | match model.appConfig.Disclaimer with 29 | | Some t -> 30 | comp { 31 | "TextAlign" => TextAlign.Center 32 | "Text" => t 33 | "Style" => "color:var(--rz-text-color-secondary);" 34 | } 35 | | None -> () 36 | } 37 | comp { 38 | "Size" => 1 39 | comp { 40 | "ButtonStyle" => ButtonStyle.Base 41 | "Size" => ButtonSize.Small 42 | "Variant" => Variant.Outlined 43 | "Style" => "background:transparent;border: 1px solid var(--rz-base-light)" 44 | attr.``class`` "rz-border-radius-10 rz-shadow-10" 45 | attr.title "Copy chat to clipboard" 46 | "Icon" => "content_copy" 47 | attr.callback "Click" (fun (e:MouseEventArgs) -> 48 | Model.selectedChat model 49 | |> Option.iter (fun c -> 50 | let text = Interaction.getText c 51 | copyToClipboard jsr text 52 | dispatch (ShowInfo "Copied"))) 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/HeaderView.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open System 3 | open Bolero 4 | open Bolero.Html 5 | open FsOpenAI.Client 6 | open FsOpenAI.Shared 7 | open Radzen 8 | open Radzen.Blazor 9 | open Microsoft.AspNetCore.Components 10 | open Microsoft.AspNetCore.Components.Web 11 | open Microsoft.JSInterop 12 | 13 | type HeaderView() = 14 | inherit ElmishComponent() 15 | let transparentBg = "background: transparent;" 16 | 17 | [] member val ThemeService:ThemeService = Unchecked.defaultof<_> with get,set 18 | 19 | override this.View model dispatch = 20 | let sidebarExpanded = TmpState.isOpenDef true C.SIDE_BAR_EXPANDED model 21 | comp { 22 | //attr.``class`` "rz-background-color-danger-dark" 23 | comp { 24 | "AlignItems" => AlignItems.Center 25 | comp { 26 | "Size" => 1 27 | comp { 28 | //"Style" => transparentBg 29 | "Icon" => (if sidebarExpanded then "chevron_left" else "chevron_right") 30 | attr.callback "Click" (fun (e:EventArgs) -> dispatch ToggleSideBar) 31 | } 32 | } 33 | comp { 34 | "Size" => 2 35 | if model.busy then 36 | comp { 37 | "ShowValue" => false 38 | "Mode" => ProgressBarMode.Indeterminate 39 | "Size" => ProgressBarCircularSize.Small 40 | "ProgressBarStyle" => ProgressBarStyle.Danger 41 | } 42 | } 43 | comp{ 44 | "Size" => 5 45 | comp { 46 | "Style" => "text-align: center; width: 100%; align-self: center; color: var(--rz-primary)" 47 | "Text" => (match model.loadConfig.AppTitle with Some t -> t | _ -> "") 48 | "TextStyle" => TextStyle.H6 49 | } 50 | } 51 | comp{ 52 | "Size" => 1 53 | if model.loadConfig.RequireLogin then 54 | let isAuthenticated = match model.user with | Authenticated _ -> true | _ -> false 55 | if not isAuthenticated then 56 | comp { 57 | "Text" => "Login" 58 | attr.callback "Click" (fun (e:MouseEventArgs) -> dispatch LoginLogout) 59 | } 60 | else 61 | comp { 62 | attr.fragment "Template" ( 63 | comp { 64 | "Path" => (model.photo |> Option.defaultValue "imgs/person.png") 65 | "Style" => "width: 1.5rem; border-radius: 50%;" 66 | }) 67 | comp { 68 | "Text" => match model.user with 69 | | Unauthenticated -> "Login" 70 | | Authenticated user -> $"Log out {user.Name}" 71 | } 72 | } 73 | } 74 | comp { 75 | "Size" => 1 76 | comp {attr.empty()} 77 | } 78 | comp { 79 | "Size" => 2 80 | ecomp model dispatch {attr.empty()} 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/MainLayout.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open System 3 | open Bolero.Html 4 | open FsOpenAI.Client 5 | open FsOpenAI.Shared 6 | open FsOpenAI.Shared.Interactions 7 | open Bolero 8 | open Microsoft.AspNetCore.Components.Web 9 | open Microsoft.AspNetCore.Components 10 | open Microsoft.JSInterop 11 | open Radzen 12 | open Radzen.Blazor 13 | 14 | type MainLayout() = 15 | inherit ElmishComponent() 16 | 17 | [] 18 | member val JSRuntime = Unchecked.defaultof with get, set 19 | 20 | [] 21 | member val ThemeService = Unchecked.defaultof with get, set 22 | 23 | member this.CopyToClipboard(text:string) = 24 | this.JSRuntime.InvokeVoidAsync ("navigator.clipboard.writeText", text) |> ignore 25 | 26 | override this.OnParametersSet() = 27 | if this.ThemeService.Theme = null then 28 | this.ThemeService.SetTheme "Humanistic" 29 | 30 | override this.View model dispatch = 31 | 32 | match model.page with 33 | | Page.Authentication action -> 34 | ecomp (action,model) dispatch {attr.empty()} 35 | 36 | | Page.Home -> 37 | concat { 38 | comp{attr.empty()} 39 | comp { text (model.appConfig.AppName |> Option.defaultValue "") } 40 | comp { "Theme" => this.ThemeService.Theme } 41 | comp{attr.empty()} 42 | comp { 43 | comp { attr.empty() } 44 | ecomp model dispatch {attr.empty()} 45 | ecomp model dispatch {attr.empty()} 46 | comp { 47 | comp { 48 | "Style" => "height: 100%;" 49 | "Orientation" => Orientation.Horizontal 50 | comp { 51 | "Size" => "75%" 52 | ecomp model dispatch {attr.empty()} 53 | } 54 | comp { 55 | "Size" => "25%" 56 | attr.``class`` "rz-p-0 rz-p-lg-12" 57 | "Style" => "overflow:auto;" 58 | ecomp model dispatch {attr.empty()} 59 | } 60 | } 61 | } 62 | Footer.view this.JSRuntime model dispatch 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/MainSettingsView.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open Microsoft.AspNetCore.Components 3 | open Microsoft.AspNetCore.Components.Web 4 | open Bolero 5 | open Bolero.Html 6 | open Radzen 7 | open Radzen.Blazor 8 | open FsOpenAI.Client 9 | open FsOpenAI.Shared 10 | open System.Collections.Generic 11 | 12 | type OpenAIKey() = 13 | inherit ElmishComponent() 14 | 15 | override this.View model (dispatch:Message -> unit) = 16 | comp { 17 | comp { 18 | "Orientation" => Orientation.Horizontal 19 | "AlignItems" => AlignItems.Center 20 | comp {"Text" => "OpenAI Key"} 21 | comp { 22 | attr.style "display: flex; flex-grow:1;" 23 | "Placeholder" => "OpenAI Key" 24 | "Value" => (model.serviceParameters |> Option.bind(fun p -> p.OPENAI_KEY) |> Option.defaultValue null) 25 | attr.callback "ValueChanged" (fun e -> dispatch (UpdateOpenKey e)) 26 | } 27 | } 28 | } 29 | 30 | type private M_MenuItems = 31 | | M_ClearChats 32 | | M_PurgeLocalData 33 | | M_SetOpenAIKey 34 | 35 | type MainSettingsView() = 36 | inherit ElmishComponent() 37 | 38 | [] 39 | member val DialogService = Unchecked.defaultof with get, set 40 | 41 | [] 42 | member val ContextMenuService = Unchecked.defaultof with get, set 43 | 44 | override this.View model (dispatch:Message -> unit) = 45 | comp { 46 | "Icon" => "menu" 47 | "Variant" => Variant.Flat 48 | "ButtonStyle" => ButtonStyle.Base 49 | "Style" => "background-color: transparent;" 50 | "Size" => ButtonSize.Small 51 | attr.callback "Click" (fun (e:MouseEventArgs) -> 52 | this.ContextMenuService.Open( 53 | e, 54 | [ 55 | ContextMenuItem(Icon="delete_sweep", Text="Clear chats", Value=M_ClearChats, IconColor=Colors.Warning) 56 | ContextMenuItem(Icon="folder_delete", Text="Purge local browser storage", Value=M_PurgeLocalData) 57 | if model.appConfig.EnabledBackends |> List.contains OpenAI then 58 | ContextMenuItem(Icon="key", Text="Set OpenAI Key", Value=M_SetOpenAIKey) 59 | ], 60 | fun (e:MenuItemEventArgs) -> 61 | match e.Value :?> M_MenuItems with 62 | | M_ClearChats -> this.ContextMenuService.Close(); dispatch Ia_ClearChats 63 | | M_PurgeLocalData -> this.ContextMenuService.Close(); dispatch PurgeLocalData 64 | | M_SetOpenAIKey -> 65 | this.ContextMenuService.Close(); 66 | let parms = ["Model", model :> obj; "Dispatch", dispatch] |> dict |> Dictionary 67 | this.DialogService.OpenAsync("OpenAI Key", parameters=parms) |> ignore 68 | )) 69 | } 70 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/PromptView.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open System 3 | open Bolero 4 | open Bolero.Html 5 | open Elmish 6 | open MudBlazor 7 | open FsOpenAI.Client 8 | () 9 | //type PromptView() = 10 | // inherit ElmishComponent() 11 | 12 | // override this.View model dispatch = 13 | // let chat,message = model 14 | // concat { 15 | // comp { 16 | // //"Class" => "d-flex flex-grow-1" 17 | // "Spacing" => 0 18 | // comp { 19 | // //"Class" => "d-flex flex-grow-1" 20 | // "xs" => 12 21 | // comp> { 22 | // attr.callback "ValueChanged" (fun e -> dispatch (Chat_AddMsg (chat.Id,e))) 23 | // "Variant" => Variant.Outlined 24 | // "Label" => "Prompt" 25 | // "Lines" => 15 26 | // "Placeholder" => "Enter prompt or question" 27 | // "Text" => message.Message 28 | // } 29 | // } 30 | // comp { 31 | // //"Class" => "d-flex flex-grow-0" 32 | // "xs" => 12 33 | // comp { 34 | // "Row" => true 35 | // comp { 36 | // "Icon" => Icons.Material.Filled.Settings 37 | // } 38 | // comp { 39 | // "Icon" => Icons.Material.Filled.DeleteSweep 40 | // "Tooltip" => "Clear data" 41 | // on.click(fun ev -> dispatch Reset ) 42 | // } 43 | // comp {attr.empty()} 44 | // comp { 45 | // "Icon" => Icons.Material.Filled.Add 46 | // on.click(fun ev -> dispatch AddDummyContent) 47 | // } 48 | // comp {attr.empty()} 49 | // comp { 50 | // "Icon" => Icons.Material.Filled.Send 51 | // on.click(fun ev -> dispatch (SubmitChat chat.Id) ) 52 | // } 53 | // } 54 | // } 55 | // } 56 | // } 57 | 58 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/SearchResultsView.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open System 3 | open Bolero 4 | open Bolero.Html 5 | open Microsoft.AspNetCore.Components.Web 6 | open Radzen 7 | open Radzen.Blazor 8 | open FsOpenAI.Client 9 | open FsOpenAI.Shared 10 | open System.Linq.Expressions 11 | open Radzen.Blazor.Rendering 12 | 13 | type SVAttr = 14 | static member Property(expr:Expression>) = 15 | "Property" => expr 16 | 17 | type SearchResultsView() = 18 | inherit ElmishComponent() 19 | let popup = Ref() 20 | let button = Ref() 21 | 22 | override this.View model dispatch = 23 | let chatId,docs = model 24 | concat { 25 | comp { 26 | "Style" => "background:transparent;height:2rem;" 27 | "Icon" => "snippet_folder" 28 | "ButtonStyle" => ButtonStyle.Base 29 | attr.title "Preview search results" 30 | attr.callback "Click" (fun (e:MouseEventArgs) -> popup.Value |> Option.iter (fun p -> p.ToggleAsync(button.Value.Value.Element) |> ignore)) 31 | button 32 | } 33 | comp { 34 | "Style" => $"display:none;position:absolute;max-height:90vh;max-width:90vw;height:20rem;width:30rem;padding:5px;background:transparent;" 35 | "Lazy" => false 36 | "PreventDefault" => false 37 | popup 38 | comp { 39 | "Style" => "height:100%;width:100%;overflow:none;background-color:var(--rz-panel-background-color);" 40 | "Variant" => Variant.Filled 41 | attr.``class`` "rz-shadow-5 rz-p-2 rz-border-radius-5 rz-border-danger-light" 42 | comp> { 43 | "Style" => "height:100%;width:100%;overflow:none;" 44 | attr.``class`` "rz-mb-2" 45 | "Data" => docs 46 | "Item" => "Document" 47 | "AllowPaging" => true 48 | "AllowVirtualization" => false 49 | "PageSize" => 1 50 | "Density" => Density.Compact 51 | attr.fragmentWith "Template" (fun (o:DocRef) -> 52 | comp { 53 | "Style" => "width:100%; height:100%; overflow:none;" 54 | comp { 55 | "Style" => "height:2rem;" 56 | "Path" => o.Ref 57 | "Target" => "_blank" 58 | $"{o.Id}: {o.Title}" 59 | } 60 | comp { 61 | attr.style "max-height:10rem; overflow:auto; white-space: pre-line;" 62 | div { 63 | attr.style "white-space: pre-line;" 64 | text o.Text 65 | } 66 | } 67 | }) 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/SidebarView.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open System 3 | open Bolero 4 | open Bolero.Html 5 | open FsOpenAI.Client 6 | open FsOpenAI.Shared 7 | open Radzen 8 | open Radzen.Blazor 9 | open FsOpenAI.Shared.Interactions 10 | open Microsoft.AspNetCore.Components 11 | open Microsoft.AspNetCore.Components.Web 12 | 13 | type SidebarView() = 14 | inherit ElmishComponent() 15 | 16 | override this.View model dispatch = 17 | let selChatId = Model.selectedChat model |> Option.map (fun x -> x.Id) |> Option.defaultValue "" 18 | comp { 19 | "Style" => "background-color: var(--rz-surface);" 20 | "Expanded" => TmpState.isOpenDef true C.SIDE_BAR_EXPANDED model 21 | attr.callback "ExpandedChanged" (fun (b:bool) -> dispatch (SidebarExpanded b)) 22 | comp { 23 | comp { 24 | "Style" => "width: 100%;" 25 | attr.``class`` "rz-p-2" 26 | "AlignItems" => AlignItems.Center 27 | "Orientation" => Orientation.Horizontal 28 | comp { 29 | "ButtonStyle" => ButtonStyle.Primary 30 | attr.``class`` "rz-border-radius-10 rz-shadow-10" 31 | attr.title "Add chat" 32 | "Icon" => "add" 33 | attr.callback "Click" (fun (e:MouseEventArgs) -> dispatch (Ia_Add InteractionMode.M_Index)) 34 | } 35 | } 36 | comp { 37 | "Gap" => "0" 38 | for x in model.interactions do 39 | comp { 40 | "Gap" => "0" 41 | attr.``class`` "rz-p-2" 42 | "Orientation" => Orientation.Horizontal 43 | "AlignItems" => AlignItems.Center 44 | comp { 45 | "Size" => ButtonSize.Small 46 | "ButtonStyle" => ButtonStyle.Base 47 | if x.Id = selChatId then 48 | "Style" => "outline: 2px solid var(--rz-primary); width: 100%;" 49 | else 50 | "Style" => "width: 100%;" 51 | "ButtonStyle" => ButtonStyle.Base 52 | 53 | attr.callback "Click" (fun (e:MouseEventArgs) -> dispatch (Ia_Selected x.Id)) 54 | Interaction.label x 55 | } 56 | comp { 57 | "ButtonStyle" => ButtonStyle.Base 58 | attr.``class`` "rz-ml-2" 59 | "Style" => "background-color: var(--rz-surface);" 60 | 61 | "Size" => ButtonSize.Small 62 | "Icon" => "close" 63 | attr.callback "Click" (fun (e:MouseEventArgs) -> dispatch (Ia_Remove x.Id)) 64 | } 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/Views/UserMessageView.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Client.Views 2 | open Bolero.Html 3 | open FsOpenAI.Client 4 | open FsOpenAI.Shared 5 | 6 | module UserMessage = 7 | open Radzen 8 | open Radzen.Blazor 9 | 10 | let view (m:InteractionMessage) (chat:Interaction) model dispatch = 11 | let bg = "background-color: transparent;" 12 | comp { 13 | "Style" => bg 14 | attr.``class`` "rz-mt-1 rz-mr-2" 15 | comp { 16 | "Size" => 12 17 | attr.style "display: flex; justify-content: flex-end;" 18 | comp { 19 | "Orientation" => Orientation.Horizontal 20 | attr.``class`` $"rz-border-radius-5 rz-p-8; rz-background-color-info-lighter" 21 | div { 22 | attr.style "white-space: pre-line;" 23 | attr.``class`` "rz-p-2" 24 | text m.Message 25 | } 26 | comp { 27 | "Style" => bg 28 | "Responsive" => false 29 | comp { 30 | "Icon" => "refresh" 31 | attr.title "Edit and resubmit this message" 32 | attr.callback "Click" (fun (e:MenuItemEventArgs) -> dispatch (Ia_Restart (chat.Id, m))) 33 | } 34 | } 35 | } 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/wwwroot/app/imgs/Persona.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwaris/FsOpenAI/fdf360acb35352387827ae9a755e79f09de254f0/src/FsOpenAI.Client/wwwroot/app/imgs/Persona.png -------------------------------------------------------------------------------- /src/FsOpenAI.Client/wwwroot/app/imgs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwaris/FsOpenAI/fdf360acb35352387827ae9a755e79f09de254f0/src/FsOpenAI.Client/wwwroot/app/imgs/favicon.png -------------------------------------------------------------------------------- /src/FsOpenAI.Client/wwwroot/app/imgs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwaris/FsOpenAI/fdf360acb35352387827ae9a755e79f09de254f0/src/FsOpenAI.Client/wwwroot/app/imgs/logo.png -------------------------------------------------------------------------------- /src/FsOpenAI.Client/wwwroot/appsettings.json.template: -------------------------------------------------------------------------------- 1 | { 2 | "Comment" : { 3 | "1":"Copy contents of this file to appsettings.json and place it in the same location as this" 4 | "2":"In the 'AzureAd' section set the appropriate values from the MS EntraID app registration for MSAL authentication.", 5 | "3":"appsettings.json is 'gitignored' as it may contain proprietary information" 6 | }, 7 | "AzureAd": { 8 | "Authority": "https://login.microsoftonline.com/", 9 | "ClientId": "", 10 | "ValidateAuthority": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/wwwroot/css/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | } 4 | 5 | body { 6 | display: flex; 7 | flex-direction: column; 8 | } 9 | 10 | #main { 11 | flex: 1; 12 | } 13 | 14 | #main > .columns { 15 | min-height: 100%; 16 | } 17 | 18 | .sidebar { 19 | width: 250px; 20 | background: whitesmoke; 21 | min-height: 100%; 22 | } 23 | 24 | .Class { 25 | background: #e20074 26 | } 27 | 28 | .magenta-background-2 { 29 | background: #ac0059 30 | } 31 | 32 | .white-background-1 { 33 | background:#faf9f8 34 | } 35 | 36 | #counter { 37 | width: 80px; 38 | } 39 | 40 | #notification-area { 41 | position: fixed; 42 | bottom: 0; 43 | left: 0; 44 | right: 0; 45 | } 46 | 47 | #notification-area > div { 48 | max-width: 600px; 49 | margin-left: auto; 50 | margin-right: auto; 51 | margin-bottom: 5px; 52 | } 53 | 54 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/wwwroot/css/theme-override.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --rz-primary-test: #a7df58 !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/wwwroot/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwaris/FsOpenAI/fdf360acb35352387827ae9a755e79f09de254f0/src/FsOpenAI.Client/wwwroot/favicon.png -------------------------------------------------------------------------------- /src/FsOpenAI.Client/wwwroot/imgs/person.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwaris/FsOpenAI/fdf360acb35352387827ae9a755e79f09de254f0/src/FsOpenAI.Client/wwwroot/imgs/person.png -------------------------------------------------------------------------------- /src/FsOpenAI.Client/wwwroot/scripts/utils.js: -------------------------------------------------------------------------------- 1 | 2 | function fso_scrollTo(elementId) { 3 | var element = document.getElementById(elementId); 4 | //elementId.parentNode.scrollTop = target.offsetTop; 5 | //element.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'start' }); 6 | //element.scrollIntoView({ behavior: 'smooth', block: 'start', inline: 'start' }); 7 | element.scrollIntoView(true) 8 | } 9 | 10 | function inputValue(elementId) { 11 | var element = document.getElementById(elementId) 12 | return element.value 13 | } 14 | -------------------------------------------------------------------------------- /src/FsOpenAI.Client/wwwroot/theme-override.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --rz-primary-test: #a7df58 !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/FsOpenAI.CodeEvaluator/FsOpenAI.CodeEvaluator.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | true 5 | Debug;Release;UNAUTHENTICATED 6 | AnyCPU 7 | 8 | 9 | True 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/AsyncExts.fs: -------------------------------------------------------------------------------- 1 | module AsyncExts 2 | open System 3 | open FSharp.Control 4 | open System.Threading 5 | open System.Threading.Tasks 6 | 7 | module Async = 8 | let map f a = async.Bind(a, f >> async.Return) 9 | 10 | module AsyncSeq = 11 | let mapAsyncParallelThrottled (parallelism:int) (f:'a -> Async<'b>) (s:AsyncSeq<'a>) : AsyncSeq<'b> = asyncSeq { 12 | use mb = MailboxProcessor.Start (ignore >> async.Return) 13 | use sm = new SemaphoreSlim(parallelism) 14 | let! err = 15 | s 16 | |> AsyncSeq.iterAsync (fun a -> async { 17 | let! _ = sm.WaitAsync () |> Async.AwaitTask 18 | let! b = Async.StartChild (async { 19 | try return! f a 20 | finally sm.Release () |> ignore }) 21 | mb.Post (Some b) }) 22 | |> Async.map (fun _ -> mb.Post None) 23 | |> Async.StartChildAsTask 24 | yield! 25 | AsyncSeq.unfoldAsync (fun (t:Task) -> async{ 26 | if t.IsFaulted then 27 | return None 28 | else 29 | let! d = mb.Receive() 30 | match d with 31 | | Some c -> 32 | let! d' = c 33 | return Some (d',t) 34 | | None -> return None 35 | }) 36 | err 37 | } 38 | (* 39 | //implementation possible within AsyncSeq, with the supporting code available there 40 | let mapAsyncParallelThrottled (parallelism:int) (f:'a -> Async<'b>) (s:AsyncSeq<'a>) : AsyncSeq<'b> = asyncSeq { 41 | use mb = MailboxProcessor.Start (ignore >> async.Return) 42 | use sm = new SemaphoreSlim(parallelism) 43 | let! err = 44 | s 45 | |> iterAsync (fun a -> async { 46 | do! sm.WaitAsync () |> Async.awaitTaskUnitCancellationAsError 47 | let! b = Async.StartChild (async { 48 | try return! f a 49 | finally sm.Release () |> ignore }) 50 | mb.Post (Some b) }) 51 | |> Async.map (fun _ -> mb.Post None) 52 | |> Async.StartChildAsTask 53 | yield! 54 | replicateUntilNoneAsync (Task.chooseTask (err |> Task.taskFault) (async.Delay mb.Receive)) 55 | |> mapAsync id } 56 | *) 57 | 58 | let private _mapAsyncParallelUnits (units:string) (unitsPerMinute:float) (f:'a -> Async<'b>) (s:AsyncSeq) : AsyncSeq<'b> = asyncSeq { 59 | use mb = MailboxProcessor.Start (ignore >> async.Return) 60 | let resolution = 0.5 //minutes 61 | let mutable markTime = DateTime.Now.Ticks 62 | let mutable unitCount = 0uL 63 | 64 | let incrUnits i = Interlocked.Add(&unitCount,i) 65 | 66 | let reset i t = 67 | Interlocked.Exchange(&unitCount,i) |> ignore 68 | Interlocked.Exchange(&markTime,t) |> ignore 69 | 70 | //some randomness to stagger calls 71 | let rand5Pct() = 72 | let rng = Random() 73 | (rng.Next(0,5) |> float) / 100.0 74 | 75 | let! err = 76 | s 77 | |> AsyncSeq.iterAsync (fun (t,a) -> async { 78 | let unitCountF = incrUnits t |> float 79 | let elapsed = (DateTime.Now - DateTime(markTime)).TotalMinutes 80 | if elapsed > resolution then 81 | //use a sliding rate 82 | let t = DateTime.Now.AddMinutes (-resolution / 2.0) 83 | let i = (uint64 (unitCountF / 2.0)) 84 | reset i t.Ticks 85 | let rate = 86 | if elapsed < 0.001 then 87 | let overagePct = rand5Pct() 88 | unitsPerMinute * (1.0 + overagePct) //initially (elapsed ~= 0) assume small random overage so initial calls are staggered 89 | else 90 | unitCountF / elapsed |> min (unitsPerMinute * 2.0) //cap rate to 2x 91 | printfn $"{units}/min: %0.0f{rate} [per sec %0.1f{rate/60.0}]" 92 | let overage = rate - unitsPerMinute 93 | if overage > 0.0 then 94 | //how much of next resolution period we should wait? 95 | //scale based on overage as %age of base rate 96 | let overagePct = overage / unitsPerMinute + (rand5Pct()) 97 | let wait = resolution * overagePct * 60000.0 |> int 98 | printfn $"wait sec %0.1f{float wait/1000.0}" 99 | do! Async.Sleep wait 100 | let! b = Async.StartChild (async { 101 | try return! f a 102 | finally () }) 103 | mb.Post (Some b) }) 104 | |> Async.map (fun _ -> mb.Post None) 105 | |> Async.StartChildAsTask 106 | yield! 107 | AsyncSeq.unfoldAsync (fun (t:Task) -> async{ 108 | if t.IsFaulted then 109 | return None 110 | else 111 | let! d = mb.Receive() 112 | match d with 113 | | Some c -> 114 | let! d' = c 115 | return Some (d',t) 116 | | None -> return None 117 | }) 118 | err 119 | } 120 | 121 | ///Invoke f in parallel while maintaining the tokens per minute rate. 122 | ///Input is a sequence of (tokens:unint64 *'a) where the tokens is the number of input tokens associated with value 'a. 123 | ///Note: ordering is not maintained 124 | let mapAsyncParallelTokenLimit (tokensPerMinute:float) (f:'a -> Async<'b>) (s:AsyncSeq) : AsyncSeq<'b> = 125 | _mapAsyncParallelUnits "tokens" tokensPerMinute f s 126 | 127 | ///Invoke f in parallel while maintaining opsPerSecond rate. 128 | ///Note: ordering is not maintained 129 | let mapAsyncParallelRateLimit (opsPerSecond:float) (f:'a -> Async<'b>) (s:AsyncSeq<'a>) : AsyncSeq<'b> = 130 | _mapAsyncParallelUnits "ops" (opsPerSecond * 60.0) f (s |> AsyncSeq.map (fun a -> 1UL,a)) 131 | 132 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/Connection.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.GenAI 2 | 3 | type CosmosDbConnection = { 4 | ConnectionString : string 5 | DatabaseName : string 6 | ContainerName : string 7 | } 8 | 9 | module Connection = 10 | open FSharp.CosmosDb 11 | open FSharp.Control 12 | 13 | let tryCreate<'containerType>(cstr,database,container) = 14 | try 15 | let db = 16 | Cosmos.fromConnectionString cstr 17 | |> Cosmos.database database 18 | do 19 | db 20 | |> Cosmos.createDatabaseIfNotExists 21 | |> Cosmos.execAsync 22 | |> AsyncSeq.iter (printfn "%A") 23 | |> Async.RunSynchronously 24 | 25 | do 26 | db 27 | |> Cosmos.container container 28 | |> Cosmos.createContainerIfNotExists<'containerType> 29 | |> Cosmos.execAsync 30 | |> AsyncSeq.iter (printfn "%A") 31 | |> Async.RunSynchronously 32 | Some { ConnectionString = cstr; DatabaseName = database; ContainerName = container } 33 | with ex -> 34 | Env.logException (ex,"Monitoring.installTable: ") 35 | None 36 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/FsOpenAI.GenAI.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net9.0 4 | true 5 | Debug;Release;UNAUTHENTICATED 6 | AnyCPU 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/Gen/ChatUtils.fs: -------------------------------------------------------------------------------- 1 | module FsOpenAI.GenAI.ChatUtils 2 | open FsOpenAI.Shared 3 | open Microsoft.SemanticKernel.ChatCompletion 4 | 5 | [] 6 | module ChatUtils = 7 | 8 | let temperature = function 9 | | Factual -> 0.f 10 | | Exploratory -> 0.2f 11 | | Creative -> 0.7f 12 | 13 | let serializeChat (ch:Interaction) : ChatLog = 14 | { 15 | SystemMessge = ch.SystemMessage 16 | Messages = 17 | ch.Messages 18 | |> Seq.filter (fun m -> not(Utils.isEmpty m.Message)) 19 | |> Seq.map(fun m -> {ChatLogMsg.Role = (match m.Role with User -> "User" | _ -> "Assistant"); ChatLogMsg.Content = m.Message}) 20 | |> Seq.toList 21 | Temperature = ch.Parameters.Mode |> temperature |> float 22 | MaxTokens = ch.Parameters.MaxTokens 23 | } 24 | 25 | let toChatHistory (ch:Interaction) = 26 | let h = ChatHistory() 27 | if ch.Parameters.ModelType <> MT_Logic && Utils.notEmpty ch.SystemMessage then //o1 does not support system messages 28 | h.AddSystemMessage(ch.SystemMessage) 29 | for m in ch.Messages do 30 | let role = if m.IsUser then AuthorRole.User else AuthorRole.Assistant 31 | h.AddMessage(role,m.Message) 32 | h 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/Gen/Endpoints.fs: -------------------------------------------------------------------------------- 1 | module FsOpenAI.GenAI.Endpoints 2 | open System 3 | open Microsoft.SemanticKernel 4 | open FsOpenAI.Shared 5 | open Microsoft.SemanticKernel.Connectors.OpenAI 6 | open Microsoft.SemanticKernel.ChatCompletion 7 | open Microsoft.SemanticKernel.Embeddings 8 | 9 | [] 10 | module Endpoints = 11 | let rng = Random() 12 | let randSelect (ls:_ list) = ls.[rng.Next(ls.Length)] 13 | 14 | let getAzureEndpoint (endpoints:AzureOpenAIEndpoints list) = 15 | if List.isEmpty endpoints then raise (ConfigurationError "No Azure OpenAI endpoints configured") 16 | let endpt = randSelect endpoints 17 | let rg = endpt.RESOURCE_GROUP 18 | let url = $"https://{rg}.openai.azure.com" 19 | rg,url,endpt.API_KEY 20 | 21 | let raiseNoOpenAIKey() = raise (ConfigurationError "No OpenAI key configured. Please set the key in application settings") 22 | 23 | let getOpenAIEndpoint parms = 24 | match parms.OPENAI_KEY with 25 | | Some key when Utils.notEmpty key -> "https://api.openai.com/v1/chat/completions",key 26 | | _ -> raiseNoOpenAIKey() 27 | 28 | let serviceEndpoint (parms:ServiceSettings) (backend:Backend) (model:string) = 29 | match backend with 30 | | AzureOpenAI -> 31 | let rg,url,key = getAzureEndpoint parms.AZURE_OPENAI_ENDPOINTS 32 | let url = $"https://{rg}.openai.azure.com/openai/deployments/{model}/chat/completions?api-version=2023-07-01-preview"; 33 | url,key 34 | | OpenAI -> getOpenAIEndpoint parms 35 | 36 | let private getClientFor (parms:ServiceSettings) backend model : IChatCompletionService*string = 37 | match backend with 38 | | AzureOpenAI -> 39 | let rg,url,key = getAzureEndpoint parms.AZURE_OPENAI_ENDPOINTS 40 | let clr = Connectors.AzureOpenAI.AzureOpenAIChatCompletionService(model,url,key) 41 | clr,rg 42 | | OpenAI -> 43 | let key = match parms.OPENAI_KEY with Some key when Utils.notEmpty key -> key | _ -> raiseNoOpenAIKey() 44 | OpenAIChatCompletionService(model,key),"OpenAI" 45 | 46 | let private getEmbeddingsClientFor (parms:ServiceSettings) backend model : ITextEmbeddingGenerationService*string = 47 | match backend with 48 | | AzureOpenAI -> 49 | let rg,url,key = getAzureEndpoint parms.AZURE_OPENAI_ENDPOINTS 50 | let clr = Connectors.AzureOpenAI.AzureOpenAITextEmbeddingGenerationService(model,url,key) 51 | clr,rg 52 | | OpenAI -> 53 | let key = match parms.OPENAI_KEY with Some key when Utils.notEmpty key -> key | _ -> raiseNoOpenAIKey() 54 | OpenAITextEmbeddingGenerationService(model,key),"OpenAI" 55 | 56 | let getClient (parms:ServiceSettings) (ch:Interaction) model = getClientFor parms ch.Parameters.Backend model 57 | 58 | let getEmbeddingsClient (parms:ServiceSettings) (ch:Interaction) model = getEmbeddingsClientFor parms ch.Parameters.Backend model 59 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/Gen/Models.fs: -------------------------------------------------------------------------------- 1 | module FsOpenAI.GenAI.Models 2 | open FsOpenAI.Shared 3 | 4 | [] 5 | module Models = 6 | 7 | let pick modelRefs = 8 | modelRefs 9 | |> List.tryHead 10 | |> Option.defaultWith(fun () -> raise (ConfigurationError "No model configured" )) 11 | 12 | let internal chatModels (invCtx:InvocationContext) backend = 13 | let modelsConfig = invCtx.ModelsConfig 14 | let modelRefs = 15 | modelsConfig.ChatModels 16 | |> List.tryFind (fun m -> m.Backend = backend) 17 | |> Option.map(fun x -> [x]) 18 | |> Option.defaultValue [] 19 | if modelRefs.IsEmpty then raise (ConfigurationError $"No chat model(s) configured for backend '{backend}'") 20 | modelRefs 21 | 22 | let private logicModels (invCtx:InvocationContext) backend = 23 | let modelsConfig = invCtx.ModelsConfig 24 | let modelRefs = 25 | modelsConfig.LogicModels 26 | |> List.tryFind (fun m -> m.Backend = backend) 27 | |> Option.map(fun x -> [x]) 28 | |> Option.defaultValue (chatModels invCtx backend) 29 | if modelRefs.IsEmpty then raise (ConfigurationError $"No logic or chat model(s) configured for backend '{backend}'") 30 | modelRefs 31 | 32 | let getModels (ch:InteractionParameters) invCtx backend = 33 | match ch.ModelType with 34 | | MT_Chat -> chatModels invCtx backend 35 | | MT_Logic -> logicModels invCtx backend 36 | 37 | let lowcostModels (invCtx:InvocationContext) backend = 38 | let modelsConfig = invCtx.ModelsConfig 39 | let modelRefs = 40 | modelsConfig.ChatModels 41 | |> List.tryFind (fun m -> m.Backend = backend) 42 | |> Option.map(fun x -> [x]) 43 | |> Option.defaultValue [] 44 | if modelRefs.IsEmpty then raise (ConfigurationError $"No lowcost chat model(s) configured for backend '{backend}'") //primarily used for ancilary tasks 45 | modelRefs 46 | 47 | let visionModel (backend:Backend) (modelConfig:ModelsConfig) = 48 | let filter (m:ModelRef) = if m.Backend = backend then Some m else None 49 | (modelConfig.ChatModels |> List.choose filter) 50 | |> List.filter (fun x->x.Model.Contains("-4o")) 51 | |> List.tryHead 52 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/Gen/SKernel.fs: -------------------------------------------------------------------------------- 1 | module FsOpenAI.GenAI.SKernel 2 | open Microsoft.SemanticKernel 3 | open Microsoft.Extensions.Logging 4 | open Microsoft.SemanticKernel.Connectors.OpenAI 5 | open Microsoft.Extensions.DependencyInjection 6 | open FSharp.Control 7 | open FsOpenAI.Shared 8 | open FsOpenAI.GenAI.Endpoints 9 | open FsOpenAI.GenAI.ChatUtils 10 | 11 | [] 12 | module SKernel = 13 | let logger = 14 | {new ILogger with 15 | member this.BeginScope(state) = raise (System.NotImplementedException()) 16 | member this.IsEnabled(logLevel) = true 17 | member this.Log(logLevel, eventId, state, ``exception``, formatter) = 18 | let msg = formatter.Invoke(state,``exception``) 19 | printfn "Kernel: %s" msg 20 | } 21 | 22 | let loggerFactory = 23 | {new ILoggerFactory with 24 | member this.AddProvider(provider) = () 25 | member this.CreateLogger(categoryName) = logger 26 | member this.Dispose() = () 27 | } 28 | 29 | let promptSettings (parms:ServiceSettings) (ch:Interaction) = 30 | match ch.Parameters.ModelType with 31 | | MT_Chat -> 32 | new OpenAIPromptExecutionSettings( 33 | MaxTokens = ch.Parameters.MaxTokens, 34 | Temperature = (ChatUtils.temperature ch.Parameters.Mode |> float), 35 | TopP = 1) 36 | | MT_Logic -> 37 | new OpenAIPromptExecutionSettings(MaxTokens = ch.Parameters.MaxTokens, Temperature=1.0) 38 | 39 | let baseKernel (parms:ServiceSettings) (modelRefs:ModelRef list) (ch:Interaction) = 40 | let chatModel = modelRefs.Head.Model 41 | let builder = Kernel.CreateBuilder() 42 | builder.Services.AddLogging(fun c -> c.AddConsole().SetMinimumLevel(LogLevel.Information) |>ignore) |> ignore 43 | match ch.Parameters.Backend with 44 | | AzureOpenAI -> 45 | let rg,uri,key = Endpoints.getAzureEndpoint parms.AZURE_OPENAI_ENDPOINTS 46 | builder.AddAzureOpenAIChatCompletion(deploymentName = chatModel,endpoint = uri, apiKey = key) 47 | | OpenAI -> 48 | let key = match parms.OPENAI_KEY with Some k -> k | None -> Endpoints.raiseNoOpenAIKey() 49 | builder.AddOpenAIChatCompletion(chatModel,key) 50 | 51 | let kernelArgsFrom parms ch (args:(string*string) seq) = 52 | let sttngs = promptSettings parms ch 53 | let kargs = KernelArguments(sttngs) 54 | for (k,v) in args do 55 | kargs.Add(k,v) 56 | kargs 57 | 58 | let kernelArgsDefault (args:(string*string) seq) = 59 | let sttngs = new OpenAIPromptExecutionSettings(MaxTokens = 150, Temperature = 0, TopP = 1) 60 | let kargs = KernelArguments(sttngs) 61 | for (k,v) in args do 62 | kargs.Add(k,v) 63 | kargs 64 | 65 | let kernelArgs (args:(string*string) seq) (overrides:OpenAIPromptExecutionSettings->unit) = 66 | let args = kernelArgsDefault args 67 | args.ExecutionSettings 68 | |> Seq.iter(fun kv -> 69 | let sttngs = (kv.Value :?> OpenAIPromptExecutionSettings) 70 | overrides sttngs) 71 | args 72 | 73 | let renderPrompt (prompt:string) (args:KernelArguments) = 74 | task { 75 | let k = Kernel.CreateBuilder().Build() 76 | let fac = KernelPromptTemplateFactory() 77 | let cfg = PromptTemplateConfig(template = prompt) 78 | let pt = fac.Create(cfg) 79 | let! rslt = pt.RenderAsync(k,args) |> Async.AwaitTask 80 | return rslt 81 | } 82 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/Gen/SemanticVectorSearch.fs: -------------------------------------------------------------------------------- 1 | module SemanticVectorSearch 2 | open System 3 | open Microsoft.SemanticKernel.Memory 4 | open System.Runtime.CompilerServices 5 | open Azure.AI.OpenAI 6 | open Azure.Search.Documents.Models 7 | open Azure.Search.Documents 8 | open FSharp.Control 9 | open Microsoft.SemanticKernel.Embeddings 10 | 11 | type SearchMode = Semantic | Hybrid | Plain 12 | 13 | type CognitiveSearch 14 | ( 15 | mode, 16 | srchClient:SearchClient, 17 | embeddingClient:ITextEmbeddingGenerationService, 18 | vectorFields, 19 | contentField, 20 | sourceRefField, 21 | descriptionField) = 22 | 23 | let idField = "id" 24 | 25 | let toMetadata (d:SearchDocument) = 26 | let id = d.[idField] :?> string 27 | let content = d.[contentField] :?> string 28 | let source = d.[sourceRefField] :?> string 29 | let desc = d.[descriptionField] :?> string 30 | MemoryRecordMetadata( 31 | isReference = true, 32 | id = id, 33 | text = content, 34 | description = desc, 35 | externalSourceName = source, 36 | additionalMetadata = srchClient.IndexName) 37 | 38 | let ( ?> ) (a:Nullable) def = if a.HasValue then a.Value else def 39 | 40 | let toMemoryResult(r:SearchResult) = 41 | let vectorField = Seq.head vectorFields 42 | let embRaw : obj = if r.Document.ContainsKey vectorField then r.Document.[vectorField] else null 43 | let emb : Nullable> = 44 | if embRaw = null then Nullable() 45 | else 46 | let emb : float32[] = embRaw :?> obj[] |> Array.map (fun x -> x :?> float |> float32) 47 | emb |> ReadOnlyMemory |> Nullable 48 | MemoryQueryResult(toMetadata(r.Document),r.Score ?> 1.0 ,emb) 49 | 50 | interface ISemanticTextMemory with 51 | member this.GetAsync(collection, key, withEmbedding, kernel, cancellationToken) = raise (System.NotImplementedException()) 52 | member this.GetCollectionsAsync(kernel, cancellationToken) = raise (System.NotImplementedException()) 53 | member this.RemoveAsync(collection, key, kernel, cancellationToken) = raise (System.NotImplementedException()) 54 | member this.SaveInformationAsync(collection, text, id, description, additionalMetadata, kernel, cancellationToken) = raise (System.NotImplementedException()) 55 | member this.SaveReferenceAsync(collection, text, externalId, externalSourceName, description, additionalMetadata, kernel, cancellationToken) = raise (System.NotImplementedException()) 56 | member this.SearchAsync(collection, query, limit, minRelevanceScore, withEmbeddings, kernel, [] cancellationToken) = 57 | asyncSeq { 58 | let so = SearchOptions(Size=limit) 59 | [idField; contentField; sourceRefField; descriptionField] |> Seq.iter so.Select.Add 60 | match mode with 61 | | Semantic | Hybrid -> 62 | let! resp = embeddingClient.GenerateEmbeddingsAsync(ResizeArray[query]) |> Async.AwaitTask 63 | let eVec = resp.[0] 64 | let vec = VectorizedQuery(KNearestNeighborsCount = limit,vector = eVec) 65 | vectorFields |> Seq.iter vec.Fields.Add 66 | //so.Filter <- query 67 | so.VectorSearch <- new VectorSearchOptions() 68 | so.VectorSearch.Queries.Add(vec) 69 | let qSearch = if mode = SearchMode.Hybrid then query else null 70 | let! srchRslt = srchClient.SearchAsync(qSearch,so) |> Async.AwaitTask 71 | let rs = srchRslt.Value.GetResultsAsync() |> AsyncSeq.ofAsyncEnum |> AsyncSeq.map toMemoryResult 72 | yield! rs 73 | | Plain -> 74 | vectorFields |> Seq.iter so.Select.Add 75 | let! srchRslt = srchClient.SearchAsync(query,so) |> Async.AwaitTask 76 | let rs = srchRslt.Value.GetResultsAsync() |> AsyncSeq.ofAsyncEnum |> AsyncSeq.map toMemoryResult 77 | yield! rs 78 | } 79 | |> AsyncSeq.toAsyncEnum 80 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/Gen/TemplateParser.fs: -------------------------------------------------------------------------------- 1 | module FsOpenAI.GenAI.TemplateParser 2 | open System 3 | 4 | ///parse semantic kernel template to extract variables and function blocks 5 | 6 | type Block = VarBlock of string | FuncBlock of string*string option 7 | 8 | [] 9 | module internal StateMachine = 10 | let MAX_LITERAL = 3000 11 | let eof = Seq.toArray "" 12 | let inline error x xs = failwithf "%s got %s" x (String(xs |> Seq.truncate 100 |> Seq.toArray)) 13 | 14 | let c2s cs = cs |> List.rev |> Seq.toArray |> String 15 | 16 | let toVar = c2s >> VarBlock 17 | let toFunc1 cs = FuncBlock (c2s cs,None) 18 | let toFunc2 cs vs = FuncBlock(c2s cs, Some (c2s vs)) 19 | 20 | let rec start (acc:Block list) = function 21 | | [] -> acc 22 | | '{'::rest -> brace1 acc rest 23 | | _::rest -> start acc rest 24 | and brace1 acc = function 25 | | [] -> error "expected {" eof 26 | | '{'::rest -> brace2 acc rest 27 | | x::rest -> error "expected {" rest 28 | and brace2 acc = function 29 | | [] -> error "expecting $ after {{" eof 30 | | '$'::rest -> beginVar [] acc rest 31 | | c::rest when Char.IsWhiteSpace c -> brace2 acc rest 32 | | c::rest when c <> '}' && c <> '{' -> beginFunc [] acc (c::rest) 33 | | xs -> error "Expected '$'" xs 34 | and beginVar vacc acc = function 35 | | [] -> error "expecting }" eof 36 | | '}'::rest -> braceEnd1 ((toVar vacc)::acc) rest 37 | | c::rest when (Char.IsWhiteSpace c) -> braceEnd1 ((toVar vacc)::acc) rest 38 | | x::rest -> beginVar (x::vacc) acc rest 39 | and braceEnds acc = function 40 | | [] -> error "expecting }}" eof 41 | | c::rest when Char.IsWhiteSpace c -> braceEnds acc rest 42 | | c::rest when c = '}' -> braceEnd1 acc rest 43 | | c::rest -> error "expected }}" rest 44 | and braceEnd1 acc = function 45 | | [] -> error "expecting }" eof 46 | | '}'::rest -> start acc rest 47 | | ' '::rest -> braceEnd1 acc rest //can ignore whitespace 48 | | xs -> error "expecting }}" xs 49 | and beginFunc facc acc = function 50 | | [] -> error "expecting function name" eof 51 | | c::rest when Char.IsWhiteSpace c -> beginParm [] facc acc rest 52 | | c::rest when c = '}' -> braceEnd1 ((toFunc1 facc)::acc) rest 53 | | c::rest -> beginFunc (c::facc) acc rest 54 | and beginParm pacc facc acc = function 55 | | [] -> error "expecting function call parameter" eof 56 | | c::rest when Char.IsWhiteSpace c -> beginParm pacc facc acc rest 57 | | c::rest when c = '$' -> beginParmVar (c::pacc) facc acc rest 58 | | c::rest when c = '"' -> beginParmLit [] facc acc rest 59 | | c::rest -> beginParmVar (c::pacc) facc acc rest 60 | and beginParmVar pacc facc acc = function 61 | | [] -> error "expecting parameter name after $" eof 62 | | c::rest when Char.IsWhiteSpace c -> braceEnds ((toFunc2 facc pacc)::acc) rest 63 | | c::rest when c = '}' -> braceEnd1 ((toFunc2 facc pacc)::acc) rest 64 | | c::rest -> beginParmVar (c::pacc) facc acc rest 65 | and beginParmLit pacc facc acc = function 66 | | [] -> error """expecting " """ eof 67 | | c::rest when (List.length pacc > MAX_LITERAL) -> error "max literal size exceeded" rest 68 | | c::rest when c = '"' -> braceEnds ((toFunc2 facc pacc)::acc) rest 69 | | c::rest -> beginParmLit (c::pacc) facc acc rest 70 | 71 | 72 | let extractVars templateStr = 73 | start [] (templateStr |> Seq.toList) 74 | |> List.distinct 75 | 76 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/Gen/Tokens.fs: -------------------------------------------------------------------------------- 1 | module FsOpenAI.GenAI.Tokens 2 | open System 3 | open Microsoft.DeepDev 4 | open FsOpenAI.Shared 5 | open FsOpenAI.GenAI.Models 6 | 7 | [] 8 | module Tokens = 9 | let tokenSize (s:string) = 10 | let tokenizer = TokenizerBuilder.CreateByModelNameAsync("gpt-4").GetAwaiter().GetResult(); 11 | let tokens = tokenizer.Encode(s, new System.Collections.Generic.HashSet()); 12 | float tokens.Count 13 | 14 | let msgRole (m:InteractionMessage) = if m.IsUser then "User" else "Assistant" 15 | 16 | let tokenEstimateMessages (msgs:InteractionMessage seq) = 17 | let xs = 18 | seq { 19 | for m in msgs do 20 | yield $"[{msgRole m}]" 21 | yield m.Message 22 | } 23 | String.Join("\n",xs) 24 | |> tokenSize 25 | 26 | let tokenEstimate ch = 27 | let xs = 28 | seq { 29 | yield "[System]" 30 | yield ch.SystemMessage 31 | for m in ch.Messages do 32 | yield $"[{msgRole m}]" 33 | yield m.Message 34 | } 35 | String.Join("\n",xs) 36 | |> tokenSize 37 | 38 | let tokenBudget modelsConfig ch = 39 | Models.chatModels modelsConfig ch.Parameters.Backend 40 | |> List.map (_.TokenLimit) |> List.max |> float 41 | 42 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/Gen/WebCompletion.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.GenAI 2 | open System 3 | open System.Net.Http 4 | open FSharp.Control 5 | open FsOpenAI.Shared 6 | open FsOpenAI.Shared.Interactions 7 | open Microsoft.SemanticKernel 8 | open FsOpenAI.GenAI.SKernel 9 | 10 | module bingApi = 11 | let private searchQuery (s:string) = 12 | $"https://api.bing.microsoft.com/v7.0/search?q={Uri.EscapeDataString(s)}&safeSearch=Strict&responseFilter=webPages&count=20" 13 | 14 | type WebPage = 15 | { 16 | name : string 17 | url : string 18 | snippet : string 19 | } 20 | 21 | type WebPages = { 22 | value : WebPage list 23 | } 24 | 25 | type Resp = 26 | { 27 | webPages : WebPages option 28 | } 29 | 30 | type HttpC(key:string) as this = 31 | inherit HttpClient() 32 | do 33 | this.DefaultRequestHeaders.Add("Ocp-Apim-Subscription-Key",key) 34 | 35 | let search key s = 36 | async{ 37 | use c = new HttpC(key) 38 | let uri = searchQuery s 39 | let! resp = c.GetStringAsync(uri) |> Async.AwaitTask 40 | try 41 | let wresp = Text.Json.JsonSerializer.Deserialize(resp) 42 | return wresp 43 | with ex -> 44 | return {webPages=None} 45 | } 46 | 47 | module WebCompletion = 48 | 49 | let searchIndicated (answer:string) = (answer.Contains("bing.search", StringComparison.OrdinalIgnoreCase)) 50 | 51 | let askModel parms invCtx ch dispatch = 52 | async { 53 | dispatch (Srv_Ia_Notification (ch.Id,"Querying model first")) 54 | let question = Interaction.lastNonEmptyUserMessageText ch 55 | let args = SKernel.kernelArgsDefault ["input",question] 56 | let! prompt = SKernel.renderPrompt Prompts.WebSearch.answerQuestionOrDoSearch args |> Async.AwaitTask 57 | let ch = Interaction.setUserMessage prompt ch 58 | let! rslt = Completions.completeChat parms invCtx ch dispatch None None 59 | return (rslt.Content,question) 60 | } 61 | 62 | let processWebChat (parms:ServiceSettings) (invCtx:InvocationContext) (ch:Interaction) dispatch = 63 | async { 64 | try 65 | if parms.BING_ENDPOINT.IsNone then failwith "Bing endpoint not set" 66 | let bingKey = parms.BING_ENDPOINT.Value.API_KEY 67 | let! answer,question = askModel parms invCtx ch dispatch 68 | if searchIndicated answer then 69 | let webQ = 70 | try 71 | let ans = (answer.Replace("\"\"","\"")) 72 | let vs = TemplateParser.extractVars ans 73 | vs 74 | |> List.tryPick (function TemplateParser.FuncBlock ("bing.search",Some v) -> Some v | _ -> None) 75 | |> Option.defaultValue question 76 | with ex -> 77 | dispatch (Srv_Ia_Notification (ch.Id,"Model did not respond with a usable search query for Bing. Using user question instead for search")) 78 | question 79 | 80 | dispatch (Srv_Ia_Notification (ch.Id,"Model instructed to invoke websearch search. Searching...")) 81 | 82 | let! webRslt = bingApi.search bingKey webQ 83 | 84 | let information = 85 | match webRslt.webPages with 86 | | Some v when v.value.IsEmpty |> not -> 87 | dispatch (Srv_Ia_Notification (ch.Id,"Got web search results. Querying model again with web data included")) 88 | let docs = v.value |> List.mapi(fun i w -> {Text=w.snippet; Embedding=[||]; Ref=w.url; Title=w.name; Id=string i; Relevance=0.0; SortOrder=None;}) 89 | dispatch (Srv_Ia_SetDocs(ch.Id,docs)) 90 | String.Join('\n', v.value |> List.map(fun w -> w.snippet)) 91 | | _ -> 92 | dispatch (Srv_Ia_Notification (ch.Id,"Web search did not yield results. Continuing without web results")) 93 | "" 94 | let args = SKernel.kernelArgsFrom parms ch ["input",question; "externalInformation",information] 95 | let! prompt = SKernel.renderPrompt Prompts.WebSearch.answerQuestion args |> Async.AwaitTask 96 | let ch = Interaction.setUserMessage prompt ch 97 | do! Completions.checkStreamCompleteChat parms invCtx ch dispatch None true 98 | else 99 | dispatch (Srv_Ia_Notification(ch.Id,"Model was able to answer query by itself")) 100 | dispatch (Srv_Ia_Delta(ch.Id,answer)) 101 | dispatch(Srv_Ia_Done(ch.Id,None)) 102 | 103 | with ex -> 104 | GenUtils.handleChatException dispatch ch.Id "WebCompletion.processWebChat" ex 105 | } 106 | -------------------------------------------------------------------------------- /src/FsOpenAI.GenAI/Monitoring.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.GenAI 2 | open System 3 | open FSharp.Control 4 | open System.Threading.Channels 5 | open FsOpenAI.Shared 6 | open FSharp.CosmosDb 7 | 8 | type ChatLogMsg = { 9 | Role : string 10 | Content : string 11 | } 12 | 13 | type ChatLog = { 14 | SystemMessge: string 15 | Messages : ChatLogMsg list 16 | Temperature : float 17 | MaxTokens : int 18 | } 19 | 20 | type PromptLog = 21 | | Embedding of string 22 | | Chat of ChatLog 23 | 24 | type MFeedback = { 25 | ThumbsUpDn : int 26 | Comment : string option 27 | } 28 | 29 | 30 | type FeedbackEntry = { 31 | LogId : string 32 | UserId : string 33 | Feedback : MFeedback 34 | } 35 | 36 | type MIndexRef = { 37 | Backend: string 38 | Name: string 39 | } 40 | 41 | type DiagEntry = { 42 | [] 43 | id : string 44 | AppId : string 45 | [] 46 | UserId : string 47 | Prompt : PromptLog 48 | Feedback : MFeedback option 49 | Question : string 50 | Response : string 51 | InputTokens : int 52 | OutputTokens : int 53 | Error : string 54 | Backend : string 55 | Resource : string 56 | Model : string 57 | IndexRefs : MIndexRef list 58 | Timestamp : DateTime 59 | } 60 | 61 | type LogEntry = Diag of DiagEntry | Feedback of FeedbackEntry 62 | 63 | [] 64 | module Monitoring = 65 | let BUFFER_SIZE = 1000 66 | let BUFFER_WAIT = 10000 67 | 68 | let mutable private _cnctnInfo = lazy None 69 | 70 | let init (ccstr,database,container) = 71 | match Connection.tryCreate(ccstr,database,container) with 72 | | Some x -> _cnctnInfo <- lazy(Some x) 73 | | None -> () 74 | 75 | let getConnectionFromConfig() = 76 | try 77 | Env.appConfig.Value 78 | |> Option.bind(fun x -> Env.logInfo $"{x.DatabaseName},{x.DiagTableName}"; x.DiagTableName |> Option.map(fun t -> x.DatabaseName,t)) 79 | |> Option.bind(fun (database,container) -> 80 | Settings.getSettings().Value.LOG_CONN_STR 81 | |> Option.map(fun cstr -> Env.logInfo $"{Utils.shorten 30 cstr}";cstr,database,container)) 82 | with ex -> 83 | Env.logException (ex,"Monitoring.getConnectionFromConfig") 84 | None 85 | 86 | let ensureConnection() = 87 | match _cnctnInfo.Value with 88 | | Some _ -> () 89 | | None -> 90 | match getConnectionFromConfig() with 91 | | Some (cstr,db,cntnr) -> init(cstr,db,cntnr) 92 | | None -> () 93 | 94 | let private writeDiagAsync (diagEntries:DiagEntry[]) = 95 | async { 96 | match _cnctnInfo.Value with 97 | | Some c -> 98 | try 99 | do! 100 | Cosmos.fromConnectionString c.ConnectionString 101 | |> Cosmos.database c.DatabaseName 102 | |> Cosmos.container c.ContainerName 103 | |> Cosmos.upsertMany (Array.toList diagEntries) 104 | |> Cosmos.execAsync 105 | |> AsyncSeq.iter (fun _ -> ()) 106 | with ex -> 107 | Env.logException (ex,"writeLog") 108 | | None -> () 109 | } 110 | 111 | let private updateDiagEntry (fb:MFeedback) (de:DiagEntry) = 112 | {de with Feedback = Some fb} 113 | 114 | let private updateWithFeedbackAsync (fbEntries:FeedbackEntry[]) = 115 | async { 116 | match _cnctnInfo.Value with 117 | | Some c -> 118 | try 119 | let db = 120 | Cosmos.fromConnectionString c.ConnectionString 121 | |> Cosmos.database c.DatabaseName 122 | |> Cosmos.container c.ContainerName 123 | 124 | do! 125 | fbEntries 126 | |> AsyncSeq.ofSeq 127 | |> AsyncSeq.iterAsync(fun fb -> 128 | db 129 | |> Cosmos.update fb.LogId fb.UserId (updateDiagEntry fb.Feedback) 130 | |> Cosmos.execAsync 131 | |> AsyncSeq.iter (fun _ -> ()) 132 | ) 133 | with ex -> 134 | Env.logException (ex,"writeLog") 135 | | None -> () 136 | } 137 | 138 | let private channel = Channel.CreateBounded(BoundedChannelOptions(BUFFER_SIZE,FullMode = BoundedChannelFullMode.DropOldest)) 139 | 140 | //background loop to read channel and write diagnostics entry to backend 141 | let private consumerLoop = 142 | asyncSeq { 143 | while true do 144 | let! data = channel.Reader.ReadAsync().AsTask() |> Async.AwaitTask 145 | yield data 146 | } 147 | |> AsyncSeq.bufferByCountAndTime 10 BUFFER_WAIT 148 | |> AsyncSeq.iterAsync (fun entries -> async { 149 | let diagEntries = entries |> Array.choose (function Diag de -> Some de | _ -> None) 150 | let feedbackEntries = entries |> Array.choose (function Feedback fb -> Some fb | _ -> None) 151 | do! writeDiagAsync diagEntries 152 | do! updateWithFeedbackAsync feedbackEntries 153 | }) 154 | |> Async.Start 155 | 156 | let write logEntry = channel.Writer.WriteAsync logEntry |> ignore 157 | 158 | -------------------------------------------------------------------------------- /src/FsOpenAI.Server/.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-ef": { 6 | "version": "8.0.1", 7 | "commands": [ 8 | "dotnet-ef" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Server/BackgroundTasks.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Server 2 | open System 3 | open System.Threading 4 | open System.IO 5 | open Microsoft.Extensions.Hosting 6 | open Microsoft.Extensions.Logging 7 | open FsOpenAI.Shared 8 | open FsOpenAI.GenAI 9 | 10 | //Start background activities at startup 11 | type BackgroundTasks(logger:ILogger) = 12 | inherit BackgroundService() 13 | let mutable cts : CancellationTokenSource = Unchecked.defaultof<_> 14 | 15 | let dispose() = 16 | if cts <> Unchecked.defaultof<_> then 17 | cts.Dispose() 18 | cts <- Unchecked.defaultof<_> 19 | 20 | let scan() = 21 | logger.LogInformation("starting scan") 22 | try 23 | Directory.GetFiles(Path.GetTempPath(), $"*.{C.UPLOAD_EXT}") 24 | |> Seq.append(Directory.GetFiles(Path.GetTempPath(),"*.fsx")) 25 | |> Seq.iter(fun fn -> 26 | try 27 | let fi = FileInfo(fn) 28 | if fi.LastWriteTime < DateTime.Now.Add(C.UPLOAD_FILE_STALENESS) then 29 | File.Delete(fn) 30 | logger.LogInformation($"removed stale file {fn}") 31 | with ex -> 32 | logger.LogError(ex,$"delete stale file: {fn}") 33 | ) 34 | with ex -> 35 | logger.LogError(ex,"scan") 36 | 37 | ///remove any dangling uploaded or code eval files in the temp folder 38 | let startScanning() = 39 | cts <- new CancellationTokenSource() 40 | let scanner = 41 | async{ 42 | while not cts.IsCancellationRequested do 43 | do! Async.Sleep C.SCAN_PERIOD 44 | scan() 45 | } 46 | Async.Start(scanner,cts.Token) 47 | 48 | let connectServices() = 49 | async { 50 | try Monitoring.ensureConnection() with ex -> 51 | Env.logException (ex,"monitor") 52 | try Sessions.ensureConnection() with ex -> 53 | Env.logException (ex,"connection") 54 | } 55 | |> Async.Start 56 | 57 | override this.Dispose(): unit = dispose() 58 | 59 | override this.ExecuteAsync(stoppingToken: System.Threading.CancellationToken): System.Threading.Tasks.Task = 60 | Tasks.Task.CompletedTask 61 | 62 | override this.ExecuteTask: System.Threading.Tasks.Task = 63 | Tasks.Task.CompletedTask 64 | 65 | override this.StartAsync(cancellationToken: System.Threading.CancellationToken): System.Threading.Tasks.Task = 66 | startScanning() 67 | connectServices() 68 | Tasks.Task.CompletedTask 69 | 70 | override this.StopAsync(cancellationToken: System.Threading.CancellationToken): System.Threading.Tasks.Task = 71 | dispose() 72 | Tasks.Task.CompletedTask -------------------------------------------------------------------------------- /src/FsOpenAI.Server/FsOpenAI.Server.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | false 6 | Debug;Release;UNAUTHENTICATED 7 | AnyCPU 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /src/FsOpenAI.Server/Index.fs: -------------------------------------------------------------------------------- 1 | module FsOpenAI.Server.Index 2 | open System 3 | open System.Text 4 | open Bolero 5 | open Bolero.Html 6 | open Bolero.Server.Html 7 | open FsOpenAI 8 | open FsOpenAI.Shared 9 | 10 | let page = doctypeHtml { 11 | let uiVersion = typeof.Assembly.GetName().Version.ToString(); 12 | 13 | let appConfig = FsOpenAI.GenAI.Env.appConfig.Value 14 | let tabTitle = appConfig |> Option.bind _.AppName |> Option.defaultValue "" 15 | let appTitle = 16 | appConfig 17 | |> Option.map _.AppBarType 18 | |> Option.bind (function 19 | | Some (AppB_Base t) -> Some t 20 | | Some (AppB_Alt t) -> Some t 21 | | None -> None) 22 | let requireLogin = appConfig |> Option.map _.RequireLogin |> Option.defaultValue false 23 | let cfg = {AppTitle=appTitle; RequireLogin=requireLogin} 24 | let cfgStr = Json.JsonSerializer.Serialize(cfg, Utils.serOptions()) |> Encoding.UTF8.GetBytes |> Convert.ToBase64String 25 | 26 | head { 27 | meta { attr.charset "UTF-8" } 28 | meta { attr.name "viewport"; attr.content "width=device-width, initial-scale=1.0" } 29 | title { tabTitle } 30 | ``base`` { attr.href "/" } 31 | link {attr.rel "short icon"; attr.``type`` "image/png"; attr.href "app/imgs/favicon.png"} 32 | link { attr.rel "stylesheet"; attr.href "css/index.css" } 33 | //utils 34 | script {attr.src $"scripts/utils.js?v={uiVersion}"} 35 | //authentication 36 | script {attr.src "_content/Microsoft.Authentication.WebAssembly.Msal/AuthenticationService.js" } 37 | link { attr.rel "stylesheet"; attr.href "css/theme-override.css?v=2" } 38 | } 39 | body { 40 | input {attr.id C.LOAD_CONFIG_ID; attr.``type`` "hidden"; attr.value cfgStr; } 41 | div { 42 | attr.id "main" 43 | comp 44 | } 45 | boleroScript 46 | } 47 | 48 | //radzen - note this needs to be after the body otherwise javascript does not find DOM elements 49 | //update version to force reload over older scripts that may be cached 50 | script {attr.src $"_content/Radzen.Blazor/Radzen.Blazor.js?v={uiVersion}"} //version change forces js refresh on client 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/FsOpenAI.Server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "https": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | }, 9 | "dotnetRunMessages": true, 10 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 11 | "applicationUrl": "https://localhost:52034;http://localhost:52033" 12 | }, 13 | "server": { 14 | "commandName": "Project", 15 | "launchBrowser": true, 16 | "environmentVariables": { 17 | "ASPNETCORE_ENVIRONMENT": "Development" 18 | }, 19 | "dotnetRunMessages": true, 20 | "applicationUrl": "https://localhost:52034;http://localhost:52033" 21 | }, 22 | "serverDebug": { 23 | "commandName": "Project", 24 | "launchBrowser": true, 25 | "environmentVariables": { 26 | "ASPNETCORE_ENVIRONMENT": "Development" 27 | }, 28 | "dotnetRunMessages": true, 29 | "applicationUrl": "https://localhost:52034?server=true;http://localhost:52033" 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/FsOpenAI.Server/Samples.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Server.Templates 2 | open System.IO 3 | open FSharp.Control 4 | open FsOpenAI.Shared 5 | open FsOpenAI.GenAI 6 | open System.Text.Json 7 | open System.Text.Json.Serialization 8 | 9 | module Samples = 10 | 11 | let loadSamples() = 12 | task { 13 | let path = Path.Combine(Env.wwwRootPath(),C.TEMPLATES_ROOT.Value) 14 | let dirs = Directory.GetDirectories(path) 15 | let samples = 16 | dirs 17 | |> Seq.map (fun d -> 18 | let name = Path.GetFileName d 19 | let str = File.ReadAllText (Path.Combine(d,C.SAMPLES_JSON)) 20 | let vs : SamplePrompt list = JsonSerializer.Deserialize(str,Utils.serOptions()) 21 | name,vs) 22 | return samples 23 | } 24 | 25 | -------------------------------------------------------------------------------- /src/FsOpenAI.Server/Startup.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Server 2 | open Microsoft.AspNetCore.Builder 3 | open Microsoft.Extensions.DependencyInjection 4 | open Microsoft.Extensions.Hosting 5 | open Microsoft.Extensions.Configuration 6 | open Microsoft.Identity.Web 7 | open Bolero.Remoting.Server 8 | open Bolero.Server 9 | open Bolero.Templating.Server 10 | open Microsoft.Extensions.Logging 11 | open Blazored.LocalStorage 12 | open FsOpenAI.GenAI 13 | open Radzen 14 | 15 | module Startup = 16 | let inline (!>) (x:^a) : ^b = ((^a or ^b) : (static member op_Implicit : ^a -> ^b) x) 17 | 18 | // This method gets called by the runtime. Use this method to add services to the container. 19 | // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940 20 | let configureServices (builder:WebApplicationBuilder) = 21 | let services = builder.Services 22 | 23 | services 24 | .AddMicrosoftIdentityWebApiAuthentication(builder.Configuration, "AzureAd") 25 | // .EnableTokenAcquisitionToCallDownstreamApi() 26 | // .AddInMemoryTokenCaches() 27 | |> ignore 28 | 29 | services.AddMvc() |> ignore 30 | services.AddServerSideBlazor() |> ignore 31 | services.AddRadzenComponents() |> ignore 32 | services.AddBlazoredLocalStorage() |> ignore 33 | services.AddControllersWithViews() |> ignore 34 | services.AddRazorPages() |> ignore 35 | services.AddMsalAuthentication(fun o -> ()) |> ignore 36 | services.AddHostedService() |> ignore 37 | 38 | services 39 | .AddSignalR(fun o -> o.MaximumReceiveMessageSize <- 1_000_000; ) 40 | .AddJsonProtocol(fun o ->FsOpenAI.Client.ClientHub.configureSer o.PayloadSerializerOptions |> ignore) |> ignore 41 | 42 | services 43 | .AddAuthorization() 44 | .AddLogging() 45 | .AddBoleroHost(prerendered=false) //**** NOTE: MSAL authenication works on client side only so set prerendered=false 46 | //.AddBoleroHost() //**** for debugging only 47 | #if DEBUG 48 | .AddHotReload(templateDir = __SOURCE_DIRECTORY__ + "/../FsOpenAI.Client") 49 | #endif 50 | |> ignore 51 | 52 | let configureApp (app:WebApplication) = 53 | let env = app.Environment 54 | 55 | //configuration 56 | let config = app.Services.GetRequiredService() 57 | let logger = app.Services.GetRequiredService>() 58 | Env.init(config,logger,env.WebRootPath) 59 | 60 | app 61 | .UseWebSockets() |> ignore 62 | 63 | if env.IsDevelopment() then 64 | app.UseWebAssemblyDebugging() 65 | else 66 | app.UseHsts() |> ignore 67 | 68 | app 69 | .UseHttpsRedirection() 70 | .UseMiddleware() 71 | .UseAuthentication() 72 | .UseStaticFiles() 73 | .UseRouting() 74 | .UseAuthorization() 75 | .UseBlazorFrameworkFiles() 76 | .UseEndpoints(fun endpoints -> 77 | #if DEBUG 78 | endpoints.UseHotReload() 79 | #endif 80 | endpoints.MapBlazorHub() |> ignore 81 | endpoints.MapBoleroRemoting() |> ignore 82 | endpoints.MapHub(FsOpenAI.Shared.C.ClientHub.urlPath) |> ignore 83 | endpoints.MapFallbackToBolero(Index.page) |> ignore) 84 | |> ignore 85 | 86 | module Program = 87 | 88 | [] 89 | let main args = 90 | let builder = WebApplication.CreateBuilder(args) 91 | Startup.configureServices builder 92 | let app = builder.Build() 93 | Startup.configureApp app 94 | app.Run() 95 | 0 96 | 97 | -------------------------------------------------------------------------------- /src/FsOpenAI.Server/Templates.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Server.Templates 2 | open System.IO 3 | open FSharp.Control 4 | open FsOpenAI.Shared 5 | open FsOpenAI.GenAI 6 | open Microsoft.SemanticKernel 7 | 8 | module Templates = 9 | let private (@@) a b = Path.Combine(a,b) 10 | 11 | let toTemplates p (plugin:KernelPlugin) = 12 | plugin 13 | |> Seq.map (fun fn -> 14 | let fnq = p @@ fn.Name @@ "question.txt" 15 | let question = if File.Exists fnq then File.ReadAllText fnq |> Some else None 16 | { 17 | Name = fn.Name 18 | Description = fn.Description 19 | Template = File.ReadAllText(p @@ fn.Name @@ "skprompt.txt") 20 | Question = question 21 | } 22 | ) 23 | |> Seq.toList 24 | 25 | let loadSkill p skillName = 26 | let k = Kernel.CreateBuilder().Build() 27 | let p = Path.Combine(p,skillName) 28 | let functions = k.ImportPluginFromPromptDirectory(p) 29 | toTemplates p functions 30 | 31 | let loadTemplates() = 32 | task { 33 | let path = Path.Combine(Env.wwwRootPath(),C.TEMPLATES_ROOT.Value) 34 | let dirs = Directory.GetDirectories(path) 35 | let templates = 36 | dirs 37 | |> Seq.filter (fun p -> 38 | let dirs = Directory.GetDirectories p |> Seq.map Path.GetFileName |> Set.ofSeq 39 | dirs.Contains(C.TMPLTS_DOCQUERY) && dirs.Contains(C.TMPLTS_EXTRACTION) 40 | ) 41 | |> Seq.map (fun d -> 42 | let name = Path.GetFileName d 43 | let dqTemplates = loadSkill d C.TMPLTS_DOCQUERY 44 | let extTemplates = loadSkill d C.TMPLTS_EXTRACTION 45 | { 46 | Label = name 47 | Templates = [DocQuery, dqTemplates; Extraction, extTemplates] |> Map.ofList 48 | } 49 | ) 50 | |> Seq.toList 51 | return templates 52 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Server/appsettings.json.template: -------------------------------------------------------------------------------- 1 | { 2 | "Comment" : { 3 | "1":"Copy contents of this file to appsettings.json and place it in the same location as this" 4 | "2":"In the 'AzureAd' section set the appropriate values from the MS EntraID app registration for MSAL authentication.", 5 | "3":"appsettings.json is 'gitignored' as it may contain proprietary information" 6 | }, 7 | "Parms": { 8 | "Settings": ".fsopenai/ServiceSettings.json" 9 | }, 10 | "AzureAd": { 11 | "Instance": "https://login.microsoftonline.com/", 12 | "Domain": "", 13 | "TenantId": "", 14 | "ClientId": "", 15 | "Audience": "api://..." 16 | }, 17 | "Logging": { 18 | "LogLevel": { 19 | "Microsoft.AspNetCore.Hosting": "Warning" 20 | } 21 | }, 22 | "DebugLogging": { 23 | "LogLevel": { 24 | "Default": "Debug", 25 | "System": "Information", 26 | "Microsoft": "Information", 27 | "Microsoft.AspNetCore.SignalR": "Debug", 28 | "Microsoft.AspNetCore.Http.Connections": "Debug" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Server/wwwroot/app/AppConfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "EnabledBackends": [ 3 | { 4 | "Case": "OpenAI" 5 | } 6 | ], 7 | "EnabledChatModes": [ 8 | [ 9 | { 10 | "Case": "M_Index" 11 | }, 12 | "You are a helpful AI assistant" 13 | ], 14 | [ 15 | { 16 | "Case": "M_Doc_Index" 17 | }, 18 | "You are a helpful AI assistant" 19 | ], 20 | [ 21 | { 22 | "Case": "M_Doc" 23 | }, 24 | "You are a helpful AI assistant" 25 | ], 26 | [ 27 | { 28 | "Case": "M_Plain" 29 | }, 30 | "You are a helpful AI assistant" 31 | ] 32 | ], 33 | "DefaultMaxDocs": 10, 34 | "Roles": [], 35 | "AppBarType": { 36 | "Case": "AppB_Base", 37 | "Fields": [ 38 | "FsOpenAI Chat" 39 | ] 40 | }, 41 | "RequireLogin": false, 42 | "AssistantIcon": null, 43 | "AssistantIconColor": null, 44 | "LogoUrl": "https:/github.com/fwaris/FsOpenAI", 45 | "AppName": "FsOpenAI Chat", 46 | "AppId": "default", 47 | "PersonaText": "FsOpenAI Chat", 48 | "PersonaSubText": "Loading ...", 49 | "Disclaimer": null, 50 | "MetaIndex": "fsopenai-meta", 51 | "IndexGroups": [ 52 | "default" 53 | ], 54 | "ModelsConfig": { 55 | "EmbeddingsModels": [ 56 | { 57 | "Backend": { 58 | "Case": "AzureOpenAI" 59 | }, 60 | "Model": "text-embedding-ada-002", 61 | "TokenLimit": 8192 62 | }, 63 | { 64 | "Backend": { 65 | "Case": "OpenAI" 66 | }, 67 | "Model": "text-embedding-ada-002", 68 | "TokenLimit": 8192 69 | } 70 | ], 71 | "ChatModels": [ 72 | { 73 | "Backend": { 74 | "Case": "AzureOpenAI" 75 | }, 76 | "Model": "gpt-4o", 77 | "TokenLimit": 30000 78 | }, 79 | { 80 | "Backend": { 81 | "Case": "OpenAI" 82 | }, 83 | "Model": "gpt-4o", 84 | "TokenLimit": 127000 85 | } 86 | ], 87 | "LogicModels": [ 88 | { 89 | "Backend": { 90 | "Case": "AzureOpenAI" 91 | }, 92 | "Model": "o3-mini", 93 | "TokenLimit": 127000 94 | }, 95 | { 96 | "Backend": { 97 | "Case": "OpenAI" 98 | }, 99 | "Model": "o3-mini", 100 | "TokenLimit": 127000 101 | } 102 | ], 103 | "LowCostModels": [ 104 | { 105 | "Backend": { 106 | "Case": "AzureOpenAI" 107 | }, 108 | "Model": "gpt-4o-mini", 109 | "TokenLimit": 8000 110 | }, 111 | { 112 | "Backend": { 113 | "Case": "OpenAI" 114 | }, 115 | "Model": "gpt-4o-mini", 116 | "TokenLimit": 8000 117 | } 118 | ] 119 | }, 120 | "DatabaseName": "fsopenai", 121 | "DiagTableName": "log1", 122 | "SessionTableName": "sessions" 123 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Server/wwwroot/app/Templates/default/DocQuerySkill/Contextual/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": 1, 3 | "description": "Combine document and search results when answering question", 4 | "execution_settings": { 5 | "default": { 6 | "max_tokens": 2000, 7 | "temperature": 0.1, 8 | "top_p": 1.0, 9 | "presence_penalty": 0.0, 10 | "frequency_penalty": 0.0 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Server/wwwroot/app/Templates/default/DocQuerySkill/Contextual/question.txt: -------------------------------------------------------------------------------- 1 | Analyze the DOCUMENT with respect to the Accounting Policies in SEARCH RESULTS and provide a side-by-side comparison of how the content in DOCUMENT affects the existing content. -------------------------------------------------------------------------------- /src/FsOpenAI.Server/wwwroot/app/Templates/default/DocQuerySkill/Contextual/skprompt.txt: -------------------------------------------------------------------------------- 1 | DOCUMENT: ''' 2 | {{$document}} 3 | ''' 4 | 5 | SEARCH RESULTS: ''' 6 | {{$searchResults}} 7 | ''' 8 | 9 | Analyze the DOCUMENT in relation to SEARCH RESULTS for ANSWERING QUESTIONS. 10 | BE CONTEXTUAL. 11 | If you don't know, ask. 12 | If you are not sure, ask. 13 | Based on calculates from TODAY 14 | TODAY is {{$date}} 15 | 16 | QUESTION: ''' 17 | {{$question}} 18 | ''' 19 | 20 | ANSWER: 21 | -------------------------------------------------------------------------------- /src/FsOpenAI.Server/wwwroot/app/Templates/default/DocQuerySkill/ContextualBrief/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": 1, 3 | "description": "Combine document and search results when answering question. Produce brief output", 4 | "execution_settings": { 5 | "default": { 6 | "max_tokens": 2000, 7 | "temperature": 0.1, 8 | "top_p": 1.0, 9 | "presence_penalty": 0.0, 10 | "frequency_penalty": 0.0 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Server/wwwroot/app/Templates/default/DocQuerySkill/ContextualBrief/question.txt: -------------------------------------------------------------------------------- 1 | Analyze the DOCUMENT with respect to the Accounting Policies in SEARCH RESULTS and provide a side-by-side comparison of how the content in DOCUMENT affects the existing content. -------------------------------------------------------------------------------- /src/FsOpenAI.Server/wwwroot/app/Templates/default/DocQuerySkill/ContextualBrief/skprompt.txt: -------------------------------------------------------------------------------- 1 | DOCUMENT: ''' 2 | {{$document}} 3 | ''' 4 | 5 | SEARCH RESULTS: ''' 6 | {{$searchResults}} 7 | ''' 8 | 9 | Analyze the DOCUMENT in relation to SEARCH RESULTS for ANSWERING QUESTIONS. 10 | BE BRIEF AND TO THE POINT, BUT WHEN SUPPLYING OPINION, IF YOU SEE THE NEED, YOU CAN BE LONGER. 11 | BE CONTEXTUAL. 12 | If you don't know, ask. 13 | If you are not sure, ask. 14 | Based on calculates from TODAY 15 | TODAY is {{$date}} 16 | 17 | QUESTION: ''' 18 | {{$question}} 19 | ''' 20 | 21 | ANSWER: 22 | -------------------------------------------------------------------------------- /src/FsOpenAI.Server/wwwroot/app/Templates/default/ExtractionSkill/Concepts/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": 1, 3 | "description": "Ask AI to extract concepts and key words from document for the purpose of search", 4 | "execution_settings": { 5 | "default": { 6 | "max_tokens": 2000, 7 | "temperature": 0.1, 8 | "top_p": 1.0, 9 | "presence_penalty": 0.0, 10 | "frequency_penalty": 0.0 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Server/wwwroot/app/Templates/default/ExtractionSkill/Concepts/skprompt.txt: -------------------------------------------------------------------------------- 1 | DOCUMENT: ''' 2 | {{$document}} 3 | ''' 4 | 5 | Analyze the DOCUMENT and extract terms and concepts which can be used to perform lookup. Be sure to include any Finance terms and standards in the query. 6 | 7 | DONT GENEARTE SQL. JUST LIST THE TERMS AS COMMA-SEPARATED VALUES 8 | 9 | QUERY: 10 | -------------------------------------------------------------------------------- /src/FsOpenAI.Server/wwwroot/app/Templates/default/Samples.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "SampleChatType": { 4 | "Case": "SM_IndexQnA", 5 | "Fields": [ 6 | "ml-docs" 7 | ] 8 | }, 9 | "SampleSysMsg": "You are a helpful AI assistant", 10 | "SampleQuestion": "How to prevent overfitting?", 11 | "SampleMode": { 12 | "Case": "Factual" 13 | }, 14 | "MaxDocs": 5 15 | }, 16 | { 17 | "SampleChatType": { 18 | "Case": "SM_CodeEval" 19 | }, 20 | "SampleSysMsg": "ignored", 21 | "SampleQuestion": "Write recursive function for the Fibonacci series and return the value of the 15th element", 22 | "SampleMode": { 23 | "Case": "Factual" 24 | }, 25 | "MaxDocs": 5 26 | }, 27 | { 28 | "SampleChatType": { 29 | "Case": "SM_CodeEval" 30 | }, 31 | "SampleSysMsg": "ignored", 32 | "SampleQuestion": "Write a function to download a page from the internet and download \u0027https://www.google.com\u0027 display results", 33 | "SampleMode": { 34 | "Case": "Factual" 35 | }, 36 | "MaxDocs": 5 37 | }, 38 | { 39 | "SampleChatType": { 40 | "Case": "SM_Plain", 41 | "Fields": [ 42 | false 43 | ] 44 | }, 45 | "SampleSysMsg": "You are a helpful AI assistant", 46 | "SampleQuestion": "What are the major languages and dialects spoken in France? Only list the language names", 47 | "SampleMode": { 48 | "Case": "Factual" 49 | }, 50 | "MaxDocs": 5 51 | } 52 | ] -------------------------------------------------------------------------------- /src/FsOpenAI.Shared/AppConfig.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Shared 2 | 3 | type Backend = OpenAI | AzureOpenAI 4 | 5 | type ModelRef = 6 | { 7 | Backend : Backend 8 | Model : string 9 | TokenLimit : int 10 | } 11 | with 12 | static member Default = 13 | { 14 | Backend = OpenAI 15 | Model = "gpt-4o" 16 | TokenLimit = 2000 17 | } 18 | 19 | type ModelsConfig = 20 | { 21 | ///List of models that can be used to generated embeddings 22 | EmbeddingsModels : ModelRef list 23 | 24 | ///List of models that may be used when input is longer than the context length of short models 25 | ChatModels : ModelRef list 26 | 27 | ///List of models that may be used for complex logic processing 28 | LogicModels : ModelRef list 29 | 30 | ///List of models that may be used for ancillary tasks (e.g. summarization to reduce token count) 31 | LowCostModels : ModelRef list 32 | } 33 | with 34 | static member Default = 35 | { 36 | EmbeddingsModels = [] 37 | ChatModels = [ModelRef.Default] 38 | LogicModels = [] 39 | LowCostModels = [] 40 | } 41 | 42 | type InvocationContext = 43 | { 44 | ModelsConfig : ModelsConfig 45 | AppId : string option 46 | User : string option 47 | } 48 | with 49 | static member Default = 50 | { 51 | ModelsConfig = ModelsConfig.Default 52 | AppId = None 53 | User = None 54 | } 55 | 56 | type InteractionMode = 57 | | M_Plain 58 | | M_Index 59 | | M_Doc 60 | | M_Doc_Index 61 | | M_CodeEval 62 | 63 | type AppBarType = 64 | | AppB_Base of string 65 | | AppB_Alt of string 66 | 67 | type AppConfig = 68 | { 69 | ///Backends that are enabled for this app. First backend in the list is the default 70 | EnabledBackends : Backend list 71 | 72 | ///Set the modes of chat that can be created under this configuration 73 | EnabledChatModes : (InteractionMode*string) list 74 | 75 | ///Default number of docs for new chats 76 | DefaultMaxDocs : int 77 | 78 | ///List of app roles. If the user's identity provider provides any of the roles, the authenticated user 79 | ///is authorized. If the list is empty then any authenticted user is authorized to use this app 80 | Roles : string list 81 | 82 | ///AppBar style 83 | AppBarType : AppBarType option 84 | 85 | ///If true, only authenticated and authorized users will be allowed to 86 | ///invoke models 87 | RequireLogin : bool 88 | 89 | AssistantIcon : string option 90 | AssistantIconColor : string option 91 | 92 | ///Url to go to when main logo is clicked 93 | LogoUrl : string option 94 | 95 | ///Name of the application that will show on the browser tab 96 | AppName: string option 97 | 98 | ///Application identifier that will be logged with each call (along if user name, if authenticated). 99 | ///This string is logged as 'User' property of the API call 100 | AppId : string option 101 | 102 | PersonaText : string option 103 | PersonaSubText : string option 104 | Disclaimer : string option 105 | 106 | MetaIndex : string option 107 | 108 | ///This application can see indexes that are associated with the given groups. 109 | ///The index-to-group association is contained in the 'meta' index named in C.META_INDEX constant 110 | IndexGroups : string list 111 | 112 | ModelsConfig : ModelsConfig 113 | 114 | DatabaseName : string 115 | 116 | DiagTableName : string option 117 | 118 | SessionTableName : string option 119 | 120 | } 121 | with 122 | static member Default = 123 | { 124 | EnabledBackends = [OpenAI] 125 | EnabledChatModes = []//M_Plain,"You are a helpful AI assistant"] 126 | DefaultMaxDocs = 10 127 | Roles = [] 128 | RequireLogin = false 129 | AppBarType = None 130 | AssistantIcon = None 131 | AssistantIconColor = None 132 | Disclaimer = None 133 | LogoUrl = Some "https://github.com/fwaris/FsOpenAI" 134 | MetaIndex = None 135 | IndexGroups = [] 136 | ModelsConfig = ModelsConfig.Default 137 | AppId = None 138 | AppName = None 139 | PersonaText = None 140 | PersonaSubText = None 141 | DatabaseName = C.DFLT_COSMOSDB_NAME 142 | DiagTableName = None 143 | SessionTableName = None 144 | } 145 | 146 | -------------------------------------------------------------------------------- /src/FsOpenAI.Shared/Constants.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Shared 2 | open Utils 3 | 4 | module C = 5 | let MAX_INTERACTIONS = 15 6 | let MAX_DOCLISTS_PER_CHAT = 3 //number of document lists that a chat can maintain (others are dropped as new doc lists are added to chat) 7 | let MAX_COMMENT_LENGTH = 10000; 8 | 9 | let CHAT_RESPONSE_TIMEOUT = 1000 * 30 //15 seconds 10 | let TIMEOUT_MSG = "timeout while waiting for service to respond" 11 | 12 | let LS_OPENAI_KEY = "LS_OPENAI_KEY" 13 | let MAIN_SETTINGS = "MAIN_SETTINGS" 14 | let ADD_CHAT_MENU = "ADD_CHAT_MENU" 15 | let CHATS = "CHATS" 16 | 17 | let DARK_THEME = "DARK_THEME" 18 | let SIDE_BAR_EXPANDED = "SIDE_BAR_EXPANDED" 19 | 20 | let MAX_VIDEO_FRAMES = 20 21 | let MAX_UPLOAD_FILE_SIZE = 1024L * 1024L * 15L 22 | let UPLOAD_EXT = ".fsopenai" 23 | let UPLOAD_FILE_STALENESS = System.TimeSpan.FromMinutes(-10.0) 24 | let SCAN_PERIOD = System.TimeSpan.FromMinutes(1.0) 25 | 26 | let defaultSystemMessage = "You are a helpful AI Assistant" 27 | 28 | let TEMPLATES_ROOT = lazy("app" @@ "Templates") 29 | let TMPLTS_EXTRACTION = "ExtractionSkill" 30 | let TMPLTS_DOCQUERY = "DocQuerySkill" 31 | 32 | let SAMPLES_JSON = "Samples.json" 33 | let APP_CONFIG_PATH = lazy("app" @@ "AppConfig.json") 34 | 35 | let DEFAULT_META_INDEX = "fsopenai-meta" //meta index name. allowed: lower case letters; digits; and dashes 36 | 37 | let VALIDATE_TOKEN_EXPIRY = "Parms:ValidateTokenExpiry" // validate token expiry on each client message 38 | 39 | let SETTINGS_FILE = "Parms:Settings" //1st: look for a reference to settings json file in appSettings 40 | let SETTINGS_FILE_ENV = "FSOPENAI_SETTINGS_FILE" //2nd: look for the whole settings file in this environment variable (base64 encoded json string) 41 | let FSOPENAI_AZURE_KEYVAULT = "FSOPENAI_AZURE_KEYVAULT" //3nd: look for a key vault name in this environment variable, 42 | let FSOPENAI_AZURE_KEYVAULT_KEY = "FSOPENAI_AZURE_KEYVAULT_KEY" // and key vault key name in this environment variable 43 | // Note: key vault key value is a base64 encoded json string 44 | let DFLT_COSMOSDB_NAME = "fsopenai" 45 | let DFLT_MONITOR_TABLE_NAME = "tmgenai-log1" //Note: follow Azure Table naming rules (no underscores or dashes) 46 | let UNAUTHENTICATED = "Unauthenticated" 47 | let DFLT_APP_ID = "default" 48 | 49 | let DFLT_ASST_ICON = "robot_2" 50 | let DFLT_ASST_ICON_COLOR = "var(--rz-primary)" 51 | let LOAD_CONFIG_ID = "fsopenai_h1" 52 | 53 | module ClientHub = 54 | let fromServer = "FromServer" 55 | let fromClient = "FromClient" 56 | let uploadStream = "UploadStream" 57 | let urlPath = "/fsopenaihub" 58 | 59 | -------------------------------------------------------------------------------- /src/FsOpenAI.Shared/FsOpenAI.Shared.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net9.0 4 | true 5 | Debug;Release;UNAUTHENTICATED 6 | AnyCPU 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/FsOpenAI.Shared/Interactions.CodeEval.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Shared.Interactions.CodeEval 2 | open System 3 | open FsOpenAI.Shared 4 | 5 | module Interaction = 6 | let codeBag (ch:Interaction) = ch.Types |> List.tryPick (function CodeEval c -> Some c | _ -> None) 7 | 8 | let setCode c ch = 9 | match codeBag ch with 10 | | Some _ -> {ch with Types = ch.Types |> List.map (function CodeEval bag -> CodeEval {bag with Code = c} | x -> x)} 11 | | None -> {ch with Types = (CodeEval {CodeEvalBag.Default with Code=c})::ch.Types} 12 | 13 | let setPlan p ch = 14 | match codeBag ch with 15 | | Some _ -> {ch with Types = ch.Types |> List.map (function CodeEval bag -> CodeEval {bag with Plan = p} | x -> x)} 16 | | None -> {ch with Types = (CodeEval {CodeEvalBag.Default with Plan=p})::ch.Types} 17 | 18 | let setEvalParms p ch = 19 | match codeBag ch with 20 | | Some _ -> {ch with Types = ch.Types |> List.map (function CodeEval bag -> CodeEval {bag with CodeEvalParms = p} | x -> x)} 21 | | None -> {ch with Types = (CodeEval {CodeEvalBag.Default with CodeEvalParms=p})::ch.Types} 22 | 23 | 24 | module Interactions = 25 | open FsOpenAI.Shared.Interactions.Core.Interactions 26 | 27 | let setCode id c cs = updateWith (Interaction.setCode c) id cs 28 | let setPlan id p cs = updateWith (Interaction.setPlan p) id cs 29 | let setEvalParms id p cs = updateWith (Interaction.setEvalParms p) id cs 30 | 31 | module CodeEvalPrompts = 32 | 33 | ///GPT models are good at F# code generation but stumble on F# string interpolation syntax. 34 | ///These instructions provide guidance on string interpolation as well general code generation 35 | let sampleCodeGenPrompt = """ 36 | # String Formatting: 37 | - Use F# string interpolation in generated code, instead of the sprintf function. 38 | - Only format number as percentages if number is a percentage. 39 | - Remember in F# interpolated strings are prefixed with $. 40 | - Do not use $ in the string interpolation. 41 | - Remember to escape %% in the string interpolation for percentage formatting with %%%%. 42 | 43 | # Code generation: 44 | - The main function return signature should be of type string 45 | - It is the answer to the user query 46 | - In addition to user QUERY follow any PLAN and/or F# Type descriptions, if provided, to generate the code. 47 | - Only generate new code. Do not output any existing code 48 | - Do not create F# modules - use existing types and functions. 49 | - Always generate functions with curried arguments. 50 | - To create a set consider the 'set' operator instead of Set.ofXXX functions. 51 | - Ensure that code formatting (whitespace, etc.) is correct according to F# conventions. 52 | - Declare all constants at the top before the main function and then reference them in the code later 53 | - Do not use F# reserved keywords as variable names (e.g. 'end', 'as', etc.). 54 | - Put type annotations where needed so types are clear in the generated code. 55 | - Prefer using |> when working with lists (e.g. invoices |> List.map ...) so the types are better inferred. 56 | - When creating lists always use put the '[' on a new line, properly indented. 57 | - ALWAYS TYPE ANNOTATE THE LAMBDA FUNCTION PARAMETERS. 58 | - BE SURE TO ACTUALLY INVOKE THE GENERATED FUNCTION WITH THE APPROPRIATE ARGUMENTS TO RETURN THE FINAL RESULT. 59 | - ALWAYS PRINT THE FINAL RESULT TO THE CONSOLE 60 | """ 61 | 62 | let regenPromptTemplate = """ 63 | While compiling the given [F# CODE TO FIX] a compilation ERROR was encountered. Regenerate the code after fixing the error. 64 | Only fix the error. Do not change the code structure. The line number of the error may not be accurate. Refer to 65 | F# Types to fix code namely to properly type annotate the lambda function parameters. 66 | 67 | ERROR``` 68 | {{{{$errorMessage}}}} 69 | ``` 70 | 71 | [F# CODE TO FIX]``` 72 | {{{{$code}}}} 73 | ``` 74 | 75 | # Other Instructions 76 | FOLLOW F# WHITESPACE RULES 77 | ENSURE THAT THE F# CODE RETURNS A VALUE OF TYPE string. Fix the code if necessary. 78 | 79 | ```fsharp 80 | 81 | """ -------------------------------------------------------------------------------- /src/FsOpenAI.Shared/Interactions.Core.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Shared.Interactions.Core 2 | open FsOpenAI.Shared 3 | 4 | module Interactions = 5 | let private update f id (c:Interaction) = if c.Id = id then f c else c 6 | let updateWith f id cs = cs |> List.map(update f id) 7 | let replace id cs c = cs |> List.map(fun x -> if x.Id = id then c else x) 8 | -------------------------------------------------------------------------------- /src/FsOpenAI.Shared/Settings.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Shared 2 | open System 3 | open System.Security.Claims 4 | 5 | type AzureOpenAIEndpoints = 6 | { 7 | API_KEY : string 8 | RESOURCE_GROUP : string 9 | } 10 | 11 | type ApiEndpoint = 12 | { 13 | API_KEY : string 14 | ENDPOINT : string 15 | } 16 | 17 | type ModelDeployments = 18 | { 19 | CHAT : string list 20 | COMPLETION : string list 21 | EMBEDDING : string list 22 | } 23 | 24 | type ServiceSettings = 25 | { 26 | AZURE_SEARCH_ENDPOINTS: ApiEndpoint list 27 | AZURE_OPENAI_ENDPOINTS: AzureOpenAIEndpoints list 28 | EMBEDDING_ENDPOINTS : AzureOpenAIEndpoints list 29 | BING_ENDPOINT : ApiEndpoint option 30 | //GOOGLE_KEY : string option 31 | OPENAI_KEY : string option 32 | LOG_CONN_STR : string option 33 | } 34 | -------------------------------------------------------------------------------- /src/FsOpenAI.Shared/Utils.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Shared 2 | open System 3 | open System.IO 4 | open System.Text.Json 5 | open System.Text.Json.Serialization 6 | open System.Security.Cryptography 7 | 8 | module Utils = 9 | 10 | let homePath = lazy( 11 | match Environment.OSVersion.Platform with 12 | | PlatformID.Unix 13 | | PlatformID.MacOSX -> Environment.GetEnvironmentVariable("HOME") 14 | | _ -> Environment.GetEnvironmentVariable("USERPROFILE")) 15 | 16 | let newId() = 17 | Guid.NewGuid().ToByteArray() 18 | |> Convert.ToBase64String 19 | |> Seq.takeWhile (fun c -> c <> '=') 20 | |> Seq.map (function '/' -> 'a' | c -> c) 21 | |> Seq.toArray 22 | |> String 23 | 24 | let notEmpty (s:string) = String.IsNullOrWhiteSpace s |> not 25 | let isEmpty (s:string) = String.IsNullOrWhiteSpace s 26 | let contains (s:string) (ptrn:string) = s.Contains(ptrn,StringComparison.CurrentCultureIgnoreCase) 27 | 28 | let isOpen key map = map |> Map.tryFind key |> Option.defaultValue false 29 | 30 | exception NoOpenAIKey of string 31 | 32 | let serOptions() = 33 | let o = JsonSerializerOptions(JsonSerializerDefaults.General) 34 | o.WriteIndented <- true 35 | o.ReadCommentHandling <- JsonCommentHandling.Skip 36 | JsonFSharpOptions.Default() 37 | //.WithUnionEncoding(JsonUnionEncoding.NewtonsoftLike) //consider for future as provides better roundtrip support 38 | .AddToJsonSerializerOptions(o) 39 | o 40 | 41 | let shorten len (s:string) = if s.Length > len then s.Substring(0,len) + "..." else s 42 | 43 | let (@@) a b = System.IO.Path.Combine(a,b) 44 | 45 | let idx (ptrn:string,start:int) (s:string) = 46 | let i = s.IndexOf(ptrn,start) 47 | if i < 0 then None else Some(i) 48 | 49 | let wrapBlock (s:string) = $"
{s}
" 50 | 51 | let blockQuotes (msg:string) = 52 | let i1 = idx ("```",0) msg 53 | let i2 = i1 |> Option.bind (fun i -> idx ("\n",i) msg) 54 | let i3 = i2 |> Option.bind (fun i -> idx ("```",i) msg) 55 | match i1,i2,i3 with 56 | | None,_,_ -> msg 57 | | Some i1, None, None -> msg.Substring(0,i1) + (wrapBlock (msg.Substring(i1+3))) 58 | | Some i1, Some i2, None -> msg.Substring(0,i1) + wrapBlock (msg.Substring(i2)) 59 | | Some i1, Some i2, Some i3 -> msg.Substring(0,i1) + wrapBlock(msg.Substring(i2+1,i3)) + msg.Substring(i3+3) 60 | | _ -> msg 61 | 62 | 63 | let genKey () = 64 | let key = Aes.Create() 65 | key.GenerateKey() 66 | key.GenerateIV() 67 | key.Key,key.IV 68 | 69 | let encrFile (key,iv) (path:string)(outpath) = 70 | use enc = Aes.Create() 71 | enc.Mode <- CipherMode.CBC 72 | enc.Key <- key 73 | enc.IV <- iv 74 | use inStream = new FileStream(path, FileMode.Open) 75 | use outStream = new FileStream(outpath, FileMode.Create) 76 | use encStream = new CryptoStream(outStream, enc.CreateEncryptor(), CryptoStreamMode.Write) 77 | inStream.CopyTo(encStream) 78 | 79 | let decrFile (key,iv) (path:string) (outpath:string) = 80 | use enc = Aes.Create() 81 | enc.Mode <- CipherMode.CBC 82 | enc.Key <- key 83 | enc.IV <- iv 84 | use inStream = new FileStream(path, FileMode.Open) 85 | use decrStream = new CryptoStream(inStream, enc.CreateDecryptor(), CryptoStreamMode.Read) 86 | use outStream = new FileStream(outpath, FileMode.Create) 87 | decrStream.CopyTo(outStream) 88 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/FsOpenAI.Tasks.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | true 6 | Debug;Release;UNAUTHENTICATED 7 | AnyCPU 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/Library.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Tasks 2 | 3 | module Say = 4 | let hello name = 5 | printfn "Hello %s" name 6 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/client/app/imgs/Persona.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwaris/FsOpenAI/fdf360acb35352387827ae9a755e79f09de254f0/src/FsOpenAI.Tasks/deployments/default/client/app/imgs/Persona.png -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/client/app/imgs/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwaris/FsOpenAI/fdf360acb35352387827ae9a755e79f09de254f0/src/FsOpenAI.Tasks/deployments/default/client/app/imgs/favicon.png -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/client/app/imgs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fwaris/FsOpenAI/fdf360acb35352387827ae9a755e79f09de254f0/src/FsOpenAI.Tasks/deployments/default/client/app/imgs/logo.png -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/client/appsettings.json.template: -------------------------------------------------------------------------------- 1 | { 2 | "Comment" : { 3 | "1":"Copy contents of this file to appsettings.json and place it in the same location as this", 4 | "2":"In the 'AzureAd' section set the appropriate values from the MS EntraID app registration for MSAL authentication.", 5 | "3":"appsettings.json is 'gitignored' as it may contain proprietary information" 6 | }, 7 | "AzureAd": { 8 | "Authority": "https://login.microsoftonline.com/", 9 | "ClientId": "", 10 | "ValidateAuthority": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/client/theme-override.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --rz-primary-test: #a7df58 !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/indexed_create.fsx: -------------------------------------------------------------------------------- 1 | #load "../../scripts/ScriptEnv.fsx" 2 | open System.IO 3 | open FsOpenAI.Shared 4 | open FsOpenAI.GenAI 5 | open FSharp.Control 6 | open ScriptEnv 7 | 8 | //'install' settings so secrets are available to script code 9 | let baseSettingsFile = @".fsopenai/poc/ServiceSettings.json" 10 | ScriptEnv.installSettings (Utils.homePath.Value @@ baseSettingsFile) 11 | 12 | //Notes: 13 | // - settings should contain an endpoint for Azure AI Search and OpenAI API key 14 | // - Azure offers a free tier for AI Search that can be used for this purpose 15 | 16 | 17 | let createCitations folder = 18 | //create citations.csv index file so hyperlinks to pdf pages can work - this is a requirement for the app 19 | let pdfs = Directory.GetFiles(folder, "*.pdf") 20 | let citations = pdfs |> Seq.map (fun f -> $"file:///{f},{Path.GetFileName(f)}") |> Seq.toList 21 | do 22 | ["Link,Document"] 23 | @ citations 24 | |> fun lines -> File.WriteAllLines (Path.Combine(folder, "citations.csv"),lines) 25 | 26 | //shred and index the pdfs 27 | let embModel = "text-embedding-ada-002" 28 | let clientFac() = ScriptEnv.openAiEmbeddingClient embModel 29 | 30 | let indexFolder indexName path = 31 | createCitations path 32 | let indexDef = ScriptEnv.Indexes.indexDefinition indexName 33 | ScriptEnv.Indexes.shredPdfsAsync path 34 | |> ScriptEnv.Indexes.getEmbeddingsAsync clientFac 7.0 35 | |> AsyncSeq.map ScriptEnv.Indexes.toSearchDoc 36 | |> ScriptEnv.Indexes.loadIndexAsync true indexDef 37 | 38 | //ai papers 39 | let docFolder = @"E:\s\genai\papers" //folder with pdfs to index 40 | 41 | //ML books 42 | let b1 = @"E:\s\genai\bishop" 43 | let b2 = @"E:\s\genai\mml" 44 | 45 | 46 | (* 47 | indexFolder "genai-papers" docFolder |> Async.RunSynchronously 48 | indexFolder "pattern-recognition" b1 |> Async.Start 49 | indexFolder "ml-math" b2 |> Async.Start 50 | *) 51 | 52 | //ScriptEnv.Indexes.shredPdfsAsync b1 |> AsyncSeq.take 10 |> AsyncSeq.toBlockingSeq |> Seq.iter (fun x -> printfn "%A" x) 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/server/appsettings.json.template: -------------------------------------------------------------------------------- 1 | { 2 | "Comment" : { 3 | "1":"Copy contents of this file to appsettings.json and place it in the same location as this" 4 | "2":"In the 'AzureAd' section set the appropriate values from the MS EntraID app registration for MSAL authentication.", 5 | "3":"appsettings.json is 'gitignored' as it may contain proprietary information" 6 | }, 7 | "Parms": { 8 | "Settings": ".fsopenai/ServiceSettings.json" 9 | }, 10 | "AzureAd": { 11 | "Instance": "https://login.microsoftonline.com/", 12 | "Domain": "", 13 | "TenantId": "", 14 | "ClientId": "", 15 | "Audience": "api://..." 16 | }, 17 | "Logging": { 18 | "LogLevel": { 19 | "Microsoft.AspNetCore.Hosting": "Warning" 20 | } 21 | }, 22 | "DebugLogging": { 23 | "LogLevel": { 24 | "Default": "Debug", 25 | "System": "Information", 26 | "Microsoft": "Information", 27 | "Microsoft.AspNetCore.SignalR": "Debug", 28 | "Microsoft.AspNetCore.Http.Connections": "Debug" 29 | } 30 | } 31 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/templates/default/DocQuerySkill/Contextual/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": 1, 3 | "description": "Combine document and search results when answering question", 4 | "execution_settings": { 5 | "default": { 6 | "max_tokens": 2000, 7 | "temperature": 0.1, 8 | "top_p": 1.0, 9 | "presence_penalty": 0.0, 10 | "frequency_penalty": 0.0 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/templates/default/DocQuerySkill/Contextual/question.txt: -------------------------------------------------------------------------------- 1 | Analyze the DOCUMENT with respect to the Accounting Policies in SEARCH RESULTS and provide a side-by-side comparison of how the content in DOCUMENT affects the existing content. -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/templates/default/DocQuerySkill/Contextual/skprompt.txt: -------------------------------------------------------------------------------- 1 | DOCUMENT: ''' 2 | {{$document}} 3 | ''' 4 | 5 | SEARCH RESULTS: ''' 6 | {{$searchResults}} 7 | ''' 8 | 9 | Analyze the DOCUMENT in relation to SEARCH RESULTS for ANSWERING QUESTIONS. 10 | BE CONTEXTUAL. 11 | If you don't know, ask. 12 | If you are not sure, ask. 13 | Based on calculates from TODAY 14 | TODAY is {{$date}} 15 | 16 | QUESTION: ''' 17 | {{$question}} 18 | ''' 19 | 20 | ANSWER: 21 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/templates/default/DocQuerySkill/ContextualBrief/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": 1, 3 | "description": "Combine document and search results when answering question. Produce brief output", 4 | "execution_settings": { 5 | "default": { 6 | "max_tokens": 2000, 7 | "temperature": 0.1, 8 | "top_p": 1.0, 9 | "presence_penalty": 0.0, 10 | "frequency_penalty": 0.0 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/templates/default/DocQuerySkill/ContextualBrief/question.txt: -------------------------------------------------------------------------------- 1 | Analyze the DOCUMENT with respect to the Accounting Policies in SEARCH RESULTS and provide a side-by-side comparison of how the content in DOCUMENT affects the existing content. -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/templates/default/DocQuerySkill/ContextualBrief/skprompt.txt: -------------------------------------------------------------------------------- 1 | DOCUMENT: ''' 2 | {{$document}} 3 | ''' 4 | 5 | SEARCH RESULTS: ''' 6 | {{$searchResults}} 7 | ''' 8 | 9 | Analyze the DOCUMENT in relation to SEARCH RESULTS for ANSWERING QUESTIONS. 10 | BE BRIEF AND TO THE POINT, BUT WHEN SUPPLYING OPINION, IF YOU SEE THE NEED, YOU CAN BE LONGER. 11 | BE CONTEXTUAL. 12 | If you don't know, ask. 13 | If you are not sure, ask. 14 | Based on calculates from TODAY 15 | TODAY is {{$date}} 16 | 17 | QUESTION: ''' 18 | {{$question}} 19 | ''' 20 | 21 | ANSWER: 22 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/templates/default/ExtractionSkill/Concepts/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "schema": 1, 3 | "description": "Ask AI to extract concepts and key words from document for the purpose of search", 4 | "execution_settings": { 5 | "default": { 6 | "max_tokens": 2000, 7 | "temperature": 0.1, 8 | "top_p": 1.0, 9 | "presence_penalty": 0.0, 10 | "frequency_penalty": 0.0 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/templates/default/ExtractionSkill/Concepts/skprompt.txt: -------------------------------------------------------------------------------- 1 | DOCUMENT: ''' 2 | {{$document}} 3 | ''' 4 | 5 | Analyze the DOCUMENT and extract terms and concepts which can be used to perform lookup. Be sure to include any Finance terms and standards in the query. 6 | 7 | DONT GENEARTE SQL. JUST LIST THE TERMS AS COMMA-SEPARATED VALUES 8 | 9 | QUERY: 10 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/deployments/default/templates/default/Samples.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "SampleChatType": { 4 | "Case": "SM_IndexQnA", 5 | "Fields": [ 6 | "ml-docs" 7 | ] 8 | }, 9 | "SampleSysMsg": "You are a helpful AI assistant", 10 | "SampleQuestion": "How to prevent overfitting?", 11 | "SampleMode": { 12 | "Case": "Factual" 13 | }, 14 | "MaxDocs": 5 15 | }, 16 | { 17 | "SampleChatType": { 18 | "Case": "SM_CodeEval" 19 | }, 20 | "SampleSysMsg": "ignored", 21 | "SampleQuestion": "Write recursive function for the Fibonacci series and return the value of the 15th element", 22 | "SampleMode": { 23 | "Case": "Factual" 24 | }, 25 | "MaxDocs": 5 26 | }, 27 | { 28 | "SampleChatType": { 29 | "Case": "SM_CodeEval" 30 | }, 31 | "SampleSysMsg": "ignored", 32 | "SampleQuestion": "Write a function to download a page from the internet and download \u0027https://www.google.com\u0027 display results", 33 | "SampleMode": { 34 | "Case": "Factual" 35 | }, 36 | "MaxDocs": 5 37 | }, 38 | { 39 | "SampleChatType": { 40 | "Case": "SM_Plain", 41 | "Fields": [ 42 | false 43 | ] 44 | }, 45 | "SampleSysMsg": "You are a helpful AI assistant", 46 | "SampleQuestion": "What are the major languages and dialects spoken in France? Only list the language names", 47 | "SampleMode": { 48 | "Case": "Factual" 49 | }, 50 | "MaxDocs": 5 51 | } 52 | ] -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/scripts/CopyAppSettings.fsx: -------------------------------------------------------------------------------- 1 | //find all appsettings.json files in a folder structure and copy them 2 | //to a new folder structure that mimics the original folder structure 3 | //(but only has appsettings.json files) 4 | //Because appsettings.json files are not committed to repo, this script 5 | //is useful for extracting these files and later merging them back 6 | //in into a new local instance of the repo 7 | 8 | open System 9 | open System.IO 10 | 11 | let inputFolder = @"C:\venv\source\reposTemp\TM_FsOpenAI" 12 | let outputFolder = @"C:\venv\source\reposTemp\TM_FsOpenAI_AppSettings" 13 | 14 | let isAppSettings (path:string) = Path.GetFileName(path).Equals("appsettings.json", StringComparison.OrdinalIgnoreCase) 15 | let excludeDirs = set ["bin";"obj";".git";".vs";".vscode";".git"] 16 | 17 | let makeLowerCase (path:string) = 18 | let dir = Path.GetDirectoryName(path) 19 | let file = Path.GetFileName(path).ToLower() 20 | Path.Combine(dir, file) 21 | 22 | let getAppSettings (root:string) = 23 | let rec loop acc (path:string) = 24 | let files = Directory.GetFiles(path, "*.json") 25 | let files = 26 | files 27 | |> Seq.filter (fun f -> 28 | f.ToLower().Split([|'/';'\\'|]) 29 | |> Array.exists (fun f -> excludeDirs.Contains(f)) |> not) 30 | let appSettings = files |> Seq.filter isAppSettings |> Seq.toList 31 | let appSettings = appSettings |> List.map makeLowerCase 32 | let acc = acc @ appSettings 33 | let dirs = Directory.GetDirectories(path) 34 | (acc,dirs) ||> Array.fold loop 35 | loop [] root 36 | 37 | if Directory.Exists(outputFolder) then 38 | Directory.Delete(outputFolder, true) 39 | 40 | for apps in (getAppSettings inputFolder) do 41 | let relativePath = Path.GetRelativePath(inputFolder, apps) 42 | let outputPath = Path.Combine(outputFolder, relativePath) 43 | Directory.CreateDirectory(Path.GetDirectoryName(outputPath)) |> ignore 44 | File.Copy(apps, outputPath, true) 45 | printfn "Copied %s to %s" apps outputPath 46 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/scripts/CosmoDB.fsx: -------------------------------------------------------------------------------- 1 | #load "ScriptEnv.fsx" 2 | open System 3 | open FSharp.CosmosDb 4 | open FSharp.Control 5 | //example on how to pull log data from Azure CosmosDB 6 | 7 | type SREf = {[] id:string; [] UserId:string; Timestamp:DateTime} 8 | 9 | let baseSettingsFile = @"%USERPROFILE%/.fsopenai/poc/ServiceSettings.json" 10 | ScriptEnv.installSettings baseSettingsFile 11 | let cstr = ScriptEnv.settings.Value.LOG_CONN_STR.Value 12 | 13 | let db() = 14 | Cosmos.fromConnectionString cstr 15 | |> Cosmos.database FsOpenAI.Shared.C.DFLT_COSMOSDB_NAME 16 | |> Cosmos.container FsOpenAI.Shared.C.DFLT_MONITOR_TABLE_NAME 17 | 18 | let findDrops() = 19 | db() 20 | |> Cosmos.query("select c.id, c.UserId, c.Timestamp from c where c.AppId = ''") 21 | |> Cosmos.execAsync 22 | |> AsyncSeq.toBlockingSeq 23 | |> Seq.toList 24 | 25 | let drop (drops:SREf list) = 26 | drops 27 | |> AsyncSeq.ofSeq 28 | |> AsyncSeq.iterAsync (fun c -> 29 | db() 30 | |> Cosmos.deleteItem c.id c.UserId 31 | |> Cosmos.execAsync 32 | |> AsyncSeq.iterAsync Async.Ignore) 33 | |> Async.Start 34 | 35 | let toDrop = findDrops() 36 | 37 | drop toDrop 38 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/scripts/CreateSettings.fsx: -------------------------------------------------------------------------------- 1 | #load "ScriptEnv.fsx" 2 | open FsOpenAI.Shared 3 | open System 4 | open System.IO 5 | open System.Text.Json 6 | 7 | //shows how to create a settings json in a type-safe way 8 | 9 | let serOpts = Utils.serOptions() 10 | let settings = 11 | { 12 | LOG_CONN_STR = None // Some "cosmosdb connection string" 13 | AZURE_OPENAI_ENDPOINTS = [{API_KEY ="api key"; RESOURCE_GROUP="rg"}] 14 | AZURE_SEARCH_ENDPOINTS = [] 15 | EMBEDDING_ENDPOINTS = [] 16 | BING_ENDPOINT = None // Some {API_KEY = "bing key"; ENDPOINT="https://bing.com"} 17 | OPENAI_KEY = None 18 | // GOOGLE_KEY = None 19 | } 20 | let str = JsonSerializer.Serialize(settings,serOpts) 21 | printfn $"{str}" 22 | 23 | let str2 = File.ReadAllText (Environment.ExpandEnvironmentVariables(@"%USERPROFILE%\.fsopenai/fsopenai1server/ServiceSettings.json")) 24 | let str3 = str2 |> Text.Encoding.UTF8.GetBytes |> Convert.ToBase64String 25 | printfn $"{str3}" 26 | let settings' = JsonSerializer.Deserialize(str2, serOpts) 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/scripts/ListOpenAIModels.fsx: -------------------------------------------------------------------------------- 1 | #r "nuget: System.Text.Json" 2 | open System 3 | open System.Text.Json 4 | open System.IO 5 | open System.Net.Http 6 | 7 | //List all models available - for the configured openai key 8 | 9 | let toDate (d:int64) = DateTimeOffset.FromUnixTimeSeconds(d).Date 10 | 11 | let jsonDoc = 12 | use wc = new HttpClient() 13 | let key = System.Environment.GetEnvironmentVariable("OPENAI_API_KEY") 14 | wc.DefaultRequestHeaders.Authorization <- System.Net.Http.Headers.AuthenticationHeaderValue("Bearer",key) 15 | wc.GetStringAsync("https://api.openai.com/v1/models") |> Async.AwaitTask |> Async.RunSynchronously 16 | 17 | let doc = System.Text.Json.JsonDocument.Parse(jsonDoc);; 18 | ;; 19 | doc.RootElement.EnumerateObject() 20 | |> Seq.collect(fun d -> if d.Name = "data" then d.Value.EnumerateArray() |> Seq.cast else Seq.empty) 21 | |> Seq.map(fun n -> n.GetProperty("id").GetString(), n.GetProperty("created").GetInt64() |> toDate) 22 | //|> Seq.filter (fun (m,d) -> m.StartsWith("gpt")) 23 | |> Seq.sortByDescending snd 24 | |> Seq.iter (fun (m,d) -> printfn "%s" $"{m}: {d.ToShortDateString()}") 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/scripts/LoadIndex.fsx: -------------------------------------------------------------------------------- 1 | #load "ScriptEnv.fsx" 2 | open FSharp.Control 3 | (* 4 | Example script that loads a collection of PDF documents in a local folder 5 | to an Azure vector search index 6 | *) 7 | 8 | ScriptEnv.installSettings "%USERPROFILE%/.fsopenai/ServiceSettings.json" 9 | let embModel = "text-embedding-ada-002" 10 | let clientFac() = ScriptEnv.openAiEmbeddingClient embModel 11 | let shredded = ScriptEnv.Indexes.shredHtmlAsync @"C:\s\glean\plans" 12 | let embedded = ScriptEnv.Indexes.getEmbeddingsAsync clientFac 7.0 shredded 13 | let searchDocs = embedded |> AsyncSeq.map ScriptEnv.Indexes.toSearchDoc 14 | let indexDef = ScriptEnv.Indexes.indexDefinition "plans" 15 | ScriptEnv.Indexes.loadIndexAsync true indexDef searchDocs |> Async.Start 16 | 17 | 18 | //let docs = Env.Index.shredHtmlAsync @"C:\s\glean\plans" |> AsyncSeq.toBlockingSeq |> Seq.toList 19 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/scripts/Sandbox.fsx: -------------------------------------------------------------------------------- 1 | //need .net sdk 9 installed 2 | #load "ScriptEnv.fsx" 3 | open System 4 | open System.IO 5 | open System.Text.Json 6 | open System.Text.Json.Serialization 7 | open FsOpenAI 8 | open FsOpenAI.GenAI 9 | 10 | let fn = @"C:\s\gc\Untitled-1.json" |> File.ReadAllText 11 | let serOpts = Sessions.sessionOptions.Value 12 | 13 | let fno = JsonSerializer.Deserialize(fn, serOpts) 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/scripts/SerializeSettings.fsx: -------------------------------------------------------------------------------- 1 | #load "ScriptEnv.fsx" 2 | open System 3 | open System.Text 4 | open System.IO 5 | open FsOpenAI.Shared 6 | open Utils 7 | 8 | let path = homePath.Value @@ ".fsopenai/ServiceSettings.json" 9 | 10 | let base64 = path |> File.ReadAllText |> Encoding.UTF8.GetBytes |> Convert.ToBase64String 11 | printfn "%s" base64 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/scripts/TemplateParsingSandbox.fsx: -------------------------------------------------------------------------------- 1 | open System 2 | 3 | let testSuite = 4 | [ 5 | """{{ bing.search "SEC regulations and guidelines applicable to special factors" }}""" 6 | "The weather today is {{weather.getForecast}}" 7 | "The weather today in {{$city}} is {{weather.getForecast $city}}" 8 | """The weather today in Schio is {{weather.getForecast "Schio"}}""" 9 | "{{ bing.search \"\"SEC regulations and guidelines applicable to special factors\"\" }}." 10 | ] 11 | 12 | module TemplateParser = 13 | type Block = VarBlock of string | FuncBlock of string*string option 14 | 15 | [] 16 | module internal StateMachine = 17 | let MAX_LITERAL = 1000 18 | let eof = Seq.toArray "" 19 | let inline error x xs = failwithf "%s got %s" x (String(xs |> Seq.truncate 100 |> Seq.toArray)) 20 | 21 | let c2s cs = cs |> List.rev |> Seq.toArray |> String 22 | 23 | let toVar = c2s >> VarBlock 24 | let toFunc1 cs = FuncBlock (c2s cs,None) 25 | let toFunc2 cs vs = FuncBlock(c2s cs, Some (c2s vs)) 26 | 27 | let rec start (acc:Block list) = function 28 | | [] -> acc 29 | | '{'::rest -> brace1 acc rest 30 | | _::rest -> start acc rest 31 | and brace1 acc = function 32 | | [] -> error "expected {" eof 33 | | '{'::rest -> brace2 acc rest 34 | | x::rest -> error "expected {" rest 35 | and brace2 acc = function 36 | | [] -> error "expecting $ after {{" eof 37 | | '$'::rest -> beginVar [] acc rest 38 | | c::rest when Char.IsWhiteSpace c -> brace2 acc rest 39 | | c::rest when c <> '}' && c <> '{' -> beginFunc [] acc (c::rest) 40 | | xs -> error "Expected '$'" xs 41 | and beginVar vacc acc = function 42 | | [] -> error "expecting }" eof 43 | | '}'::rest -> braceEnd1 ((toVar vacc)::acc) rest 44 | | c::rest when (Char.IsWhiteSpace c) -> braceEnd1 ((toVar vacc)::acc) rest 45 | | x::rest -> beginVar (x::vacc) acc rest 46 | and braceEnds acc = function 47 | | [] -> error "expecting }}" eof 48 | | c::rest when Char.IsWhiteSpace c -> braceEnds acc rest 49 | | c::rest when c = '}' -> braceEnd1 acc rest 50 | | c::rest -> error "expected }}" rest 51 | and braceEnd1 acc = function 52 | | [] -> error "expecting }" eof 53 | | '}'::rest -> start acc rest 54 | | ' '::rest -> braceEnd1 acc rest //can ignore whitespace 55 | | xs -> error "expecting }}" xs 56 | and beginFunc facc acc = function 57 | | [] -> error "expecting function name" eof 58 | | c::rest when Char.IsWhiteSpace c -> beginParm [] facc acc rest 59 | | c::rest when c = '}' -> braceEnd1 ((toFunc1 facc)::acc) rest 60 | | c::rest -> beginFunc (c::facc) acc rest 61 | and beginParm pacc facc acc = function 62 | | [] -> error "expecting function call parameter" eof 63 | | c::rest when Char.IsWhiteSpace c -> beginParm pacc facc acc rest 64 | | c::rest when c = '$' -> beginParmVar (c::pacc) facc acc rest 65 | | c::rest when c = '"' -> beginParmLit [] facc acc rest 66 | | c::rest -> beginParmVar (c::pacc) facc acc rest 67 | and beginParmVar pacc facc acc = function 68 | | [] -> error "expecting parameter name after $" eof 69 | | c::rest when Char.IsWhiteSpace c -> braceEnds ((toFunc2 facc pacc)::acc) rest 70 | | c::rest when c = '}' -> braceEnd1 ((toFunc2 facc pacc)::acc) rest 71 | | c::rest -> beginParmVar (c::pacc) facc acc rest 72 | and beginParmLit pacc facc acc = function 73 | | [] -> error """expecting " """ eof 74 | | c::rest when (List.length pacc > MAX_LITERAL) -> error "max literal size exceeded" rest 75 | | c::rest when c = '"' -> braceEnds ((toFunc2 facc pacc)::acc) rest 76 | | c::rest -> beginParmLit (c::pacc) facc acc rest 77 | 78 | 79 | let extractVars templateStr = 80 | start [] (templateStr |> Seq.toList) 81 | |> List.distinct 82 | 83 | 84 | let vss = testSuite |> List.map(fun x->x.Replace("\"\"","\"")) |> List.map TemplateParser.extractVars 85 | 86 | 87 | -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/scripts/TestOpenAIClient.fsx: -------------------------------------------------------------------------------- 1 | #r "nuget: Azure.AI.OpenAI, *-*" 2 | #r "nuget: FSharp.Control.AsyncSeq" 3 | 4 | open System 5 | open FSharp.Control 6 | open OpenAI 7 | 8 | //Shows how to get streaming content for chat completions 9 | 10 | let client = new OpenAIClient(Environment.GetEnvironmentVariable("OPENAI_API_KEY")) 11 | let chatc = client.GetChatClient("gpt-4o-mini") 12 | 13 | let m1 = Chat.ChatMessage.CreateUserMessage("What is the meaning of life?") 14 | 15 | let resp = chatc.CompleteChatStreamingAsync(m1) 16 | let gs = 17 | let mutable ls = [] 18 | resp 19 | |> AsyncSeq.ofAsyncEnum 20 | |> AsyncSeq.collect(fun cs -> AsyncSeq.ofSeq cs.ContentUpdate) 21 | |> AsyncSeq.map(fun x -> x.Text) 22 | |> AsyncSeq.iter(fun x-> printfn "%A" x; ls<-x::ls) 23 | |> Async.RunSynchronously 24 | String.concat "" (List.rev ls) 25 | printf "%s" gs -------------------------------------------------------------------------------- /src/FsOpenAI.Tasks/scripts/packages.fsx: -------------------------------------------------------------------------------- 1 | #r "nuget: Microsoft.Extensions.Http" 2 | #r "nuget: Microsoft.Extensions.Hosting" 3 | #r "nuget: Microsoft.AspNetCore.Components.WebAssembly, 8.0.10" 4 | #r "nuget: Microsoft.Extensions.DependencyInjection" 5 | #r "nuget: Microsoft.DeepDev.TokenizerLib" 6 | #r "nuget: Azure.Search.Documents" 7 | #r "nuget: Microsoft.SemanticKernel" 8 | #r "nuget: FSharp.SystemTextJson" 9 | #r "nuget: FSharp.Data" 10 | #r "nuget: FSharp.Collections.ParallelSeq" 11 | #r "nuget: FSharp.Control.AsyncSeq" 12 | #r "nuget: FsPickler.Json" 13 | #r "nuget: Docnet.Core" 14 | #r "nuget: PdfPig, *-*" 15 | #r "nuget: DocumentFormat.OpenXml" 16 | #r "nuget: OpenCvSharp4.Windows" 17 | #r "nuget: OpenCvSharp4.Extensions" 18 | #r "nuget: Azure.Identity" 19 | #r "nuget: azure.security.keyvault.secrets" 20 | #r "nuget: FSharp.CosmosDb" 21 | #r "nuget: ExcelDataReader" 22 | #r "nuget: ExcelDataReader.DataSet" 23 | #r "nuget: FSharp.Compiler.Service" 24 | #r "nuget: System.Text.Encoding.CodePages" 25 | #r "nuget: FSharp.Data.Html.Core" 26 | 27 | //transient packages upgraded to remove security warnings 28 | #r "nuget: System.Text.RegularExpressions" 29 | #r "nuget: NewtonSoft.json" 30 | #r "nuget: System.Net.Http" 31 | #r "nuget: System.Private.Uri" 32 | 33 | #I "../../FsOpenAI.Shared" 34 | #load "Utils.fs" 35 | #load "Constants.fs" 36 | #load "AppConfig.fs" 37 | #load "Settings.fs" 38 | #load "Types.fs" 39 | #load "Interactions.Core.fs" 40 | #load "Interactions.fs" 41 | 42 | #I "../../FsOpenAI.Vision" 43 | #load "Image.fs" 44 | #load "Video.fs" 45 | #load "VisionApi.fs" 46 | 47 | #I "../../FsOpenAI.GenAI" 48 | #load "AsyncExts.fs" 49 | #load "Env.fs" 50 | #load "Connection.fs" 51 | #load "Sessions.fs" 52 | #load "Monitoring.fs" 53 | #load "Gen/SemanticVectorSearch.fs" 54 | #load "Gen/StreamParser.fs" 55 | #load "Gen/TemplateParser.fs" 56 | #load "Gen/Models.fs" 57 | #load "Gen/Tokens.fs" 58 | #load "Gen/ChatUtils.fs" 59 | #load "Gen/Endpoints.fs" 60 | #load "Gen/SKernel.fs" 61 | #load "Gen/GenUtils.fs" 62 | #load "Gen/Prompts.fs" 63 | #load "Gen/Indexes.fs" 64 | #load "Gen/Completions.fs" 65 | #load "Gen/WebCompletion.fs" 66 | #load "Gen/IndexQnA.fs" 67 | #load "Gen/DocQnA.fs" 68 | -------------------------------------------------------------------------------- /src/FsOpenAI.Vision/FsOpenAI.Vision.fsproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net9.0 5 | true 6 | Debug;Release;UNAUTHENTICATED 7 | AnyCPU 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/FsOpenAI.Vision/Image.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Vision 2 | open System 3 | open System.IO 4 | 5 | module ImageUtils = 6 | 7 | let imagePath docPath pageNum imageNum = docPath + $".{pageNum}_{imageNum}.jpeg" 8 | let imageTextPath imagePath = imagePath + ".txt" 9 | 10 | [] 11 | module Extraction = 12 | open System.Drawing 13 | open System.Drawing.Imaging 14 | open UglyToad.PdfPig 15 | open DocumentFormat.OpenXml.Packaging 16 | open DocumentFormat.OpenXml.Presentation 17 | 18 | let exportImagesToDiskWord (filePath:string) = 19 | try 20 | use d = WordprocessingDocument.Open(filePath,false) 21 | d.MainDocumentPart.ImageParts 22 | |> Seq.iteri(fun i img -> 23 | let imgPath = ImageUtils.imagePath filePath 0 i 24 | printfn $"Image {i} : {imgPath}" 25 | use bmp = Bitmap.FromStream(img.GetStream()) 26 | bmp.Save(imgPath,ImageFormat.Jpeg)) 27 | with ex -> 28 | printfn $"Error in extractImagesWord {filePath} : {ex.Message}" 29 | 30 | let exportImagesToDiskPpt (filePath:string) = 31 | try 32 | use d = PresentationDocument.Open(filePath,false) 33 | let images = 34 | d.PresentationPart.SlideParts 35 | |> Seq.collect(fun slidePart -> 36 | slidePart.Slide.Descendants() 37 | |> Seq.choose(fun p -> 38 | try 39 | let part = slidePart.GetPartById(p.BlipFill.Blip.Embed) :?> ImagePart 40 | Some part 41 | with ex -> 42 | printfn $"error extracting as image - part {p.BlipFill.Blip.Embed.InnerText}" 43 | None)) 44 | |> Seq.toList 45 | images 46 | |> List.iteri (fun i img -> 47 | let imgPath = ImageUtils.imagePath filePath 0 i 48 | printfn $"Image {i} : {imgPath}" 49 | use bmp = Bitmap.FromStream(img.GetStream()) 50 | bmp.Save(imgPath,ImageFormat.Jpeg)) 51 | with ex -> 52 | printfn $"Error in extractImagesWord {filePath} : {ex.Message}" 53 | 54 | 55 | ///Extracts any jpeg images in pdf and saves to disk. 56 | ///Image file paths are _{page#}_{image#}.jpeg 57 | let exportImagesToDisk (path:string) = 58 | let pdf = PdfDocument.Open(path); 59 | 60 | let encoders = ImageCodecInfo.GetImageDecoders(); 61 | let jpegEncoder = encoders |> Seq.find (fun enc -> enc.FormatID = ImageFormat.Jpeg.Guid) 62 | 63 | for page in pdf.GetPages() do 64 | let images = page.GetImages() |> Seq.toArray 65 | let mutable j = 0 66 | for i in images do 67 | let mutable pngBytes: byte[] = Unchecked.defaultof<_> 68 | let d = i.TryGetPng(&pngBytes) 69 | use stream = new MemoryStream(if pngBytes = null then i.RawBytes.ToArray() else pngBytes) 70 | use image = Image.FromStream(stream, false, false); 71 | let fn = ImageUtils.imagePath path page.Number j 72 | image.Save(fn, jpegEncoder, null); 73 | j <- j + 1 74 | 75 | Console.WriteLine($"Extracted images from {path}") 76 | 77 | 78 | ///Utility function to list jpeg images contained in a pdf file. Can be used to check if file has any embedded images. 79 | ///Returns: page#, image#, PdfDictionary (which can be used with exportImage function to save image to disk) 80 | let listImages (path:string) = 81 | seq { 82 | use pdf = PdfDocument.Open(path); 83 | for page in pdf.GetPages() do 84 | let images = page.GetImages() |> Seq.indexed 85 | for (i,img) in images do 86 | yield page.Number, i, img 87 | 88 | } 89 | |> Seq.toList 90 | 91 | [] 92 | module Conversion = 93 | open System.Drawing 94 | open System.Drawing.Imaging 95 | 96 | let addBytes (bitmap:Bitmap) (bytes:byte[]) = 97 | let rect = new Rectangle(0,0,bitmap.Width,bitmap.Height) 98 | let bmpData = bitmap.LockBits(rect,ImageLockMode.ReadWrite,bitmap.PixelFormat) 99 | let ptr = bmpData.Scan0 100 | let bytesCount = bytes.Length 101 | System.Runtime.InteropServices.Marshal.Copy(bytes,0,ptr,bytesCount) 102 | bitmap.UnlockBits(bmpData) 103 | 104 | let jpegEncoder (mimeType:string) = 105 | ImageCodecInfo.GetImageEncoders() 106 | |> Array.tryFind (fun codec -> codec.MimeType = mimeType) 107 | 108 | let saveBmp (bmp:Bitmap) (outPath:string) = 109 | let qualityEncoder = Encoder.Quality; 110 | use qualityParameter = new EncoderParameter(qualityEncoder, 90); 111 | use encoderParms = new EncoderParameters(1) 112 | encoderParms.Param.[0] <- qualityParameter 113 | let codec = jpegEncoder "image/jpeg" |> Option.defaultWith (fun _ -> failwith "jpeg codec not found") 114 | bmp.Save(outPath,codec,encoderParms) 115 | 116 | ///Export entire pages as jpeg images to disk 117 | ///Image file paths are _{page#}_0.jpeg 118 | let exportImagesToDisk (backgroundRGB:(byte*byte*byte) option) (path:string) = 119 | use inst = Docnet.Core.DocLib.Instance 120 | use reader = inst.GetDocReader(path,Docnet.Core.Models.PageDimensions(1.0)) 121 | [0 .. reader.GetPageCount()-1] 122 | |> List.iter (fun i -> 123 | use page = reader.GetPageReader(i) 124 | let imgBytes = 125 | match backgroundRGB with 126 | | Some (red,green,blue) -> page.GetImage(new Docnet.Core.Converters.NaiveTransparencyRemover(red,blue,green)) 127 | | None -> page.GetImage() 128 | let w,h = page.GetPageWidth(),page.GetPageHeight() 129 | use bmp = new Bitmap(w,h,PixelFormat.Format32bppArgb) 130 | addBytes bmp imgBytes 131 | let outPath = ImageUtils.imagePath path i 0 132 | saveBmp bmp outPath) 133 | 134 | ///Export entire pages as jpeg images to disk 135 | ///Image file paths are _{page#}_0.jpeg 136 | let exportImagesToDiskScaled (backgroundRGB:(byte*byte*byte) option) (scale:float) (path:string) = 137 | use inst = Docnet.Core.DocLib.Instance 138 | use reader = inst.GetDocReader(path,Docnet.Core.Models.PageDimensions(scale)) 139 | [0 .. reader.GetPageCount()-1] 140 | |> List.iter (fun i -> 141 | use page = reader.GetPageReader(i) 142 | let imgBytes = 143 | match backgroundRGB with 144 | | Some (red,green,blue) -> page.GetImage(new Docnet.Core.Converters.NaiveTransparencyRemover(red,blue,green)) 145 | | None -> page.GetImage() 146 | let w,h = page.GetPageWidth(),page.GetPageHeight() 147 | use bmp = new Bitmap(w,h,PixelFormat.Format32bppArgb) 148 | addBytes bmp imgBytes 149 | let outPath = ImageUtils.imagePath path i 0 150 | saveBmp bmp outPath) 151 | -------------------------------------------------------------------------------- /src/FsOpenAI.Vision/Video.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Vision 2 | (* commented out to reduce application package size - to deploy to free azure tier 3 | open OpenCvSharp 4 | open System.Drawing 5 | open System.IO 6 | open FSharp.Control 7 | 8 | module Video = 9 | //returns the number of frames, fps, width, height and format of the video 10 | let getInfo f = 11 | use clipIn = new VideoCapture(f:string) 12 | let fc = clipIn.FrameCount 13 | clipIn.FrameCount,clipIn.Fps,clipIn.FrameWidth,clipIn.FrameHeight,string clipIn.Format 14 | 15 | let readFrame (clipIn:VideoCapture) n = 16 | async { 17 | let _ = clipIn.PosFrames <- n 18 | use mat = new Mat() 19 | let resp = 20 | if clipIn.Read(mat) then 21 | let ptr = mat.CvPtr 22 | use bmp = OpenCvSharp.Extensions.BitmapConverter.ToBitmap(mat) 23 | use ms = new MemoryStream() 24 | bmp.Save(ms, System.Drawing.Imaging.ImageFormat.Png) 25 | ms.Position <- 0L 26 | Some(ms.ToArray()) 27 | else 28 | None 29 | mat.Release() 30 | return resp 31 | } 32 | 33 | let getFrames file maxFrames = 34 | asyncSeq { 35 | use clipIn = new VideoCapture(file:string) 36 | let frames = 37 | if clipIn.FrameCount <= maxFrames then 38 | [0..clipIn.FrameCount-1] 39 | else 40 | let skip = clipIn.FrameCount / maxFrames 41 | [ 42 | yield 0 // keep first 43 | for i in 1..maxFrames-1 do // evenly spaced frames 44 | yield i*skip 45 | yield clipIn.FrameCount-1 // keep last 46 | ] 47 | |> set // remove duplicates 48 | |> Seq.toList 49 | |> List.sort 50 | for n in frames do 51 | let! frame = readFrame clipIn n 52 | yield frame 53 | clipIn.Release() 54 | } 55 | *) -------------------------------------------------------------------------------- /src/FsOpenAI.Vision/VisionApi.fs: -------------------------------------------------------------------------------- 1 | namespace FsOpenAI.Vision 2 | open System 3 | open System.Net.Http 4 | open System.Text 5 | open System.Text.Json 6 | open System.Text.Json.Serialization 7 | 8 | type ImageUrl(url:string) = 9 | member val url : string = url with get, set 10 | 11 | [] 12 | [)>] 13 | [)>] 14 | type Content(t:string) = 15 | member val ``type`` : string = t with get, set 16 | 17 | and TextContent(text:string) = 18 | inherit Content("text") 19 | member val text : string = text with get, set 20 | 21 | and ImageContent(data:string) = 22 | inherit Content("image_url") 23 | member val image_url = ImageUrl(data) with get, set 24 | 25 | type Message (role:string, cs:Content list) = 26 | 27 | member val role : string = role with get, set 28 | member val content : Content list = cs with get, set 29 | 30 | type Payload(msgs:Message list) = 31 | member val messages = msgs 32 | member val temperature = 0.7 with get, set 33 | member val top_p = 0.95 with get, set 34 | member val max_tokens = 800 with get,set 35 | member val stream = false with get, set 36 | member val model:string = null with get, set 37 | 38 | type RMsg = 39 | { 40 | role : string 41 | content : string 42 | } 43 | 44 | type RChoice = { 45 | message : RMsg 46 | } 47 | 48 | type RUsage = 49 | { 50 | completion_tokens : int 51 | prompt_tokens : int 52 | total_tokens : int 53 | } 54 | 55 | type Response = 56 | { 57 | choices : RChoice list 58 | usage : RUsage 59 | } 60 | 61 | module VisionApi = 62 | open System.Net.Http.Headers 63 | 64 | let serOptions() = 65 | let o = JsonSerializerOptions(JsonSerializerDefaults.General) 66 | o.WriteIndented <- true 67 | JsonFSharpOptions.Default() 68 | .WithAllowNullFields(true) 69 | .WithAllowOverride(true) 70 | .AddToJsonSerializerOptions(o) 71 | o 72 | let processVision (ep:Uri) (key:string) (user:string) (payload:Payload) = 73 | task { 74 | //let url = $"https://{ep.RESOURCE_GROUP}.openai.azure.com/openai/deployments/{model}/chat/completions?api-version=2023-07-01-preview"; 75 | use client = new HttpClient() 76 | client.DefaultRequestHeaders.Add("api-key",key) 77 | client.DefaultRequestHeaders.Add("fsopenai-user",user) 78 | client.DefaultRequestHeaders.Authorization <- new AuthenticationHeaderValue("Bearer", key) 79 | let productValue = new ProductInfoHeaderValue("fsopenai", "1.0") 80 | client.DefaultRequestHeaders.UserAgent.Add(productValue) 81 | let data = JsonSerializer.Serialize(payload,serOptions()) 82 | use content = new StringContent(data, Encoding.UTF8, "application/json") 83 | let! resp = client.PostAsync(ep,content) 84 | if resp.IsSuccessStatusCode then 85 | let! respData = resp.Content.ReadAsStringAsync() 86 | let resp = JsonSerializer.Deserialize(respData,serOptions()) 87 | return Some resp 88 | else 89 | printfn $"Error: {resp.StatusCode}, {resp.ReasonPhrase}" 90 | return None 91 | } 92 | 93 | --------------------------------------------------------------------------------