Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add AI completion #335

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ dotnet_diagnostic.S1075.severity = suggestion # S1075: URIs should not be hardco
dotnet_diagnostic.S1186.severity = suggestion # S1186: Methods should not be empty
dotnet_diagnostic.S2292.severity = suggestion # S2292: Trivial properties should be auto-implemented
dotnet_diagnostic.S4158.severity = none # BUGGY with C#9 code - doesnt understand local methods
dotnet_diagnostic.S4456.severity = suggestion # S4456: Split this method into two, one handling parameters check and the other handling the iterator

# Razor specific rules
[*.{cs,razor}]
Expand Down
1 change: 1 addition & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ This also includes source code snippets. Highlighting is done via [highlight.js]
- [Comments](./docs/Comments/Readme.md)
- [Storage Provider](./docs/Storage/Readme.md)
- [Search Engine Optimization (SEO)](./docs/SEO/Readme.md)
- [AI Autocomplete](./docs/Autocomplete/Readme.md)

## Installation

Expand Down
5 changes: 5 additions & 0 deletions docs/Autocomplete/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
### Autocomplete
The blog can utilize Microsofts **Semantic Kernel** to either generate or enhance the current blog post.
There are toggles to provide the AI with the Title, Descritpion, Markdown Content and Tags. You can also instruct the AI only to append content and not rewrite the whole article.

You can find the configuration in the `appsettings.json` file under the `AI` section.
11 changes: 11 additions & 0 deletions docs/Setup/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ The appsettings.json file has a lot of options to customize the content of the b
"BackgroundUrl": "assets/profile-background.webp",
"ProfilePictureUrl": "assets/profile-picture.webp"
},
"AI": {
"DeploymentName": "gpt-4o",
"EndpointUrl": "https://the-url",
"ModelId": "gpt-4o",
"ApiKey": "key"
},
"PersistenceProvider": "InMemory",
"ConnectionString": "",
"DatabaseName": "",
Expand Down Expand Up @@ -63,6 +69,11 @@ The appsettings.json file has a lot of options to customize the content of the b
| Introduction | | Is used for the introduction part of the blog |
| Description | MarkdownString | Small introduction text for yourself. This is also used for `<meta name="description">` tag. For this the markup will be converted to plain text |
| BackgroundUrl | string | Url or path to the background image. (Optional) |
| AI | | The AI section used for Semantic Kernel |
| DeploymentName | string | Name of the deployment |
| EndpointUrl | string | Url to the endpoint |
| ModelId | string | Model Id |
| ApiKey | string | Api Key |
| ProfilePictureUrl | string | Url or path to your profile picture |
| PersistenceProvider | string | Declares the type of the storage provider (one of the following: `SqlServer`, `Sqlite`, `RavenDb`, `InMemory`, `MySql`). More in-depth explanation [here](./../Storage/Readme.md) |
| ConnectionString | string | Is used for connection to a database. Not used when `InMemoryStorageProvider` is used |
Expand Down
19 changes: 19 additions & 0 deletions src/LinkDotNet.Blog.Domain/AiSettings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
namespace LinkDotNet.Blog.Domain;

public class AiSettings
{
public const string AiSettingsSection = "AI";

public string DeploymentName { get; set; }

public string EndpointUrl { get; set; }

public string ModelId { get; set; }

public string ApiKey { get; set; }

public bool IsEnabled => !string.IsNullOrEmpty(DeploymentName)
&& !string.IsNullOrEmpty(EndpointUrl)
&& !string.IsNullOrEmpty(ModelId)
&& !string.IsNullOrEmpty(ApiKey);
}
13 changes: 13 additions & 0 deletions src/LinkDotNet.Blog.Web/ConfigurationExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public static void AddConfiguration(this IServiceCollection services)
.AddApplicationConfiguration()
.AddAuthenticationConfigurations()
.AddIntroductionConfigurations()
.AddAiConfigurations()
.AddSocialConfigurations()
.AddProfileInformationConfigurations()
.AddGiscusConfiguration()
Expand Down Expand Up @@ -66,6 +67,18 @@ private static IServiceCollection AddIntroductionConfigurations(this IServiceCol
return services;
}

private static IServiceCollection AddAiConfigurations(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);

services.AddOptions<AiSettings>()
.Configure<IConfiguration>((settings, config) =>
{
config.GetSection(AiSettings.AiSettingsSection).Bind(settings);
});
return services;
}

private static IServiceCollection AddSocialConfigurations(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
Expand Down
22 changes: 22 additions & 0 deletions src/LinkDotNet.Blog.Web/Fakes/FakeCompletionService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;

namespace LinkDotNet.Blog.Web.Fakes;

internal sealed class FakeCompletionService : IChatCompletionService
{
public IReadOnlyDictionary<string, object> Attributes { get; }

public Task<IReadOnlyList<ChatMessageContent>> GetChatMessageContentsAsync(ChatHistory chatHistory, PromptExecutionSettings executionSettings = null,
Kernel kernel = null, CancellationToken cancellationToken = new CancellationToken()) =>
throw new NotImplementedException();

public IAsyncEnumerable<StreamingChatMessageContent> GetStreamingChatMessageContentsAsync(ChatHistory chatHistory,
PromptExecutionSettings executionSettings = null, Kernel kernel = null,
CancellationToken cancellationToken = new CancellationToken()) =>
throw new NotImplementedException();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
@using System.Threading
@using LinkDotNet.Blog.Web.Features.Admin.BlogPostEditor.Services
@implements IDisposable
<ModalDialog @ref="Dialog" Title="AutoComplete Content">
<form>
<div class="mb-3">
<label for="systemMessage" class="form-label">Message</label>
<TextAreaWithShortcuts Class="form-control" Id="systemMessage" Rows="2" Placeholder="Enter message..." @bind-Value="@options.Prompt"></TextAreaWithShortcuts>
</div>

<div class="mb-3">
<small class="text-muted">Include the blog post's title in the AI input.</small>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="includeTitle" @bind-value="@options.IncludeTitle">
<label class="form-check-label" for="includeTitle">Include Title</label>
</div>
</div>

<div class="mb-3">
<small class="text-muted">Include the blog post's short description in the AI input.</small>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="includeShortDescription" @bind-value="@options.IncludeShortDescription">
<label class="form-check-label" for="includeShortDescription">Include Short Description</label>
</div>
</div>

<div class="mb-3">
<small class="text-muted">Include the blog post's tags in the AI input.</small>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="includeTags" @bind-value="@options.IncludeTags">
<label class="form-check-label" for="includeTags">Include Tags</label>
</div>
</div>

<div class="mb-3">
<small class="text-muted">Include the blog post's content in the AI input.</small>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="includeContent" @bind-value="@options.IncludeContent">
<label class="form-check-label" for="includeContent">Include Content</label>
</div>
</div>

<div class="mb-3">
<small class="text-muted">Keep original text and append AI-generated content, or allow AI to rewrite the content.</small>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="keepOriginalText" @bind-value="@options.KeepOriginalText">
<label class="form-check-label" for="keepOriginalText">Keep Original Text</label>
</div>
</div>

<button type="button" class="btn btn-primary btn-sm" @onclick="Generate" disabled="@(!options.CanGenerate)">Generate</button>
<button type="button" class="btn btn-primary btn-sm" @onclick="Stop" disabled="@(options.CanGenerate)">Stop</button>

<div class="mt-3">
<label for="outputField" class="form-label">Output</label>
<textarea class="form-control" id="outputField" rows="6" readonly>@options.Content</textarea>
</div>

<div class="d-flex mt-3 justify-content-end gap-3">
<button type="button" class="btn btn-secondary" @onclick="Cancel">Cancel</button>
<button type="button" class="btn btn-success" @onclick="Save" disabled="@(!options.AllowSave)">Save</button>
</div>
</form>
</ModalDialog>

@code {
[Inject] private AutocompleteService AutocompleteService { get; set; }

[Parameter] public CreateNewModel Model { get; set; }
[Parameter] public EventCallback<string> ContentGenerated { get; set; }

private ModalDialog Dialog { get; set; }
private Options options = new ();
private CancellationTokenSource cts = new();

public void Open()
{
Dialog.Open();
StateHasChanged();
}

public void Dispose()
{
cts.Cancel();
cts.Dispose();
}

private void Cancel()
{
options = new();
cts.Cancel();
cts.Dispose();
cts = new();
Dialog.Close();
}

private async Task Generate()
{
options.CanGenerate = false;
options.Content = string.Empty;
var completeOptions = new AutocompleteOptions(
options.IncludeTitle ? Model.Title : string.Empty,
options.IncludeShortDescription ? Model.ShortDescription : string.Empty,
options.IncludeTags ? Model.Tags : string.Empty,
options.IncludeContent ? Model.Content : string.Empty,
options.Prompt,
options.KeepOriginalText);

await foreach (var token in AutocompleteService.GetAutocomplete(completeOptions, cts.Token))
{
if (string.IsNullOrEmpty(token))
{
continue;
}

options.Content += token;
StateHasChanged();
}

options.AllowSave = true;
options.CanGenerate = true;
}

private void Stop()
{
cts.Cancel();
cts.Dispose();
cts = new();
options.CanGenerate = true;
options.AllowSave = false;
}

private async Task Save()
{
await ContentGenerated.InvokeAsync(options.Content);
options = new();
Dialog.Close();
}

private sealed class Options
{
public string Prompt { get; set; }
public bool IncludeTitle { get; set; } = true;
public bool IncludeShortDescription { get; set; } = true;
public bool IncludeTags { get; set; } = true;
public bool IncludeContent { get; set; } = true;
public bool KeepOriginalText { get; set; }
public string Content { get; set; }

public bool AllowSave { get; set; }
public bool CanGenerate { get; set; } = true;
}
}
Loading